acdream/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
Erik 4d1110812c docs(overhaul): correct S5 c1 review comments
Remove the deleted outdoor-fallback claim, describe the visible-cell union as diagnostics rather than a shared admission answer, and distinguish the landscape particle, resident-light, and future shadow consumers.

Correct ParticleSystem complexity prose: only the SortedSet indexes have logarithmic lifecycle edges; the view list uses binary-search insertion with linear shift and linear removal.

Focused verification: Core ParticleSystemTests 47/47; App visibility/terrain/frame/renderer 38/38; AP-116 settings/live-object 3/3; git diff --check clean. No behavior, tests, register, or architecture change.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-04 17:09:57 +02:00

2178 lines
108 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, retaining authored traversal/registration
/// order. For ordinary cells, <see cref="WalkStaticStreamPopulator"/> builds
/// the combined static/dynamic part list and performs retail's per-cell CYpt
/// sort; these records are not final stream order. 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>The exact combined ordinary-object membership for one indoor
/// cell, preserving the registry's single static/dynamic insertion
/// sequence for S4-c3a's per-part stable CYpt sort.</summary>
WalkFrameStaticRecords GetCellObjects(uint cellId);
/// <summary>The exact combined ordinary-object membership for one
/// landscape cell; see <see cref="GetCellObjects"/>.</summary>
WalkFrameStaticRecords GetOutdoorObjects(uint cellId);
/// <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>The live dynamic occupants of one indoor cell. These records
/// enter the ordered stream at that cell's own object-list turn; a
/// building look-in supplies its exact installed portal view for the
/// per-part <c>viewconeCheck</c>.</summary>
WalkFrameStaticRecords GetCellDynamics(uint cellId);
/// <summary>One visited landscape (outdoor) cell's static content —
/// <c>RenderProjectionClass.OutdoorStatic</c>, keyed by the SAME
/// <c>(landblockId &amp; 0xFFFF0000) | (cellIndex+1)</c> id
/// <see cref="IWalkEventSink.OnLandscapeCellTurn"/> computes.</summary>
WalkFrameStaticRecords GetOutdoorStatics(uint cellId);
/// <summary>One visited landscape cell's live dynamic occupants. The
/// walk draws these at the same <c>DrawObjCell</c> turn as its statics,
/// through the landscape's currently installed view set.</summary>
WalkFrameStaticRecords GetOutdoorDynamics(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>S3 chunk 3 (§9.2 B1/B2), fix round 1 (F2), round 2 (F10):
/// retail <c>RenderDeviceD3D::DrawLandCell</c> @0x0059f120 — one or more
/// admitted LOD cells, possibly spanning several landblocks, submitted
/// together as ONE indirect draw. <see cref="WalkFrameDriver.Replay"/>
/// keeps a PENDING batch that <see cref="WalkFrameEventKind.LandCell"/>
/// events append to and flushes (calls this once) immediately before any
/// event whose GPU order against terrain is OBSERVABLE — see that
/// method's own doc comment for the exact flush-point list and its
/// pipeline-state citation. A <see cref="WalkFrameEventKind.StreamMark"/>
/// is NOT one of them (F10: the ordered stream it draws is opaque-only,
/// depth test AND write on, no blending); the F2 particle-turn exception
/// still holds (a genuinely empty <see cref="PrepareStaticParticles"/>/<see
/// cref="PrepareCellParticles"/> turn neither submits nor flushes). This is
/// order-preserving by construction: the batch's GPU submission point is
/// always the SAME point the unbatched terrain draws would have occupied
/// (immediately before the next real submission), so pixel output is
/// identical to submitting each entry as its own draw. <paramref
/// name="cells"/> carries (landblockId, side, cellIndex) triples in
/// event (draw) order — the R3 index-run arithmetic (contiguous runs
/// inside each entry's own landblock's 384-index slot,
/// <c>TerrainModernRenderer.AppendCellIndexRuns</c>) runs per entry.
/// Draws UNCLIPPED for both root kinds: retail never view-clips terrain
/// (<c>LScape::draw</c> draws whole blocks; the exit-seal/interior-
/// repaint turn owns aperture exactness afterward, S3 §9.1 R4/R5).</summary>
void DrawLandCellBatch(
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells);
/// <summary>S3 chunk 3 fix round 1 (F2): whether <paramref
/// name="cellId"/> has any renderable emitter right now — production
/// wires this to <c>ParticleSystem.HasRenderableEmittersInCell</c> with
/// the SAME <c>ParticleRenderPass.Scene</c> pass both <see
/// cref="PrepareStaticParticles"/> and <see cref="PrepareCellParticles"/>
/// prepare through. <see cref="WalkFrameDriver.Replay"/> asks this BEFORE
/// flushing the pending terrain batch (or preparing either particle leaf)
/// so a genuinely empty particle turn submits nothing and does not
/// force an otherwise-unneeded terrain flush.</summary>
bool HasRenderableEmittersInCell(uint cellId);
/// <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. Retail's runtime <c>use_built_mesh</c>
/// branch submits the complete constructed shell; the cell's drawn stamp,
/// portal depth writes, and later depth-tested repaint own visibility.</summary>
void DrawCellShell(uint cellId);
/// <summary>One landscape (land) cell's own-cell particle emitters, at
/// its own walk turn — see <see cref="WalkFrameEventKind.StaticParticles"/>
/// for the retail positional invariant this carries (the #132 falls
/// containment). Retail <c>CPhysicsObj::add_particle_shadow_to_cell</c>
/// (0x00514a70) draws an emitter at its own current cell's object turn
/// regardless of its attached owner's registry membership, so this is a
/// cell lookup, not an owner union.</summary>
ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareStaticParticles(uint cellId);
/// <summary>Submits one indoor cell's own-cell particle emitters at that
/// cell's own object-list turn. Meshes have already entered the ordered
/// stream before this leaf event.</summary>
ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareCellParticles(uint cellId);
/// <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>). The driver calls this leaf ONLY when
/// <see cref="WalkFrameDriver.PortalsDrawnCount"/> was nonzero at the
/// read-then-zero decision point (S3 §8.1 R4: retail's
/// <c>portalsDrawnCount</c>, read-then-zeroed @0x005a489c-0x005a489e —
/// <c>forceClear</c> never writes in the pseudo-C, so the clear fires
/// iff the counter was nonzero). 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) and never for a building look-in (R1: those call
/// <c>DrawCells</c> re-entrantly with <c>ov==0</c>, which never reaches
/// this leaf at all).</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"/> (when it ran) 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. Returns the
/// number of seal polygons actually submitted this turn (S3 §8.1 R4/§8.2
/// B2: retail's <c>D3DPolyRender::portalsDrawnCount</c> @0x008719b4
/// increments once per <c>DrawPortalPolyInternal</c> call with its
/// second argument FALSE — exactly the exit-seal calls, never punch
/// fans) — the driver adds the returned count to
/// <see cref="WalkFrameDriver.PortalsDrawnCount"/>, which the NEXT
/// <c>ov&gt;0</c> interior-root flood's <see cref="ClearInteriorDepth"/>
/// decision reads. 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>
int DrawExitSeals();
/// <summary>Retail <c>D3DPolyRender::FlushAlphaList(0f)</c> @0x005a4872
/// plus the pre-clear dynamics hook (<c>RetailPViewRenderer</c>'s
/// <c>FlushWalkLandscape</c>) — the first action inside
/// <c>if (outside_view.view_count &gt; 0)</c> at the interior root's own
/// flood turn (S3 §8.1 R3), strictly before the device-stamp advance and
/// the gated depth clear. Distinct from <see cref="AlphaBarrier"/> (a
/// BUILDING turn's own alpha barrier, @0x0059f30b) even though both map
/// to the same underlying flush call — kept as a separate leaf member so
/// a driver trace can tell the two turns apart. Only called for an
/// INTERIOR root's own flood with a surviving exit view (<c>ov&gt;0</c>),
/// never outdoors and never for a building look-in.</summary>
void FlushLandscape();
/// <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>Retail <c>D3DPolyRender::FlushAlphaList(0f)</c>
/// @0x0059f30b. The walk is positional: only farther cells have submitted
/// alpha when a building turn fires, so this is a full drain with no
/// synthetic viewer-distance threshold.</summary>
void AlphaBarrier();
/// <summary>S4-c2: retail <c>RenderDeviceD3D::DrawBlock</c> @0x005a17c0's
/// (per-land-cell loop head @0x005a18d0) own <c>FlushAlphaList(::flush)</c>
/// @0x005a1a07 — the
/// immutable global 0.75f pressure valve, inert below 2250 entries in
/// either list (a no-op the overwhelming majority of the time at
/// today's scene complexity) but pinned exact by the capacity/threshold
/// tests. Fires once per admitted land-block cell, at the SAME turn
/// <see cref="IWalkEventSink.OnSortCellExit"/> records it (after that
/// cell's object-list turn, before the next cell's land turn).</summary>
void FlushSortCellExit();
}
/// <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>Submits every retained translucent walk batch through
/// <see cref="WbDrawDispatcher.SubmitWalkAlphaInstance"/> up to the
/// exclusive-end index in <see cref="WalkFrameEvent.IntArg"/>. This is a
/// replay-time event: collecting the complete frame must not make alpha
/// from a later/near cell visible to an earlier building barrier.</summary>
AlphaSubmitMark,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawSky"/>.</summary>
Sky,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawLandCellBatch"/> —
/// one admitted land cell's terrain (retail <c>DrawLandCell</c>
/// @0x0059f120): <see cref="WalkFrameEvent.CellId"/> is the landblock,
/// <see cref="WalkFrameEvent.IntArg"/> packs <c>side &lt;&lt; 8 | cellIndex</c>.
/// <see cref="Replay"/> does NOT draw it immediately: it appends the
/// cell to ONE pending terrain batch that may span landblocks and
/// flushes only at a flush point whose GPU order against terrain is
/// OBSERVABLE — <see cref="PunchFan"/>, <see cref="AlphaBarrier"/>,
/// <see cref="LandscapeFlush"/>, <see cref="ClearInteriorDepth"/>,
/// <see cref="ExitSeals"/>, <see cref="Sky"/>, <see cref="CellShell"/>,
/// a particle turn whose cell has a renderable emitter, or the end of
/// Replay (S3 chunk 3 fix round 2, §9.7 F10; see <see cref="Replay"/>'s
/// own doc comment for the full citation). A <see cref="StreamMark"/> is
/// deliberately NOT a flush point: the ordered stream it draws holds
/// ONLY opaque batches, depth test AND write on, no blending, so opaque
/// terrain and opaque statics resolve identically in either submission
/// order. A particle turn whose cell has no renderable emitter submits
/// nothing and keeps the batch open (fix round 1, F2). The resolved image is therefore identical to replaying each cell
/// alone: every event whose GPU order against opaque terrain is
/// observable still flushes first (§9.7 F10), and opaque-vs-opaque
/// order is depth-resolved.</summary>
LandCell,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawCellShell"/> —
/// <see cref="WalkFrameEvent.CellId"/> is the cell. Retail's
/// <c>DrawEnvCell</c> marks the EnvCell drawn before submitting its built
/// mesh, so the shell is whole and frame-deduplicated; portal-view slices
/// are not shell geometry clips.</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.FlushLandscape"/>.</summary>
LandscapeFlush,
/// <summary><see cref="IWalkFrameLeafRenderer.ClearInteriorDepth"/>.</summary>
ClearInteriorDepth,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawExitSeals"/>.</summary>
ExitSeals,
/// <summary><see cref="IWalkFrameLeafRenderer.PrepareStaticParticles"/> —
/// ONE landscape (land) cell's (<see cref="WalkFrameEvent.CellId"/>)
/// own-cell particle emitters, 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). A building's own shell fires
/// no event of this kind any more — its interior emitters live in their
/// own EnvCells and draw at those cells' own <see cref="CellParticles"/>
/// turns (Campaign OVERHAUL S2 chunk 6, retail
/// <c>CPhysicsObj::add_particle_shadow_to_cell</c> 0x00514a70: an emitter
/// owns one shadow in its OWN current cell, never its parent's).</summary>
StaticParticles,
/// <summary><see cref="IWalkFrameLeafRenderer.PrepareCellParticles"/> —
/// <see cref="WalkFrameEvent.CellId"/> is the cell. Fires at EVERY
/// visited interior cell's own object-list turn regardless of whether
/// that cell has any visible static/dynamic owner record: an emitter
/// draws at its own cell's turn independent of its attached owner's
/// registry membership (a hidden/suspended owner's emitter still shows —
/// Campaign OVERHAUL S2 chunk 6, the portal-haze fix).</summary>
CellParticles,
/// <summary>S4-c2: <see cref="IWalkFrameLeafRenderer.FlushSortCellExit"/> —
/// retail <c>RenderDeviceD3D::DrawBlock</c> @0x005a17c0's (per-land-cell
/// loop head @0x005a18d0) own <c>FlushAlphaList(0.75f)</c>.</summary>
SortCellExit,
}
/// <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; }
/// <summary>
/// Campaign OVERHAUL S3 chunk 4 (§10.2): retail's real question — does
/// ANY active view slice for this DrawObjCell turn admit the part's
/// drawing sphere (<c>RenderDeviceD3D::DrawMesh</c> @0x005A0860's
/// per-view <c>viewconeCheck</c> loop)? Once any view admits, retail
/// submits the WHOLE constructed mesh once
/// (<c>DrawMeshInternal</c> @0x0059F360) — the portal polygon is never
/// forwarded as a GPU mesh clip, so this is a pure Boolean, never a
/// per-view clip-slot list (the deleted <c>VisibleClipSlotsInLookInTurn</c>
/// always collapsed to that same Boolean one call site up anyway).
/// <paramref name="testSphere"/> false asks "does this route have any
/// admitted view at all", ignoring the sphere geometry — retail's own
/// no-authored-sphere case, where there is nothing to sphere-test.
/// </summary>
bool SphereVisibleInLookInTurn(
int routeIndex,
in Vector3 center,
float radius,
bool testSphere = true);
/// <summary>Output-only description of the exact CY/edge-plane distances
/// used by one DrawObjCell turn. Kept behind the existing Facility probe;
/// production admission never consumes this text.</summary>
string DescribeLookInTurn(
int routeIndex,
in Vector3 center,
float radius) => "unavailable";
}
internal readonly record struct WalkLookInSlice(
int PlaneStart,
int PlaneCount,
uint ClipSlot);
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&lt;WalkFrameEvent&gt;</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)
{
Kind = kind;
IntArg = intArg;
CellId = cellId;
FloatArg = floatArg;
Polygon = polygon;
}
internal WalkFrameEventKind Kind { get; }
internal int IntArg { get; }
internal uint CellId { get; }
internal float FloatArg { get; }
internal WalkPolygon? Polygon { get; }
internal static WalkFrameEvent Mark(int exclusiveEnd) =>
new(WalkFrameEventKind.StreamMark, exclusiveEnd, 0, 0f, null);
internal static WalkFrameEvent AlphaSubmitMark(int exclusiveEnd) =>
new(WalkFrameEventKind.AlphaSubmitMark, exclusiveEnd, 0, 0f, null);
internal static WalkFrameEvent Sky() =>
new(WalkFrameEventKind.Sky, 0, 0, 0f, null);
internal static WalkFrameEvent LandCell(uint landblockId, int sideCellCount, int cellIndex) =>
new(WalkFrameEventKind.LandCell, (sideCellCount << 8) | cellIndex, landblockId, 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() =>
new(WalkFrameEventKind.AlphaBarrier, 0, 0, 0f, null);
internal static WalkFrameEvent LandscapeCellParticles(
uint cellId, int alphaExclusiveEnd, bool includeParticles) =>
new(
WalkFrameEventKind.StaticParticles,
alphaExclusiveEnd,
cellId,
includeParticles ? 1f : 0f,
null);
internal static WalkFrameEvent CellParticles(
uint cellId, int alphaExclusiveEnd, bool includeParticles) =>
new(
WalkFrameEventKind.CellParticles,
alphaExclusiveEnd,
cellId,
includeParticles ? 1f : 0f,
null);
internal static WalkFrameEvent LandscapeFlush() =>
new(WalkFrameEventKind.LandscapeFlush, 0, 0, 0f, null);
internal static WalkFrameEvent ClearInteriorDepth() =>
new(WalkFrameEventKind.ClearInteriorDepth, 0, 0, 0f, null);
internal static WalkFrameEvent ExitSeals() =>
new(WalkFrameEventKind.ExitSeals, 0, 0, 0f, null);
internal static WalkFrameEvent SortCellExit() =>
new(WalkFrameEventKind.SortCellExit, 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>LandCell</c>, <c>CellShell</c>, <c>LandscapeFlush</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 → flush/stamp/[gated clear]/seals
/// → the flood's own cells, the middle four steps ALL gated on
/// <c>outside_view.view_count &gt; 0</c> — 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)]
/// [landscape flush] [gated interior depth clear] [exit-portal seals] — the
/// landscape and those three only when <c>outside_view.view_count &gt; 0</c>
/// (S3 §8.1 R3) — then [the interior root's own flood cells] unconditionally,
/// 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 @0x005a4a000x005a4ade
/// before the complete reverse <c>DrawObjCellForDummies</c> loop
/// @0x005a4ade0x005a4b2d; <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-&gt;gfxobj[deg_level]!=0</c> gate @0x0059f2d3
/// and the alpha-barrier → portal-pass → shell order @0x0059f30b0x0059f345).</para>
/// </summary>
internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
{
private readonly WbDrawDispatcher _dispatcher;
private readonly WalkStaticStreamPopulator _populator;
private IWalkFrameLeafRenderer _leafRenderer;
private readonly IWalkFrameWorldData _worldData;
private readonly IWalkFrameDriverTrace? _trace;
private ClipFrame? _clipFrame;
private readonly OrderedDrawStream _stream = new();
private readonly List<WalkFrameEvent> _events = new();
private readonly List<int> _markPositions = new();
private readonly List<WbDrawDispatcher.WalkClassifiedBatch> _alphaSubmissions = new();
private int _alphaSubmitMark;
// 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 readonly List<int> _floodViewRouteScratch = new();
private WalkPlane _lookInCyPlane;
/// <summary>S3 chunk 4 fix round 1 (K2): set exactly when <see
/// cref="IWalkEventSink.OnWeatherTurn"/> fires this frame — i.e. a
/// Landscape turn actually ran (retail's <c>RetailFrameWalk.DrawLandscape</c>
/// only reaches that call for an outdoor root, or an interior root whose
/// flood still has a surviving exit view) AND <c>ctx.WeatherGateOpen</c>
/// held. This is "the walk's own predicate" the S3 chunk-4 fix round 1
/// unifies the transcript print and the Replay-time weather draw
/// on — before this, the print's own gate (<c>ctx.WeatherGateOpen</c>
/// alone) and the draw's gate (<c>clipAssembly.OutsideViewSlices.Length
/// &gt; 0</c>, re-derived independently at Replay) could diverge on an
/// interior root whose landscape turn ran but whose reassembled outside-
/// view slices ended up empty, letting the transcript report a weather
/// turn the frame never actually drew. Reset every Collect
/// (<see cref="BeginFrame"/>/<see cref="AbortFrame"/>); read at Replay
/// by <see cref="AcDream.App.Rendering.RetailPViewRenderer.DrawLandscapeDynamicsPhase"/>.</summary>
internal bool WeatherTurnFired { get; private set; }
/// <summary>S3 chunk 3 (§9.2 B2), fix round 1 (F2), round 2 (F10):
/// <see cref="Replay"/>'s PENDING terrain batch — every
/// <see cref="WalkFrameEventKind.LandCell"/> event since the last flush,
/// across however many landblocks and however many <see cref="WalkFrameEventKind.StreamMark"/>
/// events (F10: not a flush point), in event order. Cleared at the start
/// of each <see cref="Replay"/> call ("frame start") and by every flush;
/// NOT cleared between events, since it is the whole point of the
/// deferred-batching rule.</summary>
private readonly List<(uint LandblockId, int SideCellCount, int CellIndex)> _pendingTerrainBatch = new();
private readonly HashSet<uint> _cellShellsDrawnThisFrame = new();
// Chunk 6 review F1: one particle turn per cell per render stamp (see EmitCellContentsTurn).
private readonly HashSet<uint> _cellParticleTurnsDrawnThisFrame = new();
/// <summary>S3 chunk 2 (§8.2 B2): retail's <c>D3DPolyRender::portalsDrawnCount</c>
/// (uint16 @0x008719b4) — retained ACROSS frames (this driver is itself
/// retained by <c>RetailPViewRenderer</c>), never cleared by
/// <see cref="BeginFrame"/>/<see cref="AbortFrame"/>/<see cref="EndFrame"/>/
/// <see cref="Replay"/>. Read-then-zeroed at every <c>ov&gt;0</c>
/// interior-root flood's clear decision (<see cref="OnInteriorFloodDrawTurn"/>
/// implementation, S3 §8.1 R4); incremented at <see cref="Replay"/> by
/// the count <see cref="IWalkFrameLeafRenderer.DrawExitSeals"/> returns
/// for THIS frame's own exit-seal turn, which the NEXT <c>ov&gt;0</c>
/// frame's read will see. A driver whose interior floods never reach an
/// exit portal keeps this at zero forever — the gated clear never fires
/// for it, matching retail exactly (no seals submitted, no depth ever
/// needed clearing). Retail consumes and re-arms inside ONE synchronous
/// <c>DrawCells</c>; here the consume is at Collect and the re-arm at
/// Replay, so a frame that Collects but never Replays (an exception path
/// that already failed the frame) leaves the next ov&gt;0 frame without a
/// clear — no retail analogue, no production path.</summary>
internal int PortalsDrawnCount;
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();
/// <summary>Number of exact walk-owned portal-view slices that retail's
/// <c>CEnvCell::setup_view</c> installs while sealing one entry in
/// <see cref="InteriorFloodCells"/>. The matching route was captured at
/// that cell's shell turn and remains valid through synchronous replay.</summary>
internal int InteriorFloodViewSliceCountAt(int floodCellIndex)
{
int routeIndex = InteriorFloodViewRouteAt(floodCellIndex);
return _lookInTurns[routeIndex].SliceCount;
}
/// <summary>Borrows the clip-space half-planes for one exact walk-owned
/// portal-view slice. An empty span is the legitimate no-clip slot, not a
/// fallback to another visibility structure.</summary>
internal ReadOnlySpan<Vector4> InteriorFloodViewClipPlanesAt(
int floodCellIndex,
int sliceOffset)
{
int routeIndex = InteriorFloodViewRouteAt(floodCellIndex);
WalkLookInTurn turn = _lookInTurns[routeIndex];
if ((uint)sliceOffset >= (uint)turn.SliceCount)
throw new ArgumentOutOfRangeException(nameof(sliceOffset));
WalkLookInSlice slice = _lookInSlices[turn.SliceStart + sliceOffset];
if (_clipFrame is null)
return ReadOnlySpan<Vector4>.Empty;
return _clipFrame.GetSlotPlanes(slice.ClipSlot);
}
private int InteriorFloodViewRouteAt(int floodCellIndex)
{
if ((uint)floodCellIndex >= (uint)InteriorFloodCells.Count
|| (uint)floodCellIndex >= (uint)_floodViewRouteScratch.Count)
{
throw new ArgumentOutOfRangeException(nameof(floodCellIndex));
}
int routeIndex = _floodViewRouteScratch[floodCellIndex];
if ((uint)routeIndex >= (uint)_lookInTurns.Count)
{
throw new InvalidOperationException(
$"Interior flood cell 0x{InteriorFloodCells[floodCellIndex]:X8} "
+ "has no captured portal-view route.");
}
return routeIndex;
}
/// <summary>Test-only accessor (S3 chunk 4 fix round 1, K7): the packed
/// GPU clip slot one look-in route's slice was assigned by <see
/// cref="CaptureViews"/>'s call to <see cref="AppendClipSlot"/> — the
/// SAME value <see cref="InteriorFloodViewClipPlanesAt"/> resolves
/// through <c>_clipFrame.GetSlotPlanes(slice.ClipSlot)</c> for the root-
/// flood case. Restores the "a real look-in slice never reuses the
/// reserved no-clip slot 0" pin the deleted <c>VisibleClipSlotsInLookInTurn</c>
/// used to prove. <paramref name="routeIndex"/> indexes <c>_lookInTurns</c>
/// directly, matching <see cref="SphereVisibleInLookInTurn"/>'s own
/// parameter (not the flood-cell indirection <see
/// cref="InteriorFloodViewClipPlanesAt"/> goes through).</summary>
internal uint LookInSliceClipSlotAt(int routeIndex, int sliceOffset = 0)
{
WalkLookInTurn turn = _lookInTurns[routeIndex];
if ((uint)sliceOffset >= (uint)turn.SliceCount)
throw new ArgumentOutOfRangeException(nameof(sliceOffset));
return _lookInSlices[turn.SliceStart + sliceOffset].ClipSlot;
}
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;
/// <summary>Frame-scoped fail-loud guard (replaces the deleted
/// <c>_skyDrawnThisFrame</c> proxy — S3 chunk 2): counts this frame's
/// <see cref="WalkEventKind.Landscape"/> turns so a second one throws
/// (see <see cref="HandleLandscapeTurn"/>) rather than double-drawing
/// sky/terrain. Retail's own gate — the interior-root flood's
/// landscape/flush/stamp/clear/seal turn — now reads the real
/// <c>outside_view.view_count</c> the sink receives, not this counter.</summary>
private int _landscapeTurnsThisFrame;
private WalkDrawStage? _currentDcStage;
private bool _readyToReplay;
private int _cellViewRouteIndex;
private int _landscapeViewRouteIndex;
/// <summary>S3 chunk 1 (§11.2 B1): the "F n" transcript frame counter —
/// monotonic across this driver's whole lifetime (never reset by
/// BeginFrame/AbortFrame/EndFrame), matching a live cdb capture's own
/// per-process frame counter. Only ever incremented when the transcript
/// flag is on, so it costs nothing while off.</summary>
private int _transcriptFrameNumber;
internal WalkFrameDriver(
WbDrawDispatcher dispatcher,
IWalkFrameLeafRenderer leafRenderer,
IWalkFrameWorldData worldData,
IWalkFrameDriverTrace? trace = null,
ClipFrame? clipFrame = null)
{
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_leafRenderer = leafRenderer ?? throw new ArgumentNullException(nameof(leafRenderer));
_worldData = worldData ?? throw new ArgumentNullException(nameof(worldData));
_trace = trace;
_clipFrame = clipFrame;
_populator = new WalkStaticStreamPopulator(dispatcher);
}
/// <summary>
/// FW6 allocation closeout: reuse this driver's large event/view/stream
/// scratch across production frames while rebinding the frame-local leaf
/// and clip destination. A prior failed frame is discarded here so the
/// renderer's report-and-continue policy cannot poison the next frame.
/// </summary>
internal void RebindFrame(
IWalkFrameLeafRenderer leafRenderer,
ClipFrame? clipFrame)
{
AbortFrame();
_leafRenderer = leafRenderer
?? throw new ArgumentNullException(nameof(leafRenderer));
_clipFrame = clipFrame;
}
/// <summary>Discard only transient frame state; retained capacities stay
/// available for the next frame.</summary>
internal void AbortFrame()
{
_ctx = null;
_viewProjection = default;
_cameraWorldPosition = default;
_landscapeTurnsThisFrame = 0;
_currentDcStage = null;
_readyToReplay = false;
WeatherTurnFired = false;
_stream.Reset();
_events.Clear();
_markPositions.Clear();
_alphaSubmissions.Clear();
_pendingTerrainBatch.Clear();
_alphaSubmitMark = 0;
VisitedCells.Clear();
LookInCellTurns.Clear();
_lookInTurns.Clear();
_lookInSlices.Clear();
_lookInPlanes.Clear();
_floodViewRouteScratch.Clear();
_dispatcher.EndWalkPartFrame();
_cellShellsDrawnThisFrame.Clear();
_cellParticleTurnsDrawnThisFrame.Clear();
_lookInCyPlane = default;
LookInCells.Clear();
VisitedBuildings.Clear();
VisitedLandscapeCellIds.Clear();
InteriorFloodCells.Clear();
_cellViewRouteIndex = 0;
_landscapeViewRouteIndex = -1;
}
/// <summary>
/// Copies the walk's diagnostic union: EnvCells reached by interior
/// floods/look-ins plus outdoor land cells visited by the landscape walk.
/// The two families remain separately retained because EnvCells are shell
/// preparation input while only the landscape half feeds land-cell particle
/// eligibility; EnvCell particle eligibility is the constant-true virtual.
/// Point lights use the resident registry, and directional-shadow use of the
/// landscape product remains owned by S5-c2. No admission path consumes this
/// union as a complete <c>CObjCell::IsInView</c> answer.
/// </summary>
internal void CopyVisibleCellsTo(HashSet<uint> destination)
{
ArgumentNullException.ThrowIfNull(destination);
if (ReferenceEquals(destination, VisitedCells)
|| ReferenceEquals(destination, VisitedLandscapeCellIds))
{
throw new ArgumentException(
"The visible-cell destination cannot alias a walk source set.",
nameof(destination));
}
destination.Clear();
destination.UnionWith(VisitedCells);
destination.UnionWith(VisitedLandscapeCellIds);
}
/// <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);
// S3 chunk 1 (§11.2 B1): the frame-root "F n" + "P …" print — AFTER
// BeginFrame (a scratch reset, not an admission decision), BEFORE
// the walk itself runs, matching retail's SmartBox::RenderNormalMode
// @0x00453aa0 breakpoint (entered once per frame, before either the
// outdoor or the indoor branch). Every cost here (the counter
// increment, the landblock-local subtraction) sits inside the flag
// check, so an ordinary launch pays nothing.
if (AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled)
{
_transcriptFrameNumber++;
WalkTranscriptDump.PrintFrameRoot(
_transcriptFrameNumber,
cameraCellId,
cameraWorldPosition - new Vector3(
landscape.ViewerWorldOriginX, landscape.ViewerWorldOriginY, 0f),
ctx.CyPlane.Normal);
}
try
{
walk.WalkFrame(cameraCellId, cameraCell, landscape, ctx, this);
EndFrame();
}
catch
{
AbortFrame();
throw;
}
}
/// <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.");
}
_dispatcher.BeginWalkPartFrame();
_ctx = ctx;
_viewProjection = viewProjection;
_cameraWorldPosition = cameraWorldPosition;
_landscapeTurnsThisFrame = 0;
_currentDcStage = null;
_readyToReplay = false;
WeatherTurnFired = false;
_stream.Reset();
_events.Clear();
_markPositions.Clear();
_alphaSubmissions.Clear();
_pendingTerrainBatch.Clear();
_alphaSubmitMark = 0;
VisitedCells.Clear();
LookInCellTurns.Clear();
_lookInTurns.Clear();
_lookInSlices.Clear();
_lookInPlanes.Clear();
_lookInCyPlane = ctx.CyPlane;
_cellShellsDrawnThisFrame.Clear();
_cellParticleTurnsDrawnThisFrame.Clear();
LookInCells.Clear();
VisitedBuildings.Clear();
VisitedLandscapeCellIds.Clear();
InteriorFloodCells.Clear();
_cellViewRouteIndex = 0;
_landscapeViewRouteIndex = -1;
}
/// <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();
MarkAlphaIfGrown();
}
finally
{
_dispatcher.EndWalkPartFrame();
_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.
///
/// <para><b>S3 chunk 3 fix round 2 (§9.7 F10) — the pending terrain
/// batch's flush points, authoritative list.</b> A
/// <see cref="WalkFrameEventKind.StreamMark"/> is NOT one of them: the
/// ordered stream it draws holds ONLY opaque batches — <c>WalkStaticStreamPopulator</c>'s
/// per-entity classify step appends to the stream iff <c>batch.IsOpaque</c>
/// and otherwise routes translucent batches to the alpha list
/// (WalkStaticStreamPopulator.cs:179) — drawn through the <c>Opaque</c>/
/// <c>OpaqueAlphaToCoverage</c> pipelines, both built with depth test AND
/// write ON and NO blending (<c>WbDrawDispatcher.CreateMeshPipeline</c>:
/// <c>Depth = new GpuDepthState(Test: true, Write: depthWrite,
/// depthCompare)</c> called with <c>depthWrite: true</c>,
/// <c>GpuBlendMode.None</c> for both, WbDrawDispatcher.Rhi.cs:240-248,343);
/// <c>DrawOrderedRange</c> resolves every walk-stream command's bucket to
/// <c>PipelineBucket.Opaque</c> because every command it holds is opaque
/// (WbDrawDispatcher.OrderedStream.cs:569-571). Opaque terrain and opaque
/// statics are therefore depth-resolved correctly in EITHER submission
/// order — pixel-identical except exact z-ties — so deferring the pending
/// terrain batch across a <c>StreamMark</c> changes nothing observable.
/// The flush points that DO remain, because their GPU order against
/// terrain IS observable: <see cref="WalkFrameEventKind.PunchFan"/>
/// (<c>DEPTHTEST_ALWAYS</c> + write — the far-Z stamp this chunk's
/// interleave exists to order correctly against nearer terrain, S3 §9.1
/// R4), <see cref="WalkFrameEventKind.AlphaBarrier"/> and
/// <see cref="WalkFrameEventKind.LandscapeFlush"/> (both drain the
/// translucent alpha list, which must see every opaque surface — terrain
/// included — already resolved in the depth buffer),
/// <see cref="WalkFrameEventKind.ClearInteriorDepth"/>,
/// <see cref="WalkFrameEventKind.ExitSeals"/>,
/// <see cref="WalkFrameEventKind.Sky"/>, <see cref="WalkFrameEventKind.CellShell"/>,
/// a <see cref="WalkFrameEventKind.StaticParticles"/>/
/// <see cref="WalkFrameEventKind.CellParticles"/> turn whose cell has a
/// renderable emitter (a genuinely empty turn still submits nothing and
/// still does not flush, unchanged from fix round 1's F2), and the end of
/// this method. This list's premise is the opaque-only, depth-test-and-
/// write, no-blend pipeline pair the walk's ordered stream actually uses
/// TODAY — not <c>StreamMark</c> itself: if any future ordered-stream
/// stage draws with blending or a disabled depth write (for example the
/// deferred building-detail overlay replay,
/// <c>WbDrawDispatcher.DrawBuildingDetailRangeRhi</c>'s
/// <c>RetailDetailTransparent</c> pass — not wired into this walk path
/// today, OrderedStream.cs:46-52), that stage's <c>StreamMark</c> MUST
/// become a flush point again; the contract gets amended, not bent.</para>
/// </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.");
}
try
{
if (_stream.Count > 0)
_dispatcher.PrepareOrderedStream(frame, _stream, _viewProjection, _markPositions);
// S3 chunk 3 fix round 1 (F2): cleared at the start of THIS
// replay ("frame start") — a batch never survives across frames.
_pendingTerrainBatch.Clear();
int cursor = 0;
int alphaCursor = 0;
for (int i = 0; i < _events.Count; i++)
{
WalkFrameEvent e = _events[i];
switch (e.Kind)
{
case WalkFrameEventKind.StreamMark:
// S3 chunk 3 fix round 2 (F10): NOT a terrain flush
// point. The ordered stream this draws holds ONLY
// opaque batches (WalkStaticStreamPopulator.cs:179)
// drawn with the Opaque/OpaqueAlphaToCoverage
// pipelines — depth test AND write on, no blending
// (WbDrawDispatcher.Rhi.cs:240-248,343; bucket
// resolution WbDrawDispatcher.OrderedStream.cs:569-571)
// — so opaque terrain and opaque statics are
// depth-resolved correctly in either submission
// order. See this method's own doc comment for the
// full flush-point list and the citation this rests
// on.
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.AlphaSubmitMark:
// Not a flush point (unchanged by fix round 2, F10):
// this arm only ENQUEUES the translucent instances
// into the CPU alpha list (SubmitWalkAlphaInstance);
// nothing reaches the GPU until the list drains at an
// AlphaBarrier / LandscapeFlush leaf or the pass end
// — both remain terrain flush points (F10) — so the
// drained alpha still lands after every preceding
// land cell's terrain, exactly as when unbatched.
int alphaEnd = e.IntArg;
for (; alphaCursor < alphaEnd; alphaCursor++)
{
WbDrawDispatcher.WalkClassifiedBatch batch =
_alphaSubmissions[alphaCursor];
_dispatcher.SubmitWalkAlphaInstance(
in batch,
_viewProjection);
}
break;
case WalkFrameEventKind.Sky:
FlushPendingTerrainBatch();
_leafRenderer.DrawSky();
break;
case WalkFrameEventKind.LandCell:
// S3 chunk 3 fix round 1 (F2), round 2 (F10):
// append-only — no lookahead, no same-landblock
// restriction, no flush. The batch may span several
// landblocks; it flushes only at one of the F10
// flush points (PunchFan, AlphaBarrier,
// LandscapeFlush, ClearInteriorDepth, ExitSeals,
// Sky, CellShell, a has-emitter particle turn) or at
// Replay's own end — NOT at a StreamMark, since the
// opaque ordered stream's GPU order against terrain
// is unobservable (see Replay's own doc comment).
_pendingTerrainBatch.Add((e.CellId, e.IntArg >> 8, e.IntArg & 0xFF));
break;
case WalkFrameEventKind.CellShell:
FlushPendingTerrainBatch();
_leafRenderer.DrawCellShell(e.CellId);
break;
case WalkFrameEventKind.PunchFan:
FlushPendingTerrainBatch();
_leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg);
break;
case WalkFrameEventKind.AlphaBarrier:
FlushPendingTerrainBatch();
_leafRenderer.AlphaBarrier();
break;
case WalkFrameEventKind.SortCellExit:
FlushPendingTerrainBatch();
_leafRenderer.FlushSortCellExit();
break;
case WalkFrameEventKind.LandscapeFlush:
FlushPendingTerrainBatch();
_leafRenderer.FlushLandscape();
break;
case WalkFrameEventKind.ClearInteriorDepth:
FlushPendingTerrainBatch();
_leafRenderer.ClearInteriorDepth();
break;
case WalkFrameEventKind.ExitSeals:
FlushPendingTerrainBatch();
// S3 §8.2 B2: the driver — not Collect — owns adding
// the leaf's returned submitted-fan count to the
// persistent PortalsDrawnCount, since the real
// enumeration (and therefore the real count) only
// exists once the production leaf actually runs, at
// Replay.
PortalsDrawnCount += _leafRenderer.DrawExitSeals();
break;
case WalkFrameEventKind.StaticParticles:
// S3 chunk 3 fix round 1 (F2): a genuinely empty
// particle turn submits nothing — and, since it
// submits nothing, must NOT flush the pending
// terrain batch either (the batch is order-preserving
// only because a skipped turn changes nothing about
// what reaches the GPU, at all). Retail
// CPhysicsObj::add_particle_shadow_to_cell
// (0x00514a70): an emitter owns one shadow in its OWN
// current cell, drawn at that cell's object turn
// regardless of any owner's registry membership —
// this is a cell lookup, not an owner union.
SubmitCellAlpha(
e,
ref alphaCursor,
staticParticleTurn: true);
break;
case WalkFrameEventKind.CellParticles:
SubmitCellAlpha(
e,
ref alphaCursor,
staticParticleTurn: false);
break;
}
}
// S3 chunk 3 fix round 1 (F2), round 2 (F10): the end of Replay
// is itself a flush point — the remainder never crosses into
// the next frame.
FlushPendingTerrainBatch();
}
finally
{
_stream.Reset();
_events.Clear();
_markPositions.Clear();
_alphaSubmissions.Clear();
_pendingTerrainBatch.Clear();
_alphaSubmitMark = 0;
_readyToReplay = false;
_ctx = null;
}
}
/// <summary>
/// S4-c3a: completes one retail object-cell turn. Object delayed subsets
/// are already in stable far→near part order. Real particle records are
/// prepared only now, then merged by their retained CYpt before either
/// source appends to the two untouched FIFO queue lists. Object wins an
/// exactly equal cross-source key; AP-242 records that deterministic
/// source tie because acdream has no shared physical registration
/// ordinal. The event always drains its object range, including a cell
/// with no particle emitters.
/// </summary>
private void SubmitCellAlpha(
in WalkFrameEvent e,
ref int alphaCursor,
bool staticParticleTurn)
{
ReadOnlySpan<PreparedParticleAlphaSubmission> particles =
ReadOnlySpan<PreparedParticleAlphaSubmission>.Empty;
bool includeParticles = e.FloatArg != 0f;
if (includeParticles && _leafRenderer.HasRenderableEmittersInCell(e.CellId))
{
// Preserve the existing particle-turn terrain boundary: a row-5
// immediate mesh may draw during preparation.
FlushPendingTerrainBatch();
particles = staticParticleTurn
? _leafRenderer.PrepareStaticParticles(e.CellId)
: _leafRenderer.PrepareCellParticles(e.CellId);
}
int objectEnd = e.IntArg;
int particleIndex = 0;
while (alphaCursor < objectEnd || particleIndex < particles.Length)
{
bool takeParticle = particleIndex < particles.Length
&& (alphaCursor >= objectEnd
|| particles[particleIndex].DistanceSq
> _alphaSubmissions[alphaCursor].SortDistanceSq);
if (takeParticle)
{
particles[particleIndex++].Append();
}
else
{
WbDrawDispatcher.WalkClassifiedBatch batch =
_alphaSubmissions[alphaCursor++];
_dispatcher.SubmitWalkAlphaInstance(in batch, _viewProjection);
}
}
}
/// <summary>S3 chunk 3 fix round 1 (F2), round 2 (F10): submits the
/// pending terrain batch as ONE <see cref="IWalkFrameLeafRenderer.DrawLandCellBatch"/>
/// call, if it holds anything, and clears it. Called immediately before
/// every event kind whose GPU order against terrain is OBSERVABLE — see
/// <see cref="Replay"/>'s own doc comment for the full flush-point list
/// and its citation — so the batch's actual submission point is always
/// the exact point the unbatched per-cell draws would have occupied
/// relative to those events. A <see cref="WalkFrameEventKind.StreamMark"/>
/// is deliberately NOT one of the callers (F10): the opaque ordered
/// stream it draws is depth-resolved identically regardless of
/// submission order relative to terrain.</summary>
private void FlushPendingTerrainBatch()
{
if (_pendingTerrainBatch.Count == 0)
return;
_leafRenderer.DrawLandCellBatch(_pendingTerrainBatch);
_pendingTerrainBatch.Clear();
}
// ------------------------------------------------------------------
// IWalkEventSink
// ------------------------------------------------------------------
void IWalkEventSink.Emit(in WalkEvent walkEvent)
{
switch (walkEvent.Kind)
{
case WalkEventKind.DrawInside:
// S3 chunk 1 (§11.2 B1): PView::DrawInside @0x005a5860 entry.
WalkTranscriptDump.PrintDrawInside(walkEvent.CellId);
_currentDcStage = WalkDrawStage.CellStatic;
VisitedCells.Add(walkEvent.CellId);
break;
case WalkEventKind.Landscape:
// S3 chunk 1 (§11.2 B1): LScape::draw @0x00506330 entry —
// printed before the driver's own duplicate-turn guard so a
// desynced second LS still shows up in the transcript
// (the guard is a walk/driver fail-loud check, not a retail
// admission decision retail's own breakpoint would skip).
WalkTranscriptDump.PrintLandscape();
HandleLandscapeTurn(walkEvent.OutsideViewCount);
break;
case WalkEventKind.DrawCells:
// S3 chunk 1 (§11.2 B1): PView::DrawCells @0x005a4840 entry
// (breakpoint-entry order, matching the FW0/OH captures).
// _currentDcStage at THIS point (set by the immediately
// preceding DI or OnBuildingTurn event, never mutated by
// this case itself) tells us which PView owns this flood:
// CellStatic == the interior root's own flood (interior
// pview); anything else (LookInStatic) == a building
// look-in through the outdoor pview.
WalkTranscriptDump.PrintDrawCells(
outdoorPview: _currentDcStage == WalkDrawStage.LookInStatic,
walkEvent.OutsideViewCount,
walkEvent.Cells);
foreach (uint id in walkEvent.Cells)
VisitedCells.Add(id);
HandleDrawCellsTurn(walkEvent.Cells);
break;
case WalkEventKind.Building:
// S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawBuilding
// @0x0059f2a0 entry — unconditional, matching the BLD event's
// own "fires at ENTRY, before the degrade check" contract
// (this type's class doc).
WalkTranscriptDump.PrintBuilding(walkEvent.CellId);
// OnBuildingTurn (below) carries the actual side effects —
// this vocabulary-only event needs no driver action.
break;
}
}
/// <summary>S3 chunk 3 (§9.2 B1): records this admitted land cell's
/// terrain turn. <c>sideCellCount</c>/<c>cellIndex</c> travel packed
/// (not expanded) — R3's index-run arithmetic runs at LOD resolution in
/// the production leaf, not at 8×8-bucket resolution like the object-
/// list turn's expansion.</summary>
void IWalkEventSink.OnLandCellTurn(uint landblockId, int sideCellCount, int cellIndex)
{
RequireOpenFrame();
if (sideCellCount is not (1 or 2 or 4 or 8))
{
throw new ArgumentOutOfRangeException(
nameof(sideCellCount),
sideCellCount,
"A landscape LOD grid must be 1, 2, 4, or 8 cells per side.");
}
if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount))
throw new ArgumentOutOfRangeException(nameof(cellIndex));
// S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawLandCell @0x0059f120
// entry — one LC line per admitted LOD land cell, at LOD resolution
// (never expanded to the 8x8 owner-bucket ids OnLandscapeCellTurn
// uses for object-list content). S3 chunk 1 fix round 1 (G2): the
// flag gate wraps the LodCellId computation itself, not just
// WalkTranscriptDump.PrintLandCell's own internal check — an
// ordinary launch (flag off) must not pay for the division/modulo/
// checked-conversion work on ~1,300 land-cell turns/frame just to
// discard the result.
if (AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled)
{
WalkTranscriptDump.PrintLandCell(
WalkTranscriptDump.LodCellId(landblockId, sideCellCount, cellIndex));
}
// The one mark rule (this type's own doc comment): flush any
// already-queued stream content ahead of this leaf-renderer event.
MarkIfGrown();
_events.Add(WalkFrameEvent.LandCell(landblockId, sideCellCount, cellIndex));
}
/// <summary>S3 chunk 1 fix round 1 (G7): <c>RenderDeviceD3D::
/// DrawSortCell</c> @0x0059f140 ENTRY — fired by <see cref="RetailFrameWalk.DrawLandscape"/>
/// BEFORE that turn's own <c>DrawBuilding</c> call, so the SC line
/// prints between LC and BLD (retail's own order — see the interface
/// member's own doc comment; the three-lens review's transcript pass
/// caught the earlier placement, which printed SC from
/// <see cref="IWalkEventSink.OnLandscapeCellTurn(uint,int,int)"/> AFTER
/// DrawBuilding had already run). Print-only — no stream-side effect of
/// its own; the object-list turn stays entirely in
/// <c>OnLandscapeCellTurn</c> below, unchanged.
/// <para>
/// S3 chunk 1 fix round 2 (§11.6 H4): UNLIKE <see cref="OnLandCellTurn"/>
/// (whose <c>RequireOpenFrame</c>/range validation stay unconditional —
/// that hook always records a real <c>WalkFrameEvent</c>), this hook has
/// NO side effect beyond the print, so the flag gate wraps the ENTIRE
/// body, not just the <c>LodCellId</c> computation: an ordinary launch
/// pays exactly one interface dispatch per visited land cell and nothing
/// else (docs/launch-options.md).
/// </para>
/// </summary>
void IWalkEventSink.OnSortCellTurn(uint landblockId, int sideCellCount, int cellIndex)
{
if (!AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled)
return;
RequireOpenFrame();
if (sideCellCount is not (1 or 2 or 4 or 8))
{
throw new ArgumentOutOfRangeException(
nameof(sideCellCount),
sideCellCount,
"A landscape LOD grid must be 1, 2, 4, or 8 cells per side.");
}
if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount))
throw new ArgumentOutOfRangeException(nameof(cellIndex));
WalkTranscriptDump.PrintSortCell(
WalkTranscriptDump.LodCellId(landblockId, sideCellCount, cellIndex));
}
void IWalkEventSink.OnLandscapeCellTurn(uint cellId)
=> HandleLandscapeCellTurn(cellId);
/// <summary>The object-list half of retail's DrawSortCell turn — see
/// <see cref="IWalkEventSink.OnSortCellTurn"/> for the SC transcript
/// line, which now fires separately, BEFORE the building's own turn
/// (S3 chunk 1 fix round 1, G7); this method never prints one.</summary>
void IWalkEventSink.OnLandscapeCellTurn(
uint landblockId,
int sideCellCount,
int cellIndex)
{
if (sideCellCount is not (1 or 2 or 4 or 8))
{
throw new ArgumentOutOfRangeException(
nameof(sideCellCount),
sideCellCount,
"A landscape LOD grid must be 1, 2, 4, or 8 cells per side.");
}
if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount))
throw new ArgumentOutOfRangeException(nameof(cellIndex));
uint blockPrefix = landblockId & 0xFFFF0000u;
if (sideCellCount == 8)
{
HandleLandscapeCellTurn(blockPrefix | checked((uint)(cellIndex + 1)));
return;
}
// CLandBlock's coarse DrawSortCell owns the shadows from every
// original 24 m land cell it covers. Our render journal deliberately
// retains those authoritative 8x8 owner buckets, so turn all covered
// buckets here while preserving the coarse cell's single walk turn.
int span = 8 / sideCellCount;
int coarseX = cellIndex / sideCellCount;
int coarseY = cellIndex % sideCellCount;
int firstX = coarseX * span;
int firstY = coarseY * span;
for (int x = firstX; x < firstX + span; x++)
{
for (int y = firstY; y < firstY + span; y++)
{
HandleLandscapeCellTurn(
blockPrefix | checked((uint)(x * 8 + y + 1)));
}
}
}
private void HandleLandscapeCellTurn(uint cellId)
{
RequireOpenFrame();
if (_landscapeViewRouteIndex < 0)
{
throw new InvalidOperationException(
"A landscape cell turn fired before RetailFrameWalk installed its active "
+ "view set — the walk and draw driver are desynchronized.");
}
VisitedLandscapeCellIds.Add(cellId);
WalkFrameStaticRecords records = _worldData.GetOutdoorObjects(cellId);
_populator.PopulateCellObjects(
_stream,
WalkDrawStage.OutdoorStatic,
cellId,
records.Records,
records.TupleLandblockId,
_cameraWorldPosition,
_viewProjection,
this,
_landscapeViewRouteIndex,
_alphaSubmissions);
// FW4 (the #132 positional invariant): this cell's emitters 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). Fires
// unconditionally: retail's add_particle_shadow_to_cell draws an
// emitter at its own cell's turn independent of whether that cell
// has any visible static/dynamic owner record (Campaign OVERHAUL S2
// chunk 6 — a suspended/hidden owner's emitter must still show).
MarkIfGrown();
bool includeParticles = _cellParticleTurnsDrawnThisFrame.Add(cellId);
_alphaSubmitMark = _alphaSubmissions.Count;
_events.Add(WalkFrameEvent.LandscapeCellParticles(
cellId,
_alphaSubmitMark,
includeParticles));
}
/// <summary>S4-c2: retail <c>RenderDeviceD3D::DrawBlock</c> @0x005a17c0's
/// (per-land-cell loop head @0x005a18d0) own <c>FlushAlphaList(::flush)</c>
/// @0x005a1a07 (the 0.75f
/// pressure valve) — fires once per admitted land-block cell, at the SAME
/// gate <see cref="IWalkEventSink.OnLandscapeCellTurn(uint,int,int)"/>
/// already fires under (<c>RetailFrameWalk.DrawLandscape</c> calls this
/// immediately after that turn, still inside the loop body). Marks first,
/// same as <see cref="IWalkEventSink.OnBuildingTurn"/>'s AlphaBarrier
/// emission, so this cell's own content flushes ahead of the valve.</summary>
void IWalkEventSink.OnSortCellExit(uint landblockId, int sideCellCount, int cellIndex)
{
RequireOpenFrame();
MarkIfGrown();
MarkAlphaIfGrown();
_events.Add(WalkFrameEvent.SortCellExit());
}
void IWalkEventSink.OnLandscapeViews(WalkPortalView activeViews)
{
ArgumentNullException.ThrowIfNull(activeViews);
RequireOpenFrame();
_landscapeViewRouteIndex = _cellViewRouteIndex++;
CaptureViews(0, activeViews);
}
void IWalkEventSink.OnBuildingTurn(WalkBuilding building)
{
ArgumentNullException.ThrowIfNull(building);
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();
MarkAlphaIfGrown();
_events.Add(WalkFrameEvent.AlphaBarrier());
_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,
alphaSubmissions: _alphaSubmissions);
if (_alphaSubmissions.Count != _alphaSubmitMark)
{
MarkIfGrown();
MarkAlphaIfGrown();
}
// Campaign OVERHAUL S2 chunk 6: a building's shell fires no particle
// turn of its own any more. Retail's add_particle_shadow_to_cell
// (0x00514a70) gives an emitter exactly one shadow in ITS OWN
// current cell — a building's interior emitters live in their own
// EnvCells and draw at those cells' own CellParticles turns, never
// at their parent building's shell turn.
}
void IWalkEventSink.OnPunchGeometry(
WalkBuilding building, WalkPolygon polygon, int activeViewIndex)
{
ArgumentNullException.ThrowIfNull(building);
ArgumentNullException.ThrowIfNull(polygon);
RequireOpenFrame();
// S4-c1 C1: DrawPortalPolyInternal's degenerate-input guard
// (WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard's own
// doc comment) — tested on the polygon's LOCAL vertices, BEFORE the
// building -> world transform below. A hit drops the whole polygon:
// no punch event, no transform, no counter effect (retail's own
// reject -> transform -> clip -> count order).
if (WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon.Vertices))
return;
MarkIfGrown();
Matrix4x4 worldTransform = _worldData.GetBuildingWorldTransform(building);
_events.Add(
WalkFrameEvent.PunchFan(TransformToWorld(polygon, worldTransform), activeViewIndex));
}
void IWalkEventSink.OnInteriorFloodDrawTurn(IReadOnlyList<uint> cells, int outsideViewCount)
{
ArgumentNullException.ThrowIfNull(cells);
RequireOpenFrame();
// PView::DrawCells @0x005a4840 (S3 §8.1 R3): the landscape flush, the
// device-stamp advance, the gated depth clear, and the exit-portal
// seals ALL sit strictly inside `if (outside_view.view_count > 0)`
// (0x005a4852-0x005a49eb) — outsideViewCount==0 skips straight to
// the flood's own cells below with none of the four having run.
if (outsideViewCount > 0)
{
// D3DPolyRender::FlushAlphaList(0f) @0x005a4872, plus the
// pre-clear dynamics hook RetailPViewRenderer.FlushWalkLandscape
// bundles — the first action inside the gate.
MarkIfGrown();
_events.Add(WalkFrameEvent.LandscapeFlush());
// m_nFrameStamp += 1 @0x005a4886. Its drawn-part AND DrawEnvCell
// dedupe is per render stamp, not per presented frame: content
// admitted during the landscape must remain eligible for the
// interior-cell repaint after the clear. In Collect, every
// landscape candidate has been classified by this point and no
// interior-root candidate has, so re-arming both CPU-side stamp
// mirrors here is the exact boundary. Without the shell re-arm,
// color from a pre-clear building look-in survives while the
// root repaint is incorrectly suppressed, producing
// wall-textured bleed slabs.
_dispatcher.AdvanceWalkPartPassStamp();
_cellShellsDrawnThisFrame.Clear();
_cellParticleTurnsDrawnThisFrame.Clear();
// The gated full depth clear (pc:432731-432732): retail's
// portalsDrawnCount (D3DPolyRender::portalsDrawnCount,
// uint16 @0x008719b4) is read-then-zeroed HERE
// (@0x005a489c-0x005a489e; R4) — forceClear never writes in the
// pseudo-C, so the clear fires iff the counter was nonzero. A
// fresh driver's very first ov>0 frame therefore draws NO clear
// (portalsDrawnCount starts at 0); every later ov>0 frame clears
// because the PREVIOUS frame's own exit seals armed the counter
// at Replay (see PortalsDrawnCount's own doc comment).
int armed = PortalsDrawnCount;
PortalsDrawnCount = 0;
if (armed != 0)
{
MarkIfGrown();
_events.Add(WalkFrameEvent.ClearInteriorDepth());
}
// The exit-portal seals (pc:432785-432786) — this turn's
// submitted fan count re-arms PortalsDrawnCount at Replay (B2),
// read by the NEXT ov>0 frame's decision above.
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);
}
/// <summary>S3 chunk 4 (O3): retail's <c>GameSky::Draw(sky,1)</c>
/// @0x00506ff0 weather pass — <see cref="RetailFrameWalk.DrawLandscape"/>
/// only calls this when its own gate already held, so this handler
/// records <see cref="WeatherTurnFired"/> unconditionally (S3 chunk 4
/// fix round 1, K2 — the one predicate the Replay-time draw now reads,
/// so a landscape turn that ran with the weather gate open can never be
/// under-reported to the draw side) and is otherwise print-only,
/// matching <see cref="IWalkEventSink.OnSortCellTurn"/>'s own shape (no
/// stream side effect). The GPU draw stays where chunk 1 round 2 put
/// it — <see cref="RetailPViewPassExecutor.DrawWeatherOnce"/>, called
/// directly from <see cref="RetailPViewRenderer.DrawLandscapeDynamicsPhase"/>
/// at Replay time, now gated on THIS flag instead of re-deriving its
/// own independent condition.</summary>
void IWalkEventSink.OnWeatherTurn(uint viewerCellId)
{
RequireOpenFrame();
WeatherTurnFired = true;
if (!AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled)
return;
WalkTranscriptDump.PrintObjectCellTurn(viewerCellId);
}
// ------------------------------------------------------------------
// 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 (_landscapeTurnsThisFrame != 0)
{
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());
_landscapeTurnsThisFrame++;
// S3 chunk 3 (§9.1 R4/§9.2 B1): the FW4-slice-6 whole-stage terrain
// turn (retail draws terrain blocks ONCE per landscape turn) is
// SUPERSEDED — retail's true per-cell DrawLandCell/DrawSortCell
// interleave now emits one WalkFrameEventKind.LandCell turn per
// admitted land cell (IWalkEventSink.OnLandCellTurn, fired from
// RetailFrameWalk.DrawLandscape's own per-cell loop), not a single
// TerrainSlice(0) here. S3 chunk 3 fix round 1 (F6) corrects the
// direction of this note: drawing ALL terrain BEFORE every building
// let a FARTHER building's far-Z punch survive, because the NEARER
// terrain that retail draws AFTER that punch (and which overwrites
// it) was instead drawn BEFORE it here — nothing later covered the
// punched depth, so a doorway behind a hill punched straight through
// it. The interleave fixes this by ORDER alone: nearer terrain now
// draws AFTER the farther building's punch, exactly like retail. The
// walk's active views still own only the punch fans and dynamics
// apertures; terrain itself is UNCLIPPED for both root kinds.
}
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)
{
_floodViewRouteScratch.Clear();
for (int i = 0; i < cells.Count; i++)
_floodViewRouteScratch.Add(-1);
// PView::DrawCells @0x005A4840, loop 2 (005A4A00005A4ADE):
// cell_draw_list[count - 1] down to zero. The loop calls setup_view +
// DrawEnvCell for each live view, but DrawEnvCell @0x0059F170 begins
// with GetDrawnThisFrame/SetDrawnThisFrame. Its built-mesh path then
// calls D3DPolyRender::DrawMesh directly: the EnvCell shell is drawn
// WHOLE on the first call and every later view/cross-flood call is a
// no-op for this render stamp. Portal-view clipping remains active for
// the following object-list loop; it is not a shell geometry clip.
for (int i = cells.Count - 1; i >= 0; i--)
{
int viewRouteIndex = CaptureCellViewRoute(cells[i]);
_floodViewRouteScratch[i] = viewRouteIndex;
// S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawEnvCell
// @0x0059f170 entry — UNCONDITIONAL, before the per-render-stamp
// dedupe below. Retail's own stamp check (GetDrawnThisFrame/
// SetDrawnThisFrame) sits INSIDE DrawEnvCell, past this
// breakpoint's address, so every flood visit prints an EC line
// even when the visit's actual shell submission is suppressed —
// proof: holtburg-doorway-still.walk.log:1126,1131,1134,1137
// print "EC a9b40100" four times in ONE frame across four
// look-in DC turns that all redraw the same cell. S3 chunk 1 fix
// round 2 (§11.6 H2): "EC and OC counts are always exactly
// equal" (an earlier version of this comment) is FALSE —
// terrace-edge has 12 EC vs 16 OC, the extra 4 being the
// trailing per-frame weather OC (§11.6 H1), which has no EC
// counterpart at all; see WalkTranscriptDump.PrintEnvCellShell's
// own doc comment for the same citation.
//
// S3 review fix round 1 (F4a): retail's breakpoint sits inside
// the setup_view/DrawEnvCell LOOP (PView::DrawCells
// @0x005a4ab1-0x005a4acc), which iterates the cell's live
// portal_view slices — @0x0059f170's own entry, above, fires
// once PER live view, not once per cell. Read the SliceCount
// directly off the route CaptureCellViewRoute just captured
// (_lookInTurns[viewRouteIndex]) rather than through
// InteriorFloodViewSliceCountAt's flood-index indirection: this
// loop also runs for a building's LOOK-IN flood
// (WalkDrawStage.LookInStatic), whose cells never populate
// InteriorFloodCells (that list is the interior ROOT flood
// only — see its own doc comment) — indexing it here would
// throw ArgumentOutOfRangeException on a real look-in turn. A
// cell with zero live views (an untracked/degenerate route)
// prints zero EC lines, matching setup_view never iterating an
// empty view list. The SHELL DRAW event below is unchanged —
// the frame stamp still dedupes it to one submission regardless
// of how many times this prints.
// Guarded at the call site (the §11.5 G2 convention): flag off
// costs one bool read here, not a list index plus N no-op calls.
if (AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled)
{
int liveViewCount = _lookInTurns[viewRouteIndex].SliceCount;
for (int view = 0; view < liveViewCount; view++)
WalkTranscriptDump.PrintEnvCellShell(cells[i]);
}
if (_cellShellsDrawnThisFrame.Add(cells[i]))
{
MarkIfGrown();
_events.Add(WalkFrameEvent.CellShell(cells[i]));
}
}
// Loop 3 (005A4ADE005A4B2D): 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], _floodViewRouteScratch[i]);
}
private int CaptureCellViewRoute(uint cellId)
{
int viewRouteIndex = _cellViewRouteIndex++;
CaptureCellViews(cellId);
return viewRouteIndex;
}
private void EmitCellContentsTurn(
WalkDrawStage stage,
uint cellId,
int viewRouteIndex)
{
// S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawObjCellForDummies
// @0x005a0760 entry — the interior/look-in object-list turn (retail's
// outdoor DrawObjCell is a different function, folded into the SC
// line's own DrawSortCell call). Unconditional, matching this loop's
// existing no-dedupe shape (every flood visit already calls this
// method once, with no per-stamp gate).
WalkTranscriptDump.PrintObjectCellTurn(cellId);
// The shell loop captured the exact live portal_view for this cell.
// Static and dynamic object lists reuse that same route: retail's
// DrawEnvCell and DrawObjCell consume the same PortalList.
WalkFrameStaticRecords records = _worldData.GetCellObjects(cellId);
_populator.PopulateCellObjects(
_stream,
stage,
cellId,
records.Records,
records.TupleLandblockId,
_cameraWorldPosition,
_viewProjection,
this,
viewRouteIndex,
_alphaSubmissions);
MarkIfGrown();
if (stage == WalkDrawStage.LookInStatic)
{
LookInCellTurns.Add(cellId);
LookInCells.Add(cellId);
}
// Fires for every cell turn regardless of whether that cell has any
// visible static/dynamic owner record (Campaign OVERHAUL S2 chunk 6 —
// a suspended/hidden owner's emitter must still show), but ONCE per
// render stamp: retail's particle parts sit in the same shadow_part_list
// as every other part, so CPhysicsPart::Draw's frame stamp suppresses a
// second submission when one cell gets two object-list turns in one
// frame (a chamber reached through two portals). The same frame-scoped
// set that dedupes the cell SHELL dedupes the particle turn (chunk 6
// review F1).
bool includeParticles = _cellParticleTurnsDrawnThisFrame.Add(cellId);
_alphaSubmitMark = _alphaSubmissions.Count;
_events.Add(WalkFrameEvent.CellParticles(
cellId,
_alphaSubmitMark,
includeParticles));
}
private void CaptureCellViews(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;
}
CaptureViews(cellId, cell.TopView);
}
private void CaptureViews(uint cellId, WalkPortalView portalView)
{
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);
}
uint clipSlot = AppendClipSlot(portalView, poly);
_lookInSlices.Add(new WalkLookInSlice(
planeStart, poly.VertexCount, clipSlot));
}
_lookInTurns.Add(new WalkLookInTurn(
cellId, sliceStart, _lookInSlices.Count - sliceStart));
}
/// <summary>See the interface member's own doc comment (S3 chunk 4,
/// §10.2): replaces the deleted <c>VisibleClipSlotsInLookInTurn</c>'s
/// per-slice loop with the SAME admission condition per slice, short-
/// circuiting to a Boolean the moment any slice admits instead of
/// accumulating a slot list nothing downstream read for its content
/// (every caller only ever asked <c>Count == 0</c>).</summary>
public bool SphereVisibleInLookInTurn(
int routeIndex,
in Vector3 center,
float radius,
bool testSphere = true)
{
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 (!testSphere
|| WalkVisibilityMath.ViewconeCheck(
center,
radius,
_lookInCyPlane,
CollectionsMarshal.AsSpan(_lookInPlanes).Slice(
slice.PlaneStart,
slice.PlaneCount)) != WalkBoundingType.Outside)
{
return true;
}
}
return false;
}
public string DescribeLookInTurn(
int routeIndex,
in Vector3 center,
float radius)
{
if ((uint)routeIndex >= (uint)_lookInTurns.Count)
return "route-missing";
WalkLookInTurn turn = _lookInTurns[routeIndex];
var description = new System.Text.StringBuilder(192);
float cyDistance = Vector3.Dot(_lookInCyPlane.Normal, center)
+ _lookInCyPlane.D;
description.Append("turnCell=0x")
.Append(turn.CellId.ToString("X8"))
.Append(" slices=").Append(turn.SliceCount)
.Append(" cy=").Append(cyDistance.ToString("F5"))
.Append(" cyMargin=").Append((cyDistance + radius).ToString("F5"));
for (int sliceOffset = 0; sliceOffset < turn.SliceCount; sliceOffset++)
{
WalkLookInSlice slice = _lookInSlices[turn.SliceStart + sliceOffset];
description.Append(" slice[").Append(sliceOffset).Append("]=");
for (int planeOffset = 0; planeOffset < slice.PlaneCount; planeOffset++)
{
if (planeOffset != 0)
description.Append(',');
WalkPlane plane = _lookInPlanes[slice.PlaneStart + planeOffset];
float distance = Vector3.Dot(plane.Normal, center) + plane.D;
description.Append(distance.ToString("F5"));
}
}
return description.ToString();
}
/// <summary>Converts one retail pixel-space portal_view polygon into the
/// clip-space half-planes consumed by mesh_modern.vert. copy_view already
/// deduplicates and removes collinear vertices, so 3..8 points map
/// directly. The rare >8-edge case uses its convex AABB as a conservative
/// four-plane gate rather than falling back to slot 0 (which would erase
/// the aperture entirely).</summary>
private uint AppendClipSlot(WalkPortalView portalView, WalkViewPoly poly)
{
if (_clipFrame is null)
return 0;
IRetailFrameWalkContext ctx = (IRetailFrameWalkContext)RequireOpenFrame();
int count = poly.VertexCount;
if (count < 3)
return 0;
Span<Vector2> ndc = stackalloc Vector2[Math.Min(count, WalkCopyView.MaxVertices)];
float minX = float.MaxValue, minY = float.MaxValue;
float maxX = float.MinValue, maxY = float.MinValue;
for (int i = 0; i < ndc.Length; i++)
{
Vector2 px = portalView.View.Vertices[poly.VertexIndex + i].Point;
Vector2 point = new(
px.X / ctx.ViewportWidth * 2f - 1f,
1f - px.Y / ctx.ViewportHeight * 2f);
ndc[i] = point;
minX = MathF.Min(minX, point.X);
minY = MathF.Min(minY, point.Y);
maxX = MathF.Max(maxX, point.X);
maxY = MathF.Max(maxY, point.Y);
}
if (ndc.Length > ClipFrame.MaxPlanes)
{
Span<Vector4> aabbPlanes = stackalloc Vector4[4]
{
new(1f, 0f, 0f, -minX),
new(-1f, 0f, 0f, maxX),
new(0f, 1f, 0f, -minY),
new(0f, -1f, 0f, maxY),
};
return checked((uint)_clipFrame.AppendSlot(aabbPlanes));
}
float area2 = 0f;
for (int i = 0; i < ndc.Length; i++)
area2 += ndc[i].X * ndc[(i + 1) % ndc.Length].Y
- ndc[(i + 1) % ndc.Length].X * ndc[i].Y;
bool ccw = area2 >= 0f;
Span<Vector4> planes = stackalloc Vector4[ClipFrame.MaxPlanes];
for (int i = 0; i < ndc.Length; i++)
{
int current = ccw ? i : ndc.Length - 1 - i;
int next = ccw
? (i + 1) % ndc.Length
: (ndc.Length - 2 - i + ndc.Length) % ndc.Length;
Vector2 p = ndc[current];
Vector2 q = ndc[next];
Vector2 dir = q - p;
Vector2 normal = Vector2.Normalize(new Vector2(-dir.Y, dir.X));
planes[i] = new Vector4(normal.X, normal.Y, 0f, -Vector2.Dot(normal, p));
}
return checked((uint)_clipFrame.AppendSlot(planes[..ndc.Length]));
}
/// <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));
}
/// <summary>Records translucent walk batches at the same temporal point
/// as their cell turn. Replay submits them only when it reaches this
/// marker, so an earlier building's full alpha barrier cannot see content
/// that the far-to-near walk has not reached yet.</summary>
private void MarkAlphaIfGrown()
{
int count = _alphaSubmissions.Count;
if (count == _alphaSubmitMark)
return;
_alphaSubmitMark = count;
_events.Add(WalkFrameEvent.AlphaSubmitMark(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) };
}
}