fix(render): FW4 - static-owner particles submit at their walk turns

Ports the user-verified #132 invariant (e102fb36 on the old pipeline)
walk-natively, on the owner''s direction: 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
(DrawSortCell @0x005a17c0), so every nearer building''s pre-punch
barrier (DrawBuilding @0x0059f2a0''s FlushAlphaList @0x0059f30b)
drains the already-queued FARTHER content against still-true depth
BEFORE its punch stamps far-Z into the aperture. The walk path had
kept the per-building AlphaBarrier events but batched ALL landscape
static-owner submission into one lump at the pre-clear closure - after
every punch had run: the barriers fired over an empty queue and the
falls drained against punched-far aperture pixels (phase=pre in every
probe line, which is why six rounds of phase-staging repairs could
never see it - the phase was right, the position within the phase was
wrong).

New WalkFrameEventKind.StaticParticles: each landscape cell''s and
each building shell''s emitter owners now submit AT THAT TURN during
Replay (marked so the owner''s meshes flush first - retail''s
per-object order); the batched SubmitWalkLandscapeStaticParticles and
its closure/post-replay call sites are deleted for both root kinds.
Two driver sequence pins adjudicated to the new turn order.

Hermetic 6,762/0; InstalledDat walk conformance 40/1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-30 21:26:40 +02:00
parent f4522297cc
commit 280c054943
4 changed files with 123 additions and 47 deletions

View file

