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

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

View file

@ -522,6 +522,113 @@ public sealed class ParticleHookSinkTests
.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 :
IEntityEffectPoseSource,
IEntityEffectPoseChangeSource