diff --git a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs index f6fb066a..767431dd 100644 --- a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs +++ b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs @@ -93,7 +93,18 @@ public sealed class RetailFrameWalk } /// PView::DrawInside + the event half of - /// DrawCells. The geometry/object passes emit no walk events. + /// DrawCells. The geometry/object passes emit no walk events. + /// PView::DrawCells @0x005a4840's actual DRAW order for the root + /// flood is NOT the order its events fire in: the + /// + /// call below fires at breakpoint-ENTRY order (matching the FW0 oracle + /// traces, whose breakpoint sat at DrawCells entry — before + /// retail has drawn anything), but retail itself draws + /// LScape::draw FIRST (pc:432719, only when exit views survived), + /// then a gated full depth clear (pc:432731-432732), then the exit- + /// portal seals (pc:432785-432786), and ONLY THEN the flood's own cells + /// far-to-near. + /// fires at that later point (see its own doc comment). public void DrawInside( WalkCell cell, WalkLandscape landscape, IRetailFrameWalkContext ctx, IWalkEventSink sink) @@ -106,10 +117,16 @@ public sealed class RetailFrameWalk ctx.ViewportWidth, ctx.ViewportHeight); _interiorPView.ConstructView(cell, 0xFFFF, ctx.CellContext); - EmitDrawCells(_interiorPView, sink); + uint[] floodCells = EmitDrawCells(_interiorPView, sink); if (_interiorPView.OutsideView.ViewCount > 0) DrawLandscape(landscape, _interiorPView.OutsideView, ctx, sink); + // Additive (Campaign FW3.2b-1): see this method's own doc comment — + // the flood's actual cell-drawing turn, unconditional of whether a + // landscape turn just ran (ov==0 skips straight here from the DC + // event above). + sink.OnInteriorFloodDrawTurn(floodCells); + RemoveViews(cell.StabList, ctx); cell.PopView(); } @@ -213,12 +230,16 @@ public sealed class RetailFrameWalk sink.OnBuildingShellTurn(building); } - private void EmitDrawCells(WalkPView pview, IWalkEventSink sink) + /// Returns the extracted cell-id array so + /// can hand the SAME flood list to OnInteriorFloodDrawTurn later, + /// without re-walking pview.CellDrawList a second time. + private uint[] EmitDrawCells(WalkPView pview, IWalkEventSink sink) { uint[] cells = new uint[pview.CellDrawList.Count]; for (int i = 0; i < cells.Length; i++) cells[i] = pview.CellDrawList[i].CellId; sink.Emit(WalkEvent.DrawCells(pview.OutsideView.ViewCount, cells)); + return cells; } private void AddViews(uint[] stabList, IRetailFrameWalkContext ctx) diff --git a/src/AcDream.App/Rendering/Walk/WalkEvents.cs b/src/AcDream.App/Rendering/Walk/WalkEvents.cs index f510d93f..854710a9 100644 --- a/src/AcDream.App/Rendering/Walk/WalkEvents.cs +++ b/src/AcDream.App/Rendering/Walk/WalkEvents.cs @@ -53,7 +53,7 @@ public readonly record struct WalkEvent( /// production sink will additionally receive the ordered draw stream. /// /// Campaign FW3.2b-1 additive seam: also -/// calls the four richer, default-no-op members below at turns the +/// calls the five richer, default-no-op members below at turns the /// vocabulary-only stream cannot express (a visited /// landscape cell with no building emits no at all; /// carries only a cell id, not the @@ -125,4 +125,29 @@ public interface IWalkEventSink /// no-op. /// void OnPunchGeometry(WalkBuilding building, WalkPolygon polygon) { } + + /// + /// Fires once per interior root, at the point PView::DrawCells + /// @0x005a4840 actually DRAWS the root flood's own cells — NOT where the + /// call for the + /// SAME flood fires (that one sits at breakpoint-ENTRY order, matching + /// the FW0 oracle traces; it only RECORDS the flood list). Retail's own + /// order inside DrawCells is: LScape::draw FIRST + /// (pc:432719, only when exit views survived — see + /// and + /// ), then a full depth clear + /// (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 + /// 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 + /// landscape/clear/seal step, so their + /// call still fires at the actual draw point (a + /// driver may keep drawing those immediately, as before). Default no-op. + /// + void OnInteriorFloodDrawTurn(IReadOnlyList cells) { } } diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index a6bf5d06..6815f0c7 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -63,7 +63,8 @@ internal interface IWalkFrameWorldData /// Campaign FW stage FW3.2b-1: the leaf GPU-adjacent actions /// calls at walk turns that have no /// submission path YET (sky, terrain, an -/// EnvCell shell, a portal punch fan) or that aren't a draw at all (the +/// EnvCell shell, a portal punch fan, the interior depth clear, the exit- +/// portal seals) or that aren't a draw at all (the /// barrier). Kept as its own seam — rather /// than folding these into directly — so the /// FW3.2b-1 headless referee suite can wire a fake and prove turn ORDER @@ -109,6 +110,35 @@ internal interface IWalkFrameLeafRenderer /// this same shell-then-contents order). void DrawCellShell(uint cellId); + /// PView::DrawCells @0x005a4840's gated full depth clear + /// (pc:432731-432732) between the outside stage and the interior root's + /// own flood — production maps this to IWorldPassScope.ClearInteriorDepth + /// (see that interface's own member of the same name in + /// RetailPViewRenderer.cs, staged there on OutsideViewSlices.Length + /// > 0 — an ACKNOWLEDGED approximation of retail's true + /// portalsDrawnCount gate per that file's own comment). This walk + /// driver instead fires unconditionally for every interior root (per the + /// 2026-08-30 decomp correction: the coordinator's directive supersedes + /// the packed path's staged gate — reconcile the two if a firmer + /// portalsDrawnCount reading ever lands). Only called for an + /// INTERIOR root, never outdoors (retail has no depth clear there — + /// portalsDrawnCount never applies to LScape::draw's own + /// top-level walk). + void ClearInteriorDepth(); + + /// The exit-portal seals (pc:432785-432786) — re-stamping every + /// outside-leading portal's TRUE depth right after + /// , so the aperture the clear just wiped + /// stays occluded by the world beyond it rather than by whatever draws + /// next. Production maps this to the existing seal-fan machinery + /// (RetailPViewRenderer.DrawExitPortalMask/ + /// PortalDepthMaskRenderer) — this driver only provides the TURN; + /// the real per-portal fan geometry is FW3.2b-2's job. Only called for an + /// INTERIOR root's own flood, never for a building look-in (those call + /// DrawCells re-entrantly with no clear/seal step) and never + /// outdoors. + void DrawExitSeals(); + /// DrawPortalPolyInternal @0x0059bc90's depth-only far-Z /// punch fan — pass 1 of the building portal walk. /// is already transformed building-local @@ -172,28 +202,38 @@ internal interface IWalkFrameDriverTrace /// /// The one flush rule that reproduces the whole frame script: /// before EVERY leaf-renderer call (, -/// DrawTerrainSlice, DrawCellShell, DrawPunchFan) and -/// before every call, the -/// driver flushes the accumulated opaque stream (a no-op when the stream is -/// empty — "empty segments submit nothing"); a building's own shell content -/// is APPENDED (not flushed) the moment +/// DrawTerrainSlice, DrawCellShell, ClearInteriorDepth, +/// DrawExitSeals, DrawPunchFan) and before every +/// call, the driver +/// flushes the accumulated opaque stream (a no-op when the stream is empty — +/// "empty segments submit nothing"); a building's own shell content is +/// APPENDED (not flushed) the moment /// fires, so it flushes only at whatever non-stream action comes next (the /// next building's alpha barrier, or end of frame). This single rule, -/// combined with "shell before contents" per cell and retail's own building +/// combined with "shell before contents" per cell, retail's own building /// order (alpha barrier → portal pass → shell — see -/// 's doc comment), is what -/// produces every ordering constraint the plan's frame script names: [cell1 -/// shell] [cell1 contents flush] [cell2 shell] …, [alpha barrier] [punch fan(s) -/// + look-in flood(s), each following the SAME shell-then-contents per-cell -/// discipline] [building shell content flush], and the final end-of-frame -/// flush. No special-casing per turn kind is needed beyond that. +/// '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 flush] [cell2 shell] …, [alpha barrier] [punch fan(s) + +/// look-in flood(s), each following the SAME shell-then-contents per-cell +/// discipline] [building shell content flush], [landscape (if exit views +/// survived)] [interior depth clear] [exit-portal seals] [the interior +/// root's own flood cells], and the final end-of-frame flush. No special- +/// casing per turn kind is needed beyond that. /// /// Retail anchors: SmartBox::RenderNormalMode @0x00453aa0 (the /// root already ports), /// RenderDeviceD3D::DrawSortCell @0x0059f140 (building-before- /// DrawObjCell per landscape cell), PView::DrawCells @0x005a4840 /// (DrawEnvCell @0x005a4abe before DrawObjCellForDummies -/// @0x005a4b0d per flooded cell), RenderDeviceD3D::DrawBuilding +/// @0x005a4b0d per flooded cell; 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 /// @0x0059f2a0 (the part->gfxobj[deg_level]!=0 gate @0x0059f2d3 /// and the alpha-barrier → portal-pass → shell order @0x0059f30b–0x0059f345). /// @@ -395,6 +435,26 @@ internal sealed class WalkFrameDriver : IWalkEventSink _leafRenderer.DrawPunchFan(TransformToWorld(polygon, worldTransform)); } + void IWalkEventSink.OnInteriorFloodDrawTurn(IReadOnlyList cells) + { + ArgumentNullException.ThrowIfNull(cells); + RequireOpenFrame(); + + // PView::DrawCells @0x005a4840: the gated full depth clear + // (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 (see this driver's type doc + // comment). + FlushIfNonEmpty(); + _leafRenderer.ClearInteriorDepth(); + + FlushIfNonEmpty(); + _leafRenderer.DrawExitSeals(); + + for (int i = 0; i < cells.Count; i++) + EmitCellTurn(WalkDrawStage.CellStatic, cells[i]); + } + // ------------------------------------------------------------------ // Turn handlers // ------------------------------------------------------------------ @@ -432,6 +492,22 @@ internal sealed class WalkFrameDriver : IWalkEventSink + "(the interior root's own flood) or after a building's look-in portal pass."); } + if (stage == WalkDrawStage.CellStatic) + { + // The interior root's OWN flood: this DC event fired at + // breakpoint-ENTRY order (matching the FW0 oracle traces), not + // retail's actual draw point — RECORD only. RetailFrameWalk. + // DrawInside hands the SAME cell list to + // OnInteriorFloodDrawTurn later, at the point retail really + // draws it (after the landscape turn, or immediately when no + // exit views survived) — see that method's doc comment. + return; + } + + // 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 — + // draw immediately, unchanged from before this correction. for (int i = 0; i < cells.Count; i++) EmitCellTurn(stage, cells[i]); } diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index fc59b311..c6bdfdba 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -49,6 +49,10 @@ public sealed class WalkFrameDriverTests public void DrawCellShell(uint cellId) => log.Add($"SHELL:{cellId:x8}"); + public void ClearInteriorDepth() => log.Add("CLEAR"); + + public void DrawExitSeals() => log.Add("SEALS"); + public void DrawPunchFan(WalkPolygon worldPolygon) { Punches.Add(worldPolygon); @@ -154,8 +158,19 @@ public sealed class WalkFrameDriverTests // the NEXT cell's shell needs the stream clear (never before, never // batched across cells within this stage's turn-by-turn discipline). ── + // ── Deliverable (2026-08-30 decomp correction): PView::DrawCells + // @0x005a4840's actual DRAW order for an interior root's OWN flood is + // NOT the order its DrawInside/DrawCells EVENTS fire in (breakpoint- + // entry order, matching the FW0 oracle traces) — retail draws + // LScape::draw FIRST (pc:432719, only when exit views survived), then + // the depth clear (pc:432731-432732), then the exit-portal seals + // (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. ────────── + [Fact] - public void RunFrame_InteriorTwoCellFlood_EmitsShellThenContentsPerCellWithAFlushBetween() + public void RunFrame_InteriorFloodWithExitView_DrawsLandscapeThenClearSealsThenFloodCells() { using var fx = new DispatcherFixture(); var log = new List(); @@ -166,6 +181,94 @@ public sealed class WalkFrameDriverTests InjectRenderData(fx.Manager, gfxObjB, MakeFlatMesh( MakeBatch(0x08100002u, TranslucencyKind.Opaque, 3, 4, 3, 2))); + var ctx = new TestContext(); + var cell1 = new WalkCell + { + CellId = 0x100, + StabList = [0x101u], + Portals = + [ + new WalkCellPortal + { + OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0, + }, + // The exit portal (retail's "world beyond the door") — this + // is what raises ov to 1 and makes DrawInside run the + // landscape turn before clear+seals+the flood cells. + new WalkCellPortal + { + OtherCellId = 0xFFFFFFFF, PolygonIndex = 1, PortalSide = 0, OtherPortalId = -1, + }, + ], + PortalPolygons = [Quad(-2f), Quad(-3f)], + }; + var cell2 = new WalkCell + { + CellId = 0x101, + Portals = [new WalkCellPortal + { + OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0, + }], + PortalPolygons = [Quad(-2f)], + }; + ctx.Cells[cell1.CellId] = cell1; + ctx.Cells[cell2.CellId] = cell2; + + var worldData = new FakeWorldData(); + worldData.CellStaticsByCell[0x100] = new WalkFrameStaticRecords( + [MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)])], 0x8C04u); + worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords( + [MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)])], 0x8C04u); + + var leaf = new RecordingLeafRenderer(log); + var trace = new RecordingTrace(log); + var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, trace); + var walk = new RetailFrameWalk(); + // A minimal, no-op landscape (1x1 window, the one slot unpublished) + // — matches RetailFrameWalkTests' own exit-view fixture. LScape::draw + // still runs its full turn (sky/terrain) against it; there's simply + // nothing published to iterate blocks/cells/buildings for. + var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] }; + + using DrawScope draw = fx.BeginDraw(); + driver.RunFrame( + walk, cameraCellId: cell1.CellId, cameraCell: cell1, landscape: landscape, + ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero, + activeTerrainSliceCount: 1); + + Assert.Equal( + new[] + { + "SKY", "TERRAIN:0", "CLEAR", "SEALS", + "SHELL:00000100", "FLUSH:1:CellStatic", "SHELL:00000101", "FLUSH:1:CellStatic", + }, + log); + + List mdiCalls = + [.. fx.Device.Calls.OfType()]; + Assert.Equal(2, mdiCalls.Count); + Assert.All(mdiCalls, c => Assert.Equal(1u, c.DrawCount)); + // Nothing dropped: every populated record reached exactly one indirect draw. + Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount)); + } + + // ── Deliverable: the ov==0 interior case — no exit view survives, so + // DrawInside never runs the landscape turn at all; retail's clear+seals + // still run unconditionally for the interior root's own flood, straight + // after the (draw-nothing) DC event. ─────────────────────────────────── + + [Fact] + public void RunFrame_InteriorFloodWithNoExitView_SkipsLandscapeButStillClearsAndSeals() + { + using var fx = new DispatcherFixture(); + var log = new List(); + const ulong gfxObjA = 0x0200_0003UL; + const ulong gfxObjB = 0x0200_0004UL; + InjectRenderData(fx.Manager, gfxObjA, MakeFlatMesh( + MakeBatch(0x08100003u, TranslucencyKind.Opaque, 0, 0, 3, 1))); + InjectRenderData(fx.Manager, gfxObjB, MakeFlatMesh( + MakeBatch(0x08100004u, TranslucencyKind.Opaque, 3, 4, 3, 2))); + var ctx = new TestContext(); var cell1 = new WalkCell { @@ -206,8 +309,10 @@ public sealed class WalkFrameDriverTests ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero, activeTerrainSliceCount: 0); + // No SKY/TERRAIN — ov==0 means DrawInside never calls DrawLandscape + // at all — but CLEAR/SEALS still fire unconditionally. Assert.Equal( - new[] { "SHELL:00000100", "FLUSH:1:CellStatic", "SHELL:00000101", "FLUSH:1:CellStatic" }, + new[] { "CLEAR", "SEALS", "SHELL:00000100", "FLUSH:1:CellStatic", "SHELL:00000101", "FLUSH:1:CellStatic" }, log); List mdiCalls =