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>
This commit is contained in:
parent
31ea849277
commit
5e2f99d08e
13 changed files with 2120 additions and 0 deletions
98
docs/research/2026-06-11-holistic-map/wf1-building-shells.md
Normal file
98
docs/research/2026-06-11-holistic-map/wf1-building-shells.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# 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 e46d3d9 door 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:
|
||||
|
||||
1. 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.
|
||||
|
||||
2. 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 IS `portal->other_portal_id` (via CBldPortal::GetOtherCell null-check first). CBldPortal.other_portal_id is signed `int` (acclient.h:32100); the ctor (Ghidra 0x53bb30) initializes it to -1.
|
||||
|
||||
3. 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:
|
||||
|
||||
4. 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 on `GetCellStruct(portal.OtherCellId)?.CellBSP?.Root is null`; tests BSPQuery.SphereIntersectsCellBsp(otherCell.CellBSP.Root, localCenter, sphereRadius). BldPortalInfo.OtherPortalId is `ushort` (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's `bp.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).
|
||||
|
||||
5. 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 e223325 inference 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.
|
||||
237
docs/research/2026-06-11-holistic-map/wf1-culling.md
Normal file
237
docs/research/2026-06-11-holistic-map/wf1-culling.md
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
# Area 5 — Culling and frame composition end-to-end (incl. #108 grass-sweep + #109 far-door oscillation)
|
||||
|
||||
## RETAIL
|
||||
|
||||
RETAIL FRAME COMPOSITION (all claims verified against Ghidra decompile and/or pc lines; vtable offsets verified against the RenderDevice vtable dump at pc:1037033-1037066 / 0x7e5500).
|
||||
|
||||
== 1. Top of frame: SmartBox::RenderNormalMode (Ghidra 0x00453aa0, pc:92635) ==
|
||||
One function decides the whole frame. It computes two booleans: `viewer_is_outside` = (viewer.objcell_id & 0xffff) < 0x100 (low word under 0x100 means a LANDCELL, i.e. the camera is outdoors), and `can_see_outside` = viewer_is_outside OR viewer_cell->seen_outside (Ghidra 0x453aa0). Then:
|
||||
- OUTSIDE: LScape::update_viewpoint(viewer cell id) + Render::update_viewpoint + Render::set_default_view + Render::useSunlightSet(1) + LScape::draw(lscape) — the landscape pass IS the frame.
|
||||
- INSIDE: if seen_outside, LScape::update_viewpoint(Position::get_outside_cell_id(viewer)) keeps the landscape system centered; then Render::update_viewpoint + device->vtable+0x48(viewer_cell). Offset +0x48 in RenderDeviceD3D's vtable (0x7e5548 minus base 0x7e5500) is **DrawInside** — so the inside path is exactly DrawInside(viewer_cell). NOTE: there IS a top-level outside/inside branch in retail, but both arms drive the SAME machinery (LScape::draw is also what the indoor path calls for its outdoor view; building interiors are drawn by the same PView flood from inside the outdoor pass) — the project's "one path" rule is about DrawInside(viewer_cell) being the single indoor entry and the landscape being reached through it, which this confirms.
|
||||
- After either arm: D3DPolyRender::FlushAlphaList(0.0) — the global delay-rendered alpha list ("Alpha=2, Translucent=4, ClipMap=8" registry text at 0x7e5648) flushes once more at end of scene.
|
||||
|
||||
== 2. The outdoor pass: LScape::draw (Ghidra 0x00506330, pc:267912) ==
|
||||
Order inside the landscape pass:
|
||||
(a) **Sky first**: GameSky::Draw(sky, 0). GameSky::Draw (Ghidra 0x00506ff0, pc:268704) draws the celestial CPhysicsObjs with zfar temporarily ×4 and SetDepthBufferMode(DEPTHTEST_ALWAYS, zwrite=0) (0x507055-0x507063) — the sky always passes depth and never writes it, so everything later paints over it; restores DEPTHTEST_LESSEQUAL + zwrite=1 after (0x5070fc — this also confirms LESSEQUAL+write as the world default). Pass-0 (dome) runs unconditionally; pass-1 (weather cell, DrawObjCellForDummies(after_sky_cell)) is gated `SmartBox::is_player_outside() || pass==0` (0x507009) — i.e. **is_player_outside gates ONLY weather**, matching the project rule.
|
||||
(b) **Block culling**: LScape::draw_check_blocks (Ghidra 0x00505f80, pc:267678). If Render::PortalList is non-null (set by the indoor path, see §4) it loops `Render::set_view(&PortalList->view, i)` over every accumulated portal view and merges results — landscape CULLING is done per portal-clipped view, not per global frustum. Per block it builds a grid of view-interval columns via Render::get_clip_height and calls Render::block_check(corner intervals, max_zval, min_zval) → block->in_view; then landcell_check (Ghidra 0x005050a0) repeats the column test per 8×8 landcell → cell->in_view (LOD blocks short-circuit to all-in).
|
||||
(c) **Blocks far→near**: block_draw_list is built by LScape::get_block_order (Ghidra 0x00504c50): index 0 = the viewer's own block, then expanding rings outward — and LScape::draw iterates it from the END (`while (--i >= 0)`), i.e. farthest ring first, viewer block last. Painter's order at block granularity.
|
||||
(d) **Per block: RenderDeviceD3D::DrawBlock (pc:430027 / 0x5a17c0)**, two sub-passes: pass A per cell sorts the cell's object parts (CShadowPart::insertion_sort) after UpdateObjCell; pass B per cell INTERLEAVES: DrawLandCell (thunk pc:427860 → ACRender::landPolysDraw(cell->polygons, 2) — terrain polys go through the software poly-clip pipeline, clipped against the CURRENT view incl. portal views) → DrawSortCell → FlushAlphaList(flush) per cell. So terrain, buildings, and objects are drawn cell-by-cell, not in global passes.
|
||||
(e) **DrawSortCell (Ghidra 0x0059f140)**: building first (vtable+0x68 = DrawBuilding), then cell contents (vtable+0x60 = DrawObjCell → DrawPartCell → CShadowPart::draw per sorted shadow part, pc:429198-429220).
|
||||
(f) Weather last: GameSky::Draw(sky, 1) after the block loop (0x506396), only if weather_enabled (and internally only if player outside).
|
||||
|
||||
== 3. Buildings + interior flood from outdoors: DrawBuilding → DrawPortal ==
|
||||
RenderDeviceD3D::DrawBuilding (Ghidra 0x0059f2a0, pc:429282): sets outdoor_pview->outdoor_portal_list = building->portals, then TWO part draws: `CPhysicsPart::Draw(part, 1)` (PORTALS-ONLY pass) followed by `ObjBuildingOrBuildingPart=1; CPhysicsPart::Draw(part, 0)` (SHELL pass, the constructed mesh via D3DPolyRender::DrawMesh 0x59d4a0). The portals-only pass reaches DrawMeshInternal (pc:427978 / 0x59f360) which calls BSPTREE::build_draw_portals_only(drawing_bsp, **1**) then (drawing_bsp, **2**) (0x59f3cc/0x59f3d9 — the only two call sites of that walk, modes 1 and 2 only). BSPPORTAL::portal_draw_portals_only (pc:326881 / 0x53d870) walks the drawing BSP by viewer plane-side and fires device->DrawPortal(in_portals[i], 1, mode) for every BSPPORTAL node's CPortalPoly.
|
||||
PView::DrawPortal (Ghidra 0x005a5ab0, pc:433895): flush queued polys, backup state, look up the CBldPortal, add_views(portal stab list), then ConstructView(CBldPortal, poly, 1, mode) (Ghidra-verified 0x005a59a0, pc:433827): hard plane-side gate of the VIEWER against the portal polygon's plane (epsilon F_EPSILON=2e-4) honoring CBldPortal::portal_side; GetClip clips the portal polygon against the current view to a portal-shaped sub-view; CEnvCell::GetVisible(other_cell_id); Render::copy_view pushes the clipped view onto the destination cell's portal_view stack; then `if (mode != 2) DrawPortalPolyInternal(poly, mode==1)`; `if (mode != 1)` recurse into the CEnvCell ConstructView = interior flood. Back in DrawPortal: on success `if (mode != 1) DrawCells(this, 1)` — the interior cells are drawn nested INSIDE this building's draw, through this aperture. On flood-fail, `if (mode == 3) DrawPortalPolyInternal(poly, false)` — mode 3 has no caller in the built-mesh path.
|
||||
**Mode semantics (load-bearing, Ghidra-verified)**: mode 1 = draw the portal poly as a FAR-PLANE DEPTH PUNCH and do NOT flood; mode 2 = flood + DrawCells with NO portal poly. So per building portal the sequence is: punch the aperture's depth to the far plane, then draw the interior into the clean hole with normal LESSEQUAL depth. D3DPolyRender::DrawPortalPolyInternal (Ghidra 0x0059bc90, pc:424468): transforms the portal poly, software-clips it against the current view (polyClipFinish), then draws it with NO texture, alpha-test off, SRCALPHA/INVSRCALPHA blend, CULL_NONE, **SetDepthBufferMode(DEPTHTEST_ALWAYS, zwrite = flagbit2)**, z = ~1.0 (far plane, literal 0x3f7fffef) if flagbit0 else true z/w, vertex alpha forced 0 (invisible) when flagbit1 — flags from globals maxZ1=7 (param true → far punch) and maxZ2=6 (param false → TRUE-DEPTH invisible fence; also portalsDrawnCount++) (statics at pc:1105964-1105965; cliplandscape=1 pc:1106108; forceClear=0 pc:1366769).
|
||||
|
||||
== 4. The indoor pass: PView::DrawInside → ConstructView → DrawCells ==
|
||||
RenderDeviceD3D::DrawInside thunk (Ghidra 0x0059f0d0) → PView::DrawInside(indoor_pview, viewer_cell) (Ghidra 0x005a5860, pc:433793): curr_view_push, add_views(cell stab list), positionPush, copy_view(full-screen 4-pt view onto the root cell), ConstructView(cell, 0xffff), DrawCells. indoor_pview is constructed with draw_landscape=1 and outdoor_pview with draw_landscape=0 (RenderDeviceD3D::Init 0x0059efb0, pc:427788-427814) — only the indoor PView accumulates an outside view and re-draws the landscape; the nested outdoor-side floods never do.
|
||||
**ConstructView (0x005a57b0, pc:433750)**: resets outside_view.view_count=0, master_timestamp++, InitCell(root)+InsCellTodoList(root, 0.0), then loop: pop a cell from the todo list, append to cell_draw_list, ClipPortals, AddViewToPortals. InsCellTodoList (Ghidra 0x005a4f50) is an insertion sort DESCENDING by distance (verified: `if (param_2 < existing->dist) break` walking from the top) and the loop pops from the END — i.e. pops the NEAREST cell first, so **cell_draw_list is ordered near→far**.
|
||||
**ClipPortals (0x005a5520, pc:433572)**: sets Render::PortalList = the cell's newest portal_view; for each view and each seen portal, GetClip clips the portal polygon against that view; a portal whose other_cell_id == 0xffffffff leads OUTSIDE → if draw_landscape && cliplandscape, Render::copy_view accumulates the clipped region into **this->outside_view** (the union of every doorway/window region that reaches outdoors, in screen space); a portal to another EnvCell pushes the clipped view onto that cell's portal_view (with OtherPortalClip 0x005a5400 re-clipping through non-exact-match paired portals). AddViewToPortals (0x005a52d0, pc:433446) enqueues newly-seen neighbor cells (InitCell + InsCellTodoList with their viewer distance).
|
||||
**PView::DrawCells (0x005a4840, pc:432703)** — THE composition answer, four stages:
|
||||
Stage 1 (only if outside_view.view_count > 0): Render::useSunlightSet(1); Render::PortalList = &outside_view; **LScape::draw** — the FULL landscape machinery (sky included) runs, with blocks culled per accumulated portal view and every terrain poly software-clipped to the exact outside_view polygons; D3DPolyRender::FlushAlphaList(0); frameStamp++; then **Clear(D3DCLEAR_ZBUFFER(4), color, z=1.0)** — the WHOLE depth buffer is cleared, gated on `forceClear || portalsDrawnCount != 0` (0x5a4893-0x5a48a9); then the **portal depth FENCE**: for every cell in cell_draw_list (reverse = far→near), per view (CEnvCell::setup_view), for every portal with other_cell == 0xffffffff: DrawPortalPolyInternal(portal_poly, 0) → an invisible DEPTHTEST_ALWAYS zwrite-on quad at the aperture's TRUE depth (maxZ2). Net effect: outdoor color exists only inside the exact clipped apertures; outdoor depth is then thrown away entirely; the fence re-establishes the doorway's depth so any interior geometry FARTHER than the doorway can never overdraw the outdoor view, while interior geometry nearer than it still can (correct).
|
||||
Stage 2: useSunlightSet(0) + restore_all_lighting; reverse cell_draw_list (far→near): per view setup_view then device->DrawEnvCell (Ghidra 0x0059f170, pc:427879) — once per cell per frame (GetDrawnThisFrame guard); built-mesh path = SetStaticLightingVertexColors + D3DPolyRender::DrawMesh; legacy path submits ALL structure->polygons with planeMask=0xffffffff (pc:427922) into the software-clipped poly list. Cell shell geometry is therefore always software-clipped to the current portal view.
|
||||
Stage 3: reverse cell_draw_list: Render::PortalList = the cell's newest portal_view, then DrawObjCellForDummies (pc:429177 / 0x5a0760): re-sort shadow parts, DrawObjCell → DrawPartCell → CShadowPart::draw per part. Per-mesh culling here is **Render::viewconeCheck (Ghidra 0x0054c250)**: the part's bounding sphere is tested against the viewer plane AND the planes of the CURRENT portal view (portal_npnts loop) — every object is culled against the portal-clipped view it is drawn through, but its polygons are NOT hard-clipped (BoundingType PARTIALLY_INSIDE just disables trivial-accept).
|
||||
Stage 4: restore object scale, useSunlightSet(1).
|
||||
|
||||
== 5. Depth-state summary ==
|
||||
World default: DEPTHTEST_LESSEQUAL + zwrite on (restored at 0x5070fc). Sky: ALWAYS + no write. Portal punch: ALWAYS + write, z=far (maxZ1=7). Portal fence: ALWAYS + write, z=true (maxZ2=6), invisible. Indoor frames partition depth by ONE full Z-clear after the outdoor stage plus per-aperture fences; outdoor building apertures are partitioned by per-portal far punches before each nested interior draw. There is no per-portal-slice scissor anywhere — exactness comes from the software polygon clipper (ACRender::polyClipFinish) which every terrain/cell/portal poly passes through.
|
||||
|
||||
## ACDREAM
|
||||
|
||||
ACDREAM FRAME COMPOSITION (file:line, worktree root C:/Users/erikn/source/repos/acdream/.claude/worktrees/thirsty-goldberg-51bb9b).
|
||||
|
||||
== Frame entry: GameWindow.OnRender (src/AcDream.App/Rendering/GameWindow.cs:7124) ==
|
||||
(1) ClearColor=fog tint; DepthMask(true) asserted then Clear(COLOR|DEPTH|STENCIL) (GameWindow.cs:7141-7156). Frame-global CullFace(Back)+FrontFace(CW) (7162-7163). WbMeshAdapter.Tick (7178). Animations (7188).
|
||||
(2) Viewpoint roots: lighting root = PLAYER cell (7291-7296); render root = VIEWER cell from RetailChaseCamera.ViewerCellId (7301-7313); renderSky = viewerRoot null || rootSeenOutside (7423).
|
||||
(3) Outdoor-as-cell cutover: when the eye is outdoors, _outdoorNode = OutdoorCellNode.Build(viewerCellId) with nearby building cells gathered from Chebyshev<=1 landblocks (7458-7482); clipRoot = viewerRoot ?? _outdoorNode (7497). clipRoot is null only pre-spawn/legacy-camera.
|
||||
(4) clipRoot == null safety path (7546-7587): sky first via SkyRenderer.RenderSky (7560; depth test DISABLED + DepthMask(false), Sky/SkyRenderer.cs:194-195, restored 440-441) + SkyPreScene particles (7565), then TerrainModernRenderer.Draw (7580). Then InteriorEntityPartition outdoor bucket via InteriorRenderer.DrawEntityBucket (7737-7746), the 48m exterior look-in RetailPViewRenderer.DrawPortal (7778-7798, MaxSeedDistance=48f at 7795), LiveDynamic bucket (7813-7823); scene particles (7846-7868: clipRoot==null only); weather post-scene RenderWeather + SkyPostScene particles (7874-7889).
|
||||
(5) clipRoot != null (the normal path, indoor AND outdoor): RetailPViewRenderer.DrawInside (7604-7663) with DrawLandscapeSlice = DrawRetailPViewLandscapeSlice (7624-7634), ClearDepthSlice = scissored depth clear for INTERIOR roots only, null for the outdoor node (7644-7652), DrawCellParticles (7653). Outdoor-node LiveDynamic drawn after, unclipped (7716-7724).
|
||||
|
||||
== RetailPViewRenderer.DrawInside (src/AcDream.App/Rendering/RetailPViewRenderer.cs:44-109) ==
|
||||
Order: PortalVisibilityBuilder.Build (flood from root; root seeded at distance 0 mirroring InsCellTodoList(0f), PortalVisibilityBuilder.cs:94; exit portals union into frame.OutsideView, PortalVisibilityBuilder.cs:279; outdoor node seeds OutsideView with a FULL-SCREEN quad, PortalVisibilityBuilder.cs:80-89) → MergeNearbyBuildingFloods for the outdoor node (RetailPViewRenderer.cs:60-61, 115-145: group nearby cells by BuildingId, one ConstructViewBuilding per building with OutdoorBuildingSeedDistance=48f at line 30; ConstructViewBuilding = BuildFromExterior, PortalVisibilityBuilder.cs:548-554 with per-portal NearestPortalVertexDistance > maxSeedDistance cutoff at 426-427) → ClipFrameAssembler.Assemble + UploadClipFrame (63-64) → drawableCells = ALL OrderedVisibleCells (71) → EnvCellRenderer.PrepareRenderBatches (74-80) → InteriorEntityPartition (82) → then the draw stages:
|
||||
(a) **DrawLandscapeThroughOutsideView (214-238)**: per OutsideView slice: SetTerrainClip(slice.Planes) + upload + entity clip routing (225-227), then the GameWindow callback **DrawRetailPViewLandscapeSlice (GameWindow.cs:9465-9551)**: scissor to slice.NdcAabb (9477, BeginDoorwayScissor 9707-9724 = NDC AABB → pixel scissor), sky (clip distances enabled, 9484-9487), SkyPreScene particles (9490-9492), TERRAIN (9494-9496, clipped by the TerrainUbo planes via gl_ClipDistance + the scissor), outdoor entity bucket via WbDrawDispatcher (9503-9512), outdoor-entity particles (9519-9530), WEATHER inside the slice (9532-9541). After ALL slices are drawn: ClearDepthSlice per slice (234-235) — for interior roots a **scissored AABB depth-only clear** (GameWindow.cs:7646-7652); for the outdoor node NO clear at all (7644 rationale comment).
|
||||
(b) **DrawExitPortalMasks (325-343)**: the fence hook — iterates reverse OrderedVisibleCells and invokes ctx.DrawExitPortalMasks per slice, but **no production caller sets that callback** (grep over src/: only RetailPViewRenderer.cs and the context type definitions reference it; GameWindow.cs:7604-7663 sets DrawLandscapeSlice/ClearDepthSlice/DrawCellParticles only). The retail depth fence is UNWIRED.
|
||||
(c) **DrawEnvCellShells (345-399)**: IndoorDrawPlan.ShellPass = reverse OrderedVisibleCells far→near (IndoorDrawPlan.cs:21-33); per cell per slice: UseShellClipRouting + EnvCellRenderer.Render(Opaque) + Render(Transparent) (388-393). GL_CLIP_DISTANCE is enabled ONLY for outdoor-node roots (clipShells, 104-105, 378-380, 396-398) — **indoor roots draw shells UNCLIPPED** (#114 scope note at 96-103). EnvCellRenderer establishes its own blend/depthmask per pass (Wb/EnvCellRenderer.cs:1085-1094: opaque = blend off + DepthMask(true); transparent = blend on + DepthMask(false); restore at 1277-1278).
|
||||
(d) **DrawCellObjectLists (401-426)**: reverse OrderedVisibleCells; per-cell entity buckets via WbDrawDispatcher with **membership-only routing** — UseIndoorMembershipOnlyRouting (420, 439-450) deliberately does NOT plane-clip entities (rationale comment: retail uses viewconeCheck not hard clip); per-cell particles scissored to the slice AABB only (GameWindow.cs:9553-9580; particle.vert has no gl_ClipDistance per the 9701-9706 comment).
|
||||
DrawPortal (162-212, the legacy clipRoot==null look-in) mirrors the same stages from BuildFromExterior with the 48m seed.
|
||||
|
||||
== Culling ==
|
||||
Terrain: per-landblock-slot frustum AABB cull only (TerrainModernRenderer.cs:206-223); no per-portal-view CPU cull — the portal restriction is GPU clip planes (<=8) + scissor; sets no cull/depth state of its own (inherits frame state). ClipFrameAssembler caps a slice at 8 planes; regions needing more set a scissor-only fallback (ClipFrameAssembler.cs:136-169 outsideHasScissorFallback → TerrainClipMode.Scissor; per RetailPViewRenderer.cs:368-369 the >8-plane shell fallback is unimplemented = pass-all). Entities: per-landblock frustum AABB cull + per-entity 5m-radius AABB cull (WbDrawDispatcher.cs:208-210, 593-595), clip-slot routing by cell membership (394-488), opaque groups sorted front-to-back / transparent back-to-front (1163-1204, 1439-1445); no per-portal-view sphere test. EnvCell shells: drawableCells filter + per-slice routing.
|
||||
|
||||
== Sky/weather/particles ==
|
||||
Sky always first in whichever pass draws it, no depth test/write (matches retail's ALWAYS+no-write). Weather: drawn inside each landscape slice when renderSky (GameWindow.cs:9532-9541) and post-scene on the null path (7874-7889) — keyed on seen_outside of the VIEWER root, not on is_player_outside. Scene particles: depth test on, depth write off (ParticleRenderer.cs:141-143); on clipRoot!=null frames only entity-attached emitters draw (slice filter 9528-9529 and cell filter 9575-9576 both require AttachedObjectId != 0); the unattached-emitter draw exists only on the clipRoot==null path (7856).
|
||||
|
||||
## DIVERGENCES
|
||||
|
||||
### [CRITICAL] missing-portal-depth-fence (confirmed) — Retail's invisible portal depth fence (DrawPortalPolyInternal maxZ2) after the Z-clear is entirely missing; the DrawExitPortalMasks hook exists but is wired to nothing
|
||||
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C), every load-bearing gate checked:
|
||||
|
||||
1. PView::DrawCells (Ghidra 0x005a4840): confirmed sequence — LScape::draw(this->lscape) → D3DPolyRender::FlushAlphaList(0.0) → vtable+0x2c Clear(4, RGBAColor_Black, 0x3f800000=1.0f), gated on (forceClear || portalsDrawnCount != 0) with portalsDrawnCount reset to 0 at the check → FIRST reverse loop over cell_draw_list × CEnvCell::setup_view(cell, view) × portals where other_cell_id == -1 (0xffffffff) → D3DPolyRender::DrawPortalPolyInternal(portal_poly, false) → THEN the geometry loops (vtable+0x5c cell draw, vtable+0x64 second pass). So the exit-portal fences are drawn AFTER the depth clear and BEFORE cell geometry — exactly the claimed re-fencing role.
|
||||
|
||||
2. Clear flag semantics verified via RenderDeviceD3D::Clear (Ghidra 0x0059fd30): engine flag bit 1→D3DCLEAR_TARGET, bit 2→D3DCLEAR_STENCIL (cap-gated), bit 4→D3DCLEAR_ZBUFFER. Clear(4, …, 1.0f) is a depth-only clear to z=1.0. Claim's "Clear(D3DCLEAR_ZBUFFER, z=1.0)" correct.
|
||||
|
||||
3. D3DPolyRender::DrawPortalPolyInternal (Ghidra 0x0059bc90): mode flag selects maxZ1 (param true) vs maxZ2 (param false). Globals maxZ2=6 @0x00820e14 (pc:1105964), maxZ1=7 @0x00820e18 (pc:1105965). Bit decode from the decompile: SetDepthBufferMode(DEPTHTEST_ALWAYS, zwrite=(maxZ>>2)&1) → bit2=1 in both 6 and 7 → z-write ON; per-vertex z = (maxZ&1)==0 ? zw/w (TRUE projected depth) : 0x3f7fffef≈0.99999994 (far plane) → maxZ2=6 draws at true depth, maxZ1=7 punches to far; vertex alpha top bit = ~(maxZ<<30)&0x80000000 → bit1=1 in both → alpha 0x00, with SRCALPHA/INVSRCALPHA blend → invisible. SetStageTexture(0,null), SetCullMode(NONE). Mode-false draws increment portalsDrawnCount — the very counter that gates next frame's Z-clear in DrawCells (self-consistent loop). Claim's "invisible (alpha 0), DEPTHTEST_ALWAYS, zwrite ON, at TRUE depth" for maxZ2 and "far punch z≈1.0" for maxZ1: all confirmed.
|
||||
|
||||
4. Building twin verified: RenderDeviceD3D::DrawMeshInternal (Ghidra 0x0059f360) portals-only path calls BSPTREE::build_draw_portals_only(drawing_bsp, 1) then (…, 2); mode threaded verbatim through BSPNODE::build_draw_portals_only (0x0053c100) → BSPPORTAL::portal_draw_portals_only (0x0053d870, per-portal virtual at render_device vtable+0x4c with (in_portals[i], 1, mode)) → PView::ConstructView(CBldPortal…) (Ghidra 0x005a59a0): `if (param_4 != 2) DrawPortalPolyInternal(poly, param_4 == 1)` and recursion into the other cell only `if (param_4 != 1)`. So mode 1 = punch-without-recurse, mode 2 = recurse-without-punch — the far punch lands before nested interior view construction, as claimed.
|
||||
|
||||
ACDREAM SIDE — confirmed at the cited lines:
|
||||
- RetailPViewRenderer.cs:325-343: DrawExitPortalMasks early-returns at :331-332 when ctx.DrawExitPortalMasks is null; otherwise iterates reverse OrderedVisibleCells × slices — the hook exists and sits at the retail-correct position (called at :95 in DrawInside and :204 in DrawPortal, immediately after DrawLandscapeThroughOutsideView whose tail invokes ClearDepthSlice per OutsideView slice at :234-235, and before DrawEnvCellShells :104 / DrawCellObjectLists :106).
|
||||
- Grep over all of src/ finds DrawExitPortalMasks ONLY in RetailPViewRenderer.cs (method, two invocations, three nullable Action property declarations :497/:534/:558). Neither production context initializer sets it: GameWindow.cs:7604-7663 (DrawInside ctx — sets DrawLandscapeSlice, ClearDepthSlice, DrawCellParticles, EmitDiagnostics, NOT DrawExitPortalMasks) and GameWindow.cs:7780-7798 (DrawPortal ctx — sets no draw callbacks at all). The hook is wired to nothing; the method is a per-frame no-op.
|
||||
- The only depth partition is the scissored NDC-AABB depth clear: GameWindow.cs:7644-7652 (BeginDoorwayScissor(true, slice.NdcAabb) + glClear(DEPTH)), and it is explicitly null for outdoor-node roots (7644-7645, with the "no clear outdoors" compromise rationale in the comment block 7635-7643).
|
||||
|
||||
DIVERGENCE IS REAL, NOT EQUIVALENT-ELSEWHERE: after acdream's per-slice clear, aperture depth is 1.0 and nothing re-writes the doorway's true depth before shells/objects draw — any later geometry at any depth wins those pixels. Retail re-fences with the exact portal POLYGON at true depth (and the AABB-vs-polygon shape mismatch is an additional acdream approximation the port would eliminate). Outdoor roots lack the mode-1 far punch entirely (ClearDepthSlice=null is a compromise, not an equivalent). Strengthening note: in retail the depth fence works IN CONCERT with screen-space portal clipping (Render::set_view pc:343750 + polyClipFinish planeMask=0xffffffff pc:427922); acdream currently draws indoor-root shells UNCLIPPED (clipShells only for outdoor roots, RetailPViewRenderer.cs:104-105 + :374-380 #114 scope) — so indoor roots have NEITHER protection, making the missing fence directly load-bearing for #109's oscillating far-door aperture and the §4 "indoor geometry paints over the doorway view" class.
|
||||
|
||||
Minor port-shape details surfaced (do not change the verdict): (a) retail's Z-clear is GATED on forceClear || prev-frame portalsDrawnCount≠0, not unconditional; (b) DrawPortalPolyInternal skips degenerate portal polys whose vertices all sit at ±12.0 cell-boundary extents and screen-clips the poly (needs ≥3 clipped verts) before drawing; (c) the fence loop in DrawCells covers EVERY visible cell's exit portals (other_cell_id==-1), not just the root cell's. One honest open sequencing question for the port plan: the building far punch is drawn during the building-mesh draw (LScape::draw path) which precedes DrawCells' conditional Z-clear — on frames where the clear fires (interior fences drawn last frame), the punch region is wiped to 1.0 anyway (same value), but exterior WALL depth is also wiped, implying retail leans on screen-space portal clipping as the primary aperture discipline with the depth fence as the in-aperture layering mechanism; the port should treat clipping+fence as a pair, not the fence alone.
|
||||
- blastRadius: #109 (far exit door oscillates door-texture vs background): after our scissored depth clear, depth in the aperture region is 1.0 and NOTHING re-establishes the doorway's depth, so any interior/shell geometry drawn later — at ANY depth — wins the aperture; whether the outdoor view or the door-region geometry shows depends on per-frame flood/slice makeup → oscillation. Also a contributor to #108 and to the general 'indoor geometry paints over the doorway view' class the digest tracks under §4.
|
||||
- retailEvidence: PView::DrawCells (Ghidra 0x005a4840, pc:432703): after LScape::draw + FlushAlphaList + Clear(D3DCLEAR_ZBUFFER, z=1.0) (0x5a48a9), it loops every cell (reverse) × every view (CEnvCell::setup_view) × every portal with other_cell_id==0xffffffff and calls D3DPolyRender::DrawPortalPolyInternal(portal_poly, 0) (0x5a49b7). DrawPortalPolyInternal (Ghidra 0x0059bc90) with maxZ2=6 (pc:1105964) draws the aperture polygon invisible (alpha 0), DEPTHTEST_ALWAYS, zwrite ON, at TRUE depth — re-fencing the doorway so interior geometry farther than the door fails depth there. The outdoor-side twin is the mode-1 far punch (maxZ1=7, z≈1.0) issued per building portal before the nested interior DrawCells (ConstructView(CBldPortal) Ghidra 0x005a59a0: `if (param_4 != 2) DrawPortalPolyInternal(poly, param_4==1)`; DrawMeshInternal 0x0059f360 calls build_draw_portals_only with modes 1 then 2).
|
||||
- acdreamEvidence: RetailPViewRenderer.cs:325-343 (DrawExitPortalMasks) iterates and invokes ctx.DrawExitPortalMasks — but GameWindow.cs:7604-7663 (DrawInside ctx) and 7780-7798 (DrawPortal ctx) never set the callback; grep over src/ finds no other assignment. The only depth partition is the scissored AABB depth clear (GameWindow.cs:7644-7652), null for outdoor-node roots.
|
||||
- portShape: Implement the two invisible depth-only portal-poly draws as a small dedicated pass: (1) indoor roots — after the per-slice landscape+depth-clear stage, draw each outside-leading portal polygon (the exact portal poly from the cell's CCellPortal, world-space) with depth-func ALWAYS, depth-write ON, color mask OFF, at true depth (retail maxZ2=6); (2) outdoor roots — before each per-building flood's interior shells draw, draw that building portal's polygon with depth forced to the far plane (gl_FragDepth=1.0 or glDepthRange trick; retail maxZ1=7), replacing the 'no clear outdoors' compromise. Wire both through the existing DrawExitPortalMasks hook (it already iterates reverse cells × slices). This removes the depth-clear-shape dependency entirely.
|
||||
|
||||
### [CRITICAL] approximate-portal-clip-for-landscape (adjusted) — Terrain/sky through portals is clipped by ≤8 GL planes + an NDC-AABB scissor instead of retail's exact software polygon clip per portal view
|
||||
- correctedClaim: Retail does NOT software-clip terrain/sky polygons per portal view — that part of the claim is wrong. Retail accumulates EXACT clipped portal polygons into outside_view (ClipPortals 0x005a5520 → copy_view 0x0054dfc0, ≤31 verts/edges per view poly with per-edge world planes), then culls landscape per accumulated view at LANDBLOCK→LANDCELL (~24 m) granularity only (draw_check_blocks 0x00505f80, landcell_check 0x005050a0, get_clip_height 0x0054cff0 testing ALL edge planes), and draws in-view land cells WHOLE (DrawLandCell 0x0059f120 → landPolysDraw 0x006b7040 → DrawPrimitiveUP, no clip, no scissor; polyClipFinish 0x006b6d00 is called only by DrawPortalPolyInternal and GetClip). Pixel exactness in retail comes from compositing in DrawCells 0x005a4840: landscape first → depth clear → software-clipped exit-portal depth masks (DEPTHTEST_ALWAYS + z-write) → shells/objects overdraw the overspill. acdream instead confines the landscape pass at draw time with ≤8 gl_ClipDistance planes + per-slice NDC-AABB scissor (ClipPlaneSet.cs:54,132; ClipFrameAssembler.cs:134-169; GameWindow.cs:9477,9484-9496,9707-9724) — which is pixel-EXACT and equal-or-tighter than retail whenever the view poly has ≤8 edges (the common doorway case). The real, narrower divergences: (1) view polys with >8 edges (possible with nested portal chains; retail handles up to 31) degrade to AABB-only landscape over-coverage and pass-all shells (RetailPViewRenderer.cs:367-369); (2) particle passes are confined by AABB scissor only (no gl_ClipDistance in particle.vert; GameWindow.cs:9490-9492,9518-9530,9568-9578), where retail confines particles by depth against already-drawn shells; (3) acdream's exactness depends on the clip representation per slice, retail's on the z-clear/exit-mask/overdraw discipline — so any acdream pass that skips both planes and the mask discipline inherits the AABB slop. Severity: medium (not critical); the ≤8-edge equivalence and the zero-scissor-fallback pin at the Issue-113 site make it unlikely to be the primary cause of #108.
|
||||
- verifier notes: RE-CHECKED RETAIL (all via Ghidra decompile, 127.0.0.1:8081 — not BN pseudo-C):
|
||||
|
||||
CONFIRMED parts of the retail claim:
|
||||
1. PView::ClipPortals @ 0x005a5520: GetClip software-clips each portal poly against the current view; when other_cell_id==0xffffffff (outside sentinel) and draw_landscape!=0 && cliplandscape!=0, Render::copy_view(&this->outside_view, clip_view, n) accumulates the EXACT clipped polygon into outside_view (LAB_005a5711 path). Matches pc:433662-433682.
|
||||
2. LScape::draw_check_blocks @ 0x00505f80: loops Render::PortalList->view_count, calling Render::set_view(&PortalList->view, i) per accumulated view poly, then Render::get_clip_height + Render::block_check per landblock. PView::DrawCells @ 0x005a4840 sets Render::PortalList = &this->outside_view before LScape::draw — so landscape culling is per accumulated outside_view poly. get_clip_height @ 0x0054cff0 iterates ALL portal_npnts per-edge world planes of the current view poly (planes built in copy_view @ 0x0054dfc0, last loop: cross of unprojected edge dirs through viewpoint) — no 8-plane budget.
|
||||
3. "No scissor" — true, no scissor anywhere in the retail landscape path.
|
||||
|
||||
REFUTED parts of the retail claim:
|
||||
4. "Every terrain poly is software-clipped through ACRender::landPolysDraw → polyClipFinish ... to the pixel" is FALSE. landPolysDraw @ 0x006b7040 only backface-culls (Plane::which_side2) and dispatches to landPolyDraw @ 0x006b6320 / 0x006b6760, both of which build D3D vertices and call RenderDeviceD3D::DrawPrimitiveUP directly — no view clip, no scissor, no D3D user clip planes (no SetClipPlane symbol exists). polyClipFinish @ 0x006b6d00 xrefs are ONLY D3DPolyRender::DrawPortalPolyInternal (call at 0x0059bdb0) and PView::GetClip (0x005a43b2, 0x005a4414) — portal polys only, never terrain. landPolysDraw's only caller is RenderDeviceD3D::DrawLandCell @ 0x0059f120.
|
||||
5. "No plane-count cap" is FALSE: Render::copy_view @ 0x0054dfc0 clamps a stored view poly to 0x1f = 31 vertices/edges (and dedups verts within ~1px). 31 >> 8, but a cap exists.
|
||||
6. Retail's terrain confinement granularity is the LANDCELL (~24 m): landcell_check @ 0x005050a0 refines block cull to per-cell in_view flags; in-view cells are drawn WHOLE. Pixel exactness comes from COMPOSITING, not clipping — DrawCells @ 0x005a4840 sequence: LScape::draw (terrain first, over-covering at cell granularity) → depth-buffer clear (Clear(4, black, 1.0f), gated on portalsDrawnCount/forceClear) → per cell per view, DrawPortalPolyInternal on every other_cell_id==-1 portal (software-clipped via polyClipFinish against the current view, drawn DEPTHTEST_ALWAYS + conditional z-write = the exit-portal depth mask) → cell shells → objects. Shells drawn after overwrite all terrain overspill; the masks preserve aperture pixels.
|
||||
|
||||
RE-CHECKED ACDREAM (all cited lines verified):
|
||||
- ClipPlaneSet.cs:54 (MaxPlanes=8), :119-120 (multi-poly → union-AABB scissor), :132-133 (>8 edges → own-AABB scissor). ClipFrameAssembler.cs:134-169: outside_view slices built per polygon (ViewOf wraps ONE poly per slice, so unions become multiple slices — structurally matching retail's per-view loop, line 96-121 same for cells); TerrainClipMode.Scissor when any outside slice lacks planes (:167-169). RetailPViewRenderer.cs:367-369: shell-side slot-0 pass-all, >8-plane fallback unimplemented (comment also pins 0 such slices at the meeting hall via Issue113MeetingHallFloodTests). GameWindow.cs:9477 BeginDoorwayScissor(true, slice.NdcAabb) over the whole landscape slice; 9484-9496 EnableClipDistances around sky+terrain; 9490-9492/9518-9530/9568-9578 particle passes scissor-only (particle.vert has no gl_ClipDistance, per 9701-9706 comment); 9707-9724 NDC→pixel scissor impl. All as claimed.
|
||||
|
||||
JUDGMENT: the acdream description is accurate, but the divergence is mischaracterized in a way that flips the port recommendation. For the common doorway case (view poly ≤8 edges after collinear merge), acdream's gl_ClipDistance planes ARE the exact polygon — pixel-exact, equal-or-TIGHTER than retail's pre-composite terrain coverage (whole 24 m cells). The claim's "retail clips terrain exactly, acdream approximates" is inverted: retail approximates harder at draw time and relies on draw-order compositing (z-clear + exit-mask + shell overdraw) for exactness — a discipline acdream already partially ports (DrawExitPortalMasks at RetailPViewRenderer.cs:330-343, doorway depth-only z-clear per GameWindow.cs:9701-9706). The real residual gaps are: (a) >8-edge view polys degrade to AABB-only on the landscape pass and pass-all on shells; (b) particle passes are AABB-scissor-only; (c) retail's 31-edge cap vs our 8. "Critical / #108 primary" does not survive: the common case is behaviorally equivalent and the in-tree test pin reports zero scissor-fallback slices at the #113 site; no evidence ties >8-edge slices to the #108 repro. The proposed stencil port is not retail's mechanism (though it remains a defensible GPU-native option for the >8-edge residue).
|
||||
- blastRadius: #108 primary; #114 region quality; doorway 'grey'/'sweep' family in the render digest §4.
|
||||
- retailEvidence: Indoor outdoor-view: ClipPortals accumulates the EXACT clipped portal polygons into outside_view (0x005a5520 → Render::copy_view at 0x5a5711 path, gated by draw_landscape/cliplandscape pc:433662-433682); LScape::draw_check_blocks then culls blocks per accumulated view via Render::set_view(&PortalList->view,i) (Ghidra 0x00505f80), and every terrain poly is software-clipped through ACRender::landPolysDraw → polyClipFinish (DrawLandCell thunk pc:427860; same clipper the portal polys use at 0x59bc90). There is no scissor and no plane-count cap — the clip region is the exact polygon union, to the pixel.
|
||||
- acdreamEvidence: ClipFrameAssembler.cs:136-169: a slice gets at most 8 half-space planes (ClipFrame.MaxPlanes); regions needing more fall back to scissor-only (TerrainClipMode.Scissor); RetailPViewRenderer.cs:368-369 records the shell-side >8-plane fallback as unimplemented (pass-all). DrawRetailPViewLandscapeSlice draws sky/terrain/scenery under BeginDoorwayScissor(slice.NdcAabb) (GameWindow.cs:9477, 9707-9724) + gl_ClipDistance planes (9484-9496). The AABB is axis-aligned and the plane set is convex — a rotated/concave doorway union is over-covered by construction.
|
||||
- portShape: Faithful port = make the landscape slice's coverage exact: either (a) per-slice stencil mask — rasterize the exact OutsideView polygons into the stencil buffer once per frame and stencil-test the sky/terrain/scenery/weather slice draws (GPU-native equivalent of retail's software clip; removes the 8-plane cap and the AABB slop), or (b) triangulate the clip region and draw the landscape through a clipped viewport per polygon. Option (a) is the one-gate-shaped fix and also gives the cell shells their pixel-exact crop (#114).
|
||||
|
||||
### [HIGH] depth-clear-shape-and-order (adjusted) — Depth partition: retail clears the FULL depth buffer once (gated on portalsDrawnCount) between the outdoor stage and the interior stage; acdream clears per-slice scissored AABBs after all slices, and skips the clear entirely for outdoor-node roots
|
||||
- correctedClaim: Depth partition: retail's DrawCells (0x5a4840) issues ONE full-buffer Z-only clear — RenderDeviceD3D::Clear(4→D3DCLEAR_ZBUFFER, Count=0/pRects=NULL, full-screen viewport) @ 0x59fd30 — between the landscape stage and the interior stage, gated read-and-clear on `forceClear || portalsDrawnCount` (the count increments only on fence-mode portal-poly draws, DrawPortalPolyInternal @ 0x59bc90 arg2=false), then re-fences each outside portal at its TRUE depth (maxZ2=6, DEPTHTEST_ALWAYS+write, color-invisible). Outdoors retail never reaches a top-level DrawCells (xrefs: only DrawInside/DrawPortal); instead each building portal gets a mode-1 far punch (maxZ1=7 → z≈0x3f7fffef) before the mode-2 DrawCells re-entry draws the interior with outside_view reset to 0. acdream (src/AcDream.App/Rendering/GameWindow.cs:7644-7652, RetailPViewRenderer.cs:234-235) instead clears per-slice scissored NDC-AABBs after all landscape slices for interior roots, has NO portal-depth fence, and skips the clear entirely for outdoor-node roots — flooded interiors fight terrain on raw depth. Blast radius correction: for interior roots the AABB clear DOES wipe terrain depth inside the AABB (the landscape slice is scissored to the same AABB, GameWindow.cs:9477) — the #108 artifact mechanism indoors is surviving terrain COLOR backed by far depth with no fence (protected region = AABB ≠ aperture), plus outdoors the no-clear/no-punch raw depth fight (terrain nearer than interior when the eye is below ground); #109 contributor framing (AABB ≠ aperture ≠ door-entity draw) stands. Both issue texts name the depth-clear as suspect; attribution plausible but uncaptured.
|
||||
- verifier notes: RETAIL re-derived from Ghidra (not BN). (1) PView::DrawCells @ 0x5a4840: inside the `outside_view.view_count != 0` block, after LScape::draw + D3DPolyRender::FlushAlphaList, the decompile shows `if (forceClear || (portalsDrawnCount != 0)) { portalsDrawnCount = 0; render_device->vtbl[+0x2c](4, &RGBAColor_Black, 1.0f); }` — the claimed gate, at the claimed 0x5a4893-0x5a48a9 range (BN pc:432727-432728 shows check @ 0x5a489c, reset @ 0x5a489e), positioned between the landscape stage and the interior-cell stage. Note the count is read-and-clear at the check, and short-circuit means forceClear=true skips the reset. (2) The vtable slot resolves to RenderDeviceD3D::Clear @ 0x59fd30 (vtable entry 0x7e552c): engine flag 4 remaps to D3D bit 2 = D3DCLEAR_ZBUFFER; IDirect3DDevice9::Clear (slot 0xac) is issued with Count=0, pRects=NULL, bracketed by full-screen viewport set/restore — a genuine full-buffer Z-only clear, shape-unconditional. (3) Gate semantics: D3DPolyRender::DrawPortalPolyInternal @ 0x59bc90 increments portalsDrawnCount iff arg2==false (Ghidra: `if (!param_2) portalsDrawnCount++`), i.e. fence-mode draws. maxZ1=7 @ 0x820e18 / maxZ2=6 @ 0x820e14: bit 2 → depth-write on for both, DEPTHTEST_ALWAYS for both; bit 0 → vertex z forced to 0x3f7fffef (≈1.0 far) for maxZ1 (arg2=true, the per-aperture FAR PUNCH) vs the poly's true projected z for maxZ2 (arg2=false, the FENCE); alpha bit forced 0 → color-invisible. (4) Outdoor chain confirmed: RenderDeviceD3D::DrawMeshInternal @ 0x59f360 (building path; claimed 0x59f3cc is the build_draw_portals_only call region inside it) → BSPTREE::build_draw_portals_only @ 0x539860 modes 1 then 2 → BSPPORTAL::portal_draw_portals_only @ 0x53d870 → RenderDeviceD3D::DrawPortal @ 0x59f0e0 → PView::DrawPortal @ 0x5a5ab0: mode 1 = ConstructView success → DrawPortalPolyInternal(poly, true) = far punch only (no DrawCells); mode 2 = cell-side ConstructView @ 0x5a57b0 — whose FIRST statement resets outside_view.view_count=0 — then DrawCells re-entry. DrawCells xrefs show ONLY DrawInside (0x5a595b) and DrawPortal (0x5a5b53) as callers: there is NO outdoor-root DrawCells, so outdoors the gated full clear is structurally skipped and the mode-1 far punch is retail's only outdoor depth-isolation mechanism — this STRENGTHENS the claimed retail dichotomy (indoors: full clear + true-depth fence; outdoors: per-aperture far punch). ACDREAM verified at the cited lines (path nit: the file is src/AcDream.App/Rendering/GameWindow.cs, not src/AcDream.App/GameWindow.cs): GameWindow.cs:7644-7652 `ClearDepthSlice = clipRoot.IsOutdoorNode ? null : slice => { BeginDoorwayScissor(true, slice.NdcAabb); _gl.Clear(DepthBufferBit); ... }` with the full-screen-slice hazard comment at 7635-7643; BeginDoorwayScissor @ 9707-9724 converts the NDC AABB to a pixel scissor rect; RetailPViewRenderer.cs:234-235 (DrawLandscapeThroughOutsideView, 214-238) invokes the clears AFTER all slices drew; DrawInside stage order 44-108 (landscape+clears → exit-portal masks → shells → object lists). Grep confirms NO DepthFunc-Always fence or far-punch anywhere in RetailPViewRenderer.cs. The divergence is REAL and not behaviorally equivalent: indoors acdream clears an AABB (not full buffer) and never re-fences portal depth; outdoors acdream has neither clear nor punch, so flooded interiors fight terrain on raw depth — retail wins inside the aperture by construction. ONE BLAST-RADIUS SENTENCE IS WRONG AS WORDED: for interior roots, terrain DEPTH inside the AABB does NOT survive — the landscape slice draw is scissored to the SAME AABB the clear later wipes (GameWindow.cs:9477), so within slice AABBs depth is reset; what survives is terrain COLOR (now backed by far depth, with no fence to restore aperture depth for later passes). The outdoor half of the #108 reasoning (no clear, no punch → interiors lose raw depth fights when terrain is nearer, e.g. eye below ground) is correct as stated, and during the cellar ascent the root flips indoor/outdoor so both regimes plausibly contribute. #108/#109 attribution is consistent with the issue texts, which independently name the doorway depth-clear as a suspect (docs/ISSUES.md:3677-3690, 3694-3706; #108 explicitly 'needs its own capture' — attribution plausible, unproven). Severity HIGH stands; the port-shape coupling to the fence + exact-clip divergences is sound (the fence is what makes a full clear safe; the punch is what makes no-clear safe).
|
||||
- blastRadius: #108 (with the eye below outdoor terrain, terrain depth deposited outside the exact aperture but inside the AABB survives wherever shells don't repaint, and outdoors — no clear, no punch — flooded interiors must win raw depth fights against terrain, which they lose when terrain is nearer than the interior, e.g. eye below ground); #109 contributor (the clear region is an AABB, not the aperture, so the protected region ≠ the drawn region).
|
||||
- retailEvidence: PView::DrawCells 0x5a4893-0x5a48a9: `if (forceClear || portalsDrawnCount) render_device->Clear(4 /*Z only*/, 0x820fc0, 1.0f)` — one full-buffer depth clear, unconditional on shape, AFTER the complete landscape stage; correctness of the aperture is then delegated to the fence (see missing-portal-depth-fence) and to the software clip having confined outdoor COLOR. Outdoors the per-aperture far punch (mode 1, maxZ1) plays the clear's role per building portal (DrawMeshInternal 0x59f3cc).
|
||||
- acdreamEvidence: GameWindow.cs:7644-7652: ClearDepthSlice = scissored Clear(DepthBufferBit) per slice for interior roots, null for the outdoor node (comment explains the full-screen-slice hazard); RetailPViewRenderer.cs:234-235 invokes it after ALL slices drew. The outdoor node relies on raw depth between terrain and flooded interiors.
|
||||
- portShape: Once the fence + far-punch land (divergence 1) and the clip is exact (divergence 2), replace the scissored per-slice clear with retail's single full depth clear gated on 'any outside slice drew' for interior roots, and delete the outdoor-node no-clear special case in favor of the per-portal far punch. The three mechanisms are a set — porting them together is what makes each one safe.
|
||||
|
||||
### [HIGH] portal-poly-conditional-draw (UNVERIFIED (verifier hit token limit)) — Baked portal-filling polys (door/window quads, the e223325 finding) draw unconditionally as ordinary mesh geometry; retail routes them exclusively through the DrawPortal mode machinery (punch/fence/nothing), never as part of the shell pass
|
||||
- blastRadius: #113 phantom staircase (portal polys to interior stair cells drawn as if solid geometry — confirmed same mechanism by e223325) and the e46d3d9 door regression (filtering them out also removed legitimately-visible fillings); #109 (the door-texture half of the oscillation is the unconditional filling quad fighting the outdoor slice — for outdoor roots the quad sits geometrically coincident with the clip planes derived from the same polygon, so the clipShells gl_ClipDistance crops it with boundary-epsilon flicker).
|
||||
- retailEvidence: Portal polys live in BSPPORTAL::in_portals and are drawn ONLY by device->DrawPortal from the portals-only walk (BSPPORTAL::portal_draw_portals_only pc:326881, call sites 0x53d9a3/0x53d953 — the only emission path); the shell pass draws the constructed mesh via D3DPolyRender::DrawMesh (0x59f3f4 → 0x59d4a4) with no portal handling; DrawPortal's three outcomes for the poly are far-punch (mode 1, invisible), nothing (mode 2), or true-depth invisible fence on flood-fail (mode 3, no caller found in the built-mesh path) — all DEPTH-ONLY. (Where the TEXTURED filling draws is the open question below; what is certain is it is not unconditional shell geometry.)
|
||||
- acdreamEvidence: Post-revert 124c6cb our GfxObj building meshes contain every dictionary poly including node.Portals fillings, drawn by the normal Wb mesh path; e223325 proved all 13 Holtburg building models' non-node.Polygons polys are portal polys. RetailPViewRenderer.cs:104-105 clips shells (incl. those quads) by slice planes for outdoor roots; indoor roots draw them unclipped (378-380).
|
||||
- portShape: Separate the portal polys from the static mesh at build time (the e223325 classification makes this mechanical: node.Portals refs) into a per-portal side list, then drive them from the DrawPortal-equivalent: flood succeeded → do not draw the filling (draw the depth fence/punch instead); flood failed/not attempted → draw the filling textured (pending resolution of the open question on retail's exact textured-fill site). This is the '#113 and doors are the same mechanism with opposite signs' port.
|
||||
|
||||
### [HIGH] building-flood-seeding-48m-cutoff (adjusted) — Interior floods seed from a 48m per-building distance cutoff over Chebyshev≤1 landblocks; retail floods from the building's BSP portal walk during that building's draw with only plane-side + view-clip + cell-loaded gates (no distance cutoff)
|
||||
- correctedClaim: Interior floods in acdream seed only from exit portals within a hard 48m camera-to-portal-vertex cutoff (RetailPViewRenderer.cs:30,141-142; PortalVisibilityBuilder.cs:426-428) over a Chebyshev<=1 landblock ring around the player (GameWindow.cs:7463-7477; legacy look-in 7759-7795) — while building exteriors draw out to the full near-tier streaming radius. Retail floods every drawn building: LScape::draw (0x506330, frustum in_view gate) -> DrawBlock (0x5a17c0, per-cell view gate) -> DrawSortCell (0x59f140) -> DrawBuilding (0x59f2a0) installs the building's CBldPortal list unconditionally, and PView::ConstructView (0x5a59a0) gates each portal purely on viewer plane-side vs F_EPSILON matched to portal_side, GetClip non-emptiness, CEnvCell::GetVisible loaded-cell lookup (0x52dc10), and copy_view success — no flood-specific distance constant; retail's only distance bounds (LScape block window, degrade-slot null check) remove the entire building from view, so flood eligibility always equals building visibility and a visible aperture can never pop. User-visible consequences: interiors missing (static filling quads instead of through-the-door views) at >48m through visible doors/windows, and a threshold pop for an outdoor viewer whose eye jitters across the 48m seed boundary. NOT a cause of #109 as filed — #109 is an indoor-root across-the-room draw-order/depth oscillation (~10m) where the 48m path never executes (RetailPViewRenderer.cs:60). Port shape stands: replace the 48m+ring predicate with retail's gates (seed every frustum-passed candidate building; per-portal plane-side + clip-non-empty + cell-loaded), noting the candidate gather must widen from the 1-LB ring in the same change since the ring becomes binding once 48m is lifted.
|
||||
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (not BN pseudo-C), chain now VTABLE-VERIFIED, and it checks out:
|
||||
(1) LScape::draw (0x506330): iterates the full block_draw_list (mid_width^2 blocks) and draws every block with in_view != OUTSIDE (set by draw_check_blocks 0x505f80); the per-block dispatch is render_device vtable+0x50 = 0x7e5550 = DrawBlock (xref-confirmed).
|
||||
(2) RenderDeviceD3D::DrawBlock (0x5a17c0): per-cell loop gated only on the cell's own view check (cell vtable+0x68) or alwaysDrawObjects, then calls this vtable+0x58 = 0x7e5558 = DrawSortCell (xref-confirmed). No distance term.
|
||||
(3) RenderDeviceD3D::DrawSortCell (0x59f140): if (cell->building) call vtable+0x68 = 0x7e5568 = DrawBuilding (xref-confirmed).
|
||||
(4) RenderDeviceD3D::DrawBuilding (0x59f2a0): FIRST line installs outdoor_pview->outdoor_portal_list = building->portals (unconditional), then CPhysicsPart::UpdateViewerDistance (LOD/degrade selection) and a degrade-slot gfxobj null check before drawing the parts. The flood is triggered by portal polys encountered during the part draw: RenderDeviceD3D::DrawPortal (0x59f0e0) -> PView::DrawPortal (0x5a5ab0) -> PView::ConstructView(CBldPortal) (0x5a59a0).
|
||||
(5) PView::ConstructView (0x5a59a0) gates, verified in the Ghidra decompile: (a) viewer-eye dot portal plane vs ::F_EPSILON producing Sidedness, matched against portal_side (POSITIVE required for side 0, NEGATIVE for side 1); (b) GetClip output non-empty; (c) CEnvCell::GetVisible(other_cell_id) non-null — decompiled 0x52dc10: a pure hash lookup in visible_cell_table, i.e. a loaded-cell check, NOT a distance check; (d) copy_view success. NO distance term anywhere in the flood path. Citation fix: the claim attributed 0x5a59a0 to CEnvCell::GetVisible — 0x5a59a0 is ConstructView itself; GetVisible is 0x52dc10/0x52ad40.
|
||||
|
||||
ACDREAM SIDE — all cited lines verified against the working tree:
|
||||
- RetailPViewRenderer.cs:30 `OutdoorBuildingSeedDistance = 48f`; :60 MergeNearbyBuildingFloods runs only for IsOutdoorNode roots; :137-144 per-building ConstructViewBuilding(group, ..., 48f); PortalVisibilityBuilder.cs:548-554 ConstructViewBuilding is a passthrough to BuildFromExterior(maxSeedDistance); :426-428 `seedDistance > maxSeedDistance => continue` skips the exit-portal seed (seedDistance = camera-to-nearest-portal-vertex, :350/:426).
|
||||
- Candidate gather: GameWindow.cs:7463-7477 (live R-A2 path, Chebyshev<=1 landblocks around the PLAYER landblock -> _outdoorNodeBuildingCells -> ctx.NearbyBuildingCells at GameWindow.cs:7610) and GameWindow.cs:7759-7776 + 7795 (legacy look-in path, same ring + MaxSeedDistance=48f, only runs when clipRoot is null). Grep over src confirms 48f is passed at BOTH production call sites and the PositiveInfinity defaults are never used in production.
|
||||
- Building EXTERIORS draw via the normal entity path out to the near-tier streaming radius (N1=4 LBs), far beyond 48m — so acdream renders a building whose doorway aperture is visible at e.g. 100m but never floods its interior. Post-124cb6cc (DrawingBSP filter revert) the baked portal-filling quads draw unconditionally, so such doors show the static filling quad instead of retail's conditional through-the-aperture interior — partially masked, still not retail.
|
||||
|
||||
ADJUSTMENTS (why not 'confirmed'):
|
||||
(A) The #109 blast-radius attribution is WRONG. #109 as filed (docs/ISSUES.md:3694-3706) is an INDOOR-root scenario: standing INSIDE a Holtburg house looking at the other exit door across the room (~10m), oscillating between door texture and background, explicitly suspected as OutsideView-slice / doorway depth-clear / door-entity draw-order interaction, and explicitly noted as distinct from the (fixed) flood strobe. On an indoor root MergeNearbyBuildingFloods never executes (RetailPViewRenderer.cs:60 gate) — the 48m predicate is not in the code path at all. The 48m-boundary jitter-pop mechanism is real but applies to an OUTDOOR viewer near 48m from a doorway; no filed issue currently matches it.
|
||||
(B) Retail wording needs one nuance: retail's flood eligibility IS bounded by distance indirectly — (i) the LScape block window (block_draw_list spans the landscape draw radius) and (ii) DrawBuilding's degrade-slot null check after UpdateViewerDistance can suppress the whole building draw, not just mesh choice. But both bounds coincide exactly with "the building is drawn at all": a building too far to draw shows no aperture either, so no pop is ever user-visible. The correct invariant: flood eligibility == building visibility; retail has NO flood-specific distance constant.
|
||||
(C) The Chebyshev<=1 ring is non-operative today: ring coverage is >=192m in every direction from the player while the 48m gate binds first (the GameWindow.cs:7755-7758 comment says exactly this). It becomes the binding constraint only once the 48m is lifted — the port shape must widen the gather to frustum-passed candidates in the same change (the claimed port shape already says this; correct).
|
||||
|
||||
The core divergence is REAL, not behaviorally-equivalent, and not handled elsewhere (grep confirms no other interior-flood path outdoors). Severity high stands on the artifact class (interiors absent through any visible aperture beyond 48m + threshold pop at the 48m boundary, both breaking the one-drawing-discipline invariant), but the headline user-bug tie to #109 must be dropped.
|
||||
- blastRadius: #109's 'far' dimension: an exit door near the 48m seed boundary (or outside the 1-LB ring) flickers between flooded (interior/outdoor view through the aperture) and not flooded (filling quad / background) as the eye jitters — retail's gate is purely geometric visibility so a visible distant door never pops. Also explains interiors visibly missing through distant buildings' windows/doors at >48m.
|
||||
- retailEvidence: DrawBuilding runs for every cell whose block passed block_check (LScape::draw 0x506330 → DrawBlock 0x5a17c0 → DrawSortCell 0x59f140 → DrawBuilding 0x59f2a0) — every in-view building gets the portals-only walk regardless of distance; ConstructView(CBldPortal) gates only on viewer plane-side (epsilon 2e-4), GetClip non-emptiness, and CEnvCell::GetVisible (Ghidra 0x005a59a0). The part-level LOD (UpdateViewerDistance/deg_level, 0x59f2bc-0x59f2d3) affects mesh choice, not flood eligibility.
|
||||
- acdreamEvidence: RetailPViewRenderer.cs:30 (OutdoorBuildingSeedDistance=48f) + 137-144 (per-building ConstructViewBuilding) + GameWindow.cs:7472 (Chebyshev≤1 LB candidate gather) + 7795 (MaxSeedDistance=48f on the legacy look-in); PortalVisibilityBuilder.cs:426-427 (NearestPortalVertexDistance > maxSeedDistance ⇒ skip seed).
|
||||
- portShape: Replace the distance cutoff with retail's gates: seed every candidate building whose landblock/cell passed the frustum cull, gate per portal on plane-side + clipped-view non-emptiness + cell-loadedness. The R-A2 per-building grouping itself is retail-faithful (one ConstructView per CBldPortal) and KEEP-listed — only the eligibility predicate diverges. Perf guard: the view-clip gate rejects far/off-screen portals cheaply, which is exactly retail's mechanism.
|
||||
|
||||
### [MEDIUM] entity-cull-no-portal-viewcone (confirmed) — Entities/objects are culled by frustum + cell membership only; retail additionally sphere-tests every drawn part against the CURRENT portal view's planes (viewconeCheck)
|
||||
- correctedClaim: Confirmed as claimed, with three precision upgrades for the port plan: (1) the DrawObjCellForDummies call site is 0x005a4b0d (pc:432878), with Render::PortalList assigned in the same statement region; (2) retail's gate is a per-SLICE loop — DrawMesh (0x005a0860) iterates PortalList->view_count, set_view (0x0054d0e0) per slice, viewconeCheck (0x0054c250) per slice, and skips the part only when OUTSIDE in ALL slices — plus a once-per-frame part dedup (DrawMeshInternal 0x0059f360, GetDrawnThisFrame/SetDrawnThisFrame, player parts exempt) that a faithful port must reproduce; (3) acdream's indoor per-cell entity path is weaker than claimed: it bypasses even the frustum/AABB cull (RetailPViewRenderer.cs:465+474 pass the entry's own landblock as neverCullLandblockId into WbDrawDispatcher.cs:593-595/:662), so entities there are gated by cell membership alone.
|
||||
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (127.0.0.1:8081), per the BN-invented-branch warning:
|
||||
|
||||
1. Render::viewconeCheck @ 0x0054c250 (Ghidra decompile): scales the part's drawing sphere by object_scale, transforms center to viewer space (Position::localtoglobal + Frame::globaltolocal vs viewer_pos), tests the signed distance against viewer_world_space.CY (returns OUTSIDE if dist < -radius), then loops `portal_npnts` planes at `Render::portal_vertex` (24-byte view_vertex stride; plane read as N.x/N.y/N.z/d), returning OUTSIDE on the first fully-behind plane, else PARTIALLY_INSIDE or ENTIRELY_INSIDE. Exactly as claimed — it is a sphere-vs-plane-set BoundingType test, no geometry modification.
|
||||
|
||||
2. PView::DrawCells @ 0x005a4840 (Ghidra decompile) stage 3 (the loop that runs when the shell/portal stages are done): for each cell in cell_draw_list, `Render::PortalList = (cell->portal_view).data[cell->num_view - 1]` immediately before the render-device vtable call; the callee is RenderDeviceD3D::DrawObjCellForDummies — confirmed by pc:432878 (call at 0x005a4b0d; the claim's "0x5a4b07" is the same statement, off by 6 bytes) and the vtable slot assignment at pc:1037072.
|
||||
|
||||
3. Full chain to the per-part cull, every link decompiled: DrawObjCellForDummies @ 0x005a0760 (UpdateObjCell + CShadowPart::insertion_sort + vtbl) → DrawObjCell @ 0x005a1a40 → DrawPartCell @ 0x005a07a0 (iterates the cell's shadow_part_list) → CShadowPart::draw @ 0x006b50d0 → CPhysicsPart::Draw @ 0x0050d7a0 → vtbl+0x70 = RenderDeviceD3D::DrawMesh @ 0x005a0860.
|
||||
|
||||
4. DrawMesh @ 0x005a0860 (Ghidra decompile) is the gate: when Render::PortalList != null it loops i over PortalList->view_count, calls Render::set_view(&PortalList->view, i) — set_view @ 0x0054d0e0 installs that slice's portal_npnts/portal_vertex/inmask/xy-bounds — then viewconeCheck(gfxobj->drawing_sphere). A view returning OUTSIDE is skipped; if ALL views return OUTSIDE the function returns OUTSIDE_VIEWCONE_ODS without drawing. Slightly richer than the claim (a per-SLICE loop, plus a building_view filter), but fully consistent with "cell contents culled per portal-clipped view".
|
||||
|
||||
5. "Cull-only, never hard-clip" confirmed: DrawMeshInternal @ 0x0059f360 ignores the BoundingType argument on the built-mesh path and submits the whole constructed mesh (D3DPolyRender::DrawMesh). Bonus port-relevant detail: it dedups parts once-per-frame via CPhysicsPart Get/SetDrawnThisFrame with player-object parts EXEMPT — a part straddling cells draws under the first cell/view that passes, so a faithful port needs the frame-stamp semantics too.
|
||||
|
||||
ACDREAM SIDE — read the production code and call sites:
|
||||
|
||||
6. RetailPViewRenderer.cs:439-450 UseIndoorMembershipOnlyRouting: clears entity clip routing (`_entities.ClearClipRouting()`); the comment explicitly names Render::viewconeCheck as retail's mechanism and the character-slicing rationale for not hard-clipping — and no cull substitute exists anywhere (grep "viewcone" across src = comments only, RetailPViewRenderer.cs:371,442).
|
||||
|
||||
7. The divergence is actually slightly UNDERSTATED: in the indoor per-cell path, DrawCellObjectLists (RetailPViewRenderer.cs:401-426) → DrawEntityBucket (:460-477) builds the landblock entry with lbId = ctx.PlayerLandblockId (:465) AND passes neverCullLandblockId: ctx.PlayerLandblockId (:474) — so WbDrawDispatcher.WalkEntitiesInto's landblock frustum cull (WbDrawDispatcher.cs:593-595) and per-entity AABB frustum cull (:657-666, gated by `entry.LandblockId != neverCullLandblockId` at :662) are BOTH bypassed. The per-cell indoor entity path is gated by cell membership ONLY (EntityPassesVisibleCellGate, WbDrawDispatcher.cs:1816-1835). The claimed "frustum + 5m AABB" (PerEntityCullRadius=5.0f at :208-210) describes the general outdoor path.
|
||||
|
||||
8. Particles: GameWindow.cs:9553-9580 DrawRetailPViewCellParticles scissors to sliceCtx.Slice.NdcAabb via BeginDoorwayScissor (:9569) with clip distances explicitly disabled (:9568); the :9701-9706 comment confirms particle.vert has no gl_ClipDistance. AABB-of-slice scissor only — the AABB corners outside the actual portal polygon leak, feeding particles-through-walls.
|
||||
|
||||
9. Port-shape premise holds: the per-cell slice planes already exist at the entity draw site — GetCellSlicesOrNoClip (RetailPViewRenderer.cs:428-437) yields ClipViewSlice(Slot, NdcAabb, Vector4[] Planes) (ClipFrameAssembler.cs:40), currently consumed only by the particle scissor (:423-424), not by the entity draw.
|
||||
|
||||
REALITY OF THE DIVERGENCE: not behaviorally equivalent and not handled elsewhere. For the ROOT view retail's portal planes ≈ the screen frustum, so acdream's frustum cull is equivalent THERE; for any cell visible through a doorway the slice planes are strictly narrower than the frustum, and acdream has no test at all. User-visibility is amplified by acdream's own #113 shell clipping (RetailPViewRenderer.cs:374-380: outdoor-eye roots clip cell shells per slice via gl_ClipDistance) — a wall clipped away outside the slice no longer depth-occludes the unculled entity behind it, producing the statics/characters-visible-near-apertures class; particles leak at slice-AABB corners; and every non-culled entity is a wasted draw. Severity "medium" is fair today (depth test masks much of it for indoor-eye unclipped roots); it trends toward high once #114 lands pixel-exact indoor clip regions, because the more faithfully shells are clipped, the more the missing object cull shows.
|
||||
- blastRadius: Objects in a visible cell draw at full screen extent even when the cell is visible only through a sliver of doorway — the 'statics/characters visible through walls near apertures' class, and wasted draws; particles inherit the same gap (AABB scissor only, no plane clip), feeding the particles-through-walls bug.
|
||||
- retailEvidence: Render::viewconeCheck (Ghidra 0x0054c250) tests the object sphere against viewer_world_space.CY plus the active portal view's plane set (portal_npnts loop) and returns OUTSIDE/PARTIAL/INSIDE; DrawCells stage 3 installs Render::PortalList = the cell's portal_view before DrawObjCellForDummies (0x5a4b07), so cell contents are culled per portal-clipped view. Polygons are not hard-clipped — the test is cull-only.
|
||||
- acdreamEvidence: RetailPViewRenderer.cs:439-450 UseIndoorMembershipOnlyRouting deliberately clears clip routing for entities (rationale: hard gl_ClipDistance slices characters, which retail does not do — correct observation, but it removed the CULL too); WbDrawDispatcher.cs:208-210/593-595 cull by frustum + 5m AABB only. Particle passes scissor to the slice AABB (GameWindow.cs:9569) with no plane clip (9701-9706 comment).
|
||||
- portShape: Port viewconeCheck as a CPU sphere-vs-slice-planes test in the per-cell entity loop (the slice planes already exist in ClipViewSlice.Planes): skip the entity when fully outside every slice of its cell; never hard-clip. Apply the same test to per-cell particle emitters. Small, contained, and retail-faithful — it is a cull, not a clip.
|
||||
|
||||
### [MEDIUM] weather-gate-player-vs-viewer (adjusted) — Weather pass gating: retail draws weather only when the PLAYER is outside; acdream keys it on the viewer root's seen_outside, so rain draws through doorways while the player is inside
|
||||
- correctedClaim: CONFIRMED divergence, corrected port shape. Divergence (as claimed, verified in Ghidra): retail gates the weather pass on the PLAYER being in an outdoor cell — GameSky::Draw @ 0x00506ff0 gate `is_player_outside() || pass==0`, with SmartBox::is_player_outside @ 0x00451e80 = `(player->m_position.objcell_id & 0xffff) < 0x100`; the gate is live on indoor frames because PView::DrawCells @ 0x005a4840 (pc:432719) calls LScape::draw whenever outside_view is non-empty. acdream gates both weather call sites (GameWindow.cs:9535 via renderSky param from 7423/7632, and 7881 via drawSkyThisFrame=renderSky at 7552) on the VIEWER root's seen_outside — so rain draws through the doorway slice (and on player-inside/camera-outside frames) while the player is indoors. CORRECTED port shape: gate the two RenderWeather calls on the retail predicate "player's cell id low word < 0x100" (player not in an EnvCell, false when no player exists) — NOT on `playerRoot is null || playerSeenOutside` as originally proposed, because building interiors have SeenOutside=true, so that predicate stays true inside the inn and would leave the headline bug unfixed. The dome keeps the existing seen_outside-based renderSky gate (matches retail pass-0 behavior).
|
||||
- verifier notes: RETAIL (all branch claims re-derived from Ghidra decompiles, not BN pseudo-C): (1) GameSky::Draw @ 0x00506ff0 — outer gate is literally `if ((SmartBox::is_player_outside(smartbox) != 0) || (param_1 == 0))`; the pass-1 body is `else if (LScape::weather_enabled) { render_device->vtbl[+100](this->after_sky_cell); }`. Weather (pass 1) therefore draws ONLY when is_player_outside; the dome/sky-object loop (pass 0) is exempt via `|| pass==0`. (2) SmartBox::is_player_outside @ 0x00451e80 — `return (this->player->m_position.objcell_id & 0xffff) < 0x100` (0 if player null): it keys off the PLAYER physics object's cell (indoor EnvCells have low word >= 0x100), NOT the viewer/camera — the load-bearing player-vs-viewer distinction is genuine, not a naming artifact. (3) LScape::draw @ 0x00506330 — GameSky::Draw(0) before the DrawBlock loop, GameSky::Draw(1) after, gated on weather_enabled. (4) PView::DrawCells @ 0x005a4840 (pc:432707-432719) — on indoor frames with outside_view.view_count > 0 retail calls LScape::draw, so GameSky::Draw(1)'s player gate is live precisely in the doorway-slice scenario: dome + landscape draw through the door, weather is suppressed while the player is in an EnvCell. ACDREAM (read at the cited lines + production call sites): GameWindow.cs:7423 `bool renderSky = viewerRoot is null || rootSeenOutside`, with rootSeenOutside = VIEWER cell SeenOutside (7320; viewer cell selected at 7301-7312 from RetailChaseCamera.ViewerCellId). The indoor doorway slice passes that same renderSky into DrawRetailPViewLandscapeSlice (call site 7624-7634; param at 9472), where it gates BOTH RenderSky (9485-9487) AND RenderWeather (9533-9536). The outdoor post-scene weather call (7874-7882) is gated on `clipRoot is null && drawSkyThisFrame` where drawSkyThisFrame = renderSky (7552) — also viewer-keyed. No player-outside predicate exists on any weather path: playerSeenOutside (7296) feeds only lighting (playerInsideCell 7337 → UpdateSunFromSky 7352), and SkyRenderer.RenderWeather (SkyRenderer.cs:136-144 → shared RenderPass:154) has no internal gate. DIVERGENCE IS REAL: player inside a building interior (seen_outside=true) with the doorway in view → retail draws no weather (is_player_outside=0) while acdream draws the rain/post-scene pass in the doorway slice; likewise player-inside/camera-outside frames draw full-screen weather in acdream but none in retail. Severity medium (cosmetic, weather-only) is fair. ADJUSTMENT: the claimed port shape is wrong in a load-bearing way — gating on `playerRoot is null || playerSeenOutside` (7296) would NOT fix the headline case, because building interiors have SeenOutside=true, so the predicate stays true inside the inn and weather keeps drawing. Retail's predicate is "player's objcell_id low word < 0x100" (player in an outdoor cell; false when player is null), which in acdream terms is the player's CurrCell NOT being an EnvCell (≈ `playerRoot is null` WITHOUT the `|| playerSeenOutside` disjunct), ideally derived from the player's cell id directly so the no-player case suppresses weather like retail. Keeping the dome on the existing renderSky/seen_outside gate matches retail pass-0 (dome ungated within LScape::draw; sealed dungeons never reach LScape::draw because outside_view is empty, which acdream's seen_outside=false reproduces).
|
||||
- blastRadius: Cosmetic divergence at building doorways in rain/snow: retail shows no rain through the door while you are inside; we draw the weather cylinder through the slice. Also double-gates differently from the sky dome, which retail draws unconditionally in the landscape pass.
|
||||
- retailEvidence: GameSky::Draw (Ghidra 0x00506ff0): pass gate `is_player_outside() || pass==0` (0x507009) — the dome (pass 0) draws even on indoor frames whose outside_view ran LScape::draw, the weather cell (pass 1, after_sky_cell at 0x5070da) requires is_player_outside.
|
||||
- acdreamEvidence: DrawRetailPViewLandscapeSlice draws RenderWeather whenever renderSky (GameWindow.cs:9532-9541), and renderSky = viewerRoot null || rootSeenOutside (7423) — viewer-cell, not player-cell, and no is_player_outside equivalent on the weather half.
|
||||
- portShape: Split the gate: keep the dome on the seen_outside/outside-view condition (matches retail pass-0), gate the RenderWeather calls (9535 and 7881) on the PLAYER-outside predicate (playerRoot is null || playerSeenOutside is already computed at 7296).
|
||||
|
||||
### [MEDIUM] unattached-particles-dropped-outdoors (adjusted) — On outdoor-node frames (normal outdoor play post-cutover) emitters with AttachedObjectId==0 are never drawn — the unattached-emitter pass only exists on the clipRoot==null safety path
|
||||
- correctedClaim: Post-cutover, acdream's Scene-particle draws on clipRoot!=null frames (all normal in-world frames, outdoor and indoor) are gated by per-frame entity-membership sets AND a non-zero AttachedObjectId (GameWindow.cs:9528-9529, 9575-9576), whereas retail draws every shadow part in every in-view cell unconditionally with no attachment concept (PView::DrawCells 0x005a4840 → DrawObjCell 0x005a1a40 → DrawPartCell 0x005a07a0; emitters always have a parent physobj, makeParticleEmitter 0x0051cd80). However, the specific AttachedObjectId==0 population is EMPTY in production (every spawn path passes a non-zero key; sky emitters use non-Scene passes), so the ==0 exclusion is a LATENT trap (severity low — it will silently eat future world-positioned effects such as lightning #2), not an active regression; also the "legacy filtered branch" at GameWindow.cs:7851-7858 is unreachable (clipAssembly is non-null only when clipRoot!=null), the real safety path being the unfiltered draw at 7863-7867. The behaviorally ACTIVE divergence in the same predicates is the membership-set filter: emitters attached to partition.LiveDynamic entities (ParentCellId==null, InteriorEntityPartition.cs:39-40) belong to neither set and are never drawn on clipRoot!=null frames — that, not the ==0 clause, is the population retail would draw and acdream drops; port shape = route particle drawing off cell membership (the partition buckets) instead of attach-id set intersection, with an explicit bucket for LiveDynamic-attached and (if ever introduced) unattached emitters.
|
||||
- verifier notes: RE-CHECKED ACDREAM: (1) Outdoor-node frames really are clipRoot!=null: _outdoorNode is built whenever viewerRoot is null && viewerCellId!=0 (GameWindow.cs:7458-7482), OutdoorCellNode.Build always returns a node (OutdoorCellNode.cs:23-30), clipRoot = viewerRoot ?? _outdoorNode (GameWindow.cs:7497); per the cutover comment (7488-7496) clipRoot==null only pre-spawn/login/legacy camera. (2) On clipRoot!=null frames the only Scene-pass particle draws are the landscape-slice pass (GameWindow.cs:9519-9529: AttachedObjectId != 0 && in _outdoorSceneParticleEntityIds, populated from sliceCtx.OutdoorEntities = partition.Outdoor per RetailPViewRenderer.cs:231+571-573) and the cell pass (GameWindow.cs:9558-9576: AttachedObjectId != 0 && in _visibleSceneParticleEntityIds from the per-cell bucket, RetailPViewRenderer.cs:424). ParticleRenderer.BuildDrawList applies pass+filter per emitter (ParticleRenderer.cs:182-187). So ==0 emitters are indeed never drawn on outdoor-node (and indoor) frames — the structural gate claim is CORRECT. (3) Mechanical correction: the cited "legacy filtered branch at 7856" is DEAD CODE — it requires clipRoot==null && clipAssembly!=null, but clipAssembly is only ever assigned non-null inside the clipRoot!=null branch (only assignments: GameWindow.cs:7501, 7532, 7665); on the real safety path the UNFILTERED global draw at 7863-7867 runs (also admits ==0). (4) BLAST RADIUS REFUTED: no production path spawns a Scene-pass emitter with AttachedObjectId==0. Sole factory ParticleSystem.SpawnEmitter (ParticleSystem.cs:32-64); sole production caller ParticleHookSink.SpawnFromHook always passes the entity key (ParticleHookSink.cs:226-232); key sources are EntityScriptActivator.OnCreate (guards key!=0, EntityScriptActivator.cs:97-98), the 0xF754 wire handler (GameWindow.cs:4974-4985 — guid addresses an object; PhysicsScriptRunner.Play has no zero-guard, PhysicsScriptRunner.cs:120-142, but guid==0 on the wire is not a known ACE behavior), and sky-PES synthetic ids which are SkyPre/PostScene pass (GameWindow.cs:5012-5016) and thus excluded from Scene draws by the pass check. The ==0 predicate filters an empty set today — latent trap (e.g. for future world-positioned lightning, issue #2), not an active invisible-particles regression. RE-CHECKED RETAIL via Ghidra (not BN pseudo-C): PView::DrawCells @ 0x005a4840 final stage iterates cell_draw_list and vtable-dispatches the per-cell object draw unconditionally; RenderDeviceD3D::DrawObjCell @ 0x005a1a40 forwards to DrawPartCell @ 0x005a07a0 which draws EVERY CShadowPart in the cell's shadow_part_list — no attachment filter. Stronger: retail cannot represent an unattached emitter at all — ParticleEmitter::makeParticleEmitter @ 0x0051cd80 null-guards the parent CPhysicsObj, ParticleManager::CreateParticleEmitter @ 0x0051b6c0 takes the parent physobj, and ParticleEmitter owns its own physobj living in cells (acclient.h:52469-52489, fields parent + physobj + parts). Retail draw is purely cell-membership-driven. SIBLING FINDING (the behaviorally active divergence hiding next to the claimed one): the same predicates require the attach id to be IN one of the two membership sets; emitters attached to partition.LiveDynamic entities (server entities with ParentCellId==null, InteriorEntityPartition.cs:35-49) are in neither set, so their effects (e.g. wire PlayScript on a moving player/NPC whose ParentCellId is unset) never draw on outdoor-node frames — that population is non-empty (GameWindow.cs:7813-7823 draws a real LiveDynamic bucket) and is what retail's unconditional cell draw would render. How often live dynamics have null ParentCellId outdoors was not fully settled (open question); entities with an outdoor ParentCellId DO land in partition.Outdoor (InteriorEntityPartition.cs:61-64) and their emitters draw.
|
||||
- blastRadius: World-positioned particle effects (any emitter not attached to an entity) silently invisible during normal outdoor gameplay since the cutover; indoor too (per-cell filter also requires a non-zero attach id). A quiet regression class rather than a reported issue — worth a targeted visual check.
|
||||
- retailEvidence: Retail draws cell contents (including particle-bearing objects) via DrawObjCell/DrawObjCellForDummies for every in-view cell unconditionally (DrawBlock 0x5a19e6, DrawCells stage 3 0x5a4b0d); there is no attachment-based filter — everything in a cell's shadow list draws.
|
||||
- acdreamEvidence: GameWindow.cs:7846 gates the global Scene-particle pass on `clipRoot is null`; the clipRoot!=null replacements both require AttachedObjectId != 0 (slice pass 9528-9529, cell pass 9575-9576); only the legacy filtered branch at 7856 admits AttachedObjectId==0.
|
||||
- portShape: Add the unattached-emitter draw to the clipRoot!=null frame (one extra ParticleRenderer.Draw with the `AttachedObjectId == 0` predicate after the slice/cell passes, scissored like the others), or fold unattached emitters into the outdoor bucket's slice pass. One-line predicate change once placed.
|
||||
|
||||
### [LOW] global-passes-vs-per-cell-interleave (confirmed) — Outdoor composition is global passes (terrain → interiors → entities) instead of retail's per-cell interleave (terrain cell → building+interior → objects → alpha flush) over far-to-near blocks
|
||||
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (not BN pseudo-C) for every branch-sensitive element:
|
||||
|
||||
1. LScape::draw (0x506330, pc:267911-267951): GameSky::Draw(0) → iterate block_draw_list from index mid_width²-1 DOWN to 0, calling RenderDevice vtable DrawBlock per non-null block → weather sky pass. End-first iteration confirmed (esi_2 decrements from esi_1-1 to 0).
|
||||
2. LScape::get_block_order (0x504c50, pc:266559-266655): block_draw_list[0] = the viewer's block (0x504c8a), then a ring loop (edi_1 = ring radius 1..max, four quadrant-symmetric writes per step) fills outward. Viewer-first rings confirmed → end-first iteration in draw = far→near at BLOCK granularity. (Within a block, cells iterate in plain array order, NOT distance-sorted — the claim's wording "over far-to-near blocks" is correct.)
|
||||
3. RenderDeviceD3D::DrawBlock (0x5a17c0, pc:430021+, claimed pc:430027 — checks out; Ghidra decompile confirms): two per-cell loops. Loop 1: per cell, if in-view and has shadow objects → UpdateObjCell + CShadowPart::insertion_sort. Loop 2: per cell — SetSurfaceArray(terrain), landscape_detail_surface swap (ONLY when side_cell_count==8, i.e. full-LOD block; src_blend=5/dst_blend=6 at 0x5a199b-0x5a19b6) → vtable+0x54 DrawLandCell → vtable+0x58 DrawSortCell → FlushAlphaList(flush) gated on the global float `flush` vs 1.0 (Ghidra: `(flush < 1.0) != (flush == 1.0)`, i.e. flush ≤ 1.0; the global defaults to 0.75 at pc:1106050, so the per-cell flush IS active in the default config).
|
||||
4. RenderDeviceD3D::DrawSortCell (0x59f140, Ghidra): if (cell->building) DrawBuilding(building); then DrawObjCell(cell). Exactly "building then objects".
|
||||
5. RenderDeviceD3D::DrawBuilding (0x59f2a0, pc:427938-427961): sets outdoor_pview->outdoor_portal_list = building->portals; swaps Render::curr_detail_surface = building_detail_surface at 0x59f2eb (claimed address confirmed) with src_blend=9/dst_blend=6; calls FlushAlphaList(0f) at 0x59f30b BEFORE drawing the building (an extra flush boundary the claim didn't mention); CPhysicsPart::Draw(part,1) → DrawMeshInternal (0x59f360) → BSPTREE::build_draw_portals_only — the conditional portal-poly path; interiors recurse inline via RenderDeviceD3D::DrawPortal (0x59f0e0) → PView::DrawPortal(outdoor_pview) (0x59f109). So "building+interior" within the cell iteration is accurate. Interiors also swap their own environment_detail_surface (RenderDeviceD3D::DrawEnvCell 0x59f170 at 0x59f1c2) — the detail-state divergence covers interiors too, not just buildings.
|
||||
|
||||
ACDREAM SIDE — all cited lines verified against production code:
|
||||
1. GameWindow.DrawRetailPViewLandscapeSlice (src/AcDream.App/Rendering/GameWindow.cs:9465-9551; note: GameWindow.cs lives under Rendering/): sky (9486) → ALL terrain in one call `_terrain?.Draw(...)` (9496) → ALL outdoor entities in ONE WbDrawDispatcher.Draw over sliceCtx.OutdoorEntities (9503-9511) → outdoor-entity particles (9523) → weather (9535). Global passes confirmed.
|
||||
2. TerrainModernRenderer.Draw (src/AcDream.App/Rendering/TerrainModernRenderer.cs:206-295): builds one DEIC array over every frustum-visible slot and issues a single glMultiDrawElementsIndirect (288-292). Claim's ":206-299" checks out.
|
||||
3. Interiors drawn AFTER the landscape slice: RetailPViewRenderer.DrawInside (src/AcDream.App/Rendering/RetailPViewRenderer.cs:93-106) — DrawLandscapeThroughOutsideView (93) then DrawEnvCellShells (104-105) then DrawCellObjectLists (106). Claimed :104-106 confirmed.
|
||||
4. WbDrawDispatcher sorts opaque front-to-back / translucent back-to-front per invocation (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1203-1204; comparators :1436-1446, cull-mode-major then distance asc/desc). Claimed :1199-1204 confirmed (1197-1202 is the comment).
|
||||
5. No detail-surface analog exists anywhere in src/ — grep for detail_surface|DetailSurface|detail_tiling|DetailTiling over src/ returns zero hits. The building/landscape/environment micro-detail overlay system is wholesale absent, confirming that half of the blast radius.
|
||||
|
||||
DIVERGENCE IS REAL and not behaviorally equivalent in the abstract: retail = per-block far→near, per-cell terrain→building(+inline portal-recursed interiors)→objects→alpha-flush; acdream = global terrain MDI → global outdoor-entity dispatch → all interior shells → per-cell interior objects. Severity "low" is defensible: opaque ordering is fully masked by the depth buffer, no named issue (#99/#108/#109/#113/#114) is attributable to this divergence, and the KEEP-listed bindless-MDI architecture rules out a literal per-cell draw port anyway.
|
||||
|
||||
REFINEMENTS (recorded, not verdict-changing):
|
||||
(a) The claim's concrete mis-order example ("alpha terrain/water vs building alpha at cell boundaries") is UNVERIFIED and likely moot — TerrainModernRenderer has no translucent/blend path at all (single opaque MDI). The verified concrete translucency consequence is sharper: translucent OUTDOOR entities are blended at GameWindow.cs:9508 BEFORE interior shells/objects exist in the framebuffer (RetailPViewRenderer.cs:104-106), inverting retail's order at doorways — a translucent outdoor object in front of a door aperture blends against sky/terrain instead of the interior behind it. Latent artifact class, not currently reported; consistent with severity low.
|
||||
(b) Retail has an additional alpha-flush boundary the claim missed: FlushAlphaList(0f) inside DrawBuilding (0x59f30b) before every building draw, and the per-cell flush is conditional on the global flush ≤ 1.0 (default 0.75).
|
||||
(c) acdream's INTERIOR side already does a per-cell far→near interleave (DrawEnvCellShells iterates IndoorDrawPlan.ShellPass per cell, RetailPViewRenderer.cs:382-394; DrawCellObjectLists iterates OrderedVisibleCells in reverse per cell, :408-425) — the global-pass divergence is specifically the OUTDOOR composition plus the outdoor-before-interiors pass ordering.
|
||||
(d) The missing detail-surface system is real but is a standalone visual-fidelity gap (retail swaps it for terrain 0x5a199b, buildings 0x59f2eb, AND interiors 0x59f1c2) — it deserves its own low/polish line item independent of composition order, since it could be added to the MDI architecture without per-cell draws.
|
||||
|
||||
PORT SHAPE judgment: agreed — no correctness port needed; depth buffering substitutes for the opaque interleave, and a faithful translucency fallback (an alpha-flush/sort boundary keyed per building, or simply documenting the doorway translucent-entity case as the trigger to file) does not require abandoning the KEEP-listed MDI pipeline.
|
||||
- blastRadius: Mostly masked by the depth buffer; visible only in translucent ordering (retail's per-cell FlushAlphaList sorts alpha against the just-drawn cell; our two-pass alpha-test + per-draw sort can mis-order alpha terrain/water vs building alpha at cell boundaries) and in the absence of the per-building detail-surface state retail swaps in (building_detail vs landscape_detail tiling, 0x59f2eb vs 0x5a199b).
|
||||
- retailEvidence: DrawBlock (pc:430027): per cell DrawLandCell → DrawSortCell(building, objects) → FlushAlphaList(flush); LScape::draw iterates block_draw_list end-first (far→near, get_block_order 0x504c50 builds viewer-first rings).
|
||||
- acdreamEvidence: GameWindow.cs:9494-9512 draws ALL terrain (TerrainModernRenderer.Draw, one MDI over every visible slot, TerrainModernRenderer.cs:206-299) then ALL outdoor entities; building interiors drawn afterward in DrawEnvCellShells/DrawCellObjectLists (RetailPViewRenderer.cs:104-106); alpha = two-pass alpha-test model (CLAUDE.md KEEP-list), opaque front-to-back / transparent back-to-front sort (WbDrawDispatcher.cs:1199-1204).
|
||||
- portShape: No port needed for correctness given the KEEP-listed bindless MDI architecture — depth buffering substitutes for the interleave. File only if a concrete translucency mis-order is observed; the faithful fallback is a per-building alpha flush boundary (sort key extension), not a return to per-cell draws.
|
||||
|
||||
## OPEN QUESTIONS
|
||||
|
||||
- Where does retail draw the TEXTURED portal-filling quad (the visible door/window filling)? Verified: the shell pass draws the constructed mesh with no portal logic (DrawMeshInternal 0x59f3f4 → D3DPolyRender::DrawMesh 0x59d4a0), the portals-only pass uses only modes 1 (far punch) and 2 (flood, no poly) (0x59f3cc/0x59f3d9), and DrawPortal's mode-3 fail-fill draws the poly INVISIBLE (maxZ2 bit1 ⇒ alpha 0). If the built MeshBuffer excludes node.Portals polys (consistent with e223325's node.Polygons finding), nothing in the traced built-mesh path ever draws the filling textured — yet doors/windows are visibly filled in retail. Candidates not yet traced: MeshBuffer/constructed-mesh build including portal polys as subsets with a runtime skip I did not find; the legacy non-built-mesh BSP draw path (RenderDeviceD3D::DrawMesh 0x005a0860 / BSPNODE draw walk); or the maxZ1/maxZ2 globals being reconfigured at startup from the registry ('RenderD3D.*' strings near 0x7e5594) so the fail-fill is not invisible in practice. This is THE blocking question for the portal-poly-conditional-draw port and needs a dedicated trace (Ghidra xrefs on MeshBuffer construction + cdb on maxZ1/maxZ2 at runtime).
|
||||
- Does CCellStruct.polygons (the EnvCell shell submitted with planeMask=0xffffffff at pc:427922) include the cell-side portal polygons, or are cell portals (CCellPortal) excluded from the shell the way building portals are excluded from node.Polygons? Determines whether our EnvCell meshes need the same portal-poly separation as building GfxObjs for #109's indoor case.
|
||||
- CShadowPart::insertion_sort's exact key (assumed viewer distance from the UpdateViewerDistance calls at 0x5a17c0/0x59f2bc) and whether CShadowPart::draw itself calls Render::viewconeCheck per part or relies on a BoundingType computed earlier — I did not decompile CShadowPart::draw. Affects only the fidelity note on the entity-cull divergence, not its existence.
|
||||
- The precise fragment source of #108's grass (which terrain triangles produce the sweeping fragments when the eye is below outdoor terrain): terrain inherits the frame's back-face cull (GameWindow.cs:7162-7163, TerrainModernRenderer sets no cull state of its own), so under-surface fragments should be culled; the structural divergences (AABB/8-plane clip slop + clear-after-slices ordering + no fence) are the named suspects, but a RenderDoc capture of one #108 frame is needed to pin which one paints the visible grass.
|
||||
- DrawPortal modes: is there any runtime path that calls build_draw_portals_only / DrawPortal with mode 3 (the fail-fill mode) — e.g. a degrade/option-driven variant of CPhysicsPart::Draw — or is mode 3 dead code in the 2013 client? Ghidra xrefs on the thunk found no third call site, but virtual dispatch may hide one.
|
||||
- Retail's CEnvCell 'GetDrawnThisFrame' guard in DrawEnvCell (0x59f17e) means a cell drawn through multiple views draws its shell only ONCE (first view's clip) — seemingly at odds with the per-view setup_view loop in DrawCells stage 2. Whether the guard is per-view-stamped (reset by setup_view) or genuinely once-per-frame changes how our per-slice shell loop (RetailPViewRenderer.cs:388-393, draws once PER SLICE) should be shaped; not yet traced into SetDrawnThisFrame/num_view interaction.
|
||||
- forceClear (0x8ed824, init 0) and the portalsDrawnCount gate: confirm forceClear is debug/registry-only so the production behavior is exactly 'clear Z iff at least one fence/landscape portal poly drew this frame' — relevant to porting the clear gate faithfully.
|
||||
149
docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md
Normal file
149
docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# AREA 1 — GfxObj draw path (per-part mesh draw, drawing-BSP portal polys, clipping, degrades, viewcone)
|
||||
|
||||
## RETAIL
|
||||
|
||||
RETAIL'S GFXOBJ DRAW IS A FLAT MESH DRAW, NOT A PER-FRAME BSP WALK — WITH ONE EXCEPTION: PORTAL POLYS.
|
||||
|
||||
1. LOAD TIME (the big surprise). CGfxObj::InitLoad (Ghidra 0x005346b0, pc:318765-318789) does two things when running in the live client: (a) BSPTREE::RemoveNonPortalNodes(drawing_bsp) (Ghidra 0x0053a040) — it DELETES every non-portal node from the drawing BSP, leaving only a skeleton chain of portal-bearing nodes; (b) D3DPolyRender::ConstructMesh(this, &constructed_mesh) (wrapper Ghidra 0x0059ea90) which flattens the GfxObj into a D3DX mesh ("constructed_mesh", a MeshBuffer) from the FULL polygon dictionary: ConstructMesh(num_surfaces, m_rgSurfaces, &vertex_array, num_polygons, polygons, 1.0, false, out) (pc:427543). The inner ConstructMesh (Ghidra 0x0059dfa0) iterates ALL CPolygons (stride 0x30, struct acclient.h:31855-31869: vertices/vertex_ids/poly_id/num_pts/stippling/sides_type/pos+neg_uv_indices/pos_surface/neg_surface/plane), batching triangles per pos_surface/neg_surface (pc:426842-426871); I found NO portal-poly or stippling-based skip in it — portal fill quads land in the constructed mesh too. So retail itself is "flatten at load, draw the flat mesh per frame" — the same architecture acdream/WB uses. The runtime drawing BSP exists ONLY to drive portal polygons. EnvCells get the same treatment at CEnvCell::UnPack (ConstructMesh call at pc:311085, Ghidra 0x0052d875, detail-tiling 3.0).
|
||||
|
||||
2. PER-FRAME CHAIN FOR AN ORDINARY OBJECT. CPhysicsPart::Draw(part, mode) (Ghidra 0x0050D7A0, pc:274964): skip if hidden (draw_state & 1) or already drawn this render frame (m_current_render_frame_num == RenderDevice frame stamp; bypassed when mode!=0). Pick LOD: deg_level clamped to degrades->num_degrades else 0; mesh = gfxobj[deg_level]. Set part material, part surface array, object scale, then virtual RenderDevice::DrawMesh(mesh, &draw_pos, mode!=0) (vtable +0x70 = RenderDeviceD3D::DrawMesh, vtable dump pc:1037075). RenderDeviceD3D::DrawMesh (Ghidra 0x005a0860, pc:429245): if Render::PortalList == NULL (plain outdoor view) → one Render::viewconeCheck(gfxobj->drawing_sphere); skip if OUTSIDE. If PortalList != NULL (we are inside a portal-view context — PView::DrawCells sets PortalList=pview at pc:432718, and the per-cell object pass sets PortalList = that cell's view stack at pc:432877) → LOOP over every view: Render::set_view(view, i) (Ghidra 0x0054d0e0, pc:343750), viewconeCheck per view, draw per passing view (building_view filter). viewconeCheck (Ghidra 0x0054c250) is a SPHERE test of the object's drawing_sphere against the CURRENT VIEW's plane set (portal_vertex/portal_npnts installed by set_view): returns OUTSIDE / PARTIALLY_INSIDE / ENTIRELY_INSIDE. Then RenderDeviceD3D::DrawMeshInternal (Ghidra 0x0059f360, pc:427965-428002): non-player parts dedup via GetDrawnThisFrame/SetDrawnThisFrame; if gfxobj->use_built_mesh and mode==0 → D3DPolyRender::DrawMesh(gfxobj, constructed_mesh) (pc:427999 → 0x0059d790 → inner 0x0059d4a0). The inner draw iterates SURFACE BATCHES, routing alpha/translucent/clipmap batches to the deferred AlphaList per s_AlphaDelayMask (config string "Alpha=2, Translucent=4, ClipMap=8", pc:1037103) and hardware-drawing the rest via RenderMeshSubset. CRITICAL DETAIL: with global skipNoTexture (data-section default 1, pc:1105971 @0x00820e30), a surface batch whose CSurface type has neither Base1Image (0x2) nor Base1ClipMap (0x4) — i.e. an UNTEXTURED/SOLID-COLOR surface — is SKIPPED whenever RenderDeviceD3D::ObjBuildingOrBuildingPart==1 (building shell pass) or the cell flag arg is set (Ghidra 0x0059d4a0 at 0x0059d4f1; cell call site passes 1 at pc:427905); plain objects still draw solid batches. There is NO per-polygon geometric clipping anywhere on this mesh path — meshes are sphere-culled per view and hardware-frustum-clipped, exactly as claimed (Q5 CONFIRMED).
|
||||
|
||||
3. BUILDINGS — THE TWO-PASS PORTAL DISCIPLINE (Q2, the door-vanish answer). RenderDeviceD3D::DrawBuilding (Ghidra 0x0059f2a0, pc:427938-427961): set outdoor_pview->outdoor_portal_list = building->portals (CBldPortal list, struct acclient.h:32094); CPhysicsPart::UpdateViewerDistance(shell part) — buildings DO degrade; FlushAlphaList; then CPhysicsPart::Draw(part, 1) — the PORTAL-ONLY draw — then ObjBuildingOrBuildingPart=1; CPhysicsPart::Draw(part, 0) — the shell constructed mesh (with solid batches skipped). The mode-1 path (DrawMeshInternal pc:427988-427996) runs BSPTREE::build_draw_portals_only(drawing_bsp, 1) then (drawing_bsp, 2) (Ghidra 0x00539860; recursion BSPNODE 0x0053c100 / BSPPORTAL 0x0053d870). The walk classifies the VIEWER's eye against each node's splitting plane (dot(N, viewpoint)+d vs ±epsilon → front/back/on, node tags 'PORT'/'LEAF' 0x504f5254/0x4c454146) and, at each BSPPORTAL node (struct acclient.h:57768-57772: num_portals + CPortalPoly** in_portals; CPortalPoly = {portal_index, CPolygon* portal} acclient.h:39075-39079, wired to the SAME polygon dictionary at unpack, BSPPORTAL::UnPackPortal pc:327247), submits each portal poly via vtable DrawPortal (+0x4c, pc:1037066) = RenderDeviceD3D::DrawPortal (Ghidra 0x0059f0e0) → PView::DrawPortal (Ghidra 0x005a5ab0, pc:433895-433933).
|
||||
|
||||
THE GATE — PView::ConstructView(CBldPortal*, CPolygon*, int, pass) (Ghidra 0x005a59a0, pc:433827, Ghidra-confirmed): (a) SIDE TEST: compute which side of the portal polygon's plane the eye is on; CBldPortal::portal_side==0 requires POSITIVE, else NEGATIVE — wrong side ⇒ fail; (b) CLIP TEST: GetClip clips the portal polygon against the CURRENT view (screen frustum or accumulated portal view); fully clipped away ⇒ fail; (c) LOAD/VISIBILITY TEST: CEnvCell::GetVisible(other_cell_id) (Ghidra 0x0052dc10 — a lookup in the global visible_cell_table hash of loaded cells) must return the cell beyond ⇒ else fail; (d) Render::copy_view pushes the new through-portal view ⇒ else fail. ON SUCCESS with pass!=2 it calls D3DPolyRender::DrawPortalPolyInternal(poly, pass==1) and with pass!=1 recurses into the cell beyond. Back in PView::DrawPortal: success & pass!=1 ⇒ PView::DrawCells (draw the interior through the aperture); failure ⇒ draw NOTHING for passes 1/2 (the pass==3 fallback at pc:433914 has no live caller — the only DrawPortal call sites are the BSP walk with passes 1/2, pc:326951/326992).
|
||||
|
||||
WHAT "DRAWING THE PORTAL POLY" ACTUALLY IS: DrawPortalPolyInternal (Ghidra 0x0059bc90, pc:424490) is an INVISIBLE DEPTH-CONTROL DRAW, not a textured draw. With shipped defaults maxZ1=7 / maxZ2=6 (data section pc:1105964-1105965 @0x00820e14/18): vertex alpha computes to 0x00 (fully transparent under SRCALPHA/INVSRCALPHA), depth test ALWAYS, z-write ON; flag=true (pass 1, portal OPENS) forces Z to the far plane (0.99999988) — a "z-punch" that erases the depth buffer inside the aperture so the interior cells drawn in pass 2 land cleanly; flag=false writes the portal plane's OWN depth — a "z-seal" used by PView::DrawCells (Ghidra 0x005a4840, pc:432709-432889) on portals leading OUTSIDE (other_cell_id==0xFFFF, pc:432785-432786) so geometry beyond an unopened exit is depth-occluded. DrawCells also: draws the landscape through outside views (LScape::draw, pc:432719), conditionally CLEARS depth when seals were drawn (portalsDrawnCount, pc:432725-432732), draws cells BACK-TO-FRONT (reverse cell_draw_list) per view via DrawEnvCell, then per cell sets PortalList to the cell's views and draws cell objects (DrawObjCellForDummies, pc:432878). So: doors/windows/stair-apertures are all the SAME mechanism — an open portal punches depth and shows the room; an unopened one draws nothing (building passes) or a z-seal (cell exit portals); the visually-textured "fill" appearance comes only from the constructed mesh, where the skipNoTexture rule decides whether a fill batch (solid vs textured surface) draws at all.
|
||||
|
||||
4. PER-POLY CLIPPING / planeMask (Q3). Only the LEGACY non-built-mesh cell path does exact polygon clipping: RenderDeviceD3D::DrawEnvCell's slow path submits every cell polygon into Render::PolyList with planeMask=0xffffffff (pc:427913-427931). Render::set_view (Ghidra 0x0054d0e0) installs the active view's edge-plane array (portal_vertex, portal_npnts) and portal_inmask = (1<<(npnts+1))-1. ACRender::polyClipFinish (Ghidra 0x006b6d00, pc:702749) shifts the planeMask by (0x1e - npnts) and tests one bit per view plane as it walks them — bit set ⇒ geometrically clip the poly against that plane, bit clear ⇒ skip. planeMask=0xffffffff = "clip against every plane of the current portal view" (the conservative full clip); DrawPortalPolyInternal passes mask 0 (clip nothing — it draws depth-only with DEPTHTEST_ALWAYS, sloppy by design, because the shell mesh drawn afterwards repairs over-punched depth). Constructed-mesh draws never see planeMask.
|
||||
|
||||
5. DEGRADES (Q4). CPhysicsPart::UpdateViewerDistance (Ghidra 0x0050E030) computes eye distance to the part's sort_center and calls GfxObjDegradeInfo::get_degrade(dist/scale, °_level, °_mode) for EVERY part with a degrade table EXCEPT parts of the local player (player iid check ⇒ deg_level=0, full detail). Call sites: per-object draws via CPhysicsObj::UpdateViewerDistance (0x0051827a/0x005182b3), cell-object updates (UpdateObjCell 0x005a0712/0x005a073c), recursive part arrays (0x00510c53), AND DrawBuilding explicitly (0x0059f2bc). So distance LOD applies to building shells, statics, doors, NPCs — everything but the player. CPhysicsPart::LoadGfxObjArray (0x0050DCF0) loads the full gfxobj[] degrade array per part at setup; Draw then indexes gfxobj[deg_level].
|
||||
|
||||
## ACDREAM
|
||||
|
||||
ACDREAM'S EQUIVALENT CHAIN.
|
||||
|
||||
1. FLATTEN AT DECODE (matches retail's load-time ConstructMesh in shape). ObjectMeshManager.PrepareGfxObjMeshData (src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1012) iterates the FULL gfxObj.Polygons dictionary (line 1040) — portal fills included — batching by PosSurface/NegSurface with WB's stippling interpretation (NoPos suppresses the positive side at :1046; negative side per Negative/Both/NoNeg+CullMode at :1052-1058). DIVERGENCE INSIDE THE FLATTEN: solid surfaces are PROMOTED TO VISIBLE 32×32 solid-color textures and drawn (isSolid → TextureHelpers.CreateSolidColorTexture, :1075-1088) — retail SKIPS untextured batches on building shells and cells (skipNoTexture rule). The #113 filter experiment and its revert are memorialized in the comment block at :1020-1039. The EnvCell twin PrepareCellStructMeshData (:1370) flattens cellStruct.Polygons with NoPos/NoNeg suppression (:1393-1402) and the same solid-color promotion (:1430).
|
||||
|
||||
2. DRAW. WbDrawDispatcher.Draw (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:702/:1403) walks landblock entries → entities → MeshRefs: per-landblock AABB frustum cull (:593-595), per-entity AABB frustum cull for statics (:657-665), per-entity classification cache, palette-hash memoization (:983), part-matrix composition (:1056+), then groups into indirect commands — opaque front-to-back sorted (camera position for sort :733, :1175, :1206), transparent after — and submits via glMultiDrawElementsIndirect in CullMode runs with uDrawIDOffset (:1478-1490). Visibility gating beyond the camera frustum is the clip-routing SLOT system (UseIndoorMembershipOnlyRouting / clip-slot resolution :314-:488): each instance is routed to the clip slice of its MEMBERSHIP cell or to OutsideView, or culled when its cell is not visible. There is NO per-portal-view loop, NO per-view sphere re-cull, and no equivalent of retail's PortalList iteration; entities draw at most once per frame against the single camera frustum.
|
||||
|
||||
3. PORTAL POLYS ARE NEVER DRAWN — only used as visibility math. PortalVisibilityBuilder/PortalProjection consume portal polygons to build clip regions and planes (src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:189-194/:413-416, PortalProjection.cs:54, CellVisibility.cs:56-74, GameWindow.cs:5708-5804). RetailPViewRenderer.DrawInside (src/AcDream.App/Rendering/RetailPViewRenderer.cs:44-110) orchestrates the flood (R-A2 per-building floods :118+), draws landscape through outside views, then shells (DrawEnvCellShells :346+, gl_ClipDistance per-slice clip enabled for OUTDOOR roots only — indoor roots draw unclipped pending #114, :399-410), then cell objects unclipped (comment :395-398 — deliberately matching retail's sphere-cull-not-hard-clip for meshes). The retail z-seal hook EXISTS but is DEAD: ctx.DrawExitPortalMasks is invoked (:95/:204/:325-341) yet declared nullable (:497/:534/:558) and never assigned by any production caller (repo-wide grep: no assignment). There is no z-punch (pass-1) equivalent anywhere, and no conditional portal-poly draw of any kind.
|
||||
|
||||
4. DEGRADES. GfxObjDegradeResolver.TryResolveCloseGfxObj (src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:105-143) always returns Degrades[0] (close detail; the doc comment at :56-60 acknowledges no distance plumbing). Its ONLY production call site is GameWindow.cs:2610, gated by _options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup) (GameWindow.cs:2605); the predicate (GameWindow.cs:302-313) matches only 34-part humanoid setups with null-part sentinels. Everything else — buildings, doors, statics, scenery, non-humanoid creatures — renders the BASE GfxObj id forever (WbDrawDispatcher trusts MeshRefs, comment :987-992; the scenery DIDDegrade reads at GameWindow.cs:5424-5428 are diagnostic-only). No per-frame deg_level selection exists.
|
||||
|
||||
5. BUILDING SHELLS are WorldEntities tagged IsBuildingShell hydrated from LandBlockInfo (GameWindow.cs:5258-5267), drawn through the same dispatcher path as any static; EnvCells render through EnvCellRenderer, not as entities (GameWindow.cs:5520-5530 comment, src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs).
|
||||
|
||||
## DIVERGENCES
|
||||
|
||||
### [CRITICAL] portal-poly-conditional-pass-missing (adjusted) — No per-frame portal-poly pass: retail's z-punch / z-seal / ConstructView gate on building+cell portal polys is entirely absent
|
||||
- correctedClaim: CONFIRMED CORE (critical): retail runs a per-frame portal-poly depth pass that acdream entirely lacks. RenderDeviceD3D::DrawBuilding (Ghidra 0x0059f2a0) draws every building in two passes: first a portals-only walk of the load-time-stripped drawing BSP (RemoveNonPortalNodes 0x0053A040 in CGfxObj::InitLoad 0x005346B0; walk via build_draw_portals_only 0x00539860/0x0053c100 and BSPPORTAL::portal_draw_portals_only 0x0053d870, run twice — sub-pass 1 then 2 — from DrawMeshInternal 0x0059f360), submitting each BSPPORTAL.in_portals entry (acclient.h:57768) through RenderDeviceD3D::DrawPortal (0x0059F0E0) -> PView::DrawPortal (0x005A5AB0) -> the PView::ConstructView gate (0x005a59a0: eye-side vs CBldPortal::portal_side, GetClip vs current view, CEnvCell::GetVisible(other_cell_id), copy_view push). Gate success on sub-pass 1 ⇒ DrawPortalPolyInternal(poly,true) = invisible far-plane z-punch (mode maxZ1=7: alpha 0, DEPTHTEST_ALWAYS, z-write, z=0.99999988); success on sub-pass 2 ⇒ view recursion + DrawCells through the portal; failure ⇒ nothing. PView::DrawCells (0x005A4840) seals exit portals (other_cell_id==-1) with DrawPortalPolyInternal(poly,false) = invisible own-depth z-write (mode maxZ2=6). Acdream has zero of this machinery: no z-punch (only ColorMask in src/ is a state restore, GLStateScope.cs:208), DrawExitPortalMasks declared and invoked (RetailPViewRenderer.cs:325-341, :497/:534/:558) but never assigned at either production site (GameWindow.cs:7604-7663, :7781-7798). This is the missing depth discipline feeding #114 and the far-door residuals (#109). CORRECTION (the #113 half is reattributed): the per-frame pass is invisible-by-construction (alpha forced 0 in both default modes), so it does NOT explain doors/windows being visible — retail's visible portal-fill geometry comes from the unconditional flattened shell (ConstructMesh 0x0059ea90 consumes the full polygons array, no per-poly skip), same as acdream's flatten. The doors-visible vs phantom-stair-ramp-invisible split is instead produced by a STATIC retail mechanism the claim denied could exist: D3DPolyRender::DrawMesh (0x0059d4a0) skips untextured mesh subsets (CSurface.type & 6 == 0, i.e. no Base1Image/Base1ClipMap) when skipNoTexture=1 (default) and ObjBuildingOrBuildingPart=1 (set by DrawBuilding around the shell pass). Acdream instead draws those solid-color batches via synthesized 32x32 color textures (ObjectMeshManager.cs:1075-1088). So "no static poly filter can be correct" is wrong as stated: a static per-POLY BSP-reference filter (e46d3d9) is wrong, but retail's own visible-geometry filter is static — per-SURFACE texture presence, building-scoped. Port shape: (1) per-frame ConstructView-gated punch pass + exit-portal own-depth seal, preserving the punch-all-then-draw-interiors two-sub-pass order, never drawing fills visibly from that pass (correct as claimed); (2) ADD the building-scoped untextured-batch skip as the actual #113 visible-geometry fix; (3) pending dat verification that the stair-aperture fills are solid-color while door/window fills are image-textured.
|
||||
- verifier notes: RETAIL SIDE — re-derived from Ghidra (127.0.0.1:8081), every load-bearing branch confirmed against the live decompile, not BN pseudo-C:
|
||||
|
||||
1. CONFIRMED two-pass DrawBuilding (Ghidra 0x0059f2a0): sets outdoor_pview->outdoor_portal_list = building->portals, FlushAlphaList, then CPhysicsPart::Draw(part,1) [portal pass] followed by ObjBuildingOrBuildingPart=1; CPhysicsPart::Draw(part,0) [shell pass].
|
||||
2. CONFIRMED dispatch: CPhysicsPart::Draw (0x0050D7A0) -> render-device vtable+0x70 = RenderDeviceD3D::DrawMeshInternal (0x0059f360): portals_only=true runs BSPTREE::build_draw_portals_only(drawing_bsp,1) THEN (drawing_bsp,2) — TWO sub-passes — and returns without touching the mesh; portals_only=false draws constructed_mesh via D3DPolyRender::DrawMesh.
|
||||
3. CONFIRMED walk: build_draw_portals_only 0x00539860 (BSPTREE) / 0x0053c100 (BSPNODE) / 0x0053d870 (actually named BSPPORTAL::portal_draw_portals_only, minor naming nit). Plane-side recursive walk submits in_portals[i] (BSPPORTAL {num_portals; CPortalPoly** in_portals} verified at acclient.h:57768-57772) via vtable+0x4c = RenderDeviceD3D::DrawPortal (0x0059F0E0) -> PView::DrawPortal(outdoor_pview,...).
|
||||
4. CONFIRMED gate, PView::ConstructView(CBldPortal) (Ghidra 0x005a59a0): (a) eye-side-of-plane (dot(viewpoint,N)+d vs ±F_EPSILON) must match CBldPortal::portal_side (portal_side==0 requires POSITIVE, else NEGATIVE); (b) GetClip against clip_view, null clip ⇒ fail; (c) CEnvCell::GetVisible(other_cell_id) (0x0052DC10) must return a cell; (d) Render::copy_view push onto the cell's portal_view stack. Success: pass!=2 ⇒ DrawPortalPolyInternal(poly, pass==1); pass!=1 ⇒ recurse ConstructView(cell, other_portal_id). PView::DrawPortal (0x005A5AB0): success + pass!=1 ⇒ DrawCells(this,1); failure ⇒ nothing for passes 1/2 (pass 3 ⇒ DrawPortalPolyInternal(poly,false)). Claim's "DrawPortalPolyInternal(poly, pass==1) + DrawCells" is a compression: sub-pass 1 = punch only, sub-pass 2 = view push + recursion + DrawCells (all punches land BEFORE any through-portal interior drawing).
|
||||
5. CONFIRMED z-punch/z-seal semantics, DrawPortalPolyInternal (0x0059bc90): mode word = maxZ1 (arg true) / maxZ2 (arg false); defaults maxZ1=7, maxZ2=6 verified in the data segment (0x00820e18/0x00820e14; pc:1105964-65 region). SetDepthBufferMode(DEPTHTEST_ALWAYS, zwrite=bit2 → on for both); vertex z forced to 0.99999988 (far plane) when bit0 (mode 7) else own z/w (mode 6); alpha bit = ~(mode<<30)&0x80000000 = 0 for BOTH default modes — i.e. both draws are INVISIBLE (alpha-0 SRCALPHA/INVSRCALPHA), depth-only. (poly,true)=far-plane punch, (poly,false)=own-depth seal — exactly as claimed.
|
||||
6. CONFIRMED exit-portal seal, PView::DrawCells (0x005A4840): per cell view, portals with other_cell_id == -1 ⇒ DrawPortalPolyInternal(poly,false). Also clears Z (vtable+0x2c flag 4) when portalsDrawnCount!=0.
|
||||
7. CONFIRMED load-time strip: BSPTREE::RemoveNonPortalNodes (0x0053A040) called from CGfxObj::InitLoad (0x005346B0, xref 0x005346c6), before ConstructMesh builds the flattened shell.
|
||||
|
||||
ACDREAM SIDE — all cited locations verified: ObjectMeshManager.cs:1041 iterates the FULL gfxObj.Polygons dictionary into the flattened batch mesh (e46d3d9 filter reverted, comment block :1020-1040); portal polys otherwise used only as clip math (PortalVisibilityBuilder.cs:186-199, PortalProjection.cs:54-62); DrawExitPortalMasks hook invoked (RetailPViewRenderer.cs:95, :204 -> :325-341) but null-guarded at :331 and NEVER assigned — both production context constructions (GameWindow.cs:7604-7663 DrawInside; :7781-7798 DrawPortal) omit it; repo-wide grep for `DrawExitPortalMasks\s*=` in src/ returns nothing. No z-punch exists: the only ColorMask reference in src/ is a state-restore (Wb/GLStateScope.cs:208); no DepthFunc(Always) draw anywhere.
|
||||
|
||||
THE CORE DIVERGENCE IS REAL AND CRITICAL: retail runs a per-frame, ConstructView-gated, depth-only portal-poly pass (punch + through-portal DrawCells + exit-portal seal) before every building shell draw, and acdream has none of it. The #114 / #109 / outside-looking-in depth-discipline blast radius stands.
|
||||
|
||||
WHY ADJUSTED — the #113 visible-geometry attribution does not survive: (a) DrawPortalPolyInternal NEVER draws visible color at default settings (alpha forced 0 in both modes 7 and 6), so the per-frame pass cannot be what makes doors/windows "usually visible" — it is depth-only by construction. (b) Retail's visible portal-fill geometry comes from the UNCONDITIONAL flattened shell: ConstructMesh(CGfxObj) (0x0059ea90) passes the FULL num_polygons/polygons array (loaded verbatim from the dat by CGfxObj::Serialize 0x00534970), and the low-level builder (0x0059dfa0) has no per-poly skip in its triangle-count loop — every dictionary poly is in the constructed mesh, same as acdream's flatten. (c) The doors-visible vs stair-ramp-invisible split is instead plausibly produced by a STATIC per-surface mechanism the claim missed: D3DPolyRender::DrawMesh (0x0059d4a0; BN pc:426064-426074 agrees) skips any mesh subset whose CSurface lacks the texture-image bits (type & 6 = Base1Image|Base1ClipMap) when skipNoTexture=1 (default, data 0x00820e30) — and the draw-anyway escape requires ObjBuildingOrBuildingPart==0, which DrawBuilding sets to 1 around the shell pass. So untextured (solid-color) batches of a BUILDING shell are never drawn in retail. acdream has no equivalent: ObjectMeshManager.cs:1075-1088 synthesizes a 32x32 solid-color texture for Base1Solid surfaces and draws them. This contradicts the claim's "no static poly filter can be correct": retail itself ships a static building-scoped per-SURFACE (texture-presence) filter for the visible-geometry side; what cannot be correct is e46d3d9's static per-POLY BSP-reference filter. The punch (depth-only, drawn BEFORE the shell) cannot hide an opaque textured fill drawn afterward, so the conditional pass mechanically CANNOT explain door/window visibility — only the surface-type skip can explain ramp invisibility.
|
||||
|
||||
PORT-SHAPE IMPACT: the claimed port (per-frame ConstructView-gated punch pass + DrawExitPortalMasks as own-depth seal, never drawing fills visibly from that pass) remains correct and necessary for depth discipline, with two amendments: preserve retail's two-sub-pass ordering (all punches, then all through-portal interior draws), and ADD the building-scoped untextured-batch skip (skipNoTexture / type&6 / ObjBuildingOrBuildingPart equivalent) as the actual #113 fix for visible geometry.
|
||||
|
||||
OPEN QUESTION (falsifiable test of the corrected #113 attribution): dat-verify that the meeting-hall stair-aperture fill polys {0,1} reference solid-color (no Base1Image/Base1ClipMap) surfaces while the Holtburg door/window fill quads reference image-textured surfaces — extend the e223325 audit with surface types. Also unresolved: precisely which polys e46d3d9's filter removed that the user perceived as "doors disappearing" (door-frame polys on building models vs door-panel polys on door-weenie GfxObjs) — the filter applied to ALL GfxObjs, not just buildings.
|
||||
- blastRadius: This is the door-vanish ↔ phantom-staircase pair (#113): acdream draws ALL portal fill quads unconditionally as textured/solid-colored mesh geometry (phantom stair ramps visible; e46d3d9's static filter removed doors/windows too and had to be reverted 124c6cb — the dat side proved in e223325 that no static filter can be right). It is also the missing depth discipline for outside-looking-in: without the pass-1 z-punch and the pass-2 through-portal interior draw being fenced by ConstructView's gate, and without the z-seal on exit portals, interiors/exteriors rely on acdream's separate flood plumbing alone — feeding #114 (indoor clip-region quality) and the residual far-door artifacts (#109).
|
||||
- retailEvidence: DrawBuilding two-pass: portal-only BSP walk then shell mesh (Ghidra 0x0059f2a0, pc:427938-427961). Walk: BSPTREE/BSPNODE/BSPPORTAL::build_draw_portals_only (Ghidra 0x00539860/0x0053c100/0x0053d870) submits BSPPORTAL.in_portals (acclient.h:57768-57772) via RenderDeviceD3D::DrawPortal (pc:1037066) → PView::DrawPortal (0x005a5ab0, pc:433895). GATE = PView::ConstructView(CBldPortal) (Ghidra 0x005a59a0, Ghidra-confirmed): viewer-side-of-plane vs CBldPortal::portal_side, GetClip against current view, CEnvCell::GetVisible(other_cell_id) (0x0052dc10), copy_view. Success ⇒ DrawPortalPolyInternal(poly, pass==1) + DrawCells; failure ⇒ nothing. DrawPortalPolyInternal (0x0059bc90) with defaults maxZ1=7/maxZ2=6 (pc:1105964-65) = alpha-0 DEPTHTEST_ALWAYS z-write draw: far-plane punch (open) or own-depth seal (exit portals, DrawCells pc:432785-432786). Drawing BSP is stripped to the portal skeleton at load (RemoveNonPortalNodes, 0x0053a040, in InitLoad pc:318775).
|
||||
- acdreamEvidence: Portal polys flow into the flattened mesh and draw unconditionally (ObjectMeshManager.cs:1040-1058); portal polygons are otherwise used only as visibility-clip math (PortalVisibilityBuilder.cs:189-194, PortalProjection.cs:54); the z-seal hook DrawExitPortalMasks is invoked but never implemented (RetailPViewRenderer.cs:325-341, nullable at :497/:534/:558, no production assignment repo-wide); no z-punch exists at all.
|
||||
- portShape: Keep the flatten (it is retail-faithful) but EXCLUDE nothing statically. Add a per-building per-frame portal pass before the shell entity draws: walk the dat DrawingBSPNode.Portals skeleton (or just the building's CBldPortal list — the BSP walk only orders submissions), and per portal poly run the ported ConstructView gate (eye-side vs portal_side, clip against the current view region, cell-beyond loaded/visible, view push). On success render the portal poly as an invisible far-plane depth punch (depth ALWAYS, depth-write on, color mask off) and credit the through-portal flood; never draw fills as visible geometry from this pass. Implement DrawExitPortalMasks as the own-depth z-seal for portals with other_cell_id==0xFFFF. This unifies doors, windows, and the hall stair apertures under one mechanism, matching the e223325 mandate.
|
||||
|
||||
### [HIGH] solid-surface-skip-missing (adjusted) — Building/cell mesh draw renders untextured (solid) surface batches that retail skips (skipNoTexture rule)
|
||||
- correctedClaim: CONFIRMED MECHANISM, CONDITIONAL BLAST RADIUS: Retail's D3DPolyRender::DrawMesh (0x0059d4a0, gate asm 0x0059d4e0-0x0059d50c) skips any surface batch with (CSurface.type & 6)==0 (no BASE1_IMAGE/BASE1_CLIPMAP, acclient.h:5820) when skipNoTexture (global 0x00820e30, data default 1) is on AND the draw is a building shell (ObjBuildingOrBuildingPart=1, set only around DrawBuilding's second/mesh pass at 0x0059f322) or an EnvCell built-mesh draw (DrawEnvCell pushes 1 at 0x0059f20d); plain objects keep their solid batches (2-arg overload 0x0059d790 pushes 0). acdream has no such skip — ObjectMeshManager.cs:1075-1088 and :1429-1442 promote solid surfaces to visible 32x32 solid-color textures and draw them unconditionally. The divergence is REAL (severity: high as a mechanism, but its user-visible instances are unpinned). CORRECTIONS: (1) attributing the visible body of #113 stair ramps / door slabs to this rule is unverified — the e223325 audit never recorded whether those portal-fill polys are solid vs textured; settle that dat question first; (2) because ALL those fills are conditionally-drawn portal polys, porting the static skip ALONE would reproduce the e46d3d9 door-vanish regression if the fills are solid — sequence it with the conditional portal-poly pass, never as a standalone "drop from flatten"; (3) the port predicate must be absence-of-(IMAGE|CLIPMAP), not acdream's isSolid (which conflates NoPos stippling); (4) the skip is only proven for the built-mesh path — DrawEnvCell's raw-poly fallback (pc:427922) was not verified to skip, though acdream's pipeline corresponds to the built-mesh path anyway.
|
||||
- verifier notes: RETAIL SIDE — verified at raw-assembly level via Ghidra (not BN pseudo-C), and it checks out exactly as claimed:
|
||||
|
||||
1. The gate in D3DPolyRender::DrawMesh (4-arg overload, 0x0059d4a0). Disassembly at 0x0059d4e0–0x0059d50c: MOV EAX,[0x00820e30] (skipNoTexture); JZ 0x0059d516 (skip disabled → draw); MOV EAX,[EBP+ESI*4]; TEST byte ptr [EAX+0x58],0x6; JNZ 0x0059d516 (surface textured → draw); MOV EAX,[0x008ed3c4] (ObjBuildingOrBuildingPart); JNZ 0x0059d58e (→ loop increment = SKIP the batch); MOV AL,[ESP+0x2c] (4th arg, cell flag); JNZ 0x0059d58e (SKIP); else MOV [0x00820e30],0x1 and fall into the draw path at 0x0059d516. So: skip iff skipNoTexture!=0 AND (CSurface.type & 6)==0 AND (building-flag OR cell-flag). The odd-looking re-assert `skipNoTexture=1` in the draw-anyway branch is real in the asm but semantically inert (the global was already nonzero to reach it). Both decompilers show the same structure — no invented branch here.
|
||||
2. type&6 semantics: acclient.h:5820 SurfaceType enum — BASE1_SOLID=0x1, BASE1_IMAGE=0x2, BASE1_CLIPMAP=0x4. (type&6)==0 = neither image nor clipmap = solid-color-only. (CSurface is only forward-declared in acclient.h:342; offset 0x58=type comes from BN's typed pseudo-C at pc:426064-426074 — acceptable since the bit semantics are anchored by the enum.)
|
||||
3. Default-on: data section at 0x00820e30 `int32_t skipNoTexture = 0x1` (pc:1105971 block, dumped).
|
||||
4. Cell path: DrawEnvCell (0x0059f170) calls the 4-arg DrawMesh with PUSH 0x1 at 0x0059f20d → CALL 0x0059d4a0 at 0x0059f212 (pc:427905 region) — but only on the use_built_mesh path; the use_built_mesh==0 fallback submits raw polys with planeMask=0xffffffff through the poly list (pc:427922) and I did NOT verify an equivalent skip there. Built-mesh is the production path and is what acdream's batching corresponds to, so the comparison stands.
|
||||
5. Building path: RenderDeviceD3D::DrawBuilding (0x0059f2a0, pc:427956 region) sets ObjBuildingOrBuildingPart=1 at 0x0059f322 BETWEEN CPhysicsPart::Draw(part,1) and Draw(part,0), clearing at 0x0059f33b. Important nuance the claim glossed: the first Draw (flag still 0) is the PORTALS-ONLY pass (DrawMeshInternal 0x0059f360 → BSPTREE::build_draw_portals_only(drawing_bsp,1/2) at 0x0059f3ca/0x0059f3d7); the second Draw (flag=1) is the constructed-mesh shell draw — that's where the skip bites.
|
||||
6. Plain objects draw solid batches: the 2-arg DrawMesh overload (0x0059d790) forwards with PUSH 0x0 as the 4th arg (0x0059d7aa), and Ghidra xrefs show the ONLY callers of 0x0059d4a0 are the 2-arg overload (0x0059d7b2) and DrawEnvCell (0x0059f212). Outside DrawBuilding's second pass the global is 0, so ordinary GfxObjs keep their solid batches. Confirmed.
|
||||
|
||||
ACDREAM SIDE — confirmed: src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1075-1088 (PrepareGfxObjMeshData, used for building shells) and :1429-1442 (PrepareCellStructMeshData, the EnvCell path — call sites :519/:607/:1350) both compute `isSolid` and promote the surface to a visible 32×32 solid-color texture via TextureHelpers.CreateSolidColorTexture (src/AcDream.Core/Rendering/Wb/TextureHelpers.cs:6), batching it for unconditional draw. Repo-wide grep for Base1Solid/CreateSolidColorTexture/skipNoTexture confirms NO building- or cell-scoped untextured-skip exists anywhere: WbDrawDispatcher.cs uses IsBuildingShell only at :610/:639 (unrelated scoping), EnvCellRenderer.cs has no surface-type logic, and TextureCache.cs:394-400 does the same promote-to-solid-texture on its path. Anchor correction: IsBuildingShell is GameWindow.cs:5265 (claim said 5266), defined at WorldEntity.cs:65, tagged at LandblockLoader.cs:86.
|
||||
|
||||
WHY ADJUSTED, NOT CONFIRMED — two material corrections:
|
||||
(a) The blast-radius attribution to #113 phantom stairs and door-aperture slabs is UNPROVEN. The e223325 audit (tests/.../Issue113DoorVanishDiagnosticTests.cs) proved every orphan poly is a DrawingBSPNode.Portals PortalRef but recorded NO surface-type data (grep for Solid/SurfaceType/Stippling in that test: zero hits; no session dump captures it either). Whether those fill quads are solid or textured is exactly the claim's own open question 1 — so "likely visible body of #113" is a hypothesis, not a finding. If the fills are textured, this rule has nothing to do with #113 and the blast radius shrinks to whatever genuinely-solid shell/cell surfaces exist (unquantified).
|
||||
(b) The proposed "simpler" port shape (drop solid batches from the flatten for shell/cell meshes) is NOT safe in isolation — it can REPRODUCE the e46d3d9 door regression. Per e223325, ALL the orphan fills (doors AND stairs) are portal polys drawn conditionally by retail's portal pass (build_draw_portals_only, the flag==0 first pass of DrawBuilding). If the fills turn out solid, retail's shell-mesh pass skips them ALL and their retail visibility ("doors usually visible") comes solely from the conditional portal pass — so shipping the static skip before the portal pass exists would vanish doors again. The skip must land WITH or AFTER the conditional portal-poly pass.
|
||||
(c) Predicate precision: a faithful port must skip on ABSENCE of (BASE1_IMAGE|BASE1_CLIPMAP) ((type&6)==0), not on presence of Base1Solid, and must not reuse acdream's `isSolid` (which conflates NoPos stippling at :1075/:1429 — a NoPos-stippled poly with a textured surface would be wrongly skipped).
|
||||
- blastRadius: Any building-shell or EnvCell polygon whose surface is solid-color renders in acdream as an opaque colored quad where retail shows nothing — the likely visible body of the #113 phantom stair ramps and of door-aperture slabs (the fills users perceived as 'doors'); also the reason a faithful portal pass ALONE won't fix #113: even with the gate ported, the flattened solid fills would still draw.
|
||||
- retailEvidence: D3DPolyRender::DrawMesh inner loop (Ghidra 0x0059d4a0 at 0x0059d4f1, pc:426064-426074): when skipNoTexture != 0 (data-section default 1, pc:1105971 @0x00820e30) a surface batch with (CSurface.type & 6)==0 (no Base1Image, no Base1ClipMap) is skipped if RenderDeviceD3D::ObjBuildingOrBuildingPart==1 (set during the shell pass, pc:427956) or the cell-draw flag is passed (DrawEnvCell passes 1, pc:427905); plain objects still draw solid batches.
|
||||
- acdreamEvidence: PrepareGfxObjMeshData and PrepareCellStructMeshData promote solid surfaces into visible 32×32 solid-color textures and batch them for unconditional draw (ObjectMeshManager.cs:1075-1088 and :1430); no building/cell-scoped surface-type skip exists anywhere in the dispatcher or EnvCellRenderer.
|
||||
- portShape: Tag batches at decode with 'is building shell / is cell geometry' (already known: IsBuildingShell GameWindow.cs:5266, cell path PrepareCellStructMeshData) and 'surface is untextured' (neither Image nor ClipMap), then skip those batches at draw — or simpler, drop them from the flatten for building-shell and cell-struct meshes only (equivalent because the retail rule is per-surface, static, and default-on). Must NOT apply to ordinary objects (retail draws their solid batches). Verify first against the dat which fills are solid vs textured (open question 1).
|
||||
|
||||
### [MEDIUM] degrade-lod-scoped-to-humanoids (confirmed) — Distance LOD (deg_level) exists only as a static Degrades[0] swap for 34-part humanoids; retail degrades every non-player part per frame
|
||||
- correctedClaim: Claim stands as written, with one citation label tightened: the call sites 0x0051827a/0x005182b3 are inside the two CPartArray::UpdateViewerDistance overloads (0x00518260/0x00518290) — the per-part loop owned by CPhysicsObj — rather than functions literally named on CPhysicsObj. Substance unchanged. One strengthening addendum: retail's LoadGfxObjArray excludes the base GfxObj from the render array entirely when a degrade table exists, and INVALID_DID degrade entries become null slots that suppress drawing beyond a distance band (DrawBuilding gates the whole shell draw on gfxobj[deg_level] != NULL) — so the divergence also covers "objects/buildings that should vanish or simplify at distance never do," not just perf and close-detail silhouettes.
|
||||
- verifier notes: RETAIL side re-derived entirely from Ghidra decompiles (port 8081), not BN pseudo-C. (1) CPhysicsPart::UpdateViewerDistance @ 0x0050E030: computes distance from Render::viewer_pos to the part's scaled sort_center every call, stores it in CYpt; if this->degrades != NULL and the owning physobj's IID != player_iid, calls GfxObjDegradeInfo::get_degrade(CYpt / gfxobj_scale.z, °_level, °_mode); otherwise pins deg_level=0/deg_mode=1; then runs calc_draw_frame only if gfxobj[deg_level] != NULL. Exactly as claimed, including the player_iid exemption. (2) Caller xrefs match the claim verbatim: 0x0051827a/0x005182b3 (inside the two CPartArray::UpdateViewerDistance overloads @ 0x00518260/0x00518290 — a per-part loop; the claim labeled these "CPhysicsObj update paths," a trivial naming imprecision since CPartArray is CPhysicsObj's part container), 0x005a0712/0x005a073c inside RenderDeviceD3D::UpdateObjCell @ 0x005a0690, 0x00510c53 in UpdateViewerDistanceRecursive, and 0x0059f2bc inside RenderDeviceD3D::DrawBuilding @ 0x0059f2a0. (3) UpdateObjCell is the per-frame proof: called from DrawObjCellForDummies and DrawBlock (the render walk), it iterates the cell's shadow_object_list calling CPhysicsObj::UpdateViewerDistance on EVERY object, with a near/far split at MAX_CELL_2D_DEGRADE_DISTANCE (near = exact per-object distance, far = shared cell distance via the float/Vector3 fast-path overload — a useful port detail). (4) DrawBuilding re-picks the shell LOD first thing each draw and gates the ENTIRE building draw on gfxobj[deg_level] != 0 — stronger than claimed: buildings can legitimately skip drawing at far LOD. (5) CPhysicsPart::Draw @ 0x0050D7A0 indexes gfxobj[deg_level] with clamp-to-0 when degrades==NULL || num_degrades <= deg_level, exactly as claimed. (6) LoadGfxObjArray @ 0x0050DCF0 preloads one CGfxObj* per degrade entry; notably, when a degrade table exists the BASE GfxObj id is NOT in the render array at all (it is loaded only to discover the degrade DID), and INVALID_DID entries become null slots = draw-nothing distance bands — retail objects can intentionally vanish past a distance, which acdream can never reproduce. ACDREAM side verified by reading production code: GfxObjDegradeResolver.cs:105-143 always returns Degrades[0] (explicit ack at :56-60 "always returns slot 0... far-distance LOD is a future concern"); the resolver's sole production call site is GameWindow.cs:2610 inside the `_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup)` gate at :2605 (predicate :302-312: 34 parts + >=8 null-sentinel 0x010001EC slots in 17-33; RetailCloseDegrades is default-on per RuntimeOptions.cs:76-79, so humanoids DO get the swap in production); WbDrawDispatcher.cs:987-993 trusts MeshRefs and never re-picks ("We trust MeshRefs as the source of truth here"); ObjectMeshManager.cs carries DIDDegrade only as write-only metadata (:700, :726, :1274, :1896) — the only reads anywhere in src/ are point-sprite-mode checks (Degrades[0].DegradeMode == 2) in ParticleEmitterRenderer.cs:81-86 and ParticleRenderer.cs:400-414, plus a [scenery-z] diagnostic dump at GameWindow.cs:5424-5459 — none constitute a distance-LOD mechanism. Door claim verified via commit e223325 message: doors (setup 0x020019FF) don't take the humanoid swap and render base ids; base + every degrade variant have full BSP coverage, so nothing in #113/#114 depends on this item, matching the port-shape claim. The divergence is REAL (no behaviorally-equivalent mechanism exists anywhere in acdream), the severity (medium) and blast radius (perf; conditional wrong silhouettes for non-humanoid degrade-table models whose base id is the low-detail variant; latent correctness gap for the holistic port since DrawBuilding re-picks shell LOD per frame) are accurate and if anything slightly understated: retail's null-degrade-slot "vanish at distance" behavior and the building-draw-skip gate are correctness consequences acdream's always-draw-base approach cannot reproduce. Port shape is sound, with two refinements from the decompiles: retail picks deg_level per PART (each part has its own pos/sort_center), not per entity — per-entity is an acceptable first approximation but should be stated as such; and retail's far-cell fast path (MAX_CELL_2D_DEGRADE_DISTANCE shared-distance overload) is the retail-faithful way to keep the per-frame cost bounded.
|
||||
- blastRadius: No named open issue; consequences are (a) perf — full-detail meshes drawn at all distances for every static/building/creature, (b) wrong silhouettes for any non-humanoid model whose base GfxObj id is the LOW-detail variant (the #47 bulky-mesh class — fixed for humanoids only), (c) a latent correctness gap for the holistic port since retail's DrawBuilding explicitly re-picks the shell LOD per frame.
|
||||
- retailEvidence: CPhysicsPart::UpdateViewerDistance (Ghidra 0x0050E030): per-frame GfxObjDegradeInfo::get_degrade(distance/scale) for every part with a degrade table EXCEPT the local player (player_iid check ⇒ deg_level=0); called from CPhysicsObj update paths (0x0051827a/0x005182b3), cell-object update (0x005a0712/0x005a073c), recursive parts (0x00510c53) and DrawBuilding itself (0x0059f2bc); CPhysicsPart::Draw indexes gfxobj[deg_level] with clamp (0x0050D7A0); LoadGfxObjArray (0x0050DCF0) preloads the array.
|
||||
- acdreamEvidence: GfxObjDegradeResolver always returns Degrades[0] (GfxObjDegradeResolver.cs:105-143, ack at :56-60); sole production call gated by IsIssue47HumanoidSetup (GameWindow.cs:2605-2624, predicate :302-313); dispatcher trusts MeshRefs and never re-picks (WbDrawDispatcher.cs:987-992); doors/statics/buildings render base ids (confirmed for the door setup 0x020019FF in e223325).
|
||||
- portShape: Move degrade resolution from spawn-time id swap to a per-entity-per-frame deg_level pick: preload all degrade slots' meshes into the global buffer at registration (retail LoadGfxObjArray analog), compute viewer distance per entity in the dispatcher walk (it already has camera position for the sort), select the MeshRefs slot via a ported get_degrade. Local player pinned to slot 0. Can ship after the critical items; nothing in #113/#114 depends on it (e223325: door degrade chains have full BSP coverage).
|
||||
|
||||
### [MEDIUM] no-per-view-entity-pass (adjusted) — Entities are culled once against the camera frustum + membership slot; retail re-culls each object's sphere against EVERY active portal view
|
||||
- correctedClaim: CONFIRMED CORE, with corrections: acdream culls entities once against the camera frustum (AABB) plus cell-granular gates (visibleCellIds membership + U.4 clip-slot routing), and on the indoor cell-object path (RetailPViewRenderer.DrawCellObjectLists → DrawEntityBucket with neverCullLandblockId=player LB) even the frustum cull is bypassed — indoor cell objects draw with NO geometric cull. Retail re-culls each object part's drawing_sphere against EVERY active portal view: PView::DrawCells sets Render::PortalList = &pview->outside_view for the landscape pass and = cell->portal_view[num_view-1] per drawn cell before DrawObjCellForDummies → DrawPartCell (per-cell shadow_part_list); RenderDeviceD3D::DrawMesh (0x005a0860) loops view_count views (filtered by building_view), set_view + viewconeCheck(drawing_sphere) per view, returning OUTSIDE_VIEWCONE_ODS undrawn when all views fail; DrawMeshInternal (0x0059f360) dedups multi-view/multi-cell submission via CPhysicsPart::GetDrawnThisFrame (player parts exempt). BLAST-RADIUS CORRECTION: only the over-draw direction (membership cell visible, sphere outside every view) is explained by this divergence; the under-draw direction (object poking through an aperture from a non-visible membership cell) requires retail's per-cell shadow-part registration (multi-cell membership), which the sphere-vs-plane port alone does not provide — that half belongs to the A6.P4 per-cell shadow architecture gap. Citation fixes: the acdream "unclipped" comment is RetailPViewRenderer.cs:370-372/:441-450, not :395-398; GetDrawnThisFrame is 0x0050d4d0/0x0052c0c0 (0x0059f360 is DrawMeshInternal, its caller).
|
||||
- verifier notes: RETAIL side — re-derived entirely from Ghidra decompiles (not BN pseudo-C):
|
||||
|
||||
1. RenderDeviceD3D::DrawMesh (Ghidra 0x005a0860): CONFIRMED verbatim. When `Render::PortalList == NULL` it does ONE `viewconeCheck(gfxobj->drawing_sphere)`; when `PortalList != NULL` it loops `PortalList->view_count` views, per view calling `Render::set_view(&PortalList->view, i)` then `Render::viewconeCheck(param_1->drawing_sphere)`. A view returning OUTSIDE increments a counter; if ALL views are OUTSIDE the function returns OUTSIDE_VIEWCONE_ODS without drawing. One nuance the claim omitted: a `(building_view == -1 || building_view == i)` filter restricts the loop to a single view index during building draws.
|
||||
|
||||
2. Render::viewconeCheck (Ghidra 0x0054c250): CONFIRMED — transforms the sphere to viewer space, tests against `viewer_world_space.CY` (near plane) then `portal_npnts` planes at `portal_vertex`, returning OUTSIDE on any plane with dist < -radius. Render::set_view (Ghidra 0x0054d0e0) is what loads `portal_npnts`/`portal_vertex` (plus xmin/xmax/ymin/ymax scissor bounds) from the active view's `view_poly` — so viewconeCheck genuinely tests the per-view portal plane set.
|
||||
|
||||
3. PView::DrawCells (Ghidra 0x005a4840): CONFIRMED with one precision fix — for landscape it sets `Render::PortalList = &this->outside_view` (the portal_view_type at PView offset 0, acclient.h:45936; BN pc:432718 prints the raw `this` pointer because of the zero offset), then `LScape::draw`. The final loop (pc:432877 region, addr 0x005a4b07-0x005a4b0d) sets `Render::PortalList = cell->portal_view.data[cell->num_view-1]` per drawn cell and calls vtable DrawObjCellForDummies(cell). Traced onward: DrawObjCellForDummies (0x005a0760) → DrawObjCell (0x005a1a40) → DrawPartCell (0x005a07a0) iterates the cell's `shadow_part_list` calling CShadowPart::draw — so cell objects ARE drawn under the per-cell accumulated view list and re-culled per view in DrawMesh.
|
||||
|
||||
4. Dedup: CONFIRMED but mis-cited — 0x0059f360 is RenderDeviceD3D::DrawMeshInternal, which CONTAINS the dedup (`CPhysicsPart::GetDrawnThisFrame` early-return + `SetDrawnThisFrame`, player parts exempt via IsPartOfPlayerObj); GetDrawnThisFrame itself is at 0x0050d4d0/0x0052c0c0. Multi-view and multi-cell submission draws once. Substance correct.
|
||||
|
||||
ACDREAM side — read from production code:
|
||||
|
||||
5. WbDrawDispatcher.cs:657-666: CONFIRMED — single camera-frustum AABB cull per entity (`FrustumCuller.IsAabbVisible`), bypassed for animated entities and for `entry.LandblockId == neverCullLandblockId`. Cell-granular gates: `EntityPassesVisibleCellGate` (:1816-1835, ParentCellId ∈ visibleCellIds) and U.4 clip-slot routing `SetClipRouting`/`ResolveEntitySlot`/`ResolveSlotForFrame` (:324-331, :425-489). No sphere-vs-view-plane test exists anywhere on the entity path (grep for viewcone in src/ hits only comments describing retail).
|
||||
|
||||
6. RetailPViewRenderer.cs: citation drift — the "deliberately unclipped" rationale is at :370-372 and :441-447 (inside UseIndoorMembershipOnlyRouting, which calls `_entities.ClearClipRouting()` at :449), not :395-398 (:396-398 is the clip-disable loop). Substance correct: DrawCellObjectLists (:401-426) draws each visible cell's entity bucket with clip routing cleared.
|
||||
|
||||
7. STRENGTHENING finding the claim missed: DrawEntityBucket (:460-477) passes `neverCullLandblockId: ctx.PlayerLandblockId` while tagging the bucket with `lbId = ctx.PlayerLandblockId ?? 0u` — so indoor cell objects on the player's landblock skip even the camera-frustum AABB cull. The indoor cell-object path currently has NO geometric cull at all, only the cell-granular membership gate.
|
||||
|
||||
8. MISATTRIBUTION in the blast radius: the "vice versa" direction (object poking through an aperture from a non-visible membership cell still draws in retail) is produced by retail's per-cell shadow-PART registration (DrawPartCell iterates each cell's shadow_part_list; an object straddling a portal has parts registered in BOTH cells, deduped by GetDrawnThisFrame), NOT by the per-view sphere re-cull. acdream's single ParentCellId membership cannot replicate it, and the proposed sphere-vs-plane port alone will not close that half — it belongs to the per-cell shadow architecture gap (A6.P4 debt). The first direction (membership cell visible but sphere outside every view → drawn in acdream, culled in retail) is fully explained by the claimed mechanism and is real.
|
||||
|
||||
Port shape: plausible as stated (CPU sphere-vs-plane loop against per-cell accumulated view planes, draw once on first passing view); ClipFrameAssembly.CellIdToViewSlices (RetailPViewRenderer.cs:428-437) confirms per-cell view-plane data exists. Note retail additionally carries per-view 2D scissor bounds (set_view xmin/xmax/ymin/ymax) which the sphere-cull port does not replicate; and the acdream comment's rationale that retail does not hard-clip meshes per view is consistent with the decompile (DrawMeshInternal's BoundingType param is unused for built meshes). Severity medium is fair: visible artifact class at portal boundaries, contributes to #109, entity-side half of one-gate discipline.
|
||||
- blastRadius: Object pop/visibility mismatches at portal boundaries seen through doorways (an object whose membership cell is visible but which lies outside every portal view draws in acdream, not in retail; and vice versa for objects poking through an aperture from a non-visible cell). Contributes to the residual far-door artifact class (#109) and is the entity-side half of the one-gate discipline; particles share the 'no view gate' hole (particles-through-walls is the named bug; ParticleRenderer is outside this area's scope).
|
||||
- retailEvidence: RenderDeviceD3D::DrawMesh portal-list branch (Ghidra 0x005a0860): when Render::PortalList != NULL it loops view_count views, set_view + viewconeCheck(drawing_sphere) per view (viewconeCheck Ghidra 0x0054c250 tests the sphere against the active view's portal planes); PView::DrawCells sets PortalList=pview for landscape (pc:432718) and per-cell views for cell objects (pc:432877); per-frame dedup via GetDrawnThisFrame keeps multi-view submission single-draw (0x0059f360).
|
||||
- acdreamEvidence: WbDrawDispatcher: single camera-frustum AABB cull per entity (WbDrawDispatcher.cs:657-665) plus membership-cell slot routing/culling (:314-488); no per-view sphere re-cull; cell objects deliberately drawn unclipped (RetailPViewRenderer.cs:395-398 comment).
|
||||
- portShape: Per drawn cell, test each member entity's bounding sphere against that cell's accumulated view planes (the data already exists in ClipFrameAssembler slices) before adding its instances — a CPU sphere-vs-plane loop in the partition step, not a GPU change. Retail-faithful = test against the view's plane set, draw once on first pass.
|
||||
|
||||
### [MEDIUM] stippling-semantics-divergence (confirmed) — WB's NoPos/NoNeg = 'don't draw this side' interpretation is not visible in retail's ConstructMesh; retail treats stippling>0 as a batch FLAG and sides via sides_type
|
||||
- verifier notes: RETAIL re-derived from Ghidra decompiles (BN pseudo-C used only for line citations, cross-checked): (1) CPolygon::UnPack (Ghidra 0x00538650, pc:322296) reads pos_uv_indices only when (stippling & 4)==0 and neg_uv_indices only when sides_type==2 && (stippling & 8)==0; pos_surface/neg_surface always read; sides_type==1 aliases neg_surface=pos_surface and neg_uv=pos_uv. NoPos/NoNeg are dat-stream UV-PRESENCE flags, not draw suppressors. (2) D3DPolyRender::ConstructMesh inner (Ghidra 0x0059dfa0; emit/count loop pc:426842-426909) counts and emits triangles purely from sides_type (0=pos once, 1=pos doubled same surface, 2=pos+neg into separate surface batches) with an explicit 'uv_indices==null -> uv index 0' fallback in the emit loop — so NoPos/NoNeg sides ARE still emitted; the ONLY stippling use is batchFlagByte[pos_surface] |= (stippling>0) (Ghidra 0x0059e1c3 = pc:426868), confirming 'stippling>0 is a batch flag'. (3) D3DPolyRender::SetSurface (Ghidra 0x0059d650, pc:426157-426189) maps bit0->POSITIVE-side stippled bool, bit1->NEGATIVE-side stippled bool, as claimed. (4) ConstructMesh is the real runtime path: CGfxObj::InitLoad (Ghidra 0x005346b0, pc:318781) builds constructed_mesh from the FULL polygon array via the wrapper at 0x0059ea90 (pc:427540-427543) and sets use_built_mesh=1; an EnvCell-side caller at 0x0052d87a (pc:311085) feeds cell geometry through the same inner function; the immediate fallback DrawPolyInternal (Ghidra 0x0059d7c0) also has zero bit-4/8 checks (bit0->stippled, cull from sides_type: ==1 -> CULLMODE_NONE else CULLMODE_CW). (5) Exhaustive grep of all 15 'stippling' hits in the 1.4M-line pseudo-C found no other draw-path consumer (remainder: ctor/Pack/UnPack init+presence bits, terrain CLandBlockStruct::ConstructUVs writing 3 at pc:316764, which is terrain-only and outside this claim). ACDREAM verified at src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs: :1046 skips pos side on NoPos; :1052-1058 gates neg side on Negative||Both||(!NoNeg && SidesType==Clockwise); cell path :1394-1404 suppresses both sides on NoPos/NoNeg with an in-comment retail assertion ('still suppress hidden portal/cap faces') that has no retail citation and is contradicted by the UnPack+ConstructMesh evidence above. Enum values pinned by reflection on the referenced package (Chorizite.DatReaderWriter 2.1.7, net48 dll): CullMode Landblock=0/None=1/Clockwise=2/CounterClockwise=3 maps onto retail sides_type 0/1/2, so the sides_type gates themselves (None->double, Clockwise->neg) are retail-correct; StipplingType NoPos=4/NoNeg=8 match retail bit positions. Behavioral-equivalence check I ran to try to refute: the GfxObj path's missing sides_type==1 pos-side doubling (cell path doubles at :1399-1401, GfxObj path does not) is MASKED at draw time — batches carry CullMode=poly.SidesType (:1251,:1596) and WbDrawDispatcher.ApplyCullMode disables face culling for CullMode.None (WbDrawDispatcher.cs:1495-1517, :1504-1505) — so one emission shows both faces; correctly NOT part of the divergence. What survives as REAL divergence: (a) NoPos suppression of the pos side (:1046, :1394/:1397) — retail emits it with UV fallback 0; (b) NoNeg suppression of the neg side on sides_type==2 (:1054, :1395/:1402) — retail emits it; (c) BONUS beyond the claim: stipple bits 0/1 misread as 'neg side exists' (:1052-1053) adds phantom negative-side faces on sides_type!=2 polys carrying those bits, and the per-side stippled-translucency render flag retail derives from bits 0/1 is entirely unimplemented (acdream instead maps NoPos->solid-color texture at :1075/:1429 — also not retail). Honest residual (data-level, could not settle from code): whether NoPos/NoNeg-flagged sides in the actual Holtburg model set are ever VISIBLE in retail (e.g. if they correlate with the e223325 conditional portal polys, suppression could be coincidentally invisible — but the conditional mechanism there is BSP-portal-based, not stippling-based, so WB's stippling interpretation is still mechanically wrong). The claim's proposed port shape — a per-surface triangle-count conformance diff against retail's ConstructMesh arithmetic on a fixed model set, landed BEFORE any BSP/portal poly filter so the two filters aren't conflated — is exactly the right instrument and I endorse it. Severity medium is appropriate: quiet correctness class, no named issue, but a hard precondition for pixel-wise validation of the flatten.
|
||||
- blastRadius: Potential missing or extra faces on models/cells whose polys carry NoPos/NoNeg or stipple bits — a quiet correctness class with no named issue yet; matters for the port because the flatten must reproduce retail's triangle set exactly before the portal pass can be validated pixel-wise.
|
||||
- retailEvidence: Inner ConstructMesh (Ghidra 0x0059dfa0, emit loop pc:426842-426909) counts/emits triangles purely from sides_type (1/2 double the count) with no stippling-based skip; stippling>0 only ORs a per-surface batch flag bit (Ghidra 0x0059dfa0 counting loop; pc:426868-426871); at draw, D3DPolyRender::SetSurface (0x0059d650, pc:426157-426189) maps stippling bit0/bit1 to a per-side 'stippled' render flag.
|
||||
- acdreamEvidence: PrepareGfxObjMeshData skips the positive side on NoPos (ObjectMeshManager.cs:1046) and gates the negative side on Negative/Both/NoNeg+CullMode (:1052-1058); PrepareCellStructMeshData asserts in-comment that 'DAT-side NoPos/NoNeg flags still suppress hidden portal/cap faces' (:1388-1402) — an interpretation, not a retail citation.
|
||||
- portShape: Conformance test: for a fixed model set, reproduce retail's ConstructMesh triangle count per surface (the counting loop is simple arithmetic over sides_type/num_pts) and diff against acdream's flatten; then either drop or keep the NoPos/NoNeg suppression to match. Do this BEFORE landing the solid-skip so the two filters aren't conflated.
|
||||
|
||||
### [LOW] no-frame-dedup (adjusted) — No per-frame part dedup (retail GetDrawnThisFrame / frame-stamp)
|
||||
- correctedClaim: Retail dedups part draws with a per-pass frame stamp (CPhysicsPart::Draw @ 0x0050D7A0 early-outs when m_current_render_frame_num == m_nFrameStamp; DrawMeshInternal @ 0x0059f360 Get/SetDrawnThisFrame for non-player parts; stamp bumped at Flip pc:428642 and again mid-frame in DrawCells pc:432722, so the window is landscape-pass / cell-pass, with player parts exempt and the building portal-draw mode bypassing it). acdream has no per-part dedup — but the no-double-draw invariant is upheld not by the MDI design alone: each production frame issues multiple WbDrawDispatcher.Draw calls (one per visible cell, RetailPViewRenderer.cs:408-425/:470, plus Outdoor/LiveDynamic buckets), and the invariant rests on InteriorEntityPartition's one-bucket-per-entity assignment (InteriorEntityPartition.cs:29-73), the distinct flood list (PortalVisibilityBuilder.cs:170-172), and one explicit hand-rolled cross-pass guard (liveDynamicsDrawn, GameWindow.cs:7813) that is itself a bucket-granularity dedup. Severity low / no user-visible effect today stands. If divergence 4 (per-view or per-cell-shadow object lists, where one entity appears in multiple cells' lists) is ported, the frame-stamp dedup becomes load-bearing and must be ported with retail's exact shape: first-wins within the cell pass, player-part exemption, portal-draw-mode bypass.
|
||||
- verifier notes: RETAIL (all re-derived from Ghidra decompiles via MCP, not BN pseudo-C): (1) CPhysicsPart::Draw @ 0x0050D7A0 — gate confirmed: `(draw_state & 1)==0 && (param_1 != 0 || this->m_current_render_frame_num != RenderDevice::render_device->m_nFrameStamp)`; the frame-stamp early-out is BYPASSED when the portal-mode arg (param_1) is nonzero. (2) RenderDeviceD3D::DrawMeshInternal @ 0x0059f360 — confirmed: for non-portal draws (!param_2) with s_current_physics_part set AND NOT IsPartOfPlayerObj, GetDrawnThisFrame ⇒ early return INSIDE_VIEWCONE_ODS, else SetDrawnThisFrame (pc:427965-427981, matches claimed pc:427972-427980 region). Helpers verified: GetDrawnThisFrame @ 0x0050d4d0 returns `m_current_render_frame_num == render_device->m_nFrameStamp`; SetDrawnThisFrame @ 0x0050d4f0 assigns it — same field CPhysicsPart::Draw checks, one mechanism at two levels. (3) PView::DrawCells — `m_nFrameStamp += 1` at exactly pc:432722 (addr 005a4886), after LScape::draw + FlushAlphaList, before the cell_draw_list loop. Grep of all m_nFrameStamp writes shows the only OTHER bump is RenderDeviceD3D::Flip (pc:428642, addr 0059fec8): the stamp advances twice per presented frame, so retail's dedup window is PER-PASS (within the landscape pass; within the cell pass) — a part may legally draw once in each. Two retail nuances the claim omitted: player parts are exempt (IsPartOfPlayerObj), and the building portal-draw mode (param_2/build_draw_portals_only path) bypasses the dedup entirely.
|
||||
|
||||
ACDREAM: WbDrawDispatcher.Draw is a self-contained build+upload+MDI cycle: WalkEntitiesInto clears scratch and emits each (entity, MeshRefIndex, landblockId) tuple exactly once (WbDrawDispatcher.cs:587-698 — each landblock entry's Entities iterated once; an entity lives in one entry); each instance written once (1182-1189); one indirect command per group (1206-1229); MDI over those commands (1465-1493). The cited :1206+/:1478-1490 are the build/dispatch, not themselves the dedup property, but support it. HOWEVER the "once per frame by construction of the MDI design" rationale is incomplete: production frames issue MULTIPLE Draw calls — one per visible cell in the PView flood (RetailPViewRenderer.cs:408-425 → DrawEntityBucket :460-477 → _entities.Draw :470), plus Outdoor and LiveDynamic buckets (GameWindow.cs:7813-7823 / InteriorRenderer.cs:141-163), or the legacy global pass only when clipRoot is null (GameWindow.cs:7825-7831). The once-per-frame invariant actually rests on three things: (a) InteriorEntityPartition assigns each entity to exactly ONE bucket via its single ParentCellId (InteriorEntityPartition.cs:29-73); (b) OrderedVisibleCells is distinct by construction (PortalVisibilityBuilder.cs:170-172), so each cell bucket draws once; (c) an EXPLICIT hand-rolled guard `if (!liveDynamicsDrawn && outdoorPartition.LiveDynamic.Count > 0)` (GameWindow.cs:7813) prevents the outdoor-portal pass and the main pass from both drawing LiveDynamic — i.e., acdream already needed one cross-pass dedup, implemented at bucket granularity. EntitySet enum has only `All` (WbDrawDispatcher.cs:75-80), so no overlapping set-partition passes exist today.
|
||||
|
||||
JUDGMENT: the divergence is REAL (retail has a per-part frame-stamp dedup; acdream has none) and the low severity / no-blast-radius-today assessment holds — acdream's bucket disjointness + the :7813 guard make per-instance double-submission impossible in current production paths. Adjusted because (i) the "by construction, no dedup needed" rationale understates how the invariant is maintained (it is partition-disjointness + one explicit guard across multiple per-frame Draw calls, not the MDI design alone — and the guard proves the double-draw class already bites this architecture), and (ii) the port shape needs retail's three qualifiers: first-wins applies within a pass (stamp bumps at Flip pc:428642 AND mid-frame in DrawCells pc:432722), player parts are exempt, and the building portal-draw mode bypasses the dedup.
|
||||
- blastRadius: None today — the MDI design submits each instance once per frame by construction; becomes relevant only if the per-view entity pass (divergence 4) is ported naively as draw-per-view.
|
||||
- retailEvidence: CPhysicsPart::Draw frame-stamp check (Ghidra 0x0050D7A0); DrawMeshInternal GetDrawnThisFrame/SetDrawnThisFrame for non-player parts (Ghidra 0x0059f360, pc:427972-427980); DrawCells bumps m_nFrameStamp before through-portal drawing (pc:432722).
|
||||
- acdreamEvidence: WbDrawDispatcher submits one instance per (entity, batch) per frame (WbDrawDispatcher.cs:1206+, :1478-1490); no dedup needed.
|
||||
- portShape: Only port if divergence 4 is implemented as multi-view submission: first-passing-view-wins, exactly retail's dedup.
|
||||
|
||||
## OPEN QUESTIONS
|
||||
|
||||
- Surface class of each portal fill poly in the dat (the deciding fact for divergence 2): are the door-fill and stair-ramp quads' PosSurface/NegSurface untextured Base1Solid (retail-skipped) while window fills are textured (retail-drawn)? The Issue113DoorVanishDiagnosticTests dump (tests/AcDream.Core.Tests/Conformance/Issue113DoorVanishDiagnosticTests.cs) records geometry but not Surface.Type/Stippling per orphan — extend it with PosSurface/NegSurface/Stippling + Surface.Type before implementing the solid-skip.
|
||||
- Does retail's inner ConstructMesh (Ghidra 0x0059dfa0) really emit BOTH sides per sides_type with zero stippling-based suppression? The decompile is register-garbled in places; a cdb capture of a known model's constructed-mesh triangle count (or a careful Ghidra re-read of the emit loop) would settle WB's NoPos/NoNeg interpretation (divergence 5).
|
||||
- DrawPortalPolyInternal's ±12.0 vertex filter (Ghidra 0x0059bc90: skips the draw when ALL vertices sit at x or y = ±12.0) — presumed to exclude synthetic full-cell-boundary portals from depth punching; not traced to a dat-side producer.
|
||||
- maxZ1/maxZ2 are config-backed globals (defaults 7/6 at 0x00820e14/0x00820e18, registry strings nearby suggest they may be tunable); the z-punch/z-seal semantics asserted here assume shipped defaults — a retail-client registry dump would confirm no override ships.
|
||||
- Frame-level ordering of DrawBuilding relative to terrain and the alpha flush (when exactly the punch can erase already-drawn terrain depth, and whether the shell mesh always repairs over-punch) belongs to the Area-2/3 frame-orchestration map; within DrawBuilding the order portal-pass-then-shell is proven (pc:427955-427957) but the surrounding LScape::draw sequence was not traced here.
|
||||
- Child traversal order (pos vs neg first) in BSPNODE::build_draw_portals_only is unrecoverable from the _padding_-mangled decompile (Ghidra 0x0053c100); irrelevant for depth-only submissions but should be pinned via the binary's field offsets if the port reproduces the walk literally.
|
||||
- Defaults of Render::m_RenderPrefs.MultiPassAlpha and D3DPolyRender::s_AlphaDelayMask (the alpha/translucent/clipmap deferred-list routing in 0x0059d4a0) — they shape translucent-fill parity but were not chased to their config initializers.
|
||||
- Where exactly the #113 phantom staircase was observed from (indoor vs outdoor viewpoint) — determines whether the user-visible fix lands with divergence 1 (portal pass + flood discipline), divergence 2 (solid-skip), or both; the dat facts are consistent with both contributing.
|
||||
97
docs/research/2026-06-11-holistic-map/wf1-interior-cells.md
Normal file
97
docs/research/2026-06-11-holistic-map/wf1-interior-cells.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# AREA 3 — Interior cell rendering and the draw-side portal clip (#114)
|
||||
|
||||
## RETAIL
|
||||
|
||||
THE DATA STRUCTURES. A "portal view" is NOT a set of clip planes handed to the rasterizer — it is a 2D screen polygon whose edges each carry a 3D plane through the eye, stored per cell. `portal_view_type` (acclient.h:32345-32355) = { DArray<portal_info> portal; view_type view; float max_indist; uint view_count; int cell_view_done; int view_timestamp; int update_count }. `view_type` (acclient.h:32337-32343) = { vertex_count_total; DArray<view_poly>; DArray<view_vertex> } — note the PLURAL: one cell accumulates a LIST of view polygons. `view_poly` (acclient.h:32465-32473) = { vertex_count, vertex_index, xmin/xmax/ymin/ymax } (a slice into the vertex array + 2D screen bbox). `view_vertex` (acclient.h:32483-32487) = { Vec2D pt; Plane plane } — a screen point PLUS the 3D eye-edge plane used for object culling. `portal_info` (acclient.h:32458-32462) = { int seen; int inflag }. `CCellPortal` (acclient.h:32300-32308) = { other_cell_id, other_cell_ptr, CPolygon* portal, portal_side, other_portal_id, exact_match }. Crucially, `CCellStruct::UnPack` (Ghidra 0x00533d00) shows `portals[i] = polygons + portal_poly_id` — portal-aperture polygons are ordinary entries in the SAME drawn-polygon array.
|
||||
|
||||
VIEW CONSTRUCTION (the flood). `PView::DrawInside` (Ghidra 0x005a5860, pc:433793): curr_view_push(cell), add_views over the cell's stab list, positionPush(cell), then `Render::copy_view(cell.top_view, nullptr, 4)` — a NULL source installs the FULL-SCREEN 4-vertex viewport quad as the root view (Ghidra 0x0054dfc0, also pc:345574) — then `ConstructView(cell, 0xffff)` and `DrawCells(this, 0)`. ConstructView(CEnvCell) (pc:433750-433792): master_timestamp++, InitCell(root, 0xffff), InsCellTodoList(root, 0f), then a worklist loop popping the NEAREST cell (cell_todo_list is sorted by InitCell's max/min portal-vertex distance), appending it to cell_draw_list, and running ClipPortals + AddViewToPortals on it (pc:433786-433787).
|
||||
|
||||
`PView::InitCell` (Ghidra 0x005a4b70, pc:432896): per portal of the cell, classify the EYE against the portal polygon's plane with F_EPSILON = 0.0002 (acclient.h-adjacent const at 0x7e32f8): dist > +eps → side 0, dist < -eps → side 1; if side != portal_side the portal faces AWAY (inflag=1, not traversable); if side == portal_side OR |dist| <= eps (the knife-edge in-plane case) → inflag=0, candidate. The portal you ENTERED through (index == arg3) is force-marked inflag=1/seen=1 so the flood never walks back. `PView::ClipPortals` (Ghidra/pc:433572): for each portal with seen && !inflag, resolve the neighbour (CEnvCell::GetVisible), then FOR EACH accumulated view i of this cell (`Render::set_view(&view, i)` then `PView::GetClip(portal_side, portal_poly, &clip_view, &n, 1)` pc:433651): project the portal polygon to homogeneous screen space and software-clip it against the INSTALLED view. If the portal leads outside (other_cell_id == 0xffffffff) and cliplandscape (default 1, 0x00820f4c) → `Render::copy_view(&pview->outside_view, clip, n)` appends the clipped aperture to outside_view (pc:433668-433676); else after `PView::OtherPortalClip` (pc:433524, the neighbour-side reciprocal re-clip through the matching back-portal indexed DIRECTLY by other_portal_id, 0x005a54b2/0x005a54f6) → `Render::copy_view(neighbour.top_view, clip, n)` APPENDS the polygon to the NEIGHBOUR's view list (pc:433674, target resolved via num_view/portal_view at 0x134/0x138).
|
||||
|
||||
MULTI-PORTAL ACCUMULATION: a cell visible through multiple portals accumulates MULTIPLE view polygons — copy_view (Ghidra 0x0054dfc0) perspective-divides the clipped verts, merges vertices closer than ~1 PIXEL (|dx|<=1 && |dy|<=1 screen units), appends a new view_poly + grows view_count. It is a UNION-AS-LIST; polygons are never merged geometrically. `PView::AddViewToPortals` (pc:433446, 0x005a52d0): for each portal whose clip produced something, if the neighbour was never seen this timestamp → InitCell + InsCellTodoList (enqueue ONCE); if already seen and its view GREW (0x44 watermark != 0x38 view_count) → `AddToCell` IN PLACE and, if the cell was already drawn-listed, `FixCellList` = AdjustCellPlace (re-sorts cell_draw_list so the grown cell draws in dependency order, pc:433247) + AdjustCellView (re-clips ONLY the new views: ClipPortals(cell, update_count), pc:433741-433745). There is NO re-enqueue and no iteration cap — growth propagates recursively in place, and the 1-px vertex dedup gives the fixpoint a hard floor.
|
||||
|
||||
WHAT set_view INSTALLS: `Render::set_view(view_type*, n)` (pc:343750, 0x0054d0e0) sets Render::portal_view/portal_view_num, portal_npnts = poly.vertex_count, portal_inmask = (1<<(npnts+1))-1, portal_vertex = &vertex.data[poly.vertex_index] (the screen points + eye-edge planes), and the 2D xmin/xmax/ymin/ymax bbox. This is GLOBAL clipper state consumed by polyClipFinish and viewconeCheck.
|
||||
|
||||
THE SOFTWARE CLIP: `ACRender::polyClipFinish` (Ghidra 0x006b6d00, pc:702749) is a Sutherland-Hodgman clipper in HOMOGENEOUS screen coordinates (Vec2Dscreen = xw,yw,zw,w): stage 1 clips against w = cdstW (the near/eye plane, interpolating all four homogeneous components — no divide, so eye-grazing portals never blow up); stage 2 clips against each of the installed view's portal_npnts edges using the perspective-correct 2D test (xw - x_edge*w)*dy - (yw - y_edge*w)*dx, gated per-edge by the planeMask shifted by (0x1e - npnts) — a SET bit means SKIP that edge. <3 surviving verts → output count 0. It is pixel-exact because (a) it clips polygon-vs-polygon with no plane-count budget (views are DArrays, blocksize 0x80; the loop runs all npnts edges), and (b) the homogeneous interpolation is exactly the rasterizer's math.
|
||||
|
||||
THE DRAW — THE LOAD-BEARING SURPRISE: retail NEVER clips cell geometry. `RenderDeviceD3D::DrawEnvCell` (0x0059f170, pc:427880-427930): production path is the prebuilt D3D mesh — `D3DPolyRender::DrawMesh(num_surfaces, surfaces, constructed_mesh, 1)` (pc:427905) built once at `CEnvCell::UnPack` (Ghidra 0x0052d470, ConstructMesh of ALL structure->polygons, 3.0 detail, use_built_mesh=1). The legacy poly-list path submits every polygon with planeMask=0xffffffff (pc:427922) — and 0xffffffff after the (0x1e-npnts) shift has the sign bit set for every edge iteration, i.e. SKIP ALL VIEW EDGES; moreover `D3DPolyRender::polyListFinishInternal` (Ghidra 0x0059dba0) just calls `DrawPolyInternal` per poly, and `DrawPolyInternal` (Ghidra 0x0059d7c0) does NO view clipping whatsoever — it builds a triangle fan from the 3D verts and calls DrawPrimitiveUP, gated only by `(surface->type & 6) != 0` (BASE1_IMAGE|BASE1_CLIPMAP, acclient.h:5820-5824). The accumulated views gate ADMISSION, OBJECT CULLING and PUNCHES — never geometry pixels.
|
||||
|
||||
PIXEL-EXACTNESS = DEPTH PUNCH + ORDER + Z-BUFFER. `PView::DrawCells` (Ghidra 0x005a4840): pass 1 (only when outside_view.view_count != 0): PortalList=&outside_view, `LScape::draw` FIRST (landscape through the accumulated outside views), FlushAlphaList, m_nFrameStamp++, then for every cell far→near, FOR EACH VIEW (`CEnvCell::setup_view(cell, i)` = set_view of view i, Ghidra 0x0052c430), every portal with other_cell_id == -1 (to landscape) gets `D3DPolyRender::DrawPortalPolyInternal(poly, false)`. That function (Ghidra 0x0059bc90, pc:424490) projects the aperture polygon, SOFTWARE-CLIPS it against the installed view via polyClipFinish(mask=0 → clip ALL edges), and draws the clipped polygon as an INVISIBLE DEPTH-ONLY quad: DEPTHTEST_ALWAYS, z-write on, z = the portal's true projected depth zw/w when maxZ2=6 (bit0 clear; 0x00820e14) or z = 0.99999988 (far plane) when maxZ1=7 (bit0 set; 0x00820e18); alpha byte 0 so no color. Writing the DOOR-PLANE depth into the aperture after the landscape protects the landscape pixels: any interior geometry FARTHER than the door fails the z-test inside the aperture; nearer geometry draws normally. Pass 2 draws cells far→near, per view, via vtbl+0x5c = DrawEnvCell (vtable at pc:1037045, 0x7e555c) — the GetDrawnThisFrame guard (Ghidra 0x0052c0c0, == m_nFrameStamp) makes the per-view repeats no-ops, so cell GEOMETRY draws ONCE, unclipped; the per-view loop only matters for punches and object culling. Epilogue: per cell, PortalList = the cell's view, vtbl+0x64 = DrawObjCell — objects are tested per view by `Render::viewconeCheck` (Ghidra 0x0054c250): bounding sphere vs the camera plane AND each view_vertex.plane of the installed view (stride 6 floats), OUTSIDE → skipped; objects are CULLED, never clipped (RenderDeviceD3D::DrawMesh loops PortalList views with building_view filtering, pc:427940-428060 / 0x005a0860).
|
||||
|
||||
OUTSIDE-LOOKING-IN: `RenderDeviceD3D::DrawBuilding` (Ghidra 0x0059f2a0, pc:427938): set outdoor_pview->outdoor_portal_list = building->portals, then CPhysicsPart::Draw(part, 1) → DrawMeshInternal arg3=1 → TWO drawing-BSP walks `BSPTREE::build_draw_portals_only(drawing_bsp, 1)` then `(.., 2)` (pc:427993-427994, 0x0059f3cc/0x0059f3d9); each BSPPORTAL node fires RenderDevice::DrawPortal(portalPoly, 1, mode) (pc:326947-326992, 0x0053d870) → `PView::DrawPortal` (Ghidra 0x005a5ab0, pc:433895) → `ConstructView(CBldPortal)` (Ghidra 0x005a59a0, pc:433827). Mode 1 = eye-side gate (F_EPSILON, IN_PLANE → return 0 — building portals reject the knife-edge OUTRIGHT), GetClip vs the current view, copy_view into the interior cell, then PUNCH the door aperture to FAR-Z (param_4==1 → maxZ1) and stop. Mode 2 = no punch, RECURSE ConstructView(cell, other_portal_id) building the interior view graph, then DrawCells(this, 1) draws the interior cells into the punched aperture. THEN CPhysicsPart::Draw(part, 0) draws the building SHELL mesh last; interior pixels survive only inside the punched aperture (everywhere else the shell's nearer depth rejects them). Pixel-exact, again with zero geometry clipping.
|
||||
|
||||
KNIFE-EDGE (Q3): two distinct behaviors. Building portals (ConstructView(CBldPortal), Ghidra 0x005a59a0): |eye·N + d| <= 0.0002 → Sidedness IN_PLANE → return 0; the portal contributes nothing that frame; no degenerate view is ever built. Cell-to-cell portals (InitCell, Ghidra 0x005a4b70): the in-plane case leaves inflag=0 (candidate) and the degenerate projection dies naturally downstream — polyClipFinish's homogeneous near-W clip plus copy_view's 1-pixel vertex dedup collapse a sub-pixel sliver to <3 verts → no view appended → no propagation. Physically correct: an edge-on aperture subtends zero pixels.
|
||||
|
||||
>8 PLANES (Q4): confirmed unbounded. Views are DArray-backed (grow on demand, blocksize 0x80); polyClipFinish iterates all portal_npnts edges; the only fixed-width artifact is the 32-bit planeMask (shift by 0x1e-npnts) which the D3D path ignores entirely. There is no plane budget and no fallback tier.
|
||||
|
||||
CELL PORTAL POLYS (Q5): they live in the drawn polygon array (UnPack, 0x00533d00) and ARE emitted into the built mesh — D3DPolyRender::ConstructMesh (Ghidra 0x0059dfa0) has NO stippling or portal gate in either the counting or emission loop. Suppression is DATA + SURFACE-GATE: in the Holtburg cellar dat (a8-current-room-cellar-audit.txt / corner-cells-audit.txt, EnvCell 0xA9B40175), portal polys carry stippling NoPos and pos_surface → 0x080000DF (an untextured surface), and the draw gate skips untextured batches: DrawPolyInternal requires (type & 6) != 0 (0x0059d7c0), and DrawMesh (Ghidra 0x0059d4a0, pc:426064) skips a batch when skipNoTexture (default 1, 0x00820e30) && !(type & 6), with the bypass branch only for non-building/non-cell meshes. So cell apertures are never VISIBLY drawn; the only per-frame conditional draw of a cell portal poly is the invisible depth punch (DrawCells pass 1 for landscape portals; ConstructView/DrawPortal for building doors). Cell-to-cell apertures inside a dungeon get NO punch at all — plain z-buffer + far→near order suffices there.
|
||||
|
||||
## ACDREAM
|
||||
|
||||
FRAME ENTRY. GameWindow decides per frame on clipRoot (the VIEWER cell or the synthetic outdoor node): indoor/outdoor-node frames run ONLY RetailPViewRenderer.DrawInside (GameWindow.cs:7590-7604); the global terrain/sky block runs only when clipRoot is null (GameWindow.cs:7546-7589). The outside→in look (retail DrawPortal) exists as RetailPViewRenderer.DrawPortal driven from GameWindow.cs:7750-7780 via BuildFromExterior seeding.
|
||||
|
||||
VIEW CONSTRUCTION. PortalVisibilityBuilder.Build (PortalVisibilityBuilder.cs:63-381) is the ConstructView port: root view = full-screen NDC quad (PortalVisibilityBuilder.cs:77, 557-558), distance-priority CellTodoList (PortalVisibilityBuilder.cs:96-97), per-portal side test CameraOnInteriorSide with PortalSideEpsilon = 0.01 (PortalVisibilityBuilder.cs:38, 734-741), portal projection ProjectToClip = homogeneous transform + eye-plane-only clip at w >= 1e-4 (PortalProjection.cs:81-97), then ClipToRegion = homogeneous Sutherland-Hodgman against each active view polygon with w-multiplied edge tests (PortalProjection.cs:105-134) — a faithful polyClipFinish equivalent. Clipped regions append to the neighbour's CellView (union-as-list, AddRegion with dedup) and exit portals append to OutsideView (PortalVisibilityBuilder.cs:269-281, 334-336). Reciprocal OtherPortalClip by direct OtherPortalId index is ported (PortalVisibilityBuilder.cs:305-332, 755-764). Late view growth RE-ENQUEUES the cell, capped at MaxReprocessPerCell = 16 because ProjectToClip numerical drift otherwise never settles (PortalVisibilityBuilder.cs:40-51, 348-354); OrderedVisibleCells appends once on first pop and is never re-sorted (PortalVisibilityBuilder.cs:168-172). A clip-empty portal whose opening the eye stands inside (within 1.75 m) is RESCUED by substituting the whole current view (PortalVisibilityBuilder.cs:258-267). Per-building outdoor floods: ConstructViewBuilding == BuildFromExterior (PortalVisibilityBuilder.cs:548-554), merged by MergeBuildingFrame FIRST-WINS — a cell already present in the frame keeps its existing views and the building flood's views are dropped (RetailPViewRenderer.cs:151-160).
|
||||
|
||||
CLIP ASSEMBLY. ClipFrameAssembler.Assemble (ClipFrameAssembler.cs:78-196) packs ONE GPU clip slot per view polygon: ClipPlaneSet.From converts a single CCW NDC polygon of 3..8 edges (after ~0.5° collinear merge) into <=8 clip-space half-planes (nx,ny,0,d) (ClipPlaneSet.cs:135-149); >8 edges → scissor AABB fallback (ClipPlaneSet.cs:130-133) which the assembler maps to SLOT 0 = PASS-ALL with the renderer-side scissor documented as unimplemented (ClipFrameAssembler.cs:13-15, 114-119); degenerate/area<1e-7 → IsNothingVisible → slice omitted (ClipPlaneSet.cs:66-68, 241-242 + ClipFrameAssembler.cs:102-103). CellIdToSlot keeps only slices[0] for single-slot consumers (ClipFrameAssembler.cs:130).
|
||||
|
||||
DRAW. RetailPViewRenderer.DrawInside (RetailPViewRenderer.cs:44-109): build frame → (outdoor root) merge per-building floods → assemble clip slots → PrepareRenderBatches for all OrderedVisibleCells → DrawLandscapeThroughOutsideView: per outside slice, terrain UBO planes set + landscape drawn CLIPPED to the slice planes via gl_ClipDistance in terrain/sky shaders (RetailPViewRenderer.cs:214-238; sky.vert:153, terrain_modern.vert:47), then ClearDepthSlice per slice = scissored AABB depth CLEAR to far (indoor roots only; null for outdoor roots) (GameWindow.cs:7644-7652) → DrawExitPortalMasks — UNWIRED, GameWindow never sets the callback so it no-ops (RetailPViewRenderer.cs:331-332; absent from the ctx at GameWindow.cs:7604-7670) → DrawEnvCellShells far→near per IndoorDrawPlan.ShellPass (IndoorDrawPlan.cs:18-29), per view slice, with UseShellClipRouting routing the cell to its slice slot and the shell vertex shader writing gl_ClipDistance[i] = dot(planes[i], gl_Position) (mesh_modern.vert:120; EnvCellRenderer.cs:262, 1195-1230) — but GL clip distances are ENABLED only when clipShells == ctx.RootCell.IsOutdoorNode (9ce335e #114 scoping; RetailPViewRenderer.cs:96-105, 378-398): indoor roots draw shells UNCLIPPED → DrawCellObjectLists: entities drawn with membership cull only (UseIndoorMembershipOnlyRouting clears clip routing, RetailPViewRenderer.cs:439-450; the comment cites retail viewconeCheck but no per-view sphere-vs-plane test exists), and particles drawn per cell SCISSORED to the slice NDC AABB with clip distances disabled (GameWindow.cs:9553-9580). Cell-side portal polys are suppressed at mesh-extraction time by StipplingType.NoPos/NoNeg (ObjectMeshManager.cs:1385-1402 in PrepareCellStructMeshData, reached from the EnvCell path at ObjectMeshManager.cs:1343-1350) — a different criterion from retail's untextured-surface batch skip, agreeing on the audited cellar data.
|
||||
|
||||
## DIVERGENCES
|
||||
|
||||
### [CRITICAL] shell-chop-vs-depth-discipline (UNVERIFIED (verifier hit token limit)) — acdream clips cell-shell GEOMETRY to the view region; retail clips NOTHING — pixel exactness comes from aperture depth-punch + far→near order + z-buffer
|
||||
- blastRadius: #114 in full (chopped interior stairs, vanished candle-holder area, neighbour-room barrel visible through a chopped wall — all are under-inclusive regions amputating real geometry), the e46d3d9→124c6cb door-regression cycle, and the structural reason 9ce335e had to scope the clip back out of indoor roots (leaving indoors with NO draw-side discipline at all). This is the one-drawing-discipline invariant breaker.
|
||||
- retailEvidence: DrawEnvCell submits polys with planeMask=0xffffffff (pc:427922) which the shift in polyClipFinish (Ghidra 0x006b6d00: mask << (0x1e - npnts), set bit = SKIP edge) turns into skip-all; the production path is the prebuilt mesh DrawMesh(…, constructed_mesh, 1) (pc:427905) and DrawPolyInternal (Ghidra 0x0059d7c0) performs zero view clipping — a raw triangle fan to D3D. The accumulated view is consumed ONLY by: admission (ClipPortals/copy_view), object culling (viewconeCheck Ghidra 0x0054c250), and the depth punch (DrawPortalPolyInternal Ghidra 0x0059bc90: polyClipFinish-clipped aperture drawn DEPTHTEST_ALWAYS, z-write, alpha 0, z = portal depth (maxZ2=6 @0x00820e14) or far-z (maxZ1=7 @0x00820e18)). Cell geometry is always drawn whole; cells draw once (GetDrawnThisFrame guard Ghidra 0x0052c0c0) far→near (DrawCells Ghidra 0x005a4840).
|
||||
- acdreamEvidence: DrawEnvCellShells enables GL_CLIP_DISTANCE0..7 and hard-clips shell vertices to the per-slice region planes (RetailPViewRenderer.cs:378-398; mesh_modern.vert:120; EnvCellRenderer.cs:1195-1230) — for outdoor roots only after 9ce335e (RetailPViewRenderer.cs:96-105); indoor roots draw shells unclipped with no compensating depth discipline. The region polygons themselves drift per frame (PortalVisibilityBuilder.cs:40-51 cap rationale), so any chop is also unstable.
|
||||
- portShape: Remove the shell gl_ClipDistance chop as the enforcement mechanism (keep regions for admission). Port the retail discipline: draw every admitted cell's shell WHOLE, far→near per OrderedVisibleCells (already the order, IndoorDrawPlan.cs:21), and enforce aperture exactness with depth-only punches of the software-clipped portal polygons (see next divergence). gl_ClipDistance may survive only as the LANDSCAPE gate (terrain has no z-protection of its own until the punch exists).
|
||||
|
||||
### [CRITICAL] missing-aperture-depth-punch (UNVERIFIED (verifier hit token limit)) — The retail depth punch (DrawPortalPolyInternal) has no acdream equivalent — DrawExitPortalMasks is an unwired no-op and ClearDepthSlice clears an AABB to far instead of writing portal-plane depth on the exact clipped polygon
|
||||
- blastRadius: Landscape-vs-interior compositing at every aperture: far interior cells can overpaint the terrain seen through a door (no door-plane z floor), the outdoor root has NO depth discipline at building doors at all (ClearDepthSlice=null), and the AABB-shaped clear over-includes around door frames — a direct candidate mechanism for #108 (grass sweeping at the aperture edge) and a contributor to the #114 see-through-to-neighbour-rooms class.
|
||||
- retailEvidence: DrawCells pass 1 (Ghidra 0x005a4840): after LScape::draw, per cell per view, every other_cell_id==-1 portal is punched at its TRUE projected depth (maxZ2=6, bit0 clear → z=zw/w; Ghidra 0x0059bc90 tail) so geometry behind the door plane z-fails inside the aperture while the landscape keeps its pixels. Outside→in: ConstructView(CBldPortal) mode-1 walk punches the door to FAR-z (maxZ1=7) before the mode-2 walk draws interior cells into it, and the shell mesh draws LAST (DrawBuilding Ghidra 0x0059f2a0: Draw(part,1) then Draw(part,0); walks at pc:427993-427994). The punched polygon is the polyClipFinish-clipped aperture — pixel-exact.
|
||||
- acdreamEvidence: RetailPViewRenderer.DrawExitPortalMasks exists but GameWindow never supplies the callback (RetailPViewRenderer.cs:331-332; ctx construction GameWindow.cs:7604-7670 sets only DrawLandscapeSlice/ClearDepthSlice/DrawCellParticles/EmitDiagnostics). The only depth management is ClearDepthSlice: glScissor on the slice NDC AABB + Clear(DepthBufferBit) — wrong shape (AABB ⊇ polygon), wrong value (far clear ≈ retail's maxZ1 special case, never the protective portal-depth maxZ2 write), and disabled outright for outdoor roots (GameWindow.cs:7644-7652).
|
||||
- portShape: Wire DrawExitPortalMasks as a depth-only polygon draw: project + software-clip the aperture polygon against the slice view (ClipToRegion already does this math, PortalProjection.cs:105-134), rasterize it depth-always/z-write/color-masked at the portal's interpolated depth (indoor→out, retail maxZ2) or far-z (outside→in before interior cells, retail maxZ1), replacing ClearDepthSlice. ~80 lines of GL + the existing clipper.
|
||||
|
||||
### [HIGH] multiview-loss-first-wins (UNVERIFIED (verifier hit token limit)) — Multi-portal view accumulation is lossy: MergeBuildingFrame drops a building flood's views when the cell is already in the frame, and CellIdToSlot keeps only slices[0]
|
||||
- blastRadius: A cell visible through TWO apertures (two doors, door+window) renders/punches/scissors with only the first view — missing second-door visibility, and per-frame winner flips drive oscillation: a named suspect for #109 (far-door oscillation) and the multi-aperture cases in #114 (meeting hall).
|
||||
- retailEvidence: Render::copy_view APPENDS every clipped portal polygon as a new view_poly (Ghidra 0x0054dfc0: poly DArray grow + view_count++; called per portal per view from ClipPortals pc:433674 and ConstructView(CBldPortal) Ghidra 0x005a59a0). DrawCells iterates ALL views per cell for punches and object culling (Ghidra 0x005a4840 per-view loops; DrawMesh per-view viewconeCheck pc:427940-428060). AddViewToPortals propagates late growth in place via AddToCell/FixCellList/AdjustCellView (pc:433446, 433741-433745).
|
||||
- acdreamEvidence: MergeBuildingFrame: `if (target.CellViews.ContainsKey(cellId)) continue;` — first-wins, src views discarded (RetailPViewRenderer.cs:151-160). ClipFrameAssembler.CellIdToSlot = sliceArray[0].Slot (ClipFrameAssembler.cs:130) feeds the single-slot consumers (entity routing RetailPViewRenderer.cs:227). The per-slice shell loop itself does handle multiple slices (RetailPViewRenderer.cs:388-393), so the loss is at merge/routing, not the draw loop.
|
||||
- portShape: Merge = UNION the view lists (append src polygons through the existing AddRegion dedup) instead of skipping; once the shell chop is gone (divergence 1) multi-slice only matters for punches/scissors/object culling, where iterating all slices is already the code shape.
|
||||
|
||||
### [HIGH] eight-plane-budget-passall (UNVERIFIED (verifier hit token limit)) — The 8-plane GPU budget with an unimplemented scissor fallback (slot-0 = PASS-ALL) has no retail counterpart — retail's clip is polygon-vs-polygon software with no plane cap
|
||||
- blastRadius: Any view polygon with >8 edges after collinear-merge silently degrades to pass-all: terrain slices flood the whole screen through a small aperture (grey/grass artifacts at complex doorways, #108 class), and under the current shell-chop model a per-frame flip between exact-planes and pass-all is a visible strobe. Issue113MeetingHallFloodTests pins 0 such slices at the hall, but the fallback is load-bearing wherever clipped polygons accrete vertices (every reciprocal clip adds edges).
|
||||
- retailEvidence: polyClipFinish loops all portal_npnts view edges with DArray-backed vertex storage (Ghidra 0x006b6d00; view DArrays blocksize 0x80, acclient.h:32408-32445); the only 32-bit-mask artifact is ignored by the D3D draw path (DrawPolyInternal Ghidra 0x0059d7c0 takes no mask). copy_view's 1-pixel vertex dedup (Ghidra 0x0054dfc0) bounds vertex growth physically, not by a budget.
|
||||
- acdreamEvidence: ClipPlaneSet: >8 edges → Scissor AABB (ClipPlaneSet.cs:130-133); assembler maps scissor fallbacks to slot 0 with 'the renderer uses scissor for passes that need that fallback' documented but no glScissor implemented in the slice consumers (ClipFrameAssembler.cs:13-15, 114-119; no scissor in RetailPViewRenderer draw paths — only particles use BeginDoorwayScissor, GameWindow.cs:9569).
|
||||
- portShape: Once enforcement moves to the depth punch (CPU-rasterized clipped polygons), the 8-plane budget stops being load-bearing for shells; the landscape gate either keeps planes for the common ≤8 case with a real scissor (or stencil) fallback, or the punch protects terrain too and the budget disappears entirely. Do not extend gl_ClipDistance count.
|
||||
|
||||
### [HIGH] knife-edge-epsilon-and-rescue (UNVERIFIED (verifier hit token limit)) — Knife-edge handling diverges: retail uses ±0.0002 side classification (building portals reject IN_PLANE outright; cell portals degenerate naturally via the homogeneous near-W clip + 1-px dedup); acdream uses ±0.01 plus a non-retail 1.75 m eye-in-opening rescue that substitutes the ENTIRE current view
|
||||
- blastRadius: #114's edge-on doorway grey (degenerate slice omitted → cell admitted by rescue but landscape/region slice missing → clear color through the aperture) and admission over-inclusion when grazing a doorway; the 50x-wider epsilon plus the rescue create a band where acdream and retail disagree about which portals contribute, feeding flap-class instability at thresholds.
|
||||
- retailEvidence: ConstructView(CBldPortal) Ghidra 0x005a59a0: |dot| <= F_EPSILON (0.000199999995, const at 0x7e32f8) → IN_PLANE → return 0, no view, no punch. InitCell Ghidra 0x005a4b70: cell-portal in-plane falls through to inflag=0 (candidate) and the sliver dies in polyClipFinish stage-1 (w < cdstW homogeneous clip) or copy_view's |dx|<=1px && |dy|<=1px vertex merge → <3 verts → nothing appended. No rescue path exists anywhere.
|
||||
- acdreamEvidence: PortalSideEpsilon = 0.01 (PortalVisibilityBuilder.cs:38, used at :734-741); clip-empty + EyeInsidePortalOpening(≤1.75 m) → clippedRegion := copy of the WHOLE current view (PortalVisibilityBuilder.cs:258-267, same in BuildFromExterior :501-507 and the reciprocal-empty rescue :324-332); ClipPlaneSet's MinPolygonArea=1e-7 gate turns surviving slivers into omitted slices (ClipPlaneSet.cs:66-68, 241-242) while the cell stays admitted.
|
||||
- portShape: Adopt retail's constants and asymmetry: 0.0002 epsilon; building/exterior seeds reject in-plane; cell portals keep the candidate-then-degenerate path with a ~1-px screen-space vertex dedup in ClipToRegion output (the missing stabilizer that retail has and our drift cap compensates for). The rescue should shrink to what retail's geometry already implies — a portal the eye is truly inside projects near-full-screen through the homogeneous clip and needs no substitution.
|
||||
|
||||
### [MEDIUM] growth-requeue-vs-in-place (UNVERIFIED (verifier hit token limit)) — Late view growth: retail propagates in place (AddToCell + FixCellList/AdjustCellPlace re-sorts the draw list, AdjustCellView re-clips only new views via the update_count watermark); acdream re-enqueues with a drift cap and never re-sorts OrderedVisibleCells
|
||||
- blastRadius: Draw-order staleness for late-grown cells (mostly masked by z-buffer) and the MaxReprocessPerCell=16 cap silently truncating legitimate propagation in portal-dense interiors — a churn/oscillation contributor (#109) and the source of the 2026-06-07 indoor-hang workaround complex.
|
||||
- retailEvidence: AddViewToPortals pc:433446/0x005a52d0: first discovery → InitCell+InsCellTodoList; growth → AddToCell, and if cell_view_done, FixCellList = AdjustCellPlace (re-sort cell_draw_list, pc:433247) + AdjustCellView (ClipPortals(cell, update_count) → only NEW views re-clipped, pc:433741-433745; watermark fields 0x38/0x44 read at 0x005a5357-0x005a53b8). Termination comes from the 1-px dedup floor, not a cap.
|
||||
- acdreamEvidence: Re-enqueue on grew with popCounts cap (PortalVisibilityBuilder.cs:348-354) and the cap's own comment attributing it to ProjectToClip drift (PortalVisibilityBuilder.cs:40-51); processedViewCounts is a faithful update_count port (PortalVisibilityBuilder.cs:174-184); OrderedVisibleCells append-once, never adjusted (PortalVisibilityBuilder.cs:168-172).
|
||||
- portShape: With the dedup stabilizer (divergence 5) the cap becomes removable; the faithful shape is in-place propagation: on growth of an already-popped cell, immediately re-clip only the new view slice through its portals (recursive, like AdjustCellView) and re-position the cell in the ordered list (AdjustCellPlace) — or document why append-order + z-buffer makes re-positioning unnecessary in our GL pipeline.
|
||||
|
||||
### [MEDIUM] object-particle-gating (UNVERIFIED (verifier hit token limit)) — Objects/particles: retail culls per view by sphere-vs-view-edge-planes (viewconeCheck) and lets depth do the pixels; acdream culls by cell membership only and scissors particles to the slice NDC AABB
|
||||
- blastRadius: particles-through-walls (AABB ⊇ aperture polygon → emitters visible outside the true opening), neighbour-room objects drawn whenever their cell is admitted even when their sphere is outside every view (the barrel half of the #114 barrel-through-wall once the wall chop is fixed, the object side remains over-inclusive), minor overdraw cost.
|
||||
- retailEvidence: viewconeCheck Ghidra 0x0054c250: sphere vs viewer plane + each installed view_vertex.plane (stride 6 floats = Vec2D pt + Plane, acclient.h:32483-32487), OUTSIDE → skip; driven per view from RenderDeviceD3D::DrawMesh when PortalList is set (pc:427940-428060, building_view filter) and per cell from the DrawCells epilogue (PortalList = cell's view before vtbl+0x64 DrawObjCell, Ghidra 0x005a4840). No scissor, no geometry clip — depth + culling only.
|
||||
- acdreamEvidence: UseIndoorMembershipOnlyRouting clears all clip routing for entities and the comment explicitly defers the viewconeCheck equivalent (RetailPViewRenderer.cs:439-450); entity draw passes visibleCellIds membership only (RetailPViewRenderer.cs:460-477); particles: DisableClipDistances + BeginDoorwayScissor(slice.NdcAabb) (GameWindow.cs:9553-9580).
|
||||
- portShape: Port viewconeCheck: each ClipViewSlice already has the NDC edge set — lift the per-edge eye-planes (view_vertex.plane analog: the plane through the eye and the NDC edge, recoverable from the inverse view-projection) and test entity/emitter bounding spheres per slice before draw; drop the particle scissor once the punch protects apertures.
|
||||
|
||||
### [MEDIUM] portal-poly-suppression-criterion (UNVERIFIED (verifier hit token limit)) — Cell-side portal-poly suppression keys on the wrong property: acdream gates on Stippling NoPos/NoNeg at mesh build; retail includes them in the mesh and skips UNTEXTURED surface batches at draw (skipNoTexture + type&6)
|
||||
- blastRadius: Anywhere stippling and surface-texturedness disagree the two renderers diverge: a TEXTURED portal poly without NoPos (window-filling between cell and outdoors, closed-door fillings) draws in retail but our criterion may pass or drop it for the wrong reason; this is the same mechanism family as the #113 phantom staircase / door-vanish on the GfxObj side (e223325) viewed from the cell side. On the audited cellar cells the criteria agree (both suppress), so blast radius today is latent, not pinned to an open issue.
|
||||
- retailEvidence: ConstructMesh (Ghidra 0x0059dfa0) emits ALL polygons — no stippling/portal gate in counting or emission loops; CCellStruct::UnPack (Ghidra 0x00533d00) places portal polys inside the polygons array; the draw skip is per-surface: DrawPolyInternal requires (surface->type & 6) != 0 (Ghidra 0x0059d7c0; BASE1_IMAGE|BASE1_CLIPMAP acclient.h:5820-5824) and DrawMesh skips untextured batches when skipNoTexture (default 1 @0x00820e30) except for plain objects (Ghidra 0x0059d4a0 / pc:426064-426074). Dat evidence: cellar portal polys have stip=NoPos AND pos_surface→0x080000DF (untextured) — corner-cells-audit.txt EnvCell 0xA9B40175 polys 0x0004/0x0005.
|
||||
- acdreamEvidence: PrepareCellStructMeshData: hasPos/hasNeg from StipplingType.NoPos/NoNeg decide emission entirely (ObjectMeshManager.cs:1394-1402); a NoPos poly is dropped from the mesh before any surface consideration; conversely a portal poly WITHOUT NoPos would be emitted and drawn textured with no draw-time portal awareness (ObjectMeshManager.cs:1343-1350 entry).
|
||||
- portShape: Align the criterion with retail: emit all polys, classify batches by surface texturedness (we already read Surface.Type at ObjectMeshManager.cs:1430+), and skip untextured batches for cell/building meshes at draw — or prove from a dat sweep that NoPos ⇔ untextured-surface for all CellStructs (extend the e223325 conformance sweep to environments) and keep the cheaper build-time gate with the proof pinned.
|
||||
|
||||
## OPEN QUESTIONS
|
||||
|
||||
- LScape::draw internals: whether retail clips terrain POLYGONS against outside_view in software or only culls land blocks/cells per view (PortalList is installed before LScape::draw in DrawCells pass 1, Ghidra 0x005a4840, but I did not decompile LScape::draw). The punch+order discipline works either way, but a faithful landscape pass should know which; acdream currently plane-CLIPS terrain per slice, which retail may not do at all.
|
||||
- PView::InitCell's second loop (Ghidra 0x005a4b70 tail): the decompile shows seen=1 set for every inflag==0 portal per view with no visible screen-bbox test — either a test was optimized out of the decompile or 'seen' is simply 'candidate'. The exact seen gate (and whether it uses the view xmin/xmax bbox installed by set_view) is unconfirmed.
|
||||
- Who calls PView::DrawPortal with mode 3 (punch-on-ConstructView-failure, Ghidra 0x005a5ab0)? The BSP walks pass modes 1 and 2 (pc:427993-427994); mode 3 implies a 'seal this aperture in depth even though nothing is visible through it' caller I did not locate — possibly the unloaded-interior or option-disabled path. Matters for the port's behavior when an interior cell is not streamed in.
|
||||
- GfxObj-side (building shell) portal-poly surfaces: the cell-side data proves the untextured-surface skip for the cellar; whether the Holtburg building models' door/window-filling polys split into textured (visible filling) vs untextured (open aperture) the way the skipNoTexture gate requires is AREA 1's question — the e223325 test dump should be extended to record each portal poly's pos_surface type bits before the holistic port relies on this gate.
|
||||
- Retail DrawMesh's garbled else-branch (`skipNoTexture = 1` when ObjBuildingOrBuildingPart==0 && param_4==0, both BN pc:426074 and Ghidra agree on the assignment) — semantically it looks like a latch that should be an assignment to 0 or a one-shot draw-anyway; since skipNoTexture init is already 1 (0x00820e30) the net effect (skip untextured for buildings/cells, draw for plain objects) holds, but the branch's exact intent deserves a disassembly-level check before porting it verbatim.
|
||||
- Spatially overlapping cells within one dungeon: retail punches ONLY landscape portals (other_cell_id==-1) and building doors — cell-to-cell apertures get no punch, so retail relies on z-buffer + non-overlapping cell volumes. Whether any shipped dungeon has same-dungeon overlapping cell volumes (which would bleed under the unclipped discipline and therefore under a faithful port too) is a dat question worth a one-off sweep before declaring the port's z-only indoor compositing sufficient.
|
||||
- cdstW's exact value (the homogeneous near-W threshold in polyClipFinish, Ghidra 0x006b6d00): not extracted; acdream's EyePlaneW=1e-4/MinW=0.05 (PortalProjection.cs:182-188) should be pinned to retail's constant during the port.
|
||||
215
docs/research/2026-06-11-holistic-map/wf1-interior-collision.md
Normal file
215
docs/research/2026-06-11-holistic-map/wf1-interior-collision.md
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# Area 6 — Interior collision: per-cell shadow lists (#99, A6.P4)
|
||||
|
||||
## RETAIL
|
||||
|
||||
RETAIL ARCHITECTURE (all branch/gate claims verified in Ghidra decompile; pc:LINE given as cross-reference only).
|
||||
|
||||
== Data structures ==
|
||||
Every cell — indoor EnvCell or outdoor LandCell — owns a per-cell object index: CObjCell {num_objects + object_list (objects whose m_position is in this cell), num_shadow_objects + shadow_object_list (objects whose GEOMETRY overlaps this cell), num_stabs + stab_list, restriction_obj} (acclient.h:30916-30936). The link record is CShadowObj {physobj, cell_id, cell} (acclient.h:30940-30944): one per (object, overlapped-cell) pair, stored inline in the object's own shadow_objects DArray (stride 0x18 — Ghidra 0x00514ae0) AND pointer-referenced from each cell's shadow_object_list. So "where can this object be collided with?" is answered ONCE, at registration, by writing the object into every cell it geometrically touches.
|
||||
|
||||
== Registration: which cells does a shadow land in? (named question 1) ==
|
||||
The cell set is built by CObjCell::find_cell_list(Position, num_spheres, spheres, CELLARRAY, outCell, SPHEREPATH) — Ghidra 0x0052b4e0, pc:308742. Verified structure:
|
||||
1. Reset array. If m_position is OUTDOOR (objcell_id & 0xFFFF < 0x100): CLandCell::add_all_outside_cells(pos, n, spheres, array) — the outdoor 24 m land cells the spheres overlap (crosses landblock borders). If INDOOR: add exactly THAT one cell (and set path->hits_interior_cell=1).
|
||||
2. Then a single forward loop over the GROWING array: for each cell already in it, call its virtual find_transit_cells (vtable+0x80). Because newly appended cells are themselves visited, this is a recursive flood — but every hop is gated by ACTUAL SPHERE OVERLAP, not by topology and not by any visibility list:
|
||||
- CEnvCell::find_transit_cells (Ghidra 0x0052c820 — the verified binary anchor): for each CCellPortal: (a) exterior portal (other_cell_id == 0xFFFFFFFF): test each sphere against the portal polygon plane; if |signed distance| < radius + F_EPSILON (the sphere STRADDLES the doorway plane) set a flag; after the portal loop the flag triggers add_all_outside_cells — this is how an indoor-positioned object near a street door also lands in outdoor cells. (b) interior portal, neighbor LOADED: add the neighbor IFF some sphere intersects the neighbor's actual cell geometry — CCellStruct::sphere_intersects_cell != OUTSIDE. (c) interior portal, neighbor UNLOADED: add by cell-id with a NULL cell pointer IFF the sphere crosses the portal plane to the far side (the portal_side sign test — this is the only place portal_side appears in this function).
|
||||
- CLandCell::find_transit_cells (Ghidra 0x00533800): add_all_outside_cells, then CSortCell::find_transit_cells (Ghidra 0x00534060) which forwards to CBuildingObj::find_building_transit_cells (Ghidra 0x006b5230): for each CBldPortal (acclient.h:32094) get the interior EnvCell behind it and call CEnvCell::check_building_transit (Ghidra 0x0052c5d0): add the interior cell IFF other_portal_id >= 0 AND some sphere intersects that interior cell's BSP. THIS is the outdoor→indoor bridge: an outdoor-positioned door whose sphere pokes into the vestibule is written into the vestibule's shadow_object_list at registration. No reverse portal map exists or is needed — the building's own portal list carries the direction.
|
||||
3. Spheres used: the object's per-part CylSpheres globalized (overload Ghidra 0x0052b9f0: sphere center = each CylSphere's low_pt transformed local→global, radius = cyl radius, capped at 10 spheres), falling back to the part-array sorting sphere (calc_cross_cells, Ghidra 0x00515230). So the flood reach is the object's REAL collision footprint — a fireplace deep in a room lands in 1 cell; a door at a threshold lands in 2-3.
|
||||
4. Static placement variant calc_cross_cells_static (Ghidra 0x00515160, pc:283340) sets cell_array.do_not_load_cells=1, which (back in find_cell_list, Ghidra 0x0052b4e0 tail) prunes flood results down to {start cell} ∪ start cell's stab_list (num_stabs/stab_list, acclient.h:30930-30931) — a don't-force-load restriction, NOT the placement rule itself.
|
||||
The write: CPhysicsObj::add_shadows_to_cells (Ghidra 0x00514ae0, pc:282819): one CShadowObj per array cell; if the array entry's cell pointer is null (unloaded), the shadow records cell_id but joins no list. Each loaded cell gets CObjCell::add_shadow_object (Ghidra 0x0052b280, pc:308584: append + back-link shadow->cell) plus CPartArray::AddPartsShadow. Particle emitters (PARTICLE_EMITTER_PS=0x1000, acclient.h:2829) take a single-cell shortcut (add_particle_shadow_to_cell); HAS_PHYSICS_BSP_PS=0x10000 objects (acclient.h:2833) use find_bbox_cell_list (Ghidra 0x00510fc0: current cell + CPartArray::calc_cross_cells_static over the growing array). Children recurse.
|
||||
|
||||
== Query: who is consulted at collision time? (named question 2) ==
|
||||
Primary cell: CTransition::transitional_insert (pc:273137, 0x0050b6f0) → CTransition::insert_into_cell(sphere_path.check_cell, attempts) (pc:271991, Ghidra-region 0x00509e70) → check_cell->vtable->find_collisions(this), retried up to `attempts`. Per cell type (verified Ghidra): CEnvCell::find_collisions (0x0052c100) = find_env_collisions (own cell BSP, vtable+0x8c) THEN CObjCell::find_obj_collisions(this). CLandCell::find_collisions (0x00532d60) = find_env_collisions (terrain) THEN CSortCell::find_collisions (0x005340a0: this->building → CBuildingObj::find_building_collisions) THEN find_obj_collisions(this). Two structural facts fall out: (a) CObjCell::find_obj_collisions (Ghidra 0x0052b750, pc:308916) iterates ONLY this->shadow_object_list — skip parented objects, skip self, call CPhysicsObj::FindObjCollisions per object, first non-OK halts; entire loop skipped when insert_type == INITIAL_PLACEMENT_INSERT; (b) the BUILDING collision channel exists only on LandCell — an indoor primary cell structurally cannot collide with a building shell.
|
||||
Other cells: CTransition::check_other_cells (pc:272690-272798, 0x0050ae50): rebuilds this->cell_array via find_cell_list(&cell_array, &pick, &sphere_path) from the CURRENT sphere positions (the same flood machinery as registration), then for each array cell != the primary calls vtable+0x88 find_collisions — i.e. env AND shadow-objects per other cell. COLLIDED/ADJUSTED return immediately; SLID clears contact_plane_valid/contact_plane_is_water and returns; afterwards check_cell is retargeted to the containing-cell pick (adjust_check_pos).
|
||||
Straddling-doorway object: covered twice — it was REGISTERED into both cells (sphere-overlap flood), and the moving player's own cell_array spans both cells at the threshold so both lists are iterated anyway. There is no spatial radius anywhere in the query; the only sets are per-cell lists.
|
||||
|
||||
== Removal / update cadence (named question 3) ==
|
||||
Always remove-all-then-add-all, never an incremental diff: CPhysicsObj::remove_shadows_from_cells (Ghidra 0x00511230): per shadow, CObjCell::remove_shadow_object (Ghidra 0x0052b2d0: swap-remove + DArray shrink) + CPartArray::RemoveParts; recurse children. Triggers, verified by xref + decompile:
|
||||
- EVERY successful movement step: CPhysicsObj::SetPositionInternal(CTransition*) (Ghidra 0x00515330, pc:283270-283545) ends with: if (cell) { if (state & HAS_PHYSICS_BSP_PS) calc_cross_cells(); else if (transit->cell_array.num_cells > 0) { remove_shadows_from_cells(); add_shadows_to_cells(&transit->cell_array); } } — the movement path REUSES the transition's already-computed CELLARRAY (the one check_other_cells just built), so per-tick re-registration costs no extra flood.
|
||||
- Placement/teleport: calc_cross_cells (Ghidra 0x00515230 = fresh find_cell_list + remove + add) from the non-transition SetPositionInternal overload (xref 0x0051551b) and ForceIntoCell (xref 0x0051568b).
|
||||
- Cell load: CObjCell::init_objects (Ghidra 0x0052b420): CObjectMaint::InitObjCell + recalc_cross_cells (Ghidra 0x00515a30) for each non-static, not-completely-visible object homed in the newly loaded cell. Also set_parent (xrefs 0x00515b15/0x00515bab).
|
||||
|
||||
## ACDREAM
|
||||
|
||||
== acdream call chain ==
|
||||
REGISTRATION — src/AcDream.Core/Physics/ShadowObjectRegistry.cs. Register (line 41): if cellScope != 0 the entry goes into exactly that ONE cell (lines 62-72, the A1.5 interior-statics fix); otherwise the entry is written into the outdoor 24 m grid cells its XY bounding box overlaps, clamped to a SINGLE landblock (lines 77-105: minCx/maxCx clamp 0..7, one lbPrefix). RegisterMultiPart (line 124) does the same per shape (lines 162-186). No portal traversal, no sphere-vs-cell-BSP test, no building bridge — the cell set is an XY rectangle. UpdatePosition (lines 219-278) re-registers via Deregister + Register on the 5-10 Hz server UpdatePosition stream. Production call sites: GameWindow.cs:3264 RegisterMultiPart for server-spawned entities (doors included) passes NO cellScope → doors register into outdoor grid cells only, even when their geometry pokes through a doorway into a vestibule; GameWindow.cs:6176/6246/6282/6307/6506 Register statics with cellScope = entity.ParentCellId ?? 0u (interior EnvCell statics → one cell; landblock-baked building-shell GfxObjs → landblock-wide outdoor footprint).
|
||||
QUERY — src/AcDream.Core/Physics/TransitionTypes.cs. TransitionalInsert (line 862) runs FindEnvCollisions (line 876, primary cell env only) then ONE flat FindObjCollisions (line 900) per attempt — the comment at lines 856-859 explicitly concedes "we don't have CellArray/CheckOtherCells iteration because our FindObjCollisions ... is already a flat per-landblock query". FindObjCollisions (line 2307): queryRadius = sphereRadius + movement.Length() + 5f (line 2340 — a 5 m pad with no retail analog); CellTransit.FindCellSet supplies portalReachableCells (line 2358); ShadowObjectRegistry.GetNearbyObjects is called once with primaryCellId = sp.CheckCellId and isViewer (lines 2376-2382); results iterated with self-skip (line 2398) + a broad-phase distance pre-check (lines 2401-2409). GetNearbyObjects (ShadowObjectRegistry.cs:430): first iterates portalReachableCells lists (lines 460-471), then the b3ce505 stopgap gate — `if ((primaryCellId & 0xFFFFu) >= 0x0100u && !isViewer) return;` (lines 494-495) — else a 9-landblock radial sweep over grid cells the query radius overlaps (lines 498-539). CheckOtherCells (TransitionTypes.cs:1632-1750, the A4 port of retail check_other_cells) iterates the FindCellSet set but runs ENV collision only (terrain ValidateWalkable for outdoor ids lines 1650-1684, cell BSP for indoor lines 1687-1750) — NO per-cell shadow-object query. RunCheckOtherCellsAndAdvance (lines 2158-2195) = FindCellSet (2181) → CheckOtherCells (2185) → containing-cell retarget (2192-2193).
|
||||
CELL-SET MACHINERY — src/AcDream.Core/Physics/CellTransit.cs. FindTransitCellsSphere (line 107) is a faithful port of CEnvCell::find_transit_cells: exterior-portal straddle gate restored + live-binary verified (lines 130-176), loaded-neighbor sphere-vs-CellBSP test (lines 184-199); plus a NON-retail topology output hasExitPortal (lines 102-106, set at 132). BuildCellSetAndPickContaining (line 543): indoor seed = current cell at index 0 (line 580) + growing-array walk (lines 589-619) with the A6.P5 widening at lines 614-618 (any exit-portal cell → AddAllOutsideCells by TOPOLOGY, wider than retail's straddle gate, self-documented as a #99 stopgap); outdoor seed = AddAllOutsideCells + CheckBuildingTransit per building stab (lines 626-634 — the retail building bridge IS already ported here, just unused by registration).
|
||||
|
||||
== A6.P4 spec verdict (docs/superpowers/specs/2026-05-24-phase-a6-p4-retail-shadow-architecture.md) ==
|
||||
CONFIRMED: §2.2 per-cell shadow_object_list + find_obj_collisions(this)-only iteration (Ghidra 0x0052b750); the decomp-anchor table's function identities all check out against Ghidra (308742→0x0052b4e0, 282819→0x00514ae0, 308584→0x0052b280, 308916→0x0052b750, 309560→0x0052c100, 316951→0x00532d60); §3.1's inversion (compute cell set at registration, strict per-cell query) is the right architecture; spec §7 Q2 answered yes — doors register outdoor-only (GameWindow.cs:3264, no cellScope).
|
||||
REFUTED: §3.1/§3.3-slice-2's registration rule "indoor m_position: that cell + VisibleCellIds (forward portal traversal)" — retail does NOT use any visibility list for shadow placement. The recursion is sphere-overlap-gated portal flood (find_transit_cells: neighbor added only if the object's spheres intersect the neighbor's CCellStruct BSP, Ghidra 0x0052c820); stab_list appears only as the do_not_load_cells PRUNE in the static variant. Using VisibleCellIds would massively over-register (a fireplace would land in the whole room-chain PVS). Spec §7 Q1 is therefore moot — wrong list entirely.
|
||||
REFUTED: §3.2's worry that outdoor→indoor needs a "reverse portal map" (option 3.2.a) — no reverse lookup exists in retail. The outdoor→indoor direction goes through the BUILDING: CLandCell::find_transit_cells → CSortCell.building → CBldPortal list → CEnvCell::check_building_transit (Ghidra 0x00533800/0x00534060/0x006b5230/0x0052c5d0). acdream already ports this exact bridge (CellTransit.cs:626-634) — slice 2 just has to invoke it from the registration-side cell-set builder.
|
||||
ADJUSTED: §3.2.b ("query-side expansion ... matches retail behaviorally") shipped as the current portalReachableCells + A6.P5 widening, but it is NOT behaviorally equivalent to retail (topology-wide, plus the radial sweep persists for outdoor primaries); it is staging only, and slice 2/3 must land for retail parity, as the spec itself intends.
|
||||
MISSING FROM SPEC (1): the movement-time trigger — retail re-registers every moved object each successful transition step by reusing the transition's CELLARRAY (SetPositionInternal, Ghidra 0x00515330 tail). The spec only discusses Register/UpdatePosition; the faithful port should refresh a moving entity's shadow set from its own transition cell set, answering spec §7 Q4 (cost ≈ zero — the set is already built).
|
||||
MISSING FROM SPEC (2): buildings are not shadow objects in retail at all — the building channel hangs off LandCell::find_collisions only (CSortCell::find_collisions, Ghidra 0x005340a0/0x00532d60), so indoor cells can never see building shells. The spec keeps the cottage as an outdoor-registered shadow entry; the deeper port moves building shells out of the registry into a per-LandCell building reference (cache.GetBuilding exists, CellTransit.cs:631), which is what makes the #98 gate removable rather than merely relocated.
|
||||
|
||||
## DIVERGENCES
|
||||
|
||||
### [CRITICAL] registration-cell-set-not-portal-flood (confirmed) — Shadow registration uses an XY grid rectangle / single scoped cell instead of retail's sphere-overlap portal flood
|
||||
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C), every claimed gate verified:
|
||||
|
||||
1. Registration chokepoint confirmed. CPhysicsObj::calc_cross_cells (Ghidra 0x00515230) and calc_cross_cells_static (0x00515160) both build a CELLARRAY via CObjCell::find_cell_list, then call remove_shadows_from_cells + add_shadows_to_cells. Xrefs confirm these are THE registration paths: calc_cross_cells_static is called from CObjCell::init_static_objs, CEnvCell::init_static_objects, and CPhysicsObj::add_obj_to_cell; calc_cross_cells from SetPositionInternal, ForceIntoCell, recalc_cross_cells (movement). add_shadows_to_cells (0x00514ae0) writes one CShadowObj per CELLARRAY cell via CObjCell::add_shadow_object (0x0052b280), which appends to the cell's shadow_object_list and back-points shadow->cell — so the per-cell shadow list IS the registration result, exactly as claimed.
|
||||
|
||||
2. CObjCell::find_cell_list (0x0052b4e0): verified verbatim — outdoor seed (cell id low16 < 0x100) → CLandCell::add_all_outside_cells; indoor seed → CELLARRAY::add_cell(that one cell); then a growing-array walk (`while (i < cell_array->num_cells)` with num_cells re-read each iteration) calling each cell's vtable+0x80 (find_transit_cells). The CylSphere wrapper (0x0052b9f0) converts up to 10 CCylSpheres (low_pt → global center, cylinder radius) into the sphere set — confirming the claimed "CylSphere-derived sphere set".
|
||||
|
||||
3. CEnvCell::find_transit_cells (0x0052c820): verified — per portal: other_cell_id == 0xFFFFFFFF (exterior) → straddle test per sphere (-(F_EPSILON+radius) < planeDist < F_EPSILON+radius) sets a flag, and after the loop CLandCell::add_all_outside_cells fires if any portal straddled; loaded neighbor → added IFF CCellStruct::sphere_intersects_cell(neighbor->structure, sphere) != OUTSIDE. All as claimed. One branch the claim omitted (does not weaken it): when the neighbor cell is NOT loaded (GetOtherCell null), retail still adds the neighbor's cell id from a portal-plane distance + portal_side test — a port must preserve this for streaming-in cells.
|
||||
|
||||
4. Outdoor→indoor bridge verified: CLandCell::find_transit_cells (0x00533800) = add_all_outside_cells + CSortCell::find_transit_cells (0x00534060) → if cell has a building → CBuildingObj::find_building_transit_cells (0x006b5230) → per CBldPortal → CEnvCell::check_building_transit (0x0052c5d0), whose gate is exactly `(-1 < other_portal_id)` AND at least one sphere with sphere_intersects_cell != OUTSIDE → add_cell(interior cell). So a doorway-spanning door's spheres reach the vestibule's shadow_object_list at registration. Claim verified.
|
||||
|
||||
5. One genuine refinement (folded into notes, not a contradiction): both calc_cross_cells variants branch on `state & 0x10000` (HAS_PHYSICS_BSP) → CPhysicsObj::find_bbox_cell_list (0x00510fc0) instead of the sphere flood. That path is the SAME portal-graph growing-array architecture but geometry = part bounding boxes: CPartArray::calc_cross_cells_static (0x00518160) → vtable+0x7c part-based CEnvCell::find_transit_cells (0x0052cae0), gating neighbor add on Plane::intersect_box (portal plane) + CCellStruct::box_intersects_cell, with the same exterior → add_all_outside_cells (part variant 0x00533360). There is also a particle path (`state & 0x1000` → add_particle_shadow_to_cell). Whether ACE-sent door PhysicsState carries 0x10000 was not settled here; either way the registration is a portal flood with geometry-vs-cell-BSP gates, never an XY grid — the divergence is identical under both branches, and the sphere-flood port shape proposed matches what retail does for all non-HAS_PHYSICS_BSP objects.
|
||||
|
||||
ACDREAM SIDE — all cited lines verified by reading the code:
|
||||
- ShadowObjectRegistry.Register: cellScope!=0 → exactly ONE cell (ShadowObjectRegistry.cs:62-72); else 24m XY-grid rect from position±radius, clamped to cx/cy 0..7 of ONE landblock (ShadowObjectRegistry.cs:77-105). RegisterMultiPart repeats the identical per-shape logic (162-186). UpdatePosition re-registers through the same paths (219-278). No portal traversal, no sphere-vs-cell-BSP test anywhere in registration — confirmed by reading the whole file; the only flood machinery (CellTransit.FindCellSet) is consumed at QUERY time (GetNearbyObjects portalReachableCells param, ShadowObjectRegistry.cs:460-471), not registration.
|
||||
- Production call sites confirmed: server-spawned entities (doors included) register via RegisterMultiPart with NO cellScope argument (GameWindow.cs:3264-3273, in RegisterLiveEntityCollision) → outdoor XY-grid cells only. The five landblock-static sites pass cellScope: entity.ParentCellId ?? 0u (GameWindow.cs:6176-6197, 6246-6267, 6282-6303, 6307-6328, 6506-6527) → interior statics land in exactly one cell (retail would flood them into every cell their geometry overlaps).
|
||||
- The claimed compensation stack is real and all query-side: the #98 indoor-primary gate (`(primaryCellId & 0xFFFF) >= 0x0100 && !isViewer → return` before the outdoor sweep, ShadowObjectRegistry.cs:494-495), the A6.P4-slice-1 portalReachableCells outdoor-id iteration explicitly documented as the #99 door-reachability patch (ShadowObjectRegistry.cs:413-428), and the hasExitPortal topology widening in CellTransit.cs (:102,:132,:614-616). The 3×3-landblock query sweep (ShadowObjectRegistry.cs:502-539) compensates the registration-side landblock clamp for the outdoor-outdoor case.
|
||||
- Port-shape anchors exist as claimed: CellTransit.FindTransitCellsSphere (CellTransit.cs:53/66/107), AddAllOutsideCells (:257/:306), CheckBuildingTransit (:353), and the outdoor-seed AddAllOutsideCells + building bridge (:626-634).
|
||||
|
||||
JUDGMENT: the divergence is real and not behaviorally equivalent. Retail computes cell membership ONCE at registration via a geometric portal flood and queries only the current cell's (plus flooded cells') shadow lists; acdream registers into a grid/single-cell approximation and then patches reachability at every query with a stack the project's own physics digest labels workarounds (#98 stopgap b3ce505 → introduced #99, OPEN HIGH; A6.P4 per-cell shadow architecture named as the open debt). Severity "critical" is justified: it breaks retail's per-cell shadow-list invariant and is the root of #99. Two honest caveats for the port plan: (a) decide per-object between the sphere flood and the find_bbox_cell_list bbox flood based on HAS_PHYSICS_BSP (0x10000) state — or document choosing the sphere flood for all as a deliberate simplification; (b) preserve the unloaded-neighbor portal-plane fallback branch of CEnvCell::find_transit_cells (0x0052c820) and the 10-sphere cap of the CylSphere wrapper (0x0052b9f0).
|
||||
- blastRadius: #99 (outdoor-registered doors invisible to indoor-side collision — walk-through at thresholds, OPEN HIGH); the entire compensation stack (b3ce505 indoor gate, A6.P5 topology widening, portalReachableCells query expansion) exists because of this one divergence; also the residual risk class behind #97-family phantom collisions at indoor/outdoor seams.
|
||||
- retailEvidence: CObjCell::find_cell_list (Ghidra 0x0052b4e0, pc:308742): outdoor seed → add_all_outside_cells; indoor seed → that one cell; then a growing-array walk calling each cell's find_transit_cells (vtable+0x80). CEnvCell::find_transit_cells (Ghidra 0x0052c820): neighbor added IFF the object's spheres intersect the neighbor's CCellStruct BSP (sphere_intersects_cell != OUTSIDE); exterior portal → add_all_outside_cells IFF a sphere straddles the portal plane (|dist| < r+ε). Outdoor→indoor via CLandCell::find_transit_cells → CBuildingObj::find_building_transit_cells → CEnvCell::check_building_transit (Ghidra 0x00533800/0x006b5230/0x0052c5d0): interior cell added IFF other_portal_id >= 0 AND a sphere intersects its BSP. Result written per cell by add_shadows_to_cells → add_shadow_object (Ghidra 0x00514ae0/0x0052b280); a doorway door lands in BOTH the outdoor cell's and the vestibule's shadow_object_list at registration.
|
||||
- acdreamEvidence: ShadowObjectRegistry.Register: cellScope single cell (ShadowObjectRegistry.cs:62-72) else outdoor 24m XY-grid rectangle clamped to one landblock (ShadowObjectRegistry.cs:77-105); RegisterMultiPart same per shape (162-186). Server-spawned doors pass no cellScope (GameWindow.cs:3264-3273) → outdoor cells only. No portal traversal or sphere-vs-cell-BSP test anywhere in registration.
|
||||
- portShape: BuildShadowCellSet(m_positionCellId, shapeSpheres) that reuses the ALREADY-PORTED CellTransit machinery: indoor seed → growing-array walk with FindTransitCellsSphere (CellTransit.cs:107, minus the hasExitPortal widening, minus the membership pick); outdoor seed → AddAllOutsideCells + CheckBuildingTransit (CellTransit.cs:626-634 — the retail building bridge, already in-tree). Write the entry into every returned cell's list. Spheres = the entity's real collision shapes (per-shape CylSphere/BSP bounding spheres), mirroring retail's CylSphere-derived sphere set (Ghidra 0x0052b9f0).
|
||||
|
||||
### [CRITICAL] flat-object-query-not-per-cell (confirmed) — Object collision is one flat radial+portal-set query per step instead of per-cell shadow-list iteration
|
||||
- correctedClaim: Claim stands as written, with one refinement to the port shape: a flat union of the FindCellSet cells' shadow lists fixes the query SET but not retail's per-cell ordering — retail interleaves env+object collision per cell (find_collisions = env BSP → building → find_obj_collisions, primary cell first via insert_into_cell, then each other cell via check_other_cells, halting on the first non-OK). A fully faithful A6.P4 port should run find_obj_collisions per cell at those two retail call sites rather than as a single phased union query.
|
||||
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C), all four load-bearing functions checked:
|
||||
|
||||
1. CTransition::transitional_insert @ 0x0050b6f0: per attempt calls insert_into_cell(this, sphere_path.check_cell, num_attempts), and on OK_TS immediately calls check_other_cells(this, check_cell). So the per-step object query is strictly cell-driven: primary cell first, then every other overlapped cell.
|
||||
2. CTransition::insert_into_cell @ 0x00509e70: loops calling `(**(code**)(param_1->_padding_ + 0x88))(this)` — vtable slot +0x88 on the cell. The BN pseudo-C at pc:271984-272030 names this slot find_collisions; the Ghidra decompile of CEnvCell::find_collisions calls the ADJACENT slot +0x8c (find_env_collisions) internally, consistent with +0x88 = find_collisions. The claim's caller/callee composition ("insert_into_cell calls check_cell->find_collisions") is accurate: transitional_insert passes check_cell as param_1.
|
||||
3. CTransition::check_other_cells @ 0x0050ae50: builds the overlap set via CObjCell::find_cell_list(&this->cell_array, ...), then for each non-null cell != primary calls the SAME vtable slot +0x88 (find_collisions); COLLIDED/ADJUSTED halt, SLID clears contact_plane_valid/is_water and returns. Matches pc:272735 as claimed.
|
||||
4. The find_collisions implementations: CEnvCell::find_collisions @ 0x0052c100 = vtable+0x8c (env BSP) then CObjCell::find_obj_collisions; CLandCell::find_collisions @ 0x00532d60 = env → CSortCell::find_collisions (building, @ 0x005340a0) → CObjCell::find_obj_collisions. Both concrete cell types end in find_obj_collisions as claimed.
|
||||
5. CObjCell::find_obj_collisions @ 0x0052b750: iterates ONLY this->shadow_object_list (num_shadow_objects), skipping parented objects and self, calling CPhysicsObj::FindObjCollisions per entry. No distance/radius filter of any kind. The only spatial operation in the whole chain is find_cell_list's sphere-to-cell overlap (cell membership, not a radial object gather). "No spatial radius exists anywhere in the retail query path" — confirmed.
|
||||
|
||||
ACDREAM SIDE — every cited line checked against the actual code:
|
||||
- src/AcDream.Core/Physics/TransitionTypes.cs:862-925 (TransitionalInsert): exactly ONE FindObjCollisions(engine) per attempt at :900, after FindEnvCollisions at :876. Confirmed.
|
||||
- TransitionTypes.cs:855-860: the concession comment is verbatim — "we don't have CellArray/CheckOtherCells iteration because our FindObjCollisions (via ShadowObjectRegistry) is already a flat per-landblock query."
|
||||
- TransitionTypes.cs:2340: `float queryRadius = sphereRadius + movement.Length() + 5f;` — the +5m pad, confirmed.
|
||||
- TransitionTypes.cs:2358-2359: `CellTransit.FindCellSet(...)` already computes portalReachableCells (containing-cell result discarded with `_ =`), passed into GetNearbyObjects at :2376-2382 with primaryCellId + isViewer. Confirmed.
|
||||
- src/AcDream.Core/Physics/ShadowObjectRegistry.cs:430-540 (GetNearbyObjects): first unions the portal-reachable cells' shadow lists (:460-471), then the b3ce505 indoor gate at :494-495 (`if ((primaryCellId & 0xFFFFu) >= 0x0100u && !isViewer) return;`), then falls through to the 9-landblock radial 24m-cell grid sweep (:497-539) for outdoor primaries. Confirmed exactly as claimed, including the isViewer exemption (comment :482-493 admits "Retail's find_obj_collisions ... has NO indoor-cell gate — the gate is acdream-specific").
|
||||
- #98 causal claim independently corroborated by the in-code comment at ShadowObjectRegistry.cs:473-480: "the landblock-wide cottage GfxObj was returned by the unconditional radial sweep."
|
||||
|
||||
NOT-HANDLED-ELSEWHERE CHECK (the conflation risk): acdream DOES have a CheckOtherCells port (A4), so I verified what it iterates — TransitionTypes.cs:1632-1769 runs ONLY environment collision per other cell (terrain ValidateWalkable :1650-1684 for outdoor ids, cell BSP via BSPQuery.FindCollisions :1728-1732 for indoor). No shadow-object iteration anywhere in it. So per-cell object collision is genuinely absent, not relocated.
|
||||
|
||||
BEHAVIORAL-EQUIVALENCE CHECK: not equivalent. (a) Outdoor primaries test the union of cellSet + every shadow list within queryRadius over a 24m cell grid — objects in cells the sphere never overlaps get tested (the +5m pad guarantees this); retail tests only overlapped cells' lists. (b) Indoors, the gate makes the SET roughly retail-shaped, but doors registered at outdoor cells (cellScope=0, GameWindow.cs:3139 per the comment at TransitionTypes.cs:2342-2354) are only visible via the AddAllOutsideCells straddle — the registration rule diverges from retail's register-into-every-overlapped-cell, which is exactly why #99 exists. (c) Halt ordering differs: retail halts per cell in cell_array order with the primary cell's env+obj interleaved in one find_collisions call; acdream phases env-all-cells then objects-flat, so which collision wins first can differ.
|
||||
|
||||
CAVEATS (noted, not verdict-changing): (1) The blast-radius attribution of #97/#100/#101 specifically to the +5m pad is plausible-but-not-independently-re-proven here; the digest lists them as the phantom-collision class and the mechanism (radial gather tests non-overlapped cells' objects) structurally produces that class. (2) The proposed port shape (flat union of FindCellSet cells' shadow lists) fixes the SET but would still not reproduce retail's per-cell halt ordering or the per-cell env/obj interleave; a fully faithful port iterates find_obj_collisions per cell inside the per-cell find_collisions sequence (primary via insert_into_cell, others via check_other_cells). The divergence as claimed — flat radial+portal-set query vs per-cell shadow-list iteration — is real, and the severity rating (critical: necessitated the b3ce505 stopgap which introduced OPEN-HIGH #99, blocks A6.P4 slice 3) is justified.
|
||||
- blastRadius: #98's original cause (cellar sphere found the cottage via the radial sweep) and the reason the b3ce505 stopgap + isViewer exemption exist; the +5m pad finds objects retail would never test (phantom-collision class #97/#100/#101); blocks deleting the stopgap (A6.P4 slice 3).
|
||||
- retailEvidence: insert_into_cell calls check_cell->find_collisions (pc:271991-272030); check_other_cells calls every other overlapped cell's find_collisions (vtable+0x88, pc:272735); each find_collisions ends in CObjCell::find_obj_collisions(this) which iterates ONLY this->shadow_object_list (Ghidra 0x0052b750). No spatial radius exists anywhere in the retail query path.
|
||||
- acdreamEvidence: TransitionalInsert runs ONE FindObjCollisions per attempt (TransitionTypes.cs:900; concession comment at 856-859); queryRadius = sphereRadius + movement + 5f (TransitionTypes.cs:2340); GetNearbyObjects falls through to a 9-landblock radial grid sweep for outdoor primaries (ShadowObjectRegistry.cs:498-539) gated off for indoor primaries by the b3ce505 stopgap (ShadowObjectRegistry.cs:494-495).
|
||||
- portShape: After registration-side flood ships: GetNearbyObjects(cellSet) = union of the shadow lists of exactly the transition's FindCellSet cells (already computed at TransitionTypes.cs:2358). Delete the radial sweep, the +5m pad, the primaryCellId gate, and the isViewer exemption (the viewer's own cell set reaches whatever its swept sphere overlaps). This is A6.P4 slices 1→3 with the corrected registration rule.
|
||||
|
||||
### [HIGH] building-shell-as-shadow-object (adjusted) — Building shells are landblock-wide shadow entries; retail buildings are a per-LandCell channel that indoor cells structurally cannot reach
|
||||
- correctedClaim: Building shells in acdream are outdoor-grid footprint shadow entries (Register cellScope=0, ShadowObjectRegistry.cs:74-105) found by a membership-blind 3x3-landblock radial sweep (GetNearbyObjects:497-539) — reachable from any position pre-gate; retail instead stores at most ONE CBuildingObj pointer per outdoor CLandCell (CSortCell.building, acclient.h:31882, set once at the building's origin cell by init_buildings 0x0052fd80) and tests it ONLY inside CLandCell::find_collisions (0x00532d60 -> 0x005340a0 -> 0x006b5300). CORRECTION 1: the unreachability is per-CELL, not per-primary — an indoor-primary sphere straddling an exit portal DOES test the building in retail, because CTransition::check_other_cells (0x0050ae50) calls find_collisions on every cell in the sphere's cell array including outdoor CLandCells; only a fully-interior cell array (the #98 cellar case) structurally cannot reach a building. Retail additionally weakens (not skips) the shell BSP test when the path also hits interior cells (BSPTREE::find_collisions 0x0053a440, bldg_check && hits_interior_cell) — the port must keep the straddle-case building test or doorway collision regresses. CORRECTION 2: "landblock-wide" overstates registration (it is bounding-radius footprint over the owning landblock's outdoor 8x8 grid); the membership-blind reach comes from the query's radial sweep, which is what the #98 gate (ShadowObjectRegistry.cs:494-495) and the layered isViewer exemption compensate for. Both compensations dissolve under the retail shape, with one verification item: the camera probe must then be enclosed by interior cell-BSP collision (retail's find_env_collisions channel) for the isViewer exemption to be safely removable. Port shape stands: per-LandCell building reference consulted only in the outdoor-cell branch of the per-cell query (cache.GetBuilding already keyed this way, CellTransit.cs:631); doors stay shadow objects (retail's CObjCell::find_obj_collisions 0x0052b750 iterates shadow_object_list of dynamic CPhysicsObjs — buildings are not in it).
|
||||
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C):
|
||||
1. CLandCell::find_collisions (Ghidra 0x00532d60): vcall +0x8c (= find_env_collisions, CLandCell impl at 0x00532f20 — terrain/cell structure) -> CSortCell::find_collisions -> CObjCell::find_obj_collisions. CONFIRMED as claimed.
|
||||
2. CSortCell::find_collisions (Ghidra 0x005340a0): `if (this->building != NULL) return CBuildingObj::find_building_collisions(building, trans); return OK_TS;`. CONFIRMED — the building channel is a single per-LandCell pointer (CSortCell.building, acclient.h:31882; CSortCell : CObjCell at acclient.h:31880, CLandCell : CSortCell at :31886).
|
||||
3. CEnvCell::find_collisions (Ghidra 0x0052c100): vcall +0x8c (= find_env_collisions, CEnvCell impl at 0x0052c130 — the cell's own BSP) -> CObjCell::find_obj_collisions only. NO building call, and CEnvCell : CObjCell (acclient.h:32072) structurally has no building field. CONFIRMED.
|
||||
4. CObjCell::find_obj_collisions (Ghidra 0x0052b750): iterates this->shadow_object_list (dynamic CPhysicsObjs) only — buildings are not shadow objects in retail. CONFIRMED the channels are disjoint.
|
||||
5. CBuildingObj::find_building_collisions (Ghidra 0x006b5300): sets sphere_path.bldg_check=1, runs CPhysicsPart::find_obj_collisions on the building's physics part (shell BSP), sets collided_with_environment. CONFIRMED it is an environment-style BSP test, not a shadow-entry test.
|
||||
6. Registration: CLandBlock::init_buildings (Ghidra 0x0052fd80) -> LandDefs::adjust_to_outside on the building origin -> get_landcell -> CBuildingObj::add_to_cell (0x006b5550) -> CSortCell::add_building (0x00534030, first-wins). Each building registers in EXACTLY ONE CLandCell (its origin cell). Confirms the proposed "per-LandCell building reference" port shape matches retail's actual data shape.
|
||||
7. NUANCE THAT FORCES THE ADJUSTMENT: CTransition::check_other_cells (Ghidra 0x0050ae50) vcalls find_collisions (vtable +0x88) on EVERY cell in the sphere's cell array. When the sphere straddles an exit portal, the array contains outdoor CLandCells, whose find_collisions DOES reach the building. So "an indoor primary can never test a building shell" is overstated — the correct structural statement is per-CELL, not per-primary. The #98 blast radius survives: deep in the cellar (no exit-portal straddle) the cell array is all CEnvCells, so retail structurally never tests the cottage shell. Supporting design evidence: BSPTREE::find_collisions (Ghidra 0x0053a440) further WEAKENS the shell test when bldg_check && hits_interior_cell != 0 (placement/ethereal inserts pass centerSolid=false) — retail deliberately mutes shell collision for spheres engaged with interior cells even on the straddle path; a faithful port must preserve the straddle-case building test or doors/doorways regress.
|
||||
|
||||
ACDREAM SIDE — read the cited code:
|
||||
1. src/AcDream.App/Rendering/GameWindow.cs:6176-6182 (note: path is Rendering/GameWindow.cs): per-part Register(...) with cellScope: entity.ParentCellId ?? 0u. Landblock-baked building stabs have ParentCellId = null (src/AcDream.Core/World/WorldEntity.cs:69-74; WbDrawDispatcher.cs:452 "building shell (no ParentCellId)") -> cellScope=0 path. CONFIRMED.
|
||||
2. ShadowObjectRegistry.cs Register cellScope==0 path (lines 74-105): footprint registration into the owning landblock's outdoor 8x8 grid cells overlapped by world bounding radius. "Landblock-wide" is shorthand — registration is footprint-over-outdoor-grid; but the QUERY (GetNearbyObjects lines 497-539) radially sweeps the 3x3 landblock neighborhood, so pre-gate the shell is reachable from ANY position within queryRadius regardless of cell membership. Functional claim CONFIRMED.
|
||||
3. Both compensations are inline-documented at ShadowObjectRegistry.cs:473-495: the #98 indoor gate `if ((primaryCellId & 0xFFFF) >= 0x0100 && !isViewer) return;` (494-495, comment 473-480 names #98/cellar Z-cap explicitly) and the Phase U isViewer exemption (comment 482-493). CONFIRMED.
|
||||
4. Production call site (not just tests): Transition.FindObjCollisions at src/AcDream.Core/Physics/TransitionTypes.cs:2376-2382 passes primaryCellId: sp.CheckCellId, isViewer: oi.IsViewer, plus portalReachableCells from CellTransit.FindCellSet (:2358) — the gate is live and load-bearing. Also note the portal-reachable pass (ShadowObjectRegistry.cs:460-471) runs BEFORE the gate, so indoor-straddle spheres already reach outdoor-registered entries via A6.P5's AddAllOutsideCells (CellTransit.cs:614-618) — acdream's partial emulation of retail's straddle behavior.
|
||||
5. cache.GetBuilding(landcellId) exists and is used for transit at src/AcDream.Core/Physics/CellTransit.cs:631 — port-shape claim CONFIRMED.
|
||||
|
||||
JUDGMENT: the structural divergence is REAL, not behaviorally-equivalent-elsewhere — acdream itself documents the two compensations as patches over exactly this shape, and the physics digest classifies the b3ce505 gate as a workaround. The only caveat on the blast-radius claim: whether the isViewer exemption fully dissolves under the retail port depends on acdream's interior cell-BSP collision actually enclosing the camera probe the way retail's CEnvCell find_env_collisions does — plausible (retail bounds the viewer with the EnvCell's own BSP, same channel) but not proven here; treat as a port-plan verification item, not a refutation.
|
||||
- blastRadius: #98 cellar Z-cap root cause (cottage found from the cellar); keeps the b3ce505 gate load-bearing; the camera isViewer exemption (Phase U) is a second compensation layered on the first — both dissolve under the retail shape.
|
||||
- retailEvidence: Building collision fires only from CLandCell::find_collisions → CSortCell::find_collisions → CBuildingObj::find_building_collisions (Ghidra 0x00532d60, 0x005340a0); CEnvCell::find_collisions (Ghidra 0x0052c100) has env + shadow objects only — no building channel. An indoor primary can never test a building shell.
|
||||
- acdreamEvidence: Landblock-baked building GfxObjs register as shadow entries with cellScope=0 → outdoor-grid footprint (GameWindow.cs:6176-6182 family; ShadowObjectRegistry.cs:77-105), then are found (pre-gate) by any radial query within reach; ShadowObjectRegistry.cs:484-495 documents both compensations inline.
|
||||
- portShape: Move building-shell collision out of ShadowObjectRegistry into a per-LandCell building reference consulted only in the outdoor-cell branch of the per-cell query (cache.GetBuilding already exists — CellTransit.cs:631 uses it for transit). Doors stay shadow objects (they are dynamic CPhysicsObjs in retail too).
|
||||
|
||||
### [HIGH] check-other-cells-env-only (confirmed) — CheckOtherCells iterates other cells for environment collision only; retail runs env AND shadow objects per other cell
|
||||
- correctedClaim: Claim stands as stated, with three precision additions: (1) retail's per-other-cell find_collisions on land cells also interposes the building check (CSortCell::find_collisions → CBuildingObj::find_building_collisions @0x005340a0) between env and objects; (2) retail also runs the PRIMARY cell's objects inside insert_into_cell's own inner retry loop (acdream's retry wraps env+obj at the outer attempt level only); (3) acdream advances the carried cell BEFORE its flat object pass while retail advances it AFTER all per-cell object passes. acdream span is TransitionTypes.cs:1632-1769 (not :1750).
|
||||
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra (not BN pseudo-C):
|
||||
|
||||
1. CTransition::check_other_cells @0x0050ae50 (Ghidra decompile): builds the cell array via CObjCell::find_cell_list, then for every cell != the check cell calls `(**(code **)(pCVar1->_padding_ + 0x88))(this)`. Switch on result: COLLIDED_TS/ADJUSTED_TS return; SLID_TS clears collision_info.contact_plane_valid AND contact_plane_is_water then returns — exactly as claimed (the claim's pc:272752-272760 matches; Ghidra confirms the BN listing did not invent this branch). After the loop it sets check_cell = the new containing cell and adjust_check_pos (cell advance AFTER all per-cell collision).
|
||||
|
||||
2. The vtable+0x88 slot identity was the refutation-critical claim, and it is PROVEN, not inferred: CEnvCell::CEnvCell @0x0052c240 disassembly contains `MOV dword ptr [ESI],0x7c8c98` (instruction at 0x0052c286) — primary CEnvCell vftable base = 0x007c8c98. Ghidra DATA xref to CEnvCell::find_collisions (0x0052c100) is from 0x007c8d20 = base + 0x88. DATA xref to CEnvCell::find_env_collisions (0x0052c130) is from 0x007c8d24 = base + 0x8c. Same adjacency in CLandCell's vtable (find_collisions 0x00532d60 ref'd from 0x007c9398; find_env_collisions 0x00532f20 from 0x007c939c). So vtable+0x88 IS find_collisions; +0x8c is find_env_collisions (which is what find_collisions itself calls internally — no recursion ambiguity).
|
||||
|
||||
3. What per-cell find_collisions does (Ghidra decompiles): CEnvCell::find_collisions @0x0052c100 = vtable+0x8c (find_env_collisions) then if OK_TS → CObjCell::find_obj_collisions. CLandCell::find_collisions @0x00532d60 = env, then CSortCell::find_collisions @0x005340a0 (= CBuildingObj::find_building_collisions when the sort cell has a building), then CObjCell::find_obj_collisions. CObjCell::find_obj_collisions @0x0052b750 iterates `this->shadow_object_list` (the PER-CELL shadow-object list), calling CPhysicsObj::FindObjCollisions per entry, skipping parented objects and self, gated off for INITIAL_PLACEMENT_INSERT. So retail's per-other-cell query is env AND building AND per-cell shadow objects — the claim said env+objects; the building interposition for land cells is an additional (claim-strengthening) detail.
|
||||
|
||||
4. Ordering context (Ghidra): CTransition::transitional_insert @0x0050b6f0 per attempt calls insert_into_cell(check_cell, num_attempts) — which @0x00509e70 runs the PRIMARY cell's full vtable+0x88 find_collisions (env→[bldg]→objects) in its own inner retry loop — and only on OK_TS calls check_other_cells. So retail interleaves object collision per cell: primary-cell objects are tested before any other cell's env, and each other cell tests its own objects immediately after its env.
|
||||
|
||||
ACDREAM SIDE — read at the cited locations:
|
||||
|
||||
5. Transition.CheckOtherCells, src/AcDream.Core/Physics/TransitionTypes.cs:1632-1769 (claim said :1632-1750 — trivially off, same function): per other cell, outdoor ids (low16 < 0x100) → engine.SampleTerrainWalkable + ValidateWalkable (:1650-1684); indoor ids → BSPQuery.FindCollisions on the cell's CellStruct BSP (:1687-1732). NO shadow-object / registry iteration anywhere in the loop. Confirmed env-only.
|
||||
|
||||
6. The flat object pass: TransitionTypes.cs:900 (`var objState = FindObjCollisions(engine);` inside TransitionalInsert Phase 2) is the sole FindObjCollisions call site in the insert path (grep over src/AcDream.Core confirms; :2307 is the definition, which queries the landblock-wide ShadowObjectRegistry). The stale comment at TransitionTypes.cs:856-859 explicitly documents the simplification: "we don't have CellArray/CheckOtherCells iteration because our FindObjCollisions (via ShadowObjectRegistry) is already a flat per-landblock query" — written pre-A4; A4 later gave the ENV half per-cell treatment (CheckOtherCells) but the object half never got it.
|
||||
|
||||
7. Resolution-order divergence confirmed real: acdream per attempt = primary env (FindEnvCollisions :1954, indoor BSP :2056 / terrain :2120) → CheckOtherCells env-only (:2185) → carried-cell advance (:2192-2193) → flat object pass (:900). Retail per attempt = primary [env→bldg→obj] (with inner retry) → per other cell [env→bldg→obj] → cell advance. Two extra ordering deltas beyond the claim: (a) retail runs primary-cell objects inside insert_into_cell's inner retry loop, acdream's retry wraps both phases at the outer level; (b) acdream advances the carried cell BEFORE its object pass, retail advances AFTER all per-cell object passes.
|
||||
|
||||
JUDGMENT: both sides check out from primary sources; the divergence is real and not behaviorally-equivalent-elsewhere. The "masked today" framing is accurate — the flat landblock-wide registry query is a superset of the per-cell lists, so objects are not LOST today; the live divergence is the interleaving/ordering (a plausible contributor to threshold deltas in the #99/#108/#109 doorway family, though not proven causal for any specific one) plus the structural hole that opens the moment the A6.P4 per-cell shadow port lands. The proposed port shape (fold per-cell shadow iteration into CheckOtherCells AND the primary-cell insert, retiring the flat pass) matches retail's actual call graph. Severity "high" is fair: no standalone user-visible artifact today, but it gates the A6.P4 architecture that closes #99.
|
||||
- blastRadius: Masked today by the flat object query; becomes a correctness hole the moment the per-cell port lands (straddle coverage at doorways would be lost). Also a resolution-order divergence: retail interleaves obj collision per cell, acdream resolves all env then all objects — contributes to threshold-behavior deltas in the #99/#108/#109 doorway family.
|
||||
- retailEvidence: check_other_cells calls each other cell's vtable+0x88 find_collisions (pc:272735), which is find_env_collisions THEN find_obj_collisions per cell (Ghidra 0x0052c100 / 0x00532d60); SLID clears contact-plane fields and returns (pc:272752-272760).
|
||||
- acdreamEvidence: Transition.CheckOtherCells (TransitionTypes.cs:1632-1750) runs terrain ValidateWalkable (outdoor ids) or cell-BSP FindCollisions (indoor ids) only; no shadow-object iteration per cell. The flat FindObjCollisions at TransitionTypes.cs:900 is the sole object pass.
|
||||
- portShape: Fold the per-cell shadow-list iteration into the same loop CheckOtherCells already walks (and into the primary-cell insert), making each cell's query env-then-objects like retail's find_collisions; retire the separate Phase-2 flat object pass in TransitionalInsert.
|
||||
|
||||
### [MEDIUM] a6p5-topology-widening (adjusted) — hasExitPortal topology widening adds outdoor cells to the collision set wider than retail's straddle gate
|
||||
- correctedClaim: CONFIRMED divergence, CORRECTED port shape. Divergence (as claimed, medium severity): acdream widens the COLLISION cell set by topology — any sphere-overlapped indoor cell possessing an exterior (0xFFFF) portal triggers AddAllOutsideCells once per walk (CellTransit.cs:130-132, 614-618) — whereas retail adds outside cells only when a path sphere straddles the exterior portal plane, |dist| < radius + F_EPSILON (Ghidra 0x0052c820; caller 0x0052b4e0 has no topology branch either). Membership is unaffected (outdoorPickAllowed gate, CellTransit.cs:571/603/675). It is a deliberate, self-documented #99 stopgap keeping outdoor-registered (cellScope=0) doors findable from indoor cells via ShadowObjectRegistry.GetNearbyObjects(portalReachableCells). CORRECTED port shape: after A6.P4 registration-side flood places doors into indoor cells' shadow lists, do NOT delete lines 614-618 — that would remove acdream's only indoor-path outside-add and break retail-faithful exit demotion (straddle fires on every real building exit; without outdoor candidates in the array the pick keeps the player indoor-classified, lines 675/693/732 + comment 705-709). Instead RE-GATE the AddAllOutsideCells call from hasExitPortal to the already-implemented retail straddle flag (exitOutsideStraddle, CellTransit.cs:160-175/597), then delete the hasExitPortal plumbing (out-param and line 130-132 assignment). Once-per-walk semantics (outdoorAdded) are already retail-faithful — retail's CELLARRAY.added_outside guard at 0x00533630 does the same.
|
||||
- verifier notes: RETAIL re-derived from Ghidra decompiles (not BN pseudo-C). (1) CEnvCell::find_transit_cells @ 0x0052c820: in the exterior-portal branch (other_cell_id == 0xffffffff) a local flag (bVar7) is set iff, for some path sphere, -(F_EPSILON + radius) < dist < +(F_EPSILON + radius) against the portal plane — the straddle test exactly as claimed; CLandCell::add_all_outside_cells is called after the portal loop ONLY when that flag fired ('if (bVar7) CLandCell::add_all_outside_cells(...)'). No topology-only branch; no portal_side/exact_match test in this branch (portal_side appears only in the unloaded-interior-portal branch). (2) Caller CObjCell::find_cell_list @ 0x0052b4e0: indoor seed adds only the current cell then vtable-dispatches find_transit_cells over the growing CELLARRAY; add_all_outside_cells is called directly only for outdoor seeds ((id & 0xffff) < 0x100) — so retail has no topology widening at the caller level either. (3) add_all_outside_cells @ 0x00533630 guards on CELLARRAY.added_outside (once per walk) — acdream's outdoorAdded flag at CellTransit.cs:588/617 is retail-faithful on that sub-point. ACDREAM verified: hasExitPortal set purely on portal.OtherCellId == 0xFFFF topology (CellTransit.cs:130-132; doc 102-106 self-describes 'NOT retail'); the widening fires once per indoor walk on hasExitPortal regardless of straddle (CellTransit.cs:614-618, comment 608-613: 'by TOPOLOGY — wider than retail'); the membership PICK is protected by outdoorPickAllowed (CellTransit.cs:571, 603, consumed at 675) so this diverges only in the collision cell SET. Blast radius confirmed real: the widened set feeds ShadowObjectRegistry.GetNearbyObjects which iterates portalReachableCells unconditionally (ShadowObjectRegistry.cs:460-471), and cellScope=0 entities (server-spawned doors per the comment at ShadowObjectRegistry.cs:422-423) are keyed under outdoor landcell ids (Register, ShadowObjectRegistry.cs:77-105); Transition.CheckOtherCells also consumes outdoor ids from the set (TransitionTypes.cs:1650-1684, limited to the under-foot terrain column). Strictly wider than retail: a sphere anywhere inside a cell bearing an exterior portal (doors AND outside-facing window portals are 0xFFFF) gets outdoor cells in its collision set; retail requires plane straddle. One precision note: the widening triggers from cells IN the sphere-overlapped candidate set that possess an exterior portal (the occupied cell or an overlapped neighbour) — deep-interior cells without exterior portals never trigger it; the claim's 'ANY indoor cell that merely possesses an exit portal' is correct read that way. THE ADJUSTMENT — the claimed port shape is materially wrong: CellTransit.cs:614-618 is the ONLY AddAllOutsideCells call on the indoor path; exitOutsideStraddle currently gates only the pick (line 603), there is no separate straddle-gated set-add. Deleting lines 614-618 outright (as the port shape instructs) would also delete the retail straddle-fired outside flood: on a real building exit the straddle fires but no outdoor candidate would enter the array, outdoorResult could never set (line 675 iterates candidates), and the pick would fall through to keep-curr (line 732) — the player would stay indoor-classified after walking out, a membership regression the code's own comment (lines 705-709) documents depending on 'outside cells enter the candidate array → the normal outdoorResult path demotes there, retail-faithfully'.
|
||||
- blastRadius: Keeps #99 partially papered over today (named question 5): outdoor-registered doors stay findable from ANY indoor cell that merely possesses an exit portal, not just when a sphere straddles it. Over-wide set = extra false-positive collision candidates from deep interior cells near exits.
|
||||
- retailEvidence: CEnvCell::find_transit_cells adds outside cells ONLY when a path sphere straddles an exterior portal plane — |dist| < radius + ε (Ghidra 0x0052c820; live-binary verified 2026-06-10 per CellTransit.cs:134-145 comment). No topology-only branch exists.
|
||||
- acdreamEvidence: hasExitPortal output (CellTransit.cs:102-106, set at 130-132) drives AddAllOutsideCells once per indoor walk regardless of straddle (CellTransit.cs:614-618); self-documented as a non-retail #99 stopgap pending A6.P4. The membership PICK already ignores it (outdoorPickAllowed gate, CellTransit.cs:571/603).
|
||||
- portShape: Once registration-side flood places doors into indoor cells' lists (divergence 1), delete hasExitPortal and the lines 614-618 widening; the collision cell set reverts to the pure straddle-gated retail flood. This is the retail answer to named question 5: the door is found in the indoor cell's OWN list, so the indoor query never needs outdoor cells it doesn't overlap.
|
||||
|
||||
### [MEDIUM] single-landblock-grid-clamp (confirmed) — Registration grid clamps to the entity's own landblock; retail's add_all_outside_cells crosses block borders
|
||||
- correctedClaim: Confirmed as claimed, with one strengthening refinement: the divergence is not strictly invisible today. The 9-landblock query sweep compensates only on the outdoor radial path; the #98 indoor-primary gate (ShadowObjectRegistry.cs:494-495) skips that sweep, so an indoor sphere reaching outdoor cells through portalReachableCells (exact cell-id lookups, block-crossing per CellTransit.AddAllOutsideCells) can ALREADY miss an object whose footprint crosses a landblock seam but is registered only under its own block's prefix. The future A6.P4 slice-3 deletion of the radial sweep widens this from "doorway-at-a-seam" to all block-seam footprints.
|
||||
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C), and it checks out end-to-end:
|
||||
|
||||
1. Registration path uses the same block-agnostic cell-set machinery as transit. CPhysicsObj::calc_cross_cells (Ghidra 0x00515230) builds a CELLARRAY via CObjCell::find_cell_list(&m_position, numSpheres, spheres, &cell_array, NULL) (or find_bbox_cell_list for state&0x10000 objects), then calls remove_shadows_from_cells + add_shadows_to_cells(this, &cell_array). add_shadows_to_cells (Ghidra 0x00514ae0) iterates the CELLARRAY and calls CObjCell::add_shadow_object per cell — so retail's shadow registration set IS the find_cell_list output, verbatim.
|
||||
2. find_cell_list (Ghidra 0x0052b4e0, the citation in the claim — verified correct) calls CLandCell::add_all_outside_cells(position, numSpheres, spheres, cellArray) whenever the primary cell is outdoor ((objcell_id & 0xffff) < 0x100).
|
||||
3. add_all_outside_cells sphere variant (Ghidra 0x00533630): per sphere, calls LandDefs::adjust_to_outside(&cellId, ¢er). adjust_to_outside (Ghidra 0x005a9bc0) re-homes the point across landblock borders: get_outside_lcoord → lcoord_to_gid replaces the cell id with whatever block the point actually falls in, then folds the local origin back into [0, block_length) via floor(x/block_length) subtraction. Then gid_to_lcoord yields GLOBAL landscape coordinates and add_outside_cell + check_add_cell_boundary add cells by global lcoord.
|
||||
4. add_outside_cell (Ghidra 0x00532ec0): bounds-checks lcoords to [0, 0x7F8) (255×8 cells, whole map) and composes gid = (((x&~7)<<5)|(y>>3))<<16 | ((x&7)*8+(y&7)+1) — the landblock prefix is RE-DERIVED from the global lcoord. check_add_cell_boundary (Ghidra 0x00533260) adds lx±1/ly±1 neighbors in global lcoords when the sphere center is within radius of a 24m cell edge — crossing a multiple-of-8 lcoord lands in the adjacent landblock with the adjacent block's prefix. Block-agnostic, confirmed.
|
||||
5. The bbox/parts variant (Ghidra 0x00533360, used via find_bbox_cell_list for state&0x10000 objects) computes a global-lcoord rectangle over all parts' bounding boxes and calls add_cell_block(xmin,ymin,xmax,ymax) — also block-agnostic. Both registration shapes cross block borders.
|
||||
|
||||
ACDREAM SIDE — cited lines verified exact:
|
||||
- ShadowObjectRegistry.Register clamps minCx/maxCx/minCy/maxCy to [0,7] (src/AcDream.Core/Physics/ShadowObjectRegistry.cs:80-83) and composes cell ids under the single lbPrefix = landblockId & 0xFFFF0000 (:87, :93). RegisterMultiPart does the same per shape (:173-176, prefix at :140, compose at :182). Entries never land under a neighbor block's prefix (the cellScope path :62-72 is exact-cell and irrelevant to the outdoor grid).
|
||||
- GetNearbyObjects compensates at query time with the 9-landblock sweep (:502-539), computing per-neighbor local coords and clamped ranges.
|
||||
- Production registration sites pass the entity's own landblock (e.g. GameWindow.cs:6176-6182 passes origin.X, origin.Y, lb.LandblockId with worldRadius = local bounding-sphere radius × scale), so a building part near a seam genuinely loses its neighbor-block footprint cells.
|
||||
- The transit-side port CellTransit.AddAllOutsideCells (src/AcDream.Core/Physics/CellTransit.cs:257-331) is a faithful block-crossing port of the same retail routine (AdjustToOutside + GidToLcoord + LcoordToGid, explicit "NO same-block filter" note at :324-328; boundary-neighbor signs at :284-297 match the Ghidra decompile of check_add_cell_boundary exactly: point > cellLen−r → +1, point < r → −1). So the proposed port shape (have registration reuse this) is sound.
|
||||
|
||||
ONE REFINEMENT (strengthens, doesn't weaken): the blast-radius statement "invisible today only because the query side sweeps 9 landblocks" is slightly understated. The #98 indoor-primary gate (ShadowObjectRegistry.cs:494-495) RETURNS before the 9-block sweep when the querying sphere's primary cell is indoor (and not viewer) — production call site TransitionTypes.cs:2376-2382 passes primaryCellId = sp.CheckCellId. On that path, outdoor-registered shadows are reachable ONLY via exact cell-id lookups from portalReachableCells (:460-471), whose outdoor ids come from the block-crossing CellTransit.AddAllOutsideCells. If a building/door footprint straddles a landblock seam and the indoor sphere's exit-portal expansion yields a NEIGHBOR-block outdoor cell, the clamped registration means that lookup misses the object TODAY — no sweep compensates. Geometrically narrow (doorway at a block seam), but the divergence is already live on the indoor→outdoor portal query path, not purely a future A6.P4-slice-3 exposure. Severity "medium" remains fair.
|
||||
|
||||
Minor caveat on the port shape: "BuildShadowCellSet" is the planned A6.P4 component named in docs/superpowers/specs/2026-05-24-phase-a6-p4-retail-shadow-architecture.md — it does not exist in src/ yet. The port shape should be read as "the A6.P4 registration cell-set builder must use CellTransit.AddAllOutsideCells (block-crossing) instead of inheriting the registry's private clamped grid math" — which this verification supports. Also note for the porter: RemoveLandblock (ShadowObjectRegistry.cs:337-361) removes cells by landblock prefix; once registration crosses blocks, an entity's entries can live under multiple prefixes and landblock unload must not strand or half-remove them (retail handles this via remove_shadows_from_cells per object, Ghidra 0x00515230 caller).
|
||||
- blastRadius: Invisible today only because the query side sweeps 9 landblocks; the moment the radial sweep is deleted (A6.P4 slice 3), an object whose footprint crosses a landblock border silently vanishes from neighbor-block cells — missing collisions at block seams.
|
||||
- retailEvidence: CLandCell::add_all_outside_cells operates on lcoords and adds whatever outdoor cells the spheres overlap, block-agnostic (called from find_cell_list Ghidra 0x0052b4e0; same routine the transit path uses — the 2026-05-25 AddAllOutsideCells coord fix in acdream's TRANSIT port already handles cross-block).
|
||||
- acdreamEvidence: Register/RegisterMultiPart clamp minCx/maxCx/minCy/maxCy to 0..7 under a single lbPrefix (ShadowObjectRegistry.cs:80-83, 173-176) — entries never land in a neighboring landblock's cells. The 9-landblock loop in GetNearbyObjects (ShadowObjectRegistry.cs:502-539) compensates at query time.
|
||||
- portShape: BuildShadowCellSet's outdoor branch should call the existing CellTransit.AddAllOutsideCells (block-crossing, already fixed) instead of the registry's private clamped grid; delete the private grid math.
|
||||
|
||||
### [LOW] movement-reregistration-source (confirmed) — Moved entities re-register from a fresh XY grid; retail reuses the transition's own cell array every tick
|
||||
- verifier notes: RETAIL SIDE — re-derived from Ghidra (not BN), all three load-bearing pieces check out:
|
||||
|
||||
1. SetPositionInternal tail (Ghidra decompile 0x00515330): after handle_all_collisions, the function ends with exactly the claimed gate structure: `if (this->cell != 0) { if ((this->state & 0x10000) != 0) { calc_cross_cells(this); return 1; } if ((param_1->cell_array).num_cells != 0) { remove_shadows_from_cells(this); add_shadows_to_cells(this, ¶m_1->cell_array); } }`. The BN pseudo-C at pc:283536-283545 (addresses 0051550b-0051554c) matches the Ghidra decompile line-for-line — no invented branch here (BN's `num_cells > 0` vs Ghidra's `!= 0` is equivalent for an unsigned field). The 0x10000 flag is HAS_PHYSICS_BSP_PS per acclient.h:2833, so "BSP-bearing objects recompute via calc_cross_cells, everything else reuses the transition's cell_array" is accurate.
|
||||
|
||||
2. The "check_other_cells already built this step" attribution is accurate, which I specifically suspected of being a caller/callee conflation: the Ghidra decompile of CTransition::check_other_cells (0x0050ae50) BUILDS the array before consuming it — its first real act is `CObjCell::find_cell_list(&this->cell_array, &local_4c, &this->sphere_path)`, then it iterates `this->cell_array.cells[i].cell`. So the CELLARRAY that SetPositionInternal hands to add_shadows_to_cells is the portal-aware set populated during this transition step.
|
||||
|
||||
3. add_shadows_to_cells (Ghidra 0x00514ae0) does what the divergence implies: it sizes this->shadow_objects to cell_array.num_cells, stamps each CShadowObj with `cell_array.cells[i].cell_id`, and registers each into the corresponding CObjCell via CObjCell::add_shadow_object (plus CPartArray::AddPartsShadow), recursing into children. The targets are CObjCell pointers from the transition — indoor CEnvCells included — NOT an XY ground-grid sweep. (Nuance: a `state & 0x1000` particle-emitter branch routes to add_particle_shadow_to_cell instead; irrelevant for movers.)
|
||||
|
||||
4. Cadence claim ("retail per successful transition step") verified via xrefs: SetPositionInternal(CTransition*) is called from UpdateObjectInternal (call site 0x00515914 — the per-tick movement path) and from SetPosition (call site 0x0051614b — server-driven position sets), so both retail paths funnel shadow re-registration through the transition's cell_array.
|
||||
|
||||
ACDREAM SIDE — confirmed at the cited lines plus production call sites:
|
||||
|
||||
5. ShadowObjectRegistry.UpdatePosition (src/AcDream.Core/Physics/ShadowObjectRegistry.cs:219-278) re-runs RegisterMultiPart (line 245) for cached multi-part entities or Register (line 274) for the single-shape path. BOTH calls leave cellScope at its default 0u, so placement always goes through the outdoor 24m XY-grid loops (Register lines 77-103; RegisterMultiPart lines 169-186). The method signature takes no cell id and no cell set — the landblockId parameter is masked to the 0xFFFF0000 prefix for grid placement only. No transition cell set is consulted anywhere in the file.
|
||||
|
||||
6. Production call site: exactly one — GameWindow.cs:4492, on inbound server position updates for remote entities (`update.Guid != _playerServerGuid`), passing only (entityId, worldPos, rot, origin, p.LandblockId). The spawn-time registration for server-spawned entities (GameWindow.cs:3264 RegisterMultiPart) likewise passes no cellScope. So even an entity the server reports inside an EnvCell gets its shadow re-registered into outdoor landcells on every move — consistent with the known #99 family (doors/NPCs invisible to fully-indoor queries under the #98 indoor-primary gate at ShadowObjectRegistry.cs:494).
|
||||
|
||||
7. Real divergence, not behavioral equivalence: retail's re-registration target set is the portal-aware transition CELLARRAY (can contain indoor cells); acdream's is the outdoor XY grid, unconditionally. The blast-radius framing is honest and correct — cadence is comparable today, but after the per-cell shadow port (divergence-1 / A6.P4), UpdatePosition's XY-grid source would clobber correct per-cell registration on an entity's first move. Severity "low" is defensible as a port-ordering footnote (its present-day visible symptom is already accounted under #99).
|
||||
|
||||
One refinement worth carrying into the port shape (not a correction): retail only re-registers when the transition's cell_array is NON-EMPTY — `if (num_cells != 0)` — i.e., an empty array leaves the previous shadows in place rather than recomputing or clearing. The ported UpdatePosition should preserve that keep-when-empty behavior, and should keep the HAS_PHYSICS_BSP_PS (0x10000) → calc_cross_cells split for BSP-bearing objects.
|
||||
- blastRadius: Cadence is comparable (acdream re-registers per server UpdatePosition; retail per successful transition step), but after the per-cell port the SOURCE matters: re-registering via the XY grid would undo divergence-1's fix for every moving door/NPC on its first move.
|
||||
- retailEvidence: SetPositionInternal(CTransition*) tail (Ghidra 0x00515330; pc:283536-283545): non-BSP objects re-register via remove_shadows_from_cells + add_shadows_to_cells(&transit->cell_array) — the array check_other_cells already built this step; HAS_PHYSICS_BSP objects recompute via calc_cross_cells.
|
||||
- acdreamEvidence: ShadowObjectRegistry.UpdatePosition (ShadowObjectRegistry.cs:219-278) re-runs Register/RegisterMultiPart → XY grid placement; no transition cell set is consulted.
|
||||
- portShape: UpdatePosition takes the mover's current cell id (and ideally its transition cell set when one exists) and routes through BuildShadowCellSet, mirroring SetPositionInternal's reuse; remote entities without a local transition just run the flood from their reported cell.
|
||||
|
||||
## OPEN QUESTIONS
|
||||
|
||||
- find_bbox_cell_list's leaf (CPartArray::calc_cross_cells_static, called from Ghidra 0x00510fc0) was not decompiled — the exact bbox-driven cell rule for HAS_PHYSICS_BSP_PS statics (dungeon furniture class) is unverified; matters for which registration branch acdream's BSP-bearing statics should take.
|
||||
- Unloaded-cell shadows: add_shadows_to_cells (Ghidra 0x00514ae0) stores cell=null for CELLARRAY entries whose cell pointer was null (unloaded neighbor added by id in find_transit_cells); what re-attaches those shadows when the cell later loads is inside CObjectMaint::InitObjCell (called from init_objects, Ghidra 0x0052b420), which was not decompiled. This is spec §7 Q3 (streaming order) — still open, and the acdream port needs an equivalent (re-run BuildShadowCellSet for objects homed in / overlapping a newly streamed cell).
|
||||
- Registration spheres: the CylSphere overload (Ghidra 0x0052b9f0) builds each flood sphere at the cylinder's low_pt with the cyl radius — i.e. the BASE of the cylinder, not its center, and ignoring height. Verified in Ghidra but surprising (tall objects' flood reach is their base only); worth a second look (or a live-binary spot check) before porting verbatim.
|
||||
- INITIAL_PLACEMENT_INSERT parity: retail's find_obj_collisions skips the whole shadow loop for initial-placement inserts (Ghidra 0x0052b750); whether acdream's placement path has an equivalent skip was not checked in this area pass.
|
||||
- Is the local player itself registered in acdream's ShadowObjectRegistry (the SelfEntityId skip at TransitionTypes.cs:2398 implies live entities are)? Retail registers the player like any CPhysicsObj and relies on the self-skip in find_obj_collisions; parity of WHO is in the lists (not just how they're queried) wasn't fully audited.
|
||||
- check_other_cells halt semantics: the BN pseudo-C shows SLID (case 4) clearing contact-plane fields and RETURNING (pc:272752-272760), while acdream's ApplyOtherCellResult path was ported from the same lines — given this function family's invented-branch history, the exact SLID-return-vs-continue behavior deserves a one-shot Ghidra confirm of 0x0050ae50 before the per-cell port hardens it (the Ghidra server decompiled neighbors of this function fine, but I did not pull this exact one).
|
||||
166
docs/research/2026-06-11-holistic-map/wf1-statics-dynamics.md
Normal file
166
docs/research/2026-06-11-holistic-map/wf1-statics-dynamics.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# 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.
|
||||
95
docs/research/2026-06-11-holistic-map/wf2-camera-viewer.md
Normal file
95
docs/research/2026-06-11-holistic-map/wf2-camera-viewer.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# 2.1 Camera and viewer (issue #115 — camera feel in cramped interiors)
|
||||
|
||||
## RETAIL
|
||||
|
||||
RETAIL CALL CHAIN (one pass per render frame, two phases):
|
||||
|
||||
PHASE 1 — physics/update: Client::UseTime (pc:18670, 0x00411c40) → SmartBox::UseTime (pc listing at 0x00455410) → CPhysics::UseTime (call at 0x004554a7; body at Ghidra 0x00509950). CPhysics::UseTime iterates every physics object calling CPhysicsObj::update_object, and immediately after updating THE PLAYER object it calls SmartBox::PlayerPhysicsUpdatedCallback (Ghidra 0x005099ed-0x005099f2; fires once per frame whenever Timer::cur_time advanced — the gate at 0x0050996c only early-outs on zero elapsed). PlayerPhysicsUpdatedCallback (Ghidra-confirmed decompile of 0x00452d60; pc:91836-91844) is three lines: `sought = CameraManager::UpdateCamera(camera_manager, &ret, &this->viewer); viewer_sought_position = sought;`. THE LOAD-BEARING FACT: the third argument — the interpolation ORIGIN — is `&this->viewer`, the PUBLISHED, COLLIDED viewer from the previous frame's sweep. The collided eye feeds back into the damping every frame.
|
||||
|
||||
CameraManager::UpdateCamera (Ghidra 0x00456660, full decompile read): (a) dt = Timer::cur_time − last_update_time (own clock, real seconds); (b) integrates held-key offset-movement flags into viewer_offset / pivot_offset scaled by dt; (c) builds the TARGET pose — pivot via QueryPivotPosition, heading via LOOK_AT_OBJECT / ALIGN_WITH_PLANE 5-sample velocity ring / LOOK_IN_DIRECTION, target origin = pivot + heading-frame-rotated viewer_offset (Frame::localtoglobal), target rotation = look frame; (d) translation alpha = t_stiffness * dt * 10.0 clamped to [0,1] (Ghidra tail: `fVar19 * (float)local_178 * ___real_4024000000000000`; t_stiffness ≥ 1−F_EPSILON ⇒ instant), rotation alpha likewise from r_stiffness; (e) `Frame::interpolate_origin(&result, ¶m_1->frame /* = published viewer */, &target, t_alpha)` and `interpolate_rotation(..., r_alpha)` — an exponential lerp FROM the published collided viewer TOWARD the full-boom target; (f) convergence snap: when not instant and `Position::distance(result, param_1) < 2*F_EPSILON` and `Frame::close_rotation(result, param_1, F_EPSILON)` → return param_1 unchanged (exact fixed point; F_EPSILON = 0.000199999995). Constructor defaults t_stiffness = r_stiffness = 0.45 (pc:95963-95964, 0x004570b1-0x004570b4). There is NO explicit boom-distance lerp, NO hysteresis constants, NO per-frame max-rate clamp anywhere in update_viewer/set_viewer — the famous "shorten fast, lengthen slow" feel is EMERGENT: the sweep clamps the published eye instantly, and because the next frame's lerp origin IS that clamped eye, re-extension toward the full boom eases out exponentially (~alpha 7.5%/frame at 60fps), and while the player turns, the sought eye hugs the wall instead of orbiting at full radius behind it.
|
||||
|
||||
PHASE 2 — draw: the same per-frame SmartBox pass ends in SmartBox::DrawNoBlit (call at 0x0045557a; Ghidra labels the containing function Draw) → SmartBox::update_viewer (Ghidra xref: from 0x00454c34 in DrawNoBlit, UNCONDITIONAL — the sweep re-runs EVERY render frame regardless of whether anything moved). update_viewer (Ghidra-confirmed decompile of 0x00453ce0; pc:92675-92887): (1) player->cell null → reenter_visibility, else set_viewer(player_pos, 1) + viewer_cell = null; (2) pivot = part frame at camera_manager->pivot_part_index (else m_position) + rotated camera_manager->pivot_offset; (3) sweep START cell: outdoor ((objcell_id & 0xffff) < 0x100) → player->cell; indoor → CPhysicsObj::AdjustPosition seats the cell at the PIVOT point, falling back to player->cell; (4) sweep target = viewer_sought_position's origin re-expressed in the start cell (Position::localtoglobal); (5) CTransition with init_object(player, 0x5c), init_sphere(1, &viewer_sphere /* 0.3 m, pc:93314 */, 1.0), init_path(startCell, pivotPos, soughtPos), find_valid_position; (6) SUCCESS → set_viewer(&sphere_path.curr_pos, 0) and `viewer_cell = sphere_path.curr_cell` — the published render position IS the raw collided sweep stop, and the viewer cell IS the transition's graph-tracked end cell; (7) fallback 1: AdjustPosition at the raw sought position (which carries viewer_sought_position's own objcell_id context) → set_viewer(sought, 0), viewer_cell = adjusted cell; (8) fallback 2: set_viewer(player->m_position, 1), viewer_cell = null. set_viewer (Ghidra 0x00452c40) copies the Position verbatim into this->viewer (param_2 != 0 additionally resets viewer_sought_position — failure-path re-seed), then re-anchors the viewer light, SoundManager::SetPlayerPosition, LScape::set_sky_position, and SceneTool::SetupCamera(&this->viewer) — there is NO separate smoothed render position; the renderer consumes the collided position raw, paired with the DAMPED rotation (the sought frame's interpolated rotation rides through the sweep unchanged — flags 0x5c include FreeRotate).
|
||||
|
||||
INPUT WHILE COLLIDED (Q3): held camera keys are polled per frame in CameraSet::UpdateCamera (Ghidra 0x00458ae0, pc:97625-97745; called per frame from the UI UseTime at 0x004d74b9) → CameraSet::Rotate (0x00458310, pc:97103-97230) rotates the viewer_offset vector around Z by angle = cm->m_rCameraAdjustmentSpeed × (cur_time − m_ttLastRotate) (sin/cos at 0x00458609-0x00458629), then SetTargetDirection + SetTargetForOffset; mouse-look reaches the same Rotate with a scale argument (callers at 0x00458ef9). Rotation input ONLY moves the TARGET; no stiffness change during rotate (stiffness is forced to 1.0 only by mode switches: SetScale 0x004578fe, SetInHead 0x00458cfc, LookDown-family 0x00458097/0x00458204). The swing arc therefore collides CONTINUOUSLY: damping eases the sought eye from the published collided pose toward the rotated target each frame, and update_viewer re-sweeps pivot→sought every render frame.
|
||||
|
||||
PLAYER FADE (Q4): CameraSet::UpdateCamera (0x00458ae0): InHead → CPhysicsObj::SetTranslucencyHierarchical(player, 1f) (0x00458bb8, fully invisible); otherwise d = Position::distance(pivot, &sbox->viewer) — the PUBLISHED COLLIDED viewer (0x00458beb); d ≥ 0.449999988 → SetTranslucencyHierarchical(player, 0f) (0x00458ca1, opaque); d < 0.45 → t = 1 − (0.200000003 − d)/(0.2 − 0.45), clamped to [0,1] (0x00458c19-0x00458c53), applied via SetTranslucencyHierarchical (0x00458c6d). So retail fades the player out over the 0.45 m → 0.20 m approach band, keyed off the collided eye, applied to the actual player mesh hierarchy every frame.
|
||||
|
||||
## ACDREAM
|
||||
|
||||
ACDREAM CALL CHAIN (one pass per update tick, all in one phase):
|
||||
|
||||
GameWindow's player-mode update (src/AcDream.App/Rendering/GameWindow.cs:6728-6838) runs PlayerMovementController.Update (GameWindow.cs:6791), then updates BOTH chase cameras every frame — legacy ChaseCamera (GameWindow.cs:6821-6823) and RetailChaseCamera (GameWindow.cs:6832-6838) — passing RenderPosition, yaw, BodyVelocity, IsOnGround, ContactPlane.Normal, frame dt, the player CellId, and LocalEntityId. CameraController.Active picks per-read via CameraDiagnostics.UseRetailChaseCamera (CameraController.cs:20-33), default ON (CameraDiagnostics.cs:27-28); camera collision default ON (CameraDiagnostics.cs:48-49).
|
||||
|
||||
RetailChaseCamera.Update (src/AcDream.App/Rendering/RetailChaseCamera.cs:122-209): (1) 5-frame velocity ring + average (:133-134, mirrors retail old_velocities); (2) heading = facing projected on contact plane (ComputeHeading :140-145, :278-324); (3) target eye = pivot (player + 1.5 m, :151) − forward·D·cosP + up·D·sinP with Distance default 2.61 / Pitch 0.291 (:57-60); (4) damping: `_dampedEye = Lerp(_dampedEye, targetEye, alpha)` with alpha = stiffness·dt·10 (:167-170, ComputeDampingAlpha :390-396), stiffness defaults 0.45/0.45 (CameraDiagnostics.cs:56-63 — matches retail pc:95963), plus the ported convergence snap (:172-176, :408-416, epsilons :97-98); (5) collision: `publishedEye` starts as `_dampedEye`; if CollideCamera and probe set, `swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId, playerPosition)`; publishedEye = swept.Eye; ViewerCellId = swept.ViewerCellId (:188-198). THE LOAD-BEARING DIVERGENCE: the comment block at :179-187 explicitly states the collided result "must NOT feed back into the damped state" and claims retail keeps two non-feeding states — `_dampedEye` (:104) only ever lerps from its own previous UNCOLLIDED value (:169); (6) publish Position = publishedEye, View = LookAt(publishedEye, publishedEye + _dampedForward) (:202-203 — position collided, rotation damped, same split as retail); (7) PlayerTranslucency = ComputeTranslucency(distance(publishedEye, pivot)) with Far 0.45 / Near 0.20 (:207-208, :454-463 — formula matches retail 0x00458c19) — but grep over src/ shows PlayerTranslucency has ZERO consumers outside RetailChaseCamera.cs itself (:82, :119, :208); no code applies it to the player mesh.
|
||||
|
||||
PhysicsCameraCollisionProbe.SweepEye (src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:24-103, KEEP-LISTED verbatim port): 0.3 m sphere (:18), indoor start-cell seated at the pivot via AdjustPosition (:36-40 = retail pc:92824-92844), ResolveWithTransition with retail's exact 0x5c flags (:68-69), success → swept eye + r.CellId (:92 = retail viewer_cell = sphere_path.curr_cell), fallback 1 AdjustPosition at the sought eye (:94-99 — seeds with the PLAYER cell rather than the sought position's own cell, a documented small divergence from retail's local_120 carrying viewer_sought_position.objcell_id), fallback 2 snap to player + cell 0 (:102).
|
||||
|
||||
Input: held zoom/raise keys integrate Distance/Pitch at CameraAdjustmentSpeed·dt (GameWindow.cs:6743-6754, default 40.0 CameraDiagnostics.cs:78); RMB mouse orbit writes YawOffset −= filteredDx·0.004·sens through the ported low-pass FilterMouseDelta (GameWindow.cs:1063-1096, RetailChaseCamera.cs:231-244). Re-collide cadence MATCHES retail: the sweep runs every frame the camera updates, unconditionally (:193-198).
|
||||
|
||||
Consumption: GameWindow.OnRender roots the render at the VIEWER cell — viewerCellId = _retailChaseCamera.ViewerCellId when player mode + retail cam (GameWindow.cs:7301-7305), visibility = ComputeVisibilityFromRoot(viewerRoot, camPos) (:7313), while lighting keys on the PLAYER CurrCell (:7291-7296, :7337) — matching retail's player->cell vs SmartBox->viewer_cell split.
|
||||
|
||||
## DIVERGENCES
|
||||
|
||||
### [HIGH] boom-no-collided-feedback (confirmed) — Sought eye never re-anchors to the published collided viewer — retail's emergent boom easing is severed
|
||||
- verifier notes: Re-derived the full retail camera loop from Ghidra decompiles (not BN pseudo-C) and checked every acdream citation against the actual code. All load-bearing elements of the claim survive.
|
||||
|
||||
RETAIL (Ghidra): (1) SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60 — `UpdateCamera(this->camera_manager, &ret, &this->viewer)` with the result overwriting `viewer_sought_position`; the interpolation seed passed in is the PUBLISHED viewer, exactly as claimed. (2) CameraManager::UpdateCamera 0x00456660 tail — `local_158.objcell_id = param_1->objcell_id; Frame::interpolate_origin(&local_158.frame, ¶m_1->frame, target_frame, alpha)` where param_1 IS that published-viewer argument, alpha = t_stiffness·dt·10 clamped to 1 (constant ___real_4024000000000000 = 10.0), plus interpolate_rotation(r_stiffness·dt·10); convergence snap verified: when translation alpha unsaturated AND Position::distance(result, param_1) < F_EPSILON+F_EPSILON AND close_rotation(F_EPSILON), returns a copy of param_1 (published viewer) unchanged — matches the claim's "distance < 2·F_EPSILON" wording. (3) SmartBox::update_viewer 0x00453ce0 — reads viewer_sought_position, CTransition::init_path(cell, &pivot, &sought) (sweep starts at the PIVOT), init_sphere(viewer_sphere), and on success publishes the clamp RAW: set_viewer(&sphere_path.curr_pos, 0) + viewer_cell = sphere_path.curr_cell. (4) SmartBox::set_viewer 0x00452c40 — straight copy into this->viewer, no lerp; param_2!=0 would also reseed sought but the normal publish passes 0. (5) Call sites: update_viewer called unconditionally from SmartBox::DrawNoBlit 0x00454c34 when player != null; PlayerPhysicsUpdatedCallback fired from the physics-object update loop at 0x005099f2 (pc:271646) when the updated object is the player. No hysteresis constants exist; shorten-instant/lengthen-damped is emergent from publish→re-anchor→sweep, as claimed.
|
||||
|
||||
ACDREAM: RetailChaseCamera.cs:169 — `Vector3.Lerp(_dampedEye, targetEye, tAlpha)` lerps from its own previous never-collided value; grep confirms _dampedEye is written ONLY at :161 (init) and :175-176 (convergence snap) — the swept result at :195-197 goes into local `publishedEye` + ViewerCellId only, so the collided eye never re-anchors the sought state anywhere in the codebase. The :179-187 comment explicitly forbids feedback and mis-attributes that design to retail — refuted by the 0x00452d60/0x00456660 decompiles. Production-active: CameraDiagnostics.UseRetailChaseCamera (CameraDiagnostics.cs:27-28) and CollideCamera (:48-49) both default ON, so this is the live path, not a flag-gated experiment.
|
||||
|
||||
Behavioral consequences check out mechanically: (a) while collided+turning, acdream's sweep target is the full-boom point orbiting behind the wall (publishedEye = fresh per-frame clamp of pivot→far-target → stair-steps along wall features), retail's sought hugs the published clamp (moves only alpha per update toward full boom → clamp point glides); (b) on obstruction clear, acdream's _dampedEye has already converged to full boom during the collision (the lerp toward targetEye is never impeded), so the eye pops the full clamped-to-boom delta in one frame (~2.3 m worst case at Distance 2.61, 0.3 m sphere), where retail eases out exponentially. The #109 render-root amplifier remains a labeled hypothesis (ViewerCellId = render root per GameWindow.cs:7303; a 1-frame eye jump crossing portal planes flipping it is plausible but unverified) — appropriately flagged as such in the original claim.
|
||||
|
||||
One framing correction (does not change the verdict): retail's re-anchoring fires per PLAYER-PHYSICS UPDATE (the 0x005099f2 loop), not per render frame, and UpdateCamera computes alpha from real elapsed time (Timer::cur_time − last_update_time) — so "~7.5%/frame" is a 60 Hz illustration of a time-constant easing, not a fixed per-render-frame rate. The sweep (update_viewer) runs per render frame; the sought easing runs at physics-update cadence with time-based alpha. The proposed port doing both per render frame with dt-based alpha is behaviorally equivalent. Port shape is sound: retail's sweep starting at the PIVOT (not the previous eye) is what makes publish-feedback a stable fixed point, so the historical oscillation feared in the :183-187 comment (which arose under a different state arrangement) should not reproduce if the ordering (interpolate-from-published → sweep pivot→sought → publish) is ported exactly; the corner-seal replay + cramped-interior visual gate validation step is the right acceptance test.
|
||||
- blastRadius: PRIMARY #115 suspect ("camera feels draggy/jittery vs retail when turning in cramped interiors — like dragging over walls instead of gliding"). Two symptom modes from one root cause: (a) while turning with the boom collided, acdream's sweep target is the FULL-distance eye orbiting behind walls, so the published eye is a fresh per-frame clamp of the pivot→far-target ray — it jumps discontinuously from wall feature to wall feature (stair-stepping/jitter = "dragging over walls"); retail's sought eye starts each frame AT the collided eye and moves only ~7.5%/frame toward the full boom, so the target hugs the wall and the clamp point glides; (b) the instant the sweep clears an obstruction, acdream's eye snaps out to full boom distance in ONE frame (up to ~2.3 m pop), where retail eases out exponentially over ~10-20 frames. Secondary: a 1-frame eye jump can cross multiple portal planes, flipping ViewerCellId (= the render root, GameWindow.cs:7301-7313) discontinuously — a plausible amplifier for #109 far-door render-root oscillation when the eye sits near a clamp boundary (hypothesis, not verified). Also makes #114-class shell-clip pops more noticeable since the eye teleports rather than glides between clip regimes.
|
||||
- retailEvidence: SmartBox::PlayerPhysicsUpdatedCallback (Ghidra-confirmed decompile 0x00452d60; pc:91836-91844): `sought = CameraManager::UpdateCamera(cm, &ret, &this->viewer)` — the interpolation origin is the PUBLISHED COLLIDED viewer; result overwrites viewer_sought_position every frame. CameraManager::UpdateCamera (Ghidra 0x00456660 tail): `Frame::interpolate_origin(&result, ¶m_1->frame, &target, t_stiffness·dt·10)` + interpolate_rotation + convergence snap (distance < 2·F_EPSILON AND close_rotation(F_EPSILON) → return param_1). SmartBox::update_viewer (Ghidra 0x00453ce0) then sweeps pivot→sought per render frame (xref: unconditional call from DrawNoBlit 0x00454c34) and publishes the clamp RAW via set_viewer(&sphere_path.curr_pos, 0) (Ghidra 0x00452c40 — straight copy, no lerp). No hysteresis constants exist; shorten-instant/lengthen-damped is emergent from this loop.
|
||||
- acdreamEvidence: RetailChaseCamera.cs:169 — `_dampedEye = Lerp(_dampedEye, targetEye, alpha)` lerps from its own previous UNCOLLIDED value; :188-198 — collided result goes into a local `publishedEye` only; :179-187 — the comment explicitly forbids feedback ("must NOT feed back into the damped state") and mis-attributes that design to retail ("retail ... keeps TWO states ... collision ... must NOT feed back") — refuted by the Ghidra decompile of 0x00452d60. The collided distance is applied RAW per frame with no easing on re-extension and no wall-hugging of the sweep target.
|
||||
- portShape: Replicate retail's two-phase loop: keep ONE published viewer state (Position: cell + origin + rotation). Per frame, FIRST compute sought = interpolate_origin/rotation FROM the published viewer toward the full-boom target (alpha = stiffness·dt·10, convergence snap vs the published viewer — the existing ApplyConvergenceSnap epsilons are already correct), THEN run the keep-listed sweep pivot→sought (probe unchanged), THEN publish the swept result (position + curr_cell) as the viewer that seeds the NEXT frame's interpolation. Delete the separate never-collided _dampedEye; _dampedForward re-anchors from the published frame the same way (rotation is never clamped so behavior is identical). Validate against the historical oscillation note (:183-187) with the corner-seal replay + a cramped-interior visual gate — retail's shape is a stable fixed point (sweep starts at the PIVOT, not the previous eye), so the old vibration should not reproduce if the ordering is ported exactly.
|
||||
|
||||
### [MEDIUM] player-fade-computed-not-applied (confirmed) — Player-mesh close-camera fade is computed but never applied to the player
|
||||
- correctedClaim: Claim confirmed as stated, with one immaterial precision: in the d ≥ 0.45 branch retail calls SetTranslucencyHierarchical(player, 0) only when the current translucency is > 0 (a redundancy guard at the `if (0.0 < t)` check in 0x00458ae0), not unconditionally every frame — behaviorally identical to the claim. Everything else (thresholds 0.449999988/0.200000003, ramp formula, collided-viewer distance source, InHead → 1.0, per-frame application via gmSmartBoxUI::UseTime, hierarchical part-array application, and acdream computing-but-never-consuming PlayerTranslucency) checks out against Ghidra and the actual acdream call sites.
|
||||
- verifier notes: RETAIL re-derived from Ghidra decompile (not BN pseudo-C): CameraSet::UpdateCamera @ 0x00458ae0 (header pc:97643) — InHead → CPhysicsObj::SetTranslucencyHierarchical(player, 1.0); else d = Position::distance(pivot-from-CameraManager::QueryPivotPosition, &sbox->viewer) [the published COLLIDED viewer]; if d < CAMERA_MIN_CHAR_DIST2 → t = 1.0 − (CAMERA_MIN_CHAR_TRANS_DIST − d)/(CAMERA_MIN_CHAR_TRANS_DIST − CAMERA_MIN_CHAR_DIST2) clamped to [0,1] → SetTranslucencyHierarchical(player, t); else (d ≥ 0.45) → SetTranslucencyHierarchical(player, 0) guarded by `if (0.0 < t)` (reset-on-transition only — behaviorally equivalent to the claim's unconditional phrasing). Constants confirmed at pc:956784-956785: CAMERA_MIN_CHAR_DIST2 = 0.449999988, CAMERA_MIN_CHAR_TRANS_DIST = 0.200000003. Live per-frame caller confirmed: gmSmartBoxUI::UseTime (Ghidra xref from 004d74b9; pc:219786). SetTranslucencyHierarchical @ 0x005116c0 (Ghidra decompile) writes this->translucency, calls CPartArray::SetTranslucencyInternal, recurses CHILDLIST children — so retail's fade reaches the player's drawable part hierarchy. ACDREAM verified: RetailChaseCamera.cs:207-208 computes PlayerTranslucency = ComputeTranslucency(Distance(publishedEye, pivotWorld)) from the collided eye; ComputeTranslucency :454-463 matches retail exactly (Far=0.45, Near=0.20, same ramp, 0=opaque/1=invisible convention per :81 doc). Whole-src grep for PlayerTranslucency: ONLY RetailChaseCamera.cs:82 (declaration), :119 (doc), :208 (assignment) — zero consumers. All GameWindow.cs _retailChaseCamera sites (678, 1065-1092, 4921, 6743-6832, 7283-7304, 10442-11682) read ViewerCellId/Position/View or call Update/AdjustDistance/AdjustPitch — none reads PlayerTranslucency. Alternate-mechanism check: TranslucencyKind/SurfOpacity pipeline (GfxObjMesh.cs:201-227, TranslucencyKind.cs:80-121) is static per-SURFACE dat translucency, not per-entity dynamic; grep over src/AcDream.App/Rendering for per-entity/per-instance alpha override → no matches; CameraViewFirstPerson (InputAction.cs:216) has no App-layer consumer that hides the player. Blast radius accurate: DistanceMin = 2f (RetailChaseCamera.cs:85) means only the camera-collision pull-in can bring the eye inside 0.45 m — exactly the #115 cramped-interior scenario. The divergence is real: retail fades the player part hierarchy per frame; acdream computes the identical value and never applies it, leaving the player opaque against the back of the camera. Severity medium is fair (visible artifact class, limited to close-camera interiors).
|
||||
- blastRadius: In exactly the #115 scenario (cramped interiors, eye pulled within 0.45 m of the head pivot) retail fades the player toward invisible; acdream leaves the player fully opaque, so the camera presses into the back of the player's head/torso and the model fills or clips the view. Contributes to "indoor world feels right" and the cramped-interior feel complaint; also makes the (correct) aggressive collision pull-in look worse than retail's.
|
||||
- retailEvidence: CameraSet::UpdateCamera (Ghidra 0x00458ae0; pc:97625-97745): d = Position::distance(pivot, &sbox->viewer) at 0x00458beb (collided published viewer); d ≥ 0.449999988 → SetTranslucencyHierarchical(player, 0f) (0x00458ca1); else t = 1 − (0.200000003 − d)/(0.2 − 0.45) clamped (0x00458c19-0x00458c53) → CPhysicsObj::SetTranslucencyHierarchical(player, t) (0x00458c6d); InHead → t = 1f (0x00458bb8). Applied to the player part hierarchy every frame.
|
||||
- acdreamEvidence: RetailChaseCamera.cs:207-208 computes PlayerTranslucency via ComputeTranslucency (:454-463, thresholds and formula match retail exactly, from the collided eye — correct). Grep over src/: the only references are RetailChaseCamera.cs:82 (declaration, doc says "Read by GameWindow"), :119, :208 — no GameWindow or renderer site consumes it; no per-entity translucency override reaches the player's draw.
|
||||
- portShape: Wire the existing value through: per frame in GameWindow's camera block, push _retailChaseCamera.PlayerTranslucency into the local player entity's render path as an alpha/translucency override on its batches (the surface-metadata table already carries per-batch translucency for the two-pass alpha-test pipeline; a per-INSTANCE override needs the reserved InstanceData highlight/translucency hook or a per-entity skip-draw at t ≥ ~1). Smallest faithful first step: skip drawing the player when t == 1 and treat 0 < t < 1 via the transparent pass.
|
||||
|
||||
### [LOW] sought-position-lacks-cell-identity (confirmed) — The sought eye is a bare world-space Vector3; retail's viewer_sought_position is a cell-qualified Position
|
||||
- verifier notes: RETAIL side — re-derived entirely from Ghidra decompiles (BN pseudo-C used only for navigation):
|
||||
|
||||
1. Struct claim checks out: acclient.h:35193 `Position viewer`, :35194 `CObjCell *viewer_cell`, :35196 `Position viewer_sought_position` — the sought eye IS a cell-qualified Position in SmartBox.
|
||||
|
||||
2. SmartBox::update_viewer (Ghidra 0x00453ce0): at function entry `local_120.objcell_id = (this->viewer_sought_position).objcell_id` + frame copy — local_120 is initialized from the sought INCLUDING its cell. After `CTransition::find_valid_position` fails, fallback-1 is `CPhysicsObj::AdjustPosition(&local_120, &viewer_sphere.center, &local_170, 0, 1)`; on success `set_viewer(this, &local_120, 0); this->viewer_cell = local_170`. Exactly as claimed. (One wrinkle the claim omits, not load-bearing: before the sweep, `Position::localtoglobal(&local_d8, &local_12c, &local_120)` re-expresses local_120's ORIGIN into the start cell's landblock frame while keeping the sought objcell_id — a no-op in the same landblock; the cell-context claim is unaffected.)
|
||||
|
||||
3. The sought really does carry the published viewer's cell: SmartBox::PlayerPhysicsUpdatedCallback (Ghidra 0x00452d60; raw disasm shows `CALL 0x00456660` at 0x00452d75, input `LEA EAX,[ESI+0x8]` = &this->viewer, result objcell_id `[EAX+4]` stored to `[ESI+0x5c]` = viewer_sought_position.objcell_id) does `viewer_sought_position = CameraManager::UpdateCamera(camera_manager, &local_48, &this->viewer)`. CameraManager::UpdateCamera (Ghidra 0x00456660) tail: `local_158.objcell_id = param_1->objcell_id` before interpolate_origin/interpolate_rotation; ALL three return paths (interpolated, convergence-snap `Position::Position(ret, param_1)`, degenerate) copy param_1's (published viewer's) objcell_id into the returned Position. The claimed citation is exact.
|
||||
|
||||
4. The cell context is genuinely load-bearing inside retail AdjustPosition (Ghidra 0x00511d80): `(objcell_id & 0xffff) < 0x100` selects the branch; indoor branch searches the SEED cell's stab list (`CEnvCell::find_visible_child_cell` on `CObjCell::GetVisible(objcell_id)`); outdoor branch interprets the (landblock-relative) origin in the seed's landblock via `LandDefs::adjust_to_outside`. So sought-cell vs player-cell context can change the fallback's answer.
|
||||
|
||||
ACDREAM side — all citations check out: RetailChaseCamera.cs:104 `private Vector3 _dampedEye;` (bare Vector3, no cell; the class's only cell state is ViewerCellId, the PUBLISHED viewer cell, which is never fed back into the sweep). PhysicsCameraCollisionProbe.cs:94-99: fallback-1 calls `_physics.AdjustPosition(cellId, desiredEye)` where `cellId` is the SweepEye parameter; the comment at :96-97 self-documents the substitution. Production call chain confirms `cellId` = the PLAYER's cell: GameWindow.cs:6837 passes `cellId: _playerController.CellId` → RetailChaseCamera.cs:195 forwards it to SweepEye. The probe is stateless — nothing elsewhere supplies the sought eye's own cell.
|
||||
|
||||
Divergence is REAL, not behaviorally equivalent: when the sweep fails with the eye in a cell different from the player's (camera trailing through a doorway / across a seam), retail seeds the re-seat with the eye's own cell (outdoor eye → adjust_to_outside succeeds; indoor eye in cell A → A's own stab list), while acdream seeds with the player's cell — and acdream's indoor branch returns (seed, false) outright when the player cell's stab-list miss combines with !SeenOutside (PhysicsEngine.cs:549-551), dropping to fallback-2 (snap viewer to player, viewer cell 0) where retail would have kept the sought eye → one-frame render-root blip, exactly the claimed blast radius. Partial mitigation narrows but doesn't erase it: acdream's AdjustPosition takes a WORLD point and its outdoor path scans all loaded landblocks by the point (PhysicsEngine.cs:557-566), so the retail outdoor landblock-frame dependence is moot by construction, and SeenOutside indoor seeds fall through to the correct outdoor answer. Severity "low" / fallback-only is accurate, and the "becomes load-bearing once boom-no-collided-feedback is ported" rider is supported by the verified sought-derivation chain (0x00452d60 → 0x00456660). Port shape as claimed is consistent with the verified retail flow.
|
||||
- blastRadius: Fallback paths only: when the sweep fails outright, retail re-seats the sphere at the sought position using the sought position's OWN objcell_id as context; acdream's probe seeds AdjustPosition with the player cell instead (self-documented at PhysicsCameraCollisionProbe.cs:96-97). Wrong cell context across a landblock/indoor seam could pick a wrong fallback cell for one frame (render-root blip). Becomes load-bearing once boom-no-collided-feedback is ported, since the sought state then persists across frames and carries the published viewer's cell.
|
||||
- retailEvidence: SmartBox::update_viewer (Ghidra 0x00453ce0): local_120 is initialized from viewer_sought_position with its objcell_id and used as the fallback AdjustPosition input; CameraManager::UpdateCamera returns a Position whose objcell_id = param_1->objcell_id (published viewer's cell, Ghidra 0x00456660 tail local_158.objcell_id assignment); acclient.h SmartBox holds viewer / viewer_sought_position as Position (cell + frame) and viewer_cell as CObjCell*.
|
||||
- acdreamEvidence: RetailChaseCamera.cs:104 `_dampedEye` is a Vector3 with no cell; PhysicsCameraCollisionProbe.cs:94-99 fallback 1 seeds AdjustPosition with `cellId` (the player's cell) and the comment admits "acdream's camera doesn't track the sought-eye's cell separately".
|
||||
- portShape: Falls out of the boom-no-collided-feedback port for free: once the published viewer (cell + position) is the persistent state and the sought is derived from it each frame, carry the published cell with the sought eye and pass it as the fallback-1 AdjustPosition context.
|
||||
|
||||
### [LOW] camera-input-scalars-unverified (adjusted) — Mouse-orbit and held-key input scalars are invented constants, not retail's
|
||||
- correctedClaim: Mouse-orbit and held-key camera scalars in acdream are partly invented and the integration shape diverges from retail — but one claimed-uncited constant is actually retail-correct, and the retail formula needed two corrections. Retail (Ghidra-verified): CameraSet::Rotate @ 0x00458310 computes value = m_rCameraAdjustmentSpeed × (cur_time − m_ttLastRotate), REPLACED (not multiplied) by the caller's scale when scale ≠ 1.0, then rotates viewer_offset around Z by value × angle, where angle is a global = π/(180/8) ≈ 0.13963 rad (8°), data @ 0x0083d034, init $E123 @ 0x006eabf0; gate is F_EPSILON = 0.000199999995 s; mouse-look (MouseLookHandler, call @ 0x00458ef9) passes scale = FilterMouseInput(delta) × ICIDM[0x20] × 1/15 after a 5-sample debounce. m_rCameraAdjustmentSpeed = 40.0 IS extracted (CameraManager ctor, 0x0045710a / pc:95986) — acdream's 40.0 (CameraDiagnostics.cs:78) is retail-correct and only missing a citation, as are its 0.45 stiffness defaults. The REAL divergences: (1) acdream's RMB-orbit scalars 0.004 yaw / 0.003 pitch per count (GameWindow.cs:1091-1092) are invented; retail's per-count yaw = filtered × sensitivity × (1/15) × 0.13963 rad via offset rotation. (2) acdream's held-key pitch ×0.02 (GameWindow.cs:6751-6753) is invented; retail Raise @ 0x00457b00 uses ×0.13963 rad (≈7× faster, modulo untraced caller scale). (3) acdream's held-key zoom is additive meters at 40 m/s clamped 2-40 (GameWindow.cs:6746-6749 + RetailChaseCamera.cs:216-217); retail Closer @ 0x004586d0 is a multiplicative shrink viewer_offset ×= (1 − 40·dt·0.2) (CAMERA_MOUSELOOK_INC = 0.2 @ 0x0079bc04) — a structural shape difference, not just a scalar. Severity remains low (feel-polish, #115 class). Port shape: replace the 0.004/0.003/0.02 scalars and the additive zoom with retail's value×angle offset-rotation/shrink forms; extract ICIDM[0x20]'s default before finalizing the mouse path.
|
||||
- verifier notes: RE-CHECKED RETAIL (Ghidra decompile + disassembly, not BN-only):
|
||||
(1) CameraSet::Rotate @ 0x00458310 — confirmed: F_EPSILON minimum-elapsed gate (the claim's "0.0002 s" is exactly retail F_EPSILON = 0.000199999995, the same constant acdream already cites at src/AcDream.Core/Physics/CellTransit.cs:36); m_ttLastRotate seeded to cur_time − 1/SceneTool::m_FramesPerSecond when zero; rotation applied as a sin/cos Z-rotation of cm->viewer_offset (fsin/fcos at 0x004585cb-0x004585cf left branch, 0x00458609-0x0045860d right branch — the claimed 0x00458609-0x00458629 range is the right-branch math). TWO FORMULA CORRECTIONS: (a) param_3 (scale) REPLACES the speed×elapsed product when ≠ 1.0 (`if (param_3 != 1.0) fVar8 = param_3`), it is NOT a multiplicative term as claimed; (b) the resulting value is multiplied by a global `angle` the claim missed — instruction-level verified: static initializer $E123 @ 0x006eabf0 does FSTP [0x0083d034] with value π/(180/8) = 8° in radians ≈ 0.13963 (pc:763151), and Rotate FMULs the SAME address 0x0083d034 (0x004585bd, 0x004585fb).
|
||||
(2) Mouse-look caller — confirmed: CameraSet::MouseLookHandler (call site 0x00458ef9) passes |FilterMouseInput(raw) × ICIDM[0x20] × 0x3d888889(=1/15)| as Rotate's scale, gated by a 5-sample mouselook_x_extent debounce. So retail per-count yaw = filtered × sensitivity × (1/15) × 0.13963 rad.
|
||||
(3) m_rCameraAdjustmentSpeed — the claim said "not extracted"; NOW EXTRACTED: CameraManager::CameraManager (Ghidra decompile containing 0x0045710a; pc:95986) sets m_rCameraAdjustmentSpeed = 40.0, and pc:96077 registers it as console var "Camera_AdjustmentSpeed". So acdream's CameraDiagnostics.CameraAdjustmentSpeed = 40.0f is RETAIL-CORRECT, merely uncited — the "invented constant" framing is refuted for this one. (Bonus: same ctor sets t_stiffness = r_stiffness = 0.45, so acdream's 0.45 stiffness defaults at CameraDiagnostics.cs:56/63 are retail-correct too.)
|
||||
(4) Held-key paths — CameraSet::Raise @ 0x00457b00 (Ghidra): pitch delta = (elapsed × 40) × angle(0.13963) rad, with an extra ×0.25 when ICIDM[0x22].field_0x1 is set; CameraSet::Closer @ 0x004586d0 (Ghidra): zoom is MULTIPLICATIVE — viewer_offset *= (1 − elapsed×40×CAMERA_MOUSELOOK_INC), CAMERA_MOUSELOOK_INC = 0.2 (static const @ 0x0079bc04, pc:956772), floored at CAMERA_MIN_CHAR_DIST.
|
||||
RE-CHECKED ACDREAM: all three citations accurate. GameWindow.cs:1086-1098 — RMB orbit: YawOffset −= filteredDx × 0.004f × sens (line 1091) and AdjustPitch(filteredDy × 0.003f × sens) (line 1092); both scalars uncited. GameWindow.cs:6745-6753 — adj = CameraAdjustmentSpeed × dt; CameraZoomIn/Out → AdjustDistance(±adj) which per RetailChaseCamera.cs:216-217 is ADDITIVE METERS clamped 2..40 (i.e. 40 m/s linear); CameraRaise/Lower → AdjustPitch(±adj × 0.02f) = 0.8 rad/s, uncited. CameraDiagnostics.cs:73-78 — "Retail default 40.0" comment with no decomp citation (value now proven right). Acdream's FilterMouseDelta (RetailChaseCamera.cs:231-244) faithfully mirrors retail FilterMouseInput, so the divergence is confined to the post-filter scalars and integration shape.
|
||||
JUDGMENT: the core divergence is REAL — acdream's 0.004 (yaw/count), 0.003 (pitch/count), and 0.02 (held-key pitch) scalars have no retail provenance, and the held-key shapes diverge structurally: retail pitch rate ≈ 40 × 0.13963 ≈ 5.6 rad/s vs acdream 0.8 rad/s (~7× slower, modulo unverified caller scale params), and retail zoom is an exponential offset shrink vs acdream's linear m/s — both squarely in the #115 "feel" class. Severity low stands (feel-polish only, no rendering/correctness impact). OPEN QUESTIONS: (a) what scale param retail's per-frame held-key callers (UpdateCamera @ 0x00458b27 / OnAction @ 0x0045603b) actually pass — 1.0 assumed, not traced; (b) ICIDM[0x20] default mouse-look sensitivity value not extracted, so an exact retail-equivalent per-count yaw constant cannot be computed yet; (c) semantic of ICIDM[0x22] flag (routes rotate to character-turn motion commands 0x6500000d/0x6500000e and ×0.25 on raise) not pinned.
|
||||
- blastRadius: Feel-polish only: turn-rate of the RMB orbit and zoom/raise key speed may differ from retail, compounding the #115 "draggy" perception even after the boom fix. Not a correctness or rendering issue.
|
||||
- retailEvidence: CameraSet::Rotate (Ghidra 0x00458310; pc:97103-97230): rotation angle = cm->m_rCameraAdjustmentSpeed × (Timer::cur_time − m_ttLastRotate) × scale, applied as a Z-rotation of viewer_offset (sin/cos at 0x00458609-0x00458629), with a 0.0002 s minimum-elapsed gate and m_ttLastRotate seeded to cur_time − 1/FPS; mouse-look callers pass a delta-derived scale (0x00458ef9). The retail value of m_rCameraAdjustmentSpeed and the mouse-delta→scale mapping were not extracted in this sweep.
|
||||
- acdreamEvidence: GameWindow.cs:1089-1091 — YawOffset −= filteredDx × 0.004 × sens (0.004 is uncited); GameWindow.cs:6745-6753 — Distance/Pitch integrate CameraAdjustmentSpeed·dt with an uncited ×0.02 pitch scalar; CameraDiagnostics.cs:78 claims "Retail default 40.0" for CameraAdjustmentSpeed without a decomp citation.
|
||||
- portShape: Extract cm->m_rCameraAdjustmentSpeed's initialization (CameraManager constructor at 0x004570b1 region / config read) and the mouse-look caller at 0x00458ef9; replace the 0.004 / 0.02 / 40.0 constants with the retail values and route mouse-look through the same offset-rotation shape (rotate viewer_offset by speed×elapsed×scale) instead of direct YawOffset integration.
|
||||
|
||||
## OPEN QUESTIONS
|
||||
|
||||
- Why acdream's earlier feedback experiment oscillated (the warning at RetailChaseCamera.cs:183-187 says writing the clamped result into _dampedEye caused visible vibration against walls). Retail's exact shape — damp FROM the published viewer in the physics phase, sweep pivot→sought in the draw phase, publish raw — is a stable fixed point on paper (the sweep restarts at the pivot every frame), so the historical vibration likely came from a different feedback wiring (e.g., lerping the clamped eye toward the full target inside the same call, or the InitPath sphere-center Z-offset interacting with the clamp). The port should reproduce retail's ordering exactly and re-run the cramped-interior visual gate rather than assume the old failure generalizes.
|
||||
- Retail's mouse-look delta→CameraSet::Rotate scale mapping (callers around 0x00458ef9) and the initialization value of CameraManager::m_rCameraAdjustmentSpeed were not extracted; acdream's 0.004·sens yaw scalar, ×0.02 pitch scalar, and CameraAdjustmentSpeed=40.0 are therefore unverified against retail.
|
||||
- Ghidra labels the function containing the DrawNoBlit call at 0x0045557a as 'Draw' while the BN pseudo-C listing shows that address at the tail of the block starting with SmartBox::UseTime (0x00455410) — the two tools disagree on the function boundary. Either way both run once per frame with CPhysics::UseTime (the damping callback) executing BEFORE DrawNoBlit (the sweep), which is the ordering that matters for the port.
|
||||
- Retail's default camera pivot: update_viewer composes the pivot from camera_manager->pivot_part_index's part frame plus camera_manager->pivot_offset (Ghidra 0x00453ce0, LAB_00453da5 region); acdream hardcodes pivot = player position + 1.5 m (RetailChaseCamera.cs:71,151). Whether retail's default pivot_offset is exactly (0,0,1.5) with pivot_part_index = -1 for players was not verified in this sweep.
|
||||
- Whether the hypothesized #109 link (1-frame eye snap-out flipping ViewerCellId across portal planes → render-root oscillation at far doors) actually matches the #109 reproduction — needs a capture correlating the eye-position delta per frame with the ViewerCellId flip sequence before crediting the boom fix with any #109 improvement.
|
||||
76
docs/research/2026-06-11-holistic-map/wf2-indoor-lighting.md
Normal file
76
docs/research/2026-06-11-holistic-map/wf2-indoor-lighting.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# 2.2 — Lighting discipline for buildings/interiors (sun gating, per-cell lights, fog, shells, dynamic objects)
|
||||
|
||||
## RETAIL
|
||||
|
||||
RETAIL LIGHTING — data structures first. A light source in retail is a LIGHTINFO: { type (0=point, 1=directional), Frame offset, viewerspace_location, RGBColor color, intensity, falloff (= hard range in meters), cone_angle } (acclient.h:31688-31697). Lights do NOT live in the EnvCell dat: CEnvCell::UnPack (Ghidra 0x0052d470) reads flags/surfaces/portals/stab-list/static-objects/restriction and reads NO lights; its `RGBColor *light_array` field (acclient.h:32084) is allocated at load as `new RGBColor[structure->vertex_array.num_vertices]` — one color PER VERTEX, computed, not dat-sourced (Ghidra 0x0052d470 end). The actual light sources come from SETUP files: CSetup has `num_lights` + `LIGHTINFO *lights` (acclient.h:31137-31138) — torches, braziers, lamps are Setup objects whose dat carries the lights. At object init, CPartArray::InitLights (pc:287036 @0x518c00) wraps them in a LIGHTLIST and CPartArray::AddLightsToCell (pc:285959 @0x517ea0) registers each LIGHTOBJ {lightinfo, Frame global_offset, int state} (acclient.h ~31265) into the owning CELL's light_list via CObjCell::add_light (pc:308535 @0x52b1d0). So lights are cell-resident.
|
||||
|
||||
Per-frame global list: each cell pushes its lights into a global, distance-sorted pool — CObjCell::add_static_to_global_lights (pc:308636 @0x52b350; LIGHTOBJ.state&1 → Render::add_static_light(lightinfo, cell m_DID.id, frame)) and add_dynamic_to_global_lights (pc:308656 @0x52b390). Render::add_static_light/add_dynamic_light (pc:343907/343915 @0x54d3e0/0x54d420) call insert_light which keeps `world_lights.sorted_static_lights` / `sorted_dynamic_lights` sorted by distancesq; capacity defaults: max_static_lights=40 (pc:1101871 @0x81ec94), max_dynamic_lights=7 (pc:1101872 @0x81ec98). Each pooled entry is a RenderLight {_D3DLIGHT9, d3dLightIndex, cellID, LIGHTINFO info, distancesq} (acclient.h:38944-38951). The pool is rebuilt around the player: CellManager::ChangePosition (pc:94660-94670 @0x455a98) zeroes num_static/num_dynamic on every player cell change, and SmartBox::set_viewer calls CObjCell::add_dynamic_lights() each frame (pc:91828 @0x452d30).
|
||||
|
||||
Per-DRAW selection — the heart of the discipline. The hardware has 8 fixed-function light slots (Render::curLightUsage[0..7], reset_active_lights_state pc:342626 @0x54be00). Three selectors: (a) Render::useSunlightSet(1) (pc:343923 @0x54d450) makes the SUN the sole active light (special index 0xffffffff, lightClass 0); the sun's D3D light is rebuilt lazily in PrimD3DRender::config_hardware_light (@0x59ad30; rebuild at pc:424103-424120 @0x59b4ed: Diffuse = sunlight_color × |sunlight vector|, Direction = −sunlight, gated by m_bSunlightValid). (b) Render::minimize_object_lighting (pc:343939 @0x54d480) — per OBJECT: fills ≤8 slots with dynamic lights first (filtered by remove_object_light's sphere test: light falloff sphere vs the object's bounding sphere, pc:342820 @0x54c1b0), then static lights whose falloff sphere intersects the object (local_object_center/radius test pc:343985-344000). (c) Render::minimize_envcell_lighting (pc:342794 @0x54c170) — per CELL: enables ALL dynamic lights only (statics excluded — see burn-in below). enable_active_lights (pc:342746 @0x54c080) then issues device SetFFLight/SetFFLightEnable per slot.
|
||||
|
||||
Q1 — how interior geometry is lit: RenderDeviceD3D::DrawEnvCell (pc:427877-427910 @0x59f170) calls minimize_envcell_lighting(cell pos, drawing-BSP sphere radius) then, on the built-mesh path (use_built_mesh=1 at runtime, set in UnPack), calls D3DPolyRender::SetStaticLightingVertexColors(constructed_mesh, &cell->pos) (pc:425771-425935 @0x59cfe0). That function LOCKS the cell's vertex buffer and, for EVERY vertex, accumulates the contribution of EVERY static light in world_lights.sorted_static_lights (no 8-light cap!) — each light converted into cell-local space (LIGHTINFO::convert_to_local) and evaluated as a point light (calc_point_light) or directional, clamped to [0,1] per channel, and WRITTEN INTO THE VERTEX DIFFUSE COLORS. It is cached via MeshBuffer.burnedInStaticLights (pc:425933 @0x59d2ca) and re-burned only when num_static_lights changes. At draw, D3DPolyRender::DrawMesh switches the fixed-function ambient source to FromVertex for burned meshes (pc:425691 @0x59cea2) vs FromMaterial (pc:425535 @0x59cbc4) — i.e. the burned per-vertex static lighting acts as the ambient term, with the ≤7 dynamic FF lights added in hardware on top. So: interior static lighting = CPU per-vertex burn-in of ALL static lights; dynamic = real-time FF lights; sun = NEVER (below).
|
||||
|
||||
Q2 — sun/ambient gating, two independent gates. GATE A (per-draw-class, every frame): PView::DrawCells (@0x5a4840, Ghidra-verified decompile) — if the flood reached outside (outside_view.view_count != 0): useSunlightSet(1) → LScape::draw (landscape+buildings through the portals, sun ON); then unconditionally useSunlightSet(0) + restore_all_lighting; loop DrawEnvCell (interior geometry, sun OFF); loop DrawObjCellForDummies (objects per cell, sun OFF → each object goes through minimize_object_lighting per DrawMeshInternal pc:427983 @0x59f398 `if (useSunlight == 0) minimize_object_lighting()`); finally useSunlightSet(1) restore. SmartBox::RenderNormalMode (pc:92635-92686 @0x453aa0): outdoor viewer → set_default_view + useSunlightSet(1) + LScape::draw; indoor viewer → DrawInside(viewer_cell) with no sun-set (interiors get the DrawCells discipline). NET: interior cell geometry and interior objects are NEVER sun-lit, even when the player stands outside looking in; landscape seen through a doorway from inside IS sun-lit, in the same frame. GATE B (per player-cell change): CellManager::ChangePosition (pc:94600-94720 @0x4559b0) — if the new player cell is outdoor OR seen_outside: copy LScape::sunlight(+color) into world_lights and SetWorldAmbientLight(LScape::calc_object_light(), LScape::ambient_color) where calc_object_light = sqrt(|sunlight|)×0.2 + region ambient_level (pc:94400-94406 @0x455730); if the cell is SEALED (seen_outside==0): SetWorldAmbientLight(0.2f, 0xffffffff) — flat 0.2 white ambient (pc:94711 @0x455af4). SetWorldAmbientLight stores ambient_color = color×level into world_lights (pc:91995-92011 @0x4530a0).
|
||||
|
||||
Q3 — fog indoors: distance fog is a single global fixed-function state (GraphicsStatesType.DistanceFogColor/Near/Far, acclient.h:38975-38990). Parameters come from the region + weather in LScape::UseTime (@0x505880; override fog color/levels pc:267378-267447) and the enable is the player option (PlayerModule::DisableDistanceFog → LScape::m_fFogEnabled pc:423368-423372 @0x59a6c2; SetFFFogEnable pc:267436 @0x505ada). NOTHING in the interior path toggles fog — the Ghidra decompiles of DrawCells/DrawEnvCell/DrawInside contain no fog calls — so the SAME outdoor distance fog applies to interior fragments; interiors are simply too close to the camera for it to matter. One per-surface exception: surface-type bit 0x10000 disables fog-alpha for that surface (pc:425295 @0x59c882).
|
||||
|
||||
Q4 — building shells from outside: drawn during the sun-ON pass — RenderDeviceD3D::DrawBuilding (pc:427930-427960 @0x59f2a0) → CPhysicsPart::Draw → DrawMeshInternal with useSunlight==1, so minimize_object_lighting is SKIPPED and the shell is lit by the directional sun FF light + the global ambient. No baked lighting on shells. Additionally every surface's diffuse scalar is tinted by the sun color when the sun is on: Render::diffuse = surface.diffuse × sunlight_color, and Render::luminosity = surface.luminosity (emissive) — pc:425150-425175 @0x59c64c-0x59c699. (Terrain, for contrast, IS precomputed per-vertex: CLandBlockStruct::calc_lighting @0x531700, pc:315648+, sun+ambient.) Day/night on shells = the sun light's diffuse magnitude (|sunlight| changes over the day) + ambient.
|
||||
|
||||
Q5 — dynamic objects inside: drawn via DrawObjCellForDummies inside DrawCells (sun OFF) → per-part DrawMeshInternal → minimize_object_lighting: up to 8 FF lights chosen vs the OBJECT's bounding sphere — all reaching dynamic lights first, then reaching static (cell torch) lights. Plus the global flat/region ambient from Gate B. Retail ALSO adds a per-frame "viewer light": SmartBox::set_viewer (pc:91781-91835 @0x452c40) adds a white point light (init pc:93342-93356 @0x4547e8: type=point, color 0xffffffff, cone 360; falloff default 10 m pc:1088428 @0x818610; intensity from the registry option "SmartBox.ViewerLightIntensity", set to 0.5×4.5=2.25 at pc:761995 @0x6e9a7c) positioned 2 m above the player, registered as a dynamic light EVERY frame — the classic "personal light bubble" that keeps dungeons readable.
|
||||
|
||||
## ACDREAM
|
||||
|
||||
ACDREAM LIGHTING — one global 576-byte UBO per frame, one lighting state for everything. Data: LightSource {Kind, WorldPosition, ColorLinear, Intensity, Range (hard cutoff), ConeAngle, OwnerId, IsLit} (src/AcDream.Core/Lighting/LightSource.cs:39-53). LightManager (src/AcDream.Core/Lighting/LightManager.cs:37-137) is a flat global registry — NO cell association — whose Tick(viewerWorldPos) filters point/spot lights by Range²×1.1², sorts by distance-to-VIEWER, reserves slot 0 for the Sun, and takes the next 7. SceneLightingUbo.Build (src/AcDream.Core/Lighting/SceneLightingUbo.cs:103-134) packs those ≤8 lights + CellAmbient + FogParams/FogColor + camera into a std140 block uploaded once per frame by SceneLightingUboBinding at binding=1 (src/AcDream.App/Rendering/SceneLightingUboBinding.cs:23-59; upload at GameWindow.cs:7378).
|
||||
|
||||
Light SOURCES are read from the dat correctly in shape: LightInfoLoader.Load (src/AcDream.Core/Lighting/LightInfoLoader.cs:35-91) converts Setup.Lights (LightInfo: color/intensity/falloff/cone) into LightSources at the entity root (no per-part chain yet). Registration happens once per landblock load in GameWindow.ApplyLoadedTerrainLocked over lb.Entities (GameWindow.cs:6082-6108) — and the merged entity list includes interior EnvCell statics (merged.AddRange(BuildInteriorEntitiesForStreaming) at GameWindow.cs:5272-5275), so inn torches/fireplaces DO register. LightingHookSink (src/AcDream.Core/Lighting/LightingHookSink.cs:41-77) tracks owner→lights and flips IsLit on SetLightHook. Server-spawned entities (EntitySpawnAdapter path) and held items never register lights — LightInfoLoader has exactly one call site (GameWindow.cs:6099).
|
||||
|
||||
Indoor/outdoor switch (the GameWindow write site): the lighting root is the PLAYER cell (GameWindow.cs:7291-7296 — playerRoot from CellGraph.CurrCell, playerSeenOutside = playerRoot?.SeenOutside ?? true); playerInsideCell = playerRoot != null && !playerSeenOutside (GameWindow.cs:7337). UpdateSunFromSky(kf, playerInsideCell) (GameWindow.cs:9741-9785): if playerInsideCell — Sun zeroed (ColorLinear=0, Intensity=0) and CurrentAmbient = flat (0.2, 0.2, 0.2) (GameWindow.cs:9752-9763, explicitly citing retail ChangePosition @0x4559B0); else — Sun = sky-keyframe SunColor and CurrentAmbient = kf.AmbientColor (GameWindow.cs:9772-9783). Then Lighting.Tick(camPos) (GameWindow.cs:7353) and the single UBO build/upload (7354-7378). FogParams are overridden with streaming-radius-derived distances (N₁×192×0.7 / N₂×192×0.95, GameWindow.cs:7364-7376, deliberate A.5 T22).
|
||||
|
||||
Shading: ONE lighting model for every mesh — building shells, interior cell geometry, statics, NPCs, the player — all through mesh_modern.frag's accumulateLights (src/AcDream.App/Rendering/Shaders/mesh_modern.frag:26-63): lit = uCellAmbient + Σ active lights (directional N·L for kind 0 — the sun; hard-range point/spot otherwise), clamp 1.0, × texture, then distance fog (65-74, 109-120). The EnvCellRenderer (interior cell geometry) explicitly SHARES this shader (src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:50-55, wired at GameWindow.cs:1835-1841), and neither RetailPViewRenderer.cs nor InteriorRenderer.cs touches any lighting state (grep: zero light/ambient/sun references). Terrain uses per-vertex sun N·L + ambient from the same UBO (terrain_modern.vert:146-149). Surface Luminosity/Diffuse scalars are extracted into AcSurfaceMetadata (src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:277) but consumed ONLY by SkyRenderer (src/AcDream.App/Rendering/Sky/SkyRenderer.cs:264, 339-340) — the world mesh path ignores them. There is no per-vertex static burn-in, no per-cell or per-object light selection, no viewer light, and no per-draw sun gating: whatever the player-cell switch decided applies to every fragment drawn that frame.
|
||||
|
||||
## DIVERGENCES
|
||||
|
||||
### [CRITICAL] interior-sun-bleed (UNVERIFIED (verifier hit token limit)) — Interior cell geometry is sun-lit whenever the player-cell gate says 'outside' — retail NEVER sun-lights interiors
|
||||
- blastRadius: The single biggest 'indoor world feels right' (M1.5 mandate) lighting gap. In acdream, standing outdoors or inside any seen_outside interior (ALL surface-building interiors: Holtburg inn, cottages), interior walls/floors receive full directional sun N·L through the roof — sun-facing interior walls bright, others dark, and the whole interior re-shades as the day passes. It also flattens the retail outside-looking-in depth cue (interiors should read distinctly darker/flatter than the sun-lit exterior through a doorway — feeds the #114 doorway-quality complaint). In retail the same wall is lit only by burned-in torch light + ambient, regardless of where the player stands.
|
||||
- retailEvidence: PView::DrawCells @0x5a4840 (Ghidra-verified): useSunlightSet(1) ONLY around LScape::draw for the outside view, then unconditionally useSunlightSet(0) before the DrawEnvCell loop and the DrawObjCellForDummies loop, useSunlightSet(1) restored at the end — interior geometry and interior objects are never drawn with the sun active, even in the same frame where the outdoors is. SmartBox::RenderNormalMode pc:92635-92686 @0x453aa0 shows the outdoor branch is the only place sun is set ON at top level. The player-cell switch (CellManager::ChangePosition pc:94600-94720 @0x4559b0) gates only the AMBIENT level and sun VALIDITY, not whether interiors receive sun — that is gated per draw-class by Render::useSunlight (DrawMeshInternal pc:427983 @0x59f398).
|
||||
- acdreamEvidence: One global UBO per frame: UpdateSunFromSky keys the sun on playerInsideCell = player cell !seenOutside (GameWindow.cs:7337, 9741-9785) — seen_outside interiors keep FULL sun (comment at GameWindow.cs:7334 says so explicitly). EnvCellRenderer shares mesh_modern.frag (EnvCellRenderer.cs:50-55, GameWindow.cs:1835-1841) whose accumulateLights applies the slot-0 directional sun to every fragment (mesh_modern.frag:34-44). No per-pass lighting state exists (RetailPViewRenderer.cs / InteriorRenderer.cs: zero lighting references).
|
||||
- portShape: Split the lighting state per draw-class, not per frame — the modern equivalent of useSunlightSet. Keep ONE UBO but upload it (or a second UBO/uniform toggle) twice per frame: outdoor pass = sun + outdoor ambient; interior pass (EnvCellRenderer + interior-partition entities) = sun slot zeroed + Gate-B ambient (outdoor-formula ambient for seen_outside player cells, 0.2 flat for sealed). The flood already knows which draws are interior (EnvCellRenderer + InteriorEntityPartition), so the seam is small: re-upload the 576-byte UBO before/after the interior block in the frame, exactly mirroring DrawCells' useSunlightSet(1)→LScape / useSunlightSet(0)→cells+objects ordering.
|
||||
|
||||
### [HIGH] no-static-light-burnin (UNVERIFIED (verifier hit token limit)) — No per-vertex static-light burn-in for interior cells — interiors capped at the 8 viewer-nearest lights instead of ALL static lights
|
||||
- blastRadius: Interior light quality/coverage: retail interiors accumulate EVERY reaching static light (torches, fireplaces, lamps — no 8-light cap) into vertex colors, so a many-torch inn is evenly lit. acdream funnels ALL lighting through 8 global slots ranked by distance-to-camera: in a room with >7 point lights some torches simply don't light; lights also pop in/out as the camera moves and re-ranks the global list. Directly the 'interiors look uneven/flat/wrong' component of M1.5.
|
||||
- retailEvidence: D3DPolyRender::SetStaticLightingVertexColors pc:425771-425935 @0x59cfe0: locks the cell mesh vertex buffer and per-vertex accumulates ALL world_lights.sorted_static_lights (loop bound = num_static_lights, capacity 40 per pc:1101871 @0x81ec94) via LIGHTINFO::convert_to_local + calc_point_light, clamped [0,1], written as vertex diffuse; cached by burnedInStaticLights (pc:425933 @0x59d2ca). Called from RenderDeviceD3D::DrawEnvCell pc:427904 @0x59f1f6. At draw the FF ambient source switches to FromVertex (pc:425691 @0x59cea2). Dynamic lights ride on top via minimize_envcell_lighting (pc:342794 @0x54c170, dynamic-only).
|
||||
- acdreamEvidence: Interior cells draw with the same global-8 UBO as everything else (EnvCellRenderer.cs:50-55 sharing mesh_modern; accumulateLights mesh_modern.frag:34-63 reads uLights[8] only). LightManager.Tick picks 7 points by distance-to-viewer (LightManager.cs:95-137). No burn-in, no per-cell color buffer anywhere in the pipeline.
|
||||
- portShape: Port the burn-in: at cell registration (EnvCellRenderer.RegisterCell / mesh build) compute per-vertex RGB from all registered static lights reaching the cell (same point-light falloff math as calc_point_light), store as a per-vertex color attribute on the cell mesh, and have the cell shader use it as the ambient/base term (retail FromVertex). Re-burn when the static light set changes (retail's burnedInStaticLights == num_static_lights cache key; acdream equivalent: a registry generation counter). Dynamic lights stay in the UBO path. This also removes interiors' dependence on the global 8-slot budget.
|
||||
|
||||
### [MEDIUM] no-per-object-light-selection (UNVERIFIED (verifier hit token limit)) — Light selection is per-FRAME from the camera, not per-OBJECT against object bounds
|
||||
- blastRadius: Objects/NPCs away from the camera get the camera's lights, not their own: an NPC at the dark end of a long hall is lit by the torches near the camera (or by none), and lights pop as the camera's nearest-8 ranking churns. Retail picks lights per object via falloff-sphere vs object-bounds tests, so each object is lit by what actually reaches it. Visible in any interior with more than ~7 lights or large rooms; part of the M1.5 feel gap (no numbered issue yet).
|
||||
- retailEvidence: Render::minimize_object_lighting pc:343939-344012 @0x54d480: per object, ≤8 slots, dynamic lights first (filtered by remove_object_light's sphere-overlap test vs local_object_center/local_object_radius pc:342820 @0x54c1b0), then static lights whose falloff sphere intersects the object (pc:343975-344000). Invoked per mesh draw when sun is off: DrawMeshInternal pc:427983 @0x59f398.
|
||||
- acdreamEvidence: LightManager.Tick(camPos) runs once per frame from the camera position (GameWindow.cs:7353; LightManager.cs:95-137); every draw call reads the same uLights[8] (mesh_modern.frag:26-32). No per-object or per-cell selection exists.
|
||||
- portShape: Per-instance light indices: keep the global pool (sorted, ~40 statics + 7 dynamics like retail), and per entity (or per cell for the cell-object partition) select ≤8 reaching lights by the retail sphere tests on the CPU, writing 8 light indices into the per-instance SSBO (InstanceData has a documented extension hook). Shader indexes a larger light array (e.g. 64-entry UBO/SSBO) by those per-instance indices. The burn-in divergence above removes most of the pressure; this one covers objects/NPCs.
|
||||
|
||||
### [MEDIUM] no-viewer-light (UNVERIFIED (verifier hit token limit)) — Retail's per-frame viewer light (white point light above the player) is missing
|
||||
- blastRadius: Dungeon/dark-interior readability: retail always adds a white point light (falloff 10 m, intensity from the 'ViewerLightIntensity' option, ~2.25 at default slider) 2 m above the player, re-registered as a dynamic light every frame — the personal light bubble. acdream sealed interiors get only flat 0.2 ambient + whatever cell torches exist; dark dungeons will read much darker/deader than retail. Directly an 'indoor world feels right' item for dungeon milestones.
|
||||
- retailEvidence: SmartBox::set_viewer pc:91781-91835 @0x452c40: viewer_light intensity/falloff loaded from SmartBox::s_fViewerLightIntensity/Falloff, offset z=+2 m above the player (pc:91817-91819), Render::add_dynamic_light(&viewer_light, player objcell_id, player frame) + CObjCell::add_dynamic_lights() every frame (pc:91827-91828). Init: type=point, color 0xffffffff, cone 360 (pc:93342-93356 @0x4547e8); falloff default 10 (pc:1088428 @0x818610); intensity set 0.5×4.5=2.25 via the registry option (pc:761995 @0x6e9a7c).
|
||||
- acdreamEvidence: No viewer/player light anywhere: LightManager has only Sun + registered Setup lights (LightManager.cs:37-137); the only LightSource creation sites are LightInfoLoader.Load (GameWindow.cs:6099) and UpdateSunFromSky's sun (GameWindow.cs:9752, 9772).
|
||||
- portShape: One LightSource (Point, white, Range 10 m, Intensity 2.25 default — future user option) owned by GameWindow, repositioned to player position +2 m Z every frame before Lighting.Tick, registered as a permanent dynamic light. ~20 lines; pairs naturally with the per-object selection work but is independently shippable.
|
||||
|
||||
### [MEDIUM] surface-luminosity-diffuse-ignored (UNVERIFIED (verifier hit token limit)) — Per-surface luminosity (emissive) and diffuse scalars ignored in the world mesh path; sun-tint of surface diffuse missing
|
||||
- blastRadius: Glowing surfaces — lamp panes, lava, light-fixture textures, glowing runes — render dark indoors because their dat Luminosity never becomes emissive light; interior light FIXTURES look off even where the light they cast is right. Outdoors, retail also tints every surface's diffuse by the current sun color (warm dawn/dusk cast on buildings) which acdream approximates only via the sun light's color in N·L, losing the flat diffuse-scalar modulation.
|
||||
- retailEvidence: Surface setup @0x59c64c-0x59c699 (pc:425150-425175): Render::luminosity = surface.luminosity (r=g=b, the emissive term), and Render::diffuse = surface.diffuse × sunlight_color when useSunlight==1, else surface.diffuse flat. Same pattern confirmed in the sky path (SkyRenderer.cs:340 cites retail FUN_0059da60 'surface.Luminosity → D3DMATERIAL.Emissive').
|
||||
- acdreamEvidence: WbMeshAdapter extracts Translucency/Luminosity/Diffuse into AcSurfaceMetadata (src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:277, AcSurfaceMetadata.cs:17) but the only consumer is SkyRenderer (SkyRenderer.cs:264, 339-340); mesh_modern.frag has no luminosity/diffuse-scalar input (whole file — lighting is texture × accumulateLights only).
|
||||
- portShape: Extend the per-batch SSBO (uvec2 handle, layer, flags) with luminosity+diffuse floats from the already-populated AcSurfaceMetadataTable; in mesh_modern.frag, lit = max(lit, luminosity) or lit += luminosity (match retail: emissive floors the lighting term), and multiply the diffuse scalar (×sun color in the outdoor pass) into the diffuse contribution. Localized to WbDrawDispatcher/EnvCellRenderer batch upload + one shader edit.
|
||||
|
||||
### [LOW] dynamic-entity-lights-unregistered (UNVERIFIED (verifier hit token limit)) — Server-spawned and held entities never register Setup lights (and lights don't follow animated parts)
|
||||
- blastRadius: A server-spawned lamp/brazier weenie casts no light; a player- or NPC-held torch casts no light (and when LightInfoLoader is used, lights sit at the entity root, not the hand part — acknowledged in-code). Limited M1.5 impact since inn/dungeon lighting is mostly dat statics, but it diverges from retail where ANY physics object with Setup lights lights its cell.
|
||||
- retailEvidence: CPartArray::InitLights pc:287036 @0x518c00 runs for any part array (creatures included — note SmartArray<LIGHTINFO*> creature_mode_lights acclient.h:52564); CPartArray::AddLightsToCell pc:285959 @0x517ea0 registers into whatever cell the object occupies; LIGHTLIST::set_frame pc:285756 @0x517c60 re-frames lights as the object moves, add/remove per cell crossing (pc:285976/286005).
|
||||
- acdreamEvidence: LightInfoLoader.Load has exactly one call site — the landblock-load loop over lb.Entities (GameWindow.cs:6082-6108); the server-spawn path (EntitySpawnAdapter) and equip/hold paths register nothing. LightInfoLoader.cs:30-33 documents the missing per-part transform.
|
||||
- portShape: Call LightInfoLoader.Load + LightingHookSink.RegisterOwnedLight at server-entity spawn (the EntitySpawnAdapter / GpuWorldState add sites) and UnregisterOwner on despawn (sink already supports it, LightingHookSink.cs:54-59); reposition owned lights from the entity tick (GetOwnedLights exists for exactly this, LightingHookSink.cs:65-68). Per-part placement waits for the animation part-chain work.
|
||||
|
||||
## OPEN QUESTIONS
|
||||
|
||||
- Who WRITES CEnvCell::light_array (the per-vertex RGBColor buffer)? Ghidra confirms allocation in CEnvCell::UnPack (0x0052d470: new RGBColor[num_vertices]) and the destructor frees it (pc:311207-311213 @0x52d9e2), but I could not locate the writer/consumer by name — likely the legacy non-built-mesh (immediate poly) path's equivalent of the burn-in. At EoR runtime use_built_mesh=1 (set in UnPack under DBCache::IsRunTime), so SetStaticLightingVertexColors on the constructed mesh is the live path and light_array appears vestigial — but a faithful-port plan should confirm before declaring it dead.
|
||||
- Exact data flow from world_lights.ambient_color to the D3D ambient render state per draw: SetFFAmbientColor32 is called at 0x0059b165 (pc:423997) inside the PrimD3DRender lighting region with a packed ARGB I did not trace to its source registers; the shape (global ambient applied per draw from world_lights) is solid but the precise call chain (and whether ambient differs between the sun-on and sun-off passes beyond Gate B) is unverified.
|
||||
- Render::restore_all_lighting (called in DrawCells between the sun-off switch and the DrawEnvCell loop, Ghidra decompile) was not decompiled — assumed to restore the FF slot state cleared by useSunlightSet(0); worth one decompile during the port to make sure it doesn't re-enable lights the port should replicate.
|
||||
- Default/typical value of SmartBox.ViewerLightIntensity in practice: the static initializer is 0 (pc:1144644 @0x83cc10) and 0x006e9a7c sets 0.5×4.5=2.25 — I did not identify whether that site is the options-default path (always runs) or only when the user touches a slider; affects how bright the ported viewer light should default.
|
||||
- Whether retail's Gate-B outdoor ambient formula (sqrt(|sunlight|)×0.2 + region ambient_level, × region ambient color) materially differs from acdream's sky-keyframe kf.AmbientColor at representative times of day — acdream's outdoor ambient was visual-verified, but a side-by-side at dawn/noon/midnight would settle whether the formula needs porting for interior seen_outside cells (where it becomes the dominant light after the sun-bleed fix).
|
||||
- LightSource.cs:58's comment claims 'for indoor cells the EnvCell dat carries a per-cell ambient override (r13 §3)' — CEnvCell::UnPack shows no such field; the r13 research doc appears wrong on this point and the comment should be corrected during the port (no per-cell ambient exists in retail; ambient is the global Gate-B switch).
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# 2.6 — Picking and selection vs the building-render discipline
|
||||
|
||||
## RETAIL
|
||||
|
||||
Retail picking is a DEFERRED, DRAW-RESOLVED hit test: the click arms a global "selection cursor", and the actual hit testing happens INSIDE the very same render traversal that draws the world — same cell flood, same portal views, same viewcone gates. There is no separate ray-through-the-world trace.
|
||||
|
||||
CHAIN. (1) Mouse click reaches a UIElement_SmartBoxWrapper handler (0x004e5430, pc:233355-233375): if a 3D-icon UI element is under the mouse it short-circuits via SmartBox::set_found_object; otherwise it calls SmartBox::find_object(x,y) (0x00451C60, Ghidra decompile): stores the click pixel, zeroes click_object_id/click_object_index, validates the pixel is inside the 3D viewport, then calls Render::set_selection_cursor(x-vpX, y-vpY, polys=true) (0x0054B750, Ghidra: check_selection=1, check_curr_object_polys=1, clears m_MouseSelectData.bFoundPolygon/bFoundSphere), sets CPhysicsPart::selected_object_in_view=0 and SmartBox::lookingForObject=1. The accumulator struct Render::MouseSelectData (acclient.h:46610-46620) carries {bFoundPolygon, fClosestPolygon, PolygonID, PolygonIndex, bFoundSphere, fClosestSphere, SphereID, SphereIndex}. (2) During the next world draw, SmartBox::DrawNoBlit (0x00454c20, pc:93700-93740) runs SetNormalMode → update_viewer → RenderNormalMode. Inside Render::update_viewpoint (0x0054cdd0, pc:343689-343696), if check_selection, the click pixel is converted ONCE into a world ray direction: Render::pick_ray (0x0054B610, Ghidra: pixel × xinvscale/yinvscale − tx/ty, composed through the camera Xaxis/Yaxis/Zaxis, normalized) → stored in Render::selection_ray. (3) Every object part draw goes through CPhysicsPart::Draw (0x0050d7a0, Ghidra-confirmed): it sets the load-bearing gate Render::check_curr_object = (physobj != null && physobj->id != 0) || CPhysicsPart::creature_mode != 0, sets RenderDeviceD3D::s_current_physics_part = this, and calls the device DrawMesh. CPhysicsPart::creature_mode (static, 0x00843bf4) has NO writer anywhere in the named pseudo-C (the creature_mode written by CreatureMode::Render 0x004529d0 / cleared by SmartBox::SetNormalMode 0x00453120 is a different field, SmartBox's instance field for char-gen rendering) — so in-world the gate is exactly "this part belongs to a CPhysicsObj with a non-zero server object id". (4) RenderDeviceD3D::DrawMesh (0x005a0860, pc:429180-429340) is where pick meets visibility: with no portal list it runs Render::viewconeCheck(gfxobj->drawing_sphere) and only if not OUTSIDE calls Render::GfxObjUnderSelectionRay(gfxobj) (pc:429262, 0x005a09bb) before DrawMeshInternal; with a portal list it loops the per-cell portal views (Render::set_view(view,i) 0x0054d0e0, honoring RenderDeviceD3D::building_view), runs viewconeCheck per view, and calls GfxObjUnderSelectionRay exactly once on the first view that passes (pc:429318-429325, 0x005a08f8-0x005a090c, var_8_1 guard). An object OUTSIDE in every portal view is never selection-tested at all. (5) Render::GfxObjUnderSelectionRay (0x0054c740, Ghidra-confirmed): requires check_selection && check_curr_object && s_current_physics_part != null. It transforms selection_ray + viewpoint into part-local space (Frame::globaltolocalvec, direction scaled by 1/|gfxobj_scale|, ray length = RAY_INFINITE_DISTANCE — no max distance). Coarse test: CSphere::sphere_intersects_ray(gfxobj->drawing_sphere); a sphere hit farther than the globally-closest polygon hit so far is discarded entirely. Otherwise it records the nearest sphere hit (SphereID = CPhysicsPart::get_physobj_id = physobj->id, SphereIndex = physobj_index), then — because check_curr_object_polys — scans the object's FULL FLAT polygon array gfxobj->polygons[0..num_polygons] (stride 0x30; Ghidra-confirmed loop), NOT the DrawingBSP-filtered subset, breaking on the FIRST polygon the ray hits in array order; that hit's distance is then compared against the global fClosestPolygon and recorded if nearer. CPolygon::polygon_hits_ray (0x005395E0, Ghidra: if sides_type==0 (single-sided) and dot(ray.dir, plane.N) > 0 → miss; else Plane::compute_time_of_intersection + point_in_polygon). (6) Harvest, end of the SAME DrawNoBlit frame (pc:93722-93733): Render::GetMouseSelectionObjectID (0x0054C950): bFoundPolygon → PolygonID, else bFoundSphere → SphereID, else 0 — any polygon hit categorically beats any sphere hit. The id lands in SmartBox::click_object_id and fires ECM_UI::SendNotice_SmartBoxObjectFound(id); lookingForObject=0; Render::clear_selection_cursor (0x0054B790).
|
||||
|
||||
WHAT IS NOT TESTED. EnvCell structure polys: RenderDeviceD3D::DrawEnvCell (0x0059f170, pc:427898-427935) pushes structure->polygons straight onto Render::PolyList (planeMask=0xffffffff) or D3DPolyRender::DrawMesh for built meshes — s_current_physics_part is never set, GfxObjUnderSelectionRay is never reached: CELL GEOMETRY IS INVISIBLE TO THE PICK. Same for land cells (DrawLandCell 0x0059f120 → ACRender::landPolysDraw). Buildings DO route through CPhysicsPart::Draw (DrawBuilding 0x0059f2a0, pc:429282-429295), BUT CBuildingObj::makeBuilding (0x006b53a0, pc:701302) calls CPhysicsObj::InitObjectBegin(result, 0, 0) and InitObjectBegin (0x0050ff80, pc:278065-278073) does this->id = arg2 → buildings have id 0 → check_curr_object=0 → building shells (including their baked door/window portal quads) are NEVER pick candidates. Dat-baked statics likewise: EnvCell statics CPhysicsObj::makeObject(setupId, 0, 0) pc:309713 (0x0052c3e7), landblock statics pc:314697 (0x00530acb) — object_iid 0.
|
||||
|
||||
ANSWERS. Q1: retail hit-tests ONLY CPhysicsObj parts whose physobj->id != 0 (server-identified weenies), per-poly with sphere fallback, at draw time. It never picks against cell geometry; walls never "swallow" a click by being hit — they are invisible to the pick; missing everything yields id 0 (deselect). A baked closed-door/window portal poly on a building shell can NOT swallow a click (id 0 gate). Q2: yes — enforced by the SAME PView, by construction: objects only reach CPhysicsPart::Draw via the flood's drawn cells, and the selection test only fires in portal views where the drawing_sphere passes viewconeCheck (DrawMesh 0x005a0860). Granularity is sphere-vs-portal-view-cone, not per-pixel — an object whose sphere pokes through a doorway view can be picked via a polygon that is pixel-wise clipped; retail accepts that slop. Q3: the door ENTITY (server weenie, id != 0) takes the click categorically; the shell's baked portal quad never competes because the building is not a candidate. The only place portal polys touch picking at all is on id!=0 objects, where the FULL flat poly array is tested whether or not the DrawingBSP drew those polys this frame. Bonus: retail also tracks "is the selected object still visible" through the same draw — SmartBox::set_selected_object_id (0x00451BA0, pc:90768) sets CPhysicsPart::viewcone_check_object_id; CPhysicsPart::Draw flips selected_object_in_view=1 when DrawMesh returns 2 (drawn) for that id (Ghidra); CPlayerSystem::OnObjectRangeExit (0x00560870, pc:365073-365090) deselects via ACCWeenieObject::SetSelectedObject(0,0) when range-exited AND not in view, else re-registers a range handler at 25 m indoors / 75 m outdoors (0x005608a1).
|
||||
|
||||
## ACDREAM
|
||||
|
||||
acdream picks SYNCHRONOUSLY at input time against a server-entity dictionary, with a screen-rect sphere test plus a parallel ad-hoc ray-vs-cell-polygon occluder; it has zero coupling to the PView flood that decides what draws.
|
||||
|
||||
CHAIN. (1) InputAction.SelectLeft / SelectDblLeft → GameWindow.PickAndStoreSelection (src/AcDream.App/Rendering/GameWindow.cs:10414-10419 → 10515). (2) Candidates = _entitiesByServerGuid.Values (GameWindow.cs:10546) — the dict is populated ONLY from server spawns (GameWindow.cs:2855, `_entitiesByServerGuid[spawn.Guid] = entity`), so building shells, dat-baked EnvCell statics (0x40xxxxxx ids), and atlas scenery are structurally absent. WorldPicker additionally skips ServerGuid==0 and the player (src/AcDream.Core/Selection/WorldPicker.cs:253-254) — note retail does NOT skip the player. (3) Test = the screen-rect overload WorldPicker.Pick (WorldPicker.cs:210-285): per candidate, the Setup-level SelectionSphere (resolved by GameWindow.TryGetEntitySelectionSphere, GameWindow.cs:11010-11045: dat Setup.SelectionSphere scaled by entity scale, rotated to world; null → skipped) is projected via ScreenProjection.TryProjectSphereToScreenRect to a screen rect, inflated 8 px to match the target-indicator brackets, hit-tested against the mouse pixel; among containing rects the nearest clip.W depth wins (WorldPicker.cs:261-282). There is NO polygon stage — Stage B (CPolygon::polygon_hits_ray port) is explicitly deferred per issue #71 (WorldPicker.cs:199-204), so retail's "any polygon hit beats any sphere hit" arbitration does not exist. (4) Occlusion (#86, closed 2026-05-19): PickAndStoreSelection snapshots every loaded EnvCell's physics (`_physicsDataCache.CellStructIds`, GameWindow.cs:10535-10540) and passes cellOccluder = CellBspRayOccluder.NearestWallT (GameWindow.cs:10565-10568). NearestWallT (src/AcDream.Core/Selection/CellBspRayOccluder.cs:42-79) Möller-Trumbore-tests the click ray against EVERY polygon in every loaded cell's `Resolved` dict — which is cellStruct.PhysicsPolygons (src/AcDream.Core/Physics/PhysicsDataCache.cs:176,208), the collision set, NOT the drawn set — two-sided, no BSP, no portal awareness; the nearest wall t becomes a clip.W depth and candidates deeper than it are skipped (WorldPicker.cs:229-248, 276). Portal-opening polys live in a separate dict (CellPhysics.PortalPolygons, PhysicsDataCache.cs:178-179, 212, 571) that the occluder does not consult. (5) Visibility coupling: NONE. RetailPViewRenderer.DrawInside produces a per-frame RetailPViewFrameResult with OrderedVisibleCells (src/AcDream.App/Rendering/RetailPViewRenderer.cs:44, 71, 153) but the picker never reads it; an entity in a cell the flood rejected is still a candidate as long as no occluder polygon happens to lie along the ray, and conversely the occluder can block entities the flood draws. (6) Selected-object tracking: _selectedGuid persists until the next pick; TargetIndicatorPanel projects the same SelectionSphere every frame (GameWindow.cs:1278-1322) with no "was it drawn this frame" gate and no range-based deselect — no analog of retail's viewcone_check_object_id / selected_object_in_view / OnObjectRangeExit machinery.
|
||||
|
||||
## DIVERGENCES
|
||||
|
||||
### [MEDIUM] pick-outside-draw-traversal (UNVERIFIED (verifier hit token limit)) — Pick is resolved outside the draw traversal — visibility enforced by a parallel mechanism, not the flood
|
||||
- blastRadius: The pick can select entities the flood does not draw (entity in a cell the PView rejected — e.g. a cellar NPC below the floor, or an entity around a corner the flood culled — is pickable whenever no EnvCell physics polygon happens to lie along the ray, including whenever the cell physics simply is not streamed in), and can refuse entities the flood draws (see occluder-stricter-than-retail). No single filed issue maps to it, but it is the structural risk for the holistic port: every flood fix in the #108/#109/#114 family silently leaves picking behind, because what-you-can-click and what-you-can-see are computed by two unrelated mechanisms. Contributes to the 'indoor world feels right' mandate at doorways and multi-story interiors.
|
||||
- retailEvidence: Pick is resolved inside the same render traversal that draws: arming via SmartBox::find_object 0x00451C60 → Render::set_selection_cursor 0x0054B750; ray built once in Render::update_viewpoint (pc:343689, 0x0054cfab); per-part test Render::GfxObjUnderSelectionRay 0x0054c740 fires only from RenderDeviceD3D::DrawMesh 0x005a0860 after viewconeCheck(drawing_sphere) passes for at least one portal view (pc:429262 / 429318-429325); objects in non-flooded cells never reach CPhysicsPart::Draw 0x0050d7a0 at all; harvest at end of SmartBox::DrawNoBlit 0x00454c20 (pc:93722-93733) via Render::GetMouseSelectionObjectID 0x0054C950.
|
||||
- acdreamEvidence: GameWindow.PickAndStoreSelection (GameWindow.cs:10515) runs synchronously at input time against _entitiesByServerGuid.Values (GameWindow.cs:10546) with no reference to RetailPViewRenderer's per-frame OrderedVisibleCells (RetailPViewRenderer.cs:44,71); occlusion is the separate CellBspRayOccluder ray test (GameWindow.cs:10565-10568, CellBspRayOccluder.cs:42-79).
|
||||
- portShape: Adopt retail's arm-then-resolve-in-draw shape: a click arms a selection ray (Render::pick_ray equivalent already exists as WorldPicker.BuildRay); during the unified DrawInside traversal, each entity that passes the same per-cell / per-portal-view gates the draw uses gets a sphere test (and later the Stage-B first-poly-hit test) into a MouseSelectData-style accumulator; harvest after the frame. CellBspRayOccluder and the per-pick cell-physics snapshot get deleted — occlusion falls out of 'not submitted by the flood → not testable', the one-gate discipline.
|
||||
|
||||
### [LOW] occluder-stricter-and-looser-than-retail (UNVERIFIED (verifier hit token limit)) — Cell-polygon ray occluder blocks picks retail allows and allows picks retail blocks
|
||||
- blastRadius: Stricter: an entity visually behind a baked column / interior wall of a cell that IS in the drawn flood is pickable in retail (cell geometry is invisible to the pick; only the sphere-vs-portal-view gate applies) but blocked in acdream by the wall polygon's smaller ray-t; outdoors, an NPC standing behind a building is retail-pickable (drawn, z-buffer notwithstanding) but acdream's ray may cross the building's loaded interior EnvCells and block. Looser: beyond the streamed cell-physics window there is no occlusion at all. Both directions are rare in practice and arguably acdream's strict side feels nicer — but it is not retail, and it hard-codes a second visibility oracle. Possible doorway-blocking variant is an open question (see openQuestions).
|
||||
- retailEvidence: DrawEnvCell 0x0059f170 (pc:427898-427935) submits cell polys with no selection hook; GfxObjUnderSelectionRay 0x0054c740 requires s_current_physics_part, set only in CPhysicsPart::Draw 0x0050d7a0 — cell geometry never participates; occlusion granularity is drawing_sphere vs portal view cone in DrawMesh 0x005a0860.
|
||||
- acdreamEvidence: CellBspRayOccluder.NearestWallT (CellBspRayOccluder.cs:42-79) tests every Resolved physics polygon of every loaded EnvCell (snapshot at GameWindow.cs:10535-10540); WorldPicker.Pick skips candidates deeper than the wall depth (WorldPicker.cs:276).
|
||||
- portShape: Subsumed by pick-outside-draw-traversal: once the pick rides the draw traversal, delete the occluder rather than tuning it. No standalone fix recommended.
|
||||
|
||||
### [LOW] no-poly-stage-no-poly-beats-sphere (UNVERIFIED (verifier hit token limit)) — No polygon stage: screen-rect sphere test replaces retail's first-poly-hit + poly-beats-sphere arbitration
|
||||
- blastRadius: Over-pick on entities whose visible mesh is much smaller than their SelectionSphere rect (the documented #71 deferral), and no per-part precision on multi-part setups (retail tests each CPhysicsPart's gfxobj drawing_sphere + flat poly array; acdream tests one Setup-level SelectionSphere). Deliberate Stage-A design that intentionally matches the drawn indicator rect — only worth revisiting if visual testing surfaces an over-pick, exactly as WorldPicker.cs:199-204 already states.
|
||||
- retailEvidence: GfxObjUnderSelectionRay 0x0054c740 (Ghidra): per-part CSphere::sphere_intersects_ray(drawing_sphere) coarse gate, then first-hit-in-array-order scan of gfxobj->polygons[0..num_polygons] via CPolygon::polygon_hits_ray 0x005395E0; GetMouseSelectionObjectID 0x0054C950 returns PolygonID whenever any polygon hit exists, else SphereID — polygon hits categorically beat sphere hits.
|
||||
- acdreamEvidence: WorldPicker.Pick screen-rect overload (WorldPicker.cs:210-285): Setup.SelectionSphere → screen rect + 8 px inflate → nearest clip.W; Stage B deferred per issue #71 (WorldPicker.cs:199-204); sphere source is the Setup-level SelectionSphere (GameWindow.cs:11010-11045), not per-part drawing_spheres.
|
||||
- portShape: When the pick moves into the draw traversal, Stage B becomes natural: per drawn part, sphere gate on the part's mesh bounding sphere then first-poly-hit against the part's flat poly list (the mesh data WbMeshAdapter already decodes), with the retail accumulator arbitration. Keep #71's trigger condition — only do it when an over-pick is actually observed.
|
||||
|
||||
### [LOW] no-selected-in-view-tracking (UNVERIFIED (verifier hit token limit)) — No selected_object_in_view analog — indicator persists and selection never auto-deselects on occlusion/range
|
||||
- blastRadius: Target indicator brackets keep drawing on a selected entity that walks behind a wall or that the flood stops drawing, and selection never expires by range; retail hides/deselects via drawn-this-frame tracking + 25 m indoor / 75 m outdoor range handlers. Pure polish; no filed issue.
|
||||
- retailEvidence: SmartBox::set_selected_object_id 0x00451BA0 (pc:90768) sets CPhysicsPart::viewcone_check_object_id; CPhysicsPart::Draw 0x0050d7a0 (Ghidra) sets selected_object_in_view=1 when DrawMesh returns 2 (drawn) for the matching physobj->id; CPlayerSystem::OnObjectRangeExit 0x00560870 (pc:365073-365090) deselects via ACCWeenieObject::SetSelectedObject(0,0) when range-exited and not in view, else re-registers at 25 m indoors / 75 m outdoors (0x005608a1).
|
||||
- acdreamEvidence: _selectedGuid persists until the next pick (GameWindow.cs:10570-10572); TargetIndicatorPanel resolver (GameWindow.cs:1278-1322) projects the SelectionSphere unconditionally — no drawn-this-frame flag, no range/visibility deselect.
|
||||
- portShape: One flag on the draw traversal: when the per-entity draw submission processes the currently-selected guid, mark selectedDrawnThisFrame; indicator reads it; a range-exit hook (the interaction layer already tracks distances for UseRadius) deselects when out-of-range AND not drawn. Small, fully parasitic on the holistic port's unified entity walk.
|
||||
|
||||
## OPEN QUESTIONS
|
||||
|
||||
- Does Chorizite DatReaderWriter's CellStruct.PhysicsPolygons (the occluder's polygon set, PhysicsDataCache.cs:176/208) include polygons spanning OPEN doorway portals? Retail-side I confirmed CCellStruct::UnPack (Ghidra 0x00533D00) resolves the portal index list into the VISIBLE polygons array (`portals[i] = polygons + idx`), matching acdream's PhysicsDataCache.cs:178 comment — but that says nothing about whether physics_polygons ALSO contains a coplanar quad at the opening. If it does, CellBspRayOccluder blocks clicks on entities visible through an open doorway (user-visible: 'can't select the NPC in the next room'). Testable in 2 minutes in-client at the Holtburg inn vestibule, or by dumping a doorway cell via the existing CellDump apparatus and ray-testing through the portal. Becomes moot if the pick moves into the draw traversal.
|
||||
- CPhysicsPart::creature_mode (static, 0x00843bf4) has no writer anywhere in the 1.4M-line named pseudo-C — either dead (gate is purely physobj->id != 0) or written by raw address from the obfuscated/packed minority. Does not change in-world conclusions, but a writer hiding in packed code could widen the pick gate in some mode I haven't identified.
|
||||
- Retail does not exclude the local player from the pick (CPhysicsPart::Draw gates only on id != 0; the player has an id and is drawn in third person) — I did not trace whether ECM_UI::SendNotice_SmartBoxObjectFound consumers accept a self-click as a selection. acdream explicitly skips the player (WorldPicker.cs:254, skipServerGuid). Worth one retail-client test before porting the harvest path.
|
||||
- The consumer chain of ECM_UI::SendNotice_SmartBoxObjectFound (what the UI layer does when click_object_id is 0 or resolves to no weenie) was not traced — I assumed deselect-on-zero. Affects only the harvest port's edge cases, not the geometry/visibility conclusions.
|
||||
- Whether retail's sphere-hit fallback (GetMouseSelectionObjectID returns SphereID only when NO polygon hit exists anywhere in the frame) matters in practice for tiny/poly-less objects — determines whether acdream's Stage-A rect test is closer to retail's fallback than to its primary path, i.e. how urgent #71 (Stage B poly refine) really is.
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
# 2.3 — Sky, weather, and procedural scenery vs the portal-view discipline
|
||||
|
||||
## RETAIL
|
||||
|
||||
FRAME ENTRY — SmartBox::RenderNormalMode (Ghidra 0x453aa0). The branch key is the VIEWER (collided camera) cell: `(viewer.objcell_id & 0xffff) < 0x100` = viewer outdoors. Outdoors: LScape::update_viewpoint(viewer cell) → Render::set_default_view (no portal clip list) → useSunlightSet(1) → LScape::draw. Indoors: if viewer_cell->seen_outside, LScape::update_viewpoint(Position::get_outside_cell_id(viewer)) — this only re-centers the 2D landblock draw list under the indoor viewer, it draws nothing — then RenderDevice vtable+0x48 = RenderDeviceD3D::DrawInside (vtable map pc:1037065; impl 0x0059f0d0) which tail-calls PView::DrawInside(indoor_pview, viewer_cell).
|
||||
|
||||
SKY/WEATHER DRAW SITE — LScape::draw (Ghidra 0x00506330): (1) GameSky::Draw(sky, 0) FIRST, (2) back-to-front DrawBlock per landblock with in_view != OUTSIDE, (3) if LScape::weather_enabled: GameSky::Draw(sky, 1) LAST. GameSky::Draw (Ghidra 0x00506ff0) gates its whole body on `is_player_outside() || pass==0`: the sky pass is unconditional (whenever LScape::draw runs at all), the weather pass additionally requires the PLAYER to be outdoors. SmartBox::is_player_outside (Ghidra 0x00451e80) = (player cell & 0xffff) < 0x100. Depth state for BOTH passes: SetDepthBufferMode(DEPTHTEST_ALWAYS, z-write OFF), zfar ×4, fog forced per LScape::m_override_enabled; restored to (LESSEQUAL, write on) after (0x00507063/0x005070fc). Sky pass iterates sky_obj skipping property bit 0x01 (after-cell members), bit 0x04 objects when !weather_enabled, bit 0x02 under the admin fog override, calling CPhysicsObj::DrawRecursive each (pc:268704-268760). Weather pass = RenderDevice::DrawObjCellForDummies(after_sky_cell) (0x005070da; impl 0x005a0760 = UpdateObjCell + shadow-part sort + DrawObjCell). GameSky owns two dummy CEnvCells, before_sky_cell/after_sky_cell (acclient.h:35426): MakeObject (0x00506ee0) puts props&1 objects in after_sky_cell, the rest in before_sky_cell, and refuses to create props&4 (weather) objects while weather is disabled (also created/deleted on toggle by CreateDeletePhysicsObjects 0x005073c0, pc:268912-269036).
|
||||
|
||||
SKY POSITION — SmartBox::set_viewer (0x00452c40) calls LScape::set_sky_position(this->lscape, &this->viewer) (pc:91830 / 0x00452d45; impl 0x00504c30) → GameSky::UpdatePosition (0x00506dd0, BN pc:268569-268618): both dummy cells snap to the VIEWER's cell id + frame; weather objects (bit 0x04) snap x,y to the viewer origin and, when bit 0x08 is clear, pin z := −120.0f (constant 0xc2f00000 stored at 0x00506e96-e98 — absolute world height, not camera-relative). So the rain cylinders (GfxObj 0x01004C42/0x01004C44, ~815 m tall) ride the camera in x,y but hang at fixed world z −120.
|
||||
|
||||
INDOOR LOOKING OUT — PView::DrawInside (0x005a5860, pc:433793) → ConstructView (0x005a57b0: zero outside_view.view_count, BFS flood via InitCell/ClipPortals/AddViewToPortals) → PView::DrawCells (0x005a4840, pc:432709). ClipPortals (0x005a5520): a portal whose other_cell_id == 0xFFFFFFFF is the OUTSIDE; if this->draw_landscape, its accumulated clip view is merged into the pview's outside_view via Render::copy_view (clipped when global `cliplandscape` != 0, unclipped otherwise; 0x005a566c-5711). PView::DrawCells then runs FOUR stages: (a) if outside_view.view_count > 0 → useSunlightSet(1), Render::PortalList = pview, **LScape::draw(lscape)** — the ENTIRE outdoor world draws through the doorway: sky pass, terrain blocks, buildings, scenery, outdoor statics/creatures, and the weather pass which self-gates on is_player_outside (player indoors → no rain even through the door). Every mesh in this slice is portal-clipped: DrawMesh (0x005a0860) loops PortalList views, viewconeCheck per view, and draws per intersecting view under that view's clip planes. (b) Clear(4 /*depth*/) — a FULL depth-buffer clear, conditional on portalsDrawnCount/forceClear (0x005a48a9) — then per cell (reverse cell_draw_list) per view, the OUTSIDE portal polys (other_cell == −1) are re-drawn via D3DPolyRender::DrawPortalPolyInternal (0x005a49af-49b7): a "z-stamp" that writes the doorway aperture's true depth back so indoor geometry lying beyond the doorway can't paint over the outside image. (c) useSunlightSet(0) + restore_all_lighting; shells per cell per view via DrawEnvCell (0x0059f170; planeMask=0xffffffff submit pc:427922). (d) per cell: Render::PortalList = the cell's last portal_view; DrawObjCellForDummies(cell) — cell contents portal-clipped.
|
||||
|
||||
OUTDOOR CONTENT (what the through-door slice contains) — RenderDeviceD3D::DrawBlock (0x005a17c0): per LandCell, DrawLandCell (0x0059f120 — terrain polys) then DrawSortCell (0x0059f140): if the cell has a building → DrawBuilding, then DrawObjCell (the cell's object list). DrawBuilding (0x0059f2a0, pc:429282-429295) installs outdoor_pview->outdoor_portal_list = building->portals before CPhysicsPart::Draw; portal polys inside the building's DrawingBSP dispatch RenderDeviceD3D::DrawPortal (0x0059f0e0) → PView::DrawPortal (0x005a5ab0, pc:433895) → ConstructView(CBldPortal) (0x005a59a0) → DrawCells of that building's interior. I.e. NESTED recursion: standing inside looking out, another building's interior renders through its open door/window because DrawBuilding runs inside the through-door LScape::draw.
|
||||
|
||||
SCENERY — CLandBlock::get_land_scenes (0x00530460, pc:314322): pseudo-random ObjectDesc::Place per scene-type slot; skips cells holding buildings (CSortCell::has_building 0x00530865), road cells (on_road 0x005307ce), too-steep terrain (CheckSlope 0x0053089a); then CPhysicsObj::makeObject → set_initial_frame → **add_obj_to_cell(landcell)** (0x00530923) + CLandBlock::add_static_object. Scenery is therefore an ordinary per-LandCell object — drawn through DrawSortCell/DrawObjCell and portal-clipped through doorways exactly like every other static. There is no separate scenery draw path.
|
||||
|
||||
## ACDREAM
|
||||
|
||||
ROOT PICK — GameWindow.OnRender: `clipRoot = viewerRoot ?? _outdoorNode` (src/AcDream.App/Rendering/GameWindow.cs:7497). Steady-state frames (eye indoors OR outdoors) ALL go through RetailPViewRenderer.DrawInside; the `clipRoot is null` "Outdoor LScape entry" (GameWindow.cs:7546-7587 sky+terrain, 7874-7889 weather) survives only as the pre-spawn/login safety path. Sky policy gate: `renderSky = viewerRoot is null || rootSeenOutside` (GameWindow.cs:7423).
|
||||
|
||||
DRAWINSIDE — src/AcDream.App/Rendering/RetailPViewRenderer.cs:44-109: PortalVisibilityBuilder.Build floods from the root; exit portals (OtherCellId==0xFFFF) union their clipped screen regions into OutsideView (src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:279, 87); the synthetic outdoor node seeds a FULL-SCREEN OutsideView quad (PortalVisibilityBuilder.cs:80-89). Outdoor-node roots additionally merge per-building interior floods within 48 m (RetailPViewRenderer.cs:60-61, 115-145) — interior roots do NOT. ClipFrameAssembler produces ≤8-plane slices + an NDC AABB each; InteriorEntityPartition (src/AcDream.App/Rendering/InteriorEntityPartition.cs:22-79) buckets every landblock entity: indoor ParentCellId→ByCell, outdoor/none→Outdoor, server-guid-without-cell→LiveDynamic.
|
||||
|
||||
OUTSIDE SLICE — DrawLandscapeThroughOutsideView (RetailPViewRenderer.cs:214-238) loops slices: SetTerrainClip(slice.Planes) + entity clip routing, then GameWindow.DrawRetailPViewLandscapeSlice (GameWindow.cs:9465-9551): scissor to the slice NDC AABB → SkyRenderer.RenderSky (9486, clip distances ON) → SkyPreScene particles → terrain Draw (9496) → the WHOLE Outdoor bucket (scenery + dat stabs + building exteriors + outdoor-parented live entities) via WbDrawDispatcher, frustum cull + slice clip routing (9503-9512) → outdoor-filtered Scene particles with clip distances DISABLED, scissor only (9518-9530) → SkyRenderer.RenderWeather (9535) + SkyPostScene particles, gated ONLY on renderSky. After all slices: a SCISSORED depth clear per slice, interior roots only (GameWindow.cs:7644-7652; explicitly null for outdoor-node roots); then DrawExitPortalMasks — declared on the context (RetailPViewRenderer.cs:534) but NEVER assigned by GameWindow, so the pass no-ops (RetailPViewRenderer.cs:331-332); then DrawEnvCellShells (GL-clipped only for outdoor roots per #114 scope, :104-105, 378-398); then DrawCellObjectLists + per-cell particles (scissor only, clip off; GameWindow.cs:9553-9580).
|
||||
|
||||
SKYRENDERER — src/AcDream.App/Rendering/Sky/SkyRenderer.cs (WB SkyboxRenderManager port): RenderSky draws the pre-scene partition (Properties bit 0x01 clear), RenderWeather the post-scene partition (bit set) (:106-144, 219-235 — correct retail bit semantics, citing MakeObject 0x00506ee0). GL state: depth test OFF + depth mask OFF + cull off + per-submesh additive/alpha blend (:194-210) — equivalent to retail's DEPTHTEST_ALWAYS/no-write. View translation zeroed = camera-centred sky (:175-178). Weather −120 offset applied as a CAMERA-RELATIVE model translation for bit4 && !bit8 objects (:307-308). Fog-override bit 0x02 skip implemented (:240-241). sky.vert writes gl_ClipDistance from the terrain-clip UBO (src/AcDream.App/Rendering/Shaders/sky.vert:153) so the doorway slices genuinely clip sky and rain.
|
||||
|
||||
SCENERY — SceneryGenerator.Generate (src/AcDream.Core/World/SceneryGenerator.cs:86 via WbSceneryAdapter) → GameWindow.BuildSceneryEntitiesForStreaming (GameWindow.cs:5290-5473): scenery becomes WorldEntity ids 0x80XXYYII with MeshRefs and NO ParentCellId (5463-5472) → lands in the Outdoor partition bucket → drawn once per outside slice with per-entity frustum cull + slice clip planes. Building suppression at generation uses a 9×9 vertex-grid set derived from building origins (5310-5316). There are no per-LandCell object lists outdoors — the bucket is flat.
|
||||
|
||||
## DIVERGENCES
|
||||
|
||||
### [CRITICAL] outside-portal-zstamp-missing (UNVERIFIED (verifier hit token limit)) — No depth re-stamp of outside portal polys after the outside-view draw (and the depth clear is scissored, not full)
|
||||
- blastRadius: #108 (grass texture sweeping across the upstairs door opening during the cellar ascent — the outside image / terrain drawn through the doorway region is never re-fenced in depth) and #109 (far exit door oscillating between door texture and background — a per-frame depth race in the doorway rectangle between the slice's cleared depth, the door entity, and shell geometry). Both are top entries in the 2026-06-11 holistic mandate.
|
||||
- retailEvidence: PView::DrawCells (0x005a4840, pc:432709): after LScape::draw through outside_view it issues Clear(4 /*depth, FULL buffer*/) conditional on portalsDrawnCount (0x005a48a9), then per cell per view re-draws every OUTSIDE portal poly (other_cell == 0xFFFFFFFF) via D3DPolyRender::DrawPortalPolyInternal (0x005a49a0-49b7) — writing the doorway aperture's real depth back so indoor geometry beyond the doorway cannot overpaint the outside image, while geometry in front of it still occludes normally.
|
||||
- acdreamEvidence: Depth clear is scissored to each slice's NDC AABB and only for interior roots (GameWindow.cs:7644-7652). The z-stamp stage exists as RetailPViewRenderer.DrawExitPortalMasks (RetailPViewRenderer.cs:325-343) but the callback is null — GameWindow's RetailPViewDrawContext initializer (GameWindow.cs:7604-7663) never assigns DrawExitPortalMasks, so the pass no-ops every frame.
|
||||
- portShape: Wire the masks stage: after the slice depth-clears, draw each visible cell's exit-portal polygons depth-only (color mask off) per slice — the portal quads already exist as PortalRef polys per the e223325 dat finding. Match retail's full-buffer Clear(depth) gated on any-slice-drawn rather than per-slice scissored clears (or prove the scissored equivalent identical and document it). Order: outside slices → depth clear → portal z-stamp → shells → objects, exactly DrawCells.
|
||||
|
||||
### [HIGH] weather-indoor-gate (UNVERIFIED (verifier hit token limit)) — Weather pass not gated on is_player_outside — rain draws through doorways while the player is inside
|
||||
- blastRadius: Rain/snow visibly composites in the doorway slice (depth-test off) when standing inside a building looking out; retail shows zero weather in that situation. Part of the 'indoor world feels right' gap; no dedicated issue number yet.
|
||||
- retailEvidence: GameSky::Draw (0x00506ff0) gates its body on `is_player_outside() || pass==0` (0x00507009) — the weather pass (pass 1, DrawObjCellForDummies(after_sky_cell) at 0x005070da) requires the PLAYER cell to be outdoor (SmartBox::is_player_outside 0x00451e80: (cell & 0xffff) < 0x100), independently of the viewer/outside_view. LScape::draw additionally requires LScape::weather_enabled (0x0050638b-96).
|
||||
- acdreamEvidence: DrawRetailPViewLandscapeSlice calls _skyRenderer.RenderWeather inside every OutsideView slice gated only on renderSky (GameWindow.cs:9533-9536), and renderSky is the viewer-root seen_outside policy (GameWindow.cs:7423) — the player's indoor/outdoor state is never consulted.
|
||||
- portShape: Pass a playerIsOutside bool (player CellId & 0xFFFF < 0x100 — already computed for lighting at GameWindow.cs:7291-7320) into the slice context; skip RenderWeather + the SkyPostScene weather particles when false. RenderSky stays ungated (retail pass-0 is unconditional).
|
||||
|
||||
### [HIGH] particles-not-portal-clipped (UNVERIFIED (verifier hit token limit)) — Particles draw with clip distances disabled — scissor rectangle only, no portal-plane clipping
|
||||
- blastRadius: The reported particles-through-walls bug: an emitter whose particles fall inside the doorway's bounding RECTANGLE but outside the portal WEDGE paints across interior walls; same for per-cell particles bleeding across neighbor cells inside the AABB.
|
||||
- retailEvidence: Retail particles are cell objects drawn through DrawObjCellForDummies under the active Render::PortalList — DrawMesh (0x005a0860) loops the portal views and draws only per intersecting view with that view's clip planes installed (Render::set_view), i.e. polygon-level portal clipping identical to statics.
|
||||
- acdreamEvidence: Both particle draw sites explicitly DisableClipDistances() before drawing and rely on the slice/cell NDC-AABB scissor alone: outdoor slice particles at GameWindow.cs:9518-9530, per-cell particles at GameWindow.cs:9568-9579 (DrawRetailPViewCellParticles).
|
||||
- portShape: Give the particle pipeline the same slice clip the sky already has: write gl_ClipDistance in the particle vertex/billboard shader from the slice planes (the terrain-clip UBO is already bound), enable clip distances around the draw, keep the scissor as a cheap pre-cull.
|
||||
|
||||
### [MEDIUM] no-nested-building-flood-through-outside-view (UNVERIFIED (verifier hit token limit)) — Interior roots never flood neighbor buildings — their interiors are absent from the through-door outside view
|
||||
- blastRadius: Standing inside looking out a doorway at another building with an open door or window: retail renders that building's interior through its aperture; acdream shows the aperture as background/unsealed (same artifact family the outdoor look-in had pre-R-A2). Contributes to 'indoor world feels right'; not yet a numbered issue.
|
||||
- retailEvidence: The through-door slice is the full LScape::draw, whose DrawSortCell (0x0059f140) calls DrawBuilding (0x0059f2a0, pc:429282-429295); DrawBuilding installs outdoor_pview->outdoor_portal_list and the building DrawingBSP's portal polys dispatch RenderDeviceD3D::DrawPortal (0x0059f0e0) → PView::DrawPortal (0x005a5ab0, pc:433895) → ConstructView(CBldPortal) (0x005a59a0) → DrawCells of the neighbor interior — nested recursion reachable from inside-looking-out.
|
||||
- acdreamEvidence: MergeNearbyBuildingFloods runs only when ctx.RootCell.IsOutdoorNode (RetailPViewRenderer.cs:60-61); for interior roots NearbyBuildingCells is null by construction (GameWindow.cs:7610). The Outdoor bucket draws neighbor buildings' EXTERIOR entities through the slice (GameWindow.cs:9503-9512) but no interior cells flood.
|
||||
- portShape: When an interior root has OutsideView slices, run the existing BuildFromExterior per-building seeding (the R-A2 machinery, keep-listed) for buildings whose entrance portals intersect a slice, intersect the resulting views with the slice planes, and append to the frame — the renderer already merges per-building frames (MergeBuildingFrame, RetailPViewRenderer.cs:151-160).
|
||||
|
||||
### [LOW] outdoor-objects-flat-bucket (UNVERIFIED (verifier hit token limit)) — Outdoor scenery/statics drawn as one flat bucket per slice instead of per-LandCell object lists
|
||||
- blastRadius: No single named bug; costs are structural: the full outdoor entity set is re-dispatched once per doorway slice (perf when multiple doorways visible), alpha-blended outdoor objects have no near-to-far cell ordering, and per-cell semantics retail relies on (cell in_view, building-cell suppression at draw time) have no home. Mostly masked today by clip planes + scissor + depth buffer.
|
||||
- retailEvidence: Scenery is placed INTO land cells (CPhysicsObj::add_obj_to_cell, 0x00530923 in CLandBlock::get_land_scenes 0x00530460) and drawn per cell in block draw order: DrawBlock (0x005a17c0) → per-LandCell DrawLandCell (0x0059f120) + DrawSortCell (0x0059f140) → DrawObjCell, with per-view viewcone checks in DrawMesh (0x005a0860).
|
||||
- acdreamEvidence: Scenery entities carry no ParentCellId (GameWindow.cs:5463-5472) → InteriorEntityPartition.Outdoor (InteriorEntityPartition.cs:42-49, 61-64); the whole bucket is drawn in one WbDrawDispatcher call per OutsideView slice with frustum cull only (GameWindow.cs:9503-9512).
|
||||
- portShape: Long-term: per-LandCell object lists (the render twin of the A6.P4 per-cell shadow architecture), letting the slice walk cells in draw order like DrawBlock. Near-term acceptable as-is; do not re-fix under this area alone.
|
||||
|
||||
### [LOW] rain-anchor-z-relative (UNVERIFIED (verifier hit token limit)) — Rain cylinder z pinned camera-relative (−120 below camera) instead of world-absolute z = −120
|
||||
- blastRadius: At high terrain/camera altitude the rain volume rides up with the camera (acdream span camZ−120..camZ+695 vs retail fixed −120..+695 world) — subtle density/coverage differences on mountains; nothing user-reported.
|
||||
- retailEvidence: GameSky::UpdatePosition (0x00506dd0, BN pc:268596-268618): weather objects (props bit 4) snap x,y to the viewer origin; when bit 8 is clear the frame z slot is OVERWRITTEN with the constant 0xc2f00000 = −120.0f (0x00506e96-e98) — an absolute height, not an offset.
|
||||
- acdreamEvidence: SkyRenderer.RenderPass applies Matrix4x4.CreateTranslation(0,0,−120) to the model in a sky view whose translation is zeroed — i.e. −120 relative to the camera (SkyRenderer.cs:175-178, 307-308; the doc comment at 284-306 itself reads the decomp as an offset).
|
||||
- portShape: In the weather branch, translate by (0,0,−120 − cameraWorldPos.Z) so the cylinder's base sits at world z −120 while x,y stay camera-snapped — a two-line change in RenderPass.
|
||||
|
||||
### [LOW] weather-enabled-toggle-absent (UNVERIFIED (verifier hit token limit)) — No weather_enabled client toggle (weather objects always instantiated)
|
||||
- blastRadius: None vs retail defaults (retail ships weather on, GameSky::s_weatherEnabled init 0x1 at pc:1098001); only matters for the user-options parity pass.
|
||||
- retailEvidence: LScape::weather_enabled gates the weather draw (0x0050638b-96, 0x005070d8) and GameSky::CreateDeletePhysicsObjects (0x005073c0, pc:268912-268917) creates/destroys the weather physics objects when the flag flips.
|
||||
- acdreamEvidence: SkyDescLoader.cs:46-51 documents the missing toggle ('we don't honor a weather_enabled toggle yet'); SkyRenderer partitions purely on the dat property bits.
|
||||
- portShape: A RuntimeOptions/settings bool consulted at both the object-build site (SkyDescLoader/DayGroup hydration) and the RenderWeather call sites — mirroring retail's create/delete + draw double gate.
|
||||
|
||||
## OPEN QUESTIONS
|
||||
|
||||
- Is D3DPolyRender::DrawPortalPolyInternal in DrawCells stage (b) (0x005a49b7, second arg 0) strictly a depth-only write? The position in the sequence (after the full z-clear, before shells) and its use with arg 0 vs the ConstructView call site (arg = arg5==1, 0x005a5a7b) strongly imply a z-stamp with color off, but I did not decompile DrawPortalPolyInternal itself to confirm the write mask — confirm before porting the masks stage.
|
||||
- Default and source of the retail global `cliplandscape` (branch at 0x005a5681 in PView::ClipPortals): when 0 the outside view merges UNCLIPPED (Render::copy_view(this, nullptr, 0) at 0x005a5699). Presumably a registry/debug toggle defaulting to clipped, but I could not find its initializer.
|
||||
- Does retail's weather pass really composite rain over near buildings outdoors? GameSky::Draw sets DEPTHTEST_ALWAYS around DrawObjCellForDummies(after_sky_cell), but the poly pipeline below DrawObjCell (CShadowPart::draw / D3DPolyRender surface setup) may re-set depth state per surface — not traced to the draw-call level.
|
||||
- Exact draw-time role of before_sky_cell: GameSky::Draw pass 0 iterates sky_obj directly (skipping bit-0x01 objects) rather than drawing before_sky_cell as a cell, so the cell looks like a positioning/lighting container only — no draw site for it was found, but I did not exhaustively xref it.
|
||||
- Retail re-centers the landscape block draw list on get_outside_cell_id(viewer) while the viewer is indoors (RenderNormalMode 0x453aa0, gated on viewer_cell->seen_outside); acdream's streamed terrain window is centered on the player landblock. Whether this matters at landblock edges (viewer inside a building near a block boundary, doorway facing the un-streamed direction) is untested.
|
||||
- #108's final mechanism still needs its own capture: this map identifies the unwired portal z-stamp and the scissored-vs-full depth clear as the faithful-port gaps sitting exactly in that code path, but does not prove which of the two produces the grass sweep during the cellar ascent.
|
||||
- DrawMesh's per-view loop draws a mesh once per intersecting portal view (0x005a08ae-096f) — when two doorways show the same outdoor object, retail composites it twice under disjoint clips, same as acdream's per-slice redraw; I treated this as a match, but alpha-blended objects at overlapping view edges were not verified pixel-level.
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
# 2.5 — acdream-internal audit: every visibility/culling/clipping gate in today's frame; one-gate-rule violations and legacy remnants
|
||||
|
||||
## RETAIL
|
||||
|
||||
Retail has exactly ONE visibility product per frame and enforces it once for every geometry class. RenderNormalMode (0x453aa0) calls DrawInside(viewer_cell) unconditionally (pc:92675); PView::DrawInside (pc:433793) runs ConstructView (pc:433750) -> ClipPortals (pc:433572) -> AddViewToPortals (pc:433446), producing per-cell portal_view polygon lists + outside_view. PView::DrawCells (Ghidra 0x005a4840, decompiled this session) then enforces that single product in four stages: (0) if outside_view.view_count != 0, set Render::PortalList = &outside_view and call LScape::draw ONCE — the landscape is drawn one time under the whole outside_view region, followed by a z-buffer clear (vtbl+0x2c with flag 4, RGBAColor_Black, 1.0f) gated on portalsDrawnCount/forceClear; (1) reverse cell_draw_list, per view slice (CEnvCell::setup_view(cell, i)): DrawPortalPolyInternal for every portal with other_cell_id == -1 — the exit-portal mask/z pass; (2) reverse cell_draw_list, per setup_view slice: vtbl+0x5c (DrawEnvCell) — the shell pass, where every cell polygon is submitted with planeMask=0xffffffff (pc:427922) through the view planes Render::set_view installed (pc:343750, 0x0054d0e0); (3) reverse cell_draw_list: Render::PortalList = cell->portal_view.data[num_view-1], then vtbl+0x64 (DrawObjCell) — the object-list pass. Objects are NOT hard-clipped per slice; instead the mesh path gates each drawing sphere against the active viewcone: Render::viewconeCheck (Ghidra 0x0054c250) tests the sphere against the near plane (viewer_world_space.CY) plus portal_npnts view-edge planes, returning OUTSIDE / PARTIALLY_INSIDE / inside, and is called unconditionally from DrawMesh (xrefs 0x005a08e4, 0x005a09a4). So: one flood, one draw order, two enforcement mechanisms (hard poly clip for cell shells, viewcone sphere check for meshes) — but both read the SAME portal_view product. is_player_outside gates only sky/lighting, not the draw path.
|
||||
|
||||
## ACDREAM
|
||||
|
||||
FRAME ANATOMY (GameWindow.OnRender, src/AcDream.App/Rendering/GameWindow.cs:7124). Per frame: clear (DepthMask asserted true, :7155-7156), WbMeshAdapter.Tick (:7178), FrustumPlanes built from camera VP (:7221), lighting root = player CurrCell (:7291-7296), render root = RetailChaseCamera.ViewerCellId -> CellVisibility.TryGetCell (:7301-7312). THEN the dual-visibility surface: (A) _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos) at :7313 runs the full ACME-ported BFS (CellVisibility.cs:348-363 -> GetVisibleCellsFromRoot :455-515, eye-side portal test :493-506) every indoor frame, but its result is consumed ONLY as `bool cameraInsideCell = visibility?.CameraCell is not null` (:7314) — equivalent to `viewerRoot is not null`; VisibleCellIds and HasExitPortalVisible are produced and never read anywhere in src (grep: HasExitPortalVisible only at CellVisibility.cs:190/479). cameraInsideCell feeds only the debug-only UpdateSkyPes (:7343-7344, gated on EnableSkyPesDebug) and the [render-sig] probe (:7899). (B) The REAL gate: PortalVisibilityBuilder.Build inside RetailPViewRenderer.DrawInside (RetailPViewRenderer.cs:48-52) + per-building BuildFromExterior merges for the outdoor node (:60-61, 115-160). So yes — TWO live visibility computations run per indoor frame; only (B) gates pixels; they cannot disagree visibly today because (A)'s set is discarded, but (A) is live CPU + a drift trap (its doc says pass the player position, the call site passes the chase eye — CellVisibility.cs:343-347 vs GameWindow.cs:7313).
|
||||
|
||||
UNIFIED PATH (clipRoot != null = viewerRoot or OutdoorCellNode.Build, :7458-7482, :7497). Gates in order: [1] streaming window N1/N2 (what exists in _worldState.LandblockEntries + EnvCellRenderer registry); [2] PortalVisibilityBuilder flood (side test PortalVisibilityBuilder.cs:226-233, homogeneous portal clip via PortalProjection.ProjectToClip/ClipToRegion :81-134, reciprocal clip :779-823, eye-inside-opening rescues :258-267/:869 EyeStandingPerpDist=1.75m, MaxReprocessPerCell=16 cap :51/:348, CanonicalKey 1e-3 NDC snap-dedup PortalView.cs:115-164); [3] ClipFrameAssembler.Assemble — one slot (<=8 planes, ClipPlaneSet.cs:112-150) per view polygon; multi-polygon or >8 edges degrade to slot 0 + AABB scissor (ClipPlaneSet.cs:119-133, ClipFrameAssembler.cs:115-119); [4] drawableCells = ALL OrderedVisibleCells (RetailPViewRenderer.cs:71); [5] EnvCellRenderer.PrepareRenderBatches: cameraZ>4000 early-out (EnvCellRenderer.cs:555), camera-centred _nearRadius LB ring (:581-588), GpuReady (:590), WbFrustum.TestBox per LB (:612) + per-cell Intersects (:642), filter=drawableCells (:624/:630/:641); [6] landscape per OutsideView slice (RetailPViewRenderer.cs:223-232): scissor to slice NDC AABB (GameWindow.cs:9477, BeginDoorwayScissor :9707-9724), terrain UBO planes per slice (SetTerrainClip RetailPViewRenderer.cs:225, ClipFrame.cs:208-215), gl_ClipDistance enables (GameWindow.cs:9484/9689-9699), terrain per-slot FrustumCuller + neverCullLandblockId (TerrainModernRenderer.cs:206-218), outdoor entities clip-routed to the slice slot (SetClipRouting RetailPViewRenderer.cs:227 -> WbDrawDispatcher ResolveEntitySlot :425-455, SSBO binding=2/3 -> mesh_modern.vert:120), sky/weather clipped by the same terrain UBO (sky.vert:153); [7] depth-clear per slice scissored, indoor roots only — suppressed for the outdoor node (GameWindow.cs:7644-7652); [8] exit-portal masks: DORMANT — RetailPViewRenderer.DrawExitPortalMasks no-ops because neither production context sets the callback (RetailPViewRenderer.cs:331-332; the ctx initializers at GameWindow.cs:7604-7663 and :7780-7798 never assign DrawExitPortalMasks; no production StencilTest enable exists); [9] shells: IndoorDrawPlan.ShellPass (every flooded cell with non-empty view, far->near, IndoorDrawPlan.cs:18-29) x per slice, UseShellClipRouting (RetailPViewRenderer.cs:452-458), GL clip planes enabled ONLY when clipShells == RootCell.IsOutdoorNode (:104-105, :378-380) — indoor roots draw shells UNCLIPPED (#114 interim), cells without slices fall through to a full-screen NoClipSlice (:428-437); [10] object lists: per flooded cell far->near, InteriorEntityPartition.ByCell (InteriorEntityPartition.cs:22-79), UseIndoorMembershipOnlyRouting — clip routing explicitly cleared (:420, :439-450), so objects are gated ONLY by cell membership (WbDrawDispatcher.EntityPassesVisibleCellGate :1816-1835) + per-LB/per-entity FrustumCuller (:593-595, :662-666); [11] cell particles per cell per slice: clip distances OFF, scissor to slice AABB only, emitter filter AttachedObjectId != 0 && in-cell (GameWindow.cs:9553-9580); [12] LiveDynamic bucket (ServerGuid != 0, no ParentCellId) drawn unclipped+unfiltered ONLY for outdoor-node roots (:7716-7724); [13] global Scene-particle pass and post-scene weather run ONLY when clipRoot is null (:7846, :7874) — i.e. effectively never in normal play.
|
||||
|
||||
LEGACY PATH (clipRoot == null — pre-spawn / non-chase debug cameras, :7726-7831): sky+terrain ungated (no-clip ClipFrame, :7546-7587), global outdoor entity bucket via InteriorRenderer.DrawEntityBucket with an EMPTY visibleCells partition (:7732-7746 — all indoor-parented entities dropped), look-in via RetailPViewRenderer.DrawPortal over 1-ring candidate cells (:7748-7811) which uses a DIFFERENT membership rule (drawableCells = clipAssembly.CellIdToSlot.Keys, RetailPViewRenderer.cs:182 — the documented grey-walls under-include the DrawInside path fixed at :66-71), LiveDynamic fallback (:7813-7823), global scene particles INCLUDING AttachedObjectId==0 emitters (:7846-7868).
|
||||
|
||||
FILE CLASSIFICATION. CellVisibility.cs: PARTIALLY-LIVE — the cell REGISTRY half (AddCell/TryGetCell/GetCellsForLandblock/RemoveLandblock, :252-305) is the live backbone (root resolve GameWindow.cs:7294/7311, outdoor-node gather :7475, exterior candidates :7774, CellLookup :7613/:7786); the BFS half runs per frame (:7313) but its set is unconsumed; ComputeVisibility/GetVisibleCells test-only (:318-328 doc); IsInsideAnyCell dead in production (:414-419). InteriorRenderer.cs: PARTIALLY-LIVE — DrawInside (:63-101) has ZERO callers (dead since the RetailPViewRenderer cutover); DrawEntityBucket (:141-163) live at GameWindow.cs:7720/7739/7816. IndoorDrawPlan.cs: LIVE (RetailPViewRenderer.cs:382). PortalView.cs (ViewPolygon/CellView): LIVE (flood + assembler data model). PortalProjection.cs: PARTIALLY-LIVE — ProjectToClip/ClipToRegion live; ProjectToNdc (:32-71) has no production callers (tests only). OutdoorCellNode.cs: LIVE (GameWindow.cs:7478). FrustumCuller.cs (+FrustumPlanes): LIVE (terrain :216-218, dispatcher :593/:665, perf counter GameWindow.cs:8001). ScreenPolygonClip.cs: LEGACY-DEAD — referenced only in comments (PortalVisibilityBuilder.cs:801) and its own test file. Wb/EnvCellVisibilitySnapshot.cs: LIVE (EnvCellRenderer._activeSnapshot, EnvCellRenderer.cs:47/:718). Wb/WbFrustum.cs: LIVE (EnvCellRenderer ctor :188, updated GameWindow.cs:7396). ClipPlaneSet.cs: LIVE (ClipFrameAssembler.cs:101/:140). ClipFrame.cs/ClipFrameAssembler.cs: LIVE. RenderingDiagnostics.ShouldRenderIndoor: probe-only — playerIndoorGate no longer selects the path (GameWindow.cs:7485-7496).
|
||||
|
||||
NET: visibility is computed twice (one result discarded), and the ONE live flood product is enforced at FIVE different strengths — exact planes (outdoor-root shells), nothing (indoor-root shells), membership+frustum only (all object lists), planes+scissor (terrain/sky), scissor-rectangle only (particles) — where retail enforces one product through exactly two mechanisms (poly clip for shells, viewcone sphere check for meshes) that read the same view.
|
||||
|
||||
## DIVERGENCES
|
||||
|
||||
### [HIGH] object-lists-skip-portal-view-gate (confirmed) — Object lists are never gated by the portal view — no viewconeCheck equivalent exists
|
||||
- correctedClaim: Confirmed as claimed, with two citation refinements: (1) DrawCells loop 3 calls vtbl+0x64 = RenderDeviceD3D::DrawObjCellForDummies (0x005a0760: UpdateObjCell + shadow-part insertion sort), which forwards to vtbl+0x60 DrawObjCell → DrawPartCell → CShadowPart::draw → CPhysicsPart::Draw → vtbl+0x70 DrawMesh; (2) viewconeCheck is called once per portal view inside DrawMesh's PortalList loop (gated by building_view == -1 || building_view == i), with set_view installing each view's edge planes first; the OUTSIDE-skip is absolute on the cell-object path because CShadowPart::draw passes force=0. Additionally, acdream's situation is worse than stated: the one geometric gate it does have (global frustum AABB, WbDrawDispatcher.cs:662-666) is bypassed for indoor buckets because DrawEntityBucket passes neverCullLandblockId equal to the bucket's LandblockId (RetailPViewRenderer.cs:465-474) — indoor cell objects are drawn with no geometric culling whatsoever.
|
||||
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C):
|
||||
|
||||
1. PView::DrawCells (Ghidra 0x005a4840): loop 3 (the object pass after the env-cell pass) sets `Render::PortalList = (cell->portal_view).data[cell->num_view - 1]` per cell, then calls render_device vtbl+0x64 with the cell. Verified verbatim in the decompile.
|
||||
|
||||
2. Vtable slot correction (minor): the RenderDeviceD3D vtable base is 0x007e5500 (pc:1037039-1037075 vtable dump), so vtbl+0x64 = 0x007e5564 = RenderDeviceD3D::DrawObjCellForDummies, NOT DrawObjCell (+0x60 = 0x007e5560). DrawObjCellForDummies (Ghidra 0x005a0760) does UpdateObjCell + CShadowPart::insertion_sort of the cell's shadow_part_list, then forwards to vtbl+0x60 = DrawObjCell (Ghidra 0x005a1a40). Substance unchanged. BN pc:432878 independently resolves the loop-3 call as DrawObjCellForDummies.
|
||||
|
||||
3. Full chain to the mesh gate, every link decompiled: DrawObjCell 0x005a1a40 → DrawPartCell 0x005a07a0 (iterates cell->shadow_part_list) → CShadowPart::draw 0x006b50d0 (calls CPhysicsPart::Draw(part, 0) — note force flag = 0) → CPhysicsPart::Draw 0x0050d7a0 → vtbl+0x70 = 0x007e5570 = RenderDeviceD3D::DrawMesh 0x005a0860 (vtable dump pc:1037075).
|
||||
|
||||
4. DrawMesh 0x005a0860: with Render::PortalList non-null (always true in the cell-object pass, set by loop 3), it loops over PortalList->view_count; for each view i (subject to `building_view == -1 || building_view == i`), calls Render::set_view(&PortalList->view, i) then Render::viewconeCheck(gfxobj->drawing_sphere). If OUTSIDE in every view and the force flag is false, returns OUTSIDE_VIEWCONE_ODS WITHOUT calling DrawMeshInternal — the mesh is skipped. The claimed xref addresses verify exactly: function_xrefs on viewconeCheck returns 0x005a08e4 (PortalList==null path) and 0x005a09a4 (per-view loop), both in DrawMesh. Wording correction (minor): "unconditionally" is loose — the call is per-portal-view inside a loop with the building_view filter, and an OUTSIDE result can still draw when the caller passes force=true; but the cell-object-list path passes force=false (CShadowPart::draw → Draw(part, 0)), so OUTSIDE ⇒ skip holds for exactly the statics/doors/NPCs path the claim is about.
|
||||
|
||||
5. Render::viewconeCheck (Ghidra 0x0054c250): transforms the drawing sphere's center to viewer space, tests signed distance against the viewer_world_space.CY forward plane, then against portal_npnts planes at portal_vertex; any distance < -radius ⇒ OUTSIDE; else INSIDE/PARTIALLY_INSIDE. Render::set_view (Ghidra 0x0054d0e0, pc:343750) is what installs portal_npnts/portal_vertex/portal_inmask from view->poly.data[i] — confirming the planes tested ARE the per-cell accumulated portal-view edge planes, not just the global frustum.
|
||||
|
||||
ACDREAM SIDE — all citations verified against the code:
|
||||
|
||||
6. RetailPViewRenderer.DrawCellObjectLists (src/AcDream.App/Rendering/RetailPViewRenderer.cs:401-426) calls UseIndoorMembershipOnlyRouting() at :420 before every cell bucket; UseIndoorMembershipOnlyRouting (:439-450) clears clip routing for entities and env-cells, with the comment at :441-447 explicitly acknowledging retail's Render::viewconeCheck while implementing nothing in its place.
|
||||
|
||||
7. WbDrawDispatcher.EntityPassesVisibleCellGate (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1816-1835) is pure ParentCellId-vs-visibleCellIds membership. The AABB frustum cull at :662-666 exists BUT is bypassed for these buckets: the production call site DrawEntityBucket (RetailPViewRenderer.cs:460-477) passes neverCullLandblockId: ctx.PlayerLandblockId while setting the entry's LandblockId to the same value (:465-466), so `entry.LandblockId != neverCullLandblockId` is false and the frustum test is skipped. The divergence is therefore slightly STRONGER than claimed: indoor cell-bucket entities receive no geometric culling at all — only set membership + depth test.
|
||||
|
||||
8. No viewcone equivalent anywhere: grep for viewcone/ViewCone/view_cone across src/ hits only the two comments (RetailPViewRenderer.cs:371, :442). PortalVisibilityFrame.CellViews (the data a port would need) is consumed only by ClipFrameAssembler.cs:93, IndoorDrawPlan.cs:24, and a GameWindow debug dump (GameWindow.cs:9416) — never by an entity-sphere test. InteriorEntityPartition (src/AcDream.App/Rendering/InteriorEntityPartition.cs:22-79) is also pure membership.
|
||||
|
||||
9. Blast-radius premise verified: clipShells is true only for outdoor-eye roots (RetailPViewRenderer.cs:374-378 comment + gate at :378-380), so at indoor roots the shells draw unclipped AND the objects are ungated — depth test is the only mechanism hiding far-room objects, exactly as the claim states.
|
||||
|
||||
JUDGMENT: the divergence is real, not behaviorally-equivalent-elsewhere, and not already handled. Retail enforces the portal view on object lists twice (per-view sphere gate at draw time + BoundingType handed into DrawMeshInternal); acdream enforces it zero times for objects. Severity 'high' is fair: it is a visible artifact class (objects painting through walls wherever depth doesn't cover — unclipped indoor-root shells, anything drawn after a depth clear) plus unbounded interior overdraw, but not 'critical' since depth test masks the common case. The proposed port shape (CPU sphere-vs-CellView-edge-planes pre-draw test; defer PARTIALLY_INSIDE semantics until DrawMeshInternal's BoundingType handling is decompiled) matches the verified retail mechanism. Two citation-level corrections folded into the claim text: vtbl+0x64 is DrawObjCellForDummies (sort + forward to DrawObjCell at +0x60), and the viewconeCheck call is per-portal-view with a building_view filter and a force-flag override that is always false on this path.
|
||||
- blastRadius: Statics/doors/NPCs in any flooded cell draw whole, relying solely on depth test to hide them. Wherever the shell is unclipped (all indoor roots, see indoor-shell-clip-disabled) or depth was cleared per slice, far-room objects paint through walls — the phantom-staircase artifact class and part of #114's 'see-through to neighbour rooms'. Also pure overdraw cost in dense interiors.
|
||||
- retailEvidence: DrawCells Loop 3 (Ghidra 0x005a4840) sets Render::PortalList = cell->portal_view.data[num_view-1] then calls DrawObjCell (vtbl+0x64); the mesh path calls Render::viewconeCheck (Ghidra 0x0054c250) unconditionally from DrawMesh (xrefs 0x005a08e4 / 0x005a09a4), testing the drawing sphere against the near plane + portal_npnts view-edge planes installed by Render::set_view (pc:343750) and returning OUTSIDE to skip the mesh.
|
||||
- acdreamEvidence: RetailPViewRenderer.DrawCellObjectLists calls UseIndoorMembershipOnlyRouting() before every bucket (RetailPViewRenderer.cs:420, :439-450 — clip routing explicitly cleared with a comment asserting retail uses viewcone checks); WbDrawDispatcher then gates only on cell membership (EntityPassesVisibleCellGate, WbDrawDispatcher.cs:1816-1835) + global frustum AABB (:662-666). No code anywhere evaluates an object sphere against the cell's view polygons.
|
||||
- portShape: Port Render::viewconeCheck as a CPU pre-draw test: for each entity in a cell bucket, test its bounding sphere against the cell's CellView polygons' edge planes (the data already exists in PortalVisibilityFrame.CellViews); OUTSIDE -> skip the entity. PARTIALLY_INSIDE handling (retail per-poly clip vs draw-whole) needs the DrawMesh decompile first. This restores retail's second enforcement mechanism without hard-clipping characters (the doorway-slicing problem the comment correctly avoids).
|
||||
|
||||
### [HIGH] indoor-shell-clip-disabled (confirmed) — Shell clipping enabled only for outdoor-eye roots — indoor roots draw flooded shells whole
|
||||
- correctedClaim: Claim stands as written. One precision refinement for the gap map: retail's DrawEnvCell carries a DrawnThisFrame stamp guard (Ghidra 0x0052c0c0/0x0052c0e0 vs m_nFrameStamp), so a cell reachable through multiple view slices draws its shell once, clipped to the FIRST slice's view polygon — not re-drawn per slice. acdream's per-slice re-render (RetailPViewRenderer.cs:388-393) is a slightly-more-generous union; the asymmetry divergence itself is unaffected.
|
||||
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C):
|
||||
|
||||
1. PView::DrawCells (Ghidra 0x005a4840, decompiled): the shell-draw loop (the bottom do/while over cell_draw_list in reverse, far-to-near) runs UNCONDITIONALLY for every cell with a drawing_bsp: per view slice it calls CEnvCell::setup_view(cell, sliceIndex) then the render-device virtual draw (vtbl+0x5c, one CEnvCell* arg = DrawEnvCell's exact signature). There is NO inside/outside branch anywhere in that loop. The only outside-gated block in DrawCells is the landscape + portal-mask block gated on outside_view.view_count != 0 — which is landscape drawing, not shell clipping. So "no inside/outside asymmetry in retail [shell clipping]" is confirmed at the decompile level.
|
||||
|
||||
2. CEnvCell::setup_view (Ghidra 0x0052c430): one-liner — Render::set_view(&this->portal_view[num_view-1]->view, sliceIndex). Confirms the per-slice accumulated portal view is installed before the cell draw.
|
||||
|
||||
3. Render::set_view (Ghidra 0x0054d0e0): installs the slice polygon globally — portal_vertex (slice poly vertices, each carrying an edge plane), portal_npnts, portal_inmask = (1<<(npnts+1))-1, plus the slice screen bbox xmin/xmax/ymin/ymax. Matches the claim's pc:343750 citation.
|
||||
|
||||
4. RenderDeviceD3D::DrawEnvCell (Ghidra 0x0059f170): non-built-mesh branch submits every cell-structure polygon with planeMask = -1 (0xffffffff) to Render::PolyList then flushes via vtbl+0x40 — confirming the pc:427922 anchor. The flush pipeline's clipper ACRender::polyClipFinish (Ghidra 0x006b6d00) reads Render::portal_npnts/portal_vertex (the set_view globals) and Sutherland–Hodgman-clips each submitted poly against the slice polygon's edge planes. The use_built_mesh branch is also covered: DrawEnvCell first calls Render::obj_view_set (Ghidra 0x0054b9b0), which transforms every edge plane of the CURRENT slice polygon into object space (portal_obj_plane[]) for the mesh path — the per-slice view is prepared and enforced on both branches. Retail clips shells to the per-slice aperture region for every root type.
|
||||
|
||||
One mechanical nuance (does not change the verdict): DrawEnvCell early-outs on CEnvCell::GetDrawnThisFrame (Ghidra 0x0052c0c0: m_current_render_frame_num == render_device->m_nFrameStamp; stamp bumped once in DrawCells before the shell loop), so a multi-slice cell's shell effectively draws ONCE, clipped to its FIRST view slice, rather than once per slice. The claim's wording "setup_view per view slice before each DrawEnvCell" is call-level accurate (the early-out is inside DrawEnvCell). acdream draws the shell once per slice (union of regions) — a minor, more-generous difference irrelevant to the claimed asymmetry.
|
||||
|
||||
ACDREAM SIDE — all four citations verified against the code:
|
||||
|
||||
1. RetailPViewRenderer.cs:104-105 — DrawEnvCellShells(..., clipShells: ctx.RootCell.IsOutdoorNode). Confirmed; comment at :96-103 explicitly documents the #114 scope-down (indoor roots stay unclipped after the first user gate's chopped-stairs/vanishing-walls/see-through findings).
|
||||
2. RetailPViewRenderer.cs:378-380 and :396-398 — GL_CLIP_DISTANCE0..MaxPlanes enabled/disabled ONLY when clipShells is true. Confirmed.
|
||||
3. RetailPViewRenderer.cs:452-458 — UseShellClipRouting still writes the per-cell slot routing unconditionally, but with the enables off the gl_ClipDistance writes in mesh_modern.vert:120 are ignored (GL semantics, documented at :361-363), so the routing is inert for indoor roots. Verified no enclosing enable leaks at the production call site: GameWindow.cs:7599-7663 calls DrawInside with RootCell = clipRoot (indoor roots have IsOutdoorNode=false; the outdoor-node root is built by OutdoorCellNode.cs:27); the outdoor terrain block (GameWindow.cs:7546-7587, EnableClipDistances at :7577) is skipped when clipRoot is non-null; and DrawRetailPViewLandscapeSlice — which runs inside DrawInside BEFORE the shell pass (RetailPViewRenderer.cs:93 vs :104) — ends with DisableClipDistances() (GameWindow.cs:9550). So indoor shell draws genuinely run with all clip planes disabled.
|
||||
4. RetailPViewRenderer.cs:428-437 + :22-23 — GetCellSlicesOrNoClip falls through to the full-screen NoClipSlice (slot 0, planes empty = shader pass-all per mesh_modern.vert:122) when the assembler produced no slice. Confirmed, including the :367-369 note that the >8-plane scissor fallback is unimplemented.
|
||||
|
||||
DIVERGENCE REALITY: confirmed real, not behaviorally equivalent, not compensated elsewhere. The flood (PortalVisibilityBuilder + ClipFrameAssembler, run unconditionally at RetailPViewRenderer.cs:48-63) computes per-cell aperture regions for indoor roots too, but the indoor shell draw ignores them — exactly the "two gates disagree about the same shell" framing. DrawExitPortalMasks (:95) is a depth trick on exit apertures and does not constrain a flooded neighbor cell's geometry to its entry aperture; DrawCellObjectLists is unclipped by design in both modes (matching retail's mesh path, per :439-447). The blast-radius mapping to #114 is exact — the #114 charter and the user-gate findings are quoted in the code comment itself (:96-103), and #114 was filed in commit 6c9bbce as "indoor shell-clip region quality". The port shape in the claim matches the code reality: the mechanism exists and is live for outdoor roots; the gap is indoor region quality, after which the clipShells parameter can be deleted to restore retail's single unconditional rule. Severity "high" is appropriate.
|
||||
- blastRadius: #114 directly (chopped stairs / vanishing inner walls / see-through at the meeting hall were the user-gate findings that forced the scope-down). Two gates disagree about the same shell: the flood says 'visible through THIS aperture region', the indoor draw ignores the region. Combined with object-lists-skip-portal-view-gate it is the core of 'indoor world feels right'.
|
||||
- retailEvidence: Retail always clips shells: DrawCells Loop 2 (Ghidra 0x005a4840) runs CEnvCell::setup_view(cell, i) per view slice before each DrawEnvCell, and DrawEnvCell submits every cell polygon with planeMask=0xffffffff (pc:427922) through the set_view planes (pc:343750). There is no inside/outside asymmetry in retail.
|
||||
- acdreamEvidence: RetailPViewRenderer.cs:104-105 passes clipShells: ctx.RootCell.IsOutdoorNode into DrawEnvCellShells; the GL_CLIP_DISTANCEi enables at :378-380 are skipped for interior roots, making the UseShellClipRouting slot writes (:452-458) inert (gl_ClipDistance writes are ignored when the enables are off, per the #113 comment :360-369). Cells without an assembler slice additionally fall through to a full-screen NoClipSlice (:428-437).
|
||||
- portShape: Not a new mechanism — the machinery exists and is validated for outdoor roots. The port work is making indoor clip REGIONS pixel-exact (the #114 charter): fix the per-slice region quality (assembler slot fidelity, >8-plane fallback, slice ordering) until the indoor enables can be flipped on, then delete the clipShells parameter so one rule covers both roots.
|
||||
|
||||
### [HIGH] particles-third-gate-tier (UNVERIFIED (verifier hit token limit)) — Particles are gated by a third, weaker mechanism (scissor rectangle only) and several emitter classes are dropped entirely on the unified path
|
||||
- blastRadius: 'Particles-through-walls': a cell's particles draw anywhere inside the slice's NDC AABB rectangle (a superset of the aperture), and cells without a slice get a FULL-SCREEN NoClipSlice scissor. Additionally on every unified-path frame (the normal in-world case): Scene-pass emitters with AttachedObjectId==0 are never drawn (both production filters require !=0), and LiveDynamic entities' particles are never drawn — silent VFX loss vs the legacy branch which drew both.
|
||||
- retailEvidence: Retail draws particles through the same single view product as everything else — particle emitters hang off objects in the cell's object list, drawn inside DrawObjCell under Render::PortalList = the cell's portal_view (DrawCells Loop 3, Ghidra 0x005a4840), gated by the same viewconeCheck mesh path (0x0054c250). There is no separate scissor-rectangle tier.
|
||||
- acdreamEvidence: DrawRetailPViewCellParticles: clip distances disabled, BeginDoorwayScissor(slice.NdcAabb) only, filter AttachedObjectId != 0 (GameWindow.cs:9568-9576); NoClipSlice fallback for slot-less cells (RetailPViewRenderer.cs:428-437) makes that scissor full-screen. Outdoor attached particles same pattern (:9519-9530). The only path drawing AttachedObjectId==0 Scene emitters is the clipRoot==null block (:7846-7868) which is unreachable in normal play; LiveDynamic particles have no draw site on either branch.
|
||||
- portShape: Route particles through the object-list gate once viewconeCheck lands: an emitter draws iff its owning entity passed the viewcone test for its cell (no scissor tier needed; particle.vert has no gl_ClipDistance so a sphere-level CPU gate is the faithful shape). Re-home unattached and LiveDynamic emitters into the partition buckets so they draw under the same rule.
|
||||
|
||||
### [MEDIUM] dual-live-visibility-computations (confirmed) — Two visibility computations run per frame: the ACME BFS (CellVisibility) and the retail flood (PortalVisibilityBuilder)
|
||||
- correctedClaim: Confirmed as stated, with one refinement: the doc-vs-callsite contradiction (CellVisibility.cs:343-347 vs GameWindow.cs:7313) is real but the doc comment is the likely-stale half (it predates the Phase-W move of the render root to the VIEWER cell, where viewer-eye + viewer-root is the consistent pairing); either way the position argument is behaviorally inert because it only affects the unread VisibleCellIds/HasExitPortalVisible fields. The proposed one-liner replacement (`bool cameraInsideCell = viewerRoot is not null;`) is proven exactly equivalent: TryGetCell success implies non-empty _cellLookup, and GetVisibleCellsFromRoot unconditionally returns CameraCell = root.
|
||||
- verifier notes: RETAIL SIDE (re-derived from Ghidra decompile, not BN pseudo-C): (1) SmartBox::RenderNormalMode decompiled at 0x00453aa0 — contains NO visibility graph traversal: outdoor viewer (objcell_id & 0xffff < 0x100) goes straight to LScape::draw; indoor viewer dispatches vtable +0x48 with this->viewer_cell (= RenderDeviceD3D::DrawInside); seen_outside only gates the LScape::update_viewpoint(get_outside_cell_id) sky/lighting viewpoint. (2) PView::DrawCells decompiled at 0x005a4840 — pure consumer: reads this->outside_view.view_count, iterates this->cell_draw_list/cell_draw_num (filled by ConstructView), draws; zero portal recursion, zero ConstructView calls. (3) PView::ConstructView confirmed at pc:433750 (0x005a57b0, CEnvCell variant) with the CBldPortal variant at pc:433827; DrawInside calls ConstructView(this, cell, 0xffff) once at pc:433817. So retail runs exactly ONE per-frame visibility walk (ConstructView flood), consumed by DrawCells — the claimed retail picture is accurate.
|
||||
|
||||
ACDREAM SIDE (all citations re-read): GameWindow.cs:7313 calls _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos); :7314 `bool cameraInsideCell = visibility?.CameraCell is not null` is the ONLY read of `visibility` (grep of \bvisibility\b in GameWindow.cs: no other code reads). The BFS body GetVisibleCellsFromRoot is CellVisibility.cs:455-515 (class doc :207 'Ported faithfully from ACME's EnvCellManager.cs'), allocating per call: VisibilityResult (:457, whose VisibleCellIds HashSet inits at :184), visited HashSet (:458), Queue (:459) — runs only on indoor frames (root==null returns null immediately at :350-351). VisibilityResult.VisibleCellIds (:184, written :462/:509) and HasExitPortalVisible (:190, written :479) have ZERO readers in src (the src hits for 'VisibleCellIds' are the unrelated Core physics CellPhysics.VisibleCellIds in CellDump/CellTransit/PhysicsDataCache; PortalVisibilityBuilder.cs:73 is a comment). LastVisibilityResult (:234) also has no src reader outside CellVisibility.cs. cameraInsideCell feeds exactly two places: UpdateSkyPes at :7344 gated on _options.EnableSkyPesDebug (:7343, debug-only), and the EmitRenderSignatureIfChanged probe arg at :7899 (formatted 'camIn=' at :9293). Crucially I checked the sky gate at :7423 — it is `viewerRoot is null || rootSeenOutside`, NOT cameraInsideCell (the :7419-7422 mentions are comments only), so no hidden draw-decision consumer. The real retail flood runs separately: RetailPViewRenderer.DrawInside → PortalVisibilityBuilder.Build at RetailPViewRenderer.cs:48, invoked per frame from GameWindow.cs:7604 — so on indoor frames TWO independent portal-graph traversals execute, vs retail's one.
|
||||
|
||||
PORT SHAPE VERIFIED zero-behavior-change: viewerRoot comes from _cellVisibility.TryGetCell (:7311), which reads the same _cellLookup the BFS guards on (CellVisibility.cs:286-287), so viewerRoot non-null ⇒ _cellLookup non-empty ⇒ ComputeVisibilityFromRoot returns non-null with CameraCell=root unconditionally (:457). Hence cameraInsideCell ≡ (viewerRoot is not null) exactly. Only production caller of any BFS entry point is GameWindow.cs:7313 (ComputeVisibility/GetVisibleCells have no src callers — test-compat only per :311-316/:425-431).
|
||||
|
||||
CONTRACT-CONTRADICTION sub-claim: literally true — the param doc (CellVisibility.cs:343-347) says 'Should be the player/physics position (stable inside the cell), not the chase-camera eye'; the site passes viewerEyePos = camPos (:7306, :7313). One nuance the claim doesn't note: the doc is arguably the stale half (written in the Stage-3 player-root era; post-W-V1 the root IS the viewer cell, so viewer-eye + viewer-root is the internally consistent pairing). Moot either way: cameraPos only influences the portal-side test (:493-506) which affects only the unread VisibleCellIds/HasExitPortalVisible — never CameraCell. Severity 'medium' (one-gate violation + per-indoor-frame CPU/alloc + drift trap, no pixel disagreement today) is fair.
|
||||
- blastRadius: No pixel disagreement TODAY — the BFS result is consumed only as a non-null bool. But it is a literal one-gate violation (the rule that cost the 2026-05-25 week), live CPU + per-frame allocation (HashSet/Queue/VisibilityResult per indoor frame), and a drift trap: a future reader wiring VisibleCellIds back into a draw decision reintroduces the three-gate era. The call also contradicts its own contract (doc says pass the stable player position; the site passes the jittering chase eye).
|
||||
- retailEvidence: Retail computes visibility once: ConstructView (pc:433750) is the only per-frame visibility walk; DrawCells (Ghidra 0x005a4840) only consumes it. Nothing in RenderNormalMode runs a second graph traversal.
|
||||
- acdreamEvidence: GameWindow.cs:7313 var visibility = _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos) -> full BFS (CellVisibility.cs:455-515); :7314 is the sole consumer (bool). VisibleCellIds/HasExitPortalVisible unread in src (CellVisibility.cs:184/190 produced :462/:479/:509). cameraInsideCell feeds only debug UpdateSkyPes (:7343-7344) and the render-sig probe (:7899). The real flood runs separately at RetailPViewRenderer.cs:48.
|
||||
- portShape: Replace :7313-7314 with `bool cameraInsideCell = viewerRoot is not null;` and demote CellVisibility's BFS methods (ComputeVisibility/ComputeVisibilityFromRoot/GetVisibleCells*) to the test assembly or delete; keep the class as the cell registry (rename candidate: CellRegistry). Zero behavior change by construction.
|
||||
|
||||
### [MEDIUM] landscape-redrawn-per-outside-slice (adjusted) — The whole landscape (sky + terrain + scenery + weather) is re-drawn once per OutsideView slice; retail draws it once
|
||||
- correctedClaim: acdream re-runs the ENTIRE landscape pipeline (sky + SkyPre/Post particles + full terrain dispatch + outdoor entities + scene particles + weather) once per OutsideView slice (RetailPViewRenderer.cs:223-232 -> GameWindow.cs:9465-9551), where retail runs the landscape PASS exactly once (PView::DrawCells 0x005a4840: PortalList=&outside_view; LScape::draw once; sky/terrain/weather bundled at 0x00506330) and handles multi-polygon outside_view per PART: the visibility pass loops set_view over views to mark blocks (draw_check_blocks via 0x0050603e), and individual parts visible in multiple views ARE re-drawn once per visible view, clipped to that view (RenderDeviceD3D::DrawMesh 0x005a0860 calls DrawMeshInternal per non-OUTSIDE view). The faithful port is therefore NOT "draw each part exactly once" but "one landscape pass whose fixed costs run once, with a per-part view loop (test per view, draw clipped per visible view)" — behaviorally approximable by uploading all OutsideView polygons as one multi-region clip set tested in-shader. The user-visible divergence is (1) N x full-pipeline fixed cost per frame indoors (terrain dispatch, sky dome, weather re-run per slice — retail re-touches only parts spanning multiple views), and (2) alpha double-composite confined to acdream's slice-overlap regions, which exist mainly because particle passes draw with clip distances disabled (AABB scissor only, GameWindow.cs:9489/9518/9537) and because >8-plane slices fall back to AABB-only clipping (ClipFrameAssembler.cs:114-119) — exact-plane-clipped terrain/sky slices produce the same image as a single draw where slices are disjoint.
|
||||
- verifier notes: RETAIL side re-derived from Ghidra (not BN pseudo-C): (1) PView::DrawCells @ 0x005a4840 — gated on `(this->outside_view).view_count != 0`, sets `Render::PortalList = &this->outside_view` and calls `LScape::draw(this->lscape)` EXACTLY ONCE; no loop over view polygons surrounds it. (2) LScape::draw @ 0x00506330 — the single call bundles sky (`GameSky::Draw(sky,0)`), per-landblock terrain draw (`block_draw_list` loop, each block drawn once if `in_view != OUTSIDE` via render-device vtable+0x50), and weather (`GameSky::Draw(sky,1)` gated on `weather_enabled`). (3) LScape::draw_check_blocks (decompiled via 0x0050603e) — the VISIBILITY pass loops `Render::set_view(&PortalList->view, i)` over all view_count polygons to mark blocks in-view (OR across views); the block DRAW after it runs once per block. (4) CRITICAL NUANCE the claim missed: RenderDeviceD3D::DrawMesh @ 0x005a0860 — when PortalList != null it loops view_count, `set_view` per view, `viewconeCheck(drawing_sphere)`, and calls `DrawMeshInternal` once PER non-OUTSIDE view. So retail DOES multi-draw an individual part once per view polygon it is visible in (each draw clipped to that view); "retail draws it once" is true at the PASS level only. ACDREAM side verified: RetailPViewRenderer.cs:219-232 `DrawLandscapeThroughOutsideView` loops `foreach (var slice in clipAssembly.OutsideViewSlices)` calling `SetTerrainClip(slice.Planes)` + `ctx.DrawLandscapeSlice(...)` per slice. The callback (wired at GameWindow.cs:7624-7634 with a per-frame-constant renderSky) is GameWindow.DrawRetailPViewLandscapeSlice :9465-9551, which per invocation runs: sky :9486, SkyPreScene particles :9490-9492, FULL terrain dispatch :9496, outdoor entities :9503-9512, scene particles :9519-9530, weather :9533-9536, SkyPostScene particles :9538-9540 — i.e., the entire landscape pipeline re-runs per slice. N>1 is real in production: ClipFrameAssembler.cs:134-164 emits one ClipViewSlice per `pvFrame.OutsideView.Polygons` entry with no union step, and PortalVisibilityBuilder.cs:279 appends each exit portal's clipped region into OutsideView (one polygon — or several, since clipping can split — per visible exit aperture). Two acdream-specific aggravators beyond the claim: (a) the <=8-plane budget fallback (ClipFrameAssembler.cs:114-119, 153-158) gives a slice slot 0 with EMPTY planes — that slice clips only to its NDC-AABB scissor, inflating overlap with neighbouring slices; (b) all particle passes within the slice draw with clip distances DISABLED (GameWindow.cs:9489, 9518, 9537), so particles are clipped only by the slice's AABB scissor (:9477) — blended particle content genuinely composites N times in AABB-overlap regions. Tempering of the blast radius: for plane-clipped slices, terrain/sky fragments are clipped to the exact slice polygon, so where slices do not overlap the N draws produce the same image as one draw (the cost is N x dispatch, not double-bright); retail's per-part view loop has the same overlap exposure in principle, but clips to the exact view polygon, whereas acdream's AABB fallbacks and unclipped particles create overlap retail would not have. The double-bright-rain-through-two-doorways framing therefore holds for particles/AABB-fallback slices, but is overstated for the plane-clipped terrain/sky case. Severity medium is fair; the dominant real effect is N x full-pipeline fixed cost plus particle/fallback double-composite; the #108 contribution (scenery re-drawn per slice under frame-to-frame-changing clips) remains plausible but unproven.
|
||||
- blastRadius: With N visible exit apertures the terrain/sky/scenery/weather pipeline runs N times (N x full terrain dispatch, N x sky). Alpha-blended passes (weather rain cylinder, sky cloud layers) composite N times where slices overlap -> double-bright rain/sky through two doorways. Plausible contributor to #108 (grass-sweep: scenery re-drawn per slice under different clip planes/scissor as slices change shape frame to frame) and to indoor-frame FPS dips.
|
||||
- retailEvidence: DrawCells Loop 0 (Ghidra 0x005a4840): Render::PortalList = &this->outside_view; LScape::draw(this->lscape) is called exactly ONCE — the multi-polygon outside_view is handled by the viewcone machinery per drawn part, not by re-running the landscape per polygon.
|
||||
- acdreamEvidence: RetailPViewRenderer.DrawLandscapeThroughOutsideView loops `foreach (var slice in clipAssembly.OutsideViewSlices)` calling ctx.DrawLandscapeSlice per slice (RetailPViewRenderer.cs:223-232); each callback runs the FULL sky + terrain + outdoor-entity + weather sequence (GameWindow.DrawRetailPViewLandscapeSlice :9465-9551).
|
||||
- portShape: Draw the landscape once under the union region: either upload all OutsideView polygons as the multi-region the shaders test (requires >1 region per pass in the UBO/SSBO scheme), or scissor to the union AABB + plane-clip per geometry against its best-fit slice. The faithful shape is retail's: one landscape pass, per-part viewcone test against outside_view.
|
||||
|
||||
### [MEDIUM] flood-convergence-heuristics (UNVERIFIED (verifier hit token limit)) — The flood carries acdream-only convergence heuristics (re-enqueue cap, NDC snap-dedup, eye-inside-opening rescue) with frame-to-frame membership effects at edges
|
||||
- blastRadius: #109 far-door oscillation is the natural symptom: at grazing/far apertures the heuristics (MaxReprocessPerCell cap binding, CanonicalKey snap collapsing or admitting a drifted region, the 1.75 m eye-rescue toggling) flip a far cell in/out of OrderedVisibleCells across frames -> its shell/objects strobe. NOT proposing to revert the keep-listed flood port — this is the residual divergence inventory inside it.
|
||||
- retailEvidence: ConstructView's termination is watermark-based: AddViewToPortals (pc:433446) compares the last-incorporated view watermark vs current view_count and handles growth in place; cells append to cell_draw_list once per pop (pc:433783). Retail has no per-cell pop cap, no NDC grid snap-dedup, and no eye-distance rescue constant — its 3D homogeneous clip (GetClip finish=1 -> polyClipFinish, pc:702749 per PortalProjection.cs header) makes those unnecessary.
|
||||
- acdreamEvidence: PortalVisibilityBuilder.cs:51 MaxReprocessPerCell=16 (re-enqueue allowed when a popped cell's view grows, :348-354 — coexisting with the enqueue-once `queued` set :109); PortalView.cs:97 DedupGridNdc=1e-3 snap + collinear canonicalization :115-164; EyeStandingPerpDist=1.75 m rescue PortalVisibilityBuilder.cs:258-267/:869; MinW=0.05 / EyePlaneW=1e-4 PortalProjection.cs:182-188.
|
||||
- portShape: Confirm retail's exact re-enqueue semantics in Ghidra (open question below), then converge: if retail never re-enqueues, drop the cap+re-enqueue and propagate late growth in place exactly as AddViewToPortals does; keep the snap-dedup only as an assertion (it should become unnecessary once the region pipeline is drift-free). Each heuristic removed shrinks the #109 oscillation surface.
|
||||
|
||||
### [MEDIUM] exit-portal-mask-pass-dormant (confirmed) — Retail's exit-portal poly pass (DrawCells Loop 1) is wired as a callback that no production caller sets
|
||||
- correctedClaim: Claim stands as written, with two refinements: (1) retail's exit-portal poly pass (and the LScape draw + portalsDrawnCount-gated z-clear it pairs with) only executes when outside_view.view_count != 0 — i.e. on frames where any outside view exists; (2) the pass writes each exit-portal polygon's REAL projected depth (maxZ2=6: z-write on, DEPTHTEST_ALWAYS, alpha=0 color-invisible) and is also what arms the next frame's z-clear via portalsDrawnCount++ — so acdream is missing a coupled pair (mask + armed clear), not just the mask. acdream's callback is dead in the entire worktree (not even tests assign it).
|
||||
- verifier notes: RETAIL SIDE — re-derived from Ghidra (not BN pseudo-C):
|
||||
|
||||
1. PView::DrawCells (Ghidra decompile @ 0x005a4840) matches the claim exactly. Inside `if (outside_view.view_count != 0)`: Render::useSunlightSet(1) → LScape::draw → D3DPolyRender::FlushAlphaList → frameStamp++ → conditional z-clear `if (forceClear || portalsDrawnCount != 0) { portalsDrawnCount = 0; vtbl+0x2c(4, &RGBAColor_Black, 1.0f); }` → Loop 1: for each cell in cell_draw_list (reverse), if structure->drawing_bsp != null, for each setup_view slice (`CEnvCell::setup_view(cell, i)`), for each portal with `other_cell_id == -1` (stride 0x18): `D3DPolyRender::DrawPortalPolyInternal(portal->portal, false)`. Then useSunlightSet(0)/restore_all_lighting → Loop 2 shell pass (vtbl+0x5c per slice) → Loop 3 object lists (vtbl+0x64). So the exit-portal poly pass is AFTER LScape + the portalsDrawnCount-gated z-clear and BEFORE the shell pass, as claimed.
|
||||
|
||||
2. D3DPolyRender::DrawPortalPolyInternal (Ghidra @ 0x0059bc90), param_2=false path selects global `maxZ2`, whose static initializer is 6 (pc:1105964 `00820e14 int32_t maxZ2 = 0x6`; maxZ1 = 7 at pc:1105965); no runtime writes found. Decoding flags=6 (0b110) against the decompile: bit0=0 → vertex z = real projected z/w (not the 0.99999994 far-plane constant); bit1=1 → vertex alpha forced to 0 (`~(flags<<30) & 0x80000000` = 0) under SetBlendFunction(SRCALPHA, INVSRCALPHA) → color-invisible; bit2=1 → z-write ENABLED; depth mode = DEPTHTEST_ALWAYS; SetStageTexture(0,null); SetCullMode(NONE); DrawPrimitiveUP(TRIANGLEFAN). So Loop 1 is precisely a color-invisible, depth-always, z-writing draw of each exit-portal polygon at its REAL depth — "draw-the-portal-poly z machinery" as claimed. Additionally `portalsDrawnCount++` fires only on the param_2=false path, i.e. this pass is what ARMS the next frame's z-clear — the clear and the poly pass are a coupled pair; acdream reproduces neither half. Xrefs (Ghidra function_xrefs): callers are DrawCells (005a49b7), PView::DrawPortal (005a5b7c), PView::ConstructView (005a5a7b) — consistent with this being PView-internal machinery.
|
||||
|
||||
ACDREAM SIDE — all citations check out:
|
||||
|
||||
3. RetailPViewRenderer.cs:325-343 — DrawExitPortalMasks orchestration exists (reverse OrderedVisibleCells, per GetCellSlicesOrNoClip slice — mirrors retail's reverse cell_draw_list per-slice loop) but no-ops at :331-332 when ctx.DrawExitPortalMasks is null. It is invoked at the retail-faithful position in both flows: DrawInside at :95 (after DrawLandscapeThroughOutsideView at :93, before DrawEnvCellShells at :104) and DrawPortal at :204.
|
||||
|
||||
4. No production caller sets the callback — verified STRONGER than claimed: `grep 'DrawExitPortalMasks\s*='` across the entire worktree returns ZERO matches (not even tests assign it; only the nullable declarations at RetailPViewRenderer.cs:497/534/558). The DrawInside production context (GameWindow.cs:7604-7663) assigns RootCell/NearbyBuildingCells/DrawLandscapeSlice/ClearDepthSlice/DrawCellParticles/EmitDiagnostics etc. but never DrawExitPortalMasks; the DrawPortal context (GameWindow.cs:7780-7798) likewise omits it. Dead plumbing confirmed.
|
||||
|
||||
5. The substitute machinery is as claimed: ClearDepthSlice at GameWindow.cs:7644-7652 — for indoor roots a SCISSORED full depth clear over each OutsideView slice's NDC AABB (invoked per slice at RetailPViewRenderer.cs:234-235, after all landscape slices draw); for the outdoor node `null` (no depth op at all), with the comment at GameWindow.cs:7635-7643 explicitly documenting the full-buffer-wipe hazard the suppression works around. Stencil claim verified: grep for Stencil in src/ hits only the frame-start global Clear (GameWindow.cs:7156), a diagnostics readout (:9651), save/restore helpers (GLStateScope.cs:112-181, GLHelpers.cs:239), and framebuffer attachment plumbing (ManagedGLFrameBuffer.cs) — no production stencil pass.
|
||||
|
||||
IS THE DIVERGENCE REAL? Yes. Retail's depth story at exit portals is ONE path regardless of viewer location: (armed) full z-clear + per-exit-portal real-z color-invisible poly write. acdream substitutes two different non-equivalent behaviors branched on IsOutdoorNode: an AABB-footprint clear-to-FAR (wrong polarity — retail writes the doorway's real z, acdream erases to far; and an axis-aligned superset of the portal poly) indoors, and nothing outdoors. No other acdream mechanism is equivalent (the gl_ClipDistance shell clip is the Render::set_view geometric-plane mechanism, not the z-mask). The claim's port-shape note is also correct: GameWindow.cs:7644 is itself an indoor/outdoor branch retail does not have, so the faithful port (depth-only exit-portal quads per slice, plus the portalsDrawnCount-armed z-clear) would unify it.
|
||||
|
||||
REFINEMENTS (not refutations): (a) retail's whole Loop-1 block — including LScape::draw and the z-clear — is gated on `outside_view.view_count != 0`; in a fully sealed interior with no outside view none of it runs, so the pass only matters on frames where outside is visible (which is exactly the doorway/#109 regime). (b) DrawPortalPolyInternal also skips degenerate portal polys whose vertices all sit at x or y = ±12.0 (cell-boundary extent quads). (c) Severity "medium" is fair — it is a depth-correctness artifact class at door apertures plus a workaround-shaped branch, not a top-level invariant break on its own.
|
||||
- blastRadius: acdream substitutes a scissored full depth-clear per slice (indoor roots) / no clear (outdoor node) for retail's draw-the-portal-poly z machinery. Depth relationships at doorways therefore differ from retail by construction — candidate contributor to far-door artifacts (#109's visual component) and to the outdoor-node decision to suppress the clear entirely (GameWindow.cs:7635-7644 comment documents the wipe hazard the suppression works around).
|
||||
- retailEvidence: DrawCells Loop 1 (Ghidra 0x005a4840): per cell, per setup_view slice, D3DPolyRender::DrawPortalPolyInternal(portal->portal, false) for every portal with other_cell_id == -1, executed AFTER the LScape draw + the portalsDrawnCount-gated z-clear (vtbl+0x2c flag 4) and BEFORE the shell pass.
|
||||
- acdreamEvidence: RetailPViewRenderer.DrawExitPortalMasks no-ops when ctx.DrawExitPortalMasks is null (:331-332); neither production context initializer assigns it (GameWindow.cs:7604-7663 DrawInside ctx, :7780-7798 DrawPortal ctx). No production stencil use exists (only save/restore helpers in GLStateScope.cs/GLHelpers.cs/ParticleBatcher.cs).
|
||||
- portShape: Either implement the retail pass (draw exit-portal quads depth-only per slice, replacing the scissored glClear trick and the outdoor-node suppression) or delete the dead callback plumbing. The faithful port is the former; it also unifies the indoor/outdoor depth story that currently branches at GameWindow.cs:7644.
|
||||
|
||||
### [MEDIUM] legacy-outdoor-branch-remnant (adjusted) — A second full render path (clipRoot == null block) survives with different gates than the unified path
|
||||
- correctedClaim: A second, separately-coded render path (the clipRoot == null block, GameWindow.cs:7726-7831) survives with gates that differ from the unified DrawInside path — CONFIRMED on the acdream side in every cited respect. The retail premise must be corrected, however: retail does NOT route every in-world frame through DrawInside(viewer_cell). Ghidra 0x453aa0 (SmartBox::RenderNormalMode) shows an explicit top-level branch on (viewer.objcell_id & 0xffff) < 0x100: outdoor viewers go straight to LScape::draw (after Render::set_default_view + useSunlightSet(1)), and only indoor viewers go through vtable+0x48 → RenderDeviceD3D::DrawInside → PView::DrawInside → PView::DrawCells (0x005a4840), whose outside_view block (gated on outside_view.view_count != 0, with Render::PortalList = &outside_view) is where LScape::draw appears for indoor frames. The invariant that actually supports this divergence is narrower but still decisive: retail's two entries funnel into ONE shared drawing pipeline (the same LScape::draw → building DrawPortal → DrawCells machinery) with identical gates — the outdoor entry is the degenerate full-screen-view case — and retail's "viewer cell unknown" input (objcell_id == 0 satisfies < 0x100) lands in that SAME pipeline. acdream's analog of cell-unknown (viewerCellId == 0 → clipRoot null) instead lands in a hand-written second pipeline whose gates diverge from the unified path in at least three verified ways: (1) indoor-parented entities — dat statics AND live dynamics with an indoor ParentCellId — are dropped wholesale by Partition over an empty visible set (GameWindow.cs:7732-7734 + InteriorEntityPartition.cs:35-44,67-68), recovered only partially by the 1-ring DrawPortal look-in (:7748-7811); (2) unattached scene particles (AttachedObjectId == 0) DO draw via the unfiltered global Scene-pass call (:7862-7867, reachable because clipAssembly is always null in this branch), whereas the unified path's only Scene-pass particle draws both filter to AttachedObjectId != 0 (:9523-9529, :9570-9576) and never draw unattached emitters; (3) a raw _wbDrawDispatcher.Draw fallback with no cell gating at all when _interiorRenderer is null (:7827-7831). Reachability as claimed: clipRoot == null ⇔ viewerRoot == null AND viewerCellId == 0 (OutdoorCellNode.Build never returns null, OutdoorCellNode.cs:23-30; _outdoorNode built whenever viewerCellId != 0, GameWindow.cs:7458-7482), and viewerCellId falls back to playerRoot?.CellId ?? 0u under legacy/debug cameras (:7301-7305) — so pre-spawn and legacy-camera-outdoors frames land here, and any future regression that zeroes the viewer cell silently lands here too. The port shape stands: shrink the null branch to the login/pre-spawn sky-only minimum (K-fix1) and route every in-world frame through DrawInside, after relocating the unattached-particle draw into the unified path.
|
||||
- verifier notes: RETAIL re-derivation (Ghidra MCP, 127.0.0.1:8081, per the prefer-Ghidra rule): (1) Decompiled SmartBox::RenderNormalMode @ 0x453aa0 — it contains an explicit if/else on bVar4 = ((viewer.objcell_id & 0xffff) < 0x100). True (outdoor) branch: LScape::update_viewpoint, Render::update_viewpoint, Render::set_default_view, Render::useSunlightSet(1), LScape::draw — no PView, no DrawInside. False (indoor) branch: optional LScape::update_viewpoint(Position::get_outside_cell_id) when seen_outside, then (*(render_device->vtbl+0x48))(this->viewer_cell). (2) Decompiled the vtable target's body: RenderDeviceD3D::DrawInside(CEnvCell*) @ containing 0x0059f0d6 is a one-line wrapper calling PView::DrawInside(indoor_pview, cell); its CEnvCell* parameter type itself shows the indoor-only dispatch. (3) Decompiled DrawCells @ 0x005a4840 — it is PView::DrawCells; first block gated on (this->outside_view).view_count != 0 does Render::useSunlightSet(1); Render::PortalList = &this->outside_view; LScape::draw(this->lscape) — confirming the claimed 'LScape::draw is INSIDE DrawCells Loop 0' for the indoor flow. (4) function_xrefs?name=DrawCells: called only from PView::DrawInside (0x005a595b) and PView::DrawPortal (0x005a5b53). Conclusion: the claim's retail sentence 'RenderNormalMode -> DrawInside(viewer_cell) every in-world frame; there is no alternate outdoor pipeline' is an overstatement — exactly the branch-flattening error class this project has been burned by — but the corrected retail invariant (one shared pipeline, identical gates, unknown-cell input degrades into it) still supports the divergence, arguably more sharply: retail's fallback cannot diverge in gates because it IS the normal pipeline; acdream's can and does. ACDREAM verification (all by reading production code): GameWindow.cs:7497 (clipRoot = viewerRoot ?? _outdoorNode); :7458-7482 (_outdoorNode rebuilt per frame, only when viewerRoot is null && viewerCellId != 0); OutdoorCellNode.cs:23-30 (Build always returns a LoadedCell — never null); GameWindow.cs:7301-7305 (viewerCellId = chase camera ViewerCellId only when _playerMode && _retailChaseCamera != null && CameraDiagnostics.UseRetailChaseCamera, else playerRoot?.CellId ?? 0u — legacy/debug camera with player outdoors gives 0); :7726-7831 (the else block: Partition over cleared _outdoorRootNoCells at :7732-7734; sigOutdoorRootObjectCount/outdoor bucket draw :7735-7746; 1-ring candidate gather + DrawPortal look-in :7748-7811; LiveDynamic fallback :7813-7823; raw _wbDrawDispatcher!.Draw fallback :7827-7831). InteriorEntityPartition.cs:61-72 — AddByCellOrOutdoor silently returns (drops the entity from ALL buckets) when the cell id is indoor (low word >= 0x100, != 0xFFFF) and not in the visible set; with the empty set every indoor-parented entity is dropped, including ServerGuid != 0 live dynamics with indoor ParentCellId (:35-38), which are then also absent from the LiveDynamic fallback (it only holds null-ParentCellId entities, :39-40). Particles: the Scene-pass draw at :7846 is gated clipRoot is null; in that branch clipAssembly is provably always null (declared null :7501, only assigned :7665 inside the clipRoot != null branch), so the unfiltered global draw :7862-7867 runs — drawing AttachedObjectId == 0 emitters; the unified path's only Scene-pass particle sites are DrawRetailPViewLandscapeSlice :9523-9529 (filter AttachedObjectId != 0 && in _outdoorSceneParticleEntityIds) and DrawRetailPViewCellParticles :9570-9576 (filter AttachedObjectId != 0 && in _visibleSceneParticleEntityIds) — unattached scene emitters never draw there. Post-scene weather :7874-7889 likewise gated clipRoot is null; unified-path weather lives in the landscape slice :9533-9541. Side finding (not load-bearing): the filtered particle sub-branch at :7848-7858 (clipAssembly is not null inside clipRoot is null) is dead code by the same clipAssembly argument. Not verified here: the 'stricter membership rule' of the look-in — the claim explicitly defers it to a separate divergence entry. Severity medium and the proposed port shape are consistent with the evidence; no workaround is being proposed (the shape is delete-and-unify, matching the keep-listed Option A direction).
|
||||
- blastRadius: Reachable pre-spawn and under legacy/debug cameras; any future regression that nulls the outdoor node silently lands here. Its gates differ from the unified path in at least three ways: indoor-parented entities are dropped wholesale (empty partition set), unattached scene particles DO draw (unlike the unified path), and its look-in uses a stricter membership rule (next entry). One-path violations of exactly this shape caused the original FLAP.
|
||||
- retailEvidence: Retail has one render path: RenderNormalMode (0x453aa0) -> DrawInside(viewer_cell) every in-world frame; there is no alternate outdoor pipeline (LScape::draw is INSIDE DrawCells Loop 0, Ghidra 0x005a4840).
|
||||
- acdreamEvidence: GameWindow.cs:7726-7831: global outdoor bucket via Partition(_outdoorRootNoCells empty set, …) (:7732-7746), 1-ring DrawPortal look-in (:7748-7811), LiveDynamic fallback (:7813-7823), raw _wbDrawDispatcher.Draw fallback when _interiorRenderer is null (:7827-7831); plus the only live sites of global scene particles (:7846-7868) and post-scene weather (:7874-7889).
|
||||
- portShape: Shrink the null-clipRoot case to the login/pre-spawn minimum (sky only — the K-fix1 requirement) and route every in-world frame through DrawInside; delete the DrawPortal look-in and the global-bucket draw once the outdoor node is guaranteed non-null whenever a world exists. Move the unattached-particle draw into the unified path first (see particles entry) so deleting this branch loses nothing.
|
||||
|
||||
### [MEDIUM] drawportal-membership-rule-mismatch (UNVERIFIED (verifier hit token limit)) — DrawPortal and DrawInside use different drawable-cell membership rules in the same file
|
||||
- blastRadius: The slot-keys rule silently drops cells whose view reduced to IsNothingVisible/slot-less — the documented grey-walls bug class (unsealed shells showing clear color), still live on the look-in path. Any frame that transitions between the two paths can flash a cell in/out.
|
||||
- retailEvidence: Retail has one membership rule: every cell in cell_draw_list draws (DrawCells iterates cell_draw_num unconditionally, Ghidra 0x005a4840); a cell entered the list exactly by being popped in ConstructView (pc:433783).
|
||||
- acdreamEvidence: DrawInside: drawableCells = new HashSet(pvFrame.OrderedVisibleCells) with the R1 comment naming the old slot-keys filter as the grey-walls bug (RetailPViewRenderer.cs:66-71). DrawPortal: drawableCells = new HashSet(clipAssembly.CellIdToSlot.Keys) (:182) — the exact filter the R1 fix removed.
|
||||
- portShape: One-line alignment: DrawPortal adopts OrderedVisibleCells. Folds into deleting the legacy branch if that lands first.
|
||||
|
||||
### [MEDIUM] livedynamic-invisible-under-interior-roots (UNVERIFIED (verifier hit token limit)) — LiveDynamic entities (ServerGuid != 0, unresolved ParentCellId) draw only when the root is the outdoor node
|
||||
- blastRadius: A just-spawned or not-yet-cell-resolved server entity is invisible for as long as the viewer is indoors (player standing in the inn when something spawns). The dispatcher's ResolveEntitySlot would also CULL them under active routing (by design), but here they are never even submitted — two layers agree to hide them with no retail basis.
|
||||
- retailEvidence: Retail draws every object out of its cell's object list (DrawObjCell per cell, DrawCells Loop 3, Ghidra 0x005a4840) — an object always has a cell in retail (physics owns placement), so there is no 'unresolved' class to drop; the faithful behavior is resolve-then-draw, not drop.
|
||||
- acdreamEvidence: The LiveDynamic draw is gated on clipRoot.IsOutdoorNode (GameWindow.cs:7716-7724); interior roots have no LiveDynamic submission site. ResolveEntitySlot returns ClipSlotCull for serverGuid != 0 with null ParentCellId while routing is active (WbDrawDispatcher.cs:449-450).
|
||||
- portShape: Make cell resolution the fix, not the draw site: entities should carry a resolved ParentCellId by the time they render (membership pipeline), making the bucket empty by construction; until then, draw LiveDynamic under interior roots gated by the player-cell slice the way the outdoor node does, so nothing blinks out.
|
||||
|
||||
### [LOW] dual-frustum-implementations (UNVERIFIED (verifier hit token limit)) — Two frustum-cull implementations and two center/radius windows gate shells vs the objects inside them
|
||||
- blastRadius: Margin disagreements only: a cell's shell (WbFrustum on the cell AABB at Prepare time, camera-LB-centred _nearRadius ring) and the SAME cell's statics (FrustumCuller per entity AABB, player-LB streaming window) can disagree for one frame at screen edges or at ring boundaries when camera and player straddle different landblocks -> shell-without-statics or statics-without-shell popping. Cheap consolidation; low urgency.
|
||||
- retailEvidence: Retail has one viewcone: the planes Render::set_view installs (pc:343750) are the only cull surface both the shell submit (planeMask=0xffffffff, pc:427922) and the mesh check (viewconeCheck 0x0054c250) read.
|
||||
- acdreamEvidence: EnvCellRenderer uses WbFrustum (TestBox :612, Intersects :642, updated from envCellViewProj at GameWindow.cs:7396) + a camera-centred radius ring (:581-588, center from camPos at GameWindow.cs:7390-7391); WbDrawDispatcher/terrain use FrustumPlanes+FrustumCuller built from the same VP (GameWindow.cs:7221; WbDrawDispatcher.cs:593-595/:662-666; TerrainModernRenderer.cs:216-218); the streaming window is player-centred (GameWindow.cs:7381-7387).
|
||||
- portShape: Pick FrustumPlanes/FrustumCuller as the single implementation (already shared by terrain+entities), port EnvCellRenderer's Prepare to it, and key both radius windows off the same center. A conformance test comparing the two on random AABBs first (open question) de-risks the swap.
|
||||
|
||||
## OPEN QUESTIONS
|
||||
|
||||
- Does retail ever re-enqueue a cell into cell_todo_list after its first pop when its portal_view later grows, or is all late growth handled strictly in place via AddViewToPortals (pc:433446)? acdream keeps BOTH an enqueue-once set and a MaxReprocessPerCell=16 re-enqueue path (PortalVisibilityBuilder.cs:109 vs :348) — the faithful termination rule must be confirmed in Ghidra (decompile 0x005a5ab0-area ConstructView + AddViewToPortals) before porting it, since it directly affects #109.
|
||||
- What does retail's DrawMesh do with viewconeCheck's PARTIALLY_INSIDE result — draw whole, or descend to per-poly clipping? Needed to size the viewcone port for object lists (decompile DrawMesh at 0x005a08e4 region).
|
||||
- DrawCells Loop 3 sets Render::PortalList = cell->portal_view.data[num_view-1] — only the LAST entry. Is portal_view a stack whose last element is the accumulated union (so this is the full view), or does retail intentionally gate objects against only the most recent slice? Affects how acdream should aggregate CellViews for the object gate.
|
||||
- Do any production PhysicsScript/content paths spawn Scene-pass particle emitters with AttachedObjectId==0? If yes, they are invisible on every unified-path frame today (the only draw site requiring ==0 is the unreachable legacy branch, GameWindow.cs:7857) — needs a live capture to size the blast radius before the particle re-route.
|
||||
- Is the clipRoot==null branch ever reached in-world in player mode (can RetailChaseCamera.ViewerCellId be 0 while spawned)? Code reading says only pre-spawn/legacy cameras, but a runtime assertion/probe would prove the legacy branch is safe to shrink.
|
||||
- Can WbFrustum and FrustumCuller actually disagree on the same AABB+VP in practice (both are Gribb-Hartmann variants)? A randomized conformance test would either justify immediate consolidation or document equivalence.
|
||||
- Retail's z-clear in DrawCells Loop 0 is full-buffer but GATED on portalsDrawnCount/forceClear (Ghidra 0x005a4840) — what increments portalsDrawnCount, and does that gate reproduce acdream's outdoor-node 'no depth clear' decision naturally? Settling this defines the faithful replacement for the ClearDepthSlice scissor trick and its IsOutdoorNode suppression (GameWindow.cs:7644).
|
||||
Loading…
Add table
Add a link
Reference in a new issue