diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 324c7fe6..782c5374 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -171,6 +171,7 @@ public sealed class GameWindow : IDisposable // each frame on an indoor root (null on the outdoor root). private AcDream.App.Rendering.InteriorRenderer? _interiorRenderer; private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; + private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition; // Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain @@ -1845,6 +1846,10 @@ public sealed class GameWindow : IDisposable _clipFrame ??= ClipFrame.NoClip(); _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( _gl, _clipFrame, _envCellRenderer!, _wbDrawDispatcher!); + + // T1: invisible portal depth writes (seal/punch) — retail + // DrawPortalPolyInternal (Ghidra 0x0059bc90). + _portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl); } // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) @@ -7632,24 +7637,26 @@ public sealed class GameWindow : IDisposable renderSky, kf, environOverrideActive), - // The depth clear is a doorway "look-in" trick: clear depth inside a door/window - // region so the cell seen THROUGH it draws over the terrain drawn through that - // region (the indoor root looking out). For the OUTDOOR-node root the only - // OutsideView slice is the FULL-SCREEN base terrain, so clearing its depth wipes the - // entire depth buffer AFTER terrain/exteriors/player drew — the flooded building - // interiors (cellars) would then paint over everything (cellar in front of the - // player; building interiors through the ground). Outdoors the interiors must - // depth-test against terrain+exteriors and appear only through real door openings, - // so issue NO depth clear. Interior roots keep the doorway clear (unchanged). - ClearDepthSlice = clipRoot.IsOutdoorNode + // T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840). + // INTERIOR roots: one FULL depth clear between the outside stage and + // the interior stage, then SEALS re-stamp every outside-leading + // portal's TRUE depth (#108's protective mechanism). OUTDOOR roots: + // no clear (the world's depth must survive) — instead each flooded + // building's entry aperture gets a far-Z PUNCH so its interior shows + // through the doorway. Both are safe ONLY because dynamics draw LAST + // (DrawDynamicsLast) — the first BR-2 attempt punched after dynamics + // and erased the player (reverted 88be519). + ClearDepthForInterior = clipRoot.IsOutdoorNode ? null - : slice => + : () => { - bool zc = BeginDoorwayScissor(true, slice.NdcAabb); + _gl.Disable(EnableCap.ScissorTest); + _gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson) _gl.Clear(ClearBufferMask.DepthBufferBit); - if (zc) - _gl.Disable(EnableCap.ScissorTest); }, + DrawExitPortalMasks = sliceCtx => + DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, + forceFarZ: clipRoot.IsOutdoorNode), DrawCellParticles = sliceCtx => DrawRetailPViewCellParticles(sliceCtx, camera, camPos), EmitDiagnostics = result => @@ -7703,45 +7710,32 @@ public sealed class GameWindow : IDisposable || pviewResult.ClipAssembly.OutsideViewSlices.Length > 0) ? "pviewScoped" : sigSceneParticles; - sigOutdoorSceneryDrawn = pviewResult.Partition.Outdoor.Count > 0 + sigOutdoorSceneryDrawn = pviewResult.Partition.OutdoorStatic.Count > 0 && pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; - // Render unification: DrawInside draws the Outdoor bucket (through the landscape - // slice) and the per-cell ByCell buckets, but NOT LiveDynamic — server entities with - // no resolved ParentCellId (the transient just-spawned / unpositioned case the old - // outdoor branch drew at the bottom of its block). Preserve that draw for the - // outdoor-node root so no live entity blinks out outdoors (spec section 10 regression - // guard). DrawInside's tail clears entity clip routing and disables clip distances, so - // visibleCellIds:null draws them unclipped — identical to the old outdoor path. - if (clipRoot.IsOutdoorNode - && _interiorRenderer is not null - && pviewResult.Partition.LiveDynamic.Count > 0) - { - _interiorRenderer.DrawEntityBucket( - camera, frustum, playerLb, animatedIds, - pviewResult.Partition.LiveDynamic, visibleCellIds: null); - sigLiveDynamicDrawnCount = pviewResult.Partition.LiveDynamic.Count; - } + // T1: DrawInside now draws ALL dynamics itself in its single + // last entity pass (DrawDynamicsLast) — the old LiveDynamic + // top-up draw is gone. + sigLiveDynamicDrawnCount = pviewResult.Partition.Dynamics.Count; } else { - bool liveDynamicsDrawn = false; - if (_interiorRenderer is not null) { _outdoorRootNoCells.Clear(); var outdoorPartition = AcDream.App.Rendering.InteriorEntityPartition.Partition( _outdoorRootNoCells, _worldState.LandblockEntries); - sigOutdoorRootObjectCount = outdoorPartition.Outdoor.Count; + sigOutdoorRootObjectCount = outdoorPartition.OutdoorStatic.Count; - if (outdoorPartition.Outdoor.Count > 0) + // T1: static world first (shells + scenery)… + if (outdoorPartition.OutdoorStatic.Count > 0) { _interiorRenderer.DrawEntityBucket( camera, frustum, playerLb, animatedIds, - outdoorPartition.Outdoor, + outdoorPartition.OutdoorStatic, visibleCellIds: null); } @@ -7795,6 +7789,12 @@ public sealed class GameWindow : IDisposable MaxSeedDistance = 48f, LandblockEntries = _worldState.LandblockEntries, SetTerrainClipUbo = uboId => _terrain?.SetClipUbo(uboId), + // T1: look-in — PUNCH building entry apertures to far-Z so + // the flooded interior shows through the doorway. Safe: + // dynamics draw after this whole block. + DrawExitPortalMasks = sliceCtx => + DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, + forceFarZ: true), }); if (portalResult is not null) @@ -7804,21 +7804,20 @@ public sealed class GameWindow : IDisposable sigExteriorClipAssembly = portalResult.ClipAssembly; sigExteriorDrawableCells = portalResult.DrawableCells; sigExteriorPartition = portalResult.Partition; - liveDynamicsDrawn = portalResult.Partition.LiveDynamic.Count > 0; - if (liveDynamicsDrawn) - sigLiveDynamicDrawnCount = portalResult.Partition.LiveDynamic.Count; } } - if (!liveDynamicsDrawn && outdoorPartition.LiveDynamic.Count > 0) + // T1: …then ALL dynamics last (after the look-in punched + + // drew interiors), depth-tested, never hard-clipped. + if (outdoorPartition.Dynamics.Count > 0) { - sigLiveDynamicDrawnCount = outdoorPartition.LiveDynamic.Count; + sigLiveDynamicDrawnCount = outdoorPartition.Dynamics.Count; _interiorRenderer.DrawEntityBucket( camera, frustum, playerLb, animatedIds, - outdoorPartition.LiveDynamic, + outdoorPartition.Dynamics, visibleCellIds: null); } } @@ -9333,7 +9332,7 @@ public sealed class GameWindow : IDisposable if (partition is not null) { int shellTotal = 0, shellMesh = 0; - foreach (var e in partition.Outdoor) + foreach (var e in partition.OutdoorStatic) if (e.IsBuildingShell) { shellTotal++; if (e.MeshRefs.Count > 0) shellMesh++; } sb.Append(" bshell=").Append(shellTotal).Append('/').Append(shellMesh); } @@ -9457,8 +9456,8 @@ public sealed class GameWindow : IDisposable } if (keys.Count > MaxCells) sb.Append(",..."); - sb.Append("] out=").Append(partition.Outdoor.Count) - .Append(" live=").Append(partition.LiveDynamic.Count); + sb.Append("] out=").Append(partition.OutdoorStatic.Count) + .Append(" live=").Append(partition.Dynamics.Count); return sb.ToString(); } @@ -9550,6 +9549,51 @@ public sealed class GameWindow : IDisposable DisableClipDistances(); } + // T1: retail's invisible portal depth writes on every outside-leading + // portal (other_cell_id==0xFFFF) of this cell, clipped to the slice's view + // region — D3DPolyRender::DrawPortalPolyInternal (Ghidra 0x0059bc90), + // dispatched by PView::DrawCells (Ghidra 0x005a4840). forceFarZ is + // retail's maxZ1(true)/maxZ2(false) selector: + // • INTERIOR root (false → SEAL, true depth): after the full depth clear, + // stamp the door plane so interior geometry beyond the door z-fails + // inside the aperture and the terrain drawn through the outside view + // keeps its pixels (#108's protective mechanism). + // • OUTDOOR root / look-in (true → PUNCH, far depth): erase the world's + // depth inside a flooded building's entry aperture so the interior + // drawn next shows THROUGH the doorway. + // Both are safe ONLY because dynamics draw last (DrawDynamicsLast) — the + // first BR-2 attempt punched after dynamics and erased the player + // (reverted 88be519). Wiring only — the draw lives in + // PortalDepthMaskRenderer. + private void DrawRetailPViewPortalDepthWrite( + AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx, + System.Numerics.Matrix4x4 viewProjection, + bool forceFarZ) + { + if (_portalDepthMask is null) + return; + if (!_cellVisibility.TryGetCell(sliceCtx.CellId, out var cell) || cell is null) + return; + + Span world = stackalloc System.Numerics.Vector3[32]; + for (int i = 0; i < cell.Portals.Count; i++) + { + if (cell.Portals[i].OtherCellId != 0xFFFF) + continue; // depth writes apply to portals leading OUTSIDE only + if (i >= cell.PortalPolygons.Count) + break; + var localVerts = cell.PortalPolygons[i]; + if (localVerts.Length < 3) + continue; + + int n = System.Math.Min(localVerts.Length, world.Length); + for (int v = 0; v < n; v++) + world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform); + + _portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ); + } + } + private void DrawRetailPViewCellParticles( AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx, ICamera camera, @@ -11773,6 +11817,7 @@ public sealed class GameWindow : IDisposable _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context _wbDrawDispatcher?.Dispose(); _envCellRenderer?.Dispose(); // Phase A8 + _portalDepthMask?.Dispose(); // T1 _clipFrame?.Dispose(); // Phase U.3 _skyRenderer?.Dispose(); // depends on sampler cache; dispose first _samplerCache?.Dispose(); diff --git a/src/AcDream.App/Rendering/InteriorEntityPartition.cs b/src/AcDream.App/Rendering/InteriorEntityPartition.cs index 300abcd5..276088e4 100644 --- a/src/AcDream.App/Rendering/InteriorEntityPartition.cs +++ b/src/AcDream.App/Rendering/InteriorEntityPartition.cs @@ -6,17 +6,35 @@ namespace AcDream.App.Rendering; /// /// Splits a frame's landblock entities into the draw buckets used by the -/// retail-style DrawInside flood. Indoor ownership wins for live dynamics too: -/// a player, NPC, door, or item with a current indoor ParentCellId belongs to -/// that cell's portal-clipped object list, not a global overlay pass. +/// retail-style DrawInside flood. +/// +/// T1 (fused BR-2/3, 2026-06-11) — retail draw-order contract: the +/// frame draws STATIC world first (terrain, building shells, scenery, then +/// flooded interior cells + their static object lists), and every DYNAMIC +/// (server-spawned: player, NPCs, doors, items) draws LAST, depth-tested, +/// never hard-clipped. This is what makes the aperture depth punch safe — +/// when the punch erases depth inside a doorway, no dynamic has been drawn +/// yet, so nothing visible is destroyed (retail: objects draw per cell AFTER +/// cells, PView::DrawCells epilogue Ghidra 0x005a4840; the first BR-2 attempt +/// punched after dynamics and erased the player, reverted 88be519). +/// +/// +/// — indoor STATICS (dat-baked, ServerGuid==0) +/// per visible cell, drawn with their cell. +/// — outdoor statics (building +/// shells, scenery stabs), drawn with the world/landscape pass. +/// — ALL server-spawned entities +/// (ServerGuid != 0) regardless of cell, plus unresolved-cell live entities; +/// drawn in the frame's single LAST entity pass. +/// /// public static class InteriorEntityPartition { public sealed class Result { public Dictionary> ByCell { get; } = new(); - public List Outdoor { get; } = new(); - public List LiveDynamic { get; } = new(); + public List OutdoorStatic { get; } = new(); + public List Dynamics { get; } = new(); } public static Result Partition( @@ -32,46 +50,29 @@ public static class InteriorEntityPartition { if (e.MeshRefs.Count == 0) continue; + // Retail contract: every server-spawned entity is a DYNAMIC + // and draws in the last pass — indoor, outdoor, or unresolved. if (e.ServerGuid != 0) { - if (e.ParentCellId is uint liveCell) - AddByCellOrOutdoor(e, liveCell, visibleCells, result); - else - result.LiveDynamic.Add(e); + result.Dynamics.Add(e); } - else if (e.ParentCellId is uint cell) + else if (e.ParentCellId is uint cell && IsIndoorCellId(cell)) { - AddByCellOrOutdoor(e, cell, visibleCells, result); + if (!visibleCells.Contains(cell)) + continue; + if (!result.ByCell.TryGetValue(cell, out var list)) + result.ByCell[cell] = list = new List(); + list.Add(e); } else { - result.Outdoor.Add(e); + result.OutdoorStatic.Add(e); } } } return result; } - private static void AddByCellOrOutdoor( - WorldEntity entity, - uint cellId, - HashSet visibleCells, - Result result) - { - if (!IsIndoorCellId(cellId)) - { - result.Outdoor.Add(entity); - return; - } - - if (!visibleCells.Contains(cellId)) - return; - - if (!result.ByCell.TryGetValue(cellId, out var list)) - result.ByCell[cellId] = list = new List(); - list.Add(entity); - } - private static bool IsIndoorCellId(uint cellId) { uint low = cellId & 0xFFFFu; diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index be309602..acc702ba 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -23,7 +23,6 @@ public sealed class RetailPViewRenderer new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty()); private readonly HashSet _oneCell = new(1); - private readonly Dictionary _oneCellSlot = new(1); // R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame). private readonly Dictionary> _buildingGroups = new(); @@ -90,20 +89,21 @@ public sealed class RetailPViewRenderer 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). DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition); UseIndoorMembershipOnlyRouting(); DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); - // #113 fix scope (#114): GL-clip the shells only for the OUTDOOR root — - // the case the flood replay validated (tight, stable door-aperture - // regions) and the one that produced the phantom staircase. The first - // user gate (2026-06-11) showed INDOOR clip regions are not yet - // draw-quality (chopped stairs / vanishing inner walls at exits / - // see-through to neighbour rooms at the meeting hall) — indoor roots - // stay unclipped (yesterday's user-accepted state) until #114 brings - // the indoor regions to retail's pixel-exact crop. - DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells, - clipShells: ctx.RootCell.IsOutdoorNode); + DrawEnvCellShells(pvFrame); DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); + DrawDynamicsLast(ctx, partition); return result; } @@ -201,10 +201,13 @@ public sealed class RetailPViewRenderer ctx.EmitDiagnostics?.Invoke(result); + // T1: look-in order — punch the apertures, then interior cells WHOLE, + // then the looked-into building's per-cell statics. Dynamics are NOT + // drawn here: they belong exclusively to the frame's single last + // entity pass (the outdoor root's DrawDynamicsLast), which prevents + // double-draws of entities inside looked-into buildings. DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); - // DrawPortal is the from-outside look-in path — same validated outdoor - // regime as the outdoor root (see #114 scope note in DrawInside). - DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells, clipShells: true); + DrawEnvCellShells(pvFrame); DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); RestoreNoClip(ctx.SetTerrainClipUbo); @@ -228,11 +231,18 @@ public sealed class RetailPViewRenderer if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled) EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex); probeSliceIndex++; - ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, partition.Outdoor)); + ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, partition.OutdoorStatic)); } - foreach (var slice in clipAssembly.OutsideViewSlices) - ctx.ClearDepthSlice?.Invoke(slice); + // 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(); } @@ -342,68 +352,43 @@ public sealed class RetailPViewRenderer } } - private void DrawEnvCellShells( - IRetailPViewCellDrawCallbacks ctx, - PortalVisibilityFrame pvFrame, - ClipFrameAssembly clipAssembly, - HashSet drawableCells, // param kept this task; removed in Task 4 - bool clipShells) + private void DrawEnvCellShells(PortalVisibilityFrame pvFrame) { - // Retail DrawCells Loop 2: every visible cell's shell, reverse cell_draw_list - // (far→near), per portal_view slice. No drawableCells filter — a cell without a - // clip-slot falls through GetCellSlicesOrNoClip to NoClipSlice and draws unclipped - // (sealed; per-slice trim returns in Task 4). - // - // #113 (2026-06-10): the per-slice clip MUST actually clip. Retail clips drawn - // CELL geometry to the accumulated portal view — Render::set_view (:343750) - // installs the view polygon's edge planes and DrawEnvCell submits every cell - // polygon with planeMask=0xffffffff (:427922) through ACRender::polyClipFinish. - // Our equivalent (UseShellClipRouting → mesh_modern.vert gl_ClipDistance) was - // routed but INERT: gl_ClipDistance writes are ignored unless GL_CLIP_DISTANCEi - // is enabled, and no caller enabled it for this pass — so flooded interior cells - // drew WHOLE, painting interior geometry across exterior walls (the Holtburg - // meeting-hall phantom staircase, AAB3 0x100 stair cell coincident with the - // shell's west wall). Self-contained per feedback_render_self_contained_gl_state; - // no early-outs between enable and disable. Slot-0 slices (SSBO count=0) still - // pass-all — the assembler's >8-plane scissor fallback remains unimplemented - // (rare; Issue113MeetingHallFloodTests pins 0 such slices at the hall). - // Characters/statics stay unclipped (DrawCellObjectLists): retail's mesh path is - // viewcone-check + BoundingType handling, and hard-clipping slices characters at - // doorways (the original UseIndoorMembershipOnlyRouting observation). - // - // clipShells (#114 scope, 2026-06-11): true only for outdoor-eye roots. - // The first user gate showed indoor clip regions are not draw-quality - // yet (chopped stairs / vanishing walls at exits) — indoor roots draw - // unclipped until #114 lands pixel-exact indoor regions. - if (clipShells) - for (int i = 0; i < ClipFrame.MaxPlanes; i++) - _gl.Enable(Silk.NET.OpenGL.EnableCap.ClipDistance0 + i); - + // 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)) { - uint cellId = entry.CellId; _oneCell.Clear(); - _oneCell.Add(cellId); - - var slices = GetCellSlicesOrNoClip(clipAssembly, cellId); - - // BR-2 phantom-site probe: which cells draw their shell with a - // pass-all slice (NoClipSlice fallback or assembler slot-0)? - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled) - EmitPhantomShellProbe(cellId, slices, clipShells, - hadSlot: clipAssembly.CellIdToViewSlices.ContainsKey(cellId)); - - foreach (var slice in slices) - { - UseShellClipRouting(cellId, slice); - _envCells.Render(WbRenderPass.Opaque, _oneCell); - _envCells.Render(WbRenderPass.Transparent, _oneCell); - } + _oneCell.Add(entry.CellId); + _envCells.Render(WbRenderPass.Opaque, _oneCell); + _envCells.Render(WbRenderPass.Transparent, _oneCell); } + } - if (clipShells) - for (int i = 0; i < ClipFrame.MaxPlanes; i++) - _gl.Disable(Silk.NET.OpenGL.EnableCap.ClipDistance0 + i); + // 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. + private void DrawDynamicsLast( + IRetailPViewCellDrawContext ctx, + InteriorEntityPartition.Result partition) + { + if (partition.Dynamics.Count == 0) + return; + + UseIndoorMembershipOnlyRouting(); + DrawEntityBucket(ctx, partition.Dynamics, visibleCellIds: null); } private void DrawCellObjectLists( @@ -413,6 +398,11 @@ public sealed class RetailPViewRenderer HashSet drawableCells, InteriorEntityPartition.Result partition) { + // 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). Unclipped; per-view sphere culling + // (viewconeCheck) is T3. for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) { uint cellId = pvFrame.OrderedVisibleCells[i]; @@ -425,8 +415,8 @@ public sealed class RetailPViewRenderer _oneCell.Clear(); _oneCell.Add(cellId); - // BR-2 phantom-site probe: entity buckets draw unclipped + - // un-viewcone'd by design — log the per-cell exposure. + // BR-2 phantom-site probe: static buckets draw unclipped + + // un-viewcone'd until T3 — log the per-cell exposure. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled) EmitPhantomObjsProbe(cellId, bucket.Count); @@ -440,29 +430,11 @@ public sealed class RetailPViewRenderer // 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 (plan §BR-2). - private readonly Dictionary _phantomShellSig = new(); + // 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 EmitPhantomShellProbe(uint cellId, ClipViewSlice[] slices, bool clipShells, bool hadSlot) - { - var sb = new System.Text.StringBuilder(96); - sb.Append(clipShells ? "clip=on" : "clip=OFF"); - sb.Append(hadSlot ? " slot=yes" : " slot=NONE(pass-all)"); - sb.Append(" slices=["); - for (int i = 0; i < slices.Length; i++) - { - if (i > 0) sb.Append(','); - sb.Append(slices[i].Slot).Append(':').Append(slices[i].Planes.Length).Append("pl"); - if (slices[i].Slot == 0) sb.Append("(PASS-ALL)"); - } - sb.Append(']'); - var sig = sb.ToString(); - if (_phantomShellSig.TryGetValue(cellId, out var prev) && prev == sig) return; - _phantomShellSig[cellId] = sig; - Console.WriteLine($"[phantom-shell] cell=0x{cellId:X8} {sig}"); - } - private void EmitPhantomObjsProbe(uint cellId, int bucketCount) { if (_phantomObjsSig.TryGetValue(cellId, out var prev) && prev == bucketCount) return; @@ -483,25 +455,14 @@ public sealed class RetailPViewRenderer private void UseIndoorMembershipOnlyRouting() { - // For MESHES (characters, statics) retail's DrawMesh performs portal-view - // visibility checks (Render::viewconeCheck on the drawing sphere) rather - // than hard per-poly clipping — feeding the 2D views into gl_ClipDistance - // slices characters at stair/door boundaries, which retail does not do. - // CELL SHELL geometry is different: retail clips it to the portal view - // (planeMask=0xffffffff per cell polygon, decomp :427922 + :343750) — - // DrawEnvCellShells enables exactly that (#113). + // 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 UseShellClipRouting(uint cellId, ClipViewSlice slice) - { - _oneCellSlot.Clear(); - _oneCellSlot[cellId] = slice.Slot; - _envCells.SetClipRouting(_oneCellSlot); - _entities.ClearClipRouting(); - } - private void DrawEntityBucket( IRetailPViewCellDrawContext ctx, IReadOnlyList bucket, @@ -575,7 +536,11 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; } public required Action SetTerrainClipUbo { get; init; } public required Action DrawLandscapeSlice { get; init; } - public Action? ClearDepthSlice { 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? EmitDiagnostics { get; init; } diff --git a/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs b/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs index 213fe451..24c4b2fe 100644 --- a/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs @@ -6,6 +6,19 @@ using Xunit; namespace AcDream.App.Tests.Rendering; +/// +/// T1 (fused BR-2/3) partition contract — retail draw order: static world +/// first, every server-spawned DYNAMIC in the frame's single LAST pass. +/// +/// ALL ServerGuid != 0 entities land in Dynamics regardless of +/// cell (indoor, outdoor, unresolved, even non-visible cells — retail never +/// drops a live entity for visibility-set reasons; culling is the +/// viewcone's job, T3). +/// ByCell carries only dat-baked indoor statics of VISIBLE +/// cells (drawn with their cell). +/// OutdoorStatic carries shells/scenery (the world pass). +/// +/// public class InteriorEntityPartitionTests { private const uint CellA = 0xA9B40170; @@ -30,7 +43,7 @@ public class InteriorEntityPartitionTests (IReadOnlyDictionary?)null) }; [Fact] - public void Partitions_LiveAndStaticEntities_ByCellOutdoorAndFallback() + public void AllServerSpawned_GoToDynamics_StaticsSplitByCellAndOutdoor() { var unresolvedLive = Ent(1, serverGuid: 0x5000000A, parentCell: null); var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA); @@ -43,22 +56,25 @@ public class InteriorEntityPartitionTests var result = InteriorEntityPartition.Partition( visible, OneLb(0xA9B4FFFF, unresolvedLive, liveNpcInCell, staticA, staticB, scenery, liveOutdoor)); - Assert.Single(result.LiveDynamic); - Assert.Contains(unresolvedLive, result.LiveDynamic); + // Every server-spawned entity is a dynamic — drawn in the last pass. + Assert.Equal(3, result.Dynamics.Count); + Assert.Contains(unresolvedLive, result.Dynamics); + Assert.Contains(liveNpcInCell, result.Dynamics); + Assert.Contains(liveOutdoor, result.Dynamics); - Assert.Equal(2, result.ByCell[CellA].Count); - Assert.Contains(liveNpcInCell, result.ByCell[CellA]); + // Indoor statics ride with their (visible) cell. + Assert.Single(result.ByCell[CellA]); Assert.Contains(staticA, result.ByCell[CellA]); Assert.Single(result.ByCell[CellB]); Assert.Contains(staticB, result.ByCell[CellB]); - Assert.Equal(2, result.Outdoor.Count); - Assert.Contains(scenery, result.Outdoor); - Assert.Contains(liveOutdoor, result.Outdoor); + // Outdoor statics (shells/scenery) ride with the world pass. + Assert.Single(result.OutdoorStatic); + Assert.Contains(scenery, result.OutdoorStatic); } [Fact] - public void IndoorEntity_InNonVisibleCell_IsDropped() + public void HiddenCell_DropsStatics_ButNeverDynamics() { var staticHidden = Ent(3, serverGuid: 0, parentCell: HiddenCell); var liveHidden = Ent(4, serverGuid: 0x80001234, parentCell: HiddenCell); @@ -67,9 +83,14 @@ public class InteriorEntityPartitionTests var result = InteriorEntityPartition.Partition( visible, OneLb(0xA9B4FFFF, staticHidden, liveHidden)); + // A static in a non-flooded cell is not drawn this frame… Assert.False(result.ByCell.ContainsKey(HiddenCell)); - Assert.Empty(result.Outdoor); - Assert.Empty(result.LiveDynamic); + Assert.Empty(result.OutdoorStatic); + + // …but a LIVE entity is never dropped by the visibility set (the old + // contract dropped it — the audit's livedynamic-invisible divergence). + Assert.Single(result.Dynamics); + Assert.Contains(liveHidden, result.Dynamics); } [Fact] @@ -85,5 +106,7 @@ public class InteriorEntityPartitionTests new HashSet { CellA }, OneLb(0xA9B4FFFF, noMesh)); Assert.False(result.ByCell.ContainsKey(CellA)); + Assert.Empty(result.Dynamics); + Assert.Empty(result.OutdoorStatic); } }