feat(render): S2 chunk 6 — particle emitters draw by their own cell (add_particle_shadow_to_cell)

Owner G2 finding: the purple cloud around an arriving character no longer
drew. The server keeps the player Hidden until acdream sends LoginComplete at
reveal completion (retail-correct); the Hidden-state script's emitters spawn
in the arrival cell and are view-eligible when the world appears, but the
walk drew an owner's emitters only through the owner's registry rows, and a
hidden owner's shadow is suspended. Retail's
CPhysicsObj::add_particle_shadow_to_cell (0x00514a70) gives an emitter one
shadow in its OWN current cell, drawn at that cell's turn regardless of the
parent's hidden state (add_shadows_to_cells 0x00514aed skips the flood for
state & 0x1000).

Port: ParticleSystem keeps a per-pass cell -> renderable-handles index
(maintained at every renderable/OwnerCellId change) and
CopyRenderableEmittersInCell; ParticleRenderer.DrawForCell; the walk draws
particles BY CELL at the existing turns (interior CellParticles, landscape
LandscapeCellParticles), the events fire for every visited cell, and every
owner-union particle path is deleted (UnionOwners/UnionNewOwners for
particles, the outdoor drawn-owner dedupe, the executor's owner
classification sets, the context ParticleOwnerIds members). The post-replay
per-cell pass double-submitted the root flood's emitters and is deleted: an
emitter draws once, at its cell's replay turn. AD-117 item 4 becomes a port
note (the index lives in the particle system; an emitter is not a physics
object in acdream). The temporary [pes-spawn]/[pes-vis] traces are removed
and the ACDREAM_DUMP_PLAYSCRIPT row restored.

Verified: timed arrival route logs/selfgate-20260903-062522-haze-chunk6,
frame h02-arrive-400ms shows the cloud at the character in Facility Hub.
Gates (Release): Core 4,987/4,987; Content 214/214; Runtime 1,884/1,884; App
hermetic lane 6,760/6,760; App InstalledDat 217 pass / 2 pre-existing #383.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-03 06:28:09 +02:00
parent 2d20ee917b
commit f6b4584bf3
14 changed files with 400 additions and 336 deletions

View file

@ -470,10 +470,6 @@ internal sealed class ContentEffectsAudioCompositionPhase :
Fault(ContentEffectsAudioCompositionPoint.EmitterRegistryPublished);
ParticleSystem particles = _factory.CreateParticleSystem(emitters);
// TEMPORARY (2026-09-03 portal-haze investigation): the system's
// own trace is gated inside on ACDREAM_DUMP_PLAYSCRIPT=1.
particles.DiagnosticSink = message =>
_dependencies.Error($"vfx: {message}");
_publication.PublishParticleSystem(particles);
Fault(ContentEffectsAudioCompositionPoint.ParticleSystemPublished);

View file

