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:
parent
2d20ee917b
commit
f6b4584bf3
14 changed files with 400 additions and 336 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue