using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
///
/// App-layer port of the retail indoor render orchestration:
/// SmartBox::RenderNormalMode -> RenderDeviceD3D::DrawInside ->
/// PView::DrawInside -> ConstructView -> DrawCells.
///
public sealed class RetailPViewRenderer
{
private readonly GL _gl;
private readonly ClipFrame _clipFrame;
private readonly EnvCellRenderer _envCells;
private readonly WbDrawDispatcher _entities;
private static readonly ClipViewSlice NoClipSlice =
new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty());
private readonly HashSet _oneCell = new(1);
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
private readonly Dictionary> _buildingGroups = 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 HashSet _lookInPrepareScratch = new();
// #131/#132: the late landscape phase's scene-particle owner survivors
// (statics + outside-stage dynamics passing the slice cone).
private readonly List _lateParticleOwnerScratch = new();
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
// caller's per-building frustum pre-gate on aperture bounds (GameWindow's
// gather); seeds themselves are unbounded.
private const float OutdoorBuildingSeedDistance = float.PositiveInfinity;
public RetailPViewRenderer(
GL gl,
ClipFrame clipFrame,
EnvCellRenderer envCells,
WbDrawDispatcher entities)
{
_gl = gl;
_clipFrame = clipFrame;
_envCells = envCells;
_entities = entities;
}
public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx)
{
ArgumentNullException.ThrowIfNull(ctx);
var pvFrame = PortalVisibilityBuilder.Build(
ctx.RootCell,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.ViewProjection,
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
// 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).
_lookInFrames.Clear();
if (!ctx.RootCell.IsOutdoorNode
&& ctx.NearbyBuildingCells is not null
&& pvFrame.OutsideView.Polygons.Count > 0)
BuildInteriorRootLookIns(ctx, pvFrame);
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
UploadClipFrame(ctx.SetTerrainClipUbo);
// 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).
var drawableCells = new HashSet(pvFrame.OrderedVisibleCells);
UseIndoorMembershipOnlyRouting();
// #124: look-in cells need prepared shell batches + their statics routed
// into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main
// cell-object pass iterates pvFrame.OrderedVisibleCells, which never
// contains them). drawableCells itself stays the MAIN flood: it feeds the
// seals, the outside-stage predicate, and the frame result.
var prepareCells = drawableCells;
if (_lookInFrames.Count > 0)
{
_lookInPrepareScratch.Clear();
_lookInPrepareScratch.UnionWith(drawableCells);
foreach (var f in _lookInFrames)
foreach (uint c in f.OrderedVisibleCells)
_lookInPrepareScratch.Add(c);
prepareCells = _lookInPrepareScratch;
}
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
filter: prepareCells,
centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries);
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,
ClipAssembly = clipAssembly,
DrawableCells = drawableCells,
Partition = partition,
};
ctx.EmitDiagnostics?.Invoke(result);
// T1 (fused BR-2/3): retail's frame order — static world, then the
// aperture depth writes, then interior cells WHOLE far→near, then
// per-cell statics, then ALL dynamics last (retail draws objects after
// cells: PView::DrawCells Ghidra 0x005a4840; DrawBuilding 0x0059f2a0).
// The geometric shell chop (gl_ClipDistance crop, 927fd8f/9ce335e) is
// DELETED — retail never clips cell geometry; aperture exactness comes
// from the punch/seal depth writes + the z-buffer, and the dynamics-
// last order is what makes the punch safe (the first BR-2 attempt
// punched after dynamics and erased the player, reverted 88be519).
// T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view,
// never clipped (Ghidra 0x0054c250). Built once per frame from the
// assembled slices + this frame's view-projection.
var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection);
// #118: stage assignment for dynamics under an INTERIOR root. Retail
// draws the OUTSIDE world's objects inside the landscape stage —
// PView::DrawCells runs LScape::draw FIRST (pc:432719), then the gated
// full depth clear (pc:432731-432732) and the exit-portal SEALS
// (pc:432785-432786); DrawBlock draws every landcell's objects via
// DrawSortCell (0x005a17c0, pc:430124). A dynamic deferred to our
// single last pass instead z-fails against the seal's true-depth stamp
// the moment it stands beyond the door plane — the house-exit
// clip+vanish (pinned by HouseExitWalkReplayTests). So under an
// interior root: outdoor-classified dynamics draw in the outside
// stage; an indoor dynamic whose sphere STRADDLES an exit portal
// draws in BOTH stages (retail's per-overlapped-cell shadow-part
// draw, DrawBlock pc:430056-430064) so neither body half clips at the
// plane. Outdoor roots keep ALL dynamics in the last pass — our
// z-buffered equivalent of retail's painter-ordered outdoor pass (the
// BR-2 punch-after-dynamics lesson, reverted 88be519).
_outsideStageDynamics.Clear();
if (!ctx.RootCell.IsOutdoorNode)
{
foreach (var e in partition.Dynamics)
{
EntitySphere(e, out var c, out float r);
if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.CellLookup))
_outsideStageDynamics.Add(e);
}
}
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone);
UseIndoorMembershipOnlyRouting();
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(pvFrame);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone);
DrawDynamicsLast(ctx, partition, viewcone, ctx.RootCell.IsOutdoorNode);
return result;
}
// R-A2: group the nearby building cells by BuildingId and run one per-building flood per group
// (retail's per-building ConstructView(CBldPortal)), merging each small view into the frame. The
// grouping dict is reused across frames; inner lists are cleared each frame so a building that left
// the near set simply contributes an empty (skipped) group.
private void MergeNearbyBuildingFloods(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
{
foreach (var group in _buildingGroups.Values)
group.Clear();
foreach (var cell in ctx.NearbyBuildingCells!)
{
// R-A2 seam fix: a cell without a BuildingId (unstamped, or outdoor-adjacent with an exit
// portal) must STILL flood — the pre-R-A2 node flood reached it via a reverse portal, so
// dropping it (the original `continue`) left holes at building/terrain seams. Key it by its
// own CellId → a singleton per-entrance flood: a cell with an exit portal seeds from it, a
// cell with none contributes nothing (same as before). BuildingId/CellId key collisions are
// harmless — BuildFromExterior seeds each exit-portal cell in a group independently.
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_buildingGroups.TryGetValue(groupKey, out var group))
{
group = new List();
_buildingGroups[groupKey] = group;
}
group.Add(cell);
}
foreach (var group in _buildingGroups.Values)
{
if (group.Count == 0)
continue;
var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, OutdoorBuildingSeedDistance);
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))
{
foreach (var p in srcView.Polygons)
existing.Add(p);
continue;
}
target.CellViews[cellId] = srcView;
target.OrderedVisibleCells.Add(cellId);
}
}
// #124: per-building look-in floods for an INTERIOR root, seeded clipped
// against the OutsideView (retail: GetClip runs under the INSTALLED view —
// the accumulated doorway region — so a far building floods only within the
// doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip
// 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own
// building self-excludes via the seed eye-side test.
private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
{
foreach (var group in _buildingGroups.Values)
group.Clear();
foreach (var cell in ctx.NearbyBuildingCells!)
{
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_buildingGroups.TryGetValue(groupKey, out var group))
{
group = new List();
_buildingGroups[groupKey] = group;
}
group.Add(cell);
}
foreach (var group in _buildingGroups.Values)
{
if (group.Count == 0)
continue;
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection,
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons);
if (frame.OrderedVisibleCells.Count > 0)
_lookInFrames.Add(frame);
}
}
// #124: draw the interior-root look-ins INSIDE the landscape stage —
// retail's placement (LScape::draw → DrawBlock → DrawSortCell →
// DrawBuilding runs as the FIRST call of DrawCells' outside-view branch,
// pc:432719, before the depth clear + seals). Per building: punch ALL
// apertures first (retail finishes build_draw_portals_only pass 1 — the
// far-Z maxZ1 punch — across the whole building BSP before pass 2 floods),
// then draw the flooded cells' shells + statics far→near (the nested
// DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is
// empty by construction — PView ctor draw_landscape=0 — so no recursive
// landscape/clear/seal). Anything rasterized outside an aperture is
// repainted by the root's own shells after the depth clear, so over-draw
// here is color-safe; statics draw whole (the main viewcone has no entry
// for look-in cells; over-include is the safe direction).
private void DrawBuildingLookIns(
RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition)
{
if (_lookInFrames.Count == 0)
return;
foreach (var frame in _lookInFrames)
{
// Pass 1: far-Z punch every aperture of this building.
if (ctx.DrawLookInPortalPunch is not null)
{
foreach (uint cellId in frame.OrderedVisibleCells)
{
if (!frame.CellViews.TryGetValue(cellId, out var view))
continue;
foreach (var poly in view.Polygons)
{
var single = new CellView();
single.Add(poly);
var cps = ClipPlaneSet.From(single);
if (cps.IsNothingVisible)
continue;
var planes = new Vector4[cps.Count];
for (int p = 0; p < cps.Count; p++)
planes[p] = cps.Planes[p];
ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext(
cellId,
new ClipViewSlice(0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), planes),
Array.Empty()));
}
}
}
// Pass 2: shells + statics, far→near.
UseIndoorMembershipOnlyRouting();
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
_oneCell.Clear();
_oneCell.Add(cellId);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
_cellStaticScratch.Clear();
if (partition.ByCell.TryGetValue(cellId, out var bucket))
_cellStaticScratch.AddRange(bucket);
// #131 ROOT CAUSE: DYNAMICS living in a look-in cell (the
// Holtburg hall-porch PORTAL, pCell 0xA9B4017A) draw NOWHERE
// under an interior root — DrawDynamicsLast viewcone-culls
// them (the main cone has no entries for look-in cells), and
// post-clear they would z-fail against the root's seal anyway
// (the #118 lesson). Retail draws a look-in cell's objects
// inside the NESTED DrawCells (DrawObjCellForDummies,
// pc:432878+), i.e. right here in the landscape stage. Drawn
// WHOLE like the statics (AP-33's documented over-include).
// No double-draw: dynamics-last keeps culling them (their
// cell is absent from the main cone), and their emitters ride
// the DrawCellParticles call below, not DrawDynamicsParticles
// (which only sees dynamics-last cone survivors).
foreach (var e in partition.Dynamics)
if (e.ParentCellId == cellId)
_cellStaticScratch.Add(e);
if (_cellStaticScratch.Count > 0)
{
DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
// The cell-particles pass for look-in cells — retail's
// nested DrawCells draws objects WITH their emitters.
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(
cellId, slice, _cellStaticScratch));
}
}
}
}
private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
{
if (clipAssembly.OutsideViewSlices.Length == 0)
return;
// #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha
// draws of the landscape stage and flushes them ONCE after LScape::draw
// (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent
// landscape content (portal swirl meshes, flame particles) composites
// AFTER the building look-ins. Our dispatcher draws translucency inside
// each Draw call, so the stage is split in TWO phases instead: EARLY =
// sky + terrain + outdoor STATIC meshes (the look-in punches need their
// depth to mark against, the #117 lesson); then the look-ins; then
// LATE = outside-stage dynamics' meshes + ALL scene particles +
// weather. Content drawn early and overlapped by a look-in aperture
// was otherwise overpainted by the far interior (translucents write no
// depth to protect themselves) — the portal-swirl/candle-flame class.
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo);
// T3 (BR-5): entities are never hard-clipped — retail viewcone-
// CHECKS each mesh's sphere against the view (Ghidra 0x0054c250)
// and draws it whole. The old per-slice entity clip routing
// (gl_ClipDistance via SetClipRouting) is replaced by the sphere
// pre-filter below; terrain/sky keep their per-slice plane clip.
_entities.ClearClipRouting();
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled)
EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex);
_outdoorStaticScratch.Clear();
foreach (var e in partition.OutdoorStatic)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
_outdoorStaticScratch.Add(e);
}
probeSliceIndex++;
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
}
// #124: far-building look-ins draw HERE — still inside the landscape
// stage (their punches mark against the terrain/exterior depth just
// drawn), strictly BEFORE the depth clear + seals below, matching
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
DrawBuildingLookIns(ctx, clipAssembly, partition);
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
// pre-clear so the seal protects their aperture pixels; AFTER the
// look-ins so a translucent portal mesh blends over a far interior
// instead of being overpainted) + the scene-particle owners (statics +
// dynamics cone survivors — flames ride here for the same reason).
probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo);
_entities.ClearClipRouting();
_outdoorStaticScratch.Clear(); // late: dynamics survivors
_lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors
foreach (var e in partition.OutdoorStatic)
{
EntitySphere(e, out var c, out float r);
bool ownerPass = viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r);
if (ownerPass)
_lateParticleOwnerScratch.Add(e);
// #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids
// double as an ENTITY-id watchlist here — one line per watched
// outdoor-static owner per CHANGE of its cone verdict.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled
&& AcDream.Core.Rendering.RenderingDiagnostics.DumpEntitySourceIds.Contains(e.Id)
&& (!_outStageOwnerVerdicts.TryGetValue(e.Id, out bool prev) || prev != ownerPass))
{
_outStageOwnerVerdicts[e.Id] = ownerPass;
Console.WriteLine(System.FormattableString.Invariant(
$"[outstage-own] id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} pos=({e.Position.X:F1},{e.Position.Y:F1},{e.Position.Z:F1}) c=({c.X:F1},{c.Y:F1},{c.Z:F1}) r={r:F1} slice={probeSliceIndex} {(ownerPass ? "PASS" : "CULL")}"));
}
}
foreach (var e in _outsideStageDynamics)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
{
_outdoorStaticScratch.Add(e);
_lateParticleOwnerScratch.Add(e);
}
}
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled)
EmitOutStageProbe(probeSliceIndex, viewcone);
probeSliceIndex++;
ctx.DrawLandscapeSliceLate?.Invoke(new RetailPViewLandscapeLateSliceContext(
slice, _outdoorStaticScratch, _lateParticleOwnerScratch));
}
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
// campfires, ground effects anchored at a position) have no owner id
// to ride any of the id-filtered particle passes. The outdoor root
// has the dedicated T3 pass for them; an INTERIOR root had NO pass
// at all. Draw them ONCE per frame (not per slice — alpha particles
// must not double-draw, the #121 lesson), at the END of the landscape
// stage: after the clear they would z-fail against the doorway seal.
if (!ctx.RootCell.IsOutdoorNode)
ctx.DrawUnattachedSceneParticles?.Invoke();
// T1: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// Clear gated on portalsDrawnCount; exact gate semantics is a plan
// open question, staged as "any outside slice drawn"), then re-stamps
// every outside-leading portal's TRUE depth (the seals,
// DrawExitPortalMasks). Replaces the old per-slice scissored AABB
// clear (wrong shape, no seal after it).
if (clipAssembly.OutsideViewSlices.Length > 0)
ctx.ClearDepthForInterior?.Invoke();
UseIndoorMembershipOnlyRouting();
}
// #131 [outstage] probe state (2026-06-12, throwaway): print-on-change —
// which outdoor dynamics were routed to the outside stage and which
// survived the slice viewcone. Strip with the probe when #131 closes.
private string? _lastOutStageSig;
private readonly Dictionary _outStageOwnerVerdicts = new();
private void EmitOutStageProbe(int sliceIndex, ViewconeCuller viewcone)
{
var sb = new System.Text.StringBuilder(192);
sb.Append("slice=").Append(sliceIndex)
.Append(" outStage=").Append(_outsideStageDynamics.Count).Append(" [");
for (int i = 0; i < _outsideStageDynamics.Count; i++)
{
var e = _outsideStageDynamics[i];
EntitySphere(e, out var c, out float r);
bool pass = viewcone.SphereVisibleInOutsideSlice(sliceIndex, c, r);
if (i > 0) sb.Append(' ');
sb.Append(System.FormattableString.Invariant(
$"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}(s{e.SourceGfxObjOrSetupId:X8}):{(pass ? "PASS" : "CULL")}:r={r:F1}"));
}
sb.Append(']');
string sig = sb.ToString();
if (sig == _lastOutStageSig) return;
_lastOutStageSig = sig;
Console.WriteLine("[outstage] " + sig);
}
// §4 flap [clip-route] probe state (2026-06-10, throwaway): print-on-change signature +
// monotonic sequence so held-flap vs healthy frames diff cleanly in one capture.
private string? _lastClipRouteSig;
private long _clipRouteSeq;
private readonly List _clipRouteCellKeys = new();
// §4 flap apparatus (2026-06-10): the decisive probe between the surviving suspects
// (handoff 2026-06-09 §1). Emits the EXACT clip inputs the landscape pass draws under:
// the outside slice's slot + NDC AABB + planes (CPU side), the region-SSBO bytes decoded
// at that slot (what mesh_modern.vert reads for routed instances), the terrain-UBO head
// (what terrain/sky gate against), and the CellIdToSlot routing table. Fires AFTER
// SetTerrainClip + UploadClipFrame + SetClipRouting, BEFORE DrawLandscapeSlice — so the
// printed bytes are exactly what this slice's draws consume.
private void EmitClipRouteProbe(ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex)
{
var sb = new System.Text.StringBuilder(256);
sb.Append(System.FormattableString.Invariant(
$"slice={sliceIndex}/{clipAssembly.OutsideViewSlices.Length} slot={slice.Slot}"));
sb.Append(System.FormattableString.Invariant(
$" ndc=({slice.NdcAabb.X:F3},{slice.NdcAabb.Y:F3},{slice.NdcAabb.Z:F3},{slice.NdcAabb.W:F3})"));
sb.Append(System.FormattableString.Invariant($" planes={slice.Planes.Length}["));
for (int i = 0; i < slice.Planes.Length; i++)
{
var p = slice.Planes[i];
if (i > 0) sb.Append(' ');
sb.Append(System.FormattableString.Invariant($"({p.X:F3},{p.Y:F3},{p.Z:F3},{p.W:F3})"));
}
// CellIdToSlot sorted by cell id so dictionary enumeration order can't fake a change.
sb.Append("] cells={");
_clipRouteCellKeys.Clear();
foreach (uint key in clipAssembly.CellIdToSlot.Keys)
_clipRouteCellKeys.Add(key);
_clipRouteCellKeys.Sort();
for (int i = 0; i < _clipRouteCellKeys.Count; i++)
{
if (i > 0) sb.Append(',');
sb.Append(System.FormattableString.Invariant(
$"0x{_clipRouteCellKeys[i]:X8}:{clipAssembly.CellIdToSlot[_clipRouteCellKeys[i]]}"));
}
sb.Append('}');
// Region-SSBO content decoded at the routed slot, from the packed bytes UploadClipFrame
// just uploaded — slot stride 144: count uint at +0, planes[8] at +16.
var rb = _clipFrame.RegionBytesForTest;
int off = slice.Slot * ClipFrame.CellClipStrideBytes;
if (off >= 0 && off + ClipFrame.CellClipStrideBytes <= rb.Length)
{
uint ssboCount = System.BitConverter.ToUInt32(rb.Slice(off, 4));
sb.Append(System.FormattableString.Invariant($" ssbo[{slice.Slot}]: n={ssboCount}"));
int planeN = (int)System.Math.Min(ssboCount, (uint)ClipFrame.MaxPlanes);
for (int i = 0; i < planeN; i++)
{
int po = off + ClipFrame.CellClipPlanesOffset + i * 16;
float px = System.BitConverter.ToSingle(rb.Slice(po, 4));
float py = System.BitConverter.ToSingle(rb.Slice(po + 4, 4));
float pz = System.BitConverter.ToSingle(rb.Slice(po + 8, 4));
float pw = System.BitConverter.ToSingle(rb.Slice(po + 12, 4));
sb.Append(System.FormattableString.Invariant($" ({px:F3},{py:F3},{pz:F3},{pw:F3})"));
}
}
else
{
sb.Append(System.FormattableString.Invariant(
$" ssbo[{slice.Slot}]: OUT-OF-RANGE len={rb.Length}"));
}
// Terrain-UBO head as uploaded (std140: int count at +0, planes[8] at +16).
var tb = _clipFrame.TerrainBytesForTest;
int uboCount = System.BitConverter.ToInt32(tb.Slice(0, 4));
float u0 = System.BitConverter.ToSingle(tb.Slice(16, 4));
float u1 = System.BitConverter.ToSingle(tb.Slice(20, 4));
float u2 = System.BitConverter.ToSingle(tb.Slice(24, 4));
float u3 = System.BitConverter.ToSingle(tb.Slice(28, 4));
sb.Append(System.FormattableString.Invariant(
$" ubo: n={uboCount} p0=({u0:F3},{u1:F3},{u2:F3},{u3:F3})"));
string sig = sb.ToString();
_clipRouteSeq++;
if (sig == _lastClipRouteSig)
return;
_lastClipRouteSig = sig;
Console.WriteLine($"[clip-route] n={_clipRouteSeq} {sig}");
}
private void DrawExitPortalMasks(
IRetailPViewCellDrawCallbacks ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet drawableCells)
{
if (ctx.DrawExitPortalMasks is null)
return;
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = pvFrame.OrderedVisibleCells[i];
if (!drawableCells.Contains(cellId))
continue;
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawExitPortalMasks(new RetailPViewCellSliceContext(cellId, slice, Array.Empty()));
}
}
private void DrawEnvCellShells(PortalVisibilityFrame pvFrame)
{
// T1 (fused BR-2/3): retail DrawCells Loop 2 — every visible cell's
// shell drawn WHOLE, reverse cell_draw_list (far→near), drawn once.
// Retail NEVER clips cell geometry: the production path is the
// prebuilt mesh (DrawEnvCell use_built_mesh, pc:427905; the
// planeMask=0xffffffff legacy submit means skip-all-edges), and
// aperture exactness comes from the punch/seal depth writes + the
// z-buffer + this order. The former gl_ClipDistance chop
// (927fd8f/9ce335e, #114) is deleted with this rewrite.
// Per-cell opaque+transparent keeps the far→near transparent
// compositing the per-cell loop already provided.
UseIndoorMembershipOnlyRouting();
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
_oneCell.Clear();
_oneCell.Add(entry.CellId);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
}
}
// T1: the frame's single LAST entity pass — ALL server-spawned dynamics
// (player, NPCs, doors, items), indoor or out, drawn after the static
// world + punches + interior cells. Depth-tested, never hard-clipped
// (retail draws objects per cell AFTER cells and viewcone-culls them —
// PView::DrawCells epilogue Ghidra 0x005a4840; the sphere-vs-view cull is
// T3). Drawing dynamics last is what makes the aperture punch safe.
// T3 (BR-5): each dynamic is viewcone-culled like retail — sphere vs its
// cell's views; outdoor/unresolved vs the outside views (pass-all under
// the outdoor root's full-screen outside view). A dynamic in a NON-flooded
// room culls HERE — retail never reaches an object whose cell is not in
// the draw list; the partition keeps routing it so the CULL (not the
// visibility set) drops it, exactly retail's shape.
private void DrawDynamicsLast(
IRetailPViewCellDrawContext ctx,
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone,
bool rootIsOutdoor)
{
if (partition.Dynamics.Count == 0)
return;
_dynamicsScratch.Clear();
foreach (var e in partition.Dynamics)
{
EntitySphere(e, out var c, out float r);
bool indoor = InteriorEntityPartition.IsIndoorCellId(e.ParentCellId);
// #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)
continue;
bool visible = indoor
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
: viewcone.SphereVisibleOutside(c, r);
if (visible)
_dynamicsScratch.Add(e);
}
if (_dynamicsScratch.Count == 0)
return;
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null);
// #121: dynamics' attached emitters (portal swirls, creature effects)
// gate through the SAME cone-surviving owner set as their meshes —
// retail draws emitters with the owner object. Before this callback,
// dynamics' emitters fell through EVERY particle filter under the pview
// path (the landscape slice carries outdoor statics + #118 outside-
// stage dynamics; the cell callback carries cell statics; T4 deleted
// the old clipRoot==null global pass from normal frames) — all world
// portals went invisible. Outside-stage dynamics are excluded here:
// their emitters already drew in the landscape slice (alpha-blended
// particles must not double-draw, unlike the depth-idempotent meshes).
if (ctx.DrawDynamicsParticles is not null)
{
_dynamicsParticleScratch.Clear();
foreach (var e in _dynamicsScratch)
if (!_outsideStageDynamics.Contains(e))
_dynamicsParticleScratch.Add(e);
if (_dynamicsParticleScratch.Count > 0)
ctx.DrawDynamicsParticles(_dynamicsParticleScratch);
}
}
private void DrawCellObjectLists(
IRetailPViewCellDrawContext ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet drawableCells,
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
{
// T1: per-cell STATIC object lists only (dat-baked 0x40 statics) —
// dynamics moved to DrawDynamicsLast. Far→near with the cells, after
// the shells (retail DrawCells epilogue: PortalList = cell's views →
// DrawObjCell, Ghidra 0x005a4840). T3 (BR-5): each static's sphere is
// tested against ITS CELL's views (retail viewconeCheck) — the
// statics-through-walls fix: a static whose sphere is outside every
// view of its cell no longer paints through the wall (the cottage
// phantom staircase's draw path).
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;
_cellStaticScratch.Clear();
foreach (var e in bucket)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInCell(cellId, c, r))
_cellStaticScratch.Add(e);
}
// BR-2 phantom-site probe (T3-updated): post-viewcone survivors.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
EmitPhantomObjsProbe(cellId, _cellStaticScratch.Count);
if (_cellStaticScratch.Count > 0)
{
_oneCell.Clear();
_oneCell.Add(cellId);
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
}
// T3 (BR-5): particles gate through the SAME viewcone as their
// owners — the callback receives the cone-surviving entity set, so
// an emitter attached to a culled static no longer draws through
// the wall (the candle-flames-through-walls fix). Consumed
// synchronously within this iteration (scratch list reuse).
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, _cellStaticScratch));
}
}
// T3 scratch lists (render thread only; cleared per use).
private readonly List _outdoorStaticScratch = new();
private readonly List _cellStaticScratch = new();
private readonly List _dynamicsScratch = new();
// #118: dynamics assigned to the OUTSIDE stage this frame (interior roots
// only) — outdoor-classified + exit-portal straddlers. Cleared per frame.
private readonly List _outsideStageDynamics = new();
// #121: cone-surviving dynamics whose emitters draw in the dynamics
// particle pass (survivors minus outside-stage). Cleared per use.
private readonly List _dynamicsParticleScratch = new();
///
/// #118 stage assignment for a dynamic under an INTERIOR root: does it draw
/// in the OUTSIDE (landscape) stage — before the gated depth clear and the
/// exit-portal seals — like retail's per-landcell object draw
/// (LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell pc:430124, run at
/// the top of PView::DrawCells pc:432719)?
///
/// True for outdoor-classified dynamics (their fragments lie beyond the
/// door plane and would z-fail the seal in the last pass), and for INDOOR
/// dynamics whose sphere straddles an exit-portal plane of their flood-
/// visible cell — retail draws an object once per overlapped shadow cell
/// (DrawBlock pc:430056-430064), so a threshold-straddling body draws in
/// both stages and neither half clips at the plane. Pure — also driven
/// headlessly by HouseExitWalkReplayTests as the ordering contract.
///
public static bool DynamicDrawsInOutsideStage(
uint? parentCellId,
Vector3 sphereCenter,
float sphereRadius,
HashSet drawableCells,
Func cellLookup)
{
if (!InteriorEntityPartition.IsIndoorCellId(parentCellId))
return true;
uint cellId = parentCellId!.Value;
if (!drawableCells.Contains(cellId))
return false; // not in the flood — the last-pass cone cull owns it
var cell = cellLookup(cellId);
if (cell is null)
return false;
var localC = Vector3.Transform(sphereCenter, cell.InverseWorldTransform);
int n = Math.Min(cell.Portals.Count, cell.ClipPlanes.Count);
for (int i = 0; i < n; i++)
{
if (cell.Portals[i].OtherCellId != 0xFFFF)
continue;
var plane = cell.ClipPlanes[i];
if (plane.Normal.LengthSquared() < 1e-8f)
continue;
float dist = Vector3.Dot(plane.Normal, localC) + plane.D;
if (MathF.Abs(dist) < sphereRadius)
return true; // sphere straddles the exit-portal plane
}
return false;
}
// Conservative bounding sphere from the entity's cached AABB — the same
// bounds source the dispatcher's frustum cull uses.
private static void EntitySphere(WorldEntity e, out Vector3 center, out float radius)
{
if (e.AabbDirty)
e.RefreshAabb();
center = (e.AabbMin + e.AabbMax) * 0.5f;
radius = (e.AabbMax - e.AabbMin).Length() * 0.5f;
}
// BR-2 phantom-site probe state: print-on-change per cell so the log stays
// diffable while the condition persists. Throwaway apparatus — strip when
// the #113 phantom residual closes. (The [phantom-shell] half died with
// the T1 chop deletion — shells draw whole, there is no slice state left
// to report.)
private readonly Dictionary _phantomObjsSig = new();
private void EmitPhantomObjsProbe(uint cellId, int bucketCount)
{
if (_phantomObjsSig.TryGetValue(cellId, out var prev) && prev == bucketCount) return;
_phantomObjsSig[cellId] = bucketCount;
Console.WriteLine($"[phantom-objs] cell=0x{cellId:X8} entities={bucketCount} (drawn unclipped, no viewcone)");
}
private static ClipViewSlice[] GetCellSlicesOrNoClip(
ClipFrameAssembly clipAssembly,
uint cellId)
{
if (clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices)
&& slices.Length > 0)
return slices;
return new[] { NoClipSlice };
}
private void UseIndoorMembershipOnlyRouting()
{
// T1: NOTHING in the world passes hard-clips geometry anymore — retail
// viewcone-CHECKS meshes (sphere vs view planes, T3) and never clips
// cell shells (DrawEnvCell draws the whole prebuilt mesh, pc:427905).
// This clears any clip routing left by the landscape slices.
_envCells.SetClipRouting(null);
_entities.ClearClipRouting();
}
private void DrawEntityBucket(
IRetailPViewCellDrawContext ctx,
IReadOnlyList bucket,
HashSet? visibleCellIds)
{
uint lbId = ctx.PlayerLandblockId ?? 0u;
var entry = (lbId, Vector3.Zero, Vector3.Zero,
(IReadOnlyList)bucket,
(IReadOnlyDictionary?)null);
_entities.Draw(
ctx.Camera,
new[] { entry },
ctx.Frustum,
neverCullLandblockId: ctx.PlayerLandblockId,
visibleCellIds: visibleCellIds,
animatedEntityIds: ctx.AnimatedEntityIds);
}
private void RestoreNoClip(Action setTerrainClipUbo)
{
_clipFrame.Reset();
UploadClipFrame(setTerrainClipUbo);
UseIndoorMembershipOnlyRouting();
}
private void UploadClipFrame(Action setTerrainClipUbo)
{
_clipFrame.UploadShared(_gl);
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
setTerrainClipUbo(_clipFrame.TerrainUbo);
}
}
public interface IRetailPViewCellDrawCallbacks
{
public Action? DrawExitPortalMasks { get; }
public Action? DrawCellParticles { get; }
/// #124: far-Z punch one look-in aperture (a clipped view polygon
/// of a looked-into building cell) — always the PUNCH variant regardless
/// of root kind (retail maxZ1; the root-keyed forceFarZ selector only
/// governs the MAIN frame's exit-portal masks).
public Action? DrawLookInPortalPunch { get; }
}
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
{
public ICamera Camera { get; }
public FrustumPlanes? Frustum { get; }
public uint? PlayerLandblockId { get; }
public HashSet? AnimatedEntityIds { get; }
/// #121: draw the Scene-pass emitters attached to the frame's
/// cone-surviving dynamics (portal swirls, creature effects). Invoked once
/// per frame after the last entity pass with the survivor list.
public Action>? DrawDynamicsParticles { get; }
}
public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
{
public required LoadedCell RootCell { get; init; }
/// R-A2: nearby building cells (BuildingId-tagged) flooded per-building when the root is the
/// outdoor node. Null for interior roots. Grouped by BuildingId inside .
public IReadOnlyList? NearbyBuildingCells { get; init; }
public required Vector3 ViewerEyePos { get; init; }
public required Matrix4x4 ViewProjection { get; init; }
public required Func CellLookup { get; init; }
public required ICamera Camera { get; init; }
public required Vector3 CameraWorldPosition { get; init; }
public required FrustumPlanes? Frustum { get; init; }
public required uint? PlayerLandblockId { get; init; }
public required HashSet? AnimatedEntityIds { get; init; }
public required int RenderCenterLbX { get; init; }
public required int RenderCenterLbY { get; init; }
public required int RenderRadius { get; init; }
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; }
public required Action SetTerrainClipUbo { get; init; }
public required Action DrawLandscapeSlice { get; init; }
/// #131/#132: the LATE landscape phase, per slice, after the #124
/// look-ins — outside-stage dynamics' meshes + all scene particles +
/// weather (the FlushAlphaList deferral; see DrawLandscapeThroughOutsideView).
public Action? DrawLandscapeSliceLate { get; init; }
/// T1: one full-buffer depth clear between the outside stage and the
/// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor
/// roots — outdoors the interiors must depth-test against terrain + exteriors and
/// appear only through punched apertures.
public Action? ClearDepthForInterior { get; init; }
public Action? DrawExitPortalMasks { get; init; }
public Action? DrawCellParticles { get; init; }
public Action? DrawLookInPortalPunch { get; init; }
/// #131: Scene-pass draw of UNATTACHED emitters
/// (AttachedObjectId == 0) for interior-root frames — invoked once at the
/// end of the landscape stage (pre-clear). Outdoor roots draw them via
/// GameWindow's dedicated post-frame pass instead.
public Action? DrawUnattachedSceneParticles { get; init; }
public Action>? DrawDynamicsParticles { get; init; }
public Action? EmitDiagnostics { get; init; }
}
public sealed class RetailPViewFrameResult
{
public required PortalVisibilityFrame PortalFrame { get; init; }
public required ClipFrameAssembly ClipAssembly { get; init; }
public required HashSet DrawableCells { get; init; }
public required InteriorEntityPartition.Result Partition { get; init; }
}
public readonly record struct RetailPViewLandscapeSliceContext(
ClipViewSlice Slice,
IReadOnlyList OutdoorEntities);
/// #131/#132: the late landscape phase's per-slice payload —
/// outside-stage dynamics to mesh-draw, plus the full scene-particle owner
/// set (statics + dynamics cone survivors) the attached-emitter filter keys on.
public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice,
IReadOnlyList Dynamics,
IReadOnlyList ParticleOwners);
public readonly record struct RetailPViewCellSliceContext(
uint CellId,
ClipViewSlice Slice,
IReadOnlyList CellEntities);