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

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