fix(render): draw look-in dynamics only at retail walk turns

This commit is contained in:
Erik 2026-08-31 03:22:01 +02:00
parent 572de1ec30
commit 966836895f
5 changed files with 84 additions and 37 deletions

View file

@ -1196,6 +1196,17 @@ internal sealed class RenderScenePViewFrameBuilder
_projectionIds.Clear(); _projectionIds.Clear();
LoadSceneIndices(input.Scene); 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, // Campaign FW3.2b-2: LandscapeOutdoorStatic, LandscapeBuildingShell,
// and CellStatic no longer emit here — WalkFrameDriver draws every // and CellStatic no longer emit here — WalkFrameDriver draws every
// outdoor static, building shell, and cell static (including // outdoor static, building shell, and cell static (including
@ -1347,6 +1358,10 @@ internal sealed class RenderScenePViewFrameBuilder
for (int i = 0; i < _dynamicCount; i++) for (int i = 0; i < _dynamicCount; i++)
{ {
RenderProjectionRecord record = _dynamics[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); Sphere(in record, out Vector3 center, out float radius);
bool outside = RetailPViewRenderer.DynamicDrawsInOutsideStage( bool outside = RetailPViewRenderer.DynamicDrawsInOutsideStage(
ParentCell(in record), ParentCell(in record),
@ -1386,10 +1401,6 @@ internal sealed class RenderScenePViewFrameBuilder
RenderFrameWriter writer, RenderFrameWriter writer,
in RenderScenePViewBuildInput input) in RenderScenePViewBuildInput input)
{ {
_lookInCellScratch.Clear();
for (int index = 0; index < input.LookInCellTurns.Count; index++)
_lookInCellScratch.Add(input.LookInCellTurns[index]);
int count = 0; int count = 0;
EnsureCapacity(ref _survivors, _dynamicCount); EnsureCapacity(ref _survivors, _dynamicCount);
for (int i = 0; i < _dynamicCount; i++) for (int i = 0; i < _dynamicCount; i++)

View file

@ -156,9 +156,9 @@ public interface IWalkEventSink
/// (pc:432731-432732), then the exit-portal seals (pc:432785-432786) — /// (pc:432731-432732), then the exit-portal seals (pc:432785-432786) —
/// BOTH unconditional for an interior root's /// BOTH unconditional for an interior root's
/// own flood, whether or not a landscape turn just ran — and ONLY THEN /// 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 /// the flood's cells in two reverse passes: every EnvCell shell first,
/// discipline as <see cref="OnLandscapeCellTurn"/>'s per-cell contents /// then every cell object list (the same <c>PView::DrawCells</c>
/// and a building's look-in). This hook fires at that later point, so /// discipline used by a building's look-in). This hook fires at that later point, so
/// this is where a driver should actually draw <paramref name="cells"/>. /// this is where a driver should actually draw <paramref name="cells"/>.
/// Building look-in floods are UNAFFECTED — retail calls /// Building look-in floods are UNAFFECTED — retail calls
/// <c>DrawCells</c> re-entrantly there with <c>ov==0</c> and no /// <c>DrawCells</c> re-entrantly there with <c>ov==0</c> and no

View file

@ -104,13 +104,11 @@ internal interface IWalkFrameLeafRenderer
void DrawTerrainSlice(int sliceIndex); void DrawTerrainSlice(int sliceIndex);
/// <summary>One committed cell's EnvCell shell — /// <summary>One committed cell's EnvCell shell —
/// <c>PView::DrawCells</c>'s <c>DrawEnvCell</c> @0x005a4abe, which /// <c>PView::DrawCells</c>'s <c>DrawEnvCell</c> @0x005a4abe. Retail first
/// precedes <c>DrawObjCellForDummies</c> @0x005a4b0d (the cell's static /// draws ALL shells in reverse <c>cell_draw_list</c> order, then starts a
/// contents, appended to the stream instead — see /// second reverse loop for <c>DrawObjCellForDummies</c> @0x005a4b0d.
/// <see cref="WalkFrameDriver"/>'s type doc comment) for every cell of /// The ordinary interior-root flood and every building look-in flood use
/// EVERY flood this stage drives (the ordinary interior root's own /// this same two-pass discipline.</summary>
/// <c>DrawCells</c> AND a building's look-in <c>DrawCells</c> both walk
/// this same shell-then-contents order).</summary>
void DrawCellShell(uint cellId); void DrawCellShell(uint cellId);
/// <summary>One landscape cell's or building shell's static-owner /// <summary>One landscape cell's or building shell's static-owner
@ -421,16 +419,17 @@ internal readonly struct WalkFrameEvent
/// <see cref="IWalkEventSink.OnBuildingShellTurn"/> fires, so it only gets a /// <see cref="IWalkEventSink.OnBuildingShellTurn"/> fires, so it only gets a
/// mark ahead of whatever non-stream event comes next (the next building's /// mark ahead of whatever non-stream event comes next (the next building's
/// alpha barrier, or the final mark at <see cref="Replay"/>'s prepare step). /// alpha barrier, or the final mark at <see cref="Replay"/>'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 /// retail's own building order (alpha barrier → portal pass → shell — see
/// <see cref="RetailFrameWalk.DrawBuilding"/>'s doc comment), and retail's /// <see cref="RetailFrameWalk.DrawBuilding"/>'s doc comment), and retail's
/// own interior-root DRAW order (landscape → clear → seals → the flood's own /// own interior-root DRAW order (landscape → clear → seals → the flood's own
/// cells — see <see cref="IWalkEventSink.OnInteriorFloodDrawTurn"/>'s doc /// cells — see <see cref="IWalkEventSink.OnInteriorFloodDrawTurn"/>'s doc
/// comment; this is NOT the order the walk's EVENTS fire in, which is /// 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 /// breakpoint-entry order matching the FW0 oracle traces), is what produces
/// every ordering constraint the plan's frame script names: [cell1 shell] /// every ordering constraint the plan's frame script names: [far shell] …
/// [cell1 contents mark] [cell2 shell] …, [alpha barrier] [punch fan(s) + /// [near shell] [far contents] … [near contents], [alpha barrier] [punch
/// look-in flood(s), each following the SAME shell-then-contents per-cell /// fan(s) + look-in flood(s), each following the SAME reverse two-pass
/// discipline] [building shell content mark], [landscape (if exit views /// discipline] [building shell content mark], [landscape (if exit views
/// survived)] [interior depth clear] [exit-portal seals] [the interior /// survived)] [interior depth clear] [exit-portal seals] [the interior
/// root's own flood cells], and a final mark at Replay's prepare step. No /// root's own flood cells], and a final mark at Replay's prepare step. No
@ -440,8 +439,9 @@ internal readonly struct WalkFrameEvent
/// root <see cref="RetailFrameWalk.WalkFrame"/> already ports), /// root <see cref="RetailFrameWalk.WalkFrame"/> already ports),
/// <c>RenderDeviceD3D::DrawSortCell</c> @0x0059f140 (building-before- /// <c>RenderDeviceD3D::DrawSortCell</c> @0x0059f140 (building-before-
/// DrawObjCell per landscape cell), <c>PView::DrawCells</c> @0x005a4840 /// DrawObjCell per landscape cell), <c>PView::DrawCells</c> @0x005a4840
/// (<c>DrawEnvCell</c> @0x005a4abe before <c>DrawObjCellForDummies</c> /// (the complete reverse <c>DrawEnvCell</c> loop @0x005a4a000x005a4ade
/// @0x005a4b0d per flooded cell; <c>LScape::draw</c> pc:432719, the depth /// before the complete reverse <c>DrawObjCellForDummies</c> loop
/// @0x005a4ade0x005a4b2d; <c>LScape::draw</c> pc:432719, the depth
/// clear pc:432731-432732, and the exit-portal seals pc:432785-432786 — /// clear pc:432731-432732, and the exit-portal seals pc:432785-432786 —
/// ALL strictly before the flood's own cells, though the event marking /// ALL strictly before the flood's own cells, though the event marking
/// <c>DrawCells</c> entry fires before all three), <c>RenderDeviceD3D::DrawBuilding</c> /// <c>DrawCells</c> entry fires before all three), <c>RenderDeviceD3D::DrawBuilding</c>
@ -878,8 +878,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
for (int i = 0; i < cells.Count; i++) for (int i = 0; i < cells.Count; i++)
InteriorFloodCells.Add(cells[i]); InteriorFloodCells.Add(cells[i]);
for (int i = 0; i < cells.Count; i++) EmitFloodTurns(WalkDrawStage.CellStatic, cells);
EmitCellTurn(WalkDrawStage.CellStatic, cells[i]);
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -949,19 +948,36 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// Any other stage (LookInStatic) is a building's look-in flood: // Any other stage (LookInStatic) is a building's look-in flood:
// retail calls DrawCells re-entrantly there with no landscape/clear/ // retail calls DrawCells re-entrantly there with no landscape/clear/
// seal step, so its DC event already fires at the real draw point — // seal step, so its DC event already fires at the real draw point —
// record immediately, unchanged from before this correction. // record immediately. PView::DrawCells uses two complete reverse
for (int i = 0; i < cells.Count; i++) // loops: every EnvCell shell first, then every cell object list.
EmitCellTurn(stage, cells[i]); EmitFloodTurns(stage, cells);
} }
private void EmitCellTurn(WalkDrawStage stage, uint cellId) private void EmitFloodTurns(WalkDrawStage stage, IReadOnlyList<uint> cells)
{
// PView::DrawCells @0x005A4840, loop 2 (005A4A00005A4ADE):
// 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 (005A4ADE005A4B2D): 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); WalkFrameStaticRecords records = _worldData.GetCellStatics(cellId);
_populator.PopulateCell( _populator.PopulateCell(
_stream, stage, cellId, records.Records, records.TupleLandblockId, _stream, stage, cellId, records.Records, records.TupleLandblockId,
_cameraWorldPosition, _viewProjection); _cameraWorldPosition, _viewProjection);
MarkIfGrown();
if (stage == WalkDrawStage.LookInStatic) if (stage == WalkDrawStage.LookInStatic)
{ {
// Retail draws a look-in cell's complete object list at this // 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 // shells, which let the cathedral's 0x112 remote player overpaint
// opaque walls. Keep animation/fade in the packed route, but replay // opaque walls. Keep animation/fade in the packed route, but replay
// it here between this cell's content and the building shell. // it here between this cell's content and the building shell.
MarkIfGrown();
int routeIndex = _lookInRouteIndex++; int routeIndex = _lookInRouteIndex++;
LookInCellTurns.Add(cellId); LookInCellTurns.Add(cellId);
LookInCells.Add(cellId); LookInCells.Add(cellId);

View file

@ -193,10 +193,14 @@ public sealed class RenderScenePViewFrameProductTests
0x0100_0000_0000_0022, 0x0100_0000_0000_0022,
RenderProjectionClass.LiveDynamicRoot, RenderProjectionClass.LiveDynamicRoot,
parentCell: lookInCell); parentCell: lookInCell);
RenderProjectionRecord outdoorDynamic = Record(
0x0100_0000_0000_0023,
RenderProjectionClass.LiveDynamicRoot);
scene.Apply( scene.Apply(
[ [
RenderProjectionDelta.Register(Generation, 1, rootDynamic), RenderProjectionDelta.Register(Generation, 1, rootDynamic),
RenderProjectionDelta.Register(Generation, 2, lookInDynamic), RenderProjectionDelta.Register(Generation, 2, lookInDynamic),
RenderProjectionDelta.Register(Generation, 3, outdoorDynamic),
]); ]);
PortalVisibilityFrame portal = Portal(Cell); PortalVisibilityFrame portal = Portal(Cell);
@ -235,6 +239,18 @@ public sealed class RenderScenePViewFrameProductTests
.ToArray() .ToArray()
.Select(static record => record.Id)); .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( RenderFrameCandidateRange dynamicLast = Assert.Single(
frame.RouteRanges.ToArray(), frame.RouteRanges.ToArray(),
range => range.Route == RenderFrameCandidateRoute.DynamicLast); range => range.Route == RenderFrameCandidateRoute.DynamicLast);

View file

@ -25,7 +25,8 @@ namespace AcDream.App.Tests.Rendering.Walk;
/// Campaign FW stage FW3.2b-1: <see cref="WalkFrameDriver"/>'s headless /// Campaign FW stage FW3.2b-1: <see cref="WalkFrameDriver"/>'s headless
/// referee suite — proves that driving <see cref="RetailFrameWalk"/> with the /// referee suite — proves that driving <see cref="RetailFrameWalk"/> with the
/// driver as its <see cref="IWalkEventSink"/> produces retail's own turn /// driver as its <see cref="IWalkEventSink"/> 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 <see cref="WbDrawDispatcher.SubmitOrderedStream"/> /// content-before-punch) through the REAL <see cref="WbDrawDispatcher.SubmitOrderedStream"/>
/// onto a <see cref="RecordingGpuDevice"/> — never a mock of the submission /// onto a <see cref="RecordingGpuDevice"/> — never a mock of the submission
/// path itself. No production wiring is exercised (<c>WorldSceneRenderer</c> /// path itself. No production wiring is exercised (<c>WorldSceneRenderer</c>
@ -172,10 +173,9 @@ public sealed class WalkFrameDriverTests
Plane = new WalkPlane(new Vector3(0, 0, facingViewer ? 1f : -1f), facingViewer ? -z : z), Plane = new WalkPlane(new Vector3(0, 0, facingViewer ? 1f : -1f), facingViewer ? -z : z),
}; };
// ── Deliverable: RunFrame drives an interior two-cell flood; shell // ── Deliverable: RunFrame drives an interior two-cell flood using
// precedes contents per cell, and a flush happens exactly at the point // PView::DrawCells' exact two reverse loops: ALL shells far-to-near,
// the NEXT cell's shell needs the stream clear (never before, never // then ALL object cells far-to-near. ─────────────────────────────────
// batched across cells within this stage's turn-by-turn discipline). ──
// ── Deliverable (2026-08-30 decomp correction): PView::DrawCells // ── Deliverable (2026-08-30 decomp correction): PView::DrawCells
// @0x005a4840's actual DRAW order for an interior root's OWN flood is // @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. // (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 // 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 // (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] [Fact]
public void RunFrame_InteriorFloodWithExitView_DrawsLandscapeThenClearSealsThenFloodCells() public void RunFrame_InteriorFloodWithExitView_DrawsLandscapeThenClearSealsThenFloodCells()
@ -258,7 +258,8 @@ public sealed class WalkFrameDriverTests
new[] new[]
{ {
"SKY", "TERRAIN:0", "CLEAR", "SEALS", "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); log);
@ -329,7 +330,11 @@ public sealed class WalkFrameDriverTests
// No SKY/TERRAIN — ov==0 means DrawInside never calls DrawLandscape // No SKY/TERRAIN — ov==0 means DrawInside never calls DrawLandscape
// at all — but CLEAR/SEALS still fire unconditionally. // at all — but CLEAR/SEALS still fire unconditionally.
Assert.Equal( 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); log);
List<GpuRecordedMultiDrawIndirect> mdiCalls = List<GpuRecordedMultiDrawIndirect> mdiCalls =