1100 lines
52 KiB
C#
1100 lines
52 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Campaign FW stage FW3.2b-1: one cell's or one building's already-queried
|
||
/// static content, ready for <see cref="WalkStaticStreamPopulator"/> —
|
||
/// caller-built, never read from a retained scene (see
|
||
/// <see cref="IWalkFrameWorldData"/>'s doc comment).
|
||
/// </summary>
|
||
/// <param name="Records">Already-classified <see cref="RenderProjectionRecord"/>s
|
||
/// for this turn's cell/building, in the SAME order they must enter the walk
|
||
/// stream (never re-sorted downstream — <see cref="WalkStaticStreamPopulator"/>'s
|
||
/// own contract). Campaign FW3.4a: a segment INTO <see cref="WalkProductionWorldData"/>'s
|
||
/// per-frame arena, not a freshly allocated array — see that type's own doc
|
||
/// comment.</param>
|
||
/// <param name="TupleLandblockId">The clip-slot-resolving landblock id
|
||
/// <c>WbDrawDispatcher.ClassifyEntityForWalk</c> needs per record (FW3.2a's
|
||
/// <c>tupleLandblockId</c> parameter) — carried per-turn rather than once per
|
||
/// frame because a single frame's cells/buildings can span more than one
|
||
/// committed landblock.</param>
|
||
internal readonly record struct WalkFrameStaticRecords(
|
||
ArraySegment<RenderProjectionRecord> Records, uint TupleLandblockId)
|
||
{
|
||
public static readonly WalkFrameStaticRecords Empty =
|
||
new(ArraySegment<RenderProjectionRecord>.Empty, 0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW stage FW3.2b-1: the world-data lookups <see cref="WalkFrameDriver"/>
|
||
/// needs at each walk turn, entirely caller-built — the driver reads no
|
||
/// retained scene state of its own (mirrors <see cref="WalkStaticStreamPopulator"/>'s
|
||
/// own "reads no retained scene state itself" contract one layer up). FW3.2b-2
|
||
/// wires the real production implementation (<c>RenderSceneQuery.CopyCellStaticsTo</c>
|
||
/// / <c>CopyIndexTo</c> + <see cref="WalkBuildingRegistry"/>); this stage's
|
||
/// headless referee tests wire a synthetic fake instead.
|
||
/// </summary>
|
||
internal interface IWalkFrameWorldData
|
||
{
|
||
/// <summary>An indoor <c>PView::DrawCells</c> flood cell's (or a building
|
||
/// look-in's) static content — <c>RenderProjectionClass.IndoorCellStatic</c>.</summary>
|
||
WalkFrameStaticRecords GetCellStatics(uint cellId);
|
||
|
||
/// <summary>One visited landscape (outdoor) cell's static content —
|
||
/// <c>RenderProjectionClass.OutdoorStatic</c>, keyed by the SAME
|
||
/// <c>(landblockId & 0xFFFF0000) | (cellIndex+1)</c> id
|
||
/// <see cref="IWalkEventSink.OnLandscapeCellTurn"/> computes.</summary>
|
||
WalkFrameStaticRecords GetOutdoorStatics(uint cellId);
|
||
|
||
/// <summary>One building's own exterior shell content (<c>IsBuildingShell</c>
|
||
/// records anchored at the building's position cell).</summary>
|
||
WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building);
|
||
|
||
/// <summary>Building-local → world, for transforming a punch polygon
|
||
/// before <see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> — the
|
||
/// production implementation is <see cref="WalkBuildingRegistry.TryGetEntry"/>'s
|
||
/// <c>WorldTransform</c> (FW3.2b-2 wiring).</summary>
|
||
Matrix4x4 GetBuildingWorldTransform(WalkBuilding building);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW stage FW3.2b-1: the leaf GPU-adjacent actions
|
||
/// <see cref="WalkFrameDriver"/> calls at walk turns that have no
|
||
/// <see cref="OrderedDrawStream"/> submission path YET (sky, terrain, an
|
||
/// EnvCell shell, a portal punch fan, the interior depth clear, the exit-
|
||
/// portal seals) or that aren't a draw at all (the
|
||
/// <see cref="RetailAlphaQueue"/> barrier). Kept as its own seam — rather
|
||
/// than folding these into <see cref="WalkFrameDriver"/> directly — so the
|
||
/// FW3.2b-1 headless referee suite can wire a fake and prove turn ORDER
|
||
/// without standing up the real renderers <c>EnvCellRenderer</c>,
|
||
/// <c>TerrainModernRenderer</c>, <c>GameSky</c>, and
|
||
/// <c>PortalDepthMaskRenderer.DrawDepthFan</c> — FW3.2b-2's job.
|
||
///
|
||
/// <para>Stream submission itself (<c>WbDrawDispatcher.PrepareOrderedStream</c>/
|
||
/// <c>DrawOrderedRange</c>) is deliberately NOT part of this interface: it is
|
||
/// already real, production-tested machinery, so <see cref="WalkFrameDriver"/>
|
||
/// calls it directly rather than abstracting a method that would just
|
||
/// forward to it one layer deeper.</para>
|
||
/// </summary>
|
||
internal interface IWalkFrameLeafRenderer
|
||
{
|
||
/// <summary><c>LScape::draw</c> draws <c>GameSky</c> once per outdoor
|
||
/// walk (retail draws it once inside <c>LScape::draw</c>; the CURRENT
|
||
/// executor's per-slice call is per-slice-equals-once only for the
|
||
/// single-view outdoor case it handles today — the walk driver always
|
||
/// calls this exactly once per frame's Landscape turn).</summary>
|
||
void DrawSky();
|
||
|
||
/// <summary><c>LScape::grab_visible_cells</c>'s terrain mesh, once per
|
||
/// ACTIVE clip slice — <paramref name="sliceIndex"/> is caller-supplied
|
||
/// (<see cref="WalkFrameDriver.Collect"/>'s <c>activeTerrainSliceCount</c>)
|
||
/// since FW3.2b-1 does not wire <c>ClipFrameAssembler</c>/
|
||
/// <c>ViewconeCuller</c> (FW3.2b-2's job — see plan §FW3.2's dynamic-route
|
||
/// survival note). Terrain draws FULLY before any per-cell building/
|
||
/// outdoor-static turn in this stage's turn order — an intra-stage
|
||
/// simplification of retail's true per-cell <c>DrawLandCell</c>/
|
||
/// <c>DrawObjCell</c> interleave, recorded here rather than ported, since
|
||
/// terrain itself carries no walk event today.</summary>
|
||
void DrawTerrainSlice(int sliceIndex);
|
||
|
||
/// <summary>One committed cell's EnvCell shell —
|
||
/// <c>PView::DrawCells</c>'s <c>DrawEnvCell</c> @0x005a4abe. Retail first
|
||
/// draws ALL shells in reverse <c>cell_draw_list</c> order, then starts a
|
||
/// second reverse loop for <c>DrawObjCellForDummies</c> @0x005a4b0d.
|
||
/// The ordinary interior-root flood and every building look-in flood use
|
||
/// this same two-pass discipline.</summary>
|
||
void DrawCellShell(uint cellId);
|
||
|
||
/// <summary>One landscape cell's or building shell's static-owner
|
||
/// particle submission, at its own walk turn — see
|
||
/// <see cref="WalkFrameEventKind.StaticParticles"/> for the retail
|
||
/// positional invariant this carries (the #132 falls containment).</summary>
|
||
void DrawStaticParticles(IReadOnlySet<uint> ownerIds);
|
||
|
||
/// <summary>Draws the packed dynamic occupants of one building look-in
|
||
/// cell at that cell's OWN <c>PView::DrawCells</c> 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
|
||
/// <c>LookInObject</c> ranges.</summary>
|
||
void DrawLookInDynamics(
|
||
uint cellId,
|
||
int routeIndex,
|
||
IReadOnlySet<uint> staticParticleOwnerIds);
|
||
|
||
/// <summary><c>PView::DrawCells</c> @0x005a4840's gated full depth clear
|
||
/// (pc:432731-432732) between the outside stage and the interior root's
|
||
/// own flood — production maps this to <c>IWorldPassScope.ClearInteriorDepth</c>
|
||
/// (see that interface's own member of the same name in
|
||
/// <c>RetailPViewRenderer.cs</c>, staged there on <c>OutsideViewSlices.Length
|
||
/// > 0</c> — an ACKNOWLEDGED approximation of retail's true
|
||
/// <c>portalsDrawnCount</c> 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
|
||
/// <c>portalsDrawnCount</c> reading ever lands). Only called for an
|
||
/// INTERIOR root, never outdoors (retail has no depth clear there —
|
||
/// <c>portalsDrawnCount</c> never applies to <c>LScape::draw</c>'s own
|
||
/// top-level walk).</summary>
|
||
void ClearInteriorDepth();
|
||
|
||
/// <summary>The exit-portal seals (pc:432785-432786) — re-stamping every
|
||
/// outside-leading portal's TRUE depth right after
|
||
/// <see cref="ClearInteriorDepth"/>, 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
|
||
/// (<c>RetailPViewRenderer.DrawExitPortalMask</c>/
|
||
/// <c>PortalDepthMaskRenderer</c>) — 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
|
||
/// <c>DrawCells</c> re-entrantly with no clear/seal step) and never
|
||
/// outdoors.</summary>
|
||
void DrawExitSeals();
|
||
|
||
/// <summary><c>DrawPortalPolyInternal</c> @0x0059bc90's depth-only far-Z
|
||
/// punch fan — pass 1 of the building portal walk.
|
||
/// <paramref name="worldPolygon"/> is already transformed building-local
|
||
/// → world (<see cref="WalkFrameDriver"/> does the transform via
|
||
/// <see cref="IWalkFrameWorldData.GetBuildingWorldTransform"/> at Collect
|
||
/// time — see that type's own doc comment). The real implementation is
|
||
/// <c>PortalDepthMaskRenderer.DrawDepthFan</c> with <c>forceFarZ</c>
|
||
/// (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
|
||
/// <see cref="WalkFrameDriver"/>'s type doc comment) reaches the GPU
|
||
/// first. <paramref name="activeViewIndex"/> is the view the emitting
|
||
/// two-pass walk was pinned to (retail <c>building_view =
|
||
/// Render::portal_view_num</c> @0x0059f3bf) — production clips the fan by
|
||
/// that view's slice planes.</summary>
|
||
void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex);
|
||
|
||
/// <summary><c>RetailAlphaQueue.FlushFartherThan</c>'s <c>DrawBuilding</c>
|
||
/// barrier — retail's own call site is
|
||
/// <c>D3DPolyRender::FlushAlphaList(0f)</c> @0x0059f30b, a FLUSH-ALL, not
|
||
/// a distance-gated flush; this stage keeps the DISPATCHED
|
||
/// <c>FlushFartherThan(viewerDistanceTo(building))</c> shape (the two
|
||
/// coincide under the walk's far-to-near landscape order, since a nearer
|
||
/// emitter has not been inserted into the alpha queue yet — see
|
||
/// <c>RetailAlphaQueue.FlushFartherThan</c>'s own doc comment) and flags
|
||
/// the 0f/address detail as an FW4 adjudication candidate rather than
|
||
/// silently reinterpreting the dispatched design.</summary>
|
||
void AlphaBarrier(float viewerDistance);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW stage FW3.2b-1 test seam: an optional, diagnostic-only
|
||
/// observer of every ordered-stream range <see cref="WalkFrameDriver.Replay"/>
|
||
/// draws. Production callers pass <see langword="null"/> (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 <c>RecordingGpuDevice.Calls</c>' lower-level RHI call log.
|
||
/// </summary>
|
||
internal interface IWalkFrameDriverTrace
|
||
{
|
||
/// <summary><paramref name="stages"/> is a snapshot (never a live,
|
||
/// about-to-mutate list) of every command's <see cref="WalkDrawStage"/> 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.</summary>
|
||
void OnFlush(int commandCount, IReadOnlyList<WalkDrawStage> stages);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW3.4a: one turn Collect recorded, replayed by
|
||
/// <see cref="WalkFrameDriver.Replay"/> in the exact order Collect saw it.
|
||
/// <see cref="WalkFrameEventKind.StreamMark"/> is the collect-time analogue of
|
||
/// the old immediate driver's flush point — see <see cref="WalkFrameEventKind"/>'s
|
||
/// own doc comment for the full list and what each carries.
|
||
/// </summary>
|
||
internal enum WalkFrameEventKind : byte
|
||
{
|
||
/// <summary>The accumulated <see cref="OrderedDrawStream"/> grew since the
|
||
/// last mark and must be drawn, via <c>WbDrawDispatcher.DrawOrderedRange</c>,
|
||
/// before whatever leaf event follows. <see cref="WalkFrameEvent.IntArg"/>
|
||
/// is the stream's exclusive-end command index at the moment this event
|
||
/// was recorded.</summary>
|
||
StreamMark,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawSky"/>.</summary>
|
||
Sky,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawTerrainSlice"/> —
|
||
/// <see cref="WalkFrameEvent.IntArg"/> is the slice index.</summary>
|
||
TerrainSlice,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawCellShell"/> —
|
||
/// <see cref="WalkFrameEvent.CellId"/> is the cell.</summary>
|
||
CellShell,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> —
|
||
/// <see cref="WalkFrameEvent.Polygon"/> is the already-world-transformed
|
||
/// polygon (transformed at Collect time, exactly as the pre-FW3.4a driver
|
||
/// transformed it before its own immediate call), <see cref="WalkFrameEvent.IntArg"/>
|
||
/// is the active view index.</summary>
|
||
PunchFan,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.AlphaBarrier"/> —
|
||
/// <see cref="WalkFrameEvent.FloatArg"/> is the viewer distance, computed
|
||
/// at Collect time (the context that supplies it does not outlive Collect).</summary>
|
||
AlphaBarrier,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.ClearInteriorDepth"/>.</summary>
|
||
ClearInteriorDepth,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawExitSeals"/>.</summary>
|
||
ExitSeals,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawStaticParticles"/> —
|
||
/// ONE landscape cell's (<see cref="WalkFrameEvent.CellId"/>) or ONE
|
||
/// building shell's (<see cref="WalkFrameEvent.Building"/>) 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 <c>DrawObjCell</c> in the
|
||
/// far-to-near landscape walk, so every nearer building's pre-punch
|
||
/// alpha barrier (<c>DrawBuilding</c> @0x0059f2a0's
|
||
/// <c>FlushAlphaList</c> @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).</summary>
|
||
StaticParticles,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawLookInDynamics"/> —
|
||
/// <see cref="WalkFrameEvent.CellId"/> is the look-in cell and
|
||
/// <see cref="WalkFrameEvent.IntArg"/> is its walk-ordered packed route
|
||
/// index.</summary>
|
||
LookInDynamics,
|
||
}
|
||
|
||
/// <summary>
|
||
/// The exact portal-view cones installed at the walk's building look-in
|
||
/// <c>DrawCells</c> turns. Retail <c>RenderDeviceD3D::DrawMesh</c>
|
||
/// @0x005A0860 tests each object's drawing sphere against these views before
|
||
/// drawing the mesh whole; cell membership alone is not an admission rule.
|
||
/// </summary>
|
||
internal interface IWalkLookInViewSource
|
||
{
|
||
IReadOnlyList<uint> 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);
|
||
|
||
/// <summary>See <see cref="WalkFrameEventKind"/> 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 <c>List<WalkFrameEvent></c> —
|
||
/// only <see cref="Polygon"/> (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).</summary>
|
||
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; }
|
||
|
||
/// <summary><see cref="WalkFrameEventKind.StaticParticles"/> only: the
|
||
/// building whose shell statics' owners submit at this turn; null for a
|
||
/// landscape cell's turn (then <see cref="CellId"/> names the cell).</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW stage FW3.2b-1 — THE WALK FRAME DRIVER. Executes one full
|
||
/// static-content frame by driving <see cref="RetailFrameWalk"/> with itself
|
||
/// as the <see cref="IWalkEventSink"/>, so that GPU command-buffer order
|
||
/// equals retail's walk order (plan §FW3.2b-1's "INTERLEAVING RULE").
|
||
///
|
||
/// <para><b>Campaign FW3.4a — the ONE-walk split.</b> Before this stage, a
|
||
/// single frame ran <see cref="RetailFrameWalk"/> TWICE — once with a
|
||
/// set-collecting sink to learn the flood/visited-cell set before
|
||
/// <c>PrepareCellBatches</c>, once more through this driver to actually
|
||
/// submit — and each walk turn's stream content flushed IMMEDIATELY through
|
||
/// its own full <c>WbDrawDispatcher.SubmitOrderedStream</c> 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
|
||
/// <see cref="WalkProductionWorldData"/>'s own doc comment) and tripped the
|
||
/// campaign's ±20% stop rule. This stage collapses both: <see cref="Collect"/>
|
||
/// runs <see cref="RetailFrameWalk"/> 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
|
||
/// <see cref="WalkFrameEvent"/> 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.
|
||
/// <see cref="Replay"/> then performs the actual GPU work afterward:
|
||
/// <c>WbDrawDispatcher.PrepareOrderedStream</c> uploads the WHOLE frame's
|
||
/// stream once, and each recorded <see cref="WalkFrameEventKind.StreamMark"/>
|
||
/// becomes one cheap <c>DrawOrderedRange</c> 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.
|
||
/// <see cref="RunFrame"/> remains Collect immediately followed by Replay, for
|
||
/// callers (today: the headless referee suite) that do not need the split;
|
||
/// <c>RetailPViewRenderer</c> uses the split directly, since it must run
|
||
/// <c>PrepareCellBatches</c>/<c>BuildAndBorrow</c> BETWEEN them.</para>
|
||
///
|
||
/// <para><b>The one mark rule that reproduces the whole frame script:</b>
|
||
/// before EVERY leaf-renderer event (<see cref="WalkFrameEventKind.Sky"/>,
|
||
/// <c>TerrainSlice</c>, <c>CellShell</c>, <c>ClearInteriorDepth</c>,
|
||
/// <c>ExitSeals</c>, <c>PunchFan</c>) and before every
|
||
/// <see cref="WalkFrameEventKind.AlphaBarrier"/> event, Collect records a
|
||
/// <see cref="WalkFrameEventKind.StreamMark"/> 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
|
||
/// <see cref="IWalkEventSink.OnBuildingShellTurn"/> 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 <see cref="Replay"/>'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
|
||
/// <see cref="RetailFrameWalk.DrawBuilding"/>'s doc comment), and retail's
|
||
/// own interior-root DRAW order (landscape → clear → seals → the flood's own
|
||
/// cells — see <see cref="IWalkEventSink.OnInteriorFloodDrawTurn"/>'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.</para>
|
||
///
|
||
/// <para>Retail anchors: <c>SmartBox::RenderNormalMode</c> @0x00453aa0 (the
|
||
/// root <see cref="RetailFrameWalk.WalkFrame"/> already ports),
|
||
/// <c>RenderDeviceD3D::DrawSortCell</c> @0x0059f140 (building-before-
|
||
/// DrawObjCell per landscape cell), <c>PView::DrawCells</c> @0x005a4840
|
||
/// (the complete reverse <c>DrawEnvCell</c> loop @0x005a4a00–0x005a4ade
|
||
/// before the complete reverse <c>DrawObjCellForDummies</c> loop
|
||
/// @0x005a4ade–0x005a4b2d; <c>LScape::draw</c> 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
|
||
/// <c>DrawCells</c> entry fires before all three), <c>RenderDeviceD3D::DrawBuilding</c>
|
||
/// @0x0059f2a0 (the <c>part->gfxobj[deg_level]!=0</c> gate @0x0059f2d3
|
||
/// and the alpha-barrier → portal-pass → shell order @0x0059f30b–0x0059f345).</para>
|
||
/// </summary>
|
||
internal sealed class WalkFrameDriver : IWalkEventSink, 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<WalkFrameEvent> _events = new();
|
||
private readonly List<int> _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<uint> VisitedCells { get; } = new();
|
||
|
||
/// <summary>Building look-in cells in the exact order the walk encountered
|
||
/// their <c>DrawCells</c> turns. Duplicates are intentional: two authored
|
||
/// portal views can independently visit the same cell and therefore own
|
||
/// distinct packed route indices.</summary>
|
||
internal List<uint> LookInCellTurns { get; } = new();
|
||
|
||
private readonly List<WalkLookInTurn> _lookInTurns = new();
|
||
private readonly List<WalkLookInSlice> _lookInSlices = new();
|
||
private readonly List<WalkPlane> _lookInPlanes = new();
|
||
private WalkPlane _lookInCyPlane;
|
||
|
||
IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns;
|
||
|
||
/// <summary>The set form of <see cref="LookInCellTurns"/>, for drawn-once
|
||
/// exclusion and root-flood particle bookkeeping.</summary>
|
||
internal HashSet<uint> LookInCells { get; } = new();
|
||
|
||
/// <summary>FW4 slice 2: the interior root's ORDERED flood cell list,
|
||
/// exactly as retail's <c>PView::DrawCells</c> iterates it for the
|
||
/// exit-portal seals (pc:432785-432786) — captured at
|
||
/// <see cref="IWalkEventSink.OnInteriorFloodDrawTurn"/>. 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).</summary>
|
||
internal List<uint> InteriorFloodCells { get; } = new();
|
||
|
||
// Replay scratch for StaticParticles events (sequential replay — one
|
||
// reused set is safe).
|
||
private readonly HashSet<uint> _staticParticleOwnerScratch = new();
|
||
|
||
private static void UnionOwners(
|
||
in WalkFrameStaticRecords records, HashSet<uint> destination)
|
||
{
|
||
foreach (RenderProjectionRecord record in records.Records)
|
||
{
|
||
if (record.Source.LocalEntityId != 0)
|
||
destination.Add(record.Source.LocalEntityId);
|
||
}
|
||
}
|
||
|
||
internal List<WalkBuilding> VisitedBuildings { get; } = new();
|
||
|
||
internal HashSet<uint> 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Drives one complete frame at retail's root (<c>SmartBox::RenderNormalMode</c>):
|
||
/// <see cref="Collect"/> immediately followed by <see cref="Replay"/>. Kept
|
||
/// for callers that don't need the split (today: the headless referee
|
||
/// suite) — <c>RetailPViewRenderer</c> calls the pair directly, since it
|
||
/// must run other frame work BETWEEN them (plan §FW3.4a).
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW3.4a Phase 1 — THE ONE WALK. Drives
|
||
/// <see cref="RetailFrameWalk.WalkFrame"/> with this driver as its sink,
|
||
/// sandwiched between <see cref="BeginFrame"/>/<see cref="EndFrame"/>,
|
||
/// performing NO GPU work: see this type's own doc comment.
|
||
/// </summary>
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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
|
||
/// <see cref="RetailFrameWalk.DrawBuilding"/> or
|
||
/// <see cref="RetailFrameWalk.DrawLandscape"/> call) and wants this
|
||
/// driver's turn handling without going through the top-level root.
|
||
/// <see cref="Collect"/> is implemented in terms of this pair. Performs no
|
||
/// GPU work — see this type's own doc comment.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>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 — <see cref="Replay"/> 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.</summary>
|
||
internal void EndFrame()
|
||
{
|
||
try
|
||
{
|
||
MarkIfGrown();
|
||
}
|
||
finally
|
||
{
|
||
_ctx = null;
|
||
_readyToReplay = true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW3.4a Phase 2. Requires a completed Collect (an
|
||
/// <see cref="EndFrame"/> 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
|
||
/// <see cref="WalkFrameEventKind.StreamMark"/> issues one
|
||
/// <c>WbDrawDispatcher.DrawOrderedRange</c> call over the segment it
|
||
/// closes off; every other event kind issues its corresponding
|
||
/// <see cref="IWalkFrameLeafRenderer"/> 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.
|
||
/// </summary>
|
||
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<uint> 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<uint> cells)
|
||
{
|
||
RequireOpenFrame();
|
||
if (_currentDcStage is not { } stage)
|
||
{
|
||
throw new InvalidOperationException(
|
||
"A DrawCells turn fired before any DrawInside or Building turn established "
|
||
+ "which stage its cells belong to — a walk/driver desync (Campaign FW3.2b-1 "
|
||
+ "fail-loud rule): RetailFrameWalk only ever emits DrawCells after DrawInside "
|
||
+ "(the interior root's own flood) or after a building's look-in portal pass.");
|
||
}
|
||
|
||
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<uint> 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;
|
||
}
|
||
|
||
/// <summary>Campaign FW3.4a: the collect-time analogue of the old
|
||
/// immediate driver's <c>FlushIfNonEmpty</c> — records a
|
||
/// <see cref="WalkFrameEventKind.StreamMark"/> 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 <see cref="_markPositions"/>, which
|
||
/// <see cref="Replay"/> hands to <c>PrepareOrderedStream</c> so a merge
|
||
/// run can never span it — see <c>WbDrawDispatcher.BuildOrderedMergeRuns</c>'s
|
||
/// <c>forcedBreaksAscending</c> parameter for why that matters.</summary>
|
||
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.");
|
||
|
||
/// <summary><c>ConstructBuildingView</c>'s polygon is building-local; the
|
||
/// punch fan needs world space. Vertices transform directly; the plane
|
||
/// normal uses <see cref="Vector3.TransformNormal"/> (correct for the
|
||
/// rigid, shear-free placements <see cref="WalkBuildingFactory"/> and
|
||
/// <see cref="WalkCellFactory"/> build) and <c>D</c> is rederived from
|
||
/// the transformed normal and the first transformed vertex — the SAME
|
||
/// construction <see cref="WalkCellFactory"/>/<see cref="WalkBuildingFactory"/>
|
||
/// already use for their own polygons (<c>Plane = new WalkPlane(normal,
|
||
/// -Vector3.Dot(normal, vertices[0]))</c>).</summary>
|
||
private static WalkPolygon TransformToWorld(WalkPolygon local, Matrix4x4 worldTransform)
|
||
{
|
||
var vertices = new Vector3[local.Vertices.Length];
|
||
for (int i = 0; i < vertices.Length; i++)
|
||
vertices[i] = Vector3.Transform(local.Vertices[i], worldTransform);
|
||
|
||
Vector3 normal = local.Vertices.Length > 0
|
||
? Vector3.Normalize(Vector3.TransformNormal(local.Plane.Normal, worldTransform))
|
||
: Vector3.Zero;
|
||
float d = vertices.Length > 0 ? -Vector3.Dot(normal, vertices[0]) : 0f;
|
||
|
||
return new WalkPolygon { Vertices = vertices, Plane = new WalkPlane(normal, d) };
|
||
}
|
||
}
|