fix(render): S2 chunk 6 review round — one particle turn per cell per stamp, cheap empty cells, dead owner-set stub deleted

Retail-lens review of chunk 6 (no blocking finding). Fixed:
- a cell that gets two object-list turns in one frame (a chamber reached
  through two portals) submitted its emitters twice; retail's particle parts
  sit in the same shadow_part_list as every other part and CPhysicsPart::Draw's
  frame stamp suppresses the second draw, so the walk now dedupes the particle
  turn with the same frame-scoped set that dedupes the cell shell;
- every visited land cell paid the full per-cell draw setup even with no
  emitter; DrawForCell now returns after the cell lookup, retail's own cost
  (DrawPartCell 0x005a07a0 `num_shadow_parts > 0`);
- CopyRenderableEmittersInCell maintains LastRenderScopeEmitterVisitCount;
- the OutdoorSceneParticleEntityIds / outdoorOwnerIds stub chain (permanently
  empty, never read) is deleted through IWorldSceneRenderer,
  WorldScenePViewRenderer, IWorldScenePasses and the composition root;
- AD-117 item 4 names the two behavioral residuals (owner-cell substitution;
  no per-emission AddPartToShadowCells);
- ParticleHookSinkTests pins that an emitter's draw cell is its owner's pose
  cell and survives the projection-visibility switch across the per-frame
  view pass.

Gates: Core 4,988/4,988 (Vfx 108/108), App hermetic 6,760/6,760, Runtime
1,884/1,884.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-03 06:52:28 +02:00
parent 7969c2e6ad
commit 6d5afcccde
11 changed files with 891 additions and 800 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 | | # | 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) 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-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. Two behavioral residuals remain: acdream's emitter cell is its OWNER's pose cell (`ParticleEmitter.OwnerCellId``EntityEffectPoseRegistry.CellId`), where retail's emitter is a standalone `CPhysicsObj` (`makeParticleObject` 0x00512640) with its own position and cell; and retail re-evaluates membership per emission (`ParticleEmitter::EmitParticle``CPhysicsObj::AddPartToShadowCells` at 0x0051d126), which acdream does not — a caster in a doorway draws the cloud at the caster's cell turn only. | `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-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-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` | | 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` |

File diff suppressed because one or more lines are too long

View file

@ -508,7 +508,6 @@ internal sealed class FrameRootCompositionPhase
"The retail frame walk requires the landscape registry."), "The retail frame walk requires the landscape registry."),
d.CellVisibility, d.CellVisibility,
d.PhysicsEngine.ShadowObjects), d.PhysicsEngine.ShadowObjects),
retailPViewPassExecutor,
retailPViewPassExecutor), retailPViewPassExecutor),
retailPViewCells, retailPViewCells,
worldScenePasses, worldScenePasses,

View file

@ -284,6 +284,12 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
return; return;
_particles.CopyRenderableEmittersInCell(renderPass, cellId, _scopedEmitterScratch); _particles.CopyRenderableEmittersInCell(renderPass, cellId, _scopedEmitterScratch);
// Retail's per-cell particle cost for an emitter-less cell is one
// count check (RenderDeviceD3D::DrawPartCell 0x005a07a0,
// `num_shadow_parts > 0`); every visited land cell reaches here, so
// pay nothing more than that here either (chunk 6 review F2).
if (_scopedEmitterScratch.Count == 0)
return;
Matrix4x4.Invert(camera.View, out Matrix4x4 invView); Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13)); Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23)); Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));

View file

