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

@ -111,7 +111,7 @@ readiness/requeue adaptation. See
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| AD-117 | **Filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round.** Three residual Contract A/B readings (the row's original item 1 — a render-only owner's no-cell-array SetPosition commit republishing at its destination cell alone, `ShadowObjectRegistry.RefreshPositionRows` — was verified statically the same night as retail's own mechanism: `CObjCell::find_cell_list` 0x0052b4e0 with `num_sphere == 0` adds only the current cell (interior `add_cell` at 0x0052b563; outdoor `CLandCell::add_all_outside_cells` 0x00533630 `arg2 <= 0` branch) and skips the transit walk (`arg2 != 0` gate at 0x0052b576); it is a port, not a deviation). (2) `ShadowShapeBuilder.FromStaticRenderParts` uses the visual-AABB circumsphere as the per-portal cheap-reject sphere for a part with no physics BSP, where retail uses `gfxobj->physics_sphere` else `drawing_sphere` (pc:310147-310152) — strictly larger, so it can only WIDEN membership. (3) `PublishRetailPartEntries` publishes part rows into every CELLARRAY id, including an unloaded neighbour cell `CEnvCell::find_transit_cells` added with a null owner, where retail's `add_shadows_to_cells` (pc:282850) zeroes that shadow's cell and skips `AddPartsShadow` until the cell loads. (4) The `state & 0x1000` particle branch (`add_particle_shadow_to_cell` 0x00514a70: the object's own cell only, never clip planes) is not ported. | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`RefreshPositionRows` render-only branch, `PublishRetailPartEntries`), `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromStaticRenderParts` non-BSP arm) | (2) A cheap reject that fires less often admits a superset; the admitting tests (`Plane::intersect_box`, `box_intersects_cell`) are ported exactly. (3) The extra rows are unreachable by the walk until the cell is resident and `RefloodLandblock` converges them at hydration. (4) `ParticleRenderer` owns emitter placement and no production path registers an emitter, so the branch has no input. | (2) A decorative non-BSP part admitted to a neighbouring cell retail's cheap reject would have dropped — a draw clipped by that cell's portal planes, at worst a sliver. (3) A one-frame draw into a cell that just hydrated before its reflood ran. (4) If an emitter is ever routed through the registry it would flood as a bbox object instead of its own cell only. | `CPhysicsObj::SetPositionInternal` 0x00515330 (pc:283530-283541), `CPhysicsObj::add_shadows_to_cells` 0x00514ae0 (pc:282837-282875), `CEnvCell::find_transit_cells` 0x0052cae0 (pc:310147-310217), `CPhysicsObj::add_particle_shadow_to_cell` 0x00514a70, `CObjCell::find_cell_list` 0x0052b4e0. |
| AD-117 | **Filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round.** Three residual Contract A/B readings (the row's original item 1 — a render-only owner's no-cell-array SetPosition commit republishing at its destination cell alone, `ShadowObjectRegistry.RefreshPositionRows` — was verified statically the same night as retail's own mechanism: `CObjCell::find_cell_list` 0x0052b4e0 with `num_sphere == 0` adds only the current cell (interior `add_cell` at 0x0052b563; outdoor `CLandCell::add_all_outside_cells` 0x00533630 `arg2 <= 0` branch) and skips the transit walk (`arg2 != 0` gate at 0x0052b576); it is a port, not a deviation). (2) `ShadowShapeBuilder.FromStaticRenderParts` uses the visual-AABB circumsphere as the per-portal cheap-reject sphere for a part with no physics BSP, where retail uses `gfxobj->physics_sphere` else `drawing_sphere` (pc:310147-310152) — strictly larger, so it can only WIDEN membership. (3) `PublishRetailPartEntries` publishes part rows into every CELLARRAY id, including an unloaded neighbour cell `CEnvCell::find_transit_cells` added with a null owner, where retail's `add_shadows_to_cells` (pc:282850) zeroes that shadow's cell and skips `AddPartsShadow` until the cell loads. (4) PORTED at Campaign OVERHAUL S2 chunk 6: an emitter now owns exactly one draw membership in its own current cell via `ParticleSystem`'s per-pass cell index (`CopyRenderableEmittersInCell`), matching `add_particle_shadow_to_cell` 0x00514a70's own-cell-only, no-clip-planes rule and drawn at that cell's own walk turn independent of its attached owner's registry membership. The residual is architectural, not behavioral: the membership index lives in `ParticleSystem` rather than as a `ShadowObjectRegistry` row, because an emitter is not a `CPhysicsObj` in acdream and never registers with the shadow registry at all. | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`RefreshPositionRows` render-only branch, `PublishRetailPartEntries`), `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromStaticRenderParts` non-BSP arm), `src/AcDream.Core/Vfx/ParticleSystem.cs` (per-pass cell index, item 4) | (2) A cheap reject that fires less often admits a superset; the admitting tests (`Plane::intersect_box`, `box_intersects_cell`) are ported exactly. (3) The extra rows are unreachable by the walk until the cell is resident and `RefloodLandblock` converges them at hydration. (4) `ParticleSystem`'s cell index is maintained at every point renderable state or `OwnerCellId` changes (`RefreshRenderableIndex`, `UpdateEmitterOwnerCell`), so an emitter's draw membership always matches its live cell regardless of its attached owner's suspended/hidden registry state. | (2) A decorative non-BSP part admitted to a neighbouring cell retail's cheap reject would have dropped — a draw clipped by that cell's portal planes, at worst a sliver. (3) A one-frame draw into a cell that just hydrated before its reflood ran. (4) None behavioral — a code-location note only: if particle emitters are ever modeled as registry-backed physics objects, this cell index should be retired in favor of a genuine `ShadowObjectRegistry` row rather than kept as a parallel mechanism. | `CPhysicsObj::SetPositionInternal` 0x00515330 (pc:283530-283541), `CPhysicsObj::add_shadows_to_cells` 0x00514ae0 (pc:282837-282875), `CEnvCell::find_transit_cells` 0x0052cae0 (pc:310147-310217), `CPhysicsObj::add_particle_shadow_to_cell` 0x00514a70, `CObjCell::find_cell_list` 0x0052b4e0. |
| AD-116 | **Filed 2026-09-03 at Campaign OVERHAUL S2 chunk 5 (consumer cutover).** `WalkProductionWorldData.ResolveCellView`'s borrowed per-cell view treats an entity the registry HAS flooded into its retail CELLARRAY (so `ShadowObjectRegistry.GetRetailPartEntriesInCell` names it) but whose `RenderProjectionRecord` `RenderSceneQuery.TryGetByLocalEntityId` cannot resolve yet as contributing to NO cell for that frame — it is silently skipped rather than falling back to its authored parent cell or an outdoor root-position cell (both deleted this chunk). Every distinct entity id this happens for in one frame is counted once in `WalkProductionWorldData.UnregisteredRenderMembershipCount` and, when nonzero, reported by one print-only `[walk-membership]` line at the start of the next `BeginFrame`, gated on `RenderingDiagnostics.ProbeFacilityStairsEnabled`. | `src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs` (`ResolveCellView`, `UnregisteredRenderMembershipCount`, the `BeginFrame` diagnostic line) | Retail has no such gap at all: `CEnvCell::init_static_objects` installs the CELLARRAY before a static is ever drawable, and Contract B's collision (`shadow_object_list`) and render (`shadow_part_list`) products are ONE transaction, so they can never race. acdream's registry (the physics publisher) and its presentation scene (the projection journal) are two independently incremental pipelines fed off the same Create/appearance edge, so a transient one-frame window where the registry runs first is possible during streaming — the same class of race AD-49's residency reasoning already accepts for `CellTransit`'s own outdoor seed. Contributing NOTHING for that one frame matches retail's own rule ("an object not yet in a cell is not drawn") more closely than the deleted parent-cell/root-position fallbacks did, which could draw an object at a cell its real CELLARRAY does not actually include. | If the presentation journal's apply cadence ever falls more than one frame behind the registry's registration (not merely a same-frame ordering race), an entity would stay missing for several consecutive frames instead of appearing on the very next one — `UnregisteredRenderMembershipCount` staying nonzero across consecutive frames (not a single one-frame spike) is the signal that this row's "transient" premise has broken and needs re-investigation, not a widened fallback. | `CEnvCell::init_static_objects`; `CPartArray::AddPartsShadow` 0x00517e40 (`docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md` Contract B) |
| AD-115 | **Filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16), classification: intentional.** `AppraisalUiController.BuildCharacterTitleDisplay` composes examination element `0x10000151` (Profession/title): when Int 261 `CharacterTitleId` is absent/unresolvable AND String 5 `Template` is also absent, it returns an empty string, and `ClearCreatureText` has already blanked the element for this `ApplyCreature` call, so the element stays cleared. Retail never clears `0x10000150`/`0x10000151`/`0x10000152` anywhere — neither `CharExamineUI::Show @0x004AB5D0` nor `BasicCreatureExamineUI::Init @0x004AB9C0` writes an empty string to those elements — so in this exact case retail would keep showing the PREVIOUS assessed target's title text on screen instead of clearing it. | `src/AcDream.App/UI/Layout/AppraisalUiController.cs` (`BuildCharacterTitleDisplay`, `ClearCreatureText`) | Deliberate improvement over retail's quirk: a stale leftover title from a prior target reads as more confusing/wrong to a player than a blank line for the current one; review F16 (2026-08-25) accepted the clear-on-no-source behavior as intentional. | None expected — this is a deliberate, reviewed divergence, not a game-feel regression; a future retail-faithfulness audit assuming `0x10000151` always mirrors retail's persistent stale-text behavior would be surprised to see it clear instead when the current target's title can't be resolved. | `CharExamineUI::Show @0x004AB5D0`; `BasicCreatureExamineUI::Init @0x004AB9C0` |
| AD-114 | **Filed 2026-08-25 at Campaign AS slice AS2, owner-ruled 2026-08-25 (verbatim "we animate it, and I like it").** acdream's examination-window preview (`CreatureAppraisalFramePresenter` / `RetailCreatureAppraisalCloneFactory`) shares the assessed target's already-resolved live MeshRefs and re-synchronizes them every frame, so the preview clone plays the SAME current animated pose the live target is actually doing right now (attack, cast, run, idle, ...). Retail's `BasicCreatureExamineUI::Init @0x004AB9C0` instead clones the selected physics object ONCE, fixes its heading at 191.367905°, and lets its own private `CreatureMode` animate that clone independently — decoupled from whatever the live target is currently doing. | `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs` (`CreatureAppraisalFramePresenter`, `RetailCreatureAppraisalCloneFactory`) | Explicit owner direction, 2026-08-25 (`docs/plans/2026-08-25-assess-window-parity-campaign.md`: "The animated 3D paperdoll is an INTENTIONAL acdream deviation... Keep it"), noted alongside the owner's own observation that retail's static-clone colors are buggy — porting the decoupled-motion clone would not even be a faithfulness win here. | None expected — a deliberate, user-approved visual improvement over retail's decoupled clone motion, not a game-feel divergence; a future faithfulness audit assuming the preview mirrors retail's independent `CreatureMode` cycle would be surprised to see it track the live target's pose instead. | `BasicCreatureExamineUI::Init @0x004AB9C0`; `docs/plans/2026-08-25-assess-window-parity-campaign.md` |

