using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering.Walk;
///
/// Campaign FW stage FW3.2b-1: one cell's or one building's already-queried
/// static content, ready for —
/// caller-built, never read from a retained scene (see
/// 's doc comment).
///
/// Already-classified s
/// for this turn's cell/building, retaining authored traversal/registration
/// order. For ordinary cells, 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 's
/// per-frame arena, not a freshly allocated array — see that type's own doc
/// comment.
/// The clip-slot-resolving landblock id
/// WbDrawDispatcher.ClassifyEntityForWalk needs per record (FW3.2a's
/// tupleLandblockId parameter) — carried per-turn rather than once per
/// frame because a single frame's cells/buildings can span more than one
/// committed landblock.
internal readonly record struct WalkFrameStaticRecords(
ArraySegment Records, uint TupleLandblockId)
{
public static readonly WalkFrameStaticRecords Empty =
new(ArraySegment.Empty, 0);
}
///
/// Campaign FW stage FW3.2b-1: the world-data lookups
/// needs at each walk turn, entirely caller-built — the driver reads no
/// retained scene state of its own (mirrors 's
/// own "reads no retained scene state itself" contract one layer up). FW3.2b-2
/// wires the real production implementation (RenderSceneQuery.CopyCellStaticsTo
/// / CopyIndexTo + ); this stage's
/// headless referee tests wire a synthetic fake instead.
///
internal interface IWalkFrameWorldData
{
/// 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.
WalkFrameStaticRecords GetCellObjects(uint cellId);
/// The exact combined ordinary-object membership for one
/// landscape cell; see .
WalkFrameStaticRecords GetOutdoorObjects(uint cellId);
/// An indoor PView::DrawCells flood cell's (or a building
/// look-in's) static content — RenderProjectionClass.IndoorCellStatic.
WalkFrameStaticRecords GetCellStatics(uint cellId);
/// 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 viewconeCheck.
WalkFrameStaticRecords GetCellDynamics(uint cellId);
/// One visited landscape (outdoor) cell's static content —
/// RenderProjectionClass.OutdoorStatic, keyed by the SAME
/// (landblockId & 0xFFFF0000) | (cellIndex+1) id
/// computes.
WalkFrameStaticRecords GetOutdoorStatics(uint cellId);
/// One visited landscape cell's live dynamic occupants. The
/// walk draws these at the same DrawObjCell turn as its statics,
/// through the landscape's currently installed view set.
WalkFrameStaticRecords GetOutdoorDynamics(uint cellId);
/// One building's own exterior shell content (IsBuildingShell
/// records anchored at the building's position cell).
WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building);
/// Building-local → world, for transforming a punch polygon
/// before — the
/// production implementation is 's
/// WorldTransform (FW3.2b-2 wiring).
Matrix4x4 GetBuildingWorldTransform(WalkBuilding building);
}
///
/// Campaign FW stage FW3.2b-1: the leaf GPU-adjacent actions
/// calls at walk turns that have no
/// submission path YET (sky, terrain, an
/// EnvCell shell, a portal punch fan, the interior depth clear, the exit-
/// portal seals) or that aren't a draw at all (the
/// barrier). Kept as its own seam — rather
/// than folding these into directly — so the
/// FW3.2b-1 headless referee suite can wire a fake and prove turn ORDER
/// without standing up the real renderers EnvCellRenderer,
/// TerrainModernRenderer, GameSky, and
/// PortalDepthMaskRenderer.DrawDepthFan — FW3.2b-2's job.
///
/// Stream submission itself (WbDrawDispatcher.PrepareOrderedStream/
/// DrawOrderedRange) is deliberately NOT part of this interface: it is
/// already real, production-tested machinery, so
/// calls it directly rather than abstracting a method that would just
/// forward to it one layer deeper.
///
internal interface IWalkFrameLeafRenderer
{
/// LScape::draw draws GameSky once per outdoor
/// walk (retail draws it once inside LScape::draw; the CURRENT
/// executor's per-slice call is per-slice-equals-once only for the
/// single-view outdoor case it handles today — the walk driver always
/// calls this exactly once per frame's Landscape turn).
void DrawSky();
/// S3 chunk 3 (§9.2 B1/B2), fix round 1 (F2), round 2 (F10):
/// retail RenderDeviceD3D::DrawLandCell @0x0059f120 — one or more
/// admitted LOD cells, possibly spanning several landblocks, submitted
/// together as ONE indirect draw.
/// keeps a PENDING batch that
/// 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
/// 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 / 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. 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,
/// TerrainModernRenderer.AppendCellIndexRuns) runs per entry.
/// Draws UNCLIPPED for both root kinds: retail never view-clips terrain
/// (LScape::draw draws whole blocks; the exit-seal/interior-
/// repaint turn owns aperture exactness afterward, S3 §9.1 R4/R5).
void DrawLandCellBatch(
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells);
/// S3 chunk 3 fix round 1 (F2): whether has any renderable emitter right now — production
/// wires this to ParticleSystem.HasRenderableEmittersInCell with
/// the SAME ParticleRenderPass.Scene pass both and
/// prepare through. 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.
bool HasRenderableEmittersInCell(uint cellId);
/// One committed cell's EnvCell shell —
/// PView::DrawCells's DrawEnvCell @0x005a4abe. Retail first
/// draws ALL shells in reverse cell_draw_list order, then starts a
/// second reverse loop for DrawObjCellForDummies @0x005a4b0d.
/// The ordinary interior-root flood and every building look-in flood use
/// this same two-pass discipline. Retail's runtime use_built_mesh
/// branch submits the complete constructed shell; the cell's drawn stamp,
/// portal depth writes, and later depth-tested repaint own visibility.
void DrawCellShell(uint cellId);
/// One landscape (land) cell's own-cell particle emitters, at
/// its own walk turn — see
/// for the retail positional invariant this carries (the #132 falls
/// containment). Retail CPhysicsObj::add_particle_shadow_to_cell
/// (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.
ReadOnlySpan PrepareStaticParticles(uint cellId);
/// 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.
ReadOnlySpan PrepareCellParticles(uint cellId);
/// PView::DrawCells @0x005a4840's gated full depth clear
/// (pc:432731-432732) between the outside stage and the interior root's
/// own flood — production maps this to IWorldPassScope.ClearInteriorDepth
/// (see that interface's own member of the same name in
/// RetailPViewRenderer.cs). The driver calls this leaf ONLY when
/// was nonzero at the
/// read-then-zero decision point (S3 §8.1 R4: retail's
/// portalsDrawnCount, read-then-zeroed @0x005a489c-0x005a489e —
/// forceClear 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 —
/// portalsDrawnCount never applies to LScape::draw's own
/// top-level walk) and never for a building look-in (R1: those call
/// DrawCells re-entrantly with ov==0, which never reaches
/// this leaf at all).
void ClearInteriorDepth();
/// The exit-portal seals (pc:432785-432786) — re-stamping every
/// outside-leading portal's TRUE depth right after
/// (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 (RetailPViewRenderer.DrawExitPortalMask/
/// PortalDepthMaskRenderer) — 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 D3DPolyRender::portalsDrawnCount @0x008719b4
/// increments once per DrawPortalPolyInternal call with its
/// second argument FALSE — exactly the exit-seal calls, never punch
/// fans) — the driver adds the returned count to
/// , which the NEXT
/// ov>0 interior-root flood's
/// decision reads. Only called for an INTERIOR root's own flood, never
/// for a building look-in (those call DrawCells re-entrantly with
/// no clear/seal step) and never outdoors.
int DrawExitSeals();
/// Retail D3DPolyRender::FlushAlphaList(0f) @0x005a4872
/// plus the pre-clear dynamics hook (RetailPViewRenderer's
/// FlushWalkLandscape) — the first action inside
/// if (outside_view.view_count > 0) 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 (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 (ov>0),
/// never outdoors and never for a building look-in.
void FlushLandscape();
/// DrawPortalPolyInternal @0x0059bc90's depth-only far-Z
/// punch fan — pass 1 of the building portal walk.
/// is already transformed building-local
/// → world ( does the transform via
/// at Collect
/// time — see that type's own doc comment). The real implementation is
/// PortalDepthMaskRenderer.DrawDepthFan with forceFarZ
/// (FW3.2b-2 wiring) — Replay calls this at exactly the point Collect
/// recorded it: any content queued ahead of the punch (a preceding
/// cell's/building's contents — never this building's OWN shell, which
/// retail draws only after the whole portal walk completes; see
/// 's type doc comment) reaches the GPU
/// first. is the view the emitting
/// two-pass walk was pinned to (retail building_view =
/// Render::portal_view_num @0x0059f3bf) — production clips the fan by
/// that view's slice planes.
void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex);
/// Retail D3DPolyRender::FlushAlphaList(0f)
/// @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.
void AlphaBarrier();
/// S4-c2: retail RenderDeviceD3D::DrawBlock @0x005a17c0's
/// (per-land-cell loop head @0x005a18d0) own FlushAlphaList(::flush)
/// @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
/// records it (after that
/// cell's object-list turn, before the next cell's land turn).
void FlushSortCellExit();
}
///
/// Campaign FW stage FW3.2b-1 test seam: an optional, diagnostic-only
/// observer of every ordered-stream range
/// draws. Production callers pass (the default) — this
/// exists purely so the headless referee suite can assert range COUNT,
/// per-range command count, and per-range stage without re-deriving them
/// from RecordingGpuDevice.Calls' lower-level RHI call log.
///
internal interface IWalkFrameDriverTrace
{
/// is a snapshot (never a live,
/// about-to-mutate list) of every command's in
/// the drawn segment, in stream order — by this stage's own segmenting
/// discipline (a segment boundary before every non-stream leaf action) a
/// segment is always single-stage in practice, but the full list is
/// passed so a test can assert that invariant itself instead of trusting
/// it.
void OnFlush(int commandCount, IReadOnlyList stages);
}
///
/// Campaign FW3.4a: one turn Collect recorded, replayed by
/// in the exact order Collect saw it.
/// is the collect-time analogue of
/// the old immediate driver's flush point — see 's
/// own doc comment for the full list and what each carries.
///
internal enum WalkFrameEventKind : byte
{
/// The accumulated grew since the
/// last mark and must be drawn, via WbDrawDispatcher.DrawOrderedRange,
/// before whatever leaf event follows.
/// is the stream's exclusive-end command index at the moment this event
/// was recorded.
StreamMark,
/// Submits every retained translucent walk batch through
/// up to the
/// exclusive-end index in . 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.
AlphaSubmitMark,
/// .
Sky,
/// —
/// one admitted land cell's terrain (retail DrawLandCell
/// @0x0059f120): is the landblock,
/// packs side << 8 | cellIndex.
/// 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 — , ,
/// , ,
/// , , ,
/// a particle turn whose cell has a renderable emitter, or the end of
/// Replay (S3 chunk 3 fix round 2, §9.7 F10; see 's
/// own doc comment for the full citation). A 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.
LandCell,
/// —
/// is the cell. Retail's
/// DrawEnvCell 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.
CellShell,
/// —
/// is the already-world-transformed
/// polygon (transformed at Collect time, exactly as the pre-FW3.4a driver
/// transformed it before its own immediate call),
/// is the active view index.
PunchFan,
/// —
/// is the viewer distance, computed
/// at Collect time (the context that supplies it does not outlive Collect).
AlphaBarrier,
/// .
LandscapeFlush,
/// .
ClearInteriorDepth,
/// .
ExitSeals,
/// —
/// ONE landscape (land) cell's ()
/// 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 DrawObjCell in the
/// far-to-near landscape walk, so every nearer building's pre-punch
/// alpha barrier (DrawBuilding @0x0059f2a0's
/// FlushAlphaList @0x0059f30b) drains the already-queued FARTHER
/// content against still-true depth BEFORE the punch stamps far-Z into
/// the aperture. The former single batched submission at the pre-clear
/// closure ran AFTER every punch — the barriers fired over an empty
/// queue and the falls drained against punched-far aperture pixels (the
/// cathedral bleed; the old pipeline's user-verified #132 fix
/// `e102fb36` encoded the same invariant). 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
/// turns (Campaign OVERHAUL S2 chunk 6, retail
/// CPhysicsObj::add_particle_shadow_to_cell 0x00514a70: an emitter
/// owns one shadow in its OWN current cell, never its parent's).
StaticParticles,
/// —
/// 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).
CellParticles,
/// S4-c2: —
/// retail RenderDeviceD3D::DrawBlock @0x005a17c0's (per-land-cell
/// loop head @0x005a18d0) own FlushAlphaList(0.75f).
SortCellExit,
}
///
/// The exact portal-view cones installed at the walk's building look-in
/// DrawCells turns. Retail RenderDeviceD3D::DrawMesh
/// @0x005A0860 tests each object's drawing sphere against these views before
/// drawing the mesh whole; cell membership alone is not an admission rule.
///
internal interface IWalkLookInViewSource
{
IReadOnlyList LookInCellTurns { get; }
///
/// 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 (RenderDeviceD3D::DrawMesh @0x005A0860's
/// per-view viewconeCheck loop)? Once any view admits, retail
/// submits the WHOLE constructed mesh once
/// (DrawMeshInternal @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 VisibleClipSlotsInLookInTurn
/// always collapsed to that same Boolean one call site up anyway).
/// 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.
///
bool SphereVisibleInLookInTurn(
int routeIndex,
in Vector3 center,
float radius,
bool testSphere = true);
/// 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.
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);
/// See for what each field means per
/// kind. A single struct (rather than a kind hierarchy) keeps Collect's
/// per-turn list a flat, allocation-cheap List<WalkFrameEvent> —
/// only (a punch fan's already-transformed geometry)
/// allocates, and only once per punch, which is rare enough per frame to be
/// unconditionally acceptable (plan §FW3.4a's own call).
internal readonly struct WalkFrameEvent
{
private WalkFrameEvent(
WalkFrameEventKind kind, int intArg, uint cellId, float floatArg, WalkPolygon? polygon)
{
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);
}
///
/// Campaign FW stage FW3.2b-1 — THE WALK FRAME DRIVER. Executes one full
/// static-content frame by driving with itself
/// as the , so that GPU command-buffer order
/// equals retail's walk order (plan §FW3.2b-1's "INTERLEAVING RULE").
///
/// Campaign FW3.4a — the ONE-walk split. Before this stage, a
/// single frame ran TWICE — once with a
/// set-collecting sink to learn the flood/visited-cell set before
/// PrepareCellBatches, once more through this driver to actually
/// submit — and each walk turn's stream content flushed IMMEDIATELY through
/// its own full WbDrawDispatcher.SubmitOrderedStream call (~40 of
/// those per frame at a town, each rewriting and rebinding all nine
/// per-instance sections for that turn's handful of instances). The FW3.4
/// perf checkpoint measured +33.5% CPU p50 and 14× frame allocation from
/// exactly those two costs (plus a third, unrelated one — see
/// 's own doc comment) and tripped the
/// campaign's ±20% stop rule. This stage collapses both:
/// runs ONCE, doing everything the immediate
/// driver used to do EXCEPT the actual GPU submission — stream appends
/// accumulate without flushing, every former immediate leaf call records a
/// instead, and the driver keeps its
/// visited-set bookkeeping (absorbing the renderer's old dedicated
/// set-collecting sink) so the SAME walk answers both questions.
/// then performs the actual GPU work afterward:
/// WbDrawDispatcher.PrepareOrderedStream uploads the WHOLE frame's
/// stream once, and each recorded
/// becomes one cheap DrawOrderedRange call over the already-uploaded
/// payload — interleaved, in the exact recorded order, with the leaf
/// renderer calls the OLD immediate driver made inline. Because Replay walks
/// the SAME event sequence Collect recorded at the SAME points the old code
/// flushed, GPU command order is unchanged bit-for-bit; only the number of
/// walks (two → one) and the shape of the GPU submission (many small
/// rebind-and-draw calls → one bind, many cheap draws) changes.
/// remains Collect immediately followed by Replay, for
/// callers (today: the headless referee suite) that do not need the split;
/// RetailPViewRenderer uses the split directly, since it must run
/// PrepareCellBatches/BuildAndBorrow BETWEEN them.
///
/// The one mark rule that reproduces the whole frame script:
/// before EVERY leaf-renderer event (,
/// LandCell, CellShell, LandscapeFlush,
/// ClearInteriorDepth, ExitSeals, PunchFan) and before
/// every event, Collect
/// records a if the stream grew
/// since the last one (a no-op otherwise — "empty segments submit
/// nothing"); a building's own shell content is APPENDED (not marked) the
/// moment fires, so it only
/// gets a mark ahead of whatever non-stream event comes next (the next
/// building's alpha barrier, or the final mark at 's
/// prepare step). This single rule, combined with retail's two reverse
/// flood passes (ALL shells, then ALL contents),
/// retail's own building order (alpha barrier → portal pass → shell — see
/// 's doc comment), and retail's
/// own interior-root DRAW order (landscape → flush/stamp/[gated clear]/seals
/// → the flood's own cells, the middle four steps ALL gated on
/// outside_view.view_count > 0 — see
/// 's doc comment; this
/// is NOT the order the walk's EVENTS fire in, which is breakpoint-entry
/// order matching the FW0 oracle traces), is what produces every ordering
/// constraint the plan's frame script names: [far shell] … [near shell]
/// [far contents] … [near contents], [alpha barrier] [punch fan(s) +
/// look-in flood(s), each following the SAME reverse two-pass discipline]
/// [building shell content mark], [landscape (if exit views survived)]
/// [landscape flush] [gated interior depth clear] [exit-portal seals] — the
/// landscape and those three only when outside_view.view_count > 0
/// (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.
///
/// Retail anchors: SmartBox::RenderNormalMode @0x00453aa0 (the
/// root already ports),
/// RenderDeviceD3D::DrawSortCell @0x0059f140 (building-before-
/// DrawObjCell per landscape cell), PView::DrawCells @0x005a4840
/// (the complete reverse DrawEnvCell loop @0x005a4a00–0x005a4ade
/// before the complete reverse DrawObjCellForDummies loop
/// @0x005a4ade–0x005a4b2d; LScape::draw pc:432719, the depth
/// clear pc:432731-432732, and the exit-portal seals pc:432785-432786 —
/// ALL strictly before the flood's own cells, though the event marking
/// DrawCells entry fires before all three), RenderDeviceD3D::DrawBuilding
/// @0x0059f2a0 (the part->gfxobj[deg_level]!=0 gate @0x0059f2d3
/// and the alpha-barrier → portal-pass → shell order @0x0059f30b–0x0059f345).
///
internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
{
private readonly WbDrawDispatcher _dispatcher;
private readonly WalkStaticStreamPopulator _populator;
private IWalkFrameLeafRenderer _leafRenderer;
private readonly IWalkFrameWorldData _worldData;
private readonly IWalkFrameDriverTrace? _trace;
private ClipFrame? _clipFrame;
private readonly OrderedDrawStream _stream = new();
private readonly List _events = new();
private readonly List _markPositions = new();
private readonly List _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 VisitedCells { get; } = new();
/// Building look-in cells in the exact order the walk encountered
/// their DrawCells turns. Duplicates are intentional: two authored
/// portal views can independently visit the same cell and therefore own
/// distinct packed route indices.
internal List LookInCellTurns { get; } = new();
private readonly List _lookInTurns = new();
private readonly List _lookInSlices = new();
private readonly List _lookInPlanes = new();
private readonly List _floodViewRouteScratch = new();
private WalkPlane _lookInCyPlane;
/// S3 chunk 4 fix round 1 (K2): set exactly when fires this frame — i.e. a
/// Landscape turn actually ran (retail's RetailFrameWalk.DrawLandscape
/// only reaches that call for an outdoor root, or an interior root whose
/// flood still has a surviving exit view) AND ctx.WeatherGateOpen
/// 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 (ctx.WeatherGateOpen
/// alone) and the draw's gate (clipAssembly.OutsideViewSlices.Length
/// > 0, 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
/// (/); read at Replay
/// by .
internal bool WeatherTurnFired { get; private set; }
/// S3 chunk 3 (§9.2 B2), fix round 1 (F2), round 2 (F10):
/// 's PENDING terrain batch — every
/// event since the last flush,
/// across however many landblocks and however many
/// events (F10: not a flush point), in event order. Cleared at the start
/// of each call ("frame start") and by every flush;
/// NOT cleared between events, since it is the whole point of the
/// deferred-batching rule.
private readonly List<(uint LandblockId, int SideCellCount, int CellIndex)> _pendingTerrainBatch = new();
private readonly HashSet _cellShellsDrawnThisFrame = new();
// Chunk 6 review F1: one particle turn per cell per render stamp (see EmitCellContentsTurn).
private readonly HashSet _cellParticleTurnsDrawnThisFrame = new();
/// S3 chunk 2 (§8.2 B2): retail's D3DPolyRender::portalsDrawnCount
/// (uint16 @0x008719b4) — retained ACROSS frames (this driver is itself
/// retained by RetailPViewRenderer), never cleared by
/// ///
/// . Read-then-zeroed at every ov>0
/// interior-root flood's clear decision (
/// implementation, S3 §8.1 R4); incremented at by
/// the count returns
/// for THIS frame's own exit-seal turn, which the NEXT ov>0
/// 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
/// DrawCells; 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>0 frame without a
/// clear — no retail analogue, no production path.
internal int PortalsDrawnCount;
IReadOnlyList IWalkLookInViewSource.LookInCellTurns => LookInCellTurns;
/// The set form of , for drawn-once
/// exclusion and root-flood particle bookkeeping.
internal HashSet LookInCells { get; } = new();
/// FW4 slice 2: the interior root's ORDERED flood cell list,
/// exactly as retail's PView::DrawCells iterates it for the
/// exit-portal seals (pc:432785-432786) — captured at
/// . Empty for an
/// outdoor-rooted frame. The production seal draw iterates THIS list
/// (the walk's own flood), not the old visibility apparatus's — at the
/// #456 seam band the old builder's flood misses exit portals the walk
/// provably floods, leaving unsealed aperture depth the end-of-frame
/// alpha drain splats through (the cathedral falls shine-through).
internal List InteriorFloodCells { get; } = new();
/// Number of exact walk-owned portal-view slices that retail's
/// CEnvCell::setup_view installs while sealing one entry in
/// . The matching route was captured at
/// that cell's shell turn and remains valid through synchronous replay.
internal int InteriorFloodViewSliceCountAt(int floodCellIndex)
{
int routeIndex = InteriorFloodViewRouteAt(floodCellIndex);
return _lookInTurns[routeIndex].SliceCount;
}
/// 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.
internal ReadOnlySpan 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.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;
}
/// Test-only accessor (S3 chunk 4 fix round 1, K7): the packed
/// GPU clip slot one look-in route's slice was assigned by 's call to — the
/// SAME value resolves
/// through _clipFrame.GetSlotPlanes(slice.ClipSlot) for the root-
/// flood case. Restores the "a real look-in slice never reuses the
/// reserved no-clip slot 0" pin the deleted VisibleClipSlotsInLookInTurn
/// used to prove. indexes _lookInTurns
/// directly, matching 's own
/// parameter (not the flood-cell indirection goes through).
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 VisitedBuildings { get; } = new();
internal HashSet VisitedLandscapeCellIds { get; } = new();
// ---- transient per-Collect state (set in BeginFrame, read by Replay,
// cleared by Replay's own completion) ----
private IWalkBuildingFrameContext? _ctx;
private Matrix4x4 _viewProjection;
private Vector3 _cameraWorldPosition;
/// Frame-scoped fail-loud guard (replaces the deleted
/// _skyDrawnThisFrame proxy — S3 chunk 2): counts this frame's
/// turns so a second one throws
/// (see ) 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
/// outside_view.view_count the sink receives, not this counter.
private int _landscapeTurnsThisFrame;
private WalkDrawStage? _currentDcStage;
private bool _readyToReplay;
private int _cellViewRouteIndex;
private int _landscapeViewRouteIndex;
/// 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.
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);
}
///
/// 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.
///
internal void RebindFrame(
IWalkFrameLeafRenderer leafRenderer,
ClipFrame? clipFrame)
{
AbortFrame();
_leafRenderer = leafRenderer
?? throw new ArgumentNullException(nameof(leafRenderer));
_clipFrame = clipFrame;
}
/// Discard only transient frame state; retained capacities stay
/// available for the next frame.
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;
}
///
/// 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 CObjCell::IsInView answer.
///
internal void CopyVisibleCellsTo(HashSet 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);
}
///
/// Drives one complete frame at retail's root (SmartBox::RenderNormalMode):
/// immediately followed by . Kept
/// for callers that don't need the split (today: the headless referee
/// suite) — RetailPViewRenderer calls the pair directly, since it
/// must run other frame work BETWEEN them (plan §FW3.4a).
///
internal void RunFrame(
RetailFrameWalk walk,
uint cameraCellId,
WalkCell? cameraCell,
WalkLandscape landscape,
IRetailFrameWalkContext ctx,
IGpuFrame frame,
IGpuPassEncoder encoder,
Matrix4x4 viewProjection,
Vector3 cameraWorldPosition)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(encoder);
Collect(
walk, cameraCellId, cameraCell, landscape, ctx,
viewProjection, cameraWorldPosition);
Replay(frame, encoder);
}
///
/// Campaign FW3.4a Phase 1 — THE ONE WALK. Drives
/// with this driver as its sink,
/// sandwiched between /,
/// performing NO GPU work: see this type's own doc comment.
///
internal void Collect(
RetailFrameWalk walk,
uint cameraCellId,
WalkCell? cameraCell,
WalkLandscape landscape,
IRetailFrameWalkContext ctx,
Matrix4x4 viewProjection,
Vector3 cameraWorldPosition)
{
ArgumentNullException.ThrowIfNull(walk);
ArgumentNullException.ThrowIfNull(landscape);
ArgumentNullException.ThrowIfNull(ctx);
BeginFrame(ctx, viewProjection, cameraWorldPosition);
// 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;
}
}
///
/// Opens a collect scope without driving the walk itself — for a caller
/// (or a test) that already holds an isolated walk entry point (e.g. one
/// or
/// call) and wants this
/// driver's turn handling without going through the top-level root.
/// is implemented in terms of this pair. Performs no
/// GPU work — see this type's own doc comment.
///
internal void BeginFrame(
IRetailFrameWalkContext ctx,
Matrix4x4 viewProjection,
Vector3 cameraWorldPosition)
{
ArgumentNullException.ThrowIfNull(ctx);
if (_ctx is not null)
{
throw new InvalidOperationException(
"WalkFrameDriver.BeginFrame was called while a previous frame was still open — "
+ "the driver is not re-entrant (Campaign FW3.2b-1 fail-loud rule); call "
+ "EndFrame (or let a thrown exception's cleanup run) before starting the next.");
}
_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;
}
/// Records the final segment mark (plan §FW3.2b-1's "at frame
/// end: final segment flush", now a mark rather than a draw — see this
/// type's own doc comment), then closes the collect scope. The recorded
/// stream/events survive this call — consumes them —
/// which is the one behavioral difference from the pre-FW3.4a EndFrame,
/// which reset the stream here because it had just drawn it.
internal void EndFrame()
{
try
{
MarkIfGrown();
MarkAlphaIfGrown();
}
finally
{
_dispatcher.EndWalkPartFrame();
_ctx = null;
_readyToReplay = true;
}
}
///
/// Campaign FW3.4a Phase 2. Requires a completed Collect (an
/// having run since the last Replay) — throws
/// otherwise, rather than silently replaying a stale or empty event list.
/// Uploads the WHOLE collected stream exactly once (skipped when it is
/// empty), then walks the recorded events in order: a
/// issues one
/// WbDrawDispatcher.DrawOrderedRange call over the segment it
/// closes off; every other event kind issues its corresponding
/// call. Because Collect recorded
/// these events at EXACTLY the points the pre-FW3.4a immediate driver
/// used to flush/draw, this reproduces the SAME interleaved GPU command
/// order — the campaign invariant — from one walk instead of two.
///
/// S3 chunk 3 fix round 2 (§9.7 F10) — the pending terrain
/// batch's flush points, authoritative list. A
/// is NOT one of them: the
/// ordered stream it draws holds ONLY opaque batches — WalkStaticStreamPopulator's
/// per-entity classify step appends to the stream iff batch.IsOpaque
/// and otherwise routes translucent batches to the alpha list
/// (WalkStaticStreamPopulator.cs:179) — drawn through the Opaque/
/// OpaqueAlphaToCoverage pipelines, both built with depth test AND
/// write ON and NO blending (WbDrawDispatcher.CreateMeshPipeline:
/// Depth = new GpuDepthState(Test: true, Write: depthWrite,
/// depthCompare) called with depthWrite: true,
/// GpuBlendMode.None for both, WbDrawDispatcher.Rhi.cs:240-248,343);
/// DrawOrderedRange resolves every walk-stream command's bucket to
/// PipelineBucket.Opaque 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 StreamMark changes nothing observable.
/// The flush points that DO remain, because their GPU order against
/// terrain IS observable:
/// (DEPTHTEST_ALWAYS + write — the far-Z stamp this chunk's
/// interleave exists to order correctly against nearer terrain, S3 §9.1
/// R4), and
/// (both drain the
/// translucent alpha list, which must see every opaque surface — terrain
/// included — already resolved in the depth buffer),
/// ,
/// ,
/// , ,
/// a /
/// 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 StreamMark itself: if any future ordered-stream
/// stage draws with blending or a disabled depth write (for example the
/// deferred building-detail overlay replay,
/// WbDrawDispatcher.DrawBuildingDetailRangeRhi's
/// RetailDetailTransparent pass — not wired into this walk path
/// today, OrderedStream.cs:46-52), that stage's StreamMark MUST
/// become a flush point again; the contract gets amended, not bent.
///
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;
}
}
///
/// 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.
///
private void SubmitCellAlpha(
in WalkFrameEvent e,
ref int alphaCursor,
bool staticParticleTurn)
{
ReadOnlySpan particles =
ReadOnlySpan.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);
}
}
}
/// S3 chunk 3 fix round 1 (F2), round 2 (F10): submits the
/// pending terrain batch as ONE
/// call, if it holds anything, and clears it. Called immediately before
/// every event kind whose GPU order against terrain is OBSERVABLE — see
/// '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
/// 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.
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;
}
}
/// S3 chunk 3 (§9.2 B1): records this admitted land cell's
/// terrain turn. sideCellCount/cellIndex 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.
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));
}
/// S3 chunk 1 fix round 1 (G7): RenderDeviceD3D::
/// DrawSortCell @0x0059f140 ENTRY — fired by
/// BEFORE that turn's own DrawBuilding 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
/// AFTER
/// DrawBuilding had already run). Print-only — no stream-side effect of
/// its own; the object-list turn stays entirely in
/// OnLandscapeCellTurn below, unchanged.
///
/// S3 chunk 1 fix round 2 (§11.6 H4): UNLIKE
/// (whose RequireOpenFrame/range validation stay unconditional —
/// that hook always records a real WalkFrameEvent), this hook has
/// NO side effect beyond the print, so the flag gate wraps the ENTIRE
/// body, not just the LodCellId computation: an ordinary launch
/// pays exactly one interface dispatch per visited land cell and nothing
/// else (docs/launch-options.md).
///
///
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);
/// The object-list half of retail's DrawSortCell turn — see
/// 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.
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));
}
/// S4-c2: retail RenderDeviceD3D::DrawBlock @0x005a17c0's
/// (per-land-cell loop head @0x005a18d0) own FlushAlphaList(::flush)
/// @0x005a1a07 (the 0.75f
/// pressure valve) — fires once per admitted land-block cell, at the SAME
/// gate
/// already fires under (RetailFrameWalk.DrawLandscape calls this
/// immediately after that turn, still inside the loop body). Marks first,
/// same as 's AlphaBarrier
/// emission, so this cell's own content flushes ahead of the valve.
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 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);
}
/// S3 chunk 4 (O3): retail's GameSky::Draw(sky,1)
/// @0x00506ff0 weather pass —
/// only calls this when its own gate already held, so this handler
/// records 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 's own shape (no
/// stream side effect). The GPU draw stays where chunk 1 round 2 put
/// it — , called
/// directly from
/// at Replay time, now gated on THIS flag instead of re-deriving its
/// own independent condition.
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 cells)
{
RequireOpenFrame();
if (_currentDcStage is not { } stage)
{
throw new InvalidOperationException(
"A DrawCells turn fired before any DrawInside or Building turn established "
+ "which stage its cells belong to — a walk/driver desync (Campaign FW3.2b-1 "
+ "fail-loud rule): RetailFrameWalk only ever emits DrawCells after DrawInside "
+ "(the interior root's own flood) or after a building's look-in portal pass.");
}
if (stage == WalkDrawStage.CellStatic)
{
// The interior root's OWN flood: this DC event fired at
// breakpoint-ENTRY order (matching the FW0 oracle traces), not
// retail's actual draw point — RECORD only. RetailFrameWalk.
// DrawInside hands the SAME cell list to
// OnInteriorFloodDrawTurn later, at the point retail really
// draws it (after the landscape turn, or immediately when no
// exit views survived) — see that method's doc comment.
return;
}
// Any other stage (LookInStatic) is a building's look-in flood:
// retail calls DrawCells re-entrantly there with no landscape/clear/
// seal step, so its DC event already fires at the real draw point —
// record immediately. PView::DrawCells uses two complete reverse
// loops: every EnvCell shell first, then every cell object list.
EmitFloodTurns(stage, cells);
}
private void EmitFloodTurns(WalkDrawStage stage, IReadOnlyList cells)
{
_floodViewRouteScratch.Clear();
for (int i = 0; i < cells.Count; i++)
_floodViewRouteScratch.Add(-1);
// PView::DrawCells @0x005A4840, loop 2 (005A4A00–005A4ADE):
// 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 (005A4ADE–005A4B2D): restart at count - 1 and draw each
// complete object cell. Keep a stream mark per cell so the packed
// static records and its dynamic/particle tail stay at that exact
// retail turn.
for (int i = cells.Count - 1; i >= 0; i--)
EmitCellContentsTurn(stage, cells[i], _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));
}
/// See the interface member's own doc comment (S3 chunk 4,
/// §10.2): replaces the deleted VisibleClipSlotsInLookInTurn'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 Count == 0).
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();
}
/// 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).
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 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 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 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]));
}
/// Campaign FW3.4a: the collect-time analogue of the old
/// immediate driver's FlushIfNonEmpty — records a
/// at the stream's current
/// length if it grew since the last mark (a no-op otherwise, exactly
/// like that method's own "empty segments submit nothing" rule). Also
/// appends the boundary to , which
/// hands to PrepareOrderedStream so a merge
/// run can never span it — see WbDrawDispatcher.BuildOrderedMergeRuns's
/// forcedBreaksAscending parameter for why that matters.
private void MarkIfGrown()
{
int count = _stream.Count;
int last = _markPositions.Count > 0 ? _markPositions[^1] : 0;
if (count == last)
return;
_markPositions.Add(count);
_events.Add(WalkFrameEvent.Mark(count));
}
/// 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.
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.");
/// ConstructBuildingView's polygon is building-local; the
/// punch fan needs world space. Vertices transform directly; the plane
/// normal uses (correct for the
/// rigid, shear-free placements and
/// build) and D is rederived from
/// the transformed normal and the first transformed vertex — the SAME
/// construction /
/// already use for their own polygons (Plane = new WalkPlane(normal,
/// -Vector3.Dot(normal, vertices[0]))).
private static WalkPolygon TransformToWorld(WalkPolygon local, Matrix4x4 worldTransform)
{
var vertices = new Vector3[local.Vertices.Length];
for (int i = 0; i < vertices.Length; i++)
vertices[i] = Vector3.Transform(local.Vertices[i], worldTransform);
Vector3 normal = local.Vertices.Length > 0
? Vector3.Normalize(Vector3.TransformNormal(local.Plane.Normal, worldTransform))
: Vector3.Zero;
float d = vertices.Length > 0 ? -Vector3.Dot(normal, vertices[0]) : 0f;
return new WalkPolygon { Vertices = vertices, Plane = new WalkPlane(normal, d) };
}
}