@ -112,6 +112,12 @@ internal interface IWalkFrameLeafRenderer
/// this same shell-then-contents order).</summary>
void DrawCellShell(uint cellId);
/// <summary>One landscape cell's or building shell's static-owner
/// particle submission, at its own walk turn — see
/// <see cref="WalkFrameEventKind.StaticParticles"/> for the retail
/// positional invariant this carries (the #132 falls containment).</summary>
void DrawStaticParticles(IReadOnlySet<uint> ownerIds);
/// <summary><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>
@ -236,6 +242,23 @@ internal enum WalkFrameEventKind : byte
/// <summary><see cref="IWalkFrameLeafRenderer.DrawExitSeals"/>.</summary>
ExitSeals,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawStaticParticles"/> —
/// ONE landscape cell's (<see cref="WalkFrameEvent.CellId"/>) or ONE
/// building shell's (<see cref="WalkFrameEvent.Building"/>) static-owner
/// particle submission, emitted AT ITS OWN WALK TURN. Retail's falls
/// containment is positional: an outdoor emitter's polys join the one
/// alpha list during its owner cell's <c>DrawObjCell</c> in the
/// far-to-near landscape walk, so every nearer building's pre-punch
/// alpha barrier (<c>DrawBuilding</c> @0x0059f2a0's
/// <c>FlushAlphaList</c> @0x0059f30b) drains the already-queued FARTHER
/// content against still-true depth BEFORE the punch stamps far-Z into
/// the aperture. The former single batched submission at the pre-clear
/// closure ran AFTER every punch — the barriers fired over an empty
/// queue and the falls drained against punched-far aperture pixels (the
/// cathedral bleed; the old pipeline's user-verified #132 fix
/// `e102fb36` encoded the same invariant).</summary>
StaticParticles,
}
/// <summary>See <see cref="WalkFrameEventKind"/> for what each field means per
@ -247,13 +270,15 @@ internal enum WalkFrameEventKind : byte
internal readonly struct WalkFrameEvent
{
private WalkFrameEvent(
WalkFrameEventKind kind, int intArg, uint cellId, float floatArg, WalkPolygon? polygon)
WalkFrameEventKind kind, int intArg, uint cellId, float floatArg, WalkPolygon? polygon,
WalkBuilding? building = null)
{
Kind = kind;
IntArg = intArg;
CellId = cellId;
FloatArg = floatArg;
Polygon = polygon;
Building = building;
}
internal WalkFrameEventKind Kind { get; }
@ -266,6 +291,11 @@ internal readonly struct WalkFrameEvent
internal WalkPolygon? Polygon { get; }
/// <summary><see cref="WalkFrameEventKind.StaticParticles"/> only: the
/// building whose shell statics' owners submit at this turn; null for a
/// landscape cell's turn (then <see cref="CellId"/> names the cell).</summary>
internal WalkBuilding? Building { get; }
internal static WalkFrameEvent Mark(int exclusiveEnd) =>
new(WalkFrameEventKind.StreamMark, exclusiveEnd, 0, 0f, null);
@ -284,6 +314,12 @@ internal readonly struct WalkFrameEvent
internal static WalkFrameEvent AlphaBarrier(float viewerDistance) =>
new(WalkFrameEventKind.AlphaBarrier, 0, 0, viewerDistance, null);
internal static WalkFrameEvent LandscapeCellParticles(uint cellId) =>
new(WalkFrameEventKind.StaticParticles, 0, cellId, 0f, null);
internal static WalkFrameEvent BuildingShellParticles(WalkBuilding building) =>
new(WalkFrameEventKind.StaticParticles, 0, 0, 0f, null, building);
internal static WalkFrameEvent ClearInteriorDepth() =>
new(WalkFrameEventKind.ClearInteriorDepth, 0, 0, 0f, null);
@ -397,6 +433,20 @@ internal sealed class WalkFrameDriver : IWalkEventSink
/// alpha drain splats through (the cathedral falls shine-through).</summary>
internal List<uint> InteriorFloodCells { get; } = new();
// Replay scratch for StaticParticles events (sequential replay — one
// reused set is safe).
private readonly HashSet<uint> _staticParticleOwnerScratch = new();
private static void UnionOwners(
in WalkFrameStaticRecords records, HashSet<uint> destination)
{
foreach (RenderProjectionRecord record in records.Records)
{
if (record.Source.LocalEntityId != 0)
destination.Add(record.Source.LocalEntityId);
}
}
internal List<WalkBuilding> VisitedBuildings { get; } = new();
internal HashSet<uint> VisitedLandscapeCellIds { get; } = new();
@ -595,6 +645,16 @@ internal sealed class WalkFrameDriver : IWalkEventSink
case WalkFrameEventKind.ExitSeals:
_leafRenderer.DrawExitSeals();
break;
case WalkFrameEventKind.StaticParticles:
_staticParticleOwnerScratch.Clear();
UnionOwners(
e.Building is WalkBuilding shellOwner
? _worldData.GetBuildingShellStatics(shellOwner)
: _worldData.GetOutdoorStatics(e.CellId),
_staticParticleOwnerScratch);
if (_staticParticleOwnerScratch.Count > 0)
_leafRenderer.DrawStaticParticles(_staticParticleOwnerScratch);
break;
}
}
@ -639,6 +699,27 @@ internal sealed class WalkFrameDriver : IWalkEventSink
_populator.PopulateOutdoorStatics(
_stream, cellId, records.Records, records.TupleLandblockId,
_cameraWorldPosition, _viewProjection);
// FW4 (the #132 positional invariant): this cell's emitter owners
// submit AT THIS TURN, so nearer buildings' pre-punch barriers
// drain them against still-true depth — see
// WalkFrameEventKind.StaticParticles. Mark first so the cell's own
// meshes flush ahead of its particle submission (retail's
// per-object DrawObjCell order).
if (HasAnyOwner(records))
{
MarkIfGrown();
_events.Add(WalkFrameEvent.LandscapeCellParticles(cellId));
}
}
private static bool HasAnyOwner(in WalkFrameStaticRecords records)
{
foreach (RenderProjectionRecord record in records.Records)
{
if (record.Source.LocalEntityId != 0)
return true;
}
return false;
}
void IWalkEventSink.OnBuildingTurn(WalkBuilding building)
@ -672,6 +753,14 @@ internal sealed class WalkFrameDriver : IWalkEventSink
_populator.PopulateCell(
_stream, WalkDrawStage.BuildingShell, building.PositionCellId,
shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection);
// FW4 (the #132 positional invariant): the building's own shell
// emitters submit at the shell turn, after the shell content
// flushes — see WalkFrameEventKind.StaticParticles.
if (HasAnyOwner(shell))
{
MarkIfGrown();
_events.Add(WalkFrameEvent.BuildingShellParticles(building));
}
}
void IWalkEventSink.OnPunchGeometry(