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