using System; using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering.Scene; using AcDream.Core.World; 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 InteriorEntityPartition.IObserver? _partitionObserver; private readonly ICurrentRenderPViewObserver? _candidateObserver; private readonly RenderScenePViewFrameProductController? _sceneFrameProduct; 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 static readonly IReadOnlySet NoParticleOwners = new HashSet(); // Frame unions for the once-per-frame particle submissions (retail: one // unclipped alpha-list insertion per emitter; occlusion by depth at the // flush). Per-slice owner culls still run — these accumulate their union. private readonly HashSet _staticParticleUnionScratch = new(); private readonly HashSet _cellParticleUnionScratch = new(); // Every cell drawn as a building look-in this frame. Retail marks each // drawn non-player part for the frame (DrawMeshInternal @0x0059F360, // GetDrawnThisFrame), so an object whose cell drew with a look-in cannot // draw again in a later pass; dynamics-last consults this set to honor // the same drawn-once contract. private readonly HashSet _lookInCellIds = new(); 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: landscape scene-particle owner survivors. With building // look-ins, static owners use the pre-building alpha barrier and the late // phase contains only outside-stage dynamics; otherwise the late phase // carries both sets. private readonly HashSet _lateParticleOwnerScratch = new(); private readonly HashSet _cellParticleOwnerScratch = new(); private readonly HashSet _dynamicParticleOwnerScratch = new(); // FW4 slice 4: owners whose particles ALREADY submitted in the // pre-clear outside stage this frame (interior roots only). The last // pass subtracts them — retail submits an emitter's polys at its // object's FIRST draw (the landscape stage for anything overlapping // outdoor shadow cells) and drains them at the pre-clear boundary // flush over true landscape depth; a straddler re-emitted post-clear // has no outdoor depth left to occlude it (the cathedral falls // shine-through, fourth surface). private readonly HashSet _preClearParticleOwnerScratch = new(); // FW4 slice 5: interior flood-cell STATICS whose bounds straddle their // cell's exit-portal plane (the cathedral falls — EnvCell stab-list // objects hanging out over the lake). Retail draws such an object // during LScape::draw via the outdoor shadow-cell lists (DrawBlock // pc:430056-430064, once per overlapped shadow cell), so its particles // submit in the landscape scope and drain at the pre-clear boundary // flush over true terrain depth; the cell's own post-clear turn skips // them (drawn-once). Computed after Collect from the walk's flood; // submitted by the pre-clear closure; subtracted from the post-replay // cell-owners union. private readonly HashSet _preClearCellStaticOwnerScratch = 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. // Slice G4: this is now a diagnostic/fallback oracle only. Normal // production consumes the retained RenderFrameView routes directly. 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(); public RetailPViewRenderer() { } // FW3 visual-gate fix: the interior root's dynamics phase, invoked by // the driver's clearInteriorDepth closure at the walk's pre-clear // boundary (retail draws outside objects inside LScape::draw, before // the clear+seals). Assigned per frame around the Replay, always // cleared in finally. private Action? _walkPreClearDynamics; // ACDREAM_PROBE_WALK_ROOT (FW3 visual-gate apparatus, throwaway): the // previous frame's root kind + a post-flip frame countdown so each // interior/outdoor transition dumps 8 frames of rooting facts. private bool? _probeWalkRootPrevOutdoor; private int _probeWalkRootFramesLeft; private ulong _probeWalkRootFrame; // Campaign FW3.2b-2: the walk's production world-data registries // (published/retired by LandblockRenderPublisher) plus the per-frame // driver state. Null until the composition passes them; the static // cutover requires all three. private readonly Walk.WalkBuildingRegistry? _walkBuildings; private readonly Walk.WalkLandscapeAssembler? _walkLandscape; private readonly CellVisibility? _walkCellRegistry; // Campaign FW3.2b-2: the production IWalkFrameWorldData over the retained // scene — owned here (not just inside the driver) because DrawInside also // reads it directly to re-source particle owners for the routes the walk // now draws (plan §FW3 item 4). Non-null exactly when _walkBuildings is. private readonly Walk.WalkProductionWorldData? _walkWorldData; internal RetailPViewRenderer( InteriorEntityPartition.IObserver? partitionObserver, RenderScenePViewFrameProductController? sceneFrameProduct = null, Walk.WalkBuildingRegistry? walkBuildings = null, Walk.WalkLandscapeAssembler? walkLandscape = null, CellVisibility? walkCellRegistry = null) { _walkBuildings = walkBuildings; _walkLandscape = walkLandscape; _walkCellRegistry = walkCellRegistry; _walkWorldData = walkBuildings is not null ? new Walk.WalkProductionWorldData(walkBuildings) : null; _partitionObserver = partitionObserver; _candidateObserver = partitionObserver as ICurrentRenderPViewObserver; _sceneFrameProduct = sceneFrameProduct; } // 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 RetailPViewFrameResult DrawInside( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes) { ArgumentNullException.ThrowIfNull(ctx); ArgumentNullException.ThrowIfNull(passes); passes.BeginFrame(); RecycleLookInFrames(); ResetBuildingGroups(); _preClearParticleOwnerScratch.Clear(); var pvFrame = PortalVisibilityBuilder.Build( ctx.RootCell, ctx.ViewerEyePos, ctx.Cells.Find, ctx.ViewProjection, buildingMembership: null, 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 = passes.AssembleClipFrame( pvFrame, _clipAssemblyScratch); passes.AppendLookInClipFrames(_lookInFrames, clipAssembly); // FW4 slice 1: PrepareClipFrame (the one clip-region publication) // moved BELOW the walk block — an interior-rooted walk frame // re-derives the outside-view slices from the walk's own views // first, so the appended slots join the same single publication. // 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; passes.UseIndoorMembershipOnlyRouting(); // Campaign FW3.2b-2: the production rooting. A concrete executor is // required — the walk submits through WbDrawDispatcher.SubmitOrderedStream // and needs a real GPU frame/encoder (RequireWalkSubmission), which no // test IRetailPViewPassExecutor fake can supply. Whenever a concrete // executor IS present, the packed entity-route product must ALSO be // wired with all three walk registries — a scene product without the // walk data (or vice versa) is a production miswiring, not a // legacy/diagnostic shape, so it fails loud rather than silently // falling back to the retired static routes (plan §FW3 item 6). RetailPViewPassExecutor? walkExecutor = passes as RetailPViewPassExecutor; bool walkRegistriesReady = _walkBuildings is not null && _walkLandscape is not null && _walkCellRegistry is not null && _walkWorldData is not null; if (walkExecutor is not null && _sceneFrameProduct is not null && !walkRegistriesReady) { throw new InvalidOperationException( "RetailPViewRenderer has a concrete pass executor and a " + "RenderScenePViewFrameProductController but the walk registries " + "(WalkBuildingRegistry/WalkLandscapeAssembler/CellVisibility) are " + "not all wired — the Campaign FW3.2b-2 static cutover requires " + "every piece together; see FrameRootComposition's " + "RetailPViewRenderer construction."); } bool walkActive = walkExecutor is not null && _sceneFrameProduct is not null && walkRegistriesReady; // Campaign FW3.4a: THE ONE WALK. Builds walkContext/walkLandscape/ // walkCameraCell exactly as the pre-FW3.4a pre-walk collection pass // did, then drives WalkFrameDriver.Collect — a SINGLE RetailFrameWalk // pass that both learns the flood/visited-cell set (needed below, // BEFORE prepareCells is finalized, so EnvCellRenderer prepares // batches for every shell the driver will draw later in this same // DrawInside call) and records the walk's draw events for Replay // further down, in DrawWalkDrivenStatics. The former SECOND walk pass // (a dedicated set-collecting sink, run again through this same // driver machinery just to submit) is gone — see WalkFrameDriver's // own doc comment for the FW3.4 perf numbers that motivated this. // _walkWorldData.BeginFrame precedes Collect deliberately: Collect's // stream appends classify records immediately (WalkStaticStreamPopulator // runs at append time, not at Replay time), so the world data must // already be rebuilt for this frame before the walk starts. Walk.WalkFrameDriver? walkDriver = null; if (walkActive) { Matrix4x4 view = ctx.CameraView; var forward = Vector3.Normalize(new Vector3(-view.M13, -view.M23, -view.M33)); (int Width, int Height)? attachment = walkExecutor!.WalkAttachmentExtent; // Fallback matches the FW3.2b-2 shadow probe's own comment: every // screen projection shares the same constants, so this is only // reached before the world pass has published its scope. float viewportWidth = attachment?.Width ?? 1024f; float viewportHeight = attachment?.Height ?? 720f; var walkContext = new Walk.WalkProductionFrameContext( _walkCellRegistry!, _walkBuildings!, ctx.ViewerEyePos, forward, ctx.ViewProjection, viewportWidth, viewportHeight); _walkLandscape!.SetViewer(ctx.ViewerCellId, ctx.ViewerEyePos); Walk.WalkLandscape walkLandscape = _walkLandscape.Landscape; Walk.WalkCell? walkCameraCell = null; if ((ctx.ViewerCellId & 0xFFFFu) >= 0x100) { walkCameraCell = _walkCellRegistry!.TryGetCell(ctx.ViewerCellId, out LoadedCell? loaded) ? loaded?.Walk : null; if (walkCameraCell is null) { throw new InvalidOperationException( $"walk root=0x{ctx.ViewerCellId:X8}: the interior camera cell has " + "no committed walk data — the Campaign FW3.2b-2 static cutover " + "requires the walk registry to already hold the viewer's own cell " + "(fail-loud rule; a silently skipped root would leave the frame " + "with no static draws at all)."); } } if (ctx.RootCell.IsOutdoorNode && clipAssembly.OutsideViewSlices.Length != 1) { throw new InvalidOperationException( "walk static cutover: an outdoor root's clip assembly produced " + $"{clipAssembly.OutsideViewSlices.Length} outside-view slices, not the " + "expected 1 — the outdoor root draws through the full-screen default " + "view (retail set_default_view; the walk fans exactly its own 1), and " + "the outdoor slice data still comes from the assembler (plan §FW3 item " + "2c's pinned assumption; assert rather than silently coercing to 1)."); } _walkWorldData!.BeginFrame( _sceneFrameProduct!.SceneQuery, ctx.PlayerLandblockId ?? 0u, ctx.RenderCenterLbX, ctx.RenderCenterLbY); Action clearInteriorDepth = () => { // FW3 visual-gate fix (owner report: doors/candles invisible // looking out; the crossing vanish): retail draws the // OUTSIDE world's objects INSIDE LScape::draw — strictly // BEFORE the depth clear + seals (the #118 house-exit // clip+vanish lesson: anything drawn after the seals z-fails // against their true-depth stamp the moment it stands beyond // the door plane). The surviving dynamic routes + outdoor // particles + weather therefore run HERE, at the walk's // pre-clear boundary, for an interior root. _walkPreClearDynamics?.Invoke(); // Retail PView::DrawCells 0x005A4872 drains the landscape // alpha list immediately before the gated full depth clear — // mirrors DrawLandscapeThroughOutsideView's own pre-clear // drain. passes.FlushLandscapeAlpha(); passes.ClearInteriorDepth(); }; // FW4 slice 2: the seals stamp the WALK'S OWN flood cells (see // DrawWalkExitPortalMasks). walkDriver is assigned below, before // any Replay can fire this closure. Action drawExitSeals = () => DrawWalkExitPortalMasks(ctx, passes, clipAssembly, walkDriver!); var leafRenderer = new WalkProductionLeafRenderer( walkExecutor!, ctx, clipAssembly, clearInteriorDepth, drawExitSeals); walkDriver = new Walk.WalkFrameDriver(walkExecutor!.Dispatcher, leafRenderer, _walkWorldData); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled) { AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame = _probeWalkRootFrame % 90 == 0; } walkDriver.Collect( _frameWalk, ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext, ctx.ViewProjection, ctx.CameraWorldPosition); AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame = false; // FW4 slice 1: an interior root's terrain/sky/punch clip slices // come from THE WALK'S OWN outside_view — retail's one // visibility structure. See ClipFrameAssembler. // ReassembleOutsideViewFromWalk's doc comment for the boundary- // frame desync (the stairwell/grass flash) this retires. The // outdoor root keeps the assembler's single full-screen slice // (asserted ==1 above; identical content by construction). if (walkCameraCell is not null) { ClipFrameAssembler.ReassembleOutsideViewFromWalk( clipAssembly, _frameWalk.InteriorOutsideView, viewportWidth, viewportHeight); } if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled) { _probeWalkRootFrame++; bool outdoorNow = ctx.RootCell.IsOutdoorNode; if (_probeWalkRootPrevOutdoor is bool prev && prev != outdoorNow) { _probeWalkRootFramesLeft = 8; Console.WriteLine( $"[walk-root] ---- FLIP {(prev ? "OUT->IN" : "IN->OUT")} at frame {_probeWalkRootFrame} ----"); } _probeWalkRootPrevOutdoor = outdoorNow; if (_probeWalkRootFramesLeft > 0) { _probeWalkRootFramesLeft--; var cellsList = new List(walkDriver.VisitedCells); cellsList.Sort(); string cells = string.Join(",", cellsList.ConvertAll(c => c.ToString("x8")) .GetRange(0, Math.Min(cellsList.Count, 10))); Console.WriteLine( $"[walk-root] f={_probeWalkRootFrame} out={(outdoorNow ? 1 : 0)} " + $"viewer=0x{ctx.ViewerCellId:X8} root=0x{ctx.RootCell.CellId:X8} " + $"res={ctx.CameraCellResolution} slices={clipAssembly.OutsideViewSlices.Length} " + $"eye=({ctx.ViewerEyePos.X:F2},{ctx.ViewerEyePos.Y:F2},{ctx.ViewerEyePos.Z:F2}) " + $"walkCells={walkDriver.VisitedCells.Count} bld={walkDriver.VisitedBuildings.Count} " + $"cells=[{cells}]"); } } } // FW4 slice 1: the ONE clip-region publication, after any walk // reassembly so the walk-derived outside-view slots are included // (moved from directly after AssembleClipFrame; the count is // reservation metadata the RHI arm ignores). int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2); passes.PrepareClipFrame(terrainUploadCount); // #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; _lookInCellIds.Clear(); if (_lookInFrames.Count > 0 || walkActive) { _lookInPrepareScratch.Clear(); _lookInPrepareScratch.UnionWith(drawableCells); foreach (var f in _lookInFrames) { foreach (uint c in f.OrderedVisibleCells) { _lookInPrepareScratch.Add(c); _lookInCellIds.Add(c); } } if (walkActive) { // The walk's own flood/look-in cell set — unioned in (never // aliased with drawableCells, which the outside-stage and seal // predicates below still need scoped to the OLD flood only). _lookInPrepareScratch.UnionWith(walkDriver!.VisitedCells); } 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.) passes.PrepareCellBatches(ctx, prepareCells); // 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); IRenderFrameEntityPassExecutor? frameEntityPasses = null; if (_sceneFrameProduct is not null) { frameEntityPasses = passes as IRenderFrameEntityPassExecutor ?? throw new InvalidOperationException( "The production frame product requires a packed entity-pass executor."); } RenderFrameView frameView = default; bool frameViewBorrowed = false; bool entityFrameOpen = false; _candidateObserver?.BeginPViewFrame(); try { // FW4 slice 3: the outside-stage predicate's flood-membership set // is THE WALK'S visited cells on walk frames (root flood + // look-in floods — retail draws all of their objects inside // LScape::draw, pre-clear). The old apparatus's drawableCells // misses cells at the #456 seam band, dropping interior-parented // outdoor emitters (the cathedral falls weenies) to the // post-clear last pass, where the cleared depth lets them splat // across terrain and water. drawableCells keeps its other roles // (prepare filter, frame result) unchanged. HashSet outsideStageFlood = walkActive ? walkDriver!.VisitedCells : drawableCells; if (_sceneFrameProduct is not null) { frameView = _sceneFrameProduct.BuildAndBorrow( pvFrame, clipAssembly, viewcone, _lookInFrames, outsideStageFlood, ctx.Cells, ctx.AnimatedEntityIds, ctx.RootCell.IsOutdoorNode); frameViewBorrowed = true; frameEntityPasses!.BeginEntityFrame(in frameView); entityFrameOpen = true; } // The retained scene product is the production object source. // Rebuild the former WorldEntity partition only for the standalone // fallback and explicitly enabled comparison/probe paths. InteriorEntityPartition.Result? partition = null; if (_sceneFrameProduct is null || LegacyPartitionDiagnosticsEnabled) { InteriorEntityPartition.Partition( _partitionResult, prepareCells, ctx.LandblockEntries, _partitionObserver, ctx.Frustum, ctx.PlayerLandblockId ?? 0u); partition = _partitionResult; } RenderFrameDiagnosticCounts counts = frameViewBorrowed ? frameView.DiagnosticCounts : LegacyDiagnosticCounts(partition!); RenderProjectionCounts sourceCounts = frameViewBorrowed ? frameView.SourceDigest.Counts : LegacySourceCounts(partition!); // prepareCells is exactly "main flood ∪ look-in cells" — the cells // this traversal actually reached, i.e. retail's in-view set. RetailPViewFrameResult result = _frameResultScratch.Reset( pvFrame, clipAssembly, drawableCells, prepareCells, counts, sourceCounts, partition); passes.EmitDiagnostics(ctx, result); // Campaign FW3.2b-2 flip apparatus (ACDREAM_PROBE_WALK_SHADOW=1, // throwaway — dies with the flip commit): run the PRODUCTION // retail frame walk in shadow and report set divergence vs this // frame's old-path visibility. No draws change. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkShadowEnabled) RunWalkShadowProbe(ctx, prepareCells); // #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 (partition is not null && !ctx.RootCell.IsOutdoorNode) { foreach (var e in partition.Dynamics) { EntitySphere(e, out var c, out float r); // FW4 slice 3: same walk-flood membership set as the // frame product's BuildOutsideDynamicRoutes — the two // predicate call sites must agree or routed and drawn // stages diverge. if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, outsideStageFlood, ctx.Cells)) _outsideStageDynamics.Add(e); } } // FW4 slice 5: classify the interior flood cells' STATICS by the // exit-plane straddle test — a straddler's particles (the // cathedral falls: EnvCell stab objects hanging over the lake) // submit pre-clear like retail's outdoor shadow-cell draw; see // _preClearCellStaticOwnerScratch's field comment. GetCellStatics // is per-frame cached, so this also pre-warms the post-replay // cell-owners pass. _preClearCellStaticOwnerScratch.Clear(); if (walkActive && !ctx.RootCell.IsOutdoorNode) { foreach (uint cellId in walkDriver!.VisitedCells) { if ((cellId & 0xFFFFu) < 0x100u || _lookInCellIds.Contains(cellId)) continue; LoadedCell? staticCell = ctx.Cells.Find(cellId); if (staticCell is null) continue; Walk.WalkFrameStaticRecords cellRecords = _walkWorldData!.GetCellStatics(cellId); foreach (RenderProjectionRecord record in cellRecords.Records) { if (record.Source.LocalEntityId == 0) continue; Vector3 c = (record.Bounds.Minimum + record.Bounds.Maximum) * 0.5f; float r = (record.Bounds.Maximum - record.Bounds.Minimum).Length() * 0.5f; if (SphereStraddlesExitPlane(staticCell, c, r)) _preClearCellStaticOwnerScratch.Add(record.Source.LocalEntityId); } } } if (walkActive) { // Campaign FW3.2b-2: the walk owns every static draw — // terrain/sky, building shells + punches + look-in cell // statics, and the interior root's own flood shells + // statics (with the depth clear + exit seals at their real // retail turn). The OLD visibility (pvFrame/clipAssembly/ // viewcone, already built above) keeps running unchanged to // feed the surviving dynamic routes only (plan §FW3 item 1's // dual-compute split). Campaign FW3.4a: the walk itself // already ran (Collect, above) — this is Replay only. // // FW3 visual-gate fix: for an INTERIOR root the dynamics // phase (outside dynamics + look-in dynamics + outdoor // particles + weather) must draw at the walk's PRE-CLEAR // boundary — retail draws them inside LScape::draw, before // the clear+seals — so the driver's clearInteriorDepth // closure invokes it via _walkPreClearDynamics. For an // OUTDOOR root there is no clear (retail has none) and the // phase runs after the driver, matching the old order. RenderFrameView capturedView = frameView; IRenderFrameEntityPassExecutor? capturedPasses = frameEntityPasses; InteriorEntityPartition.Result? capturedPartition = partition; ViewconeCuller capturedViewcone = viewcone; Walk.WalkFrameDriver capturedDriver = walkDriver!; _walkPreClearDynamics = () => { // Retail inserts static-owner emitters (candle flames) // during their cells' landscape walk turns — submit // BEFORE the pre-clear drain so they composite against // still-true landscape depth, never the seals. SubmitWalkLandscapeStaticParticles(ctx, walkExecutor!, capturedDriver); // FW4 slice 5: straddling flood-cell statics' emitters // (the falls) — retail's shadow-cell landscape draw. if (_preClearCellStaticOwnerScratch.Count > 0) { _preClearParticleOwnerScratch.UnionWith( _preClearCellStaticOwnerScratch); passes.DrawLandscapeStaticParticles( ctx, new RetailPViewLandscapeStaticParticleContext( _preClearCellStaticOwnerScratch)); } passes.UseIndoorMembershipOnlyRouting(); DrawLandscapeDynamicsPhase( ctx, passes, clipAssembly, capturedPartition, capturedViewcone, capturedPasses, in capturedView); }; try { DrawWalkDrivenStatics(ctx, walkExecutor!, walkDriver!); passes.UseIndoorMembershipOnlyRouting(); if (ctx.RootCell.IsOutdoorNode) { DrawLandscapeDynamicsPhase( ctx, passes, clipAssembly, partition, viewcone, frameEntityPasses, in frameView); } } finally { _walkPreClearDynamics = null; } } else { DrawLandscapeThroughOutsideView( ctx, passes, clipAssembly, partition, viewcone, frameEntityPasses, in frameView); passes.UseIndoorMembershipOnlyRouting(); // Retail DrawBuilding @0x0059F2A0 runs FlushAlphaList(0f) BEFORE // its portal-only far-Z pass. In retail's strict far→near walk // everything queued at that instant is FARTHER than the structure // being punched, so no already-drained poly can meet a punched // aperture's falsified depth, and everything drained later is // NEARER than the punched structure and legitimately composites in // front of it. The batched outdoor frame reproduces that invariant // here: drain the far prefix — every entry at or beyond the // nearest cell whose exit-portal mask is about to punch far-Z — // against still-true landscape depth. Without this, an exterior // waterfall beyond the cathedral drains after the punches and // z-passes across every aperture pixel whose true depth the punch // erased (#132 regression found at the 2026-08-29 cathedral gate). // Interior roots keep their pre-clear stage-boundary drain. if (ctx.RootCell.IsOutdoorNode) { passes.FlushLandscapeAlphaFartherThan( ExitPortalMaskBarrierDistance( pvFrame, drawableCells, ctx.Cells, ctx.CameraWorldPosition)); } DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells); DrawEnvCellShells(passes, pvFrame); DrawCellObjectLists( ctx, passes, pvFrame, clipAssembly, drawableCells, partition, viewcone, frameEntityPasses, in frameView); } DrawDynamicsLast( ctx, passes, partition, viewcone, ctx.RootCell.IsOutdoorNode, frameEntityPasses, in frameView); // OUTDOOR root: the LScape-boundary alpha drain deferred from the // landscape stage runs HERE, after punches, interior shells, cell // objects, and the dynamics pass — the frame's complete opaque // world. Retail's walk draws all of those before its boundary // flush (LScape::draw includes every cell's objects, // DrawSortCell 0x005A17C0), so this is the same one-list far→near // composite over finished depth; draining at the stage end instead // let every later opaque mesh overwrite the flames (#132). if (ctx.RootCell.IsOutdoorNode) passes.FlushLandscapeAlpha(); // Interior-cell UNATTACHED emitters (spell ground effects and // swirls anchored in EnvCells) draw in this final world scope — // the cells' walls and the seals already own the depth buffer, so // one unclipped submission matches retail's cell-walk insertion. // Outdoor-cell unattached emitters drew in the landscape stage. passes.DrawUnattachedSceneParticles(ctx, outdoorCells: false); if (entityFrameOpen) { frameEntityPasses!.CompleteEntityFrame(in frameView); entityFrameOpen = false; } _candidateObserver?.CompletePViewFrame(); _sceneFrameProduct?.CompleteProduction(in frameView); return result; } catch { if (entityFrameOpen) frameEntityPasses!.AbortEntityFrame(); _candidateObserver?.AbortPViewFrame(); throw; } finally { if (frameViewBorrowed) _sceneFrameProduct!.Release(in frameView); } } // 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(RetailPViewFrameInput 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.Cells.Find, 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(RetailPViewFrameInput 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.Cells.Find, ctx.ViewProjection, OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons, reuseFrame: frameScratch); LoadedCell sourceCell = group[0]; frame.SourceBuildingKey = sourceCell.BuildingId ?? sourceCell.CellId; frame.SourceBuildingLandblockId = sourceCell.CellId & 0xFFFF0000u; if (frame.OrderedVisibleCells.Count > 0) _lookInFrames.Add(frame); else ReturnLookInFrame(frame); } } /// /// Conservative barrier drain threshold for one look-in frame: the viewer /// distance to the frame's nearest anchor-cell ORIGIN. Cell origins sit /// inside the building, so this over-estimates the building's /// nearest-point distance and under-drains; anything conservatively /// retained still composites correctly at the later depth-tested drains. /// Retail needs no threshold — its far→near walk guarantees only farther /// content is queued when DrawBuilding flushes (@0x0059F2A0). Returns 0 /// (full drain, today's behavior) when no cell resolves. /// /// /// The pre-punch barrier threshold for : /// the nearest drawable cell whose exit-portal mask is about to write /// far-Z. Every queued alpha entry at or beyond it must drain first /// (retail DrawBuilding @0x0059F2A0's FlushAlphaList(0f) before the /// portal-only pass), because after the punch those entries would z-pass /// across aperture pixels whose true depth no longer exists. No punched /// cells → MaxValue → the partial drain retains everything. /// internal static float ExitPortalMaskBarrierDistance( PortalVisibilityFrame frame, HashSet drawableCells, IRetailPViewCellSource cells, Vector3 viewerPosition) { float best = float.PositiveInfinity; for (int i = 0; i < frame.OrderedVisibleCells.Count; i++) { uint cellId = frame.OrderedVisibleCells[i]; if (!drawableCells.Contains(cellId)) continue; LoadedCell? cell = cells.Find(cellId); if (cell is null) continue; float distance = Vector3.Distance( cell.WorldTransform.Translation, viewerPosition); if (distance < best) best = distance; } return float.IsFinite(best) ? best : float.MaxValue; } internal static float LookInBarrierDrainDistance( PortalVisibilityFrame frame, IRetailPViewCellSource cells, Vector3 viewerPosition) { float best = float.PositiveInfinity; for (int i = 0; i < frame.OrderedVisibleCells.Count; i++) { LoadedCell? cell = cells.Find(frame.OrderedVisibleCells[i]); if (cell is null) continue; float distance = Vector3.Distance( cell.WorldTransform.Translation, viewerPosition); if (distance < best) best = distance; } return float.IsFinite(best) ? best : 0f; } 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). Retail CEnvCell::setup_view installs every cell's // nested portal_view before DrawEnvCell, while DrawMesh iterates that same // PortalList for cell objects. Preserve that per-slice gate here; drawing a // nested cell whole lets its floor, details, and emitters escape the authored // aperture even when the outer depth choreography is otherwise correct. private void DrawBuildingLookIns( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result? partition, ViewconeCuller viewcone, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView) { if (_lookInFrames.Count == 0) return; int outsideSliceCount = clipAssembly.OutsideViewSlices.Length; int lookInRouteIndex = 0; for (int frameIndex = 0; frameIndex < _lookInFrames.Count; frameIndex++) { PortalVisibilityFrame frame = _lookInFrames[frameIndex]; // Retail enters DrawBuilding once per building and drains the // alpha accumulated by the preceding building before punching the // next building's portals — and because retail's far→near walk // has only inserted FARTHER content by then, that drain can never // composite an emitter nearer than this building // (FlushAlphaList(0f) @0x0059F2A0 under the walk; AP-236). // The first building uses the pre-look-in barrier in // DrawLandscapeThroughOutsideView. if (frameIndex > 0) { passes.FlushLandscapeAlphaFartherThan( LookInBarrierDrainDistance( frame, ctx.Cells, ctx.CameraWorldPosition)); } // Pass 1: far-Z punch every aperture of this building. foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals) { foreach (var poly in seed.View.Polygons) { var cps = ClipPlaneSet.From(poly); if (cps.IsNothingVisible) continue; passes.DrawLookInPortalPunch(ctx, new RetailPViewCellSliceContext( seed.CellId, new ClipViewSlice( 0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), cps.PlaneArray), NoParticleOwners), seed.PortalIndex); } } // Pass 2: shells + objects, far→near, once per portal_view slice. for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) { uint cellId = frame.OrderedVisibleCells[i]; var clipKey = new LookInClipCell(frameIndex, cellId); if (!clipAssembly.LookInCellToViewSlices.TryGetValue( clipKey, out ClipViewSlice[]? cellSlices) || cellSlices.Length == 0) { continue; } _cellStaticScratch.Clear(); if (partition is not null && 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. // 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). if (partition is not null) { foreach (var e in partition.Dynamics) if (e.ParentCellId == cellId) _cellStaticScratch.Add(e); } bool cellDrewObjects = false; _cellParticleUnionScratch.Clear(); foreach (ClipViewSlice slice in cellSlices) { int routeIndex = lookInRouteIndex++; passes.UseCellPortalViewRouting(cellId, slice); _oneCell.Clear(); _oneCell.Add(cellId); passes.DrawOpaqueCellShells(_oneCell); if (passes.CellHasTransparentShell(cellId)) passes.DrawTransparentCellShells(_oneCell); if (frameEntityPasses is not null) { RenderFrameRouteOwnerSelector.Replace( _cellParticleOwnerScratch, in frameView, RenderFrameCandidateRoute.LookInObject, routeIndex, cellId); } else { ReplaceOwnerIds( _cellParticleOwnerScratch, _cellStaticScratch); } if (frameEntityPasses is not null || _cellStaticScratch.Count > 0) { _candidateObserver?.ObservePViewBucket( CurrentRenderPViewRoute.LookInObject, routeIndex, cellId, _cellStaticScratch); DrawEntityRouteOrLegacy( ctx, passes, frameEntityPasses, in frameView, RenderFrameCandidateRoute.LookInObject, routeIndex, cellId, _cellStaticScratch, _oneCell); cellDrewObjects = true; _cellParticleUnionScratch.UnionWith( _cellParticleOwnerScratch); } } // The nested DrawCells object pass includes emitters: ONE // unclipped submission per look-in cell (retail draws a // particle during its cell's walk turn; the cell walls own // occlusion by depth at alpha playback — never a view clip). if (cellDrewObjects) { passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext( cellId, NoClipSlice, _cellParticleUnionScratch)); } } // The ordinary exterior building shell is clipped by the outer // outside_view, not by the nested cell PortalList. passes.UseIndoorMembershipOnlyRouting(); // Retail's ordinary shell pass immediately follows this same // building's portal-only pass. Pair by the shell's authored // anchor EnvCell; never let an unrelated building repaint a // look-in merely because both happen to be nearby. int sliceIndex = 0; _staticParticleUnionScratch.Clear(); foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices) { int shellRouteIndex = LookInBuildingShellRouteIndex( frameIndex, outsideSliceCount, sliceIndex); _buildingShellScratch.Clear(); if (partition is not null) { foreach (WorldEntity entity in partition.OutdoorStatic) { if (!entity.IsBuildingShell || FindLookInFrameIndex( entity.BuildingShellAnchorCellId ?? 0, _lookInFrames, ctx.Cells) != frameIndex) { continue; } EntitySphere(entity, out Vector3 center, out float radius); if (viewcone.SphereVisibleInOutsideSlice( sliceIndex, center, radius)) { _buildingShellScratch.Add(entity); } } } _candidateObserver?.ObservePViewBucket( CurrentRenderPViewRoute.LandscapeBuildingShell, shellRouteIndex, 0, _buildingShellScratch); bool hasPackedShell = frameEntityPasses is not null && HasExactRoute( in frameView, RenderFrameCandidateRoute.LandscapeBuildingShell, shellRouteIndex, 0); if (hasPackedShell || _buildingShellScratch.Count > 0) { RenderFrameEntityDrawRequest? shellDraw = frameEntityPasses is null ? null : new RenderFrameEntityDrawRequest( frameView, RenderFrameCandidateRoute.LandscapeBuildingShell, shellRouteIndex, 0, ctx.PlayerLandblockId ?? 0); passes.DrawLandscapeBuildingShellSlice( ctx, new RetailPViewLandscapeBuildingShellSliceContext( slice, _buildingShellScratch) { EntityDraw = shellDraw, }); _lateParticleOwnerScratch.Clear(); if (frameEntityPasses is not null) { RenderFrameRouteOwnerSelector.Replace( _lateParticleOwnerScratch, in frameView, RenderFrameCandidateRoute.LandscapeBuildingShell, shellRouteIndex, 0); } else { ReplaceOwnerIds( _lateParticleOwnerScratch, _buildingShellScratch); } _staticParticleUnionScratch.UnionWith( _lateParticleOwnerScratch); } sliceIndex++; } // ONE unclipped submission for this look-in frame's shell-route // owners (retail: one alpha-list insertion per emitter, // depth-occluded at the flush — never re-drawn per outside view). passes.DrawLandscapeStaticParticles( ctx, new RetailPViewLandscapeStaticParticleContext( _staticParticleUnionScratch)); _staticParticleUnionScratch.Clear(); } } /// Campaign FW3.2b-2 flip apparatus (the I5 dual-shadow /// pattern): drive the production walk over the FW3.1 registries with a /// set-collecting sink and print one [walk-shadow] line per frame /// whose visited cells diverge from the old path's /// (main flood ∪ look-ins). Divergence is /// EXPECTED where the walk's retail model deliberately differs from the /// old builder — the probe's value is proving the production data /// pipeline live and QUANTIFYING the difference for the flip review. A /// probe exception prints loudly and never kills the frame (it is the /// probe's own signal, not a production fault). private void RunWalkShadowProbe( RetailPViewFrameInput ctx, HashSet oldPathCells) { if (_walkBuildings is null || _walkLandscape is null || _walkCellRegistry is null) { return; } try { // Forward = -(view column 3): System.Numerics CreateLookAt's // zaxis is eye-target (backward). Viewport: the walk's SETS are // viewport-scale-tolerant (every screen projection shares the // same constants), so the shadow pins retail's capture size; the // flip itself will use the real attachment extent. Matrix4x4 view = ctx.CameraView; var forward = Vector3.Normalize(new Vector3(-view.M13, -view.M23, -view.M33)); var context = new Walk.WalkProductionFrameContext( _walkCellRegistry, _walkBuildings, ctx.ViewerEyePos, forward, ctx.ViewProjection, viewportWidth: 1024f, viewportHeight: 720f); Walk.WalkLandscape landscape = _walkLandscape.Landscape; _walkLandscape.SetViewer(ctx.ViewerCellId, ctx.ViewerEyePos); Walk.WalkCell? cameraCell = null; if ((ctx.ViewerCellId & 0xFFFFu) >= 0x100) { cameraCell = _walkCellRegistry.TryGetCell(ctx.ViewerCellId, out LoadedCell? loaded) ? loaded?.Walk : null; if (cameraCell is null) { Console.WriteLine( $"[walk-shadow] root={ctx.ViewerCellId:x8} interior camera cell has no walk data"); return; } } var sink = new WalkVisitedSetCollector(); _frameWalk.WalkFrame( ctx.ViewerCellId, cameraCell, landscape, context, sink); int onlyWalk = 0; foreach (uint id in sink.Cells) if (!oldPathCells.Contains(id)) onlyWalk++; int onlyOld = 0; foreach (uint id in oldPathCells) if (!sink.Cells.Contains(id)) onlyOld++; if (onlyWalk != 0 || onlyOld != 0) { Console.WriteLine( $"[walk-shadow] root={ctx.ViewerCellId:x8} walkCells={sink.Cells.Count} " + $"oldCells={oldPathCells.Count} onlyWalk={onlyWalk} onlyOld={onlyOld} " + $"walkBuildings={sink.BuildingCount} landscapeTurns={sink.LandscapeCount}"); } } catch (Exception failure) { Console.WriteLine($"[walk-shadow] PROBE FAULT root={ctx.ViewerCellId:x8}: {failure}"); } } // Campaign FW3.2b-2: the one RetailFrameWalk instance shared by the // diagnostic shadow probe and the real Collect run (WalkFrameDriver.Collect, // driven from DrawInside's walkActive block) — WalkFrame calls are never // concurrent/re-entrant within a single-threaded render loop, so one // shared instance is safe and avoids re-allocating the walk's own PView // scratch per call site. Campaign FW3.4a retired the THIRD role this // field used to serve (a dedicated pre-walk collection pass) — Collect // now gathers the same visited sets itself, on WalkFrameDriver, as a // side effect of the one walk it already runs. private readonly Walk.RetailFrameWalk _frameWalk = new(); /// Campaign FW3.2b-2 (the I5 dual-shadow pattern): an /// events-only that collects the SETS a /// driven run would touch, without doing any leaf drawing itself. Used /// ONLY by now — Campaign FW3.4a moved /// the production role (the walk's flood cell set for the /// prepareCells union, the visited building list and /// landscape-cell turn ids for particle re-sourcing) onto /// itself, which gathers the same sets /// as a side effect of the one walk /// already runs, instead of a second dedicated pass. private sealed class WalkVisitedSetCollector : Walk.IWalkEventSink { public readonly HashSet Cells = new(); public readonly List Buildings = new(); public readonly HashSet LandscapeCellIds = new(); public int BuildingCount => Buildings.Count; public int LandscapeCount => LandscapeCellIds.Count; public void Reset() { Cells.Clear(); Buildings.Clear(); LandscapeCellIds.Clear(); } public void Emit(in Walk.WalkEvent walkEvent) { switch (walkEvent.Kind) { case Walk.WalkEventKind.DrawInside: Cells.Add(walkEvent.CellId); break; case Walk.WalkEventKind.DrawCells: foreach (uint id in walkEvent.Cells) Cells.Add(id); break; } } public void OnLandscapeCellTurn(uint cellId) => LandscapeCellIds.Add(cellId); public void OnBuildingTurn(Walk.WalkBuilding building) => Buildings.Add(building); } /// Campaign FW3.2b-2 — THE PRODUCTION ROOTING; Campaign FW3.4a — /// REPLAY ONLY. already ran its Collect pass /// earlier in (before PrepareCellBatches); /// this method's job is now just /// (terrain/sky, every building's shell + punch + look-in cell statics, /// and the interior root's own flood shells + statics, with retail's /// depth-clear/exit-seal turn — all in walk order, replacing /// 's static half, /// 's old top-level call, /// , and 's /// static half for this frame) plus re-sourcing the particle owners the /// routes it replaced used to ride, from the driver's own visited /// sets (plan §FW3 "FW3.2b-2 — the production rooting", items 2 and /// 4). /// The landscape-stage static-owner particle submission: the /// union of every outdoor-static record from a landscape cell the walk /// visited this frame, plus every shell record from a building the walk /// visited (plan §FW3 item 4 — retail gates particles per cell turn, /// ShouldDrawParticles @0x0050FE60, so this is MORE /// retail-faithful than the old per-slice sphere filter it replaced). /// Interior roots invoke this at the walk's pre-clear boundary; outdoor /// roots post-replay. private void SubmitWalkLandscapeStaticParticles( RetailPViewFrameInput ctx, RetailPViewPassExecutor passes, Walk.WalkFrameDriver driver) { _staticParticleUnionScratch.Clear(); foreach (uint cellId in driver.VisitedLandscapeCellIds) UnionRecordOwners(_walkWorldData!.GetOutdoorStatics(cellId), _staticParticleUnionScratch); foreach (Walk.WalkBuilding building in driver.VisitedBuildings) UnionRecordOwners(_walkWorldData!.GetBuildingShellStatics(building), _staticParticleUnionScratch); if (_staticParticleUnionScratch.Count > 0) { passes.DrawLandscapeStaticParticles( ctx, new RetailPViewLandscapeStaticParticleContext(_staticParticleUnionScratch)); _staticParticleUnionScratch.Clear(); } } private void DrawWalkDrivenStatics( RetailPViewFrameInput ctx, RetailPViewPassExecutor passes, Walk.WalkFrameDriver driver) { var (frame, encoder) = passes.RequireWalkSubmission(); driver.Replay(frame, encoder); // Landscape-stage static-owner particles (candle flames on lamp and // candle statics): for an OUTDOOR root they submit here, post-replay // (drained at the frame-end flush over finished depth — the #132 // placement). For an INTERIOR root they must submit at the walk's // PRE-CLEAR boundary instead — retail inserts each one during its // cell's landscape walk turn, before the clear+seals; a post-seal // drain z-fails them at the aperture (the visual-gate "no candle // flames looking out" report) — so SubmitWalkLandscapeStaticParticles // is invoked from _walkPreClearDynamics in that case, and skipped // here. if (ctx.RootCell.IsOutdoorNode) SubmitWalkLandscapeStaticParticles(ctx, passes, driver); // Cell-stage particle owners for the interior root's OWN flood cells // (non-look-in — the walk draws their statics too, via // OnInteriorFloodDrawTurn/EmitCellTurn, so the retired CellStatic // route's cell-particle submission needs the same re-sourcing). // These stay POST-replay: interior emitters draw in the final world // scope where the cell walls already own depth (retail's cell-walk // insertion). Look-in cells get their OWN per-cell union in // DrawBuildingLookInDynamics so a static owner is never submitted // twice. _cellParticleOwnerScratch.Clear(); foreach (uint cellId in driver.VisitedCells) { if (_lookInCellIds.Contains(cellId)) continue; UnionRecordOwners(_walkWorldData!.GetCellStatics(cellId), _cellParticleOwnerScratch); } // FW4 slice 5: exit-plane straddlers (the falls) already emitted at // the pre-clear boundary — retail's shadow-cell landscape draw; // every owner emits exactly once. _cellParticleOwnerScratch.ExceptWith(_preClearCellStaticOwnerScratch); if (_cellParticleOwnerScratch.Count > 0) { passes.DrawCellParticles( ctx, new RetailPViewCellSliceContext(0u, NoClipSlice, _cellParticleOwnerScratch)); } } private static void UnionRecordOwners( Walk.WalkFrameStaticRecords records, HashSet destination) { foreach (RenderProjectionRecord record in records.Records) { if (record.Source.LocalEntityId != 0) destination.Add(record.Source.LocalEntityId); } } /// Campaign FW3.2b-2: the DYNAMICS-only remainder of the old /// + /// split — the walk now draws every STATIC route (see /// ); this method keeps ONLY what /// stays on the OLD visibility pipeline per the plan's dual-compute /// split: outdoor-cell unattached particles, LookInObject dynamics + their /// per-cell particles, the late per-slice outside-dynamics/weather loop, /// and the late particle union submission. private void DrawLandscapeDynamicsPhase( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result? partition, ViewconeCuller viewcone, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView) { if (clipAssembly.OutsideViewSlices.Length == 0) return; // Ownerless OUTDOOR-cell emitters — now unconditional: the old // hasBuildingLookIns gate only existed to sequence this submission // around the OLD static barrier drains, which no longer run here // (the walk owns its own alpha barriers — WalkFrameDriver.OnBuildingTurn). passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true); DrawBuildingLookInDynamics( ctx, passes, clipAssembly, partition, frameEntityPasses, in frameView); // LATE phase (per slice): outside-stage dynamics' meshes + weather — // unchanged from DrawLandscapeThroughOutsideView's own late loop. _staticParticleUnionScratch.Clear(); int probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { passes.SetTerrainClip(slice.Planes); passes.ClearClipRouting(); _outdoorStaticScratch.Clear(); _lateParticleOwnerScratch.Clear(); foreach (var e in _outsideStageDynamics) { EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) { _outdoorStaticScratch.Add(e); if (!InteriorEntityPartition.IsIndoorCellId(e.ParentCellId)) _lateParticleOwnerScratch.Add(e.Id); } } if (frameEntityPasses is not null) { RenderFrameRouteOwnerSelector.Union( _lateParticleOwnerScratch, in frameView, RenderFrameCandidateRoute.LandscapeOutsideDynamic, probeSliceIndex, 0); } _candidateObserver?.ObservePViewBucket( CurrentRenderPViewRoute.LandscapeOutsideDynamic, probeSliceIndex, 0, _outdoorStaticScratch); RenderFrameEntityDrawRequest? entityDraw = frameEntityPasses is null ? null : new RenderFrameEntityDrawRequest( frameView, RenderFrameCandidateRoute.LandscapeOutsideDynamic, probeSliceIndex, 0, ctx.PlayerLandblockId ?? 0); probeSliceIndex++; _staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch); passes.DrawLandscapeSliceLate( ctx, new RetailPViewLandscapeLateSliceContext(slice, _outdoorStaticScratch) { EntityDraw = entityDraw, }); } // Late-particle union submission. OUTDOOR root: DynamicLast owners // excluded (both stages drain at the same final flush there — a // duplicate submission would double-composite), matching // DrawLandscapeThroughOutsideView's own final submission. INTERIOR // root (FW4 slice 4): the outside-stage owners — exit-plane // STRADDLERS included — submit HERE, pre-clear, and the last pass // subtracts them instead: retail submits an emitter's polys at its // object's FIRST draw (the landscape stage) and drains them at the // pre-clear boundary flush over true landscape depth; the former // except-here/emit-last placement left a straddler's outside half // with no depth to occlude it after the clear (the cathedral falls // bleeding through terrain and water, probe-pinned: // outside=1 cone=1 yet phase=post). if (frameEntityPasses is not null && ctx.RootCell.IsOutdoorNode) { RenderFrameRouteOwnerSelector.ExceptRoute( _staticParticleUnionScratch, in frameView, RenderFrameCandidateRoute.DynamicLast); } if (!ctx.RootCell.IsOutdoorNode) _preClearParticleOwnerScratch.UnionWith(_staticParticleUnionScratch); if (_staticParticleUnionScratch.Count > 0) { passes.DrawLandscapeStaticParticles( ctx, new RetailPViewLandscapeStaticParticleContext(_staticParticleUnionScratch)); _staticParticleUnionScratch.Clear(); } passes.UseIndoorMembershipOnlyRouting(); } /// Campaign FW3.2b-2: the DYNAMICS-only remainder of the old /// — punches, shells, and look-in cell /// STATICS are now walk-owned ('s /// Building/BuildingShell/LookInStatic turns); this method keeps ONLY the /// LookInObject route (now dynamic-classified — see /// RenderScenePViewFrameBuilder.BuildLookInRoutes) and the per-cell /// particle union that route's owners feed, unioned with the walk's /// static owners for that SAME cell (plan §FW3 item 4 — GetCellStatics /// fills the gap the retired CellStatic-route particle submission left /// for look-in cells specifically). private void DrawBuildingLookInDynamics( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result? partition, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView) { if (_lookInFrames.Count == 0) return; int lookInRouteIndex = 0; for (int frameIndex = 0; frameIndex < _lookInFrames.Count; frameIndex++) { PortalVisibilityFrame frame = _lookInFrames[frameIndex]; for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) { uint cellId = frame.OrderedVisibleCells[i]; var clipKey = new LookInClipCell(frameIndex, cellId); if (!clipAssembly.LookInCellToViewSlices.TryGetValue( clipKey, out ClipViewSlice[]? cellSlices) || cellSlices.Length == 0) { continue; } _cellStaticScratch.Clear(); if (partition is not null) { foreach (var e in partition.Dynamics) if (e.ParentCellId == cellId) _cellStaticScratch.Add(e); } bool cellDrewObjects = false; _cellParticleUnionScratch.Clear(); foreach (ClipViewSlice slice in cellSlices) { int routeIndex = lookInRouteIndex++; passes.UseCellPortalViewRouting(cellId, slice); if (frameEntityPasses is not null) { RenderFrameRouteOwnerSelector.Replace( _cellParticleOwnerScratch, in frameView, RenderFrameCandidateRoute.LookInObject, routeIndex, cellId); } else { ReplaceOwnerIds( _cellParticleOwnerScratch, _cellStaticScratch); } if (frameEntityPasses is not null || _cellStaticScratch.Count > 0) { _candidateObserver?.ObservePViewBucket( CurrentRenderPViewRoute.LookInObject, routeIndex, cellId, _cellStaticScratch); _oneCell.Clear(); _oneCell.Add(cellId); DrawEntityRouteOrLegacy( ctx, passes, frameEntityPasses, in frameView, RenderFrameCandidateRoute.LookInObject, routeIndex, cellId, _cellStaticScratch, _oneCell); cellDrewObjects = true; _cellParticleUnionScratch.UnionWith( _cellParticleOwnerScratch); } } // The walk already drew this cell's STATIC content // (WalkFrameDriver's LookInStatic turn) but never submits // particles for it — GetCellStatics fills that gap, unioned // with the dynamic route's own owners so ONE // DrawCellParticles call covers both. if (_walkWorldData is not null) { Walk.WalkFrameStaticRecords statics = _walkWorldData.GetCellStatics(cellId); foreach (RenderProjectionRecord record in statics.Records) { if (record.Source.LocalEntityId != 0) { _cellParticleUnionScratch.Add(record.Source.LocalEntityId); cellDrewObjects = true; } } } if (cellDrewObjects) { passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext( cellId, NoClipSlice, _cellParticleUnionScratch)); } } passes.UseIndoorMembershipOnlyRouting(); } } private void DrawLandscapeThroughOutsideView( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result? partition, ViewconeCuller viewcone, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView) { if (clipAssembly.OutsideViewSlices.Length == 0) return; // #131/#132: retail drains the remaining landscape alpha after // LScape::draw (DrawCells pc:432720), while each DrawBuilding is also // an earlier alpha barrier before its portal traversal (pc:427954). // Our dispatcher batches outdoor content, so the stage is split into: // EARLY sky/terrain/static meshes; an optional pre-look-in static-alpha // barrier; building look-ins; then LATE outside-stage dynamics, // remaining particles, and weather; followed by the outer flush. int probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { passes.SetTerrainClip(slice.Planes); // 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. passes.ClearClipRouting(); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled) passes.EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex); _outdoorStaticScratch.Clear(); if (partition is not null) { foreach (var e in partition.OutdoorStatic) { if (e.IsBuildingShell && FindLookInFrameIndex( e.BuildingShellAnchorCellId ?? 0, _lookInFrames, ctx.Cells) >= 0) { continue; } EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInOutsideSlice( probeSliceIndex, c, r)) { _outdoorStaticScratch.Add(e); } } } _candidateObserver?.ObservePViewBucket( CurrentRenderPViewRoute.LandscapeOutdoorStatic, probeSliceIndex, 0, _outdoorStaticScratch); RenderFrameEntityDrawRequest? entityDraw = frameEntityPasses is null ? null : new RenderFrameEntityDrawRequest( frameView, RenderFrameCandidateRoute.LandscapeOutdoorStatic, probeSliceIndex, 0, ctx.PlayerLandblockId ?? 0); probeSliceIndex++; passes.DrawLandscapeSlice( ctx, new RetailPViewLandscapeSliceContext( slice, _outdoorStaticScratch) { EntityDraw = entityDraw, }); } // Retail DrawBuilding flushes every alpha submission accumulated before // the building immediately before its portal-only traversal // (RenderDeviceD3D::DrawBuilding pc:427954-427956). That barrier is // essential at open-air seams: foliage and static emitters encountered // before the building must not be flushed after the look-in cell floor // and repaint it. Our outdoor statics are one retained batch rather than // retail's BSP-by-building walk, so use one barrier before the first // look-in; DrawBuildingLookIns adds the corresponding barrier between // each later building pair. Submit the early static owners' particles // into the same alpha queue first; their mesh alpha was already // submitted by the EARLY entity route above. bool hasBuildingLookIns = _lookInFrames.Count > 0; if (hasBuildingLookIns) { // Ownerless OUTDOOR-cell emitters cannot ride an entity route. // Retail inserts each one into the single alpha list once, during // its cell's landscape walk turn, with no portal-view clip; the // interior-cell ownerless emitters submit in the final world // scope instead (see DrawDynamicsLast). passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true); _staticParticleUnionScratch.Clear(); int outsideSliceTotal = clipAssembly.OutsideViewSlices.Length; for (int barrierSliceIndex = 0; barrierSliceIndex < outsideSliceTotal; barrierSliceIndex++) { _lateParticleOwnerScratch.Clear(); if (partition is not null) { foreach (var e in partition.OutdoorStatic) { if (e.IsBuildingShell && FindLookInFrameIndex( e.BuildingShellAnchorCellId ?? 0, _lookInFrames, ctx.Cells) >= 0) { continue; } EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInOutsideSlice( barrierSliceIndex, c, r)) { _lateParticleOwnerScratch.Add(e.Id); } } } if (frameEntityPasses is not null) { RenderFrameRouteOwnerSelector.Replace( _lateParticleOwnerScratch, in frameView, RenderFrameCandidateRoute.LandscapeOutdoorStatic, barrierSliceIndex, 0); } _staticParticleUnionScratch.UnionWith( _lateParticleOwnerScratch); } // ONE unclipped submission for the union of every slice's cone // survivors, then retail's pre-building barrier drain. Under // retail's far→near walk, DrawBuilding's FlushAlphaList(0f) // @0x0059F2A0 can only ever flush content from cells FARTHER // than the building it precedes — a nearer emitter (the Holtburg // candle in front of a door) has not been inserted yet and // composites at a later flush, after that building's opaques. // Drain the far prefix only; nearer entries stay queued for the // DrawCells-boundary flush, which runs after the late dynamics // (AP-236 retirement). passes.DrawLandscapeStaticParticles( ctx, new RetailPViewLandscapeStaticParticleContext( _staticParticleUnionScratch)); _staticParticleUnionScratch.Clear(); passes.FlushLandscapeAlphaFartherThan( LookInBarrierDrainDistance( _lookInFrames[0], ctx.Cells, ctx.CameraWorldPosition)); } // #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 outer depth clear + seals below, matching // retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785). DrawBuildingLookIns( ctx, passes, clipAssembly, partition, viewcone, frameEntityPasses, in frameView); // 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) accumulate across the slices and submit // ONCE, unclipped, after the loop. _staticParticleUnionScratch.Clear(); probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { passes.SetTerrainClip(slice.Planes); passes.ClearClipRouting(); _outdoorStaticScratch.Clear(); // late: dynamics survivors _lateParticleOwnerScratch.Clear(); // late: dynamics, plus statics without look-ins if (!hasBuildingLookIns && partition is not null) { 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.Id); } } foreach (var e in _outsideStageDynamics) { EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) { _outdoorStaticScratch.Add(e); // Particles emit in the stage matching the PARENT CELL: // an INTERIOR dynamic whose sphere merely straddles an // exit-portal plane keeps its mesh in both stages (#118) // but its particles belong to the final pass — draining // them at the pre-clear boundary lets the interior stage // repaint over them except on seal-protected aperture // pixels (the cathedral middle-cell spell-star cut). if (!InteriorEntityPartition.IsIndoorCellId(e.ParentCellId)) _lateParticleOwnerScratch.Add(e.Id); } } if (frameEntityPasses is not null) { if (hasBuildingLookIns) { _lateParticleOwnerScratch.Clear(); } else { RenderFrameRouteOwnerSelector.Replace( _lateParticleOwnerScratch, in frameView, RenderFrameCandidateRoute.LandscapeOutdoorStatic, probeSliceIndex, 0); } RenderFrameRouteOwnerSelector.Union( _lateParticleOwnerScratch, in frameView, RenderFrameCandidateRoute.LandscapeOutsideDynamic, probeSliceIndex, 0); } _candidateObserver?.ObservePViewBucket( CurrentRenderPViewRoute.LandscapeOutsideDynamic, probeSliceIndex, 0, _outdoorStaticScratch); RenderFrameEntityDrawRequest? entityDraw = frameEntityPasses is null ? null : new RenderFrameEntityDrawRequest( frameView, RenderFrameCandidateRoute.LandscapeOutsideDynamic, probeSliceIndex, 0, ctx.PlayerLandblockId ?? 0); probeSliceIndex++; _staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch); passes.DrawLandscapeSliceLate( ctx, new RetailPViewLandscapeLateSliceContext( slice, _outdoorStaticScratch) { EntityDraw = entityDraw, }); } // ONE unclipped submission for every late-stage particle owner — // OUTDOOR-parented outside-stage dynamics' emitters plus, without // look-ins, the outdoor statics' emitters (retail: one alpha-list // insertion per emitter during the landscape walk; per-slice // re-submission with clip slots was the direction-dependent // disappearance class). Interior-parented straddlers appear in BOTH // the LandscapeOutsideDynamic and DynamicLast routes; their particles // emit only in the final pass, so remove them here. if (frameEntityPasses is not null) { RenderFrameRouteOwnerSelector.ExceptRoute( _staticParticleUnionScratch, in frameView, RenderFrameCandidateRoute.DynamicLast); } if (_staticParticleUnionScratch.Count > 0) { passes.DrawLandscapeStaticParticles( ctx, new RetailPViewLandscapeStaticParticleContext( _staticParticleUnionScratch)); _staticParticleUnionScratch.Clear(); } // #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. OUTDOOR-cell ones // submit ONCE in the landscape stage, unclipped — retail inserts each // particle into the single alpha list during its owner cell's walk // turn (ShouldDrawParticles @0x0050FE60 gates by cell + distance; // FlushAlphaList @0x0059D2E0 depth-tests at composition). The former // once-per-outside-slice submission with that slice's clip slot cut // effects at aperture boundaries and drew NOTHING when no outside // slice was in view. Interior-cell unattached emitters submit in the // final world scope (DrawDynamicsLast) — in the landscape stage the // upcoming depth clear + interior repaint would erase them. if (!hasBuildingLookIns) passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true); // 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. // // Only an INTERIOR root drains here: its full depth clear follows, and // a flame drained after that clear would z-pass through every interior // wall. An OUTDOOR root has no depth clear (retail gates it on // portalsDrawnCount, pc:432731), and retail's LScape::draw walk has // already drawn every building interior and every cell object via // DrawSortCell 0x005A17C0 before that boundary — while our outdoor // frame draws punches, interior shells, cell objects, and ALL dynamics // (doors, creatures, NPCs) after this point. Draining here painted the // flames first and let each of those later opaque meshes overwrite // them (#132: "the door draws over the candle"); the outdoor drain // therefore runs after DrawDynamicsLast, where world depth is complete // and the one far-to-near list composites over everything, exactly as // retail's boundary flush does relative to its finished walk. if (!ctx.RootCell.IsOutdoorNode) passes.FlushLandscapeAlpha(); // 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.RootCell.IsOutdoorNode) passes.ClearInteriorDepth(); passes.UseIndoorMembershipOnlyRouting(); } internal static int LookInBuildingShellRouteIndex( int frameIndex, int outsideSliceCount, int sliceIndex) => checked((frameIndex * outsideSliceCount) + sliceIndex); internal static int FindLookInFrameIndex( uint buildingShellAnchorCellId, IReadOnlyList lookInFrames, IRetailPViewCellSource cells) { if (buildingShellAnchorCellId == 0) return -1; LoadedCell? anchorCell = cells.Find(buildingShellAnchorCellId); if (anchorCell is null) return -1; uint buildingKey = anchorCell.BuildingId ?? anchorCell.CellId; uint landblockId = anchorCell.CellId & 0xFFFF0000u; for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++) { PortalVisibilityFrame frame = lookInFrames[frameIndex]; if (frame.SourceBuildingKey == buildingKey && frame.SourceBuildingLandblockId == landblockId) { return frameIndex; } } return -1; } private static bool HasExactRoute( in RenderFrameView view, RenderFrameCandidateRoute route, int routeIndex, uint cellId) { foreach (RenderFrameCandidateRange range in view.RouteRanges) { if (range.Route == route && range.RouteIndex == routeIndex && range.CellId == cellId && range.Count > 0) { return true; } } return false; } private void DrawExitPortalMasks( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells) { 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)) passes.DrawExitPortalMask( ctx, new RetailPViewCellSliceContext( cellId, slice, NoParticleOwners)); } } /// Campaign FW4 slice 2 — the walk-flood seal draw. Retail's /// PView::DrawCells stamps every exit portal of THE FLOOD'S OWN /// cells (pc:432785-432786, reverse cell_draw_list far→near) — one /// visibility structure decides the flood, the seals, and the terrain /// views alike. The old apparatus's flood misses exit portals at the /// #456 cathedral seam band (its never-drawn panel family), leaving /// aperture depth unsealed after the interior clear; the end-of-frame /// alpha drain (cell-owned emitters — retail's own timing) then /// z-passes across the whole opening (the falls shine-through, /// probe-pinned via ACDREAM_PROBE_WALK_ROOT's phase tags). Per-cell /// slice clips still come from the old assembly where present; a cell /// the old apparatus missed seals unclipped (the depth fan is the exact /// dat aperture polygon and z-tests, so over-coverage is benign). private void DrawWalkExitPortalMasks( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, Walk.WalkFrameDriver driver) { List floodCells = driver.InteriorFloodCells; for (int i = floodCells.Count - 1; i >= 0; i--) { uint cellId = floodCells[i]; foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) passes.DrawExitPortalMask( ctx, new RetailPViewCellSliceContext( cellId, slice, NoParticleOwners)); } } private void DrawEnvCellShells( IRetailPViewPassExecutor passes, 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. passes.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) passes.DrawOpaqueCellShells(_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 (passes.CellHasTransparentShell(entry.CellId)) _orderedTransparentShellCells.Add(entry.CellId); } if (_orderedTransparentShellCells.Count > 0) passes.DrawTransparentCellShellsOrdered(_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( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, InteriorEntityPartition.Result? partition, ViewconeCuller viewcone, bool rootIsOutdoor, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView) { if (partition is null) { RenderFrameRouteOwnerSelector.Replace( _dynamicParticleOwnerScratch, in frameView, RenderFrameCandidateRoute.DynamicLast, 0, 0); // FW4 slice 4: owners already emitted pre-clear (the outside // stage's late union — straddlers included) emit ONCE; see // _preClearParticleOwnerScratch's field comment. _dynamicParticleOwnerScratch.ExceptWith(_preClearParticleOwnerScratch); passes.UseIndoorMembershipOnlyRouting(); DrawEntityRouteOrLegacy( ctx, passes, frameEntityPasses, in frameView, RenderFrameCandidateRoute.DynamicLast, 0, 0, Array.Empty(), visibleCellIds: null); // Particles emit exactly once, in the stage matching the parent // cell. Pure-outdoor dynamics are absent from the DynamicLast // route (they draw only in the outside stage), and interior // straddlers — present in BOTH routes — emit their particles // HERE so the interior stage cannot repaint over them; the late // landscape submission excludes DynamicLast owners for the same // reason. if (_dynamicParticleOwnerScratch.Count > 0) { passes.DrawDynamicsParticles( ctx, _dynamicParticleOwnerScratch); } return; } if (partition.Dynamics.Count == 0 && frameEntityPasses is null) 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; } // Drawn-once (retail DrawMeshInternal @0x0059F360 marks every // non-player part for the frame): a dynamic whose cell drew as a // building LOOK-IN already rendered with that cell inside the // landscape stage (#131). Redrawing it here would land AFTER the // boundary alpha drain and overpaint nearer flames — the Holtburg // door repainting the candle in front of it. if (indoor && _lookInCellIds.Contains(e.ParentCellId!.Value)) 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 && frameEntityPasses is null) return; if (_dynamicsScratch.Count > 0) { _candidateObserver?.ObservePViewBucket( CurrentRenderPViewRoute.DynamicLast, 0, 0, _dynamicsScratch); } passes.UseIndoorMembershipOnlyRouting(); DrawEntityRouteOrLegacy( ctx, passes, frameEntityPasses, in frameView, RenderFrameCandidateRoute.DynamicLast, 0, 0, _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 (frameEntityPasses is not null) { // Parent-cell stage split: every DynamicLast owner emits its // particles here. Pure-outdoor dynamics are absent from this // route (outside stage only), and interior straddlers — whose // meshes drew in both stages — must emit HERE so the interior // stage cannot repaint over them (matches the production // partition-null path above). RenderFrameRouteOwnerSelector.Replace( _dynamicParticleOwnerScratch, in frameView, RenderFrameCandidateRoute.DynamicLast, 0, 0); } else { _dynamicParticleOwnerScratch.Clear(); // Interior-parented dynamics — INCLUDING exit-portal straddlers // whose mesh also drew in the outside stage — emit particles in // this final pass; outdoor-parented ones emitted in the late // landscape submission (parent-cell stage split). foreach (var e in _dynamicsScratch) if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId)) _dynamicParticleOwnerScratch.Add(e.Id); } // FW4 slice 4: pre-clear-emitted owners (straddlers) emit once — // see _preClearParticleOwnerScratch's field comment. _dynamicParticleOwnerScratch.ExceptWith(_preClearParticleOwnerScratch); if (_dynamicParticleOwnerScratch.Count > 0) passes.DrawDynamicsParticles(ctx, _dynamicParticleOwnerScratch); } private void DrawCellObjectLists( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, InteriorEntityPartition.Result? partition, ViewconeCuller viewcone, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView) { if (partition is null) { RenderFrameRouteOwnerSelector.Replace( _cellParticleOwnerScratch, in frameView, RenderFrameCandidateRoute.CellStatic, 0, 0); passes.UseIndoorMembershipOnlyRouting(); DrawEntityRouteOrLegacy( ctx, passes, frameEntityPasses, in frameView, RenderFrameCandidateRoute.CellStatic, 0, 0, Array.Empty(), visibleCellIds: null); passes.DrawCellParticles( ctx, new RetailPViewCellSliceContext( 0u, NoClipSlice, _cellParticleOwnerScratch)); return; } // 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); } // 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 (frameEntityPasses is not null || _allCellStatics.Count > 0) { _candidateObserver?.ObservePViewBucket( CurrentRenderPViewRoute.CellStatic, 0, 0, _allCellStatics); passes.UseIndoorMembershipOnlyRouting(); DrawEntityRouteOrLegacy( ctx, passes, frameEntityPasses, in frameView, RenderFrameCandidateRoute.CellStatic, 0, 0, _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 // (RetailPViewPassExecutor.DrawCellParticles → 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 — RetailPViewPassExecutor.DrawCellParticles 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 (frameEntityPasses is not null || _allCellStatics.Count > 0) { if (frameEntityPasses is not null) { RenderFrameRouteOwnerSelector.Replace( _cellParticleOwnerScratch, in frameView, RenderFrameCandidateRoute.CellStatic, 0, 0); } else { ReplaceOwnerIds( _cellParticleOwnerScratch, _allCellStatics); } passes.DrawCellParticles( ctx, new RetailPViewCellSliceContext( 0u, NoClipSlice, _cellParticleOwnerScratch)); } } private static void DrawEntityRouteOrLegacy( RetailPViewFrameInput frame, IRetailPViewPassExecutor passes, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView, RenderFrameCandidateRoute route, int routeIndex, uint cellId, IReadOnlyList legacyEntities, HashSet? visibleCellIds) { if (frameEntityPasses is not null) { frameEntityPasses.DrawEntityRoute( frame.Camera, in frameView, route, routeIndex, cellId, frame.PlayerLandblockId ?? 0); return; } passes.DrawEntityBucket( frame, legacyEntities, visibleCellIds); } // T3 scratch lists (render thread only; cleared per use). private readonly List _outdoorStaticScratch = new(); private readonly List _buildingShellScratch = 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(); // 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(); private bool LegacyPartitionDiagnosticsEnabled => _partitionObserver is not null || AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled || AcDream.App.Streaming.EntityVanishProbe.Enabled; internal static RenderFrameDiagnosticCounts LegacyDiagnosticCounts( InteriorEntityPartition.Result partition) { int cellStaticCount = 0; foreach (List bucket in partition.ByCell.Values) cellStaticCount = checked(cellStaticCount + bucket.Count); return new RenderFrameDiagnosticCounts( partition.OutdoorStatic.Count, cellStaticCount, partition.Dynamics.Count, TransformCount: 0, OpaqueClassificationCount: 0, AlphaClassificationCount: 0, LightSetCount: 0, SelectionPartCount: 0, RouteCandidateCount: checked( partition.OutdoorStatic.Count + cellStaticCount + partition.Dynamics.Count), EntityCandidateCount: 0, MeshPartCount: 0); } internal static RenderProjectionCounts LegacySourceCounts( InteriorEntityPartition.Result partition) { int cellStaticCount = 0; foreach (List bucket in partition.ByCell.Values) cellStaticCount = checked(cellStaticCount + bucket.Count); int total = checked( partition.OutdoorStatic.Count + cellStaticCount + partition.Dynamics.Count); return new RenderProjectionCounts( total, partition.OutdoorStatic.Count, cellStaticCount, partition.Dynamics.Count, ActiveAnimatedStatic: 0, EquippedChild: 0); } private static void ReplaceOwnerIds( HashSet destination, IReadOnlyList entities) { destination.Clear(); for (int index = 0; index < entities.Count; index++) { uint localEntityId = entities[index].Id; if (localEntityId != 0) destination.Add(localEntityId); } } /// /// #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, IRetailPViewCellSource cells) { 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 = cells.Find(cellId); if (cell is null) return false; return SphereStraddlesExitPlane(cell, sphereCenter, sphereRadius); } /// The exit-plane straddle test shared by the #118 dynamic /// stage split and the FW4 slice-5 cell-static particle split — retail /// draws a straddling object once per overlapped outdoor shadow cell /// inside LScape::draw (DrawBlock pc:430056-430064). internal static bool SphereStraddlesExitPlane( LoadedCell cell, Vector3 sphereCenter, float sphereRadius) { 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; } private static ClipViewSlice[] GetCellSlicesOrNoClip( ClipFrameAssembly clipAssembly, uint cellId) { if (clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices) && slices.Length > 0) return slices; return NoClipSlices; } } public interface IRetailPViewCellSource { LoadedCell? Find(uint cellId); } /// /// Typed execution seam for the GL passes ordered by /// . Implementations execute the requested /// pass only; visibility construction and draw ordering remain renderer-owned. /// All frame inputs and results are borrowed for the duration of the call. /// public interface IRetailPViewPassExecutor { void AbortFrame(); void BeginFrame(); ClipFrameAssembly AssembleClipFrame( PortalVisibilityFrame portalFrame, ClipFrameAssembly reuseAssembly); void AppendLookInClipFrames( IReadOnlyList lookInFrames, ClipFrameAssembly assembly); void PrepareClipFrame(int terrainUploadCount); void SetTerrainClip(ReadOnlySpan planes); void ClearClipRouting(); void UseIndoorMembershipOnlyRouting(); void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice); void PrepareCellBatches( RetailPViewFrameInput frame, HashSet visibleCellIds); void DrawOpaqueCellShells(HashSet cellIds); bool CellHasTransparentShell(uint cellId); void DrawTransparentCellShells(HashSet cellIds); void DrawTransparentCellShellsOrdered(IReadOnlyList cellIds); void DrawEntityBucket( RetailPViewFrameInput frame, IReadOnlyList entities, HashSet? visibleCellIds); void EmitClipRouteProbe( ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex); void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context); void DrawLandscapeStaticParticles( RetailPViewFrameInput frame, RetailPViewLandscapeStaticParticleContext context); void DrawLandscapeBuildingShellSlice( RetailPViewFrameInput frame, RetailPViewLandscapeBuildingShellSliceContext context); void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context); void ClearInteriorDepth(); void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); void DrawLookInPortalPunch( RetailPViewFrameInput frame, RetailPViewCellSliceContext context, int portalIndex); /// /// One unclipped submission for every renderable UNATTACHED emitter whose /// owner cell matches the scope: outdoor landcells in the landscape stage, /// interior EnvCells in the final world scope. Retail inserts each such /// particle into the single alpha list during its owner cell's walk turn /// and never clips it to a portal view. /// void DrawUnattachedSceneParticles( RetailPViewFrameInput frame, bool outdoorCells); void FlushLandscapeAlpha(); /// /// Pre/inter-building barrier drain: composites only the queued alpha at /// or beyond and retains nearer /// entries for the later boundary flush — retail's far→near walk outcome /// (DrawBuilding's FlushAlphaList(0f) @0x0059F2A0 can only ever flush /// content from cells farther than that building; AP-236). The default /// falls back to a full flush so non-production executors keep today's /// behavior until they opt in. /// void FlushLandscapeAlphaFartherThan(float minViewerDistance) => FlushLandscapeAlpha(); void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet ownerIds); void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result); } /// /// 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 RetailPViewFrameInput { public LoadedCell RootCell { get; private set; } = null!; /// 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; private set; } public Vector3 ViewerEyePos { get; private set; } public Matrix4x4 ViewProjection { get; private set; } public IRetailPViewCellSource Cells { get; private set; } = null!; public ICamera Camera { get; private set; } = null!; public Vector3 CameraWorldPosition { get; private set; } public FrustumPlanes? Frustum { get; private set; } public uint? PlayerLandblockId { get; private set; } public HashSet? AnimatedEntityIds { get; private set; } public int RenderCenterLbX { get; private set; } public int RenderCenterLbY { get; private set; } public int RenderRadius { get; private set; } public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; private set; } = Array.Empty<(uint, Vector3, Vector3, IReadOnlyList, IReadOnlyDictionary?)>(); // Pass-presentation and diagnostic values consumed synchronously by the // typed executor. This input is data-only and is never retained. public bool RenderSky { get; private set; } public bool RenderWeather { get; private set; } public float DayFraction { get; private set; } public DayGroupData? ActiveDayGroup { get; private set; } public SkyKeyframe SkyKeyframe { get; private set; } public bool EnvironOverrideActive { get; private set; } public uint ViewerCellId { get; private set; } public uint PlayerCellId { get; private set; } public Vector3 PlayerViewPosition { get; private set; } public Matrix4x4 CameraView { get; private set; } public CameraCellResolution CameraCellResolution { get; private set; } internal RetailPViewFrameInput Reset( LoadedCell rootCell, IReadOnlyList? nearbyBuildingCells, Vector3 viewerEyePos, Matrix4x4 viewProjection, IRetailPViewCellSource cells, ICamera camera, Vector3 cameraWorldPosition, FrustumPlanes? frustum, uint? playerLandblockId, HashSet? animatedEntityIds, int renderCenterLbX, int renderCenterLbY, int renderRadius, IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)> landblockEntries, bool renderSky, bool renderWeather, float dayFraction, DayGroupData? activeDayGroup, SkyKeyframe skyKeyframe, bool environOverrideActive, uint viewerCellId, uint playerCellId, Vector3 playerViewPosition, Matrix4x4 cameraView, CameraCellResolution cameraCellResolution) { RootCell = rootCell; NearbyBuildingCells = nearbyBuildingCells; ViewerEyePos = viewerEyePos; ViewProjection = viewProjection; Cells = cells; Camera = camera; CameraWorldPosition = cameraWorldPosition; Frustum = frustum; PlayerLandblockId = playerLandblockId; AnimatedEntityIds = animatedEntityIds; RenderCenterLbX = renderCenterLbX; RenderCenterLbY = renderCenterLbY; RenderRadius = renderRadius; LandblockEntries = landblockEntries; RenderSky = renderSky; RenderWeather = renderWeather; DayFraction = dayFraction; ActiveDayGroup = activeDayGroup; SkyKeyframe = skyKeyframe; EnvironOverrideActive = environOverrideActive; ViewerCellId = viewerCellId; PlayerCellId = playerCellId; PlayerViewPosition = playerViewPosition; CameraView = cameraView; CameraCellResolution = cameraCellResolution; return this; } } /// /// 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!; /// /// Every cell this completed view actually reached: the main flood /// () plus the building look-in cells. This is /// retail's per-cell in_view answer for effect consumers — /// CPhysicsObj::ShouldDrawParticles @0x0050FE60 gates on /// cell->IsInView(), and a cell entered through a building portal /// (PView::ConstructView @0x005A57B0, installed by /// RenderDeviceD3D::DrawBuilding @0x0059F2A0) is drawn by the same /// PView::DrawCells traversal as a flooded cell, so retail marks it /// in view identically. acdream's look-in adaptation keeps those cells out /// of (seals / outside-stage predicate stay /// main-flood scoped, #124); particle and light visibility must consume /// THIS set or look-in rooms render with frozen emitters and dark lights. /// public HashSet InViewCells { get; private set; } = null!; internal RenderFrameDiagnosticCounts DiagnosticCounts { get; private set; } internal RenderProjectionCounts SourceCounts { get; private set; } internal InteriorEntityPartition.Result? DiagnosticPartition { get; private set; } internal RetailPViewFrameResult Reset( PortalVisibilityFrame portalFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, HashSet inViewCells, RenderFrameDiagnosticCounts diagnosticCounts, RenderProjectionCounts sourceCounts, InteriorEntityPartition.Result? diagnosticPartition) { PortalFrame = portalFrame; ClipAssembly = clipAssembly; DrawableCells = drawableCells; InViewCells = inViewCells; DiagnosticCounts = diagnosticCounts; SourceCounts = sourceCounts; DiagnosticPartition = diagnosticPartition; return this; } internal RetailPViewFrameResult Reset( PortalVisibilityFrame portalFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, InteriorEntityPartition.Result diagnosticPartition) => Reset( portalFrame, clipAssembly, drawableCells, drawableCells, RetailPViewRenderer.LegacyDiagnosticCounts( diagnosticPartition), RetailPViewRenderer.LegacySourceCounts( diagnosticPartition), diagnosticPartition); } public readonly record struct RetailPViewLandscapeSliceContext( ClipViewSlice Slice, IReadOnlyList OutdoorEntities) { internal RenderFrameEntityDrawRequest? EntityDraw { get; init; } } /// /// Scene-particle owners for ONE unclipped landscape-stage submission (the /// union of every outside slice's cone survivors). Mesh alpha for the same /// owners is already queued by the entity routes; retail inserts each /// emitter's polys into the single alpha list once, during its owner cell's /// walk turn, with no portal-view clip. /// public readonly record struct RetailPViewLandscapeStaticParticleContext( IReadOnlySet ParticleOwnerIds); /// Retail DrawBuilding's ordinary exterior-shell pass, issued after /// the same building's portal-only look-in traversal. public readonly record struct RetailPViewLandscapeBuildingShellSliceContext( ClipViewSlice Slice, IReadOnlyList BuildingShells) { internal RenderFrameEntityDrawRequest? EntityDraw { get; init; } } /// #131/#132: the late landscape phase's per-slice payload — /// outside-stage dynamics to mesh-draw, plus the particle owners not already /// submitted at a pre-building barrier. public readonly record struct RetailPViewLandscapeLateSliceContext( ClipViewSlice Slice, IReadOnlyList Dynamics) { internal RenderFrameEntityDrawRequest? EntityDraw { get; init; } } public readonly record struct RetailPViewCellSliceContext( uint CellId, ClipViewSlice Slice, IReadOnlySet ParticleOwnerIds);