feat(render) Campaign FW3.2b-1: the walk frame driver
WalkFrameDriver executes one full static-content frame from the walk's turns so GPU command-buffer order equals retail's walk order. One rule does the interleaving: the accumulated OrderedDrawStream flushes through SubmitOrderedStream immediately before EVERY non-stream draw (sky, terrain slice, cell shell, punch fan, alpha barrier). Turn script, all decomp-cited and two of them corrected in review: - Interior flood: per cell IN FLOOD ORDER, shell first then contents (PView::DrawCells @0x005a4840: DrawEnvCell @0x005a4abe precedes DrawObjCellForDummies @0x005a4b0d). - Landscape: sky once, terrain per active slice, then blocks far-to-near; per cell the building turn precedes the cell's outdoor statics (DrawSortCell @0x0059f140). - Building (DrawBuilding @0x0059f2a0): the BLD probe event stays at entry, but the ENTIRE body - alpha barrier, portal passes, shell - sits inside retail's gfxobj[deg_level]!=0 gate @0x0059f2d3, and the order is FlushAlphaList @0x0059f30b -> the two-pass punch/look-in walk -> THEN the shell draw @0x0059f345. The driver review caught both the missing gate and a shell-before-punch inversion; fixed with the addresses cited. Walk seam: three additive default-implemented IWalkEventSink hooks (OnLandscapeCellTurn / OnBuildingTurn / OnBuildingShellTurn / OnPunchGeometry) - every existing sink and all FW1 conformance fixtures unchanged. WalkLandBlock gains LandblockId for the cell-id encoding. Leaf draws go through IWalkFrameLeafRenderer so FW3.2b-2 wires the real renderers and the referee suite runs on fakes + RecordingGpuDevice. Flagged for FW3.2b-2/FW4 adjudication (documented in code): FlushFartherThan(building distance) vs retail flush-all FlushAlphaList(0f); terrain-before-statics within the landscape turn. Suites: full Release build 0 warnings; Walk lane 200/1 skip; InstalledDat Walk conformance 40/1 untouched; hermetic 6,752/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
81c6531727
commit
03f63686cc
6 changed files with 1303 additions and 7 deletions
|
|
@ -142,17 +142,33 @@ public sealed class RetailFrameWalk
|
||||||
{
|
{
|
||||||
continue;
|
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)
|
if (block.CellBuildings[cellIndex] is WalkBuilding building)
|
||||||
DrawBuilding(building, activeViews, ctx, sink);
|
DrawBuilding(building, activeViews, ctx, sink);
|
||||||
|
sink.OnLandscapeCellTurn((block.LandblockId & 0xFFFF0000u) | (uint)(cellIndex + 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary><c>RenderDeviceD3D::DrawBuilding</c> @0x0059f2a0 at the walk
|
/// <summary><c>RenderDeviceD3D::DrawBuilding</c> @0x0059f2a0 at the walk
|
||||||
/// level: the BLD event fires at ENTRY (before the degrade check); the
|
/// level: the BLD event fires at ENTRY (before the degrade check — the
|
||||||
/// portal pass walks the drawing BSP twice per active view (punch, then
|
/// oracle traces' breakpoint sat there, so this stays unconditional for
|
||||||
/// look-in + DrawCells on the outdoor pview); the shell pass emits no
|
/// conformance). The ENTIRE rest of the body — the alpha barrier, the
|
||||||
/// walk event.</summary>
|
/// portal pass, and the shell draw — sits inside retail's
|
||||||
|
/// <c>if (part->gfxobj[part->deg_level] != 0)</c> @0x0059f2d3; a
|
||||||
|
/// degraded-out slot draws NOTHING beyond the BLD event.
|
||||||
|
/// <c>HasGeometry</c> + a non-null <c>SelectDrawingBsp</c> together model
|
||||||
|
/// that one gate. Inside the gate, retail's own order
|
||||||
|
/// (@0x0059f30b–0x0059f345) is <c>D3DPolyRender::FlushAlphaList(0f)</c> →
|
||||||
|
/// <c>CPhysicsPart::Draw(parts, 1)</c> (the PORTAL flavor — the two-pass
|
||||||
|
/// punch/look-in walk below) → <c>CPhysicsPart::Draw(parts, 0)</c> (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.</summary>
|
||||||
public void DrawBuilding(
|
public void DrawBuilding(
|
||||||
WalkBuilding building, WalkPortalView activeViews,
|
WalkBuilding building, WalkPortalView activeViews,
|
||||||
IRetailFrameWalkContext ctx, IWalkEventSink sink)
|
IRetailFrameWalkContext ctx, IWalkEventSink sink)
|
||||||
|
|
@ -167,8 +183,15 @@ public sealed class RetailFrameWalk
|
||||||
degradeMultiplier: DegradeMultiplier);
|
degradeMultiplier: DegradeMultiplier);
|
||||||
if (bsp is null) return;
|
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);
|
int viewCount = Math.Max(activeViews.ViewCount, 0);
|
||||||
var passSink = new PortalPassSink(sink);
|
var passSink = new PortalPassSink(building, sink);
|
||||||
Vector3 viewpoint = ctx.ViewpointInBuilding(building);
|
Vector3 viewpoint = ctx.ViewpointInBuilding(building);
|
||||||
for (int v = 0; v < viewCount; v++)
|
for (int v = 0; v < viewCount; v++)
|
||||||
{
|
{
|
||||||
|
|
@ -182,6 +205,12 @@ public sealed class RetailFrameWalk
|
||||||
(portalRef, pass) => WalkBuildingPortals.DrawPortal(
|
(portalRef, pass) => WalkBuildingPortals.DrawPortal(
|
||||||
_outdoorPView, building, portalRef, pass, ctx, passSink));
|
_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)
|
private void EmitDrawCells(WalkPView pview, IWalkEventSink sink)
|
||||||
|
|
@ -204,13 +233,17 @@ public sealed class RetailFrameWalk
|
||||||
ctx.GetVisible(id)?.PopView();
|
ctx.GetVisible(id)?.PopView();
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class PortalPassSink(IWalkEventSink sink)
|
private sealed class PortalPassSink(WalkBuilding building, IWalkEventSink sink)
|
||||||
: WalkBuildingPortals.IWalkPortalPassSink
|
: WalkBuildingPortals.IWalkPortalPassSink
|
||||||
{
|
{
|
||||||
public void OnPunch(WalkPolygon polygon)
|
public void OnPunch(WalkPolygon polygon)
|
||||||
{
|
{
|
||||||
// The far-Z punch is a depth-only GPU submission (FW2's ordered
|
// 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)
|
public void OnDrawCells(WalkPView pview)
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,78 @@ public readonly record struct WalkEvent(
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The sink a walk emits into. FW1 conformance tests collect events; FW2's
|
/// The sink a walk emits into. FW1 conformance tests collect events; FW2's
|
||||||
/// production sink will additionally receive the ordered draw stream.
|
/// production sink will additionally receive the ordered draw stream.
|
||||||
|
///
|
||||||
|
/// <para>Campaign FW3.2b-1 additive seam: <see cref="RetailFrameWalk"/> also
|
||||||
|
/// calls the four richer, default-no-op members below at turns the
|
||||||
|
/// vocabulary-only <see cref="WalkEvent"/> stream cannot express (a visited
|
||||||
|
/// landscape cell with no building emits no <see cref="WalkEvent"/> at all;
|
||||||
|
/// <see cref="WalkEventKind.Building"/> carries only a cell id, not the
|
||||||
|
/// <see cref="WalkBuilding"/> 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 <see cref="Emit"/>
|
||||||
|
/// continues to compile and behave identically — these are C# default
|
||||||
|
/// interface members, never called by any pre-FW3.2b-1 code path.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IWalkEventSink
|
public interface IWalkEventSink
|
||||||
{
|
{
|
||||||
void Emit(in WalkEvent walkEvent);
|
void Emit(in WalkEvent walkEvent);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires once per visited landscape cell, AFTER that cell's building
|
||||||
|
/// turn (if any) — <c>RenderDeviceD3D::DrawSortCell</c> @0x0059f140
|
||||||
|
/// calls <c>DrawBuilding(building)</c> first, then
|
||||||
|
/// <c>DrawObjCell(cell)</c> UNCONDITIONALLY (decomp-confirmed
|
||||||
|
/// 2026-08-30). <paramref name="cellId"/> follows retail's outdoor cell
|
||||||
|
/// encoding — <c>(landblockId & 0xFFFF0000) | (cellIndex + 1)</c> —
|
||||||
|
/// the same convention <see cref="WalkLandscapeAssembler.BuildSlot"/>
|
||||||
|
/// and <see cref="WalkProductionFrameContext.SetViewer"/> +
|
||||||
|
/// <see cref="WalkLandscapeAssembler.SetViewer"/> already use. Default
|
||||||
|
/// no-op.
|
||||||
|
/// </summary>
|
||||||
|
void OnLandscapeCellTurn(uint cellId) { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires at <see cref="RetailFrameWalk.DrawBuilding"/> once retail's own
|
||||||
|
/// gate has passed — <c>RenderDeviceD3D::DrawBuilding</c> @0x0059f2a0
|
||||||
|
/// wraps its ENTIRE body (the alpha barrier, the portal pass, the shell
|
||||||
|
/// draw) in <c>if (part->gfxobj[part->deg_level] != 0)</c>
|
||||||
|
/// @0x0059f2d3 — a degraded-out slot draws NOTHING beyond the
|
||||||
|
/// unconditional <see cref="WalkEventKind.Building"/> <see cref="Emit"/>
|
||||||
|
/// call. <see cref="WalkBuilding.HasGeometry"/> and a non-null
|
||||||
|
/// <see cref="WalkBuilding.SelectDrawingBsp"/> result together model that
|
||||||
|
/// one gate, so this hook fires only after both have passed. This is the
|
||||||
|
/// ALPHA BARRIER turn (<c>D3DPolyRender::FlushAlphaList(0f)</c>
|
||||||
|
/// @0x0059f30b) — retail's own order runs it BEFORE the portal pass
|
||||||
|
/// (<c>CPhysicsPart::Draw(parts, 1)</c>), which in turn runs BEFORE the
|
||||||
|
/// shell draw (<c>CPhysicsPart::Draw(parts, 0)</c> — see
|
||||||
|
/// <see cref="OnBuildingShellTurn"/>, fired separately, after the portal
|
||||||
|
/// pass completes). Default no-op.
|
||||||
|
/// </summary>
|
||||||
|
void OnBuildingTurn(WalkBuilding building) { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires at the END of <see cref="RetailFrameWalk.DrawBuilding"/>, after
|
||||||
|
/// its two-pass portal walk (punches + look-ins, across every active
|
||||||
|
/// view) has fully completed — <c>CPhysicsPart::Draw(parts, 0)</c>
|
||||||
|
/// @0x0059f331, retail's plain-mesh draw of the building's own shell,
|
||||||
|
/// which runs strictly AFTER <c>CPhysicsPart::Draw(parts, 1)</c> (the
|
||||||
|
/// portal flavor) per the decomp sequence at @0x0059f30b–0x0059f345. Only
|
||||||
|
/// fires when <see cref="OnBuildingTurn"/> also fired (same gate; see its
|
||||||
|
/// doc comment) — a degraded-out slot reaches neither. Default no-op.
|
||||||
|
/// </summary>
|
||||||
|
void OnBuildingShellTurn(WalkBuilding building) { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires when the two-pass portal machinery
|
||||||
|
/// (<see cref="WalkBuildingPortals"/>) would draw a far-Z punch fan
|
||||||
|
/// (<c>DrawPortalPolyInternal</c> @0x0059bc90, pass 1) — carries the
|
||||||
|
/// owning building and the polygon in BUILDING-LOCAL space (as stored on
|
||||||
|
/// the emitted <see cref="WalkPortalRef"/>) so a driver can resolve the
|
||||||
|
/// world transform itself. The FW1 oracle traces never log punches
|
||||||
|
/// (<see cref="RetailFrameWalk"/>'s internal <c>PortalPassSink.OnPunch</c>
|
||||||
|
/// stayed a no-op through FW1/FW2 for exactly that reason). Default
|
||||||
|
/// no-op.
|
||||||
|
/// </summary>
|
||||||
|
void OnPunchGeometry(WalkBuilding building, WalkPolygon polygon) { }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
489
src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
Normal file
489
src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW3.2b-1: one cell's or one building's already-queried
|
||||||
|
/// static content, ready for <see cref="WalkStaticStreamPopulator"/> —
|
||||||
|
/// caller-built, never read from a retained scene (see
|
||||||
|
/// <see cref="IWalkFrameWorldData"/>'s doc comment).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Records">Already-classified <see cref="RenderProjectionRecord"/>s
|
||||||
|
/// for this turn's cell/building, in the SAME order they must enter the walk
|
||||||
|
/// stream (never re-sorted downstream — <see cref="WalkStaticStreamPopulator"/>'s
|
||||||
|
/// own contract).</param>
|
||||||
|
/// <param name="TupleLandblockId">The clip-slot-resolving landblock id
|
||||||
|
/// <c>WbDrawDispatcher.ClassifyEntityForWalk</c> needs per record (FW3.2a's
|
||||||
|
/// <c>tupleLandblockId</c> parameter) — carried per-turn rather than once per
|
||||||
|
/// frame because a single frame's cells/buildings can span more than one
|
||||||
|
/// committed landblock.</param>
|
||||||
|
internal readonly record struct WalkFrameStaticRecords(
|
||||||
|
RenderProjectionRecord[] Records, uint TupleLandblockId)
|
||||||
|
{
|
||||||
|
public static readonly WalkFrameStaticRecords Empty = new(Array.Empty<RenderProjectionRecord>(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW3.2b-1: the world-data lookups <see cref="WalkFrameDriver"/>
|
||||||
|
/// needs at each walk turn, entirely caller-built — the driver reads no
|
||||||
|
/// retained scene state of its own (mirrors <see cref="WalkStaticStreamPopulator"/>'s
|
||||||
|
/// own "reads no retained scene state itself" contract one layer up). FW3.2b-2
|
||||||
|
/// wires the real production implementation (<c>RenderSceneQuery.CopyCellStaticsTo</c>
|
||||||
|
/// / <c>CopyIndexTo</c> + <see cref="WalkBuildingRegistry"/>); this stage's
|
||||||
|
/// headless referee tests wire a synthetic fake instead.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IWalkFrameWorldData
|
||||||
|
{
|
||||||
|
/// <summary>An indoor <c>PView::DrawCells</c> flood cell's (or a building
|
||||||
|
/// look-in's) static content — <c>RenderProjectionClass.IndoorCellStatic</c>.</summary>
|
||||||
|
WalkFrameStaticRecords GetCellStatics(uint cellId);
|
||||||
|
|
||||||
|
/// <summary>One visited landscape (outdoor) cell's static content —
|
||||||
|
/// <c>RenderProjectionClass.OutdoorStatic</c>, keyed by the SAME
|
||||||
|
/// <c>(landblockId & 0xFFFF0000) | (cellIndex+1)</c> id
|
||||||
|
/// <see cref="IWalkEventSink.OnLandscapeCellTurn"/> computes.</summary>
|
||||||
|
WalkFrameStaticRecords GetOutdoorStatics(uint cellId);
|
||||||
|
|
||||||
|
/// <summary>One building's own exterior shell content (<c>IsBuildingShell</c>
|
||||||
|
/// records anchored at the building's position cell).</summary>
|
||||||
|
WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building);
|
||||||
|
|
||||||
|
/// <summary>Building-local → world, for transforming a punch polygon
|
||||||
|
/// before <see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> — the
|
||||||
|
/// production implementation is <see cref="WalkBuildingRegistry.TryGetEntry"/>'s
|
||||||
|
/// <c>WorldTransform</c> (FW3.2b-2 wiring).</summary>
|
||||||
|
Matrix4x4 GetBuildingWorldTransform(WalkBuilding building);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW3.2b-1: the leaf GPU-adjacent actions
|
||||||
|
/// <see cref="WalkFrameDriver"/> calls at walk turns that have no
|
||||||
|
/// <see cref="OrderedDrawStream"/> submission path YET (sky, terrain, an
|
||||||
|
/// EnvCell shell, a portal punch fan) or that aren't a draw at all (the
|
||||||
|
/// <see cref="RetailAlphaQueue"/> barrier). Kept as its own seam — rather
|
||||||
|
/// than folding these into <see cref="WalkFrameDriver"/> directly — so the
|
||||||
|
/// FW3.2b-1 headless referee suite can wire a fake and prove turn ORDER
|
||||||
|
/// without standing up the real renderers <c>EnvCellRenderer</c>,
|
||||||
|
/// <c>TerrainModernRenderer</c>, <c>GameSky</c>, and
|
||||||
|
/// <c>PortalDepthMaskRenderer.DrawDepthFan</c> — FW3.2b-2's job.
|
||||||
|
///
|
||||||
|
/// <para>Stream submission itself (<c>WbDrawDispatcher.SubmitOrderedStream</c>)
|
||||||
|
/// is deliberately NOT part of this interface: it is already a real,
|
||||||
|
/// FW2/FW3.2a-tested production method, so <see cref="WalkFrameDriver"/>
|
||||||
|
/// 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.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal interface IWalkFrameLeafRenderer
|
||||||
|
{
|
||||||
|
/// <summary><c>LScape::draw</c> draws <c>GameSky</c> once per outdoor
|
||||||
|
/// walk (retail draws it once inside <c>LScape::draw</c>; 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).</summary>
|
||||||
|
void DrawSky();
|
||||||
|
|
||||||
|
/// <summary><c>LScape::grab_visible_cells</c>'s terrain mesh, once per
|
||||||
|
/// ACTIVE clip slice — <paramref name="sliceIndex"/> is caller-supplied
|
||||||
|
/// (<see cref="WalkFrameDriver.RunFrame"/>'s <c>activeTerrainSliceCount</c>)
|
||||||
|
/// since FW3.2b-1 does not wire <c>ClipFrameAssembler</c>/
|
||||||
|
/// <c>ViewconeCuller</c> (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 <c>DrawLandCell</c>/
|
||||||
|
/// <c>DrawObjCell</c> interleave, recorded here rather than ported, since
|
||||||
|
/// terrain itself carries no walk event today.</summary>
|
||||||
|
void DrawTerrainSlice(int sliceIndex);
|
||||||
|
|
||||||
|
/// <summary>One committed cell's EnvCell shell —
|
||||||
|
/// <c>PView::DrawCells</c>'s <c>DrawEnvCell</c> @0x005a4abe, which
|
||||||
|
/// precedes <c>DrawObjCellForDummies</c> @0x005a4b0d (the cell's static
|
||||||
|
/// contents, appended to the stream instead — see
|
||||||
|
/// <see cref="WalkFrameDriver"/>'s type doc comment) for every cell of
|
||||||
|
/// EVERY flood this stage drives (the ordinary interior root's own
|
||||||
|
/// <c>DrawCells</c> AND a building's look-in <c>DrawCells</c> both walk
|
||||||
|
/// this same shell-then-contents order).</summary>
|
||||||
|
void DrawCellShell(uint cellId);
|
||||||
|
|
||||||
|
/// <summary><c>DrawPortalPolyInternal</c> @0x0059bc90's depth-only far-Z
|
||||||
|
/// punch fan — pass 1 of the building portal walk.
|
||||||
|
/// <paramref name="worldPolygon"/> is already transformed building-local
|
||||||
|
/// → world (<see cref="WalkFrameDriver"/> does the transform via
|
||||||
|
/// <see cref="IWalkFrameWorldData.GetBuildingWorldTransform"/> before
|
||||||
|
/// calling this). The real implementation is
|
||||||
|
/// <c>PortalDepthMaskRenderer.DrawDepthFan</c> with <c>forceFarZ</c>
|
||||||
|
/// (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 <see cref="WalkFrameDriver"/>'s type doc comment)
|
||||||
|
/// reaches the GPU first.</summary>
|
||||||
|
void DrawPunchFan(WalkPolygon worldPolygon);
|
||||||
|
|
||||||
|
/// <summary><c>RetailAlphaQueue.FlushFartherThan</c>'s <c>DrawBuilding</c>
|
||||||
|
/// barrier — retail's own call site is
|
||||||
|
/// <c>D3DPolyRender::FlushAlphaList(0f)</c> @0x0059f30b, a FLUSH-ALL, not
|
||||||
|
/// a distance-gated flush; this stage keeps the DISPATCHED
|
||||||
|
/// <c>FlushFartherThan(viewerDistanceTo(building))</c> 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
|
||||||
|
/// <c>RetailAlphaQueue.FlushFartherThan</c>'s own doc comment) and flags
|
||||||
|
/// the 0f/address detail as an FW4 adjudication candidate rather than
|
||||||
|
/// silently reinterpreting the dispatched design.</summary>
|
||||||
|
void AlphaBarrier(float viewerDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW3.2b-1 test seam: an optional, diagnostic-only
|
||||||
|
/// observer of every stream FLUSH <see cref="WalkFrameDriver"/> performs.
|
||||||
|
/// Production callers pass <see langword="null"/> (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 <c>RecordingGpuDevice.Calls</c>' lower-level RHI call log.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IWalkFrameDriverTrace
|
||||||
|
{
|
||||||
|
/// <summary><paramref name="stages"/> is a snapshot (never the live,
|
||||||
|
/// about-to-be-<c>Reset</c> list) of every command's
|
||||||
|
/// <see cref="WalkDrawStage"/> 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.</summary>
|
||||||
|
void OnFlush(int commandCount, IReadOnlyList<WalkDrawStage> stages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW3.2b-1 — THE WALK FRAME DRIVER. Executes one full
|
||||||
|
/// static-content frame by driving <see cref="RetailFrameWalk"/> with itself
|
||||||
|
/// as the <see cref="IWalkEventSink"/>, turning each walk turn into either a
|
||||||
|
/// stream append (<see cref="WalkStaticStreamPopulator"/>, FW3.2a) or a leaf
|
||||||
|
/// call (<see cref="IWalkFrameLeafRenderer"/>), 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 — <c>WorldSceneRenderer</c>
|
||||||
|
/// 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
|
||||||
|
/// <c>RecordingGpuDevice</c> proving the interleaving.
|
||||||
|
///
|
||||||
|
/// <para><b>The one flush rule that reproduces the whole frame script:</b>
|
||||||
|
/// before EVERY leaf-renderer call (<see cref="IWalkFrameLeafRenderer.DrawSky"/>,
|
||||||
|
/// <c>DrawTerrainSlice</c>, <c>DrawCellShell</c>, <c>DrawPunchFan</c>) and
|
||||||
|
/// before every <see cref="IWalkFrameLeafRenderer.AlphaBarrier"/> 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 <see cref="IWalkEventSink.OnBuildingShellTurn"/>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="RetailFrameWalk.DrawBuilding"/>'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.</para>
|
||||||
|
///
|
||||||
|
/// <para>Retail anchors: <c>SmartBox::RenderNormalMode</c> @0x00453aa0 (the
|
||||||
|
/// root <see cref="RetailFrameWalk.WalkFrame"/> already ports),
|
||||||
|
/// <c>RenderDeviceD3D::DrawSortCell</c> @0x0059f140 (building-before-
|
||||||
|
/// DrawObjCell per landscape cell), <c>PView::DrawCells</c> @0x005a4840
|
||||||
|
/// (<c>DrawEnvCell</c> @0x005a4abe before <c>DrawObjCellForDummies</c>
|
||||||
|
/// @0x005a4b0d per flooded cell), <c>RenderDeviceD3D::DrawBuilding</c>
|
||||||
|
/// @0x0059f2a0 (the <c>part->gfxobj[deg_level]!=0</c> gate @0x0059f2d3
|
||||||
|
/// and the alpha-barrier → portal-pass → shell order @0x0059f30b–0x0059f345).</para>
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives one complete frame at retail's root (<c>SmartBox::RenderNormalMode</c>):
|
||||||
|
/// calls <see cref="RetailFrameWalk.WalkFrame"/> with this driver as the
|
||||||
|
/// sink, sandwiched between <see cref="BeginFrame"/>/<see cref="EndFrame"/>.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="RetailFrameWalk.DrawBuilding"/> or
|
||||||
|
/// <see cref="RetailFrameWalk.DrawLandscape"/> call) and wants this
|
||||||
|
/// driver's turn handling without going through the top-level root.
|
||||||
|
/// <see cref="RunFrame"/> is implemented in terms of this pair.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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 <c>try</c>/<c>finally</c> discipline in
|
||||||
|
/// <see cref="RunFrame"/> — a caller driving the walk manually should
|
||||||
|
/// follow the same shape.</summary>
|
||||||
|
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<uint> 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.");
|
||||||
|
|
||||||
|
/// <summary><c>ConstructBuildingView</c>'s polygon is building-local; the
|
||||||
|
/// punch fan needs world space. Vertices transform directly; the plane
|
||||||
|
/// normal uses <see cref="Vector3.TransformNormal"/> (correct for the
|
||||||
|
/// rigid, shear-free placements <see cref="WalkBuildingFactory"/> and
|
||||||
|
/// <see cref="WalkCellFactory"/> build) and <c>D</c> is rederived from
|
||||||
|
/// the transformed normal and the first transformed vertex — the SAME
|
||||||
|
/// construction <see cref="WalkCellFactory"/>/<see cref="WalkBuildingFactory"/>
|
||||||
|
/// already use for their own polygons (<c>Plane = new WalkPlane(normal,
|
||||||
|
/// -Vector3.Dot(normal, vertices[0]))</c>).</summary>
|
||||||
|
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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,21 @@ namespace AcDream.App.Rendering.Walk;
|
||||||
/// static shape plus the per-frame visibility the landscape pass writes.</summary>
|
/// static shape plus the per-frame visibility the landscape pass writes.</summary>
|
||||||
public sealed class WalkLandBlock
|
public sealed class WalkLandBlock
|
||||||
{
|
{
|
||||||
|
/// <summary>Additive (Campaign FW3.2b-1): this block's landblock id in
|
||||||
|
/// retail's <c>(bx<<24 | by<<16)</c> encoding —
|
||||||
|
/// <see cref="WalkLandscapeAssembler.BuildSlot"/> already derives
|
||||||
|
/// <c>(bx, by)</c> from this SAME encoding (its <c>BlockCoords</c>
|
||||||
|
/// helper), and <see cref="WalkLandscapeAssembler.SetViewer"/> /
|
||||||
|
/// <see cref="WalkProductionFrameContext"/>'s low-word convention
|
||||||
|
/// (<c>low >= 1 && low <= 0x40</c>) both key off it too.
|
||||||
|
/// This field lets a per-visited-cell walk-turn hook
|
||||||
|
/// (<see cref="IWalkEventSink.OnLandscapeCellTurn"/>) recover a real
|
||||||
|
/// outdoor cell id — <c>(LandblockId & 0xFFFF0000) | (cellIndex+1)</c>
|
||||||
|
/// — 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).</summary>
|
||||||
|
public uint LandblockId;
|
||||||
|
|
||||||
public int SideCellCount = 8;
|
public int SideCellCount = 8;
|
||||||
public float MaxZ;
|
public float MaxZ;
|
||||||
public float MinZ;
|
public float MinZ;
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,9 @@ public sealed class WalkLandscapeAssembler
|
||||||
int sideCellCount = SideCellCountForRing(RingOf(gx, gy));
|
int sideCellCount = SideCellCountForRing(RingOf(gx, gy));
|
||||||
var block = new WalkLandBlock
|
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,
|
SideCellCount = sideCellCount,
|
||||||
MaxZ = data.MaxZ,
|
MaxZ = data.MaxZ,
|
||||||
MinZ = data.MinZ,
|
MinZ = data.MinZ,
|
||||||
|
|
|
||||||
686
tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
Normal file
686
tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW3.2b-1: <see cref="WalkFrameDriver"/>'s headless
|
||||||
|
/// referee suite — proves that driving <see cref="RetailFrameWalk"/> with the
|
||||||
|
/// driver as its <see cref="IWalkEventSink"/> produces retail's own turn
|
||||||
|
/// order (shell-then-contents per cell, flush-before-every-leaf-action,
|
||||||
|
/// content-before-punch) through the REAL <see cref="WbDrawDispatcher.SubmitOrderedStream"/>
|
||||||
|
/// onto a <see cref="RecordingGpuDevice"/> — never a mock of the submission
|
||||||
|
/// path itself. No production wiring is exercised (<c>WorldSceneRenderer</c>
|
||||||
|
/// still does not construct this driver); every world-data/leaf-renderer
|
||||||
|
/// dependency here is a synthetic fake per plan §FW3.2b-1.
|
||||||
|
/// </summary>
|
||||||
|
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<string> log) : IWalkFrameLeafRenderer
|
||||||
|
{
|
||||||
|
public readonly List<WalkPolygon> 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<string> log) : IWalkFrameDriverTrace
|
||||||
|
{
|
||||||
|
public void OnFlush(int commandCount, IReadOnlyList<WalkDrawStage> stages) =>
|
||||||
|
log.Add($"FLUSH:{commandCount}:{string.Join(',', stages.Distinct())}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorldData : IWalkFrameWorldData
|
||||||
|
{
|
||||||
|
public readonly Dictionary<uint, WalkFrameStaticRecords> CellStaticsByCell = new();
|
||||||
|
public readonly Dictionary<uint, WalkFrameStaticRecords> OutdoorStaticsByCell = new();
|
||||||
|
public readonly Dictionary<WalkBuilding, WalkFrameStaticRecords> ShellByBuilding = new();
|
||||||
|
public readonly Dictionary<WalkBuilding, Matrix4x4> 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<uint, WalkCell> Cells = new();
|
||||||
|
public readonly Dictionary<WalkBuilding, float> 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<WalkScreenPoint> output)
|
||||||
|
{
|
||||||
|
Span<WalkScreenPoint> 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<string>();
|
||||||
|
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<GpuRecordedMultiDrawIndirect> mdiCalls =
|
||||||
|
[.. fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
|
||||||
|
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<string>();
|
||||||
|
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<GpuRecordedMultiDrawIndirect> mdiCalls =
|
||||||
|
[.. fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
|
||||||
|
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<string>();
|
||||||
|
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<InvalidOperationException>(
|
||||||
|
() => ((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<string>();
|
||||||
|
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<InvalidOperationException>(
|
||||||
|
() => 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<string>();
|
||||||
|
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<GpuRecordedMultiDrawIndirect>());
|
||||||
|
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<MeshRef> 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<ObjectRenderBatch>(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<ulong, ObjectRenderData>)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<WbMeshAdapter>.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<uint, IDatDatabase> CellRegions { get; } =
|
||||||
|
new(new Dictionary<uint, IDatDatabase>());
|
||||||
|
|
||||||
|
public IDatDatabase HighRes => _highRes;
|
||||||
|
|
||||||
|
public IDatDatabase Language => _language;
|
||||||
|
|
||||||
|
public IDatDatabase Local => _language;
|
||||||
|
|
||||||
|
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
||||||
|
new(new Dictionary<uint, uint>());
|
||||||
|
|
||||||
|
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<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
||||||
|
Array.Empty<uint>();
|
||||||
|
|
||||||
|
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
||||||
|
Array.Empty<IDatReaderWriter.IdResolution>();
|
||||||
|
|
||||||
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public bool TrySave<T>(
|
||||||
|
uint regionId,
|
||||||
|
T obj,
|
||||||
|
int iteration = 0) where T : IDBObj =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
[return: MaybeNull]
|
||||||
|
public T Get<T>(uint fileId) where T : IDBObj => default;
|
||||||
|
|
||||||
|
public bool TryGet<T>(
|
||||||
|
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<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
||||||
|
Array.Empty<uint>();
|
||||||
|
|
||||||
|
public bool TryGet<T>(
|
||||||
|
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>(T obj, int iteration = 0) where T : IDBObj =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue