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);