@ -262,6 +262,42 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
FinishDraw(camera, renderPass);
}
/// <summary>
/// Draws exactly one cell's renderable emitters — retail
/// <c>CPhysicsObj::add_particle_shadow_to_cell</c> (0x00514a70): an
/// emitter owns one shadow in its own current cell, drawn at that cell's
/// object turn like any object, independent of its attached owner's
/// registry membership (a hidden/suspended owner's emitter still draws).
/// No portal-view clip is applied here — retail never clips a particle to
/// a view; occlusion is the depth test at the alpha flush
/// (<c>add_shadows_to_cells</c> 0x00514aed's particle branch skips the
/// CELLARRAY flood/clip-planes entirely for this state bit).
/// </summary>
public void DrawForCell(
ICamera camera,
Vector3 cameraWorldPos,
ParticleRenderPass renderPass,
uint cellId,
uint clipSlot = 0)
{
if (camera is null)
return;
_particles.CopyRenderableEmittersInCell(renderPass, cellId, _scopedEmitterScratch);
Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
BuildDrawLists(
cameraWorldPos,
renderPass,
cameraRight,
cameraUp,
emitterFilter: null,
_scopedEmitterScratch,
clipSlot);
FinishDraw(camera, renderPass);
}
private void FinishDraw(ICamera camera, ParticleRenderPass renderPass)
{
if (_submissionScratch.Count == 0)

View file

@ -274,16 +274,11 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
public void ClearInteriorDepth() => _clearInteriorDepth();
public void DrawStaticParticles(IReadOnlySet<uint> ownerIds) =>
_passes.DrawLandscapeStaticParticles(
_frame, new RetailPViewLandscapeStaticParticleContext(ownerIds));
public void DrawStaticParticles(uint cellId) =>
_passes.DrawLandscapeStaticParticles(_frame, cellId);
public void DrawCellParticles(uint cellId, IReadOnlySet<uint> ownerIds)
{
_passes.DrawCellParticles(
_frame,
new RetailPViewCellSliceContext(cellId, default, ownerIds));
}
public void DrawCellParticles(uint cellId) =>
_passes.DrawCellParticles(_frame, cellId);
public void DrawExitSeals() => _drawExitSeals();

View file

@ -41,37 +41,6 @@ internal sealed class RetailPViewCellSource : IRetailPViewCellSource
_cells.TryGetCell(cellId, out LoadedCell? cell) ? cell : null;
}
internal sealed class RetailPViewParticleClassifications
{
private readonly HashSet<uint> _outdoor = [];
private readonly HashSet<uint> _visible = [];
private readonly HashSet<uint> _dynamics = [];
public IReadOnlySet<uint> Outdoor => _outdoor;
public HashSet<uint> Visible => _visible;
public HashSet<uint> Dynamics => _dynamics;
public void BeginFrame()
{
_outdoor.Clear();
_visible.Clear();
_dynamics.Clear();
}
public void ReplaceOutdoor(IReadOnlyList<WorldEntity> owners)
{
_outdoor.Clear();
foreach (WorldEntity owner in owners)
_outdoor.Add(owner.Id);
}
public void ReplaceOutdoor(IReadOnlySet<uint> ownerIds)
{
_outdoor.Clear();
_outdoor.UnionWith(ownerIds);
}
}
/// <summary>
/// Concrete GL implementation of the named passes ordered by
/// <see cref="RetailPViewRenderer"/>. It owns reusable pass-local particle
@ -107,14 +76,17 @@ internal sealed partial class RetailPViewPassExecutor :
private readonly RetailAlphaQueue _alpha;
private readonly WorldRenderDiagnostics _diagnostics;
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
private readonly RetailPViewParticleClassifications _particleClassifications = new();
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
/// <summary>
/// Borrowed until the next late landscape pass. The outdoor-root post-world
/// particle pass consumes this synchronously before another PView frame.
/// </summary>
// Campaign OVERHAUL S2 chunk 6: the walk now draws every owner's scene
// particles at its own cell's turn (DrawLandscapeStaticParticles/
// DrawCellParticles below), so there is no longer a per-frame outdoor
// owner set to hand the flat-world safety path's post-world particle
// pass — that pass's outdoorOwnerIds parameter is unused on every live
// code path (WorldScenePassExecutor.DrawPostWorldParticles) and this
// property now always reports empty.
private static readonly IReadOnlySet<uint> NoOutdoorSceneParticleEntityIds = new HashSet<uint>();
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds =>
_particleClassifications.Outdoor;
NoOutdoorSceneParticleEntityIds;
public RetailPViewPassExecutor(
IWorldPassSurface surface,
@ -150,7 +122,9 @@ internal sealed partial class RetailPViewPassExecutor :
public void BeginFrame()
{
_particleClassifications.BeginFrame();
// Campaign OVERHAUL S2 chunk 6: no per-frame particle-owner
// classification to reset any more — particle draws are cell-scoped
// and read live from ParticleSystem's own retained cell index.
}
/// <summary>Campaign FW3.2b-2: the shared dispatcher, for
@ -178,7 +152,6 @@ internal sealed partial class RetailPViewPassExecutor :
TryAbort(_frameGlState.RestoreFrameDefaults);
TryAbort(() => _envCells.SetClipRouting(null));
TryAbort(_entities.ClearClipRouting);
TryAbort(_particleClassifications.BeginFrame);
TryAbort(_noSceneParticleEntityIds.Clear);
if (failures is { Count: > 0 })
throw new AggregateException("Retail PView pass abort failed.", failures);
@ -311,26 +284,27 @@ internal sealed partial class RetailPViewPassExecutor :
public void DrawLandscapeStaticParticles(
RetailPViewFrameInput frame,
RetailPViewLandscapeStaticParticleContext context)
uint cellId)
{
// One unclipped submission per owner per frame. Retail never clips a
// One unclipped submission per cell per frame. Retail never clips a
// particle to a portal view — its polys join the one alpha list during
// the owner cell's walk turn and the depth test at the flush decides
// occlusion (FlushAlphaList @0x0059D2E0). The former per-slice call
// with the slice's clip slot both hardware-cut effects at aperture
// boundaries and double-submitted owners visible in two slices.
// Retail CPhysicsObj::add_particle_shadow_to_cell (0x00514a70): an
// emitter owns one shadow in its OWN current cell, so this is a cell
// lookup, not an owner union — a hidden/suspended owner's emitter
// still draws here.
DisableClipDistances();
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
if (_particleClassifications.Outdoor.Count > 0
&& _particles is not null
&& _particleRenderer is not null)
if (_particles is not null && _particleRenderer is not null)
{
_particleRenderer.DrawForOwners(
_particleRenderer.DrawForCell(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_particleClassifications.Outdoor,
cellId,
clipSlot: 0);
}
@ -385,29 +359,22 @@ internal sealed partial class RetailPViewPassExecutor :
public void DrawCellParticles(
RetailPViewFrameInput frame,
RetailPViewCellSliceContext context)
uint cellId)
{
if (_particles is null
|| _particleRenderer is null
|| context.ParticleOwnerIds.Count == 0)
{
return;
}
HashSet<uint> visible = _particleClassifications.Visible;
visible.Clear();
visible.UnionWith(context.ParticleOwnerIds);
if (visible.Count == 0)
if (_particles is null || _particleRenderer is null)
return;
DisableClipDistances();
// Retail never clips cell particles to a portal view: the owner
// cell's walls own occlusion via the depth test at the alpha flush.
_particleRenderer.DrawForOwners(
// CPhysicsObj::add_particle_shadow_to_cell (0x00514a70) draws an
// emitter in its OWN current cell, so this is a cell lookup, not an
// owner union — a hidden/suspended owner's emitter still draws here.
_particleRenderer.DrawForCell(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
visible,
cellId,
clipSlot: 0);
DisableClipDistances();
}

View file

@ -18,13 +18,6 @@ internal sealed class RetailPViewRenderer
private readonly ClipFrameAssembly _clipAssemblyScratch = new();
private readonly RetailPViewFrameResult _frameResultScratch = new();
private static readonly ClipViewSlice NoClipSlice =
new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>());
private static readonly IReadOnlySet<uint> NoParticleOwners =
new HashSet<uint>();
private readonly HashSet<uint> _cellParticleOwnerScratch = new();
// MP-Alloc (2026-07-05): DrawInside's drawable-cell set, reused across
// frames instead of `new HashSet<uint>(pvFrame.OrderedVisibleCells)` every
// call. Every walk consumer reads it synchronously in this frame.
@ -579,38 +572,12 @@ internal sealed class RetailPViewRenderer
// building's pre-punch AlphaBarrier drains the farther content
// against still-true depth before its punch stamps far-Z.
// Cell-stage particle owners for the interior root's OWN flood cells
// (non-look-in — the walk draws their statics too, via
// OnInteriorFloodDrawTurn/EmitCellTurn, so the retired CellStatic
// route's cell-particle submission needs the same re-sourcing).
// These stay POST-replay: interior emitters draw in the final world
// scope where the cell walls already own depth (retail's cell-walk
// insertion). Look-in cells get their OWN per-cell union in
// DrawBuildingLookInDynamics so a static owner is never submitted
// twice.
_cellParticleOwnerScratch.Clear();
foreach (uint cellId in driver.VisitedCells)
{
if (driver.LookInCells.Contains(cellId))
continue;
UnionRecordOwners(_walkWorldData!.GetCellStatics(cellId), _cellParticleOwnerScratch);
}
if (_cellParticleOwnerScratch.Count > 0)
{
passes.DrawCellParticles(
ctx,
new RetailPViewCellSliceContext(0u, NoClipSlice, _cellParticleOwnerScratch));
}
}
private static void UnionRecordOwners(
Walk.WalkFrameStaticRecords records, HashSet<uint> destination)
{
foreach (RenderProjectionRecord record in records.Records)
{
if (record.Source.LocalEntityId != 0)
destination.Add(record.Source.LocalEntityId);
}
// Campaign OVERHAUL S2 chunk 6: an emitter draws ONCE, at its own
// cell's turn inside Replay (WalkFrameEventKind.CellParticles fires for
// every visited interior cell — root flood and look-in alike), exactly
// retail's add_particle_shadow_to_cell (0x00514a70) membership. The
// former post-replay per-cell pass double-submitted the root flood's
// emitters and is deleted.
}
/// <summary>Campaign FW3.2b-2: the DYNAMICS-only remainder of the old
@ -857,24 +824,9 @@ public sealed class RetailPViewFrameResult
}
/// <summary>
/// Scene-particle owners for ONE unclipped landscape-stage submission (the
/// union of every outside slice's cone survivors). Mesh alpha for the same
/// owners is already queued by the entity routes; retail inserts each
/// emitter's polys into the single alpha list once, during its owner cell's
/// walk turn, with no portal-view clip.
/// </summary>
public readonly record struct RetailPViewLandscapeStaticParticleContext(
IReadOnlySet<uint> ParticleOwnerIds);
/// <summary>#131/#132: the late landscape phase's per-slice payload —
/// outside-stage dynamics to mesh-draw, plus the particle owners not already
/// submitted at a pre-building barrier.</summary>
public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> Dynamics);
public readonly record struct RetailPViewCellSliceContext(
uint CellId,
ClipViewSlice Slice,
IReadOnlySet<uint> ParticleOwnerIds);

