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()
+ {
+ }
+ }
+ }
+}