From 966836895ff310ca8a0654731c3356677a0180a6 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 31 Aug 2026 03:22:01 +0200 Subject: [PATCH] fix(render): draw look-in dynamics only at retail walk turns --- .../Scene/RenderScenePViewFrameProduct.cs | 19 ++++-- src/AcDream.App/Rendering/Walk/WalkEvents.cs | 6 +- .../Rendering/Walk/WalkFrameDriver.cs | 59 ++++++++++++------- .../RenderScenePViewFrameProductTests.cs | 16 +++++ .../Rendering/Walk/WalkFrameDriverTests.cs | 21 ++++--- 5 files changed, 84 insertions(+), 37 deletions(-) diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index 0b3ea9f3..64999f3d 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -1196,6 +1196,17 @@ internal sealed class RenderScenePViewFrameBuilder _projectionIds.Clear(); LoadSceneIndices(input.Scene); + // The walk owns every look-in cell's complete object-list turn. + // Keep one set for both downstream dynamic routes: those objects + // belong to LookInObject exactly once, never to the generic + // landscape-outside route and never to DynamicLast. The former + // outside-route duplicate was especially damaging across an + // interior root's depth clear: its color survived the clear even + // when the later look-in draw was correctly occluded by a wall. + _lookInCellScratch.Clear(); + for (int index = 0; index < input.LookInCellTurns.Count; index++) + _lookInCellScratch.Add(input.LookInCellTurns[index]); + // Campaign FW3.2b-2: LandscapeOutdoorStatic, LandscapeBuildingShell, // and CellStatic no longer emit here — WalkFrameDriver draws every // outdoor static, building shell, and cell static (including @@ -1347,6 +1358,10 @@ internal sealed class RenderScenePViewFrameBuilder for (int i = 0; i < _dynamicCount; i++) { RenderProjectionRecord record = _dynamics[i]; + uint? parentCellId = ParentCell(in record); + if (parentCellId is uint parentCell + && _lookInCellScratch.Contains(parentCell)) + continue; Sphere(in record, out Vector3 center, out float radius); bool outside = RetailPViewRenderer.DynamicDrawsInOutsideStage( ParentCell(in record), @@ -1386,10 +1401,6 @@ internal sealed class RenderScenePViewFrameBuilder RenderFrameWriter writer, in RenderScenePViewBuildInput input) { - _lookInCellScratch.Clear(); - for (int index = 0; index < input.LookInCellTurns.Count; index++) - _lookInCellScratch.Add(input.LookInCellTurns[index]); - int count = 0; EnsureCapacity(ref _survivors, _dynamicCount); for (int i = 0; i < _dynamicCount; i++) diff --git a/src/AcDream.App/Rendering/Walk/WalkEvents.cs b/src/AcDream.App/Rendering/Walk/WalkEvents.cs index a2c911d3..a50a9f47 100644 --- a/src/AcDream.App/Rendering/Walk/WalkEvents.cs +++ b/src/AcDream.App/Rendering/Walk/WalkEvents.cs @@ -156,9 +156,9 @@ public interface IWalkEventSink /// (pc:432731-432732), then the exit-portal seals (pc:432785-432786) — /// BOTH unconditional for an interior root's /// own flood, whether or not a landscape turn just ran — and ONLY THEN - /// the flood's cells far-to-near (shell then contents per cell, same - /// discipline as 's per-cell contents - /// and a building's look-in). This hook fires at that later point, so + /// the flood's cells in two reverse passes: every EnvCell shell first, + /// then every cell object list (the same PView::DrawCells + /// discipline used by a building's look-in). This hook fires at that later point, so /// this is where a driver should actually draw . /// Building look-in floods are UNAFFECTED — retail calls /// DrawCells re-entrantly there with ov==0 and no diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index 2e187dc5..8c45fb17 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -104,13 +104,11 @@ internal interface IWalkFrameLeafRenderer void DrawTerrainSlice(int sliceIndex); /// One committed cell's EnvCell shell — - /// PView::DrawCells's DrawEnvCell @0x005a4abe, which - /// precedes DrawObjCellForDummies @0x005a4b0d (the cell's static - /// contents, appended to the stream instead — see - /// 's type doc comment) for every cell of - /// EVERY flood this stage drives (the ordinary interior root's own - /// DrawCells AND a building's look-in DrawCells both walk - /// this same shell-then-contents order). + /// PView::DrawCells's DrawEnvCell @0x005a4abe. Retail first + /// draws ALL shells in reverse cell_draw_list order, then starts a + /// second reverse loop for DrawObjCellForDummies @0x005a4b0d. + /// The ordinary interior-root flood and every building look-in flood use + /// this same two-pass discipline. void DrawCellShell(uint cellId); /// One landscape cell's or building shell's static-owner @@ -421,16 +419,17 @@ internal readonly struct WalkFrameEvent /// fires, so it only gets a /// mark ahead of whatever non-stream event comes next (the next building's /// alpha barrier, or the final mark at 's prepare step). -/// This single rule, combined with "shell before contents" per cell, +/// This single rule, combined with retail's two reverse flood passes (ALL +/// shells, then ALL contents), /// retail's own building order (alpha barrier → portal pass → shell — see /// 's doc comment), and retail's /// own interior-root DRAW order (landscape → clear → seals → the flood's own /// cells — see 's doc /// comment; this is NOT the order the walk's EVENTS fire in, which is /// breakpoint-entry order matching the FW0 oracle traces), is what produces -/// every ordering constraint the plan's frame script names: [cell1 shell] -/// [cell1 contents mark] [cell2 shell] …, [alpha barrier] [punch fan(s) + -/// look-in flood(s), each following the SAME shell-then-contents per-cell +/// every ordering constraint the plan's frame script names: [far shell] … +/// [near shell] [far contents] … [near contents], [alpha barrier] [punch +/// fan(s) + look-in flood(s), each following the SAME reverse two-pass /// discipline] [building shell content mark], [landscape (if exit views /// survived)] [interior depth clear] [exit-portal seals] [the interior /// root's own flood cells], and a final mark at Replay's prepare step. No @@ -440,8 +439,9 @@ internal readonly struct WalkFrameEvent /// root already ports), /// RenderDeviceD3D::DrawSortCell @0x0059f140 (building-before- /// DrawObjCell per landscape cell), PView::DrawCells @0x005a4840 -/// (DrawEnvCell @0x005a4abe before DrawObjCellForDummies -/// @0x005a4b0d per flooded cell; LScape::draw pc:432719, the depth +/// (the complete reverse DrawEnvCell loop @0x005a4a00–0x005a4ade +/// before the complete reverse DrawObjCellForDummies loop +/// @0x005a4ade–0x005a4b2d; LScape::draw pc:432719, the depth /// clear pc:432731-432732, and the exit-portal seals pc:432785-432786 — /// ALL strictly before the flood's own cells, though the event marking /// DrawCells entry fires before all three), RenderDeviceD3D::DrawBuilding @@ -878,8 +878,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource for (int i = 0; i < cells.Count; i++) InteriorFloodCells.Add(cells[i]); - for (int i = 0; i < cells.Count; i++) - EmitCellTurn(WalkDrawStage.CellStatic, cells[i]); + EmitFloodTurns(WalkDrawStage.CellStatic, cells); } // ------------------------------------------------------------------ @@ -949,19 +948,36 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource // Any other stage (LookInStatic) is a building's look-in flood: // retail calls DrawCells re-entrantly there with no landscape/clear/ // seal step, so its DC event already fires at the real draw point — - // record immediately, unchanged from before this correction. - for (int i = 0; i < cells.Count; i++) - EmitCellTurn(stage, cells[i]); + // record immediately. PView::DrawCells uses two complete reverse + // loops: every EnvCell shell first, then every cell object list. + EmitFloodTurns(stage, cells); } - private void EmitCellTurn(WalkDrawStage stage, uint cellId) + private void EmitFloodTurns(WalkDrawStage stage, IReadOnlyList cells) + { + // PView::DrawCells @0x005A4840, loop 2 (005A4A00–005A4ADE): + // cell_draw_list[count - 1] down to zero, DrawEnvCell only. + for (int i = cells.Count - 1; i >= 0; i--) + { + MarkIfGrown(); + _events.Add(WalkFrameEvent.CellShell(cells[i])); + } + + // Loop 3 (005A4ADE–005A4B2D): restart at count - 1 and draw each + // complete object cell. Keep a stream mark per cell so the packed + // static records and its dynamic/particle tail stay at that exact + // retail turn. + for (int i = cells.Count - 1; i >= 0; i--) + EmitCellContentsTurn(stage, cells[i]); + } + + private void EmitCellContentsTurn(WalkDrawStage stage, uint cellId) { - MarkIfGrown(); - _events.Add(WalkFrameEvent.CellShell(cellId)); WalkFrameStaticRecords records = _worldData.GetCellStatics(cellId); _populator.PopulateCell( _stream, stage, cellId, records.Records, records.TupleLandblockId, _cameraWorldPosition, _viewProjection); + MarkIfGrown(); if (stage == WalkDrawStage.LookInStatic) { // Retail draws a look-in cell's complete object list at this @@ -970,7 +986,6 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource // shells, which let the cathedral's 0x112 remote player overpaint // opaque walls. Keep animation/fade in the packed route, but replay // it here between this cell's content and the building shell. - MarkIfGrown(); int routeIndex = _lookInRouteIndex++; LookInCellTurns.Add(cellId); LookInCells.Add(cellId); diff --git a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs index 3402154a..4cf9975e 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs @@ -193,10 +193,14 @@ public sealed class RenderScenePViewFrameProductTests 0x0100_0000_0000_0022, RenderProjectionClass.LiveDynamicRoot, parentCell: lookInCell); + RenderProjectionRecord outdoorDynamic = Record( + 0x0100_0000_0000_0023, + RenderProjectionClass.LiveDynamicRoot); scene.Apply( [ RenderProjectionDelta.Register(Generation, 1, rootDynamic), RenderProjectionDelta.Register(Generation, 2, lookInDynamic), + RenderProjectionDelta.Register(Generation, 3, outdoorDynamic), ]); PortalVisibilityFrame portal = Portal(Cell); @@ -235,6 +239,18 @@ public sealed class RenderScenePViewFrameProductTests .ToArray() .Select(static record => record.Id)); + RenderFrameCandidateRange outside = Assert.Single( + frame.RouteRanges.ToArray(), + range => range.Route + == RenderFrameCandidateRoute.LandscapeOutsideDynamic); + RenderProjectionId[] outsideIds = frame.RouteCandidates + .Slice(outside.Offset, outside.Count) + .ToArray() + .Select(static record => record.Id) + .ToArray(); + Assert.Contains(outdoorDynamic.Id, outsideIds); + Assert.DoesNotContain(lookInDynamic.Id, outsideIds); + RenderFrameCandidateRange dynamicLast = Assert.Single( frame.RouteRanges.ToArray(), range => range.Route == RenderFrameCandidateRoute.DynamicLast); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index 3d99ed61..9e5210f1 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -25,7 +25,8 @@ namespace AcDream.App.Tests.Rendering.Walk; /// Campaign FW stage FW3.2b-1: 's headless /// referee suite — proves that driving with the /// driver as its produces retail's own turn -/// order (shell-then-contents per cell, flush-before-every-leaf-action, +/// order (all shells then all contents in reverse flood order, +/// flush-before-every-leaf-action, /// content-before-punch) through the REAL /// onto a — never a mock of the submission /// path itself. No production wiring is exercised (WorldSceneRenderer @@ -172,10 +173,9 @@ public sealed class WalkFrameDriverTests Plane = new WalkPlane(new Vector3(0, 0, facingViewer ? 1f : -1f), facingViewer ? -z : z), }; - // ── Deliverable: RunFrame drives an interior two-cell flood; shell - // precedes contents per cell, and a flush happens exactly at the point - // the NEXT cell's shell needs the stream clear (never before, never - // batched across cells within this stage's turn-by-turn discipline). ── + // ── Deliverable: RunFrame drives an interior two-cell flood using + // PView::DrawCells' exact two reverse loops: ALL shells far-to-near, + // then ALL object cells far-to-near. ───────────────────────────────── // ── Deliverable (2026-08-30 decomp correction): PView::DrawCells // @0x005a4840's actual DRAW order for an interior root's OWN flood is @@ -186,7 +186,7 @@ public sealed class WalkFrameDriverTests // (pc:432785-432786), and ONLY THEN the flood's own cells far-to-near. // This case has a surviving exit view (ov=1): DC records the flood list // (no draw), the landscape turn runs (flush no-op, sky, terrain), THEN - // clear, seals, and the two flood cells shell-then-contents. ────────── + // clear, seals, then all shells and all contents in reverse order. ─── [Fact] public void RunFrame_InteriorFloodWithExitView_DrawsLandscapeThenClearSealsThenFloodCells() @@ -258,7 +258,8 @@ public sealed class WalkFrameDriverTests new[] { "SKY", "TERRAIN:0", "CLEAR", "SEALS", - "SHELL:00000100", "FLUSH:1:CellStatic", "SHELL:00000101", "FLUSH:1:CellStatic", + "SHELL:00000101", "SHELL:00000100", + "FLUSH:1:CellStatic", "FLUSH:1:CellStatic", }, log); @@ -329,7 +330,11 @@ public sealed class WalkFrameDriverTests // No SKY/TERRAIN — ov==0 means DrawInside never calls DrawLandscape // at all — but CLEAR/SEALS still fire unconditionally. Assert.Equal( - new[] { "CLEAR", "SEALS", "SHELL:00000100", "FLUSH:1:CellStatic", "SHELL:00000101", "FLUSH:1:CellStatic" }, + new[] + { + "CLEAR", "SEALS", "SHELL:00000101", "SHELL:00000100", + "FLUSH:1:CellStatic", "FLUSH:1:CellStatic", + }, log); List mdiCalls =