View file

@ -124,16 +124,19 @@ internal interface IWalkFrameLeafRenderer
/// portal depth writes, and later depth-tested repaint own visibility.</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>One landscape (land) cell's own-cell particle emitters, at
/// its own walk turn — see <see cref="WalkFrameEventKind.StaticParticles"/>
/// for the retail positional invariant this carries (the #132 falls
/// containment). Retail <c>CPhysicsObj::add_particle_shadow_to_cell</c>
/// (0x00514a70) draws an emitter at its own current cell's object turn
/// regardless of its attached owner's registry membership, so this is a
/// cell lookup, not an owner union.</summary>
void DrawStaticParticles(uint cellId);
/// <summary>Submits one indoor cell's static + dynamic particle owners at
/// that cell's own object-list turn. Meshes have already entered the
/// ordered stream before this leaf event.</summary>
void DrawCellParticles(uint cellId, IReadOnlySet<uint> ownerIds);
/// <summary>Submits one indoor cell's own-cell particle emitters at that
/// cell's own object-list turn. Meshes have already entered the ordered
/// stream before this leaf event.</summary>
void DrawCellParticles(uint cellId);
/// <summary><c>PView::DrawCells</c> @0x005a4840's gated full depth clear
/// (pc:432731-432732) between the outside stage and the interior root's
@ -265,11 +268,10 @@ internal enum WalkFrameEventKind : byte
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
/// ONE landscape (land) cell's (<see cref="WalkFrameEvent.CellId"/>)
/// own-cell particle emitters, emitted AT ITS OWN WALK TURN. Retail's
/// falls containment is positional: an outdoor emitter's polys join the
/// one alpha list during its owner cell's <c>DrawObjCell</c> in the
/// far-to-near landscape walk, so every nearer building's pre-punch
/// alpha barrier (<c>DrawBuilding</c> @0x0059f2a0's
/// <c>FlushAlphaList</c> @0x0059f30b) drains the already-queued FARTHER
@ -278,11 +280,21 @@ internal enum WalkFrameEventKind : byte
/// 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>
/// `e102fb36` encoded the same invariant). A building's own shell fires
/// no event of this kind any more — its interior emitters live in their
/// own EnvCells and draw at those cells' own <see cref="CellParticles"/>
/// turns (Campaign OVERHAUL S2 chunk 6, retail
/// <c>CPhysicsObj::add_particle_shadow_to_cell</c> 0x00514a70: an emitter
/// owns one shadow in its OWN current cell, never its parent's).</summary>
StaticParticles,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawCellParticles"/> —
/// <see cref="WalkFrameEvent.CellId"/> is the cell.</summary>
/// <see cref="WalkFrameEvent.CellId"/> is the cell. Fires at EVERY
/// visited interior cell's own object-list turn regardless of whether
/// that cell has any visible static/dynamic owner record: an emitter
/// draws at its own cell's turn independent of its attached owner's
/// registry membership (a hidden/suspended owner's emitter still shows —
/// Campaign OVERHAUL S2 chunk 6, the portal-haze fix).</summary>
CellParticles,
}
@ -340,15 +352,13 @@ internal readonly record struct WalkLookInTurn(
internal readonly struct WalkFrameEvent
{
private WalkFrameEvent(
WalkFrameEventKind kind, int intArg, uint cellId, float floatArg, WalkPolygon? polygon,
WalkBuilding? building = null)
WalkFrameEventKind kind, int intArg, uint cellId, float floatArg, WalkPolygon? polygon)
{
Kind = kind;
IntArg = intArg;
CellId = cellId;
FloatArg = floatArg;
Polygon = polygon;
Building = building;
}
internal WalkFrameEventKind Kind { get; }
@ -361,11 +371,6 @@ 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);
@ -390,9 +395,6 @@ internal readonly struct WalkFrameEvent
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 CellParticles(uint cellId) =>
new(WalkFrameEventKind.CellParticles, 0, cellId, 0f, null);
@ -516,7 +518,6 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
private readonly List<int> _floodViewRouteScratch = new();
private WalkPlane _lookInCyPlane;
private readonly HashSet<uint> _outdoorParticleOwnersDrawnThisFrame = new();
private readonly HashSet<uint> _cellShellsDrawnThisFrame = new();
IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns;
@ -582,33 +583,6 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
return routeIndex;
}
// 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);
}
}
private static void UnionNewOwners(
in WalkFrameStaticRecords records,
HashSet<uint> destination,
HashSet<uint> drawnOnce)
{
foreach (RenderProjectionRecord record in records.Records)
{
uint ownerId = record.Source.LocalEntityId;
if (ownerId != 0 && drawnOnce.Add(ownerId))
destination.Add(ownerId);
}
}
internal List<WalkBuilding> VisitedBuildings { get; } = new();
internal HashSet<uint> VisitedLandscapeCellIds { get; } = new();
@ -678,14 +652,12 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_visibleClipSlotScratch.Clear();
_floodViewRouteScratch.Clear();
_dispatcher.EndWalkPartFrame();
_outdoorParticleOwnersDrawnThisFrame.Clear();
_cellShellsDrawnThisFrame.Clear();
_lookInCyPlane = default;
LookInCells.Clear();
VisitedBuildings.Clear();
VisitedLandscapeCellIds.Clear();
InteriorFloodCells.Clear();
_staticParticleOwnerScratch.Clear();
_cellViewRouteIndex = 0;
_landscapeViewRouteIndex = -1;
}
@ -814,7 +786,6 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_lookInPlanes.Clear();
_visibleClipSlotScratch.Clear();
_lookInCyPlane = ctx.CyPlane;
_outdoorParticleOwnersDrawnThisFrame.Clear();
_cellShellsDrawnThisFrame.Clear();
LookInCells.Clear();
VisitedBuildings.Clear();
@ -925,41 +896,15 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_leafRenderer.DrawExitSeals();
break;
case WalkFrameEventKind.StaticParticles:
_staticParticleOwnerScratch.Clear();
if (e.Building is WalkBuilding shellOwner)
{
UnionOwners(
_worldData.GetBuildingShellStatics(shellOwner),
_staticParticleOwnerScratch);
}
else
{
UnionNewOwners(
_worldData.GetOutdoorStatics(e.CellId),
_staticParticleOwnerScratch,
_outdoorParticleOwnersDrawnThisFrame);
UnionNewOwners(
_worldData.GetOutdoorDynamics(e.CellId),
_staticParticleOwnerScratch,
_outdoorParticleOwnersDrawnThisFrame);
}
if (_staticParticleOwnerScratch.Count > 0)
_leafRenderer.DrawStaticParticles(_staticParticleOwnerScratch);
// 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.
_leafRenderer.DrawStaticParticles(e.CellId);
break;
case WalkFrameEventKind.CellParticles:
_staticParticleOwnerScratch.Clear();
UnionOwners(
_worldData.GetCellStatics(e.CellId),
_staticParticleOwnerScratch);
UnionOwners(
_worldData.GetCellDynamics(e.CellId),
_staticParticleOwnerScratch);
if (_staticParticleOwnerScratch.Count > 0)
{
_leafRenderer.DrawCellParticles(
e.CellId,
_staticParticleOwnerScratch);
}
_leafRenderer.DrawCellParticles(e.CellId);
break;
}
}
@ -1181,17 +1126,17 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
MarkIfGrown();
MarkAlphaIfGrown();
}
// 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) || HasAnyOwner(dynamics))
{
MarkIfGrown();
_events.Add(WalkFrameEvent.LandscapeCellParticles(cellId));
}
// 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();
_events.Add(WalkFrameEvent.LandscapeCellParticles(cellId));
}
void IWalkEventSink.OnLandscapeViews(WalkPortalView activeViews)
@ -1202,16 +1147,6 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
CaptureViews(0, activeViews);
}
private static bool HasAnyOwner(in WalkFrameStaticRecords records)
{
foreach (RenderProjectionRecord record in records.Records)
{
if (record.Source.LocalEntityId != 0)
return true;
}
return false;
}
void IWalkEventSink.OnBuildingTurn(WalkBuilding building)
{
ArgumentNullException.ThrowIfNull(building);
@ -1257,14 +1192,12 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
MarkAlphaIfGrown();
}
}
// 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));
}
// 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(
@ -1495,8 +1428,11 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
alphaSubmissions: _alphaSubmissions);
MarkIfGrown();
MarkAlphaIfGrown();
if (HasAnyOwner(records) || HasAnyOwner(dynamics))
_events.Add(WalkFrameEvent.CellParticles(cellId));
// 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).
_events.Add(WalkFrameEvent.CellParticles(cellId));
}
private void CaptureCellViews(uint cellId)