View file

@ -246,7 +246,7 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv"
|---|---|---|---|---|---|
| `ACDREAM_CAPTURE_PLAYER_QUANTA` | `=<path>` (any non-whitespace path) | Opt-in JSON-Lines trace of every admitted player physics quantum (position/orientation/velocity/contact-plane snapshots at each stage boundary of `CPhysicsObj::UpdateObjectInternal`) | Appends+flushes one JSON line per physics quantum to the file (real file I/O on the physics tick when enabled); disabled path costs one static string null/empty check, no allocation. Read once into a mutable static property (settable via `ResetForTest`) rather than a typed options object. | unset (disabled, zero-alloc) | `PlayerPhysicsQuantumCapture` static class (`AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs:22`) |
| `ACDREAM_DUMP_MOTION` | `=1` | prints `UM`/`[UM_STALE]`/`[MOTIONDONE]`/`VU.land`/raw-hex wire dump lines tracing inbound `UpdateMotion` handling, remote ground-contact edges, and motion-done callbacks (bug-a/#32 stuck-cast subthread is temporary; core trace is long-lived) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` and `UpdateMotion.cs` fire on EVERY inbound motion/UM event (not cached) — `Environment.GetEnvironmentVariable` call per packet even when off; `UpdateMotion.cs`'s branch additionally builds a `StringBuilder` hex dump when on. Rule-5 violation (raw reads outside a diagnostics-owner class) at 5+ call sites | off | THREE independent readers: `PhysicsDiagnostics.DumpMotionEnabled` (owner, appears unconsumed — see Notes), `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached once at startup, consumed by `LiveEntityAnimationPresenter`), and raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (4 sites) + `Core.Net/Messages/UpdateMotion.cs:163` + `Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:630` |
| `ACDREAM_DUMP_PLAYSCRIPT` | `="1"` (ordinal) | Traces PhysicsScript playback: missing/empty script resolution, malformed `StartTime` entries, and other `[pes]`-prefixed hook-dispatch events; TEMPORARY (2026-09-03 portal-haze investigation, delete with it): `[pes-spawn]` per emitter spawn (owner, handle, owner cell, hidden-presentation state) from `ParticleHookSink` and `[pes-vis]` per renderable-index flip (view eligibility, presentation visibility, owner cell) from `ParticleSystem`, both through the vfx diagnostic sink | print-only (`Console.WriteLine`) at all 4 runner use sites (`:85-86,136,300,328`) plus the two temporary vfx traces | unset (off) | `PhysicsScriptRunner.DiagEnabled` (`PhysicsScriptRunner.cs:61-62`) — per-instance settable property seeded from the env var, not a shared static diagnostics-owner class; the two temporary traces read the same env var once into a private static (`ParticleSystem.DiagEnabled`, `ParticleHookSink.SpawnTraceEnabled`) |
| `ACDREAM_DUMP_PLAYSCRIPT` | `="1"` (ordinal) | Traces PhysicsScript playback: missing/empty script resolution, malformed `StartTime` entries, and other `[pes]`-prefixed hook-dispatch events | print-only (`Console.WriteLine`) at all 4 use sites (`:85-86,136,300,328`) | unset (off) | `PhysicsScriptRunner.DiagEnabled` (`PhysicsScriptRunner.cs:61-62`) — per-instance settable property seeded from the env var, not a shared static diagnostics-owner class |
| `ACDREAM_DUMP_SURFACES` | `="1"` (ordinal) | One-shot (per session) surface-format histogram dump for the atlas-opportunity audit — fires once after `_dumpFrameCounter>=600` OnRender ticks AND `_uploadMetadata.Count>=100` uploaded textures; writes to the host diagnostics directory | Doc comment claims "Zero cost when off" but `_uploadMetadata[name]=(w,h,fmt)` (`TextureCache.cs:1042`) is written **unconditionally on every texture upload regardless of the flag** — real (small) always-on dictionary-write cost. `TickSurfaceHistogramDumpIfEnabled` also re-reads `Environment.GetEnvironmentVariable` every OnRender frame (not cached) until the one-shot fires. Dump-write failures are caught and logged to stderr, not fatal. | unset (off) | `TextureCache` (`TextureCache.cs:102-113` fields, gate at `TextureCache.cs:802-812`, dump at `TextureCache.cs:814-829`), Phase N.6 slice 1 |
| `ACDREAM_FRAME_PROF` | `=1` | master toggle for the frame profiler: CPU frame time, GPU time samples, per-stage CPU attribution, per-frame alloc/GC, `[frame-prof]` report every ~5 s (doc: "permanent apparatus ... do not strip with session probes") | when on, samples `GC.GetAllocatedBytesForCurrentThread()` and stage-scope timing every frame (cheap, by design); its own XML doc claims a GPU-query self-disable tied to `ACDREAM_WB_DIAG=1` that `FrameProfiler.cs` says no longer exists — see Notes #1; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.FrameProfEnabled` / `FrameProfiler` |
| `ACDREAM_PROBE_ENVCELL` | `=1` | emits one `[envcells]` line per indoor frame: `CellsRendered`/`TrianglesDrawn` + ourBldgs/otherBldgs/filter counts (phase a8 relic; its own render pass was removed but the probe was kept) | print-only; implicitly turned on whenever `ACDREAM_PROBE_VIS` is on (getter is `_probeEnvCellEnabled \ | \ | `RenderingDiagnostics.ProbeEnvCellEnabled` (backing field OR'd with `ProbeVisibilityEnabled`) |

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)

View file

@ -7,21 +7,6 @@ namespace AcDream.App.Tests.Rendering;
public sealed class RetailPViewPassExecutorTests
{
[Fact]
public void Particle_classifications_reset_before_an_empty_following_frame()
{
var classifications = new RetailPViewParticleClassifications();
classifications.ReplaceOutdoor(new HashSet<uint> { 7u });
classifications.Visible.Add(8u);
classifications.Dynamics.Add(9u);
classifications.BeginFrame();
Assert.Empty(classifications.Outdoor);
Assert.Empty(classifications.Visible);
Assert.Empty(classifications.Dynamics);
}
[Fact]
public void Extracted_contracts_retain_no_window_callbacks_or_visibility_owner()
{
@ -42,17 +27,8 @@ public sealed class RetailPViewPassExecutorTests
}
[Fact]
public void Concrete_executor_forwards_frame_reset_and_brackets_terrain_diagnostics()
public void Concrete_executor_brackets_terrain_diagnostics()
{
MethodInfo begin = typeof(RetailPViewPassExecutor).GetMethod(
nameof(RetailPViewPassExecutor.BeginFrame))!;
IReadOnlyList<CompiledCall> beginCalls = CompiledCallGraph.Read(begin);
Assert.True(
CompiledCallGraph.IndexOf(
beginCalls,
typeof(RetailPViewParticleClassifications),
nameof(RetailPViewParticleClassifications.BeginFrame)) >= 0);
MethodInfo landscape = typeof(RetailPViewPassExecutor).GetMethod(
"DrawWalkTerrainSlice",
BindingFlags.Instance | BindingFlags.NonPublic)!;

View file

@ -81,23 +81,9 @@ public sealed class WalkFrameDriverTests
alpha.Flush();
}
public void DrawStaticParticles(IReadOnlySet<uint> ownerIds)
{
var sorted = new List<uint>(ownerIds);
sorted.Sort();
log.Add($"PARTICLES:{string.Join(",", sorted.ConvertAll(o => o.ToString("x")))}");
}
public void DrawStaticParticles(uint cellId) => log.Add($"PARTICLES:{cellId:x8}");
public void DrawCellParticles(
uint cellId,
IReadOnlySet<uint> staticParticleOwnerIds)
{
var sorted = new List<uint>(staticParticleOwnerIds);
sorted.Sort();
log.Add(
$"CELL-PARTICLES:{cellId:x8}:"
+ string.Join(",", sorted.ConvertAll(o => o.ToString("x"))));
}
public void DrawCellParticles(uint cellId) => log.Add($"CELL-PARTICLES:{cellId:x8}");
}
private sealed class RecordingTrace(List<string> log) : IWalkFrameDriverTrace
@ -287,8 +273,8 @@ public sealed class WalkFrameDriverTests
{
"SKY", "TERRAIN:0", "CLEAR", "SEALS",
"SHELL:00000101", "SHELL:00000100",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101:66",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100:65",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
},
log);
@ -435,8 +421,8 @@ public sealed class WalkFrameDriverTests
new[]
{
"CLEAR", "SEALS", "SHELL:00000101", "SHELL:00000100",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101:66",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100:65",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
},
log);
@ -539,14 +525,15 @@ public sealed class WalkFrameDriverTests
driver.Replay(draw.Frame, draw.Pass);
Assert.Equal(
// FW4 #132 positional invariant: the building's shell emitters
// submit at the shell turn, after the shell content flushes.
// Campaign OVERHAUL S2 chunk 6: a building's shell fires no
// particle turn of its own any more — interior emitters draw at
// their own cell's CellParticles turn instead.
new[]
{
"ALPHA", "PUNCH:4@v0", "SHELL:00000104",
"FLUSH:1:LookInStatic", "FLUSH:1:Dynamic",
"CELL-PARTICLES:00000104:ca,cb",
"FLUSH:1:BuildingShell", "PARTICLES:c9",
"CELL-PARTICLES:00000104",
"FLUSH:1:BuildingShell",
},
log);
@ -623,6 +610,42 @@ public sealed class WalkFrameDriverTests
Assert.Equal(2, log.Count(entry => entry == "SEALS"));
}
// Campaign OVERHAUL S2 chunk 6 pin — the portal-haze bug this chunk
// fixes: a hidden/suspended owner (a portalling player materializing
// into its arrival cell) publishes NO registry rows there, so
// GetCellStatics/GetCellDynamics report empty records for that cell —
// yet retail's CPhysicsObj::add_particle_shadow_to_cell (0x00514a70)
// still draws that owner's emitter at the cell's own object-list turn,
// because particle draw membership is the emitter's OWN cell, never a
// registry/owner lookup. The walk must fire the cell's particle turn
// regardless of whether any static/dynamic record names an owner there.
[Fact]
public void EmitCellContentsTurn_FiresCellParticlesEvenWhenTheCellHasNoStaticOrDynamicRecords()
{
using var fx = new DispatcherFixture();
var log = new List<string>();
var leaf = new RecordingLeafRenderer(log);
var ctx = new TestContext();
const uint arrivalCellId = 0x8A020141u;
var arrivalCell = new WalkCell { CellId = arrivalCellId };
ctx.Cells[arrivalCellId] = arrivalCell;
// FakeWorldData has NO entry at all for the arrival cell —
// GetCellStatics/GetCellDynamics both fall back to
// WalkFrameStaticRecords.Empty, exactly the "hidden owner, no
// registry rows" scenario.
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
IWalkEventSink sink = driver;
using DrawScope draw = fx.BeginDraw();
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
sink.OnInteriorFloodDrawTurn([arrivalCellId]);
driver.EndFrame();
driver.Replay(draw.Frame, draw.Pass);
Assert.Contains("CELL-PARTICLES:8a020141", log);
}
[Fact]
public void LandscapeStampBoundary_RearmsWholeShellForPostClearRootRepaint()
{
@ -877,9 +900,10 @@ public sealed class WalkFrameDriverTests
driver.Replay(draw.Frame, draw.Pass);
// FW4 #132 positional invariant: the cell's emitters submit at its
// own landscape turn, after its meshes flush.
// own landscape turn, after its meshes flush. Campaign OVERHAUL S2
// chunk 6: the leaf draws by CELL, not by owner set.
Assert.Equal(
new[] { "FLUSH:2:OutdoorStatic,Dynamic", "PARTICLES:12d,12e" },
new[] { "FLUSH:2:OutdoorStatic,Dynamic", "PARTICLES:8c040005" },
log);
var visibleCells = new HashSet<uint> { 0xDEAD_BEEFu };
driver.CopyVisibleCellsTo(visibleCells);
@ -890,8 +914,15 @@ public sealed class WalkFrameDriverTests
Assert.Equal(2u, mdi.Aggregate(0u, static (sum, call) => sum + call.DrawCount));
}
// Campaign OVERHAUL S2 chunk 6: the mesh-dedup shape (a shadow alias's
// record entering two landscape cells' dictionaries) is a MESH-only
// concern — retail's per-part render membership is whole-object per
// cell. Particles are NOT part of that alias: an emitter owns exactly
// one shadow in its own current cell (add_particle_shadow_to_cell
// 0x00514a70), so each VISITED landscape cell fires its own independent
// StaticParticles turn with no cross-cell dedupe needed or performed.
[Fact]
public void OnLandscapeCellTurn_MultiCellShadowAlias_DrawsMeshAndParticlesOnlyOnce()
public void OnLandscapeCellTurn_MultiCellShadowAlias_DrawsMeshOnceButParticlesPerVisitedCell()
{
using var fx = new DispatcherFixture();
var log = new List<string>();
@ -934,7 +965,7 @@ public sealed class WalkFrameDriverTests
driver.Replay(draw.Frame, draw.Pass);
Assert.Equal(
new[] { "FLUSH:1:Dynamic", "PARTICLES:12f" },
new[] { "FLUSH:1:Dynamic", "PARTICLES:f07f0040", "PARTICLES:f0800001" },
log);
GpuRecordedMultiDrawIndirect[] mdi =
fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().ToArray();

View file

@ -837,6 +837,115 @@ public sealed class ParticleSystemTests
Assert.Single(destination);
}
// Campaign OVERHAUL S2 chunk 6: retail CPhysicsObj::add_particle_shadow_to_cell
// (0x00514a70) gives an emitter exactly one shadow in ITS OWN current
// cell. These pins drive CopyRenderableEmittersInCell purely through the
// public ParticleSystem API — no ShadowObjectRegistry/owner concept is
// involved at all.
[Fact]
public void CellIndex_AddRemoveMove_TracksRenderableEmittersPerCell()
{
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x32000090u,
Type = ParticleType.Still,
MaxParticles = 1,
};
int handle = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: 501u);
sys.UpdateEmitterOwnerCell(handle, 0x0102_0001u);
var visible = new HashSet<uint> { 0x0102_0001u, 0x0102_0002u };
sys.ApplyRetailView(Vector3.Zero, visible, hasCompletedView: true);
var destination = new List<ParticleEmitter>();
// Add: the emitter is enumerated by its cell once renderable.
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0001u, destination);
Assert.Equal(new[] { handle }, destination.Select(e => e.Handle));
// A different cell (even one that IS visible) enumerates nothing.
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0002u, destination);
Assert.Empty(destination);
// Move: the handle relocates from the old cell's bucket to the new
// one — an emitter is in exactly one cell at a time.
sys.UpdateEmitterOwnerCell(handle, 0x0102_0002u);
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0001u, destination);
Assert.Empty(destination);
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0002u, destination);
Assert.Equal(new[] { handle }, destination.Select(e => e.Handle));
// Remove: a hard stop clears the cell bucket too.
sys.StopEmitter(handle, fadeOut: false);
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0002u, destination);
Assert.Empty(destination);
}
[Fact]
public void CellIndex_MultipleEmittersInOneCell_EnumerateInSpawnOrder()
{
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x32000091u,
Type = ParticleType.Still,
MaxParticles = 1,
};
int first = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: 601u);
sys.UpdateEmitterOwnerCell(first, 0x0203_0005u);
int second = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: 602u);
sys.UpdateEmitterOwnerCell(second, 0x0203_0005u);
int unattachedInSameCell = sys.SpawnEmitter(desc, Vector3.Zero);
sys.UpdateEmitterOwnerCell(unattachedInSameCell, 0x0203_0005u);
var visible = new HashSet<uint> { 0x0203_0005u };
sys.ApplyRetailView(Vector3.Zero, visible, hasCompletedView: true);
var destination = new List<ParticleEmitter>();
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0203_0005u, destination);
Assert.Equal(
new[] { first, second, unattachedInSameCell },
destination.Select(e => e.Handle));
}
/// <summary>
/// THE chunk-6 pin: an emitter's cell membership does not depend on its
/// attached owner ever having any OTHER visible presence. This is what
/// makes the retail-Hidden portalling player's emitter still draw in its
/// arrival cell — <c>ParticleSystem</c> has no coupling to
/// <c>ShadowObjectRegistry</c> at all; presentation/view-eligibility (set
/// by the hook sink / <see cref="ParticleSystem.ApplyRetailView"/>) is the
/// ONLY gate, never the owner's render/collision membership.
/// </summary>
[Fact]
public void CopyRenderableEmittersInCell_FindsAnEmitterWhoseOwnerHasNoOtherPresence()
{
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x32000092u,
Type = ParticleType.Still,
MaxParticles = 1,
};
// A large, otherwise-unused owner id stands in for a hidden/suspended
// entity that publishes no registry rows anywhere; ParticleSystem
// never looks at any such registry, so nothing needs to simulate one.
const uint hiddenOwnerId = 0x5000_00FFu;
const uint arrivalCellId = 0x8A02_0141u;
int handle = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: hiddenOwnerId);
sys.UpdateEmitterOwnerCell(handle, arrivalCellId);
var visible = new HashSet<uint> { arrivalCellId };
sys.ApplyRetailView(Vector3.Zero, visible, hasCompletedView: true);
var destination = new List<ParticleEmitter>();
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, arrivalCellId, destination);
Assert.Equal(new[] { handle }, destination.Select(e => e.Handle));
}
[Fact]
public void OrderedIndexes_PreserveSpawnOrderAcrossHardRemovalAndPassFiltering()
{