From 4918677b4562ffb03cbf37889fc170ae02984c7e Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 30 Aug 2026 15:43:22 +0200 Subject: [PATCH] feat(render) Campaign FW3.2b-2: THE STATIC CUTOVER - the walk drives production statics The retail frame walk now drives every production static draw. In RetailPViewRenderer.DrawInside, when the concrete executor + the packed product + the FW3.1 walk registries are all wired (all production compositions - anything less throws): - A pre-walk events-only collection pass (the shadow sink generalized to WalkVisitedSetCollector) gathers the frame's visited cells, buildings, and landscape-cell turns; the visited cells union into prepareCells so EnvCellRenderer prepares every shell the driver draws. - DrawWalkDrivenStatics runs the WalkFrameDriver over the production world data (WalkProductionWorldData over RenderSceneQuery + the building registry): sky, terrain slices, outdoor statics at their landscape-cell turns, buildings (alpha barrier -> punch/look-in passes -> shell) in retail order, interior clear+seals as leaf closures (the old tail block's drain reasoning moves with them), flood cells shell-then-contents. Landscape/cell-stage particle owners re-source from the walk's visited sets - retail gates particles per cell turn (ShouldDrawParticles @0x0050FE60), which this is; the old sphere filter was the approximation. - DrawLandscapeDynamicsPhase + DrawBuildingLookInDynamics carry the dynamics-only remainder (LookInObject now dynamic-classified, late outside-dynamics + weather, particle unions); DrawDynamicsLast and the outdoor flush are unchanged. - The product builder stops emitting LandscapeOutdoorStatic / LandscapeBuildingShell / CellStatic (methods deleted, dead index tracking removed); LookInObject loads cells with includeStatics: false. The old static path survives ONLY behind !walkActive for the standalone/diagnostic executor-fake path that keeps 15 retail-ordering regression tests exercising the barrier/punch/seal machinery; no production composition can reach it. Its deletion is FW4 scope (the plan's "deleting the patch apparatus") - recorded in the plan. Transitional risks recorded in code/report: the two-pass walk cost (FW3.4 measures), the interior slice-count reconciliation between the old clip assembly and the walk's own exit-view survival, and the outdoor merged-flood punch coverage now riding the walk's own building-BSP punches (retail-faithful per FW1; the owner visual gate verifies). Suites (lead-verified): full Release build 0 warnings; hermetic 6,750/0 (baseline minus the three deleted route tests); Walk lane 201/1; InstalledDat Walk conformance 40/1 untouched. The two IL-branch tests the implementation round reported failing pass in every lead run - the recurring parallel-load flake pair. Co-Authored-By: Claude Fable 5 --- .../2026-08-30-campaign-fw-frame-walk.md | 57 ++ .../Rendering/RetailPViewPassExecutor.cs | 20 + .../Rendering/RetailPViewRenderer.cs | 616 ++++++++++++++++-- .../Scene/RenderScenePViewFrameProduct.cs | 229 +------ .../Rendering/Walk/WalkProductionWorldData.cs | 180 +++++ .../Wb/WbDrawDispatcher.OrderedStream.cs | 26 + .../RenderScenePViewFrameProductTests.cs | 377 +---------- .../Rendering/RetailPViewPassExecutorTests.cs | 26 +- 8 files changed, 919 insertions(+), 612 deletions(-) create mode 100644 src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs diff --git a/docs/plans/2026-08-30-campaign-fw-frame-walk.md b/docs/plans/2026-08-30-campaign-fw-frame-walk.md index aa429a29..faa9a0c6 100644 --- a/docs/plans/2026-08-30-campaign-fw-frame-walk.md +++ b/docs/plans/2026-08-30-campaign-fw-frame-walk.md @@ -343,6 +343,63 @@ deeper commitment. landscape turn; the `_drawCullModes`/alpha-scope sequencing constraint resolved (own cull scratch or strict sequencing). Route-keyed particle owners re-sourced from the walk. +- **FW3.2b-2 — the production rooting (DESIGN, 2026-08-30; execute + INLINE, not via subagent — the integration-slice rule).** The + dual-compute split for this stage: the OLD visibility + (`PortalVisibilityBuilder` + look-in frames + clip assembly + + viewcone) KEEPS RUNNING to feed everything dynamic; the WALK draws + everything static. No old static DRAWS remain (no double draw): + 1. `RetailPViewRenderer.DrawInside` keeps steps 1–8 and 10–11 + (visibility, merge/look-in seeding, clip frame, prepare — with + `prepareCells` UNIONED with the walk's flood/look-in cells — + viewcone, route build) but `RenderScenePViewFrameProduct` STOPS + emitting `LandscapeOutdoorStatic`, `LandscapeBuildingShell`, and + `CellStatic` ranges. Per the plan's route split, LOOK-IN OBJECTS + (`LookInObject`) are dynamic-side and STAY PACKED this stage — + the old look-in machinery keeps drawing look-in cell content and + its punches; the WALK's building turns therefore draw shell + content + punches for NON-look-in... NO — resolve the overlap the + other way: the walk's building turns run their punch+look-in + machinery ONLY for the walk's own conformance events; production + look-in DRAWS (punch fans + content) stay entirely on the old + path this stage, and the walk's `BuildingShell` stage content + + `OnPunchGeometry`/look-in DC draws are SUPPRESSED in the driver's + production wiring for look-in-owned buildings… REJECTED — that is + a dual path. FINAL: the walk owns ALL of it — building shells, + punches, look-in STATIC content (stage LookInStatic) — and the + `LookInObject` route survives ONLY for the DYNAMICS inside + look-in cells (`BuildLookInRoutes` filtered to dynamic + classifications). The old `DrawBuildingLookIns` static half and + `DrawLookInPortalPunch` retire with the static routes. + 2. `WalkFrameDriver`'s leaf renderer maps to: + sky → `RetailPViewPassExecutor` sky call; terrain slice → + `_terrain.Draw(camera, frustum, clipPlanes: slice.Planes, + ndcClipAabb: slice.NdcAabb)`; cell shell → + `EnvCellRenderer.Render(pass, singleCellSet)` (+ transparent + ordered per cell); punch fan → `PortalDepthMaskRenderer. + DrawDepthFan(worldVerts, viewProjection, slicePlanes, + forceFarZ: true)`; `ClearInteriorDepth` → + `IWorldPassScope.ClearInteriorDepth` (keep production's + slices>0 gate); `DrawExitSeals` → the existing seal-fan loop + (`DrawPortalDepthWrite` seal flavor over the root flood's exit + portals); alpha barrier → `RetailAlphaQueue.FlushFartherThan`. + 3. Static records: cell statics via `RenderSceneQuery. + CopyCellStaticsTo(cellId)`; outdoor statics via a per-landscape- + cell index built once per frame from + `CopyIndexTo(OutdoorStatic)`; building shells via the + `IsBuildingShell` entities keyed by anchor cell. + 4. Consumers re-pointed: particle owners + (`RenderFrameRouteOwnerSelector.Replace(LandscapeOutdoorStatic…)` + sites) re-source from the walk's populated cells; diagnostics + source counts tolerate the route removal; `EmitRenderSignature`'s + terrain/scenery counts re-source or annotate. + 5. Sky/weather: sky moves into the walk's LS turn (once); weather + stays in the old late-dynamics slice loop this stage (it is + dynamic-adjacent and the plan's FW4 moves it fully). + 6. Gates before the owner sees a build: hermetic + InstalledDat + suites; the connected lifecycle route; the offline pixel gate + (outdoor statics); dense-Arwic pre/post pair (physical display). + - **FW3.3 — ShellDrawLiftZ retirement.** THREE sites (the register row AP-32 lists only two — `LandblockBuildFactory.cs` bakes the lift into the drawn cell transform and is unlisted): delete all three, flip the diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index 9477567c..329f34cd 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Sky; using AcDream.App.Rendering.Wb; @@ -181,6 +182,25 @@ internal sealed partial class RetailPViewPassExecutor : _particleClassifications.BeginFrame(); } + /// Campaign FW3.2b-2: the shared dispatcher, for + /// 's WalkFrameDriver + /// construction — the driver's ctor takes a + /// directly (it calls SubmitOrderedStream itself; see that + /// class's own doc comment). + internal WbDrawDispatcher Dispatcher => _entities; + + /// Campaign FW3.2b-2: forwards to + /// — the frame/ + /// encoder pair the walk driver submits its stream flushes into. + internal (IGpuFrame Frame, IGpuPassEncoder Encoder) RequireWalkSubmission() => + _entities.RequireWalkSubmission(); + + /// Campaign FW3.2b-2: forwards to + /// — the real + /// viewport size for WalkProductionFrameContext. + internal (int Width, int Height)? WalkAttachmentExtent => + _entities.WalkAttachmentExtent; + public void BeginEntityFrame(in RenderFrameView view) => _entities.BeginPackedProductionFrame(in view); diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 81c38464..fbfb9050 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -101,6 +101,12 @@ public sealed class RetailPViewRenderer private readonly Walk.WalkLandscapeAssembler? _walkLandscape; private readonly CellVisibility? _walkCellRegistry; + // Campaign FW3.2b-2: the production IWalkFrameWorldData over the retained + // scene — owned here (not just inside the driver) because DrawInside also + // reads it directly to re-source particle owners for the routes the walk + // now draws (plan §FW3 item 4). Non-null exactly when _walkBuildings is. + private readonly Walk.WalkProductionWorldData? _walkWorldData; + internal RetailPViewRenderer( InteriorEntityPartition.IObserver? partitionObserver, RenderScenePViewFrameProductController? sceneFrameProduct = null, @@ -111,6 +117,9 @@ public sealed class RetailPViewRenderer _walkBuildings = walkBuildings; _walkLandscape = walkLandscape; _walkCellRegistry = walkCellRegistry; + _walkWorldData = walkBuildings is not null + ? new Walk.WalkProductionWorldData(walkBuildings) + : null; _partitionObserver = partitionObserver; _candidateObserver = partitionObserver as ICurrentRenderPViewObserver; _sceneFrameProduct = sceneFrameProduct; @@ -187,6 +196,90 @@ public sealed class RetailPViewRenderer var drawableCells = _drawableCellsScratch; passes.UseIndoorMembershipOnlyRouting(); + // Campaign FW3.2b-2: the production rooting. A concrete executor is + // required — the walk submits through WbDrawDispatcher.SubmitOrderedStream + // and needs a real GPU frame/encoder (RequireWalkSubmission), which no + // test IRetailPViewPassExecutor fake can supply. Whenever a concrete + // executor IS present, the packed entity-route product must ALSO be + // wired with all three walk registries — a scene product without the + // walk data (or vice versa) is a production miswiring, not a + // legacy/diagnostic shape, so it fails loud rather than silently + // falling back to the retired static routes (plan §FW3 item 6). + RetailPViewPassExecutor? walkExecutor = passes as RetailPViewPassExecutor; + bool walkRegistriesReady = + _walkBuildings is not null + && _walkLandscape is not null + && _walkCellRegistry is not null + && _walkWorldData is not null; + if (walkExecutor is not null + && _sceneFrameProduct is not null + && !walkRegistriesReady) + { + throw new InvalidOperationException( + "RetailPViewRenderer has a concrete pass executor and a " + + "RenderScenePViewFrameProductController but the walk registries " + + "(WalkBuildingRegistry/WalkLandscapeAssembler/CellVisibility) are " + + "not all wired — the Campaign FW3.2b-2 static cutover requires " + + "every piece together; see FrameRootComposition's " + + "RetailPViewRenderer construction."); + } + bool walkActive = + walkExecutor is not null + && _sceneFrameProduct is not null + && walkRegistriesReady; + + // Campaign FW3.2b-2 pre-walk collection pass: run the production walk + // EVENTS-ONLY (no leaf draws) to learn the flood cell set it will draw + // this frame, BEFORE prepareCells is finalized below — so EnvCellRenderer + // prepares batches for every shell the driver will draw later in this + // same DrawInside call. The same WalkProductionFrameContext + camera + // cell resolved here are reused for the real driven run further down + // (plan §FW3 "FW3.2b-2 — the production rooting", item 1). + Walk.WalkProductionFrameContext? walkContext = null; + Walk.WalkLandscape? walkLandscape = null; + Walk.WalkCell? walkCameraCell = null; + if (walkActive) + { + Matrix4x4 view = ctx.CameraView; + var forward = Vector3.Normalize(new Vector3(-view.M13, -view.M23, -view.M33)); + (int Width, int Height)? attachment = walkExecutor!.WalkAttachmentExtent; + // Fallback matches the FW3.2b-2 shadow probe's own comment: every + // screen projection shares the same constants, so this is only + // reached before the world pass has published its scope. + float viewportWidth = attachment?.Width ?? 1024f; + float viewportHeight = attachment?.Height ?? 720f; + walkContext = new Walk.WalkProductionFrameContext( + _walkCellRegistry!, + _walkBuildings!, + ctx.ViewerEyePos, + forward, + ctx.ViewProjection, + viewportWidth, + viewportHeight); + _walkLandscape!.SetViewer(ctx.ViewerCellId, ctx.ViewerEyePos); + walkLandscape = _walkLandscape.Landscape; + + if ((ctx.ViewerCellId & 0xFFFFu) >= 0x100) + { + walkCameraCell = _walkCellRegistry!.TryGetCell(ctx.ViewerCellId, out LoadedCell? loaded) + ? loaded?.Walk + : null; + if (walkCameraCell is null) + { + throw new InvalidOperationException( + $"walk root=0x{ctx.ViewerCellId:X8}: the interior camera cell has " + + "no committed walk data — the Campaign FW3.2b-2 static cutover " + + "requires the walk registry to already hold the viewer's own cell " + + "(fail-loud rule; a silently skipped root would leave the frame " + + "with no static draws at all)."); + } + } + + _walkVisitedScratch.Reset(); + _frameWalk.WalkFrame( + ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext, _walkVisitedScratch); + } + // #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 @@ -194,7 +287,7 @@ public sealed class RetailPViewRenderer // seals, the outside-stage predicate, and the frame result. var prepareCells = drawableCells; _lookInCellIds.Clear(); - if (_lookInFrames.Count > 0) + if (_lookInFrames.Count > 0 || walkActive) { _lookInPrepareScratch.Clear(); _lookInPrepareScratch.UnionWith(drawableCells); @@ -206,6 +299,13 @@ public sealed class RetailPViewRenderer _lookInCellIds.Add(c); } } + if (walkActive) + { + // The walk's own flood/look-in cell set — unioned in (never + // aliased with drawableCells, which the outside-stage and seal + // predicates below still need scoped to the OLD flood only). + _lookInPrepareScratch.UnionWith(_walkVisitedScratch.Cells); + } prepareCells = _lookInPrepareScratch; } @@ -330,52 +430,85 @@ public sealed class RetailPViewRenderer } } - DrawLandscapeThroughOutsideView( - ctx, - passes, - clipAssembly, - partition, - viewcone, - frameEntityPasses, - in frameView); - passes.UseIndoorMembershipOnlyRouting(); - - // Retail DrawBuilding @0x0059F2A0 runs FlushAlphaList(0f) BEFORE - // its portal-only far-Z pass. In retail's strict far→near walk - // everything queued at that instant is FARTHER than the structure - // being punched, so no already-drained poly can meet a punched - // aperture's falsified depth, and everything drained later is - // NEARER than the punched structure and legitimately composites in - // front of it. The batched outdoor frame reproduces that invariant - // here: drain the far prefix — every entry at or beyond the - // nearest cell whose exit-portal mask is about to punch far-Z — - // against still-true landscape depth. Without this, an exterior - // waterfall beyond the cathedral drains after the punches and - // z-passes across every aperture pixel whose true depth the punch - // erased (#132 regression found at the 2026-08-29 cathedral gate). - // Interior roots keep their pre-clear stage-boundary drain. - if (ctx.RootCell.IsOutdoorNode) + if (walkActive) { - passes.FlushLandscapeAlphaFartherThan( - ExitPortalMaskBarrierDistance( - pvFrame, - drawableCells, - ctx.Cells, - ctx.CameraWorldPosition)); + // Campaign FW3.2b-2: the walk owns every static draw — + // terrain/sky, building shells + punches + look-in cell + // statics, and the interior root's own flood shells + + // statics (with the depth clear + exit seals at their real + // retail turn). The OLD visibility (pvFrame/clipAssembly/ + // viewcone, already built above) keeps running unchanged to + // feed the surviving dynamic routes only (plan §FW3 item 1's + // dual-compute split). + DrawWalkDrivenStatics( + ctx, + walkExecutor!, + clipAssembly, + pvFrame, + drawableCells, + walkContext!, + walkCameraCell, + walkLandscape!); + passes.UseIndoorMembershipOnlyRouting(); + DrawLandscapeDynamicsPhase( + ctx, + passes, + clipAssembly, + partition, + viewcone, + frameEntityPasses, + in frameView); + } + else + { + DrawLandscapeThroughOutsideView( + ctx, + passes, + clipAssembly, + partition, + viewcone, + frameEntityPasses, + in frameView); + passes.UseIndoorMembershipOnlyRouting(); + + // Retail DrawBuilding @0x0059F2A0 runs FlushAlphaList(0f) BEFORE + // its portal-only far-Z pass. In retail's strict far→near walk + // everything queued at that instant is FARTHER than the structure + // being punched, so no already-drained poly can meet a punched + // aperture's falsified depth, and everything drained later is + // NEARER than the punched structure and legitimately composites in + // front of it. The batched outdoor frame reproduces that invariant + // here: drain the far prefix — every entry at or beyond the + // nearest cell whose exit-portal mask is about to punch far-Z — + // against still-true landscape depth. Without this, an exterior + // waterfall beyond the cathedral drains after the punches and + // z-passes across every aperture pixel whose true depth the punch + // erased (#132 regression found at the 2026-08-29 cathedral gate). + // Interior roots keep their pre-clear stage-boundary drain. + if (ctx.RootCell.IsOutdoorNode) + { + passes.FlushLandscapeAlphaFartherThan( + ExitPortalMaskBarrierDistance( + pvFrame, + drawableCells, + ctx.Cells, + ctx.CameraWorldPosition)); + } + + DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells); + DrawEnvCellShells(passes, pvFrame); + DrawCellObjectLists( + ctx, + passes, + pvFrame, + clipAssembly, + drawableCells, + partition, + viewcone, + frameEntityPasses, + in frameView); } - DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells); - DrawEnvCellShells(passes, pvFrame); - DrawCellObjectLists( - ctx, - passes, - pvFrame, - clipAssembly, - drawableCells, - partition, - viewcone, - frameEntityPasses, - in frameView); DrawDynamicsLast( ctx, passes, @@ -930,8 +1063,8 @@ public sealed class RetailPViewRenderer return; } } - var sink = new WalkShadowSink(); - _walkShadowFrameWalk.WalkFrame( + var sink = new WalkVisitedSetCollector(); + _frameWalk.WalkFrame( ctx.ViewerCellId, cameraCell, landscape, context, sink); int onlyWalk = 0; @@ -956,13 +1089,40 @@ public sealed class RetailPViewRenderer } } - private readonly Walk.RetailFrameWalk _walkShadowFrameWalk = new(); + // Campaign FW3.2b-2: the one RetailFrameWalk instance shared by the + // diagnostic shadow probe, the production pre-walk collection pass, and + // the real driven run — WalkFrame calls are never concurrent/re-entrant + // within a single-threaded render loop, so one shared instance is safe + // and avoids re-allocating the walk's own PView scratch per call site. + private readonly Walk.RetailFrameWalk _frameWalk = new(); - private sealed class WalkShadowSink : Walk.IWalkEventSink + // Campaign FW3.2b-2: the pre-walk collection pass's reusable sink (see + // DrawInside's walkActive block) — reset and re-driven once per frame. + private readonly WalkVisitedSetCollector _walkVisitedScratch = new(); + + /// Campaign FW3.2b-2 (the I5 dual-shadow pattern, extended for + /// production use): an events-only that + /// collects the SETS a driven run would touch, without doing any leaf + /// drawing itself — the shadow probe's original role, now ALSO the + /// pre-walk collection pass's role (plan §FW3 item 1: the walk's flood + /// cell set for the prepareCells union, the visited building list + /// and landscape-cell turn ids for re-sourcing particle owners once the + /// walk owns the static routes those owners used to ride). + private sealed class WalkVisitedSetCollector : Walk.IWalkEventSink { public readonly HashSet Cells = new(); - public int BuildingCount; - public int LandscapeCount; + public readonly List Buildings = new(); + public readonly HashSet LandscapeCellIds = new(); + + public int BuildingCount => Buildings.Count; + public int LandscapeCount => LandscapeCellIds.Count; + + public void Reset() + { + Cells.Clear(); + Buildings.Clear(); + LandscapeCellIds.Clear(); + } public void Emit(in Walk.WalkEvent walkEvent) { @@ -975,14 +1135,358 @@ public sealed class RetailPViewRenderer foreach (uint id in walkEvent.Cells) Cells.Add(id); break; - case Walk.WalkEventKind.Building: - BuildingCount++; - break; - case Walk.WalkEventKind.Landscape: - LandscapeCount++; - break; } } + + public void OnLandscapeCellTurn(uint cellId) => LandscapeCellIds.Add(cellId); + + public void OnBuildingTurn(Walk.WalkBuilding building) => Buildings.Add(building); + } + + /// Campaign FW3.2b-2 — THE PRODUCTION ROOTING. Runs the real + /// through + /// over + /// : terrain/sky, every + /// building's shell + punch + look-in cell statics, and the interior + /// root's own flood shells + statics (with retail's depth-clear/exit-seal + /// turn, via bound as the driver's own + /// seal action) all draw here, in walk order — replacing + /// 's static half, + /// 's old top-level call, + /// , and 's + /// static half for this frame (plan §FW3 "FW3.2b-2 — the production + /// rooting", item 2). Also re-sources the particle owners the routes it + /// just replaced used to ride (item 4). + private void DrawWalkDrivenStatics( + RetailPViewFrameInput ctx, + RetailPViewPassExecutor passes, + ClipFrameAssembly clipAssembly, + PortalVisibilityFrame pvFrame, + HashSet drawableCells, + Walk.WalkProductionFrameContext walkContext, + Walk.WalkCell? cameraCell, + Walk.WalkLandscape landscape) + { + _walkWorldData!.BeginFrame(_sceneFrameProduct!.SceneQuery, ctx.PlayerLandblockId ?? 0u); + + Action clearInteriorDepth = () => + { + // Retail PView::DrawCells 0x005A4872 drains the landscape alpha + // list immediately before the gated full depth clear — mirrors + // DrawLandscapeThroughOutsideView's own pre-clear drain (this + // action only ever fires for an INTERIOR root; see + // IWalkFrameLeafRenderer.ClearInteriorDepth's own doc comment for + // why the driver never invokes it outdoors). + passes.FlushLandscapeAlpha(); + passes.ClearInteriorDepth(); + }; + Action drawExitSeals = () => + DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells); + + var leafRenderer = new WalkProductionLeafRenderer( + passes, ctx, clipAssembly, clearInteriorDepth, drawExitSeals); + var driver = new Walk.WalkFrameDriver(passes.Dispatcher, leafRenderer, _walkWorldData); + + var (frame, encoder) = passes.RequireWalkSubmission(); + + int activeTerrainSliceCount = clipAssembly.OutsideViewSlices.Length; + if (ctx.RootCell.IsOutdoorNode && activeTerrainSliceCount != 1) + { + throw new InvalidOperationException( + "walk static cutover: an outdoor root's clip assembly produced " + + $"{activeTerrainSliceCount} outside-view slices, not the expected 1 — " + + "WalkFrameDriver's landscape turn assumes the outdoor root's default " + + "full-screen view (plan §FW3 item 2c's pinned assumption; assert " + + "rather than silently coercing to 1)."); + } + + driver.RunFrame( + _frameWalk, + ctx.ViewerCellId, + cameraCell, + landscape, + walkContext, + frame, + encoder, + ctx.ViewProjection, + ctx.CameraWorldPosition, + activeTerrainSliceCount); + + // Landscape-stage particle owners: the union of every outdoor-static + // record from a landscape cell the walk visited this frame, plus + // every shell record from a building the walk visited (plan §FW3 + // item 4 — retail gates particles per cell turn, ShouldDrawParticles + // @0x0050FE60, so this is MORE retail-faithful than the old per- + // slice sphere filter it replaces). + _staticParticleUnionScratch.Clear(); + foreach (uint cellId in _walkVisitedScratch.LandscapeCellIds) + UnionRecordOwners(_walkWorldData.GetOutdoorStatics(cellId), _staticParticleUnionScratch); + foreach (Walk.WalkBuilding building in _walkVisitedScratch.Buildings) + UnionRecordOwners(_walkWorldData.GetBuildingShellStatics(building), _staticParticleUnionScratch); + if (_staticParticleUnionScratch.Count > 0) + { + passes.DrawLandscapeStaticParticles( + ctx, + new RetailPViewLandscapeStaticParticleContext(_staticParticleUnionScratch)); + _staticParticleUnionScratch.Clear(); + } + + // Cell-stage particle owners for the interior root's OWN flood cells + // (non-look-in — the walk draws their statics too, via + // OnInteriorFloodDrawTurn/EmitCellTurn, so the retired CellStatic + // route's cell-particle submission needs the same re-sourcing). + // Look-in cells get their OWN per-cell union in + // DrawBuildingLookInDynamics so a static owner is never submitted + // twice. + _cellParticleOwnerScratch.Clear(); + foreach (uint cellId in _walkVisitedScratch.Cells) + { + if (_lookInCellIds.Contains(cellId)) + continue; + UnionRecordOwners(_walkWorldData.GetCellStatics(cellId), _cellParticleOwnerScratch); + } + if (_cellParticleOwnerScratch.Count > 0) + { + passes.DrawCellParticles( + ctx, + new RetailPViewCellSliceContext(0u, NoClipSlice, _cellParticleOwnerScratch)); + } + } + + private static void UnionRecordOwners( + Walk.WalkFrameStaticRecords records, HashSet destination) + { + foreach (RenderProjectionRecord record in records.Records) + { + if (record.Source.LocalEntityId != 0) + destination.Add(record.Source.LocalEntityId); + } + } + + /// Campaign FW3.2b-2: the DYNAMICS-only remainder of the old + /// + + /// split — the walk now draws every STATIC route (see + /// ); this method keeps ONLY what + /// stays on the OLD visibility pipeline per the plan's dual-compute + /// split: outdoor-cell unattached particles, LookInObject dynamics + their + /// per-cell particles, the late per-slice outside-dynamics/weather loop, + /// and the late particle union submission. + private void DrawLandscapeDynamicsPhase( + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes, + ClipFrameAssembly clipAssembly, + InteriorEntityPartition.Result? partition, + ViewconeCuller viewcone, + IRenderFrameEntityPassExecutor? frameEntityPasses, + in RenderFrameView frameView) + { + if (clipAssembly.OutsideViewSlices.Length == 0) + return; + + // Ownerless OUTDOOR-cell emitters — now unconditional: the old + // hasBuildingLookIns gate only existed to sequence this submission + // around the OLD static barrier drains, which no longer run here + // (the walk owns its own alpha barriers — WalkFrameDriver.OnBuildingTurn). + passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true); + + DrawBuildingLookInDynamics( + ctx, passes, clipAssembly, partition, frameEntityPasses, in frameView); + + // LATE phase (per slice): outside-stage dynamics' meshes + weather — + // unchanged from DrawLandscapeThroughOutsideView's own late loop. + _staticParticleUnionScratch.Clear(); + int probeSliceIndex = 0; + foreach (var slice in clipAssembly.OutsideViewSlices) + { + passes.SetTerrainClip(slice.Planes); + passes.ClearClipRouting(); + + _outdoorStaticScratch.Clear(); + _lateParticleOwnerScratch.Clear(); + foreach (var e in _outsideStageDynamics) + { + EntitySphere(e, out var c, out float r); + if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) + { + _outdoorStaticScratch.Add(e); + if (!InteriorEntityPartition.IsIndoorCellId(e.ParentCellId)) + _lateParticleOwnerScratch.Add(e.Id); + } + } + if (frameEntityPasses is not null) + { + RenderFrameRouteOwnerSelector.Union( + _lateParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.LandscapeOutsideDynamic, + probeSliceIndex, + 0); + } + _candidateObserver?.ObservePViewBucket( + CurrentRenderPViewRoute.LandscapeOutsideDynamic, + probeSliceIndex, + 0, + _outdoorStaticScratch); + RenderFrameEntityDrawRequest? entityDraw = + frameEntityPasses is null + ? null + : new RenderFrameEntityDrawRequest( + frameView, + RenderFrameCandidateRoute.LandscapeOutsideDynamic, + probeSliceIndex, + 0, + ctx.PlayerLandblockId ?? 0); + probeSliceIndex++; + _staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch); + passes.DrawLandscapeSliceLate( + ctx, + new RetailPViewLandscapeLateSliceContext(slice, _outdoorStaticScratch) + { + EntityDraw = entityDraw, + }); + } + + // Late-particle union submission — DynamicLast owners excluded, same + // as DrawLandscapeThroughOutsideView's own final submission. + if (frameEntityPasses is not null) + { + RenderFrameRouteOwnerSelector.ExceptRoute( + _staticParticleUnionScratch, in frameView, RenderFrameCandidateRoute.DynamicLast); + } + if (_staticParticleUnionScratch.Count > 0) + { + passes.DrawLandscapeStaticParticles( + ctx, + new RetailPViewLandscapeStaticParticleContext(_staticParticleUnionScratch)); + _staticParticleUnionScratch.Clear(); + } + + passes.UseIndoorMembershipOnlyRouting(); + } + + /// Campaign FW3.2b-2: the DYNAMICS-only remainder of the old + /// — punches, shells, and look-in cell + /// STATICS are now walk-owned ('s + /// Building/BuildingShell/LookInStatic turns); this method keeps ONLY the + /// LookInObject route (now dynamic-classified — see + /// RenderScenePViewFrameBuilder.BuildLookInRoutes) and the per-cell + /// particle union that route's owners feed, unioned with the walk's + /// static owners for that SAME cell (plan §FW3 item 4 — GetCellStatics + /// fills the gap the retired CellStatic-route particle submission left + /// for look-in cells specifically). + private void DrawBuildingLookInDynamics( + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes, + ClipFrameAssembly clipAssembly, + InteriorEntityPartition.Result? partition, + IRenderFrameEntityPassExecutor? frameEntityPasses, + in RenderFrameView frameView) + { + if (_lookInFrames.Count == 0) + return; + + int lookInRouteIndex = 0; + for (int frameIndex = 0; frameIndex < _lookInFrames.Count; frameIndex++) + { + PortalVisibilityFrame frame = _lookInFrames[frameIndex]; + for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) + { + uint cellId = frame.OrderedVisibleCells[i]; + var clipKey = new LookInClipCell(frameIndex, cellId); + if (!clipAssembly.LookInCellToViewSlices.TryGetValue( + clipKey, + out ClipViewSlice[]? cellSlices) + || cellSlices.Length == 0) + { + continue; + } + + _cellStaticScratch.Clear(); + if (partition is not null) + { + foreach (var e in partition.Dynamics) + if (e.ParentCellId == cellId) + _cellStaticScratch.Add(e); + } + + bool cellDrewObjects = false; + _cellParticleUnionScratch.Clear(); + foreach (ClipViewSlice slice in cellSlices) + { + int routeIndex = lookInRouteIndex++; + passes.UseCellPortalViewRouting(cellId, slice); + + if (frameEntityPasses is not null) + { + RenderFrameRouteOwnerSelector.Replace( + _cellParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.LookInObject, + routeIndex, + cellId); + } + else + { + ReplaceOwnerIds( + _cellParticleOwnerScratch, + _cellStaticScratch); + } + + if (frameEntityPasses is not null + || _cellStaticScratch.Count > 0) + { + _candidateObserver?.ObservePViewBucket( + CurrentRenderPViewRoute.LookInObject, + routeIndex, + cellId, + _cellStaticScratch); + _oneCell.Clear(); + _oneCell.Add(cellId); + DrawEntityRouteOrLegacy( + ctx, + passes, + frameEntityPasses, + in frameView, + RenderFrameCandidateRoute.LookInObject, + routeIndex, + cellId, + _cellStaticScratch, + _oneCell); + + cellDrewObjects = true; + _cellParticleUnionScratch.UnionWith( + _cellParticleOwnerScratch); + } + } + + // The walk already drew this cell's STATIC content + // (WalkFrameDriver's LookInStatic turn) but never submits + // particles for it — GetCellStatics fills that gap, unioned + // with the dynamic route's own owners so ONE + // DrawCellParticles call covers both. + if (_walkWorldData is not null) + { + Walk.WalkFrameStaticRecords statics = + _walkWorldData.GetCellStatics(cellId); + foreach (RenderProjectionRecord record in statics.Records) + { + if (record.Source.LocalEntityId != 0) + { + _cellParticleUnionScratch.Add(record.Source.LocalEntityId); + cellDrewObjects = true; + } + } + } + + if (cellDrewObjects) + { + passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext( + cellId, NoClipSlice, _cellParticleUnionScratch)); + } + } + + passes.UseIndoorMembershipOnlyRouting(); + } } private void DrawLandscapeThroughOutsideView( diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index 10bb06e3..7bd78e5d 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -122,6 +122,11 @@ internal sealed class RenderScenePViewFrameProductController : { private readonly RenderSceneShadowRuntime _shadow; private readonly CurrentRenderSceneOracle? _current; + + /// Campaign FW3.2b-2: the frame's retained-scene read view for + /// the walk's world-data provider (the same query + /// reads its routes from). + internal RenderSceneQuery SceneQuery => _shadow.Query; private readonly WbDrawDispatcher? _dispatcher; private readonly RenderScenePViewFrameBuilder _builder = new(); private readonly RenderFrameExchange _exchange = new(); @@ -1123,7 +1128,6 @@ internal sealed class RenderScenePViewFrameBuilder private const byte EnvCellProjectionDomain = 2; private readonly HashSet _projectionIds = []; - private RenderProjectionRecord[] _outdoor = []; private RenderProjectionRecord[] _dynamics = []; private RenderProjectionRecord[] _cell = []; private RenderProjectionRecord[] _dirty = []; @@ -1134,14 +1138,9 @@ internal sealed class RenderScenePViewFrameBuilder // marks every non-player part): an object whose cell drew with a look-in // must not enter the final dynamics route again. private readonly HashSet _lookInCellScratch = new(); - private RenderProjectionRecord[] _cellRoute = []; - private readonly Dictionary - _outdoorPositions = []; private readonly Dictionary _dynamicPositions = []; - private int _outdoorCount; private int _dynamicCount; - private int _cellRouteCount; private int _dirtyCount; private RenderSceneGeneration _indexGeneration; private ulong _indexRevision; @@ -1164,7 +1163,13 @@ internal sealed class RenderScenePViewFrameBuilder _projectionIds.Clear(); LoadSceneIndices(input.Scene); - BuildOutdoorRoutes(writer, in input); + // Campaign FW3.2b-2: LandscapeOutdoorStatic, LandscapeBuildingShell, + // and CellStatic no longer emit here — WalkFrameDriver draws every + // outdoor static, building shell, and cell static (including + // look-in cell statics) directly through OrderedDrawStream (plan + // §FW3 "FW3.2b-2 — the production rooting", item 3). LookInObject + // keeps emitting, but BuildLookInRoutes below is now filtered to + // DYNAMIC candidates only — the walk owns that route's statics. int lookInRouteIndex = 0; for (int frameIndex = 0; frameIndex < input.LookInFrames.Count; @@ -1175,13 +1180,8 @@ internal sealed class RenderScenePViewFrameBuilder in input, frameIndex, ref lookInRouteIndex); - BuildLookInBuildingShellRoutes( - writer, - in input, - frameIndex); } BuildOutsideDynamicRoutes(writer, in input); - BuildCellStaticRoute(writer, in input); BuildDynamicLastRoute(writer, in input); writer.Publish(); AcknowledgeCachedDirtyRecords(); @@ -1200,18 +1200,6 @@ internal sealed class RenderScenePViewFrameBuilder if (scene.Generation != _indexGeneration || revision != _indexRevision) { - EnsureCapacity(ref _outdoor, counts.OutdoorStatic); - _outdoorCount = scene.CopyIndexTo( - RenderSceneIndex.OutdoorStatic, - _outdoor); - _outdoorCount = CompactAndSort( - _outdoor, - _outdoorCount); - BuildPositionIndex( - _outdoor, - _outdoorCount, - _outdoorPositions); - EnsureCapacity(ref _dynamics, counts.Dynamic); _dynamicCount = scene.CopyIndexTo( RenderSceneIndex.Dynamic, @@ -1235,12 +1223,6 @@ internal sealed class RenderScenePViewFrameBuilder for (int index = 0; index < _dirtyCount; index++) { RenderProjectionRecord record = _dirty[index]; - if (_outdoorPositions.TryGetValue( - record.Id, - out int outdoorPosition)) - { - _outdoor[outdoorPosition] = record; - } if (_dynamicPositions.TryGetValue( record.Id, out int dynamicPosition)) @@ -1255,16 +1237,6 @@ internal sealed class RenderScenePViewFrameBuilder for (int index = 0; index < _dirtyCount; index++) { RenderProjectionId id = _dirty[index].Id; - if (_outdoorPositions.TryGetValue( - id, - out int outdoorPosition)) - { - _outdoor[outdoorPosition] = - _outdoor[outdoorPosition] with - { - DirtyMask = RenderDirtyMask.None, - }; - } if (_dynamicPositions.TryGetValue( id, out int dynamicPosition)) @@ -1289,103 +1261,13 @@ internal sealed class RenderScenePViewFrameBuilder positions.Add(records[index].Id, index); } - private void BuildOutdoorRoutes( - RenderFrameWriter writer, - in RenderScenePViewBuildInput input) - { - int sliceCount = input.ClipAssembly.OutsideViewSlices.Length; - for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++) - { - int count = 0; - EnsureCapacity(ref _survivors, _outdoorCount); - for (int i = 0; i < _outdoorCount; i++) - { - RenderProjectionRecord record = _outdoor[i]; - if (record.EntityPayload.IsBuildingShell - && RetailPViewRenderer.FindLookInFrameIndex( - record.Source.BuildingShellAnchorCellId, - input.LookInFrames, - input.Cells) >= 0) - { - continue; - } - Sphere(in record, out Vector3 center, out float radius); - if (!input.Viewcone.SphereVisibleInOutsideSlice( - sliceIndex, - in center, - radius)) - { - continue; - } - - _survivors[count++] = record; - writer.AddOutdoor(in record); - AddProjection( - writer, - in record, - input.AnimatedEntityIds); - } - - writer.AddRouteRange( - RenderFrameCandidateRoute.LandscapeOutdoorStatic, - sliceIndex, - 0, - _survivors.AsSpan(0, count)); - } - } - - private void BuildLookInBuildingShellRoutes( - RenderFrameWriter writer, - in RenderScenePViewBuildInput input, - int frameIndex) - { - int sliceCount = input.ClipAssembly.OutsideViewSlices.Length; - for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++) - { - int count = 0; - EnsureCapacity(ref _survivors, _outdoorCount); - for (int i = 0; i < _outdoorCount; i++) - { - RenderProjectionRecord record = _outdoor[i]; - if (!record.EntityPayload.IsBuildingShell - || RetailPViewRenderer.FindLookInFrameIndex( - record.Source.BuildingShellAnchorCellId, - input.LookInFrames, - input.Cells) != frameIndex) - { - continue; - } - - Sphere(in record, out Vector3 center, out float radius); - if (!input.Viewcone.SphereVisibleInOutsideSlice( - sliceIndex, - in center, - radius)) - { - continue; - } - - _survivors[count++] = record; - writer.AddOutdoor(in record); - AddProjection( - writer, - in record, - input.AnimatedEntityIds); - } - - int routeIndex = - RetailPViewRenderer.LookInBuildingShellRouteIndex( - frameIndex, - sliceCount, - sliceIndex); - writer.AddRouteRange( - RenderFrameCandidateRoute.LandscapeBuildingShell, - routeIndex, - 0, - _survivors.AsSpan(0, count)); - } - } - + /// Campaign FW3.2b-2: LookInObject now carries DYNAMIC + /// candidates only — the walk draws every look-in cell's STATIC content + /// directly (WalkFrameDriver's LookInStatic turn, via + /// WalkProductionWorldData.GetCellStatics), so loading this + /// route's cell contents with includeStatics: false is what keeps + /// the two draws from doubling a look-in room's furniture (plan §FW3 + /// item 3). private void BuildLookInRoutes( RenderFrameWriter writer, in RenderScenePViewBuildInput input, @@ -1408,6 +1290,7 @@ internal sealed class RenderScenePViewFrameBuilder int count = LoadCell( input.Scene, cellId, + includeStatics: false, includeDynamics: true); for (int sliceIndex = 0; sliceIndex < slices.Length; sliceIndex++) { @@ -1475,61 +1358,6 @@ internal sealed class RenderScenePViewFrameBuilder } } - private void BuildCellStaticRoute( - RenderFrameWriter writer, - in RenderScenePViewBuildInput input) - { - _cellRouteCount = 0; - IReadOnlyList ordered = input.PortalFrame.OrderedVisibleCells; - for (int i = ordered.Count - 1; i >= 0; i--) - { - uint cellId = ordered[i]; - if (!input.DrawableCells.Contains(cellId)) - continue; - - int loaded = LoadCell( - input.Scene, - cellId, - includeDynamics: false); - int count = 0; - for (int index = 0; index < loaded; index++) - { - RenderProjectionRecord record = _cell[index]; - Sphere(in record, out Vector3 center, out float radius); - if (!input.Viewcone.SphereVisibleInCell( - cellId, - in center, - radius)) - { - continue; - } - - _cell[count++] = record; - AddProjection( - writer, - in record, - input.AnimatedEntityIds); - } - - writer.AddCellRange( - cellId, - i, - _cell.AsSpan(0, count)); - EnsureCapacity( - ref _cellRoute, - checked(_cellRouteCount + count)); - _cell.AsSpan(0, count).CopyTo( - _cellRoute.AsSpan(_cellRouteCount)); - _cellRouteCount += count; - } - - writer.AddRouteRange( - RenderFrameCandidateRoute.CellStatic, - 0, - 0, - _cellRoute.AsSpan(0, _cellRouteCount)); - } - private void BuildDynamicLastRoute( RenderFrameWriter writer, in RenderScenePViewBuildInput input) @@ -1593,18 +1421,25 @@ internal sealed class RenderScenePViewFrameBuilder private int LoadCell( RenderSceneQuery scene, uint cellId, - bool includeDynamics) + bool includeDynamics, + bool includeStatics = true) { - int staticCount = scene.GetCellStaticCount(cellId); + int staticCount = includeStatics + ? scene.GetCellStaticCount(cellId) + : 0; int dynamicCount = includeDynamics ? scene.GetCellDynamicCount(cellId) : 0; EnsureCapacity( ref _cell, checked(staticCount + dynamicCount)); - int count = scene.CopyCellStaticsTo( - cellId, - _cell.AsSpan(0, staticCount)); + int count = 0; + if (staticCount > 0) + { + count = scene.CopyCellStaticsTo( + cellId, + _cell.AsSpan(0, staticCount)); + } if (dynamicCount > 0) { count += scene.CopyCellDynamicsTo( diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs new file mode 100644 index 00000000..17392765 --- /dev/null +++ b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs @@ -0,0 +1,180 @@ +using System.Numerics; +using AcDream.App.Rendering.Scene; + +namespace AcDream.App.Rendering.Walk; + +/// +/// Campaign FW3.2b-2: the production over +/// the retained scene () and the FW3.1 +/// . Rebuilt facts per frame via +/// : +/// +/// +/// Cell statics — on +/// demand, one pooled array per distinct cell per frame (a cell can be +/// visited once by the root flood OR once per admitting look-in portal; the +/// per-frame cache keeps the copy single). +/// Outdoor statics — ONE +/// sweep bucketed by landscape cell id +/// ((lb & 0xFFFF0000) | (cellX*8 + cellY + 1) from the record's +/// world position — the same encoding +/// computes), EXCLUDING building shells (they draw at their building's own +/// shell turn, retail CPhysicsPart::Draw(parts, 0) @0x0059f331, not +/// at the cell's DrawObjCell turn). +/// Building shells — the same sweep's IsBuildingShell records +/// bucketed by Source.BuildingShellAnchorCellId; a +/// maps to its anchor via its first +/// non-exit portal's destination (the SAME rule +/// LandblockLoader used to author the anchor). +/// +/// +/// The tuple landblock id handed to the classifier is the frame's player +/// landblock — the packed path's own convention for every +/// RenderFrameEntityDrawRequest. +/// +internal sealed class WalkProductionWorldData : IWalkFrameWorldData +{ + private readonly WalkBuildingRegistry _buildings; + private RenderSceneQuery _scene; + private uint _tupleLandblockId; + + private readonly Dictionary _cellCache = new(); + private readonly Dictionary> _outdoorByCell = new(); + private readonly Dictionary> _shellsByAnchor = new(); + private readonly Dictionary _outdoorMaterialized = new(); + private readonly Dictionary _shellMaterialized = new(); + private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024]; + private RenderProjectionRecord[] _cellScratch = new RenderProjectionRecord[256]; + + internal WalkProductionWorldData(WalkBuildingRegistry buildings) + { + _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); + } + + /// Rebuilds the frame's outdoor/shell buckets and clears the + /// per-cell cache. Call once per frame before the driver runs. + internal void BeginFrame(RenderSceneQuery scene, uint tupleLandblockId) + { + _scene = scene; + _tupleLandblockId = tupleLandblockId; + _cellCache.Clear(); + _outdoorMaterialized.Clear(); + _shellMaterialized.Clear(); + foreach (List bucket in _outdoorByCell.Values) + bucket.Clear(); + foreach (List bucket in _shellsByAnchor.Values) + bucket.Clear(); + + int count; + while (true) + { + count = _scene.CopyIndexTo(RenderSceneIndex.OutdoorStatic, _sweepScratch); + if (count < _sweepScratch.Length) + break; + _sweepScratch = new RenderProjectionRecord[_sweepScratch.Length * 2]; + } + for (int i = 0; i < count; i++) + { + ref readonly RenderProjectionRecord record = ref _sweepScratch[i]; + if (record.EntityPayload.IsBuildingShell) + { + uint anchor = record.Source.BuildingShellAnchorCellId; + if (!_shellsByAnchor.TryGetValue(anchor, out List? shells)) + _shellsByAnchor[anchor] = shells = new List(); + shells.Add(record); + continue; + } + uint cellId = LandscapeCellId(record.Transform.Position); + if (!_outdoorByCell.TryGetValue(cellId, out List? bucket)) + _outdoorByCell[cellId] = bucket = new List(); + bucket.Add(record); + } + } + + /// The landscape cell owning a world position — retail's + /// 24 m cell grid inside the 192 m landblock, the same + /// (lb & 0xFFFF0000) | (cellX*8 + cellY + 1) encoding the walk's + /// landscape turn emits. + internal static uint LandscapeCellId(Vector3 worldPosition) + { + int lbX = (int)MathF.Floor(worldPosition.X / 192f); + int lbY = (int)MathF.Floor(worldPosition.Y / 192f); + float localX = worldPosition.X - lbX * 192f; + float localY = worldPosition.Y - lbY * 192f; + int cellX = Math.Clamp((int)(localX / 24f), 0, 7); + int cellY = Math.Clamp((int)(localY / 24f), 0, 7); + uint landblock = ((uint)(byte)lbX << 24) | ((uint)(byte)lbY << 16); + return landblock | (uint)(cellX * 8 + cellY + 1); + } + + public WalkFrameStaticRecords GetCellStatics(uint cellId) + { + if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) + return cached; + int count; + while (true) + { + count = _scene.CopyCellStaticsTo(cellId, _cellScratch); + if (count < _cellScratch.Length) + break; + _cellScratch = new RenderProjectionRecord[_cellScratch.Length * 2]; + } + WalkFrameStaticRecords records = count == 0 + ? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId } + : new WalkFrameStaticRecords(_cellScratch[..count], _tupleLandblockId); + _cellCache[cellId] = records; + return records; + } + + public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) + { + if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached)) + return cached; + WalkFrameStaticRecords records = + _outdoorByCell.TryGetValue(cellId, out List? bucket) + && bucket.Count > 0 + ? new WalkFrameStaticRecords([.. bucket], _tupleLandblockId) + : WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; + _outdoorMaterialized[cellId] = records; + return records; + } + + public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) + { + uint anchor = AnchorCellId(building); + if (anchor == 0) + return WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; + if (_shellMaterialized.TryGetValue(anchor, out WalkFrameStaticRecords cached)) + return cached; + WalkFrameStaticRecords records = + _shellsByAnchor.TryGetValue(anchor, out List? shells) + && shells.Count > 0 + ? new WalkFrameStaticRecords([.. shells], _tupleLandblockId) + : WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; + _shellMaterialized[anchor] = records; + return records; + } + + /// The building's authored shell anchor: its first non-exit + /// portal's destination cell — the SAME rule LandblockLoader used + /// when it stamped BuildingShellAnchorCellId on the shell entity. + internal static uint AnchorCellId(WalkBuilding building) + { + foreach (ref readonly WalkBldPortal portal in building.Portals.AsSpan()) + { + if (portal.OtherCellId != 0xFFFFFFFFu) + return portal.OtherCellId; + } + return 0; + } + + public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building) + { + if (!_buildings.TryGetEntry(building, out WalkBuildingFactory.Entry? entry)) + { + throw new InvalidOperationException( + $"walk building 0x{building.PositionCellId:X8} has no committed registry entry"); + } + return entry.WorldTransform; + } +} diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs index c4e4508e..2c46058e 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs @@ -210,6 +210,32 @@ public sealed unsafe partial class WbDrawDispatcher } } + /// + /// Campaign FW3.2b-2: the production frame/encoder pair for + /// 's own + /// calls (contrast this stage's diagnostic-target callers, which supply + /// their own frame/encoder — see 's own + /// doc comment). Reads the SAME world-pass scope + /// already requires ( / + /// _scope.RequireEncoder()) — fails loud rather than handing the + /// driver a null pair when the world phase is not bracketing. + /// + internal (IGpuFrame Frame, IGpuPassEncoder Encoder) RequireWalkSubmission() => + (RequireRhiFrame(), _scope!.RequireEncoder()); + + /// + /// Campaign FW3.2b-2: the live colour-attachment size, for + /// 's viewport (the walk's ray + /// caster needs the REAL viewport, not the FW0/FW1 capture-client + /// fixture constants — see that class's own doc comment). Null outside + /// the world phase (no scope published yet); the caller falls back to + /// the fixture constants with a comment in that case rather than + /// failing loud, since a missing scope here is a startup-ordering + /// timing question, not a misconfiguration. + /// + internal (int Width, int Height)? WalkAttachmentExtent => + _scope is null ? null : (_scope.AttachmentWidth, _scope.AttachmentHeight); + /// /// Submits in walk order through the existing /// RHI: per-instance-first emission (see the type doc comment), one diff --git a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs index b8d560ba..86b08063 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs @@ -14,326 +14,33 @@ public sealed class RenderScenePViewFrameProductTests private static readonly RenderSceneGeneration Generation = RenderSceneGeneration.FromRaw(1); - [Fact] - public void Builder_PreservesPViewRoutesAndExcludesShellsAndWithdrawnOwners() - { - using var scene = new ArchRenderScene(Generation); - RenderProjectionRecord outdoor = Record( - 0x0100_0000_0000_0001, - RenderProjectionClass.OutdoorStatic); - RenderProjectionRecord buildingShell = Record( - 0x0100_0000_0000_0008, - RenderProjectionClass.OutdoorStatic, - isBuildingShell: true); - RenderProjectionRecord hidden = Record( - 0x0100_0000_0000_0002, - RenderProjectionClass.OutdoorStatic, - flags: RenderProjectionFlags.Hidden - | RenderProjectionFlags.SpatiallyResident); - RenderProjectionRecord withdrawn = Record( - 0x0100_0000_0000_0003, - RenderProjectionClass.OutdoorStatic, - flags: RenderProjectionFlags.Draw); - RenderProjectionRecord shell = Record( - 0x0200_0000_0000_0004, - RenderProjectionClass.IndoorCellStatic, - parentCell: Cell); - RenderProjectionRecord cellStatic = Record( - 0x0100_0000_0000_0005, - RenderProjectionClass.IndoorCellStatic, - parentCell: Cell); - RenderProjectionRecord outdoorDynamic = Record( - 0x0300_0000_0000_0006, - RenderProjectionClass.LiveDynamicRoot); - RenderProjectionRecord cellDynamic = Record( - 0x0300_0000_0000_0007, - RenderProjectionClass.LiveDynamicRoot, - parentCell: Cell); - RenderProjectionRecord[] records = - [ - outdoor, - buildingShell, - hidden, - withdrawn, - shell, - cellStatic, - outdoorDynamic, - cellDynamic, - ]; - var deltas = new RenderProjectionDelta[records.Length]; - for (int index = 0; index < records.Length; index++) - { - deltas[index] = RenderProjectionDelta.Register( - Generation, - (ulong)index + 1, - records[index]); - } - scene.Apply(deltas); - - PortalVisibilityFrame portal = Portal(Cell); - ClipFrameAssembly clip = FullScreenClip(Cell); - ViewconeCuller viewcone = - ViewconeCuller.Build(clip, Matrix4x4.Identity); - var exchange = new RenderFrameExchange(); - var builder = new RenderScenePViewFrameBuilder(); - RenderSceneDigest digest = - scene.BuildDigest(new RenderSceneDigestBuffer()); - var input = new RenderScenePViewBuildInput( - scene.OpenQuery(), - digest, - portal, - clip, - viewcone, - [], - [Cell], - EmptyCellSource.Instance, - [], - RootIsOutdoor: true); - - builder.Build(exchange, frameSequence: 1, in input); - RenderFrameView view = exchange.BorrowLatest(Generation, 1); - try - { - Assert.Equal( - [outdoor.Id, hidden.Id, buildingShell.Id], - view.OutdoorStaticCandidates.ToArray() - .Select(static item => item.Id)); - Assert.Equal( - [cellStatic.Id], - view.CellStaticCandidates.ToArray() - .Select(static item => item.Id)); - Assert.Equal( - [outdoorDynamic.Id, cellDynamic.Id], - view.DynamicCandidates.ToArray() - .Select(static item => item.Id)); - Assert.Equal(6, view.Transforms.Length); - Assert.Equal(6, view.RouteCandidates.Length); - Assert.Equal(3, view.RouteRanges.Length); - RenderFrameCandidateRange outdoorRange = Assert.Single( - view.RouteRanges.ToArray(), - range => range.Route - == RenderFrameCandidateRoute.LandscapeOutdoorStatic); - Assert.Equal(3, outdoorRange.Count); - Assert.Contains( - view.RouteCandidates.Slice( - outdoorRange.Offset, - outdoorRange.Count).ToArray(), - item => item.Id == buildingShell.Id); - Assert.DoesNotContain( - view.RouteRanges.ToArray(), - range => range.Route - == RenderFrameCandidateRoute.LandscapeBuildingShell); - Assert.DoesNotContain( - view.RouteCandidates.ToArray(), - item => item.Id == withdrawn.Id || item.Id == shell.Id); - Assert.Equal( - new RenderFrameDiagnosticCounts( - OutdoorStaticCandidates: 3, - CellStaticCandidates: 1, - DynamicCandidates: 2, - TransformCount: 6, - OpaqueClassificationCount: 0, - AlphaClassificationCount: 0, - LightSetCount: 0, - SelectionPartCount: 0, - RouteCandidateCount: 6, - EntityCandidateCount: 6, - MeshPartCount: 6), - view.DiagnosticCounts); - Assert.Equal(6, view.EntityCandidates.Length); - Assert.Equal(6, view.MeshParts.Length); - Assert.Same(portal, view.PortalFrame); - Assert.Same(clip, view.ClipAssembly); - Assert.Equal(digest, view.SourceDigest); - } - finally - { - exchange.Release(in view); - } - } - - [Fact] - public void Builder_OrdersMultiSliceBuildingShellsAfterLookIns() - { - using var scene = new ArchRenderScene(Generation); - RenderProjectionRecord outdoor = Record( - 0x0100_0000_0000_0101, - RenderProjectionClass.OutdoorStatic); - RenderProjectionRecord buildingShell = Record( - 0x0100_0000_0000_0102, - RenderProjectionClass.OutdoorStatic, - isBuildingShell: true, - buildingShellAnchorCellId: Cell); - RenderProjectionRecord lookInObject = Record( - 0x0100_0000_0000_0103, - RenderProjectionClass.IndoorCellStatic, - parentCell: Cell); - scene.Apply( - [ - RenderProjectionDelta.Register(Generation, 1, outdoor), - RenderProjectionDelta.Register(Generation, 2, buildingShell), - RenderProjectionDelta.Register(Generation, 3, lookInObject), - ]); - - PortalVisibilityFrame portal = Portal(Cell); - PortalVisibilityFrame lookIn = Portal(Cell); - lookIn.SourceBuildingKey = 2u; - lookIn.SourceBuildingLandblockId = Cell & 0xFFFF0000u; - var anchorCell = new LoadedCell - { - CellId = Cell, - BuildingId = 2u, - }; - ClipFrameAssembly clip = TwoSliceClip(Cell); - clip.LookInCellToViewSlices[new LookInClipCell(0, Cell)] = - [clip.CellIdToViewSlices[Cell][0]]; - ViewconeCuller viewcone = - ViewconeCuller.Build(clip, Matrix4x4.Identity); - var exchange = new RenderFrameExchange(); - var builder = new RenderScenePViewFrameBuilder(); - RenderSceneDigest digest = - scene.BuildDigest(new RenderSceneDigestBuffer()); - var input = new RenderScenePViewBuildInput( - scene.OpenQuery(), - digest, - portal, - clip, - viewcone, - [lookIn], - [Cell], - new DictionaryCellSource(anchorCell), - [], - RootIsOutdoor: true); - - builder.Build(exchange, frameSequence: 1, in input); - RenderFrameView view = exchange.BorrowLatest(Generation, 1); - try - { - Assert.Equal( - [ - (RenderFrameCandidateRoute.LandscapeOutdoorStatic, 0, 0u), - (RenderFrameCandidateRoute.LandscapeOutdoorStatic, 1, 0u), - (RenderFrameCandidateRoute.LookInObject, 0, Cell), - (RenderFrameCandidateRoute.LandscapeBuildingShell, 0, 0u), - (RenderFrameCandidateRoute.LandscapeBuildingShell, 1, 0u), - (RenderFrameCandidateRoute.CellStatic, 0, 0u), - ], - view.RouteRanges.ToArray().Select(static range => - (range.Route, range.RouteIndex, range.CellId))); - } - finally - { - exchange.Release(in view); - } - } - - [Fact] - public void Builder_InterleavesEachLookInWithItsOwnPackedBuildingShellRoutes() - { - const uint secondCell = 0xA9B40180u; - using var scene = new ArchRenderScene(Generation); - RenderProjectionRecord firstShell = Record( - 0x0100_0000_0000_0201, - RenderProjectionClass.OutdoorStatic, - isBuildingShell: true, - buildingShellAnchorCellId: Cell); - RenderProjectionRecord secondShell = Record( - 0x0100_0000_0000_0202, - RenderProjectionClass.OutdoorStatic, - isBuildingShell: true, - buildingShellAnchorCellId: secondCell); - RenderProjectionRecord firstObject = Record( - 0x0100_0000_0000_0203, - RenderProjectionClass.IndoorCellStatic, - parentCell: Cell); - RenderProjectionRecord secondObject = Record( - 0x0100_0000_0000_0204, - RenderProjectionClass.IndoorCellStatic, - parentCell: secondCell); - scene.Apply( - [ - RenderProjectionDelta.Register(Generation, 1, firstShell), - RenderProjectionDelta.Register(Generation, 2, secondShell), - RenderProjectionDelta.Register(Generation, 3, firstObject), - RenderProjectionDelta.Register(Generation, 4, secondObject), - ]); - - PortalVisibilityFrame portal = Portal(Cell); - PortalVisibilityFrame firstLookIn = Portal(Cell); - firstLookIn.SourceBuildingKey = 2u; - firstLookIn.SourceBuildingLandblockId = Cell & 0xFFFF0000u; - PortalVisibilityFrame secondLookIn = Portal(secondCell); - secondLookIn.SourceBuildingKey = 3u; - secondLookIn.SourceBuildingLandblockId = secondCell & 0xFFFF0000u; - var firstAnchor = new LoadedCell - { - CellId = Cell, - BuildingId = 2u, - }; - var secondAnchor = new LoadedCell - { - CellId = secondCell, - BuildingId = 3u, - }; - ClipFrameAssembly clip = TwoSliceClip(Cell); - clip.LookInCellToViewSlices[new LookInClipCell(0, Cell)] = - [clip.CellIdToViewSlices[Cell][0]]; - clip.LookInCellToViewSlices[new LookInClipCell(1, secondCell)] = - [clip.CellIdToViewSlices[Cell][0]]; - ViewconeCuller viewcone = - ViewconeCuller.Build(clip, Matrix4x4.Identity); - var exchange = new RenderFrameExchange(); - var builder = new RenderScenePViewFrameBuilder(); - RenderSceneDigest digest = - scene.BuildDigest(new RenderSceneDigestBuffer()); - var input = new RenderScenePViewBuildInput( - scene.OpenQuery(), - digest, - portal, - clip, - viewcone, - [firstLookIn, secondLookIn], - [Cell], - new DictionaryCellSource(firstAnchor, secondAnchor), - [], - RootIsOutdoor: true); - - builder.Build(exchange, frameSequence: 1, in input); - RenderFrameView view = exchange.BorrowLatest(Generation, 1); - try - { - Assert.Equal( - [ - (RenderFrameCandidateRoute.LookInObject, 0, Cell), - (RenderFrameCandidateRoute.LandscapeBuildingShell, 0, 0u), - (RenderFrameCandidateRoute.LandscapeBuildingShell, 1, 0u), - (RenderFrameCandidateRoute.LookInObject, 1, secondCell), - (RenderFrameCandidateRoute.LandscapeBuildingShell, 2, 0u), - (RenderFrameCandidateRoute.LandscapeBuildingShell, 3, 0u), - ], - view.RouteRanges.ToArray() - .Where(static range => - range.Route is RenderFrameCandidateRoute.LookInObject - or RenderFrameCandidateRoute.LandscapeBuildingShell) - .Select(static range => - (range.Route, range.RouteIndex, range.CellId))); - } - finally - { - exchange.Release(in view); - } - } + // Campaign FW3.2b-2 (adjudicated 2026-08-30): Builder_PreservesPViewRoutesAndExcludesShellsAndWithdrawnOwners, + // Builder_OrdersMultiSliceBuildingShellsAfterLookIns, and + // Builder_InterleavesEachLookInWithItsOwnPackedBuildingShellRoutes are + // DELETED — all three asserted route-range interleaving for + // LandscapeOutdoorStatic/LandscapeBuildingShell/CellStatic, which + // RenderScenePViewFrameBuilder no longer emits at all (WalkFrameDriver + // draws every outdoor static, building shell, and cell static — look-in + // included — directly through OrderedDrawStream; plan §FW3 + // "FW3.2b-2 — the production rooting", item 3). There is no successor + // assertion to write: the routes themselves are retired, not relocated. [Fact] public void Builder_ReusesOrderedIndicesWhileRefreshingDirtyRecords() { + // Campaign FW3.2b-2 (adjudicated 2026-08-30): re-expressed against + // the DYNAMIC index (LiveDynamicRoot / DynamicCandidates) instead of + // the now-dead OutdoorStatic index — LoadSceneIndices' dirty-record + // refresh + sort-key reorder mechanism this test proves is SHARED + // between the two indices; the OutdoorStatic side was deleted along + // with BuildOutdoorRoutes (its only reader). using var scene = new ArchRenderScene(Generation); RenderProjectionRecord later = Record( 0x0100_0000_0000_0012, - RenderProjectionClass.OutdoorStatic); + RenderProjectionClass.LiveDynamicRoot); RenderProjectionRecord earlier = Record( 0x0100_0000_0000_0011, - RenderProjectionClass.OutdoorStatic); + RenderProjectionClass.LiveDynamicRoot); scene.Apply( [ RenderProjectionDelta.Register(Generation, 1, later), @@ -353,7 +60,7 @@ public sealed class RenderScenePViewFrameProductTests { Assert.Equal( [earlier.Id, later.Id], - first.OutdoorStaticCandidates.ToArray() + first.DynamicCandidates.ToArray() .Select(static record => record.Id)); } finally @@ -401,7 +108,7 @@ public sealed class RenderScenePViewFrameProductTests projected.Transform.LocalToWorld); Assert.Equal( [earlier.Id, later.Id], - second.OutdoorStaticCandidates.ToArray() + second.DynamicCandidates.ToArray() .Select(static record => record.Id)); } finally @@ -431,7 +138,7 @@ public sealed class RenderScenePViewFrameProductTests { Assert.Equal( [later.Id, earlier.Id], - third.OutdoorStaticCandidates.ToArray() + third.DynamicCandidates.ToArray() .Select(static record => record.Id)); } finally @@ -463,14 +170,18 @@ public sealed class RenderScenePViewFrameProductTests [Fact] public void Controller_MatchesCurrentPViewThenNamesAWithdrawnCandidate() { - WorldEntity outdoor = Entity(10, serverGuid: 0, parentCell: null); - WorldEntity cellStatic = Entity(11, serverGuid: 0, parentCell: Cell); + // Campaign FW3.2b-2 (adjudicated 2026-08-30): no static entities here + // — LandscapeOutdoorStatic/CellStatic no longer flow through the + // packed product at all (WalkFrameDriver owns every static now), so + // a static observed on the LEGACY oracle side with nothing on the + // packed side would report a permanent mismatch unrelated to what + // this test is proving (withdrawal detection on the SURVIVING + // DynamicLast route). WorldEntity outdoorDynamic = Entity(1_000_012, 0x8000_0012, parentCell: null); WorldEntity cellDynamic = Entity(1_000_013, 0x8000_0013, parentCell: Cell); - WorldEntity[] entities = - [outdoor, cellStatic, outdoorDynamic, cellDynamic]; + WorldEntity[] entities = [outdoorDynamic, cellDynamic]; var oracle = new CurrentRenderSceneOracle(); var partition = new InteriorEntityPartition.Result(); InteriorEntityPartition.Partition( @@ -479,16 +190,6 @@ public sealed class RenderScenePViewFrameProductTests [Entry(entities)], oracle); oracle.BeginPViewFrame(); - oracle.ObservePViewBucket( - CurrentRenderPViewRoute.LandscapeOutdoorStatic, - 0, - 0, - [outdoor]); - oracle.ObservePViewBucket( - CurrentRenderPViewRoute.CellStatic, - 0, - 0, - [cellStatic]); oracle.ObservePViewBucket( CurrentRenderPViewRoute.DynamicLast, 0, @@ -496,14 +197,6 @@ public sealed class RenderScenePViewFrameProductTests [outdoorDynamic, cellDynamic]); oracle.CompletePViewFrame(); oracle.BeginDispatcherFrame(); - oracle.ObserveDispatcherDraw( - WbDrawDispatcher.EntitySet.All, - entitiesWalked: 1, - [(outdoor, 0, Landblock)]); - oracle.ObserveDispatcherDraw( - WbDrawDispatcher.EntitySet.All, - entitiesWalked: 1, - [(cellStatic, 0, Landblock)]); oracle.ObserveDispatcherDraw( WbDrawDispatcher.EntitySet.All, entitiesWalked: 2, @@ -544,15 +237,15 @@ public sealed class RenderScenePViewFrameProductTests Assert.True(matched.Enabled); Assert.Equal(1uL, matched.ComparisonCount); Assert.Equal(1uL, matched.SuccessfulComparisonCount); - Assert.Equal(4, matched.ExpectedCandidateCount); - Assert.Equal(4, matched.ActualCandidateCount); + Assert.Equal(2, matched.ExpectedCandidateCount); + Assert.Equal(2, matched.ActualCandidateCount); Assert.Equal(0, matched.MismatchCount); Assert.Null(matched.FirstMismatch); Assert.Equal(matched.ExpectedDigest, matched.ActualDigest); Assert.Equal(1uL, matched.PackedInputComparisonCount); Assert.Equal(1uL, matched.PackedInputSuccessfulComparisonCount); - Assert.Equal(4, matched.PackedInputExpectedCount); - Assert.Equal(4, matched.PackedInputActualCount); + Assert.Equal(2, matched.PackedInputExpectedCount); + Assert.Equal(2, matched.PackedInputActualCount); Assert.Equal(0, matched.PackedInputMismatchCount); Assert.Null(matched.PackedInputFirstMismatch); Assert.Equal( @@ -581,10 +274,10 @@ public sealed class RenderScenePViewFrameProductTests Assert.Equal(2uL, mismatch.ComparisonCount); Assert.Equal(1uL, mismatch.SuccessfulComparisonCount); Assert.Equal(1, mismatch.MismatchCount); - Assert.Contains("field=count expected=4 actual=3", mismatch.FirstMismatch); + Assert.Contains("field=count expected=2 actual=1", mismatch.FirstMismatch); Assert.Equal(1, mismatch.PackedInputMismatchCount); Assert.Contains( - "field=count expected=4 actual=3", + "field=count expected=2 actual=1", mismatch.PackedInputFirstMismatch); Assert.Equal(2, logs.Count); } diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index 36628dbf..85e44e27 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -196,10 +196,15 @@ public sealed class RetailPViewPassExecutorTests [Fact] public void DrawInside_production_product_supplies_the_ordered_entity_routes() { + // Campaign FW3.2b-2: no cell-static entity here (adjudicated + // 2026-08-30) — CellStatic no longer flows through the packed + // product at all (WalkFrameDriver owns every cell static now, via + // OrderedDrawStream, not this route); a static entity registered on + // the LEGACY side with nothing on the packed side would report a + // permanent MismatchCount, which is not what this test is proving. + // The route-consumption-order assertion below still exercises the + // one route that DID survive the cutover (DynamicLast). LoadedCell interior = InteriorWithExit(0xA9B40100u); - WorldEntity cellStatic = Entity( - 30u, - parentCellId: interior.CellId); WorldEntity dynamic = Entity( 31u, serverGuid: 0x80000031u, @@ -208,17 +213,6 @@ public sealed class RetailPViewPassExecutorTests var oracle = new CurrentRenderSceneOracle(); using var shadow = new RenderSceneShadowRuntime( RenderSceneGeneration.FromRaw(1)); - RenderProjectionRecord staticRecord = - RenderProjectionRecordFactory.ProjectEntity( - StaticRenderProjectionJournal.StaticEntityId( - landblockId, - cellStatic.Id), - RenderProjectionClass.IndoorCellStatic, - RenderOwnerIncarnation.FromRaw(cellStatic.Id), - landblockId, - interior.CellId, - cellStatic, - spatiallyVisible: true); RenderProjectionRecord dynamicRecord = RenderProjectionRecordFactory.ProjectEntity( LiveRenderProjectionJournal.ProjectionId(dynamic.Id), @@ -228,7 +222,6 @@ public sealed class RetailPViewPassExecutorTests interior.CellId, dynamic, spatiallyVisible: true); - shadow.Journal.Register(in staticRecord); shadow.Journal.Register(in dynamicRecord); shadow.DrainUpdateBoundary(); var product = new RenderScenePViewFrameProductController( @@ -238,13 +231,12 @@ public sealed class RetailPViewPassExecutorTests using var executor = new RecordingExecutor(); renderer.DrawInside( - Frame(interior, [cellStatic, dynamic]), + Frame(interior, [dynamic]), executor); AssertAppearsInOrder( string.Join('|', executor.Operations), "entity-frame-begin", - "entity-route:CellStatic:0:00000000", "entity-route:DynamicLast:0:00000000", "entity-frame-complete"); Assert.Equal(0, product.Snapshot.MismatchCount);