using System; using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering.Wb; using AcDream.Core.World; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; /// /// App-layer port of the retail indoor render orchestration: /// SmartBox::RenderNormalMode -> RenderDeviceD3D::DrawInside -> /// PView::DrawInside -> ConstructView -> DrawCells. /// public sealed class RetailPViewRenderer { private readonly GL _gl; private readonly ClipFrame _clipFrame; private readonly EnvCellRenderer _envCells; private readonly WbDrawDispatcher _entities; private readonly PortalVisibilityFrame _mainPortalFrameScratch = new(); private readonly ClipFrameAssembly _clipAssemblyScratch = new(); private readonly ViewconeCuller _viewconeScratch = new(); private readonly RetailPViewFrameResult _frameResultScratch = new(); private static readonly ClipViewSlice NoClipSlice = new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty()); private static readonly ClipViewSlice[] NoClipSlices = { NoClipSlice }; private readonly HashSet _oneCell = new(1); // Shell-batch scratch: all of a pass's cells collected for ONE batched // opaque Render call (instead of one heavy Render per cell). Reused across // frames + across look-in buildings. Spec: // docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md private readonly HashSet _shellBatch = new(); // Transparent shell cells retain IndoorDrawPlan's far-to-near order. The // EnvCell renderer uploads shared instance/command data once, then issues // range-addressed MDI calls in this exact order. private readonly List _orderedTransparentShellCells = new(); // R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame). private readonly BuildingGroupScratch _buildingGroups = new(); private readonly PortalVisibilityFrame _outdoorBuildingFrameScratch = new(); // #124: per-building look-in frames under an INTERIOR root, drawn as a // landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the // main frame (see DrawInside). Rebuilt each interior-root frame. private readonly List _lookInFrames = new(); private readonly Stack _lookInFramePool = new(); private readonly HashSet _lookInPrepareScratch = new(); // #131/#132: the late landscape phase's scene-particle owner survivors // (statics + outside-stage dynamics passing the slice cone). private readonly List _lateParticleOwnerScratch = new(); // MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/ // Dynamics), reused across frames instead of `new`ing a Result (a Dictionary // + 2 Lists, plus one List per visible cell) every DrawInside // call. See InteriorEntityPartition.Partition(Result, ...) — clears in // place and reuses each cell's list across frames when the cell stays // visible. private readonly InteriorEntityPartition.Result _partitionResult = new(); // MP-Alloc (2026-07-05): DrawInside's drawable-cell set, reused across // frames instead of `new HashSet(pvFrame.OrderedVisibleCells)` every // call. Every consumer (DrawEntityBucket, DrawExitPortalMasks, // DrawCellObjectLists, RetailPViewFrameResult.DrawableCells) reads it // synchronously within the same frame it was built. private readonly HashSet _drawableCellsScratch = new(); private readonly RetailPViewScratchRetention _scratchRetention = new(); // T2 (BR-4): retail has NO distance constant on the flood-admission chain // (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test + // GetClip + GetVisible only). The old 48 m seed cap is replaced by the // caller's per-building frustum pre-gate on aperture bounds (GameWindow's // gather); seeds themselves are unbounded. private const float OutdoorBuildingSeedDistance = float.PositiveInfinity; public RetailPViewRenderer( GL gl, ClipFrame clipFrame, EnvCellRenderer envCells, WbDrawDispatcher entities) { _gl = gl; _clipFrame = clipFrame; _envCells = envCells; _entities = entities; } public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx) { ArgumentNullException.ThrowIfNull(ctx); RecycleLookInFrames(); ResetBuildingGroups(); var pvFrame = PortalVisibilityBuilder.Build( ctx.RootCell, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ, reuseFrame: _mainPortalFrameScratch); // R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge // the small (~2-cell) per-building views into the frame. Retail reaches building interiors via // the terrain BSP -> DrawPortal -> ConstructView(CBldPortal) (decomp:326881/433895/433827); the // land root itself has no portals (it floods nothing into buildings). Per-building seeding is // robust to the eye's ~36 µm rest jitter where the pre-R-A2 single reverse-portal flood // oscillated as the chase eye grazed a doorway (the indoor flap). if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null) MergeNearbyBuildingFloods(ctx, pvFrame); // #124: interior-root building look-ins. Retail runs the look-in INSIDE // the landscape stage for ANY root — LScape::draw is the FIRST call of // DrawCells' outside-view branch (pc:432719), strictly BEFORE the depth // clear (pc:432732) and the exit-portal seals (pc:432785); a far // building seen through our doorway floods clipped to the INSTALLED // outside view (GetClip vs current view, ConstructView(CBldPortal) // 0x005a59a0). These frames therefore draw in DrawBuildingLookIns // (inside the landscape stage), NEVER merged into the main frame — a // merged cell would draw post-clear and z-fail against the root's seal // (its geometry is beyond the door plane). The eye-side seed test // self-excludes the root's own building (the eye is on its interior // side). Outdoor roots keep the MergeNearbyBuildingFloods path above // (no depth clear under outdoor roots — the merged form is equivalent // there). if (!ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null && pvFrame.OutsideView.Polygons.Count > 0) BuildInteriorRootLookIns(ctx, pvFrame); var clipAssembly = ClipFrameAssembler.Assemble( _clipFrame, pvFrame, _clipAssemblyScratch); int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2); PrepareClipFrame(ctx.SetTerrainClipUbo, terrainUploadCount); // R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the // assembler handed a clip-slot. This feeds the Prepare filter + entity partition, // so every visible cell's shell has a prepared batch and seals — killing the grey // (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells). // Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained). _drawableCellsScratch.Clear(); _drawableCellsScratch.UnionWith(pvFrame.OrderedVisibleCells); var drawableCells = _drawableCellsScratch; UseIndoorMembershipOnlyRouting(); // #124: look-in cells need prepared shell batches + their statics routed // into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main // cell-object pass iterates pvFrame.OrderedVisibleCells, which never // contains them). drawableCells itself stays the MAIN flood: it feeds the // seals, the outside-stage predicate, and the frame result. var prepareCells = drawableCells; if (_lookInFrames.Count > 0) { _lookInPrepareScratch.Clear(); _lookInPrepareScratch.UnionWith(drawableCells); foreach (var f in _lookInFrames) foreach (uint c in f.OrderedVisibleCells) _lookInPrepareScratch.Add(c); prepareCells = _lookInPrepareScratch; } // (#176 correction, 2026-07-06: the flood-scoped light-pool rebuild that ran // here was the seam-floor flicker mechanism — retail's visible_cell_table is // the RESIDENT-cell registry, not the frame flood — and is deleted. The pool // is built once per frame in GameWindow, player-anchored.) _envCells.PrepareRenderBatches( ctx.ViewProjection, ctx.CameraWorldPosition, filter: prepareCells, centerLbX: ctx.RenderCenterLbX, centerLbY: ctx.RenderCenterLbY, renderRadius: ctx.RenderRadius); InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries); var partition = _partitionResult; RetailPViewFrameResult result = _frameResultScratch.Reset( pvFrame, clipAssembly, drawableCells, partition); ctx.EmitDiagnostics?.Invoke(result); // T1 (fused BR-2/3): retail's frame order — static world, then the // aperture depth writes, then interior cells WHOLE far→near, then // per-cell statics, then ALL dynamics last (retail draws objects after // cells: PView::DrawCells Ghidra 0x005a4840; DrawBuilding 0x0059f2a0). // The geometric shell chop (gl_ClipDistance crop, 927fd8f/9ce335e) is // DELETED — retail never clips cell geometry; aperture exactness comes // from the punch/seal depth writes + the z-buffer, and the dynamics- // last order is what makes the punch safe (the first BR-2 attempt // punched after dynamics and erased the player, reverted 88be519). // T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view, // never clipped (Ghidra 0x0054c250). Built once per frame from the // assembled slices + this frame's view-projection. var viewcone = ViewconeCuller.Build( clipAssembly, ctx.ViewProjection, _viewconeScratch); // #118: stage assignment for dynamics under an INTERIOR root. Retail // draws the OUTSIDE world's objects inside the landscape stage — // PView::DrawCells runs LScape::draw FIRST (pc:432719), then the gated // full depth clear (pc:432731-432732) and the exit-portal SEALS // (pc:432785-432786); DrawBlock draws every landcell's objects via // DrawSortCell (0x005a17c0, pc:430124). A dynamic deferred to our // single last pass instead z-fails against the seal's true-depth stamp // the moment it stands beyond the door plane — the house-exit // clip+vanish (pinned by HouseExitWalkReplayTests). So under an // interior root: outdoor-classified dynamics draw in the outside // stage; an indoor dynamic whose sphere STRADDLES an exit portal // draws in BOTH stages (retail's per-overlapped-cell shadow-part // draw, DrawBlock pc:430056-430064) so neither body half clips at the // plane. Outdoor roots keep ALL dynamics in the last pass — our // z-buffered equivalent of retail's painter-ordered outdoor pass (the // BR-2 punch-after-dynamics lesson, reverted 88be519). _outsideStageDynamics.Clear(); if (!ctx.RootCell.IsOutdoorNode) { foreach (var e in partition.Dynamics) { EntitySphere(e, out var c, out float r); if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.CellLookup)) _outsideStageDynamics.Add(e); } } DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone); UseIndoorMembershipOnlyRouting(); DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); DrawEnvCellShells(pvFrame); DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone); DrawDynamicsLast(ctx, partition, viewcone, ctx.RootCell.IsOutdoorNode); return result; } // R-A2: group the nearby building cells by BuildingId and run one per-building flood per group // (retail's per-building ConstructView(CBldPortal)), merging each small view into the frame. The // grouping dict contains only this frame's keys; lists are pooled across frames. private void MergeNearbyBuildingFloods(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame) { RebuildBuildingGroups(ctx.NearbyBuildingCells!); foreach (var group in _buildingGroups.Values) { if (group.Count == 0) continue; var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding( group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, OutdoorBuildingSeedDistance, reuseFrame: _outdoorBuildingFrameScratch); MergeBuildingFrame(pvFrame, buildingFrame); } } // T2 (BR-4): merge a per-building flood's cells + views into the frame as a // UNION. Retail accumulates EVERY clipped portal polygon as a new view_poly // on the cell (Render::copy_view appends + view_count++, Ghidra 0x0054dfc0; // a cell visible through two apertures holds two views, all consumed // downstream). The old first-wins (`ContainsKey -> continue`) dropped the // second building flood's views whenever a cell was already in the frame — // the multiview-loss-first-wins divergence (a named #109 suspect: per-frame // winner flips between apertures). CellView.Add dedups exact/collinear // re-emissions (the dac8f6a CanonicalKey), so unioning is convergent. // OutsideView is NOT merged — the outdoor root already seeds full-screen // terrain, and ConstructViewBuilding (BuildFromExterior) leaves OutsideView // empty (it stops at exit portals once inside the building). private static void MergeBuildingFrame(PortalVisibilityFrame target, PortalVisibilityFrame src) { foreach (uint cellId in src.OrderedVisibleCells) { if (!src.CellViews.TryGetValue(cellId, out var srcView)) continue; if (!target.CellViews.TryGetValue(cellId, out var existing)) { existing = target.RentCellView(); target.CellViews[cellId] = existing; target.OrderedVisibleCells.Add(cellId); } // Copy the view polygons into storage owned by the target frame. // Source building frames are reused immediately for the next // building flood, so retaining their CellView reference would // alias pooled scratch and mutate the merged main view. foreach (var p in srcView.Polygons) existing.Add(target.CopyPolygon(p.Vertices)); } } // #124: per-building look-in floods for an INTERIOR root, seeded clipped // against the OutsideView (retail: GetClip runs under the INSTALLED view — // the accumulated doorway region — so a far building floods only within the // doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip // 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own // building self-excludes via the seed eye-side test. private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame) { RebuildBuildingGroups(ctx.NearbyBuildingCells!); foreach (var group in _buildingGroups.Values) { if (group.Count == 0) continue; PortalVisibilityFrame frameScratch = _lookInFramePool.Count != 0 ? _lookInFramePool.Pop() : new PortalVisibilityFrame(); var frame = PortalVisibilityBuilder.ConstructViewBuilding( group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons, reuseFrame: frameScratch); if (frame.OrderedVisibleCells.Count > 0) _lookInFrames.Add(frame); else ReturnLookInFrame(frame); } } private void RecycleLookInFrames() { for (int i = 0; i < _lookInFrames.Count; i++) ReturnLookInFrame(_lookInFrames[i]); _scratchRetention.ClearFrameBuffers( _lookInFrames, _lookInPrepareScratch, _drawableCellsScratch, _shellBatch, _orderedTransparentShellCells); } private void ReturnLookInFrame(PortalVisibilityFrame frame) { frame.ResetForBuild(); if (_lookInFramePool.Count < RetailPViewScratchRetention.MaxRetainedLookInFrames) _lookInFramePool.Push(frame); } private void RebuildBuildingGroups(IReadOnlyList nearbyCells) => _buildingGroups.Rebuild(nearbyCells); private void ResetBuildingGroups() => _buildingGroups.Reset(); // #124: draw the interior-root look-ins INSIDE the landscape stage — // retail's placement (LScape::draw → DrawBlock → DrawSortCell → // DrawBuilding runs as the FIRST call of DrawCells' outside-view branch, // pc:432719, before the depth clear + seals). Per building: punch ALL // apertures first (retail finishes build_draw_portals_only pass 1 — the // far-Z maxZ1 punch — across the whole building BSP before pass 2 floods), // then draw the flooded cells' shells + statics far→near (the nested // DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is // empty by construction — PView ctor draw_landscape=0 — so no recursive // landscape/clear/seal). Anything rasterized outside an aperture is // repainted by the root's own shells after the depth clear, so over-draw // here is color-safe; statics draw whole (the main viewcone has no entry // for look-in cells; over-include is the safe direction). private void DrawBuildingLookIns( RetailPViewDrawContext ctx, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result partition) { if (_lookInFrames.Count == 0) return; foreach (var frame in _lookInFrames) { // Pass 1: far-Z punch every aperture of this building. if (ctx.DrawLookInPortalPunch is not null) { foreach (uint cellId in frame.OrderedVisibleCells) { if (!frame.CellViews.TryGetValue(cellId, out var view)) continue; foreach (var poly in view.Polygons) { var cps = ClipPlaneSet.From(poly); if (cps.IsNothingVisible) continue; ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext( cellId, new ClipViewSlice( 0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), cps.PlaneArray), Array.Empty())); } } } // Pass 2: shells + statics, far→near. UseIndoorMembershipOnlyRouting(); // Opaque shells batched per building into ONE Render (this building's // aperture punches above already ran; z-buffer handles order and // lighting is per-instance CellId-keyed) — was one heavy per-frame // Render per cell. Per-cell entity/particle work stays in the loop. _shellBatch.Clear(); foreach (uint cid in frame.OrderedVisibleCells) _shellBatch.Add(cid); if (_shellBatch.Count > 0) _envCells.Render(WbRenderPass.Opaque, _shellBatch); for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) { uint cellId = frame.OrderedVisibleCells[i]; _oneCell.Clear(); _oneCell.Add(cellId); // Opaque shell batched above. Transparent stays per-cell (far→near) // for correct compositing; skipped for opaque-only cells. if (_envCells.CellHasTransparent(cellId)) _envCells.Render(WbRenderPass.Transparent, _oneCell); _cellStaticScratch.Clear(); if (partition.ByCell.TryGetValue(cellId, out var bucket)) _cellStaticScratch.AddRange(bucket); // #131 ROOT CAUSE: DYNAMICS living in a look-in cell (the // Holtburg hall-porch PORTAL, pCell 0xA9B4017A) draw NOWHERE // under an interior root — DrawDynamicsLast viewcone-culls // them (the main cone has no entries for look-in cells), and // post-clear they would z-fail against the root's seal anyway // (the #118 lesson). Retail draws a look-in cell's objects // inside the NESTED DrawCells (DrawObjCellForDummies, // pc:432878+), i.e. right here in the landscape stage. Drawn // WHOLE like the statics (AP-33's documented over-include). // No double-draw: dynamics-last keeps culling them (their // cell is absent from the main cone), and their emitters ride // the DrawCellParticles call below, not DrawDynamicsParticles // (which only sees dynamics-last cone survivors). foreach (var e in partition.Dynamics) if (e.ParentCellId == cellId) _cellStaticScratch.Add(e); if (_cellStaticScratch.Count > 0) { DrawEntityBucket(ctx, _cellStaticScratch, _oneCell); // The cell-particles pass for look-in cells — retail's // nested DrawCells draws objects WITH their emitters. foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext( cellId, slice, _cellStaticScratch)); } } } } private void DrawLandscapeThroughOutsideView( RetailPViewDrawContext ctx, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result partition, ViewconeCuller viewcone) { if (clipAssembly.OutsideViewSlices.Length == 0) return; // #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha // draws of the landscape stage and flushes them ONCE after LScape::draw // (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent // landscape content (portal swirl meshes, flame particles) composites // AFTER the building look-ins. Our dispatcher draws translucency inside // each Draw call, so the stage is split in TWO phases instead: EARLY = // sky + terrain + outdoor STATIC meshes (the look-in punches need their // depth to mark against, the #117 lesson); then the look-ins; then // LATE = outside-stage dynamics' meshes + ALL scene particles + // weather. Content drawn early and overlapped by a look-in aperture // was otherwise overpainted by the far interior (translucents write no // depth to protect themselves) — the portal-swirl/candle-flame class. int probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { _clipFrame.SetTerrainClip(slice.Planes); UploadTerrainClip(ctx.SetTerrainClipUbo); // T3 (BR-5): entities are never hard-clipped — retail viewcone- // CHECKS each mesh's sphere against the view (Ghidra 0x0054c250) // and draws it whole. The old per-slice entity clip routing // (gl_ClipDistance via SetClipRouting) is replaced by the sphere // pre-filter below; terrain/sky keep their per-slice plane clip. _entities.ClearClipRouting(); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled) EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex); _outdoorStaticScratch.Clear(); foreach (var e in partition.OutdoorStatic) { EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) _outdoorStaticScratch.Add(e); } probeSliceIndex++; ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); } // #124: far-building look-ins draw HERE — still inside the landscape // stage (their punches mark against the terrain/exterior depth just // drawn), strictly BEFORE the depth clear + seals below, matching // retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785). DrawBuildingLookIns(ctx, clipAssembly, partition); // LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn // pre-clear so the seal protects their aperture pixels; AFTER the // look-ins so a translucent portal mesh blends over a far interior // instead of being overpainted) + the scene-particle owners (statics + // dynamics cone survivors — flames ride here for the same reason). probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { _clipFrame.SetTerrainClip(slice.Planes); UploadTerrainClip(ctx.SetTerrainClipUbo); _entities.ClearClipRouting(); _outdoorStaticScratch.Clear(); // late: dynamics survivors _lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors foreach (var e in partition.OutdoorStatic) { EntitySphere(e, out var c, out float r); bool ownerPass = viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r); if (ownerPass) _lateParticleOwnerScratch.Add(e); // #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids // double as an ENTITY-id watchlist here — one line per watched // outdoor-static owner per CHANGE of its cone verdict. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled && AcDream.Core.Rendering.RenderingDiagnostics.DumpEntitySourceIds.Contains(e.Id) && (!_outStageOwnerVerdicts.TryGetValue(e.Id, out bool prev) || prev != ownerPass)) { _outStageOwnerVerdicts[e.Id] = ownerPass; Console.WriteLine(System.FormattableString.Invariant( $"[outstage-own] id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} pos=({e.Position.X:F1},{e.Position.Y:F1},{e.Position.Z:F1}) c=({c.X:F1},{c.Y:F1},{c.Z:F1}) r={r:F1} slice={probeSliceIndex} {(ownerPass ? "PASS" : "CULL")}")); } } foreach (var e in _outsideStageDynamics) { EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) { _outdoorStaticScratch.Add(e); _lateParticleOwnerScratch.Add(e); } } if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled) EmitOutStageProbe(probeSliceIndex, viewcone); probeSliceIndex++; ctx.DrawLandscapeSliceLate?.Invoke(new RetailPViewLandscapeLateSliceContext( slice, _outdoorStaticScratch, _lateParticleOwnerScratch)); } // #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls, // campfires, ground effects anchored at a position) have no owner id // to ride any of the id-filtered particle passes. The outdoor root // has the dedicated T3 pass for them; an INTERIOR root had NO pass // at all. Draw them ONCE per frame (not per slice — alpha particles // must not double-draw, the #121 lesson), at the END of the landscape // stage: after the clear they would z-fail against the doorway seal. if (!ctx.RootCell.IsOutdoorNode) ctx.DrawUnattachedSceneParticles?.Invoke(); // Retail PView::DrawCells 0x005A4872 drains the landscape alpha list // immediately after LScape::draw and before the optional depth clear. // The queue remains active for the post-clear/final-world scope. ctx.FlushLandscapeAlpha?.Invoke(); // T1: retail clears the FULL depth buffer ONCE between the outside // stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 — // Clear gated on portalsDrawnCount; exact gate semantics is a plan // open question, staged as "any outside slice drawn"), then re-stamps // every outside-leading portal's TRUE depth (the seals, // DrawExitPortalMasks). Replaces the old per-slice scissored AABB // clear (wrong shape, no seal after it). if (clipAssembly.OutsideViewSlices.Length > 0) ctx.ClearDepthForInterior?.Invoke(); UseIndoorMembershipOnlyRouting(); } // #131 [outstage] probe state (2026-06-12, throwaway): print-on-change — // which outdoor dynamics were routed to the outside stage and which // survived the slice viewcone. Strip with the probe when #131 closes. private string? _lastOutStageSig; private readonly Dictionary _outStageOwnerVerdicts = new(); private void EmitOutStageProbe(int sliceIndex, ViewconeCuller viewcone) { var sb = new System.Text.StringBuilder(192); sb.Append("slice=").Append(sliceIndex) .Append(" outStage=").Append(_outsideStageDynamics.Count).Append(" ["); for (int i = 0; i < _outsideStageDynamics.Count; i++) { var e = _outsideStageDynamics[i]; EntitySphere(e, out var c, out float r); bool pass = viewcone.SphereVisibleInOutsideSlice(sliceIndex, c, r); if (i > 0) sb.Append(' '); sb.Append(System.FormattableString.Invariant( $"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}(s{e.SourceGfxObjOrSetupId:X8}):{(pass ? "PASS" : "CULL")}:r={r:F1}")); } sb.Append(']'); string sig = sb.ToString(); if (sig == _lastOutStageSig) return; _lastOutStageSig = sig; Console.WriteLine("[outstage] " + sig); } // §4 flap [clip-route] probe state (2026-06-10, throwaway): print-on-change signature + // monotonic sequence so held-flap vs healthy frames diff cleanly in one capture. private string? _lastClipRouteSig; private long _clipRouteSeq; private readonly List _clipRouteCellKeys = new(); // §4 flap apparatus (2026-06-10): the decisive probe between the surviving suspects // (handoff 2026-06-09 §1). Emits the EXACT clip inputs the landscape pass draws under: // the outside slice's slot + NDC AABB + planes (CPU side), the region-SSBO bytes decoded // at that slot (what mesh_modern.vert reads for routed instances), the terrain-UBO head // (what terrain/sky gate against), and the CellIdToSlot routing table. Fires AFTER // SetTerrainClip + UploadTerrainClip + SetClipRouting, BEFORE DrawLandscapeSlice — so the // printed bytes are exactly what this slice's draws consume. private void EmitClipRouteProbe(ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex) { var sb = new System.Text.StringBuilder(256); sb.Append(System.FormattableString.Invariant( $"slice={sliceIndex}/{clipAssembly.OutsideViewSlices.Length} slot={slice.Slot}")); sb.Append(System.FormattableString.Invariant( $" ndc=({slice.NdcAabb.X:F3},{slice.NdcAabb.Y:F3},{slice.NdcAabb.Z:F3},{slice.NdcAabb.W:F3})")); sb.Append(System.FormattableString.Invariant($" planes={slice.Planes.Length}[")); for (int i = 0; i < slice.Planes.Length; i++) { var p = slice.Planes[i]; if (i > 0) sb.Append(' '); sb.Append(System.FormattableString.Invariant($"({p.X:F3},{p.Y:F3},{p.Z:F3},{p.W:F3})")); } // CellIdToSlot sorted by cell id so dictionary enumeration order can't fake a change. sb.Append("] cells={"); _clipRouteCellKeys.Clear(); foreach (uint key in clipAssembly.CellIdToSlot.Keys) _clipRouteCellKeys.Add(key); _clipRouteCellKeys.Sort(); for (int i = 0; i < _clipRouteCellKeys.Count; i++) { if (i > 0) sb.Append(','); sb.Append(System.FormattableString.Invariant( $"0x{_clipRouteCellKeys[i]:X8}:{clipAssembly.CellIdToSlot[_clipRouteCellKeys[i]]}")); } sb.Append('}'); // Region-SSBO content decoded at the routed slot, from the packed bytes UploadRegions // just uploaded — slot stride 144: count uint at +0, planes[8] at +16. var rb = _clipFrame.RegionBytesForTest; int off = slice.Slot * ClipFrame.CellClipStrideBytes; if (off >= 0 && off + ClipFrame.CellClipStrideBytes <= rb.Length) { uint ssboCount = System.BitConverter.ToUInt32(rb.Slice(off, 4)); sb.Append(System.FormattableString.Invariant($" ssbo[{slice.Slot}]: n={ssboCount}")); int planeN = (int)System.Math.Min(ssboCount, (uint)ClipFrame.MaxPlanes); for (int i = 0; i < planeN; i++) { int po = off + ClipFrame.CellClipPlanesOffset + i * 16; float px = System.BitConverter.ToSingle(rb.Slice(po, 4)); float py = System.BitConverter.ToSingle(rb.Slice(po + 4, 4)); float pz = System.BitConverter.ToSingle(rb.Slice(po + 8, 4)); float pw = System.BitConverter.ToSingle(rb.Slice(po + 12, 4)); sb.Append(System.FormattableString.Invariant($" ({px:F3},{py:F3},{pz:F3},{pw:F3})")); } } else { sb.Append(System.FormattableString.Invariant( $" ssbo[{slice.Slot}]: OUT-OF-RANGE len={rb.Length}")); } // Terrain-UBO head as uploaded (std140: int count at +0, planes[8] at +16). var tb = _clipFrame.TerrainBytesForTest; int uboCount = System.BitConverter.ToInt32(tb.Slice(0, 4)); float u0 = System.BitConverter.ToSingle(tb.Slice(16, 4)); float u1 = System.BitConverter.ToSingle(tb.Slice(20, 4)); float u2 = System.BitConverter.ToSingle(tb.Slice(24, 4)); float u3 = System.BitConverter.ToSingle(tb.Slice(28, 4)); sb.Append(System.FormattableString.Invariant( $" ubo: n={uboCount} p0=({u0:F3},{u1:F3},{u2:F3},{u3:F3})")); string sig = sb.ToString(); _clipRouteSeq++; if (sig == _lastClipRouteSig) return; _lastClipRouteSig = sig; Console.WriteLine($"[clip-route] n={_clipRouteSeq} {sig}"); } private void DrawExitPortalMasks( IRetailPViewCellDrawCallbacks ctx, PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells) { if (ctx.DrawExitPortalMasks is null) return; for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) { uint cellId = pvFrame.OrderedVisibleCells[i]; if (!drawableCells.Contains(cellId)) continue; foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) ctx.DrawExitPortalMasks(new RetailPViewCellSliceContext(cellId, slice, Array.Empty())); } } private void DrawEnvCellShells(PortalVisibilityFrame pvFrame) { // T1 (fused BR-2/3): retail DrawCells Loop 2 — every visible cell's // shell drawn WHOLE, reverse cell_draw_list (far→near), drawn once. // Retail NEVER clips cell geometry: the production path is the // prebuilt mesh (DrawEnvCell use_built_mesh, pc:427905; the // planeMask=0xffffffff legacy submit means skip-all-edges), and // aperture exactness comes from the punch/seal depth writes + the // z-buffer + this order. The former gl_ClipDistance chop // (927fd8f/9ce335e, #114) is deleted with this rewrite. // Per-cell opaque+transparent keeps the far→near transparent // compositing the per-cell loop already provided. UseIndoorMembershipOnlyRouting(); // Opaque: ONE batched Render for all shell cells (was one heavy per-frame // Render call PER cell — the dense-town FPS sink, ~94 calls/24.75ms at // Arwic). Opaque needs no draw order (z-buffer), and lighting is // per-instance (CellId-keyed light SSBO in EnvCellRenderer.RenderModernMDI- // Internal), so cross-cell batching is visually identical. The filtered // Render path already groups all cells' instances into one MDI. _shellBatch.Clear(); foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) _shellBatch.Add(entry.CellId); if (_shellBatch.Count > 0) _envCells.Render(WbRenderPass.Opaque, _shellBatch); // Transparent: far-to-near order matters for compositing. The ordered // list retains ShellPass cell boundaries while EnvCellRenderer shares // one instance/command/light upload across the complete pass. _orderedTransparentShellCells.Clear(); foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) { if (_envCells.CellHasTransparent(entry.CellId)) _orderedTransparentShellCells.Add(entry.CellId); } if (_orderedTransparentShellCells.Count > 0) _envCells.RenderTransparentOrdered(_orderedTransparentShellCells); } // T1: the frame's single LAST entity pass — ALL server-spawned dynamics // (player, NPCs, doors, items), indoor or out, drawn after the static // world + punches + interior cells. Depth-tested, never hard-clipped // (retail draws objects per cell AFTER cells and viewcone-culls them — // PView::DrawCells epilogue Ghidra 0x005a4840; the sphere-vs-view cull is // T3). Drawing dynamics last is what makes the aperture punch safe. // T3 (BR-5): each dynamic is viewcone-culled like retail — sphere vs its // cell's views; outdoor/unresolved vs the outside views (pass-all under // the outdoor root's full-screen outside view). A dynamic in a NON-flooded // room culls HERE — retail never reaches an object whose cell is not in // the draw list; the partition keeps routing it so the CULL (not the // visibility set) drops it, exactly retail's shape. private void DrawDynamicsLast( IRetailPViewCellDrawContext ctx, InteriorEntityPartition.Result partition, ViewconeCuller viewcone, bool rootIsOutdoor) { if (partition.Dynamics.Count == 0) return; _dynamicsScratch.Clear(); foreach (var e in partition.Dynamics) { EntitySphere(e, out var c, out float r); bool indoor = InteriorEntityPartition.IsIndoorCellId(e.ParentCellId); // TEMP (#138-B): trace the avatar's survival through this cull. bool isProbePlayer = AcDream.App.Streaming.EntityVanishProbe.Enabled && AcDream.App.Streaming.EntityVanishProbe.PlayerGuid != 0 && e.ServerGuid == AcDream.App.Streaming.EntityVanishProbe.PlayerGuid; // #118: under an interior root, outdoor-classified dynamics drew in // the outside stage (pre-clear, seal-protected) — retail draws them // via LScape::draw's per-landcell DrawSortCell, never in the // post-seal cell-object epilogue (PView::DrawCells pc:432719 vs // pc:432878). Drawing them here instead z-fails them against the // seal. Indoor dynamics (incl. exit-portal straddlers, which drew // in BOTH stages) stay — this pass is retail's loop C. if (!rootIsOutdoor && !indoor) { if (isProbePlayer) AcDream.App.Streaming.EntityVanishProbe.LogPlayerDynOnChange( $"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)"); continue; } bool visible = indoor ? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r) : viewcone.SphereVisibleOutside(c, r); if (isProbePlayer) AcDream.App.Streaming.EntityVanishProbe.LogPlayerDynOnChange( $"cell=0x{(e.ParentCellId ?? 0):X8} indoor={indoor} rootOutdoor={rootIsOutdoor} viewcone={visible} -> {(visible ? "DRAWN" : "CULLED(viewcone)")}"); if (visible) _dynamicsScratch.Add(e); } if (_dynamicsScratch.Count == 0) return; UseIndoorMembershipOnlyRouting(); DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null); // #121: dynamics' attached emitters (portal swirls, creature effects) // gate through the SAME cone-surviving owner set as their meshes — // retail draws emitters with the owner object. Before this callback, // dynamics' emitters fell through EVERY particle filter under the pview // path (the landscape slice carries outdoor statics + #118 outside- // stage dynamics; the cell callback carries cell statics; T4 deleted // the old clipRoot==null global pass from normal frames) — all world // portals went invisible. Outside-stage dynamics are excluded here: // their emitters already drew in the landscape slice (alpha-blended // particles must not double-draw, unlike the depth-idempotent meshes). if (ctx.DrawDynamicsParticles is not null) { _dynamicsParticleScratch.Clear(); foreach (var e in _dynamicsScratch) if (!_outsideStageDynamics.Contains(e)) _dynamicsParticleScratch.Add(e); if (_dynamicsParticleScratch.Count > 0) ctx.DrawDynamicsParticles(_dynamicsParticleScratch); } } private void DrawCellObjectLists( IRetailPViewCellDrawContext ctx, PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, InteriorEntityPartition.Result partition, ViewconeCuller viewcone) { // T1: per-cell STATIC object lists only (dat-baked 0x40 statics) — // dynamics moved to DrawDynamicsLast. Far→near with the cells, after // the shells (retail DrawCells epilogue: PortalList = cell's views → // DrawObjCell, Ghidra 0x005a4840). T3 (BR-5): each static's sphere is // tested against ITS CELL's views (retail viewconeCheck) — the // statics-through-walls fix: a static whose sphere is outside every // view of its cell no longer paints through the wall (the cottage // phantom staircase's draw path). // Dense-town FPS iteration-1 (spec 2026-06-23-cellobject-draw-batching): // the per-cell DrawEntityBucket calls below were the top CPU sink at Arwic // (cellobjects ~3.5 ms/frame; each WbDrawDispatcher.Draw orphans 6 SSBOs + // full state setup). Collapse them into ONE cross-cell batched draw — the // shipped cells-shell batching pattern applied to cell OBJECTS. Two loops // preserve the statics-before-particles depth order: loop 1 culls + // accumulates every cell's survivors and draws them once; loop 2 runs the // per-cell particle passes AFTER the statics own the depth buffer (particles // depth-test but write no depth). The dispatcher sorts opaque front-to-back // and transparent back-to-front by group distance (WbDrawDispatcher.cs: // 1469-1470), so cross-cell batching composites correctly — equal-or-better // than the old per-cell-bucketed order. visibleCellIds = the union of cells, // so the dispatcher admits exactly the same survivor set. // Loop 1: per-cell viewcone cull → accumulate survivors + the union of cells. _allCellStatics.Clear(); _cellObjCells.Clear(); for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) { uint cellId = pvFrame.OrderedVisibleCells[i]; if (!drawableCells.Contains(cellId)) continue; if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0) continue; int survivorsBefore = _allCellStatics.Count; foreach (var e in bucket) { EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInCell(cellId, c, r)) _allCellStatics.Add(e); } int survivors = _allCellStatics.Count - survivorsBefore; if (survivors > 0) _cellObjCells.Add(cellId); // BR-2 phantom-site probe (T3-updated): post-viewcone survivors. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled) EmitPhantomObjsProbe(cellId, survivors); } // ONE batched static-object draw for every visible cell (was N per-cell // WbDrawDispatcher.Draw calls). T1: per-cell STATIC lists only — dynamics // draw in DrawDynamicsLast. T3 (BR-5): each static was sphere-tested against // ITS cell's views above (the statics-through-walls fix is preserved by the // cull; only the draw is batched). if (_allCellStatics.Count > 0) { UseIndoorMembershipOnlyRouting(); DrawEntityBucket(ctx, _allCellStatics, _cellObjCells); } // Cell-particle pass — consolidated across ALL visible cells into ONE // draw. Was per-cell, and each call re-walked the ENTIRE live particle set // (DrawRetailPViewCellParticles → ParticleRenderer.Draw enumerates every // live emitter), i.e. O(cells × particles) — the dense-town cellobjects // sink (~5 ms at Arwic). Static owners are disjoint per cell, so the UNION // (= _allCellStatics, already accumulated above for the batched draw) draws // EXACTLY the same emitters: the callback gates on owner id (the cone- // surviving set), the renderer sorts globally back-to-front, and the per- // cell slice was never used for clipping (the scissor gate was deleted in // T3 — DrawRetailPViewCellParticles disables clip distances). Runs after // the batched static draw so emitters depth-test against the statics now in // the buffer (the statics-before-particles order). cellId/slice are unused // by the particle pass — pass NoClipSlice + the union owner list. This also // drops the per-cell BuildDrawList allocations (N → 1). if (_allCellStatics.Count > 0) ctx.DrawCellParticles?.Invoke( new RetailPViewCellSliceContext(0u, NoClipSlice, _allCellStatics)); } // T3 scratch lists (render thread only; cleared per use). private readonly List _outdoorStaticScratch = new(); private readonly List _cellStaticScratch = new(); private readonly List _dynamicsScratch = new(); // #118: dynamics assigned to the OUTSIDE stage this frame (interior roots // only) — outdoor-classified + exit-portal straddlers. Cleared per frame. private readonly List _outsideStageDynamics = new(); // #121: cone-surviving dynamics whose emitters draw in the dynamics // particle pass (survivors minus outside-stage). Cleared per use. private readonly List _dynamicsParticleScratch = new(); // Dense-town FPS iteration-1 (cellobject batching): all visible cells' // viewcone-surviving statics accumulated for ONE batched DrawEntityBucket, // plus the union of their cell ids for the dispatcher's visibleCellIds gate. // Cleared at the top of DrawCellObjectLists. private readonly List _allCellStatics = new(); private readonly HashSet _cellObjCells = new(); /// /// #118 stage assignment for a dynamic under an INTERIOR root: does it draw /// in the OUTSIDE (landscape) stage — before the gated depth clear and the /// exit-portal seals — like retail's per-landcell object draw /// (LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell pc:430124, run at /// the top of PView::DrawCells pc:432719)? /// /// True for outdoor-classified dynamics (their fragments lie beyond the /// door plane and would z-fail the seal in the last pass), and for INDOOR /// dynamics whose sphere straddles an exit-portal plane of their flood- /// visible cell — retail draws an object once per overlapped shadow cell /// (DrawBlock pc:430056-430064), so a threshold-straddling body draws in /// both stages and neither half clips at the plane. Pure — also driven /// headlessly by HouseExitWalkReplayTests as the ordering contract. /// public static bool DynamicDrawsInOutsideStage( uint? parentCellId, Vector3 sphereCenter, float sphereRadius, HashSet drawableCells, Func cellLookup) { if (!InteriorEntityPartition.IsIndoorCellId(parentCellId)) return true; uint cellId = parentCellId!.Value; if (!drawableCells.Contains(cellId)) return false; // not in the flood — the last-pass cone cull owns it var cell = cellLookup(cellId); if (cell is null) return false; var localC = Vector3.Transform(sphereCenter, cell.InverseWorldTransform); int n = Math.Min(cell.Portals.Count, cell.ClipPlanes.Count); for (int i = 0; i < n; i++) { if (cell.Portals[i].OtherCellId != 0xFFFF) continue; var plane = cell.ClipPlanes[i]; if (plane.Normal.LengthSquared() < 1e-8f) continue; float dist = Vector3.Dot(plane.Normal, localC) + plane.D; if (MathF.Abs(dist) < sphereRadius) return true; // sphere straddles the exit-portal plane } return false; } // Conservative bounding sphere from the entity's cached AABB — the same // bounds source the dispatcher's frustum cull uses. private static void EntitySphere(WorldEntity e, out Vector3 center, out float radius) { if (e.AabbDirty) e.RefreshAabb(); center = (e.AabbMin + e.AabbMax) * 0.5f; radius = (e.AabbMax - e.AabbMin).Length() * 0.5f; } // BR-2 phantom-site probe state: print-on-change per cell so the log stays // diffable while the condition persists. Throwaway apparatus — strip when // the #113 phantom residual closes. (The [phantom-shell] half died with // the T1 chop deletion — shells draw whole, there is no slice state left // to report.) private readonly Dictionary _phantomObjsSig = new(); private void EmitPhantomObjsProbe(uint cellId, int bucketCount) { if (_phantomObjsSig.TryGetValue(cellId, out var prev) && prev == bucketCount) return; _phantomObjsSig[cellId] = bucketCount; Console.WriteLine($"[phantom-objs] cell=0x{cellId:X8} entities={bucketCount} (drawn unclipped, no viewcone)"); } private static ClipViewSlice[] GetCellSlicesOrNoClip( ClipFrameAssembly clipAssembly, uint cellId) { if (clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices) && slices.Length > 0) return slices; return NoClipSlices; } private void UseIndoorMembershipOnlyRouting() { // T1: NOTHING in the world passes hard-clips geometry anymore — retail // viewcone-CHECKS meshes (sphere vs view planes, T3) and never clips // cell shells (DrawEnvCell draws the whole prebuilt mesh, pc:427905). // This clears any clip routing left by the landscape slices. _envCells.SetClipRouting(null); _entities.ClearClipRouting(); } private void DrawEntityBucket( IRetailPViewCellDrawContext ctx, IReadOnlyList bucket, HashSet? visibleCellIds) { uint lbId = ctx.PlayerLandblockId ?? 0u; var entry = (lbId, Vector3.Zero, Vector3.Zero, (IReadOnlyList)bucket, (IReadOnlyDictionary?)null); _entities.Draw( ctx.Camera, new[] { entry }, ctx.Frustum, neverCullLandblockId: ctx.PlayerLandblockId, visibleCellIds: visibleCellIds, animatedEntityIds: ctx.AnimatedEntityIds); } private void PrepareClipFrame( Action setTerrainClipUbo, int terrainUploadCount) { // Allocate every terrain record before issuing the first draw. BufferData // therefore never replaces the arena's backing store while an earlier // slice can still reference it. The large region table is immutable for // this assembled PView frame and is uploaded exactly once. _clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount); _clipFrame.UploadRegions(_gl); _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); _envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); UploadTerrainClip(setTerrainClipUbo); } private void UploadTerrainClip(Action setTerrainClipUbo) { TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl); setTerrainClipUbo(binding); } } public interface IRetailPViewCellDrawCallbacks { public Action? DrawExitPortalMasks { get; } public Action? DrawCellParticles { get; } /// #124: far-Z punch one look-in aperture (a clipped view polygon /// of a looked-into building cell) — always the PUNCH variant regardless /// of root kind (retail maxZ1; the root-keyed forceFarZ selector only /// governs the MAIN frame's exit-portal masks). public Action? DrawLookInPortalPunch { get; } } public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks { public ICamera Camera { get; } public FrustumPlanes? Frustum { get; } public uint? PlayerLandblockId { get; } public HashSet? AnimatedEntityIds { get; } /// #121: draw the Scene-pass emitters attached to the frame's /// cone-surviving dynamics (portal swirls, creature effects). Invoked once /// per frame after the last entity pass with the survivor list. public Action>? DrawDynamicsParticles { get; } } /// /// Capacity policy for renderer-owned, one-frame scratch. Normal warmed frames /// retain their storage; a pathological visibility spike is released at the /// next frame boundary instead of becoming permanent process memory. /// internal sealed class RetailPViewScratchRetention { internal const int MaxRetainedLookInFrames = 32; internal const int MaxRetainedCellItems = 512; internal const int CapacityTrimIdleFrames = 120; private int _lookInFramesUnderusedFrames; private int _lookInPrepareUnderusedFrames; private int _drawableCellsUnderusedFrames; private int _shellBatchUnderusedFrames; private int _orderedTransparentUnderusedFrames; internal void ClearFrameBuffers( List lookInFrames, HashSet lookInPrepare, HashSet drawableCells, HashSet shellBatch, List orderedTransparentShellCells) { ClearCold( lookInFrames, MaxRetainedLookInFrames, ref _lookInFramesUnderusedFrames); ClearCold( lookInPrepare, MaxRetainedCellItems, ref _lookInPrepareUnderusedFrames); ClearCold( drawableCells, MaxRetainedCellItems, ref _drawableCellsUnderusedFrames); ClearCold(shellBatch, MaxRetainedCellItems, ref _shellBatchUnderusedFrames); ClearCold( orderedTransparentShellCells, MaxRetainedCellItems, ref _orderedTransparentUnderusedFrames); } private static void ClearCold( List values, int maximumRetainedCapacity, ref int underusedFrames) { int usedCount = values.Count; int capacity = values.Capacity; values.Clear(); if (!ShouldTrim( capacity, usedCount, maximumRetainedCapacity, ref underusedFrames)) return; if (capacity > maximumRetainedCapacity) values.Capacity = 0; } private static void ClearCold( HashSet values, int maximumRetainedCapacity, ref int underusedFrames) { int usedCount = values.Count; int capacity = values.EnsureCapacity(0); values.Clear(); if (!ShouldTrim( capacity, usedCount, maximumRetainedCapacity, ref underusedFrames)) return; if (capacity > maximumRetainedCapacity) values.TrimExcess(); } private static bool ShouldTrim( int capacity, int usedCount, int maximumRetainedCapacity, ref int underusedFrames) { if (capacity <= maximumRetainedCapacity || (long)usedCount * 2L > capacity) { underusedFrames = 0; return false; } underusedFrames++; if (underusedFrames < CapacityTrimIdleFrames) return false; underusedFrames = 0; return true; } } /// /// Frame-scoped grouping for retail's per-building portal floods. Active keys /// are rebuilt in nearby-cell encounter order every frame; the value lists are /// retained through a bounded pool so travelling through the world cannot turn /// every historical building id into permanent memory or per-frame scan work. /// internal sealed class BuildingGroupScratch { internal const int MaxRetainedGroups = 256; internal const int MaxRetainedCellsPerGroup = 256; private readonly Dictionary> _active = new(); private readonly Stack> _listPool = new(); internal Dictionary>.ValueCollection Values => _active.Values; internal IReadOnlyDictionary> Groups => _active; internal int ActiveGroupCount => _active.Count; internal int RetainedListCount => _listPool.Count; internal int MapCapacity => _active.EnsureCapacity(0); internal void Rebuild(IReadOnlyList nearbyCells) { ArgumentNullException.ThrowIfNull(nearbyCells); Reset(); for (int i = 0; i < nearbyCells.Count; i++) { LoadedCell cell = nearbyCells[i]; // R-A2 seam behavior: an unstamped cell still gets a singleton // entrance flood keyed by CellId. uint groupKey = cell.BuildingId ?? cell.CellId; if (!_active.TryGetValue(groupKey, out List? group)) { group = _listPool.Count != 0 ? _listPool.Pop() : new List(); _active.Add(groupKey, group); } group.Add(cell); } } internal void Reset() { foreach (List group in _active.Values) { group.Clear(); if (group.Capacity <= MaxRetainedCellsPerGroup && _listPool.Count < MaxRetainedGroups) { _listPool.Push(group); } } _active.Clear(); if (_active.EnsureCapacity(0) > MaxRetainedGroups) _active.TrimExcess(); } } public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext { public required LoadedCell RootCell { get; init; } /// R-A2: nearby building cells (BuildingId-tagged) flooded per-building when the root is the /// outdoor node. Null for interior roots. Grouped by BuildingId inside . public IReadOnlyList? NearbyBuildingCells { get; init; } public required Vector3 ViewerEyePos { get; init; } public required Matrix4x4 ViewProjection { get; init; } public required Func CellLookup { get; init; } public required ICamera Camera { get; init; } public required Vector3 CameraWorldPosition { get; init; } public required FrustumPlanes? Frustum { get; init; } public required uint? PlayerLandblockId { get; init; } public required HashSet? AnimatedEntityIds { get; init; } public required int RenderCenterLbX { get; init; } public required int RenderCenterLbY { get; init; } public required int RenderRadius { get; init; } public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; } public required Action SetTerrainClipUbo { get; init; } public required Action DrawLandscapeSlice { get; init; } /// #131/#132: the LATE landscape phase, per slice, after the #124 /// look-ins — outside-stage dynamics' meshes + all scene particles + /// weather (the FlushAlphaList deferral; see DrawLandscapeThroughOutsideView). public Action? DrawLandscapeSliceLate { get; init; } /// T1: one full-buffer depth clear between the outside stage and the /// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor /// roots — outdoors the interiors must depth-test against terrain + exteriors and /// appear only through punched apertures. public Action? ClearDepthForInterior { get; init; } public Action? DrawExitPortalMasks { get; init; } public Action? DrawCellParticles { get; init; } public Action? DrawLookInPortalPunch { get; init; } /// #131: Scene-pass draw of UNATTACHED emitters /// (AttachedObjectId == 0) for interior-root frames — invoked once at the /// end of the landscape stage (pre-clear). Outdoor roots draw them via /// GameWindow's dedicated post-frame pass instead. public Action? DrawUnattachedSceneParticles { get; init; } /// Drains retail's delayed landscape alpha list before the /// optional outside-to-inside depth clear. The same frame-scoped queue /// remains open for the final world alpha scope. public Action? FlushLandscapeAlpha { get; init; } public Action>? DrawDynamicsParticles { get; init; } /// /// Synchronous observation of the borrowed current-frame result. The /// callback must copy anything it needs to retain beyond this invocation. /// public Action? EmitDiagnostics { get; init; } } /// /// Borrowed renderer scratch valid only until the next /// call. Collections and nested /// frame objects are deliberately reused to keep the render loop allocation /// free; consumers must copy any state they need to retain asynchronously. /// public sealed class RetailPViewFrameResult { public PortalVisibilityFrame PortalFrame { get; private set; } = null!; public ClipFrameAssembly ClipAssembly { get; private set; } = null!; public HashSet DrawableCells { get; private set; } = null!; public InteriorEntityPartition.Result Partition { get; private set; } = null!; internal RetailPViewFrameResult Reset( PortalVisibilityFrame portalFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, InteriorEntityPartition.Result partition) { PortalFrame = portalFrame; ClipAssembly = clipAssembly; DrawableCells = drawableCells; Partition = partition; return this; } } public readonly record struct RetailPViewLandscapeSliceContext( ClipViewSlice Slice, IReadOnlyList OutdoorEntities); /// #131/#132: the late landscape phase's per-slice payload — /// outside-stage dynamics to mesh-draw, plus the full scene-particle owner /// set (statics + dynamics cone survivors) the attached-emitter filter keys on. public readonly record struct RetailPViewLandscapeLateSliceContext( ClipViewSlice Slice, IReadOnlyList Dynamics, IReadOnlyList ParticleOwners); public readonly record struct RetailPViewCellSliceContext( uint CellId, ClipViewSlice Slice, IReadOnlyList CellEntities);