using System.Numerics;
using System.Runtime.InteropServices;
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). Campaign FW3.4a: a segment INTO 's
/// per-frame arena, not a freshly allocated array — see that type's own doc
/// comment.
/// 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(
ArraySegment Records, uint TupleLandblockId)
{
public static readonly WalkFrameStaticRecords Empty =
new(ArraySegment.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, the interior depth clear, the exit-
/// portal seals) or that aren't a draw at all (the
/// barrier). Kept as its own seam — rather
/// than folding these into directly — so the
/// FW3.2b-1 headless referee suite can wire a fake and prove turn ORDER
/// without standing up the real renderers EnvCellRenderer,
/// TerrainModernRenderer, GameSky, and
/// PortalDepthMaskRenderer.DrawDepthFan — FW3.2b-2's job.
///
/// Stream submission itself (WbDrawDispatcher.PrepareOrderedStream/
/// DrawOrderedRange) is deliberately NOT part of this interface: it is
/// already real, production-tested machinery, so
/// calls it directly 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. Retail first
/// draws ALL shells in reverse cell_draw_list order, then starts a
/// second reverse loop for DrawObjCellForDummies @0x005a4b0d.
/// The ordinary interior-root flood and every building look-in flood use
/// this same two-pass discipline.
void DrawCellShell(uint cellId);
/// One landscape cell's or building shell's static-owner
/// particle submission, at its own walk turn — see
/// for the retail
/// positional invariant this carries (the #132 falls containment).
void DrawStaticParticles(IReadOnlySet ownerIds);
/// Draws the packed dynamic occupants of one building look-in
/// cell at that cell's OWN PView::DrawCells turn, then submits the
/// cell's static + dynamic particle owners at the same turn. The route
/// index is assigned in the exact order Collect encountered look-in cell
/// turns and therefore matches the frame product's walk-keyed
/// LookInObject ranges.
void DrawLookInDynamics(
uint cellId,
int routeIndex,
IReadOnlySet staticParticleOwnerIds);
/// PView::DrawCells @0x005a4840's gated full depth clear
/// (pc:432731-432732) between the outside stage and the interior root's
/// own flood — production maps this to IWorldPassScope.ClearInteriorDepth
/// (see that interface's own member of the same name in
/// RetailPViewRenderer.cs, staged there on OutsideViewSlices.Length
/// > 0 — an ACKNOWLEDGED approximation of retail's true
/// portalsDrawnCount gate per that file's own comment). This walk
/// driver instead fires unconditionally for every interior root (per the
/// 2026-08-30 decomp correction: the coordinator's directive supersedes
/// the packed path's staged gate — reconcile the two if a firmer
/// portalsDrawnCount reading ever lands). Only called for an
/// INTERIOR root, never outdoors (retail has no depth clear there —
/// portalsDrawnCount never applies to LScape::draw's own
/// top-level walk).
void ClearInteriorDepth();
/// The exit-portal seals (pc:432785-432786) — re-stamping every
/// outside-leading portal's TRUE depth right after
/// , so the aperture the clear just wiped
/// stays occluded by the world beyond it rather than by whatever draws
/// next. Production maps this to the existing seal-fan machinery
/// (RetailPViewRenderer.DrawExitPortalMask/
/// PortalDepthMaskRenderer) — this driver only provides the TURN;
/// the real per-portal fan geometry is FW3.2b-2's job. Only called for an
/// INTERIOR root's own flood, never for a building look-in (those call
/// DrawCells re-entrantly with no clear/seal step) and never
/// outdoors.
void DrawExitSeals();
/// DrawPortalPolyInternal @0x0059bc90's depth-only far-Z
/// punch fan — pass 1 of the building portal walk.
/// is already transformed building-local
/// → world ( does the transform via
/// at Collect
/// time — see that type's own doc comment). The real implementation is
/// PortalDepthMaskRenderer.DrawDepthFan with forceFarZ
/// (FW3.2b-2 wiring) — Replay calls this at exactly the point Collect
/// recorded it: 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. is the view the emitting
/// two-pass walk was pinned to (retail building_view =
/// Render::portal_view_num @0x0059f3bf) — production clips the fan by
/// that view's slice planes.
void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex);
/// 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 ordered-stream range
/// draws. Production callers pass (the default) — this
/// exists purely so the headless referee suite can assert range COUNT,
/// per-range command count, and per-range stage without re-deriving them
/// from RecordingGpuDevice.Calls' lower-level RHI call log.
///
internal interface IWalkFrameDriverTrace
{
/// is a snapshot (never a live,
/// about-to-mutate list) of every command's in
/// the drawn segment, in stream order — by this stage's own segmenting
/// discipline (a segment boundary 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 FW3.4a: one turn Collect recorded, replayed by
/// in the exact order Collect saw it.
/// is the collect-time analogue of
/// the old immediate driver's flush point — see 's
/// own doc comment for the full list and what each carries.
///
internal enum WalkFrameEventKind : byte
{
/// The accumulated grew since the
/// last mark and must be drawn, via WbDrawDispatcher.DrawOrderedRange,
/// before whatever leaf event follows.
/// is the stream's exclusive-end command index at the moment this event
/// was recorded.
StreamMark,
/// .
Sky,
/// —
/// is the slice index.
TerrainSlice,
/// —
/// is the cell.
CellShell,
/// —
/// is the already-world-transformed
/// polygon (transformed at Collect time, exactly as the pre-FW3.4a driver
/// transformed it before its own immediate call),
/// is the active view index.
PunchFan,
/// —
/// is the viewer distance, computed
/// at Collect time (the context that supplies it does not outlive Collect).
AlphaBarrier,
/// .
ClearInteriorDepth,
/// .
ExitSeals,
/// —
/// ONE landscape cell's () or ONE
/// building shell's () static-owner
/// particle submission, emitted AT ITS OWN WALK TURN. Retail's falls
/// containment is positional: an outdoor emitter's polys join the one
/// alpha list during its owner cell's DrawObjCell in the
/// far-to-near landscape walk, so every nearer building's pre-punch
/// alpha barrier (DrawBuilding @0x0059f2a0's
/// FlushAlphaList @0x0059f30b) drains the already-queued FARTHER
/// content against still-true depth BEFORE the punch stamps far-Z into
/// the aperture. The former single batched submission at the pre-clear
/// closure ran AFTER every punch — the barriers fired over an empty
/// queue and the falls drained against punched-far aperture pixels (the
/// cathedral bleed; the old pipeline's user-verified #132 fix
/// `e102fb36` encoded the same invariant).
StaticParticles,
/// —
/// is the look-in cell and
/// is its walk-ordered packed route
/// index.
LookInDynamics,
}
///
/// The exact portal-view cones installed at the walk's building look-in
/// DrawCells turns. Retail RenderDeviceD3D::DrawMesh
/// @0x005A0860 tests each object's drawing sphere against these views before
/// drawing the mesh whole; cell membership alone is not an admission rule.
///
internal interface IWalkLookInViewSource
{
IReadOnlyList LookInCellTurns { get; }
bool SphereVisibleInLookInTurn(
int routeIndex,
in Vector3 center,
float radius);
}
internal readonly record struct WalkLookInSlice(int PlaneStart, int PlaneCount);
internal readonly record struct WalkLookInTurn(
uint CellId,
int SliceStart,
int SliceCount);
/// See for what each field means per
/// kind. A single struct (rather than a kind hierarchy) keeps Collect's
/// per-turn list a flat, allocation-cheap List<WalkFrameEvent> —
/// only (a punch fan's already-transformed geometry)
/// allocates, and only once per punch, which is rare enough per frame to be
/// unconditionally acceptable (plan §FW3.4a's own call).
internal readonly struct WalkFrameEvent
{
private WalkFrameEvent(
WalkFrameEventKind kind, int intArg, uint cellId, float floatArg, WalkPolygon? polygon,
WalkBuilding? building = null)
{
Kind = kind;
IntArg = intArg;
CellId = cellId;
FloatArg = floatArg;
Polygon = polygon;
Building = building;
}
internal WalkFrameEventKind Kind { get; }
internal int IntArg { get; }
internal uint CellId { get; }
internal float FloatArg { get; }
internal WalkPolygon? Polygon { get; }
/// only: the
/// building whose shell statics' owners submit at this turn; null for a
/// landscape cell's turn (then names the cell).
internal WalkBuilding? Building { get; }
internal static WalkFrameEvent Mark(int exclusiveEnd) =>
new(WalkFrameEventKind.StreamMark, exclusiveEnd, 0, 0f, null);
internal static WalkFrameEvent Sky() =>
new(WalkFrameEventKind.Sky, 0, 0, 0f, null);
internal static WalkFrameEvent TerrainSlice(int sliceIndex) =>
new(WalkFrameEventKind.TerrainSlice, sliceIndex, 0, 0f, null);
internal static WalkFrameEvent CellShell(uint cellId) =>
new(WalkFrameEventKind.CellShell, 0, cellId, 0f, null);
internal static WalkFrameEvent PunchFan(WalkPolygon worldPolygon, int activeViewIndex) =>
new(WalkFrameEventKind.PunchFan, activeViewIndex, 0, 0f, worldPolygon);
internal static WalkFrameEvent AlphaBarrier(float viewerDistance) =>
new(WalkFrameEventKind.AlphaBarrier, 0, 0, viewerDistance, null);
internal static WalkFrameEvent LandscapeCellParticles(uint cellId) =>
new(WalkFrameEventKind.StaticParticles, 0, cellId, 0f, null);
internal static WalkFrameEvent BuildingShellParticles(WalkBuilding building) =>
new(WalkFrameEventKind.StaticParticles, 0, 0, 0f, null, building);
internal static WalkFrameEvent LookInDynamics(uint cellId, int routeIndex) =>
new(WalkFrameEventKind.LookInDynamics, routeIndex, cellId, 0f, null);
internal static WalkFrameEvent ClearInteriorDepth() =>
new(WalkFrameEventKind.ClearInteriorDepth, 0, 0, 0f, null);
internal static WalkFrameEvent ExitSeals() =>
new(WalkFrameEventKind.ExitSeals, 0, 0, 0f, null);
}
///
/// Campaign FW stage FW3.2b-1 — THE WALK FRAME DRIVER. Executes one full
/// static-content frame by driving with itself
/// as the , so that GPU command-buffer order
/// equals retail's walk order (plan §FW3.2b-1's "INTERLEAVING RULE").
///
/// Campaign FW3.4a — the ONE-walk split. Before this stage, a
/// single frame ran TWICE — once with a
/// set-collecting sink to learn the flood/visited-cell set before
/// PrepareCellBatches, once more through this driver to actually
/// submit — and each walk turn's stream content flushed IMMEDIATELY through
/// its own full WbDrawDispatcher.SubmitOrderedStream call (~40 of
/// those per frame at a town, each rewriting and rebinding all nine
/// per-instance sections for that turn's handful of instances). The FW3.4
/// perf checkpoint measured +33.5% CPU p50 and 14× frame allocation from
/// exactly those two costs (plus a third, unrelated one — see
/// 's own doc comment) and tripped the
/// campaign's ±20% stop rule. This stage collapses both:
/// runs ONCE, doing everything the immediate
/// driver used to do EXCEPT the actual GPU submission — stream appends
/// accumulate without flushing, every former immediate leaf call records a
/// instead, and the driver keeps its
/// visited-set bookkeeping (absorbing the renderer's old dedicated
/// set-collecting sink) so the SAME walk answers both questions.
/// then performs the actual GPU work afterward:
/// WbDrawDispatcher.PrepareOrderedStream uploads the WHOLE frame's
/// stream once, and each recorded
/// becomes one cheap DrawOrderedRange call over the already-uploaded
/// payload — interleaved, in the exact recorded order, with the leaf
/// renderer calls the OLD immediate driver made inline. Because Replay walks
/// the SAME event sequence Collect recorded at the SAME points the old code
/// flushed, GPU command order is unchanged bit-for-bit; only the number of
/// walks (two → one) and the shape of the GPU submission (many small
/// rebind-and-draw calls → one bind, many cheap draws) changes.
/// remains Collect immediately followed by Replay, for
/// callers (today: the headless referee suite) that do not need the split;
/// RetailPViewRenderer uses the split directly, since it must run
/// PrepareCellBatches/BuildAndBorrow BETWEEN them.
///
/// The one mark rule that reproduces the whole frame script:
/// before EVERY leaf-renderer event (,
/// TerrainSlice, CellShell, ClearInteriorDepth,
/// ExitSeals, PunchFan) and before every
/// event, Collect records a
/// if the stream grew since the
/// last one (a no-op otherwise — "empty segments submit nothing"); a
/// building's own shell content is APPENDED (not marked) the moment
/// fires, so it only gets a
/// mark ahead of whatever non-stream event comes next (the next building's
/// alpha barrier, or the final mark at 's prepare step).
/// This single rule, combined with retail's two reverse flood passes (ALL
/// shells, then ALL contents),
/// retail's own building order (alpha barrier → portal pass → shell — see
/// 's doc comment), and retail's
/// own interior-root DRAW order (landscape → clear → seals → the flood's own
/// cells — see 's doc
/// comment; this is NOT the order the walk's EVENTS fire in, which is
/// breakpoint-entry order matching the FW0 oracle traces), is what produces
/// every ordering constraint the plan's frame script names: [far shell] …
/// [near shell] [far contents] … [near contents], [alpha barrier] [punch
/// fan(s) + look-in flood(s), each following the SAME reverse two-pass
/// discipline] [building shell content mark], [landscape (if exit views
/// survived)] [interior depth clear] [exit-portal seals] [the interior
/// root's own flood cells], and a final mark at Replay's prepare step. 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
/// (the complete reverse DrawEnvCell loop @0x005a4a00–0x005a4ade
/// before the complete reverse DrawObjCellForDummies loop
/// @0x005a4ade–0x005a4b2d; LScape::draw pc:432719, the depth
/// clear pc:432731-432732, and the exit-portal seals pc:432785-432786 —
/// ALL strictly before the flood's own cells, though the event marking
/// DrawCells entry fires before all three), RenderDeviceD3D::DrawBuilding
/// @0x0059f2a0 (the part->gfxobj[deg_level]!=0 gate @0x0059f2d3
/// and the alpha-barrier → portal-pass → shell order @0x0059f30b–0x0059f345).
///
internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
{
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();
private readonly List _events = new();
private readonly List _markPositions = new();
// Campaign FW3.4a: visited-set collection, absorbed from the renderer's
// former dedicated set-collecting sink (RetailPViewRenderer's old
// WalkVisitedSetCollector) — the SAME shapes that sink produced, now
// populated by the ONE walk Collect already runs instead of a second
// walk pass dedicated to nothing but set-gathering.
internal HashSet VisitedCells { get; } = new();
/// Building look-in cells in the exact order the walk encountered
/// their DrawCells turns. Duplicates are intentional: two authored
/// portal views can independently visit the same cell and therefore own
/// distinct packed route indices.
internal List LookInCellTurns { get; } = new();
private readonly List _lookInTurns = new();
private readonly List _lookInSlices = new();
private readonly List _lookInPlanes = new();
private WalkPlane _lookInCyPlane;
IReadOnlyList IWalkLookInViewSource.LookInCellTurns => LookInCellTurns;
/// The set form of , for drawn-once
/// exclusion and root-flood particle bookkeeping.
internal HashSet LookInCells { get; } = new();
/// FW4 slice 2: the interior root's ORDERED flood cell list,
/// exactly as retail's PView::DrawCells iterates it for the
/// exit-portal seals (pc:432785-432786) — captured at
/// . Empty for an
/// outdoor-rooted frame. The production seal draw iterates THIS list
/// (the walk's own flood), not the old visibility apparatus's — at the
/// #456 seam band the old builder's flood misses exit portals the walk
/// provably floods, leaving unsealed aperture depth the end-of-frame
/// alpha drain splats through (the cathedral falls shine-through).
internal List InteriorFloodCells { get; } = new();
// Replay scratch for StaticParticles events (sequential replay — one
// reused set is safe).
private readonly HashSet _staticParticleOwnerScratch = new();
private static void UnionOwners(
in WalkFrameStaticRecords records, HashSet destination)
{
foreach (RenderProjectionRecord record in records.Records)
{
if (record.Source.LocalEntityId != 0)
destination.Add(record.Source.LocalEntityId);
}
}
internal List VisitedBuildings { get; } = new();
internal HashSet VisitedLandscapeCellIds { get; } = new();
// ---- transient per-Collect state (set in BeginFrame, read by Replay,
// cleared by Replay's own completion) ----
private IWalkBuildingFrameContext? _ctx;
private Matrix4x4 _viewProjection;
private Vector3 _cameraWorldPosition;
private bool _skyDrawnThisFrame;
private WalkDrawStage? _currentDcStage;
private bool _readyToReplay;
private int _lookInRouteIndex;
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):
/// immediately followed by . Kept
/// for callers that don't need the split (today: the headless referee
/// suite) — RetailPViewRenderer calls the pair directly, since it
/// must run other frame work BETWEEN them (plan §FW3.4a).
///
internal void RunFrame(
RetailFrameWalk walk,
uint cameraCellId,
WalkCell? cameraCell,
WalkLandscape landscape,
IRetailFrameWalkContext ctx,
IGpuFrame frame,
IGpuPassEncoder encoder,
Matrix4x4 viewProjection,
Vector3 cameraWorldPosition)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(encoder);
Collect(
walk, cameraCellId, cameraCell, landscape, ctx,
viewProjection, cameraWorldPosition);
Replay(frame, encoder);
}
///
/// Campaign FW3.4a Phase 1 — THE ONE WALK. Drives
/// with this driver as its sink,
/// sandwiched between /,
/// performing NO GPU work: see this type's own doc comment.
///
internal void Collect(
RetailFrameWalk walk,
uint cameraCellId,
WalkCell? cameraCell,
WalkLandscape landscape,
IRetailFrameWalkContext ctx,
Matrix4x4 viewProjection,
Vector3 cameraWorldPosition)
{
ArgumentNullException.ThrowIfNull(walk);
ArgumentNullException.ThrowIfNull(landscape);
ArgumentNullException.ThrowIfNull(ctx);
BeginFrame(ctx, viewProjection, cameraWorldPosition);
walk.WalkFrame(cameraCellId, cameraCell, landscape, ctx, this);
EndFrame();
}
///
/// Opens a collect scope 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. Performs no
/// GPU work — see this type's own doc comment.
///
internal void BeginFrame(
IRetailFrameWalkContext ctx,
Matrix4x4 viewProjection,
Vector3 cameraWorldPosition)
{
ArgumentNullException.ThrowIfNull(ctx);
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;
_viewProjection = viewProjection;
_cameraWorldPosition = cameraWorldPosition;
_skyDrawnThisFrame = false;
_currentDcStage = null;
_readyToReplay = false;
_stream.Reset();
_events.Clear();
_markPositions.Clear();
VisitedCells.Clear();
LookInCellTurns.Clear();
_lookInTurns.Clear();
_lookInSlices.Clear();
_lookInPlanes.Clear();
_lookInCyPlane = ctx.CyPlane;
LookInCells.Clear();
VisitedBuildings.Clear();
VisitedLandscapeCellIds.Clear();
InteriorFloodCells.Clear();
_lookInRouteIndex = 0;
}
/// Records the final segment mark (plan §FW3.2b-1's "at frame
/// end: final segment flush", now a mark rather than a draw — see this
/// type's own doc comment), then closes the collect scope. The recorded
/// stream/events survive this call — consumes them —
/// which is the one behavioral difference from the pre-FW3.4a EndFrame,
/// which reset the stream here because it had just drawn it.
internal void EndFrame()
{
try
{
MarkIfGrown();
}
finally
{
_ctx = null;
_readyToReplay = true;
}
}
///
/// Campaign FW3.4a Phase 2. Requires a completed Collect (an
/// having run since the last Replay) — throws
/// otherwise, rather than silently replaying a stale or empty event list.
/// Uploads the WHOLE collected stream exactly once (skipped when it is
/// empty), then walks the recorded events in order: a
/// issues one
/// WbDrawDispatcher.DrawOrderedRange call over the segment it
/// closes off; every other event kind issues its corresponding
/// call. Because Collect recorded
/// these events at EXACTLY the points the pre-FW3.4a immediate driver
/// used to flush/draw, this reproduces the SAME interleaved GPU command
/// order — the campaign invariant — from one walk instead of two.
///
internal void Replay(IGpuFrame frame, IGpuPassEncoder encoder)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(encoder);
if (!_readyToReplay)
{
throw new InvalidOperationException(
"WalkFrameDriver.Replay was called without a completed Collect (BeginFrame/"
+ "EndFrame, or Collect/RunFrame) preceding it — there is nothing recorded to "
+ "replay.");
}
if (_stream.Count > 0)
_dispatcher.PrepareOrderedStream(frame, _stream, _viewProjection, _markPositions);
int cursor = 0;
for (int i = 0; i < _events.Count; i++)
{
WalkFrameEvent e = _events[i];
switch (e.Kind)
{
case WalkFrameEventKind.StreamMark:
int end = e.IntArg;
int count = end - cursor;
if (_trace is not null)
_trace.OnFlush(count, _stream.Stages.GetRange(cursor, count));
_dispatcher.DrawOrderedRange(encoder, cursor, count);
cursor = end;
break;
case WalkFrameEventKind.Sky:
_leafRenderer.DrawSky();
break;
case WalkFrameEventKind.TerrainSlice:
_leafRenderer.DrawTerrainSlice(e.IntArg);
break;
case WalkFrameEventKind.CellShell:
_leafRenderer.DrawCellShell(e.CellId);
break;
case WalkFrameEventKind.PunchFan:
_leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg);
break;
case WalkFrameEventKind.AlphaBarrier:
_leafRenderer.AlphaBarrier(e.FloatArg);
break;
case WalkFrameEventKind.ClearInteriorDepth:
_leafRenderer.ClearInteriorDepth();
break;
case WalkFrameEventKind.ExitSeals:
_leafRenderer.DrawExitSeals();
break;
case WalkFrameEventKind.StaticParticles:
_staticParticleOwnerScratch.Clear();
UnionOwners(
e.Building is WalkBuilding shellOwner
? _worldData.GetBuildingShellStatics(shellOwner)
: _worldData.GetOutdoorStatics(e.CellId),
_staticParticleOwnerScratch);
if (_staticParticleOwnerScratch.Count > 0)
_leafRenderer.DrawStaticParticles(_staticParticleOwnerScratch);
break;
case WalkFrameEventKind.LookInDynamics:
_staticParticleOwnerScratch.Clear();
UnionOwners(
_worldData.GetCellStatics(e.CellId),
_staticParticleOwnerScratch);
_leafRenderer.DrawLookInDynamics(
e.CellId,
e.IntArg,
_staticParticleOwnerScratch);
break;
}
}
_stream.Reset();
_events.Clear();
_markPositions.Clear();
_readyToReplay = false;
}
// ------------------------------------------------------------------
// IWalkEventSink
// ------------------------------------------------------------------
void IWalkEventSink.Emit(in WalkEvent walkEvent)
{
switch (walkEvent.Kind)
{
case WalkEventKind.DrawInside:
_currentDcStage = WalkDrawStage.CellStatic;
VisitedCells.Add(walkEvent.CellId);
break;
case WalkEventKind.Landscape:
HandleLandscapeTurn(walkEvent.OutsideViewCount);
break;
case WalkEventKind.DrawCells:
foreach (uint id in walkEvent.Cells)
VisitedCells.Add(id);
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();
VisitedLandscapeCellIds.Add(cellId);
WalkFrameStaticRecords records = _worldData.GetOutdoorStatics(cellId);
_populator.PopulateOutdoorStatics(
_stream, cellId, records.Records, records.TupleLandblockId,
_cameraWorldPosition, _viewProjection);
// FW4 (the #132 positional invariant): this cell's emitter owners
// submit AT THIS TURN, so nearer buildings' pre-punch barriers
// drain them against still-true depth — see
// WalkFrameEventKind.StaticParticles. Mark first so the cell's own
// meshes flush ahead of its particle submission (retail's
// per-object DrawObjCell order).
if (HasAnyOwner(records))
{
MarkIfGrown();
_events.Add(WalkFrameEvent.LandscapeCellParticles(cellId));
}
}
private static bool HasAnyOwner(in WalkFrameStaticRecords records)
{
foreach (RenderProjectionRecord record in records.Records)
{
if (record.Source.LocalEntityId != 0)
return true;
}
return false;
}
void IWalkEventSink.OnBuildingTurn(WalkBuilding building)
{
ArgumentNullException.ThrowIfNull(building);
IWalkBuildingFrameContext ctx = RequireOpenFrame();
VisitedBuildings.Add(building);
// 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).
MarkIfGrown();
_events.Add(WalkFrameEvent.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)). Mark first so this building's shell content never
// shares a replayed range with whatever the portal pass's last
// look-in flood appended (keeps every range single-stage).
MarkIfGrown();
WalkFrameStaticRecords shell = _worldData.GetBuildingShellStatics(building);
_populator.PopulateCell(
_stream, WalkDrawStage.BuildingShell, building.PositionCellId,
shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection);
// FW4 (the #132 positional invariant): the building's own shell
// emitters submit at the shell turn, after the shell content
// flushes — see WalkFrameEventKind.StaticParticles.
if (HasAnyOwner(shell))
{
MarkIfGrown();
_events.Add(WalkFrameEvent.BuildingShellParticles(building));
}
}
void IWalkEventSink.OnPunchGeometry(
WalkBuilding building, WalkPolygon polygon, int activeViewIndex)
{
ArgumentNullException.ThrowIfNull(building);
ArgumentNullException.ThrowIfNull(polygon);
RequireOpenFrame();
MarkIfGrown();
Matrix4x4 worldTransform = _worldData.GetBuildingWorldTransform(building);
_events.Add(
WalkFrameEvent.PunchFan(TransformToWorld(polygon, worldTransform), activeViewIndex));
}
void IWalkEventSink.OnInteriorFloodDrawTurn(IReadOnlyList cells)
{
ArgumentNullException.ThrowIfNull(cells);
RequireOpenFrame();
// PView::DrawCells @0x005a4840: the gated full depth clear
// (pc:432731-432732) then the exit-portal seals (pc:432785-432786) —
// both unconditional for an interior root's own flood, whether or
// not a landscape turn just ran (see this driver's type doc
// comment).
MarkIfGrown();
_events.Add(WalkFrameEvent.ClearInteriorDepth());
MarkIfGrown();
_events.Add(WalkFrameEvent.ExitSeals());
// FW4 slice 2: retain the ordered flood for the seal draw (the
// DrawExitSeals leaf runs at Replay, when Collect has long filled
// this) — see the property's own doc comment.
InteriorFloodCells.Clear();
for (int i = 0; i < cells.Count; i++)
InteriorFloodCells.Add(cells[i]);
EmitFloodTurns(WalkDrawStage.CellStatic, cells);
}
// ------------------------------------------------------------------
// Turn handlers
// ------------------------------------------------------------------
private void HandleLandscapeTurn(int activeViewCount)
{
RequireOpenFrame();
if (activeViewCount < 1)
{
throw new InvalidOperationException(
$"A Landscape turn fired with {activeViewCount} active views — "
+ "RetailFrameWalk only draws the landscape through an installed view set "
+ "(the outdoor root's full-screen default view, or an interior root's "
+ "surviving exit views, both at least 1). A zero/negative count is a "
+ "walk/driver desync (Campaign FW fail-loud rule).");
}
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).");
}
MarkIfGrown();
_events.Add(WalkFrameEvent.Sky());
_skyDrawnThisFrame = true;
// FW4 slice 6 (correcting slice 1's per-view fan): retail's
// LScape::draw draws the terrain blocks ONCE per landscape turn —
// the active views feed only the block-level visibility union
// (CheckBlocks); terrain cells are ordinary meshes and retail never
// clips those per view (pixel exactness = the depth clear + seals +
// interior repaint afterward). One terrain turn, always; the walk's
// views still own the punch fans and dynamics apertures.
_events.Add(WalkFrameEvent.TerrainSlice(0));
}
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.");
}
if (stage == WalkDrawStage.CellStatic)
{
// The interior root's OWN flood: this DC event fired at
// breakpoint-ENTRY order (matching the FW0 oracle traces), not
// retail's actual draw point — RECORD only. RetailFrameWalk.
// DrawInside hands the SAME cell list to
// OnInteriorFloodDrawTurn later, at the point retail really
// draws it (after the landscape turn, or immediately when no
// exit views survived) — see that method's doc comment.
return;
}
// Any other stage (LookInStatic) is a building's look-in flood:
// retail calls DrawCells re-entrantly there with no landscape/clear/
// seal step, so its DC event already fires at the real draw point —
// record immediately. PView::DrawCells uses two complete reverse
// loops: every EnvCell shell first, then every cell object list.
EmitFloodTurns(stage, cells);
}
private void EmitFloodTurns(WalkDrawStage stage, IReadOnlyList cells)
{
// PView::DrawCells @0x005A4840, loop 2 (005A4A00–005A4ADE):
// cell_draw_list[count - 1] down to zero, DrawEnvCell only.
for (int i = cells.Count - 1; i >= 0; i--)
{
MarkIfGrown();
_events.Add(WalkFrameEvent.CellShell(cells[i]));
}
// Loop 3 (005A4ADE–005A4B2D): restart at count - 1 and draw each
// complete object cell. Keep a stream mark per cell so the packed
// static records and its dynamic/particle tail stay at that exact
// retail turn.
for (int i = cells.Count - 1; i >= 0; i--)
EmitCellContentsTurn(stage, cells[i]);
}
private void EmitCellContentsTurn(WalkDrawStage stage, uint cellId)
{
WalkFrameStaticRecords records = _worldData.GetCellStatics(cellId);
_populator.PopulateCell(
_stream, stage, cellId, records.Records, records.TupleLandblockId,
_cameraWorldPosition, _viewProjection);
MarkIfGrown();
if (stage == WalkDrawStage.LookInStatic)
{
// Retail draws a look-in cell's complete object list at this
// re-entrant DrawCells turn. The packed dynamic route used to run
// much later at the pre-clear boundary, after nearer building
// shells, which let the cathedral's 0x112 remote player overpaint
// opaque walls. Keep animation/fade in the packed route, but replay
// it here between this cell's content and the building shell.
int routeIndex = _lookInRouteIndex++;
LookInCellTurns.Add(cellId);
LookInCells.Add(cellId);
CaptureLookInViews(cellId);
_events.Add(WalkFrameEvent.LookInDynamics(cellId, routeIndex));
}
}
private void CaptureLookInViews(uint cellId)
{
IWalkBuildingFrameContext ctx = RequireOpenFrame();
WalkCell? cell = ctx.GetVisible(cellId);
if (cell is null || cell.NumView <= 0)
{
_lookInTurns.Add(new WalkLookInTurn(
cellId, _lookInSlices.Count, 0));
return;
}
WalkPortalView portalView = cell.TopView;
int sliceStart = _lookInSlices.Count;
for (int sliceIndex = 0; sliceIndex < portalView.ViewCount; sliceIndex++)
{
WalkViewPoly poly = portalView.View.Polys[sliceIndex];
int planeStart = _lookInPlanes.Count;
for (int edge = 0; edge < poly.VertexCount; edge++)
{
_lookInPlanes.Add(
portalView.View.Vertices[poly.VertexIndex + edge].Plane);
}
_lookInSlices.Add(new WalkLookInSlice(
planeStart, poly.VertexCount));
}
_lookInTurns.Add(new WalkLookInTurn(
cellId, sliceStart, _lookInSlices.Count - sliceStart));
}
public bool SphereVisibleInLookInTurn(
int routeIndex,
in Vector3 center,
float radius)
{
if ((uint)routeIndex >= (uint)_lookInTurns.Count)
return false;
WalkLookInTurn turn = _lookInTurns[routeIndex];
for (int sliceOffset = 0; sliceOffset < turn.SliceCount; sliceOffset++)
{
WalkLookInSlice slice = _lookInSlices[turn.SliceStart + sliceOffset];
if (WalkVisibilityMath.ViewconeCheck(
center,
radius,
_lookInCyPlane,
CollectionsMarshal.AsSpan(_lookInPlanes).Slice(
slice.PlaneStart,
slice.PlaneCount)) != WalkBoundingType.Outside)
{
return true;
}
}
return false;
}
/// Campaign FW3.4a: the collect-time analogue of the old
/// immediate driver's FlushIfNonEmpty — records a
/// at the stream's current
/// length if it grew since the last mark (a no-op otherwise, exactly
/// like that method's own "empty segments submit nothing" rule). Also
/// appends the boundary to , which
/// hands to PrepareOrderedStream so a merge
/// run can never span it — see WbDrawDispatcher.BuildOrderedMergeRuns's
/// forcedBreaksAscending parameter for why that matters.
private void MarkIfGrown()
{
int count = _stream.Count;
int last = _markPositions.Count > 0 ? _markPositions[^1] : 0;
if (count == last)
return;
_markPositions.Add(count);
_events.Add(WalkFrameEvent.Mark(count));
}
private IWalkBuildingFrameContext RequireOpenFrame() =>
_ctx ?? throw new InvalidOperationException(
"WalkFrameDriver received a walk turn outside BeginFrame/EndFrame — call "
+ "BeginFrame (or Collect/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) };
}
}