diff --git a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs index a43e4b96..f6fb066a 100644 --- a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs +++ b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs @@ -142,17 +142,33 @@ public sealed class RetailFrameWalk { continue; } + // RenderDeviceD3D::DrawSortCell @0x0059f140 (decomp- + // confirmed 2026-08-30): DrawBuilding(building) FIRST, then + // DrawObjCell(cell) UNCONDITIONALLY — the building's turn + // (shell + portal machinery) precedes this cell's own + // outdoor-static turn. if (block.CellBuildings[cellIndex] is WalkBuilding building) DrawBuilding(building, activeViews, ctx, sink); + sink.OnLandscapeCellTurn((block.LandblockId & 0xFFFF0000u) | (uint)(cellIndex + 1)); } } } /// RenderDeviceD3D::DrawBuilding @0x0059f2a0 at the walk - /// level: the BLD event fires at ENTRY (before the degrade check); the - /// portal pass walks the drawing BSP twice per active view (punch, then - /// look-in + DrawCells on the outdoor pview); the shell pass emits no - /// walk event. + /// level: the BLD event fires at ENTRY (before the degrade check — the + /// oracle traces' breakpoint sat there, so this stays unconditional for + /// conformance). The ENTIRE rest of the body — the alpha barrier, the + /// portal pass, and the shell draw — sits inside retail's + /// if (part->gfxobj[part->deg_level] != 0) @0x0059f2d3; a + /// degraded-out slot draws NOTHING beyond the BLD event. + /// HasGeometry + a non-null SelectDrawingBsp together model + /// that one gate. Inside the gate, retail's own order + /// (@0x0059f30b–0x0059f345) is D3DPolyRender::FlushAlphaList(0f) → + /// CPhysicsPart::Draw(parts, 1) (the PORTAL flavor — the two-pass + /// punch/look-in walk below) → CPhysicsPart::Draw(parts, 0) (the + /// plain mesh — the building's own SHELL) → flag reset: the alpha + /// barrier and the portal pass both precede the shell draw, not follow + /// it. public void DrawBuilding( WalkBuilding building, WalkPortalView activeViews, IRetailFrameWalkContext ctx, IWalkEventSink sink) @@ -167,8 +183,15 @@ public sealed class RetailFrameWalk degradeMultiplier: DegradeMultiplier); if (bsp is null) return; + // Additive (Campaign FW3.2b-1): the alpha barrier + // (D3DPolyRender::FlushAlphaList(0f) @0x0059f30b) — gated by the + // SAME part->gfxobj[deg_level]!=0 check as everything below it, so + // this fires only now that both HasGeometry and the bsp lookup have + // passed. + sink.OnBuildingTurn(building); + int viewCount = Math.Max(activeViews.ViewCount, 0); - var passSink = new PortalPassSink(sink); + var passSink = new PortalPassSink(building, sink); Vector3 viewpoint = ctx.ViewpointInBuilding(building); for (int v = 0; v < viewCount; v++) { @@ -182,6 +205,12 @@ public sealed class RetailFrameWalk (portalRef, pass) => WalkBuildingPortals.DrawPortal( _outdoorPView, building, portalRef, pass, ctx, passSink)); } + + // Additive (Campaign FW3.2b-1): CPhysicsPart::Draw(parts, 0) + // @0x0059f331 — the building's own shell mesh — runs AFTER the + // portal walk completes (CPhysicsPart::Draw(parts, 1) just above), + // not before it. + sink.OnBuildingShellTurn(building); } private void EmitDrawCells(WalkPView pview, IWalkEventSink sink) @@ -204,13 +233,17 @@ public sealed class RetailFrameWalk ctx.GetVisible(id)?.PopView(); } - private sealed class PortalPassSink(IWalkEventSink sink) + private sealed class PortalPassSink(WalkBuilding building, IWalkEventSink sink) : WalkBuildingPortals.IWalkPortalPassSink { public void OnPunch(WalkPolygon polygon) { // The far-Z punch is a depth-only GPU submission (FW2's ordered - // stream); the oracle traces do not log it. + // stream); the oracle traces do not log it. Additive (Campaign + // FW3.2b-1): forward to the richer sink so a driver can flush + + // draw the punch fan — building-local space, world transform is + // the driver's job (WalkProductionFrameContext-style lookup). + sink.OnPunchGeometry(building, polygon); } public void OnDrawCells(WalkPView pview) diff --git a/src/AcDream.App/Rendering/Walk/WalkEvents.cs b/src/AcDream.App/Rendering/Walk/WalkEvents.cs index b3aa43c0..f510d93f 100644 --- a/src/AcDream.App/Rendering/Walk/WalkEvents.cs +++ b/src/AcDream.App/Rendering/Walk/WalkEvents.cs @@ -51,8 +51,78 @@ public readonly record struct WalkEvent( /// /// The sink a walk emits into. FW1 conformance tests collect events; FW2's /// 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 +/// vocabulary-only stream cannot express (a visited +/// landscape cell with no building emits no at all; +/// carries only a cell id, not the +/// reference a driver needs to look up shell +/// content, degrade state, or world transform; and punches are not part of +/// the FW1 conformance vocabulary at all — the oracle traces do not log +/// them). Every FW1/FW2 sink that only implements +/// continues to compile and behave identically — these are C# default +/// interface members, never called by any pre-FW3.2b-1 code path. /// public interface IWalkEventSink { void Emit(in WalkEvent walkEvent); + + /// + /// Fires once per visited landscape cell, AFTER that cell's building + /// turn (if any) — RenderDeviceD3D::DrawSortCell @0x0059f140 + /// calls DrawBuilding(building) first, then + /// DrawObjCell(cell) UNCONDITIONALLY (decomp-confirmed + /// 2026-08-30). follows retail's outdoor cell + /// encoding — (landblockId & 0xFFFF0000) | (cellIndex + 1) — + /// the same convention + /// and + + /// already use. Default + /// no-op. + /// + void OnLandscapeCellTurn(uint cellId) { } + + /// + /// Fires at once retail's own + /// gate has passed — RenderDeviceD3D::DrawBuilding @0x0059f2a0 + /// wraps its ENTIRE body (the alpha barrier, the portal pass, the shell + /// draw) in if (part->gfxobj[part->deg_level] != 0) + /// @0x0059f2d3 — a degraded-out slot draws NOTHING beyond the + /// unconditional + /// call. and a non-null + /// result together model that + /// one gate, so this hook fires only after both have passed. This is the + /// ALPHA BARRIER turn (D3DPolyRender::FlushAlphaList(0f) + /// @0x0059f30b) — retail's own order runs it BEFORE the portal pass + /// (CPhysicsPart::Draw(parts, 1)), which in turn runs BEFORE the + /// shell draw (CPhysicsPart::Draw(parts, 0) — see + /// , fired separately, after the portal + /// pass completes). Default no-op. + /// + void OnBuildingTurn(WalkBuilding building) { } + + /// + /// Fires at the END of , after + /// its two-pass portal walk (punches + look-ins, across every active + /// view) has fully completed — CPhysicsPart::Draw(parts, 0) + /// @0x0059f331, retail's plain-mesh draw of the building's own shell, + /// which runs strictly AFTER CPhysicsPart::Draw(parts, 1) (the + /// portal flavor) per the decomp sequence at @0x0059f30b–0x0059f345. Only + /// fires when also fired (same gate; see its + /// doc comment) — a degraded-out slot reaches neither. Default no-op. + /// + void OnBuildingShellTurn(WalkBuilding building) { } + + /// + /// Fires when the two-pass portal machinery + /// () would draw a far-Z punch fan + /// (DrawPortalPolyInternal @0x0059bc90, pass 1) — carries the + /// owning building and the polygon in BUILDING-LOCAL space (as stored on + /// the emitted ) so a driver can resolve the + /// world transform itself. The FW1 oracle traces never log punches + /// ('s internal PortalPassSink.OnPunch + /// stayed a no-op through FW1/FW2 for exactly that reason). Default + /// no-op. + /// + void OnPunchGeometry(WalkBuilding building, WalkPolygon polygon) { } } diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs new file mode 100644 index 00000000..a6bf5d06 --- /dev/null +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -0,0 +1,489 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Rendering.Walk; + +/// +/// Campaign FW stage FW3.2b-1: one cell's or one building's already-queried +/// static content, ready for — +/// caller-built, never read from a retained scene (see +/// 's doc comment). +/// +/// Already-classified s +/// for this turn's cell/building, in the SAME order they must enter the walk +/// stream (never re-sorted downstream — 's +/// own contract). +/// The clip-slot-resolving landblock id +/// WbDrawDispatcher.ClassifyEntityForWalk needs per record (FW3.2a's +/// tupleLandblockId parameter) — carried per-turn rather than once per +/// frame because a single frame's cells/buildings can span more than one +/// committed landblock. +internal readonly record struct WalkFrameStaticRecords( + RenderProjectionRecord[] Records, uint TupleLandblockId) +{ + public static readonly WalkFrameStaticRecords Empty = new(Array.Empty(), 0); +} + +/// +/// Campaign FW stage FW3.2b-1: the world-data lookups +/// needs at each walk turn, entirely caller-built — the driver reads no +/// retained scene state of its own (mirrors 's +/// own "reads no retained scene state itself" contract one layer up). FW3.2b-2 +/// wires the real production implementation (RenderSceneQuery.CopyCellStaticsTo +/// / CopyIndexTo + ); this stage's +/// headless referee tests wire a synthetic fake instead. +/// +internal interface IWalkFrameWorldData +{ + /// An indoor PView::DrawCells flood cell's (or a building + /// look-in's) static content — RenderProjectionClass.IndoorCellStatic. + WalkFrameStaticRecords GetCellStatics(uint cellId); + + /// One visited landscape (outdoor) cell's static content — + /// RenderProjectionClass.OutdoorStatic, keyed by the SAME + /// (landblockId & 0xFFFF0000) | (cellIndex+1) id + /// computes. + WalkFrameStaticRecords GetOutdoorStatics(uint cellId); + + /// One building's own exterior shell content (IsBuildingShell + /// records anchored at the building's position cell). + WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building); + + /// Building-local → world, for transforming a punch polygon + /// before — the + /// production implementation is 's + /// WorldTransform (FW3.2b-2 wiring). + Matrix4x4 GetBuildingWorldTransform(WalkBuilding building); +} + +/// +/// 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 +/// 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 +/// without standing up the real renderers EnvCellRenderer, +/// TerrainModernRenderer, GameSky, and +/// PortalDepthMaskRenderer.DrawDepthFan — FW3.2b-2's job. +/// +/// Stream submission itself (WbDrawDispatcher.SubmitOrderedStream) +/// is deliberately NOT part of this interface: it is already a real, +/// FW2/FW3.2a-tested production method, so +/// calls it directly (plan §FW3.2b-1's "the driver calls +/// WbDrawDispatcher.SubmitOrderedStream" wording) rather than abstracting a +/// method that would just forward to it one layer deeper. +/// +internal interface IWalkFrameLeafRenderer +{ + /// LScape::draw draws GameSky once per outdoor + /// walk (retail draws it once inside LScape::draw; the CURRENT + /// executor's per-slice call is per-slice-equals-once only for the + /// single-view outdoor case it handles today — the walk driver always + /// calls this exactly once per frame's Landscape turn). + void DrawSky(); + + /// LScape::grab_visible_cells's terrain mesh, once per + /// ACTIVE clip slice — is caller-supplied + /// ('s activeTerrainSliceCount) + /// since FW3.2b-1 does not wire ClipFrameAssembler/ + /// ViewconeCuller (FW3.2b-2's job — see plan §FW3.2's dynamic-route + /// survival note). Terrain draws FULLY before any per-cell building/ + /// outdoor-static turn in this stage's turn order — an intra-stage + /// simplification of retail's true per-cell DrawLandCell/ + /// DrawObjCell interleave, recorded here rather than ported, since + /// terrain itself carries no walk event today. + void DrawTerrainSlice(int sliceIndex); + + /// One committed cell's EnvCell shell — + /// PView::DrawCells's DrawEnvCell @0x005a4abe, which + /// precedes DrawObjCellForDummies @0x005a4b0d (the cell's static + /// contents, appended to the stream instead — see + /// 's type doc comment) for every cell of + /// EVERY flood this stage drives (the ordinary interior root's own + /// DrawCells AND a building's look-in DrawCells both walk + /// this same shell-then-contents order). + void DrawCellShell(uint cellId); + + /// DrawPortalPolyInternal @0x0059bc90's depth-only far-Z + /// punch fan — pass 1 of the building portal walk. + /// is already transformed building-local + /// → world ( does the transform via + /// before + /// calling this). The real implementation is + /// PortalDepthMaskRenderer.DrawDepthFan with forceFarZ + /// (FW3.2b-2 wiring) — this stage only proves the CALL happens at the + /// right point in walk order: the driver flushes the accumulated stream + /// segment immediately before this call, so ANY content queued ahead of + /// the punch (a preceding cell's/building's contents — never this + /// building's OWN shell, which retail draws only after the whole portal + /// walk completes; see 's type doc comment) + /// reaches the GPU first. + void DrawPunchFan(WalkPolygon worldPolygon); + + /// RetailAlphaQueue.FlushFartherThan's DrawBuilding + /// barrier — retail's own call site is + /// D3DPolyRender::FlushAlphaList(0f) @0x0059f30b, a FLUSH-ALL, not + /// a distance-gated flush; this stage keeps the DISPATCHED + /// FlushFartherThan(viewerDistanceTo(building)) shape (the two + /// coincide under the walk's far-to-near landscape order, since a nearer + /// emitter has not been inserted into the alpha queue yet — see + /// RetailAlphaQueue.FlushFartherThan's own doc comment) and flags + /// the 0f/address detail as an FW4 adjudication candidate rather than + /// silently reinterpreting the dispatched design. + void AlphaBarrier(float viewerDistance); +} + +/// +/// Campaign FW stage FW3.2b-1 test seam: an optional, diagnostic-only +/// observer of every stream FLUSH performs. +/// Production callers pass (the default) — this +/// exists purely so the headless referee suite can assert flush COUNT, +/// per-flush command count, and per-flush stage without re-deriving them +/// from RecordingGpuDevice.Calls' lower-level RHI call log. +/// +internal interface IWalkFrameDriverTrace +{ + /// is a snapshot (never the live, + /// about-to-be-Reset list) of every command's + /// in the flushed segment, in stream order — + /// by this stage's own flush discipline (flush before every non-stream + /// leaf action) a segment is always single-stage in practice, but the + /// full list is passed so a test can assert that invariant itself + /// instead of trusting it. + void OnFlush(int commandCount, IReadOnlyList stages); +} + +/// +/// Campaign FW stage FW3.2b-1 — THE WALK FRAME DRIVER. Executes one full +/// static-content frame by driving with itself +/// as the , turning each walk turn into either a +/// stream append (, FW3.2a) or a leaf +/// call (), so that GPU command-buffer +/// order equals retail's walk order (plan §FW3.2b-1's "INTERLEAVING RULE"). +/// NOT rooted into any production caller yet — WorldSceneRenderer +/// does not construct or call this class (FW3.2b-2's job); this stage's +/// deliverable is the driver plus a headless referee suite on +/// RecordingGpuDevice proving the interleaving. +/// +/// 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 +/// 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 +/// 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. +/// +/// 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 +/// @0x0059f2a0 (the part->gfxobj[deg_level]!=0 gate @0x0059f2d3 +/// and the alpha-barrier → portal-pass → shell order @0x0059f30b–0x0059f345). +/// +internal sealed class WalkFrameDriver : IWalkEventSink +{ + private readonly WbDrawDispatcher _dispatcher; + private readonly WalkStaticStreamPopulator _populator; + private readonly IWalkFrameLeafRenderer _leafRenderer; + private readonly IWalkFrameWorldData _worldData; + private readonly IWalkFrameDriverTrace? _trace; + private readonly OrderedDrawStream _stream = new(); + + // ---- transient per-RunFrame state (set in BeginFrame, cleared in EndFrame) ---- + private IWalkBuildingFrameContext? _ctx; + private IGpuFrame? _frame; + private IGpuPassEncoder? _encoder; + private Matrix4x4 _viewProjection; + private Vector3 _cameraWorldPosition; + private int _activeTerrainSliceCount; + private bool _skyDrawnThisFrame; + private WalkDrawStage? _currentDcStage; + + internal WalkFrameDriver( + WbDrawDispatcher dispatcher, + IWalkFrameLeafRenderer leafRenderer, + IWalkFrameWorldData worldData, + IWalkFrameDriverTrace? trace = null) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _leafRenderer = leafRenderer ?? throw new ArgumentNullException(nameof(leafRenderer)); + _worldData = worldData ?? throw new ArgumentNullException(nameof(worldData)); + _trace = trace; + _populator = new WalkStaticStreamPopulator(dispatcher); + } + + /// + /// Drives one complete frame at retail's root (SmartBox::RenderNormalMode): + /// calls with this driver as the + /// sink, sandwiched between /. + /// + internal void RunFrame( + RetailFrameWalk walk, + uint cameraCellId, + WalkCell? cameraCell, + WalkLandscape landscape, + IRetailFrameWalkContext ctx, + IGpuFrame frame, + IGpuPassEncoder encoder, + Matrix4x4 viewProjection, + Vector3 cameraWorldPosition, + int activeTerrainSliceCount = 1) + { + ArgumentNullException.ThrowIfNull(walk); + ArgumentNullException.ThrowIfNull(landscape); + ArgumentNullException.ThrowIfNull(ctx); + + BeginFrame(ctx, frame, encoder, viewProjection, cameraWorldPosition, activeTerrainSliceCount); + walk.WalkFrame(cameraCellId, cameraCell, landscape, ctx, this); + EndFrame(); + } + + /// + /// Opens a driver frame without driving the walk itself — for a caller + /// (or a test) that already holds an isolated walk entry point (e.g. one + /// or + /// call) and wants this + /// driver's turn handling without going through the top-level root. + /// is implemented in terms of this pair. + /// + internal void BeginFrame( + IWalkBuildingFrameContext ctx, + IGpuFrame frame, + IGpuPassEncoder encoder, + Matrix4x4 viewProjection, + Vector3 cameraWorldPosition, + int activeTerrainSliceCount) + { + ArgumentNullException.ThrowIfNull(ctx); + ArgumentNullException.ThrowIfNull(frame); + ArgumentNullException.ThrowIfNull(encoder); + if (activeTerrainSliceCount < 0) + { + throw new ArgumentOutOfRangeException( + nameof(activeTerrainSliceCount), activeTerrainSliceCount, + "The active terrain slice count cannot be negative."); + } + if (_ctx is not null) + { + throw new InvalidOperationException( + "WalkFrameDriver.BeginFrame was called while a previous frame was still open — " + + "the driver is not re-entrant (Campaign FW3.2b-1 fail-loud rule); call " + + "EndFrame (or let a thrown exception's cleanup run) before starting the next."); + } + + _ctx = ctx; + _frame = frame; + _encoder = encoder; + _viewProjection = viewProjection; + _cameraWorldPosition = cameraWorldPosition; + _activeTerrainSliceCount = activeTerrainSliceCount; + _skyDrawnThisFrame = false; + _currentDcStage = null; + _stream.Reset(); + } + + /// Final segment flush (plan §FW3.2b-1's "at frame end: final + /// segment flush"), then clears transient per-frame state. Always runs + /// via the caller's try/finally discipline in + /// — a caller driving the walk manually should + /// follow the same shape. + internal void EndFrame() + { + try + { + FlushIfNonEmpty(); + } + finally + { + _ctx = null; + _frame = null; + _encoder = null; + _stream.Reset(); + } + } + + // ------------------------------------------------------------------ + // IWalkEventSink + // ------------------------------------------------------------------ + + void IWalkEventSink.Emit(in WalkEvent walkEvent) + { + switch (walkEvent.Kind) + { + case WalkEventKind.DrawInside: + _currentDcStage = WalkDrawStage.CellStatic; + break; + case WalkEventKind.Landscape: + HandleLandscapeTurn(); + break; + case WalkEventKind.DrawCells: + HandleDrawCellsTurn(walkEvent.Cells); + break; + case WalkEventKind.Building: + // OnBuildingTurn (below) carries the actual side effects — + // this vocabulary-only event needs no driver action. + break; + } + } + + void IWalkEventSink.OnLandscapeCellTurn(uint cellId) + { + RequireOpenFrame(); + WalkFrameStaticRecords records = _worldData.GetOutdoorStatics(cellId); + _populator.PopulateOutdoorStatics( + _stream, cellId, records.Records, records.TupleLandblockId, + _cameraWorldPosition, _viewProjection); + } + + void IWalkEventSink.OnBuildingTurn(WalkBuilding building) + { + ArgumentNullException.ThrowIfNull(building); + IWalkBuildingFrameContext ctx = RequireOpenFrame(); + + // D3DPolyRender::FlushAlphaList(0f) @0x0059f30b — retail's alpha + // barrier, first inside the gate. The portal pass (punches + + // look-ins) follows this call; the building's own shell content is + // appended only once that pass completes (OnBuildingShellTurn). + FlushIfNonEmpty(); + _leafRenderer.AlphaBarrier(ctx.ViewerDistanceTo(building)); + + _currentDcStage = WalkDrawStage.LookInStatic; + } + + void IWalkEventSink.OnBuildingShellTurn(WalkBuilding building) + { + ArgumentNullException.ThrowIfNull(building); + RequireOpenFrame(); + + // CPhysicsPart::Draw(parts, 0) @0x0059f331 — retail's plain-mesh + // shell draw, strictly after the portal pass (CPhysicsPart::Draw + // (parts, 1)). Flush first so this building's shell content never + // shares a segment with whatever the portal pass's last look-in + // flood appended (keeps every flushed segment single-stage). + FlushIfNonEmpty(); + WalkFrameStaticRecords shell = _worldData.GetBuildingShellStatics(building); + _populator.PopulateCell( + _stream, WalkDrawStage.BuildingShell, building.PositionCellId, + shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection); + } + + void IWalkEventSink.OnPunchGeometry(WalkBuilding building, WalkPolygon polygon) + { + ArgumentNullException.ThrowIfNull(building); + ArgumentNullException.ThrowIfNull(polygon); + RequireOpenFrame(); + + FlushIfNonEmpty(); + Matrix4x4 worldTransform = _worldData.GetBuildingWorldTransform(building); + _leafRenderer.DrawPunchFan(TransformToWorld(polygon, worldTransform)); + } + + // ------------------------------------------------------------------ + // Turn handlers + // ------------------------------------------------------------------ + + private void HandleLandscapeTurn() + { + RequireOpenFrame(); + if (_skyDrawnThisFrame) + { + throw new InvalidOperationException( + "A second Landscape turn fired in one frame — RetailFrameWalk.WalkFrame/" + + "DrawInside's own call graph guarantees at most one Landscape turn per " + + "frame (outdoor root draws it once; an interior root draws it at most " + + "once more, through surviving exit views). A second occurrence is a walk/" + + "driver desync, not something to silently double-draw sky for (Campaign " + + "FW3.2b-1 fail-loud rule)."); + } + + FlushIfNonEmpty(); + _leafRenderer.DrawSky(); + _skyDrawnThisFrame = true; + for (int slice = 0; slice < _activeTerrainSliceCount; slice++) + _leafRenderer.DrawTerrainSlice(slice); + } + + private void HandleDrawCellsTurn(IReadOnlyList cells) + { + RequireOpenFrame(); + if (_currentDcStage is not { } stage) + { + throw new InvalidOperationException( + "A DrawCells turn fired before any DrawInside or Building turn established " + + "which stage its cells belong to — a walk/driver desync (Campaign FW3.2b-1 " + + "fail-loud rule): RetailFrameWalk only ever emits DrawCells after DrawInside " + + "(the interior root's own flood) or after a building's look-in portal pass."); + } + + for (int i = 0; i < cells.Count; i++) + EmitCellTurn(stage, cells[i]); + } + + private void EmitCellTurn(WalkDrawStage stage, uint cellId) + { + FlushIfNonEmpty(); + _leafRenderer.DrawCellShell(cellId); + WalkFrameStaticRecords records = _worldData.GetCellStatics(cellId); + _populator.PopulateCell( + _stream, stage, cellId, records.Records, records.TupleLandblockId, + _cameraWorldPosition, _viewProjection); + } + + private void FlushIfNonEmpty() + { + if (_stream.Count == 0) + return; + + if (_trace is not null) + _trace.OnFlush(_stream.Count, _stream.Stages.ToArray()); + + _dispatcher.SubmitOrderedStream(_frame!, _encoder!, _stream, _viewProjection); + _stream.Reset(); + } + + private IWalkBuildingFrameContext RequireOpenFrame() => + _ctx ?? throw new InvalidOperationException( + "WalkFrameDriver received a walk turn outside BeginFrame/EndFrame — call " + + "BeginFrame (or RunFrame) before driving the walk with this driver as its " + + "IWalkEventSink."); + + /// ConstructBuildingView's polygon is building-local; the + /// punch fan needs world space. Vertices transform directly; the plane + /// normal uses (correct for the + /// rigid, shear-free placements and + /// build) and D is rederived from + /// the transformed normal and the first transformed vertex — the SAME + /// construction / + /// already use for their own polygons (Plane = new WalkPlane(normal, + /// -Vector3.Dot(normal, vertices[0]))). + private static WalkPolygon TransformToWorld(WalkPolygon local, Matrix4x4 worldTransform) + { + var vertices = new Vector3[local.Vertices.Length]; + for (int i = 0; i < vertices.Length; i++) + vertices[i] = Vector3.Transform(local.Vertices[i], worldTransform); + + Vector3 normal = local.Vertices.Length > 0 + ? Vector3.Normalize(Vector3.TransformNormal(local.Plane.Normal, worldTransform)) + : Vector3.Zero; + float d = vertices.Length > 0 ? -Vector3.Dot(normal, vertices[0]) : 0f; + + return new WalkPolygon { Vertices = vertices, Plane = new WalkPlane(normal, d) }; + } +} diff --git a/src/AcDream.App/Rendering/Walk/WalkLandscape.cs b/src/AcDream.App/Rendering/Walk/WalkLandscape.cs index 01818a63..bad0855c 100644 --- a/src/AcDream.App/Rendering/Walk/WalkLandscape.cs +++ b/src/AcDream.App/Rendering/Walk/WalkLandscape.cs @@ -4,6 +4,21 @@ namespace AcDream.App.Rendering.Walk; /// static shape plus the per-frame visibility the landscape pass writes. public sealed class WalkLandBlock { + /// Additive (Campaign FW3.2b-1): this block's landblock id in + /// retail's (bx<<24 | by<<16) encoding — + /// already derives + /// (bx, by) from this SAME encoding (its BlockCoords + /// helper), and / + /// 's low-word convention + /// (low >= 1 && low <= 0x40) both key off it too. + /// This field lets a per-visited-cell walk-turn hook + /// () recover a real + /// outdoor cell id — (LandblockId & 0xFFFF0000) | (cellIndex+1) + /// — without a second landblock lookup. Defaults to 0: purely additive, + /// unread by every pre-FW3.2b-1 caller (the FW1 conformance harness's + /// synthetic blocks never populate it). + public uint LandblockId; + public int SideCellCount = 8; public float MaxZ; public float MinZ; diff --git a/src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs b/src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs index 2eda6ecb..e1871383 100644 --- a/src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs +++ b/src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs @@ -168,6 +168,9 @@ public sealed class WalkLandscapeAssembler int sideCellCount = SideCellCountForRing(RingOf(gx, gy)); var block = new WalkLandBlock { + // Additive (Campaign FW3.2b-1): same (bx<<24 | by<<16) encoding + // BlockCoords decodes below — see WalkLandBlock.LandblockId. + LandblockId = (uint)bx << 24 | (uint)by << 16, SideCellCount = sideCellCount, MaxZ = data.MaxZ, MinZ = data.MinZ, diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs new file mode 100644 index 00000000..fc59b311 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -0,0 +1,686 @@ +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Vk; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Wb; +using AcDream.App.Rendering.Walk; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.Content; +using AcDream.Core.Meshing; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Lib.IO; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AcDream.App.Tests.Rendering.Walk; + +/// +/// Campaign FW stage FW3.2b-1: 's headless +/// referee suite — proves that driving with the +/// driver as its produces retail's own turn +/// order (shell-then-contents per cell, flush-before-every-leaf-action, +/// content-before-punch) through the REAL +/// onto a — never a mock of the submission +/// path itself. No production wiring is exercised (WorldSceneRenderer +/// still does not construct this driver); every world-data/leaf-renderer +/// dependency here is a synthetic fake per plan §FW3.2b-1. +/// +public sealed class WalkFrameDriverTests +{ + // ── Shared ordered log: BOTH the fake leaf renderer and the fake trace + // write into ONE list, so a single sequence assertion proves the FULL + // interleave (stream flushes interleaved with sky/terrain/shell/punch/ + // alpha-barrier), not just each half in isolation. ───────────────────── + + private sealed class RecordingLeafRenderer(List log) : IWalkFrameLeafRenderer + { + public readonly List Punches = new(); + + public void DrawSky() => log.Add("SKY"); + + public void DrawTerrainSlice(int sliceIndex) => log.Add($"TERRAIN:{sliceIndex}"); + + public void DrawCellShell(uint cellId) => log.Add($"SHELL:{cellId:x8}"); + + public void DrawPunchFan(WalkPolygon worldPolygon) + { + Punches.Add(worldPolygon); + log.Add($"PUNCH:{worldPolygon.Vertices.Length}"); + } + + public void AlphaBarrier(float viewerDistance) => log.Add($"ALPHA:{viewerDistance:F2}"); + } + + private sealed class RecordingTrace(List log) : IWalkFrameDriverTrace + { + public void OnFlush(int commandCount, IReadOnlyList stages) => + log.Add($"FLUSH:{commandCount}:{string.Join(',', stages.Distinct())}"); + } + + private sealed class FakeWorldData : IWalkFrameWorldData + { + public readonly Dictionary CellStaticsByCell = new(); + public readonly Dictionary OutdoorStaticsByCell = new(); + public readonly Dictionary ShellByBuilding = new(); + public readonly Dictionary WorldTransformByBuilding = new(); + + public WalkFrameStaticRecords GetCellStatics(uint cellId) => + CellStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty); + + public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) => + OutdoorStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty); + + public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) => + ShellByBuilding.GetValueOrDefault(building, WalkFrameStaticRecords.Empty); + + public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building) => + WorldTransformByBuilding.GetValueOrDefault(building, Matrix4x4.Identity); + } + + // ── The walk-level test context (interior flood + building portal pass) ─ + + private sealed class Caster : IWalkRayCaster + { + public Vector3 RayThrough(float screenX, float screenY) => new(screenX, screenY, 100f); + } + + private sealed class TestContext : IWalkFrameContext, IRetailFrameWalkContext + { + public readonly Dictionary Cells = new(); + public readonly Dictionary ViewerDistances = new(); + private readonly Matrix4x4 _viewProj; + private static readonly Vector2[] RootQuad = + [ + new(0, 480), new(640, 480), new(640, 0), new(0, 0), + ]; + + public TestContext() + { + Matrix4x4 view = Matrix4x4.CreateLookAt( + Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); + Matrix4x4 proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f); + _viewProj = view * proj; + } + + public Vector3 ViewpointIn(WalkCell cell) => Vector3.Zero; + public Matrix4x4 ObjectToClip(WalkCell cell) => _viewProj; + public WalkCell? GetVisible(uint cellId) => Cells.GetValueOrDefault(cellId); + public IWalkRayCaster Rays { get; } = new Caster(); + public Vector3 WorldViewpoint => Vector3.Zero; + public float ViewportWidth => 640f; + public float ViewportHeight => 480f; + + public Vector3 ViewpointInBuilding(WalkBuilding building) => Vector3.Zero; + + public float ViewerDistanceTo(WalkBuilding building) => + ViewerDistances.GetValueOrDefault(building, 0f); + + public IWalkFrameContext CellContext => this; + public WalkPlane CyPlane => new(new Vector3(0, 0, 1), 0f); + public void SetActiveView(WalkPortalView views, int index) { } + + public int ClipBuildingPolygon( + WalkBuilding building, WalkPolygon polygon, int side, Span output) + { + Span projected = stackalloc WalkScreenPoint[polygon.Vertices.Length]; + for (int i = 0; i < polygon.Vertices.Length; i++) + projected[i] = WalkScreenClip.TransformToScreen( + polygon.Vertices[i], _viewProj, ViewportWidth, ViewportHeight); + if (side != 0) + projected.Reverse(); + return WalkScreenClip.ClipAgainstView(projected, RootQuad, output); + } + } + + private static WalkPolygon Quad(float z, bool facingViewer = true) => new() + { + Vertices = + [ + new Vector3(-0.5f, -0.5f, z), new Vector3(0.5f, -0.5f, z), + new Vector3(0.5f, 0.5f, z), new Vector3(-0.5f, 0.5f, z), + ], + Plane = new WalkPlane(new Vector3(0, 0, facingViewer ? 1f : -1f), facingViewer ? -z : z), + }; + + // ── Deliverable: RunFrame drives an interior two-cell flood; shell + // precedes contents per cell, and a flush happens exactly at the point + // the NEXT cell's shell needs the stream clear (never before, never + // batched across cells within this stage's turn-by-turn discipline). ── + + [Fact] + public void RunFrame_InteriorTwoCellFlood_EmitsShellThenContentsPerCellWithAFlushBetween() + { + using var fx = new DispatcherFixture(); + var log = new List(); + const ulong gfxObjA = 0x0200_0001UL; + const ulong gfxObjB = 0x0200_0002UL; + InjectRenderData(fx.Manager, gfxObjA, MakeFlatMesh( + MakeBatch(0x08100001u, TranslucencyKind.Opaque, 0, 0, 3, 1))); + 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, + }], + PortalPolygons = [Quad(-2f)], + }; + 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(); + + using DrawScope draw = fx.BeginDraw(); + driver.RunFrame( + walk, cameraCellId: cell1.CellId, cameraCell: cell1, landscape: new WalkLandscape(), + ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero, + activeTerrainSliceCount: 0); + + Assert.Equal( + new[] { "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: a building turn's alpha barrier precedes its portal + // pass (retail RenderDeviceD3D::DrawBuilding @0x0059f2a0: + // FlushAlphaList(0f) -> CPhysicsPart::Draw(parts,1) [the portal walk] + // -> CPhysicsPart::Draw(parts,0) [the shell] @0x0059f30b-0x0059f345); + // the punch pass runs with nothing of THIS building's own queued yet + // (the shell is not appended until the whole portal pass completes); + // the look-in DC turn draws shell-then-contents exactly like an ordinary + // interior flood; the building's own shell content is appended and + // flushed only AFTER the portal pass, at frame end; and the punch + // polygon reaches the leaf renderer transformed building-local -> + // world. ───────────────────────────────────────────────────────────── + + [Fact] + public void BeginEndFrame_BuildingTurnWithPunchAndLookIn_OrdersAlphaBarrierPortalPassThenShell() + { + using var fx = new DispatcherFixture(); + var log = new List(); + const ulong shellGfxObj = 0x0200_0010UL; + const ulong interiorGfxObj = 0x0200_0011UL; + InjectRenderData(fx.Manager, shellGfxObj, MakeFlatMesh( + MakeBatch(0x08100010u, TranslucencyKind.Opaque, 0, 0, 3, 1))); + InjectRenderData(fx.Manager, interiorGfxObj, MakeFlatMesh( + MakeBatch(0x08100011u, TranslucencyKind.Opaque, 3, 4, 3, 2))); + + var ctx = new TestContext(); + var interior = new WalkCell + { + CellId = 0x104, + Portals = [new WalkCellPortal + { + OtherCellId = 0xFFFFFFFF, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0, + }], + PortalPolygons = [Quad(-2f)], + }; + ctx.Cells[interior.CellId] = interior; + + var building = new WalkBuilding + { + PositionCellId = 0xA9B4000Fu, + Portals = + [ + new WalkBldPortal + { + PortalSide = 0, OtherCellId = 0x104, OtherPortalId = 0, + StabList = [0x104u], + }, + ], + // Viewpoint (0,0,0) is on the NEGATIVE side of this splitting + // plane (d=-5): the single PORT node's side==1 arm emits its + // portal exactly once per pass (WalkBuildingPortals.Walk). + DrawingBsp = new WalkBspNode + { + SplittingPlane = new WalkPlane(new Vector3(1, 0, 0), -5f), + InPortals = [new WalkPortalRef { PortalIndex = 0, Polygon = Quad(-2f) }], + }, + }; + ctx.ViewerDistances[building] = 12.5f; + + var worldData = new FakeWorldData(); + worldData.ShellByBuilding[building] = new WalkFrameStaticRecords( + [MakeRecord(201, 0, Vector3.Zero, [new MeshRef((uint)shellGfxObj, Matrix4x4.Identity)])], 0x8C04u); + worldData.CellStaticsByCell[0x104] = new WalkFrameStaticRecords( + [MakeRecord(202, 0, Vector3.Zero, [new MeshRef((uint)interiorGfxObj, Matrix4x4.Identity)])], 0x8C04u); + Matrix4x4 buildingWorld = Matrix4x4.CreateTranslation(10f, 0f, 0f); + worldData.WorldTransformByBuilding[building] = buildingWorld; + + var leaf = new RecordingLeafRenderer(log); + var trace = new RecordingTrace(log); + var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, trace); + var walk = new RetailFrameWalk(); + + var activeView = new WalkPortalView(); + activeView.ResetForPush(); + WalkCopyView.AppendFullViewportQuad( + activeView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + Assert.Equal(1, activeView.ViewCount); + + using DrawScope draw = fx.BeginDraw(); + driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0); + walk.DrawBuilding(building, activeView, ctx, driver); + driver.EndFrame(); + + Assert.Equal( + new[] { "ALPHA:12.50", "PUNCH:4", "SHELL:00000104", "FLUSH:1:LookInStatic", "FLUSH:1:BuildingShell" }, + log); + + // The punch polygon reached the leaf renderer in WORLD space: the + // building-local Quad(-2f) vertex (-0.5,-0.5,-2) translates by + // (10,0,0) under the caller-supplied building world transform. + WalkPolygon punch = Assert.Single(leaf.Punches); + Assert.Equal(new Vector3(9.5f, -0.5f, -2f), punch.Vertices[0]); + + List mdiCalls = + [.. fx.Device.Calls.OfType()]; + Assert.Equal(2, mdiCalls.Count); + Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount)); + } + + // ── Fail-loud: a DrawCells turn with no preceding DrawInside/Building + // turn is a walk/driver desync, not a silent skip. ───────────────────── + + [Fact] + public void Emit_DrawCellsBeforeAnyDrawInsideOrBuildingTurn_ThrowsRatherThanSilentlyDropping() + { + using var fx = new DispatcherFixture(); + var log = new List(); + var ctx = new TestContext(); + var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData()); + + using DrawScope draw = fx.BeginDraw(); + driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0); + + Assert.Throws( + () => ((IWalkEventSink)driver).Emit(WalkEvent.DrawCells(0, [0x100u]))); + } + + // ── Fail-loud: BeginFrame is not re-entrant. ──────────────────────────── + + [Fact] + public void BeginFrame_CalledWhileAFrameIsAlreadyOpen_Throws() + { + using var fx = new DispatcherFixture(); + var log = new List(); + var ctx = new TestContext(); + var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData()); + + using DrawScope draw = fx.BeginDraw(); + driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0); + + Assert.Throws( + () => driver.BeginFrame( + ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0)); + + driver.EndFrame(); + // EndFrame cleared the open-frame guard: BeginFrame is usable again. + driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0); + driver.EndFrame(); + } + + // ── Deliverable: an outdoor landscape-cell turn with no building appends + // straight to the stream (no shell call — outdoor cells have no EnvCell + // shell), and the accumulated content flushes at frame end. ─────────── + + [Fact] + public void OnLandscapeCellTurn_AppendsOutdoorStaticsWithNoShellCallAndFlushesAtFrameEnd() + { + using var fx = new DispatcherFixture(); + var log = new List(); + const ulong gfxObj = 0x0200_0020UL; + InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh( + MakeBatch(0x08100020u, TranslucencyKind.Opaque, 0, 0, 3, 1))); + + var ctx = new TestContext(); + var worldData = new FakeWorldData(); + worldData.OutdoorStaticsByCell[0x8C040005u] = new WalkFrameStaticRecords( + [MakeRecord(301, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)])], 0x8C04u); + + var driver = new WalkFrameDriver( + fx.Dispatcher, new RecordingLeafRenderer(log), worldData, new RecordingTrace(log)); + + using DrawScope draw = fx.BeginDraw(); + driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0); + ((IWalkEventSink)driver).OnLandscapeCellTurn(0x8C040005u); + Assert.Empty(log); // accumulates in the stream; nothing flushed yet + driver.EndFrame(); + + Assert.Equal(new[] { "FLUSH:1:OutdoorStatic" }, log); + GpuRecordedMultiDrawIndirect mdi = Assert.Single(fx.Device.Calls.OfType()); + Assert.Equal(1u, mdi.DrawCount); + } + + // ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture — + // FW3.2a's own referee) ───────────────────────────────────────────────── + + private static RenderProjectionRecord MakeRecord( + uint localEntityId, + uint serverGuid, + Vector3 position, + IReadOnlyList meshRefs, + bool isBuildingShell = false, + uint parentCellId = 0u) => + new( + Id: RenderProjectionId.FromRaw(localEntityId), + ProjectionClass: RenderProjectionClass.OutdoorStatic, + OwnerIncarnation: RenderOwnerIncarnation.FromRaw(1), + Transform: new RenderTransform(Matrix4x4.CreateTranslation(position)), + PreviousTransform: default, + MeshSet: default, + Material: default, + Residency: default, + Bounds: default, + Flags: RenderProjectionFlags.Draw, + DegradeState: default, + SortKey: new RenderSortKey(0), + DirtyMask: default, + Source: new RenderSourceMetadata( + LocalEntityId: localEntityId, + ServerGuid: serverGuid, + SourceId: 0, + ParentCellId: parentCellId, + EffectCellId: 0, + BuildingShellAnchorCellId: 0, + TransformFingerprint: default, + GeometryFingerprint: default, + AppearanceFingerprint: default), + EntityPayload: new RenderEntityPayload( + MeshRefs: meshRefs, + PaletteOverride: null, + IsBuildingShell: isBuildingShell)); + + private static ObjectRenderBatch MakeBatch( + uint surfaceId, + TranslucencyKind translucency, + uint firstIndex, + int baseVertex, + int indexCount, + uint textureSlotIndex, + uint textureLayer = 0, + CullMode cullMode = CullMode.CounterClockwise) => + new() + { + Key = new TextureKey { SurfaceId = surfaceId, IsSolid = false }, + Translucency = translucency, + FirstIndex = firstIndex, + BaseVertex = (uint)baseVertex, + IndexCount = indexCount, + TextureSlot = new GpuTextureSlot(textureSlotIndex), + TextureIndex = (int)textureLayer, + }; + + private static ObjectRenderData MakeFlatMesh(params ObjectRenderBatch[] batches) => + new() { Batches = new List(batches) }; + + private static void InjectRenderData(ObjectMeshManager manager, ulong id, ObjectRenderData data) + { + FieldInfo field = typeof(ObjectMeshManager).GetField( + "_renderData", BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException( + "ObjectMeshManager._renderData field not found — test relies on this exact name."); + var dict = (ConcurrentDictionary)field.GetValue(manager)!; + dict[id] = data; + } + + private readonly struct DrawScope : IDisposable + { + private readonly IDisposable _publication; + private readonly IGpuPassEncoder _pass; + + public DrawScope(IGpuFrame frame, IGpuPassEncoder pass, IDisposable publication) + { + Frame = frame; + _pass = pass; + _publication = publication; + } + + public IGpuFrame Frame { get; } + + public IGpuPassEncoder Pass => _pass; + + public void Dispose() + { + _publication.Dispose(); + _pass.Dispose(); + } + } + + private sealed class DispatcherFixture : IDisposable + { + private readonly WbMeshAdapter _meshAdapter; + private readonly TextureCache _textures; + + public DispatcherFixture() + { + Device = new RecordingGpuDevice(); + FrameLifetime = new GpuDeviceFrameLifetime(Device); + Scope = new VulkanWorldPassScope(sampleCount: 1); + _textures = new TextureCache(Device, new NoopDatReaderWriter()); + _meshAdapter = new WbMeshAdapter( + Device, + new NoopDatReaderWriter(), + new NullPreparedAssetSource(), + NullLogger.Instance, + Device.Retirement); + var entitySpawnAdapter = new EntitySpawnAdapter( + _textures, + _ => throw new NotSupportedException("Not exercised by these tests.")); + + Dispatcher = new WbDrawDispatcher( + Device, + FrameLifetime, + Scope, + _textures, + _meshAdapter, + entitySpawnAdapter, + new EntityClassificationCache(), + new AcDream.Core.Rendering.TranslucencyFadeManager()); + } + + public RecordingGpuDevice Device { get; } + + public GpuDeviceFrameLifetime FrameLifetime { get; } + + public VulkanWorldPassScope Scope { get; } + + public WbDrawDispatcher Dispatcher { get; } + + public ObjectMeshManager Manager => _meshAdapter.MeshManager!; + + public DrawScope BeginDraw() + { + FrameLifetime.BeginFrame(); + IGpuFrame frame = FrameLifetime.CurrentFrame!; + IGpuPassEncoder pass = frame.BeginPass( + GpuPassDescription.BackbufferClear( + "fw3-2b-1-walk-frame-driver-test", Vector4.Zero, sampleCount: 1)); + IDisposable publication = Scope.Publish(pass); + Device.Clear(); + return new DrawScope(frame, pass, publication); + } + + public void Dispose() + { + Dispatcher.Dispose(); + _meshAdapter.Dispose(); + _textures.Dispose(); + Device.Dispose(); + } + } + + private sealed class NullPreparedAssetSource : IPreparedAssetSource + { + public PreparedAssetSourceStats Stats => default; + + public CacheStats DecodedTextureCacheStats => default; + + public PreparedAssetPresence Probe( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + PreparedAssetPresence.Missing; + + public PreparedAssetReadResult Read( + in PreparedAssetRequest request, + CancellationToken cancellationToken = default) => + PreparedAssetReadResult.Missing; + + public void Dispose() + { + } + } + + private sealed class NoopDatReaderWriter : IDatReaderWriter + { + private readonly StubDatabase _portal = new(); + private readonly StubDatabase _highRes = new(); + private readonly StubDatabase _language = new(); + private readonly StubDatabase _cell = new(); + + public string SourceDirectory => string.Empty; + + public IDatDatabase Portal => _portal; + + public IDatDatabase Cell => _cell; + + public ReadOnlyDictionary CellRegions { get; } = + new(new Dictionary()); + + public IDatDatabase HighRes => _highRes; + + public IDatDatabase Language => _language; + + public IDatDatabase Local => _language; + + public ReadOnlyDictionary RegionFileMap { get; } = + new(new Dictionary()); + + public int PortalIteration => 0; + + public int CellIteration => 0; + + public int HighResIteration => 0; + + public int LanguageIteration => 0; + + public bool TryGetFileBytes( + uint regionId, + uint fileId, + ref byte[] bytes, + out int bytesRead) + { + bytesRead = 0; + return false; + } + + public IEnumerable GetAllIdsOfType() where T : IDBObj => + Array.Empty(); + + public IEnumerable ResolveId(uint id) => + Array.Empty(); + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + public bool TrySave( + uint regionId, + T obj, + int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + [return: MaybeNull] + public T Get(uint fileId) where T : IDBObj => default; + + public bool TryGet( + uint fileId, + [MaybeNullWhen(false)] out T value) where T : IDBObj + { + value = default; + return false; + } + + public void Dispose() + { + } + + private sealed class StubDatabase : IDatDatabase + { + public DatDatabase Db => throw new NotSupportedException(); + + public int Iteration => 0; + + public IEnumerable GetAllIdsOfType() where T : IDBObj => + Array.Empty(); + + public bool TryGet( + uint fileId, + [MaybeNullWhen(false)] out T value) where T : IDBObj + { + value = default; + return false; + } + + public bool TryGetFileBytes( + uint fileId, + [MaybeNullWhen(false)] out byte[] value) + { + value = null; + return false; + } + + public bool TryGetFileBytes( + uint fileId, + ref byte[] bytes, + out int bytesRead) + { + bytesRead = 0; + return false; + } + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + public void Dispose() + { + } + } + } +}