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

@ -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()
{