@ -50,18 +50,12 @@ internal sealed class RetailPViewCellSource : IRetailPViewCellSource
/// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather /// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather
/// placement follows <c>LScape::draw @ 0x00506330</c>. /// placement follows <c>LScape::draw @ 0x00506330</c>.
/// </summary> /// </summary>
internal interface IOutdoorSceneParticleOwnerSource
{
IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; }
}
/// <summary> /// <summary>
/// Campaign V slice V6j: backend-neutral. The order it implements is retail's and /// Campaign V slice V6j: backend-neutral. The order it implements is retail's and
/// is written once; the four places it touches graphics state directly are owned /// is written once; the four places it touches graphics state directly are owned
/// by <see cref="IWorldPassSurface"/>. /// by <see cref="IWorldPassSurface"/>.
/// </summary> /// </summary>
internal sealed partial class RetailPViewPassExecutor : internal sealed partial class RetailPViewPassExecutor
IOutdoorSceneParticleOwnerSource
{ {
private readonly IWorldPassSurface _surface; private readonly IWorldPassSurface _surface;
private readonly IRenderFrameGlState _frameGlState; private readonly IRenderFrameGlState _frameGlState;
@ -77,17 +71,6 @@ internal sealed partial class RetailPViewPassExecutor :
private readonly WorldRenderDiagnostics _diagnostics; private readonly WorldRenderDiagnostics _diagnostics;
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics; private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
private readonly HashSet<uint> _noSceneParticleEntityIds = []; private readonly HashSet<uint> _noSceneParticleEntityIds = [];
// 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 =>
NoOutdoorSceneParticleEntityIds;
public RetailPViewPassExecutor( public RetailPViewPassExecutor(
IWorldPassSurface surface, IWorldPassSurface surface,
IRenderFrameGlState frameGlState, IRenderFrameGlState frameGlState,

View file

@ -519,6 +519,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
private WalkPlane _lookInCyPlane; private WalkPlane _lookInCyPlane;
private readonly HashSet<uint> _cellShellsDrawnThisFrame = new(); private readonly HashSet<uint> _cellShellsDrawnThisFrame = new();
// Chunk 6 review F1: one particle turn per cell per render stamp (see EmitCellContentsTurn).
private readonly HashSet<uint> _cellParticleTurnsDrawnThisFrame = new();
IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns; IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns;
@ -653,6 +655,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_floodViewRouteScratch.Clear(); _floodViewRouteScratch.Clear();
_dispatcher.EndWalkPartFrame(); _dispatcher.EndWalkPartFrame();
_cellShellsDrawnThisFrame.Clear(); _cellShellsDrawnThisFrame.Clear();
_cellParticleTurnsDrawnThisFrame.Clear();
_lookInCyPlane = default; _lookInCyPlane = default;
LookInCells.Clear(); LookInCells.Clear();
VisitedBuildings.Clear(); VisitedBuildings.Clear();
@ -787,6 +790,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_visibleClipSlotScratch.Clear(); _visibleClipSlotScratch.Clear();
_lookInCyPlane = ctx.CyPlane; _lookInCyPlane = ctx.CyPlane;
_cellShellsDrawnThisFrame.Clear(); _cellShellsDrawnThisFrame.Clear();
_cellParticleTurnsDrawnThisFrame.Clear();
LookInCells.Clear(); LookInCells.Clear();
VisitedBuildings.Clear(); VisitedBuildings.Clear();
VisitedLandscapeCellIds.Clear(); VisitedLandscapeCellIds.Clear();
@ -1249,6 +1253,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
{ {
_dispatcher.AdvanceWalkPartPassStamp(); _dispatcher.AdvanceWalkPartPassStamp();
_cellShellsDrawnThisFrame.Clear(); _cellShellsDrawnThisFrame.Clear();
_cellParticleTurnsDrawnThisFrame.Clear();
} }
// PView::DrawCells @0x005a4840: the gated full depth clear // PView::DrawCells @0x005a4840: the gated full depth clear
@ -1428,10 +1433,16 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
alphaSubmissions: _alphaSubmissions); alphaSubmissions: _alphaSubmissions);
MarkIfGrown(); MarkIfGrown();
MarkAlphaIfGrown(); MarkAlphaIfGrown();
// Fires unconditionally: retail's add_particle_shadow_to_cell draws // Fires for every cell turn regardless of whether that cell has any
// an emitter at its own cell's turn independent of whether that cell // visible static/dynamic owner record (Campaign OVERHAUL S2 chunk 6 —
// has any visible static/dynamic owner record (Campaign OVERHAUL S2 // a suspended/hidden owner's emitter must still show), but ONCE per
// chunk 6 — a suspended/hidden owner's emitter must still show). // render stamp: retail's particle parts sit in the same shadow_part_list
// as every other part, so CPhysicsPart::Draw's frame stamp suppresses a
// second submission when one cell gets two object-list turns in one
// frame (a chamber reached through two portals). The same frame-scoped
// set that dedupes the cell SHELL dedupes the particle turn (chunk 6
// review F1).
if (_cellParticleTurnsDrawnThisFrame.Add(cellId))
_events.Add(WalkFrameEvent.CellParticles(cellId)); _events.Add(WalkFrameEvent.CellParticles(cellId));
} }

View file