View file

@ -21,9 +21,6 @@ public sealed class LiveEntityPresentationController : IDisposable
public const uint UnHideScriptType = 0x75u;
public const uint HiddenScriptType = 0x76u;
private static readonly bool HiddenTraceEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
private readonly LiveEntityRuntime _liveEntities;
private readonly ShadowObjectRegistry _shadows;
private readonly Func<uint, uint, float, bool> _playTyped;

View file

@ -63,9 +63,6 @@ public sealed class ParticleHookSink : IAnimationHookSink
public Action<string>? DiagnosticSink { get; set; }
private static readonly bool SpawnTraceEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
/// <summary>
/// Diagnostic ownership counts used by lifecycle stress gates. These count
/// the sink's retained bookkeeping, not only emitters still present in the
@ -438,15 +435,6 @@ public sealed class ParticleHookSink : IAnimationHookSink
bool haveOwnerCell =
_cells is not null && _cells.TryGetCellId(ownerLocalId, out ownerCellId);
_system.UpdateEmitterOwnerCell(handle, haveOwnerCell ? ownerCellId : 0u);
// TEMPORARY (2026-09-03 portal-haze investigation, dies with it):
// print-only, gated on ACDREAM_DUMP_PLAYSCRIPT=1 like the runner.
if (SpawnTraceEnabled)
{
DiagnosticSink?.Invoke(
$"[pes-spawn] emitter=0x{emitterInfoId:X8} owner=0x{ownerLocalId:X8} handle={handle} "
+ $"cell=0x{(haveOwnerCell ? ownerCellId : 0u):X8} "
+ $"hiddenPresentation={_hiddenPresentationOwners.Contains(ownerLocalId)} pass={renderPass}");
}
if (_hiddenPresentationOwners.Contains(ownerLocalId))
{
_system.SetEmitterPresentationVisible(handle, false);

View file

@ -27,6 +27,15 @@ public sealed class ParticleSystem : IParticleSystem
[[], [], []];
private readonly Dictionary<uint, OwnerEmitterBucket>[] _ownerHandlesByPass =
[new(), new(), new()];
// Retail CPhysicsObj::add_particle_shadow_to_cell (0x00514a70): an emitter
// owns exactly one shadow in ITS OWN current cell, independent of its
// attached owner's membership (a hidden/suspended owner still shows its
// emitters). Keyed by ParticleEmitter.OwnerCellId, maintained wherever
// renderable state or OwnerCellId itself changes; the OwnerEmitterBucket
// shape (a sorted renderable-handle set plus a logical membership count)
// is reused verbatim from the per-owner index above.
private readonly Dictionary<uint, OwnerEmitterBucket>[] _cellHandlesByPass =
[new(), new(), new()];
private readonly List<int> _tickSnapshot = [];
private readonly List<int> _scopeHandleScratch = [];
@ -272,8 +281,35 @@ public sealed class ParticleSystem : IParticleSystem
public void UpdateEmitterOwnerCell(int handle, uint ownerCellId)
{
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
emitter.OwnerCellId = ownerCellId;
if (!_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)
|| emitter.OwnerCellId == ownerCellId)
{
return;
}
int passIndex = RenderPassIndex(emitter.RenderPass);
bool isRenderable = IsRenderable(emitter);
Dictionary<uint, OwnerEmitterBucket> cellBuckets = _cellHandlesByPass[passIndex];
if (cellBuckets.TryGetValue(emitter.OwnerCellId, out OwnerEmitterBucket? oldBucket))
{
if (isRenderable)
oldBucket.RemoveRenderable(handle);
oldBucket.LogicalCount--;
if (oldBucket.LogicalCount == 0)
cellBuckets.Remove(emitter.OwnerCellId);
}
emitter.OwnerCellId = ownerCellId;
if (!cellBuckets.TryGetValue(ownerCellId, out OwnerEmitterBucket? newBucket))
{
newBucket = new OwnerEmitterBucket();
cellBuckets.Add(ownerCellId, newBucket);
}
newBucket.LogicalCount++;
if (isRenderable)
newBucket.AddRenderable(handle);
}
public void SetEmitterVisibilityPolicy(
@ -570,6 +606,37 @@ public sealed class ParticleSystem : IParticleSystem
destination.Sort(static (left, right) => left.Handle.CompareTo(right.Handle));
}
/// <summary>
/// Copies the renderable emitters whose <see cref="ParticleEmitter.OwnerCellId"/>
/// is <paramref name="cellId"/>, retaining spawn order. Retail
/// <c>CPhysicsObj::add_particle_shadow_to_cell</c> (0x00514a70) gives an
/// emitter exactly one shadow in its own current cell, independent of its
/// attached owner's registry membership — a hidden/suspended owner's
/// emitter is still enumerated here as long as it remains renderable
/// (presentation-visible and view-eligible). No per-call allocation after
/// warmup: the bucket's own sorted handle list is copied through the
/// retained <see cref="_scopeHandleScratch"/> buffer.
/// </summary>
public void CopyRenderableEmittersInCell(
ParticleRenderPass renderPass,
uint cellId,
List<ParticleEmitter> destination)
{
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
int passIndex = RenderPassIndex(renderPass);
if (!_cellHandlesByPass[passIndex].TryGetValue(cellId, out OwnerEmitterBucket? bucket))
return;
_scopeHandleScratch.Clear();
bucket.CopyRenderableHandlesTo(_scopeHandleScratch);
foreach (int handle in _scopeHandleScratch)
{
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
destination.Add(emitter);
}
}
/// <summary>
/// Splits unattached emitters by their owner cell kind so each draws once
/// in its retail stage: an outdoor landcell emitter belongs to the
@ -806,6 +873,15 @@ public sealed class ParticleSystem : IParticleSystem
if (bucket.LogicalCount == 0)
_ownerHandlesByPass[passIndex].Remove(emitter.AttachedObjectId);
}
if (_cellHandlesByPass[passIndex].TryGetValue(
emitter.OwnerCellId,
out OwnerEmitterBucket? cellBucket))
{
cellBucket.RemoveRenderable(handle);
cellBucket.LogicalCount--;
if (cellBucket.LogicalCount == 0)
_cellHandlesByPass[passIndex].Remove(emitter.OwnerCellId);
}
NotifyEmitterDied(handle);
}
@ -850,6 +926,14 @@ public sealed class ParticleSystem : IParticleSystem
ownerBucket.LogicalCount++;
}
Dictionary<uint, OwnerEmitterBucket> cellBuckets = _cellHandlesByPass[passIndex];
if (!cellBuckets.TryGetValue(emitter.OwnerCellId, out OwnerEmitterBucket? cellBucket))
{
cellBucket = new OwnerEmitterBucket();
cellBuckets.Add(emitter.OwnerCellId, cellBucket);
}
cellBucket.LogicalCount++;
if (IsRenderable(emitter))
{
_renderableHandlesByPass[passIndex].Add(emitter.Handle);
@ -857,28 +941,15 @@ public sealed class ParticleSystem : IParticleSystem
_renderableUnattachedHandlesByPass[passIndex].Add(emitter.Handle);
else
ownerBucket!.AddRenderable(emitter.Handle);
cellBucket.AddRenderable(emitter.Handle);
}
}
/// <summary>TEMPORARY (2026-09-03 portal-haze investigation, dies with
/// it): print-only trace of renderable-index flips, wired under
/// <c>ACDREAM_DUMP_PLAYSCRIPT=1</c> beside the hook sink's trace.</summary>
public Action<string>? DiagnosticSink { get; set; }
private static readonly bool DiagEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
private void RefreshRenderableIndex(ParticleEmitter emitter, bool wasRenderable)
{
bool isRenderable = IsRenderable(emitter);
if (wasRenderable == isRenderable)
return;
if (DiagEnabled)
DiagnosticSink?.Invoke(
$"[pes-vis] handle={emitter.Handle} owner=0x{emitter.AttachedObjectId:X8} "
+ $"cell=0x{emitter.OwnerCellId:X8} renderable={isRenderable} "
+ $"viewEligible={emitter.ViewEligible} presentationVisible={emitter.PresentationVisible} "
+ $"pass={emitter.RenderPass}");
SortedSet<int> index =
_renderableHandlesByPass[RenderPassIndex(emitter.RenderPass)];
@ -904,6 +975,16 @@ public sealed class ParticleSystem : IParticleSystem
else
bucket.RemoveRenderable(emitter.Handle);
}
if (_cellHandlesByPass[passIndex].TryGetValue(
emitter.OwnerCellId,
out OwnerEmitterBucket? cellBucket))
{
if (isRenderable)
cellBucket.AddRenderable(emitter.Handle);
else
cellBucket.RemoveRenderable(emitter.Handle);
}
}
private static bool IsRenderable(ParticleEmitter emitter)