@ -35,7 +35,6 @@ internal interface IWorldScenePassExecutor
LoadedCell? clipRoot, LoadedCell? clipRoot,
ClipFrameAssembly? clipAssembly, ClipFrameAssembly? clipAssembly,
in WorldCameraFrame camera, in WorldCameraFrame camera,
IReadOnlySet<uint> outdoorOwnerIds,
string currentSignature); string currentSignature);
void DrawFlatWeather( void DrawFlatWeather(
@ -194,7 +193,6 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
LoadedCell? clipRoot, LoadedCell? clipRoot,
ClipFrameAssembly? clipAssembly, ClipFrameAssembly? clipAssembly,
in WorldCameraFrame camera, in WorldCameraFrame camera,
IReadOnlySet<uint> outdoorOwnerIds,
string currentSignature) string currentSignature)
{ {
if (_particles is null || _particleRenderer is null) if (_particles is null || _particleRenderer is null)

View file

@ -9,7 +9,6 @@ namespace AcDream.App.Rendering;
internal interface IWorldScenePViewRenderer internal interface IWorldScenePViewRenderer
{ {
IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; }
RetailPViewFrameResult DrawInside(RetailPViewFrameInput input); RetailPViewFrameResult DrawInside(RetailPViewFrameInput input);
@ -42,20 +41,15 @@ internal sealed class WorldScenePViewRenderer : IWorldScenePViewRenderer
{ {
private readonly RetailPViewRenderer _renderer; private readonly RetailPViewRenderer _renderer;
private readonly RetailPViewPassExecutor _passes; private readonly RetailPViewPassExecutor _passes;
private readonly IOutdoorSceneParticleOwnerSource _particles;
public WorldScenePViewRenderer( public WorldScenePViewRenderer(
RetailPViewRenderer renderer, RetailPViewRenderer renderer,
RetailPViewPassExecutor passes, RetailPViewPassExecutor passes)
IOutdoorSceneParticleOwnerSource particles)
{ {
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_passes = passes ?? throw new ArgumentNullException(nameof(passes)); _passes = passes ?? throw new ArgumentNullException(nameof(passes));
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
} }
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds =>
_particles.OutdoorSceneParticleEntityIds;
public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input) => public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input) =>
_renderer.DrawInside(input, _passes); _renderer.DrawInside(input, _passes);
@ -301,7 +295,6 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
clipRoot, clipRoot,
pviewResult?.ClipAssembly, pviewResult?.ClipAssembly,
in camera, in camera,
_pview.OutdoorSceneParticleEntityIds,
sceneParticles); sceneParticles);
if (clipRoot is null && drawSkyThisFrame) if (clipRoot is null && drawSkyThisFrame)

View file

@ -624,12 +624,14 @@ public sealed class ParticleSystem : IParticleSystem
{ {
ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(destination);
destination.Clear(); destination.Clear();
LastRenderScopeEmitterVisitCount = 0;
int passIndex = RenderPassIndex(renderPass); int passIndex = RenderPassIndex(renderPass);
if (!_cellHandlesByPass[passIndex].TryGetValue(cellId, out OwnerEmitterBucket? bucket)) if (!_cellHandlesByPass[passIndex].TryGetValue(cellId, out OwnerEmitterBucket? bucket))
return; return;
_scopeHandleScratch.Clear(); _scopeHandleScratch.Clear();
bucket.CopyRenderableHandlesTo(_scopeHandleScratch); bucket.CopyRenderableHandlesTo(_scopeHandleScratch);
LastRenderScopeEmitterVisitCount = _scopeHandleScratch.Count;
foreach (int handle in _scopeHandleScratch) foreach (int handle in _scopeHandleScratch)
{ {
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))

View file

@ -313,8 +313,6 @@ public sealed class WorldSceneRendererTests
Assert.True(result.NormalWorldDrawn); Assert.True(result.NormalWorldDrawn);
Assert.Contains("particles:pviewScoped", rig.Calls); Assert.Contains("particles:pviewScoped", rig.Calls);
Assert.Same(rig.PView.OutdoorSceneParticleEntityIds, rig.Passes.ParticleOwners);
Assert.Equal([0xCAFEu], rig.Passes.ParticleOwners);
Assert.DoesNotContain("flat:weather", rig.Calls); Assert.DoesNotContain("flat:weather", rig.Calls);
} }
@ -756,9 +754,6 @@ public sealed class WorldSceneRendererTests
diagnosticPartition: null); diagnosticPartition: null);
} }
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; } =
new HashSet<uint> { 0xCAFEu };
public RetailPViewFrameInput? LastInput { get; private set; } public RetailPViewFrameInput? LastInput { get; private set; }
public bool ThrowOnDraw { get; set; } public bool ThrowOnDraw { get; set; }
@ -800,7 +795,6 @@ public sealed class WorldSceneRendererTests
public float WeatherDayFraction { get; private set; } public float WeatherDayFraction { get; private set; }
public IReadOnlySet<uint>? ParticleOwners { get; private set; }
public void BeginFrame() => calls.Add("passes:begin"); public void BeginFrame() => calls.Add("passes:begin");
@ -837,10 +831,8 @@ public sealed class WorldSceneRendererTests
LoadedCell? clipRoot, LoadedCell? clipRoot,
ClipFrameAssembly? clipAssembly, ClipFrameAssembly? clipAssembly,
in WorldCameraFrame camera, in WorldCameraFrame camera,
IReadOnlySet<uint> outdoorOwnerIds,
string currentSignature) string currentSignature)
{ {
ParticleOwners = outdoorOwnerIds;
string kind = clipRoot switch string kind = clipRoot switch
{ {
null => "global", null => "global",

View file

@ -522,6 +522,113 @@ public sealed class ParticleHookSinkTests
.AnchorPos); .AnchorPos);
} }
/// <summary>
/// Campaign OVERHAUL S2 chunk 6 (review F6): an emitter's draw membership
/// is its OWNER's pose cell, published through the sink, and nothing else
/// — no registry row, no owner "spatial" state. Retail
/// <c>add_particle_shadow_to_cell</c> (0x00514a70) gives the emitter one
/// shadow in its own cell, drawn at that cell's turn regardless of the
/// parent's hidden state; the only presentation gate the sink owns is the
/// explicit projection-visibility switch, which is NOT the retail Hidden
/// state. Route retail-Hidden into that switch and the portal cloud
/// vanishes again — this pin is what fails first.
/// </summary>
[Fact]
public void EmitterKeepsItsOwnerCellAndStaysRenderableWithNoOwnerPresenceBesidesThePose()
{
const uint emitterId = 0x3200_0777u;
const uint cell = 0x8A02015Eu;
var registry = new EmitterDescRegistry();
registry.Register(MakeDesc(emitterId, attachLocal: false, totalParticles: 0, totalDuration: 0f));
var system = new ParticleSystem(registry, new Random(42));
var poses = new CellPoseSource();
poses.Publish(Owner, Matrix4x4.CreateTranslation(3, 4, 5), cell);
var sink = new ParticleHookSink(system, poses);
sink.OnHook(Owner, new Vector3(3, 4, 5), Create(emitterId, logicalId: 7u));
ParticleEmitter emitter = system.EnumerateLive().Single().Emitter;
Assert.Equal(cell, emitter.OwnerCellId);
// The retail ShouldDrawParticles gate: the owner cell is in view.
sink.RefreshAttachedEmitters();
system.ApplyRetailView(new Vector3(3, 4, 5), new HashSet<uint> { cell }, hasCompletedView: true);
Assert.True(emitter.PresentationVisible, "presentation visible");
Assert.True(emitter.ViewEligible, "view eligible (owner cell in view, in range)");
Assert.Equal(ParticleRenderPass.Scene, emitter.RenderPass);
var byOwner = new List<ParticleEmitter>();
system.CopyRenderableEmittersForOwners(
ParticleRenderPass.Scene, new HashSet<uint> { Owner }, includeUnattached: false, byOwner);
Assert.Single(byOwner);
var inCell = new List<ParticleEmitter>();
system.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, cell, inCell);
Assert.Single(inCell);
Assert.Same(emitter, inCell[0]);
// Only the sink's explicit projection-visibility switch removes it
// from the cell's renderable set — and only until it flips back.
sink.SetEntityPresentationVisible(Owner, false);
system.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, cell, inCell);
Assert.Empty(inCell);
sink.SetEntityPresentationVisible(Owner, true);
sink.RefreshAttachedEmitters();
// The next frame's view pass re-evaluates eligibility (production
// cadence: bindings refresh, then ApplyRetailView, then the walk).
system.ApplyRetailView(new Vector3(3, 4, 5), new HashSet<uint> { cell }, hasCompletedView: true);
system.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, cell, inCell);
Assert.Single(inCell);
Assert.Equal(cell, emitter.OwnerCellId);
}
private sealed class CellPoseSource :
IEntityEffectPoseSource,
IEntityEffectCellSource,
IEntityEffectPoseChangeSource
{
private readonly Dictionary<uint, (Matrix4x4 Root, uint Cell)> _poses = new();
public event Action<uint>? EffectPoseChanged;
public void Publish(uint id, Matrix4x4 root, uint cellId)
{
_poses[id] = (root, cellId);
EffectPoseChanged?.Invoke(id);
}
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
{
if (_poses.TryGetValue(localEntityId, out var pose))
{
rootWorld = pose.Root;
return true;
}
rootWorld = default;
return false;
}
public bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal)
{
// One identity part at index 0, the shape the sink resolves an
// emitter anchor through before it spawns.
if (partIndex == 0 && _poses.ContainsKey(localEntityId))
{
partLocal = Matrix4x4.Identity;
return true;
}
partLocal = default;
return false;
}
public bool TryGetCellId(uint localEntityId, out uint cellId)
{
if (_poses.TryGetValue(localEntityId, out var pose))
{
cellId = pose.Cell;
return true;
}
cellId = 0;
return false;
}
}
private sealed class MutablePoseSource : private sealed class MutablePoseSource :
IEntityEffectPoseSource, IEntityEffectPoseSource,
IEntityEffectPoseChangeSource IEntityEffectPoseChangeSource