acdream/src/AcDream.App/World/LiveEntityPresentationController.cs
Erik f6b4584bf3 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>
2026-09-03 06:28:09 +02:00

354 lines
14 KiB
C#

using AcDream.App.Physics;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.App.World;
/// <summary>
/// Applies the presentation/collision side effects of accepted retail
/// PhysicsState transitions after the canonical live owner is ready.
/// Logical ownership stays in <see cref="LiveEntityRuntime"/>; Hidden never
/// unregisters scripts, particles, lights, meshes, or the live record.
/// </summary>
/// <remarks>
/// Ports <c>CPhysicsObj::set_state</c> (<c>0x00514DD0</c>),
/// <c>set_nodraw</c> (<c>0x0050FCA0</c>), and <c>set_hidden</c>
/// (<c>0x00514C60</c>). Typed script ids are retail
/// <c>PS_UnHide=0x75</c> and <c>PS_Hidden=0x76</c> from <c>acclient.h</c>.
/// </remarks>
public sealed class LiveEntityPresentationController : IDisposable
{
public const uint UnHideScriptType = 0x75u;
public const uint HiddenScriptType = 0x76u;
private readonly LiveEntityRuntime _liveEntities;
private readonly ShadowObjectRegistry _shadows;
private readonly Func<uint, uint, float, bool> _playTyped;
private readonly Action<uint, bool> _setDirectChildrenNoDraw;
private readonly Action<uint> _clearInvalidTarget;
private readonly Func<(int X, int Y)> _liveCenter;
private readonly Action<uint>? _onShadowRestored;
private readonly LiveEntityPartArrayEnterWorldPort _partArrayEnterWorld;
private readonly HashSet<RuntimeEntityKey> _readyOwners = [];
private readonly HashSet<RuntimeEntityKey> _suspendedShadowOwners = [];
private readonly HashSet<LiveEntityRecord> _drainingRecords = new();
private bool _disposed;
public LiveEntityPresentationController(
LiveEntityRuntime liveEntities,
ShadowObjectRegistry shadows,
Func<uint, uint, float, bool> playTyped,
LiveEntityPartArrayEnterWorldPort partArrayEnterWorld,
Action<uint, bool>? setDirectChildrenNoDraw = null,
Action<uint>? clearInvalidTarget = null,
Func<(int X, int Y)>? liveCenter = null,
Action<uint>? onShadowRestored = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_playTyped = playTyped ?? throw new ArgumentNullException(nameof(playTyped));
_partArrayEnterWorld = partArrayEnterWorld
?? throw new ArgumentNullException(nameof(partArrayEnterWorld));
_setDirectChildrenNoDraw = setDirectChildrenNoDraw ?? ((_, _) => { });
_clearInvalidTarget = clearInvalidTarget ?? (_ => { });
_liveCenter = liveCenter ?? (() => (0, 0));
_onShadowRestored = onShadowRestored;
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
}
/// <summary>
/// Opens the state-side-effect barrier after render/effect owners have
/// registered, then drains constructor and pre-materialization transitions
/// exactly once in accepted order.
/// </summary>
public bool OnLiveEntityReady(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is null
|| !record.ResourcesRegistered)
{
return false;
}
RuntimeEntityKey key = RequireProjectionKey(record);
_readyOwners.Add(key);
if (!ApplyPendingTransitions(record)
|| record.WorldEntity is not { } entity
|| !IsCurrent(record, entity))
{
return false;
}
// An object can materialize into a pending landblock before collision
// registration and before this ready barrier opens. Its initial false
// visibility edge therefore had nothing to suspend. Reconcile here,
// after every create-time owner exists, so the retained registration
// cannot remain active offscreen and hydration has a restore marker.
SuspendOrdinaryShadowOutsideProjection(record, entity);
return IsCurrent(record, entity);
}
/// <summary>Drains a newly accepted SetState when this incarnation is ready.</summary>
public bool OnStateAccepted(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| !TryGetProjectionKey(record, out RuntimeEntityKey key)
|| !_readyOwners.Contains(key))
{
return false;
}
return ApplyPendingTransitions(record);
}
internal bool HasDeferredShadowRestore(uint serverGuid) =>
TryGetCurrentProjectionKey(serverGuid, out RuntimeEntityKey key)
&& _suspendedShadowOwners.Contains(key);
internal int ReadyOwnerCount => _readyOwners.Count;
internal int DeferredShadowRestoreCount => _suspendedShadowOwners.Count;
public void Forget(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
if (TryGetProjectionKey(record, out RuntimeEntityKey key))
{
_readyOwners.Remove(key);
_suspendedShadowOwners.Remove(key);
}
_drainingRecords.Remove(record);
}
public void Clear()
{
_readyOwners.Clear();
_suspendedShadowOwners.Clear();
_drainingRecords.Clear();
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
Clear();
}
private bool ApplyPendingTransitions(LiveEntityRecord record)
{
// State callbacks can synchronously accept another SetState. Retail's
// update thread completes the current transition before the next FIFO
// entry; a recursive drain would interleave Visible halfway through
// Hidden and then let the older side effects win last.
if (!_drainingRecords.Add(record))
return IsCurrent(record, record.WorldEntity);
try
{
while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition))
{
if (record.WorldEntity is not { } entity
|| !IsCurrent(record, entity))
{
return false;
}
// set_state writes the final state before any PartArray/cell side
// effect. Retained shadow registrations must see those same bits
// even while their cell rows are suspended.
_shadows.UpdatePhysicsState(entity.Id, (uint)transition.FinalState);
switch (transition.HiddenTransition)
{
case RetailHiddenTransition.BecameHidden:
_playTyped(entity.Id, HiddenScriptType, 1f);
if (!IsCurrent(record, entity))
return false;
_setDirectChildrenNoDraw(record.ServerGuid, true);
if (!IsCurrent(record, entity))
return false;
_shadows.Suspend(entity.Id);
_suspendedShadowOwners.Add(RequireProjectionKey(record));
// Retail CPhysicsObj::set_hidden @ 0x00514C60 calls
// CPartArray::HandleEnterWorld after hiding the object
// from its cell. Despite the name, this is the motion
// timeline boundary: it strips link animations and
// aborts every pending completion through
// MotionTableManager::HandleEnterWorld @ 0x0051BDD0.
_partArrayEnterWorld.HandleEnterWorld(entity.Id);
if (!IsCurrent(record, entity))
return false;
_clearInvalidTarget(record.ServerGuid);
if (!IsCurrent(record, entity))
return false;
break;
case RetailHiddenTransition.BecameVisible:
_playTyped(entity.Id, UnHideScriptType, 1f);
if (!IsCurrent(record, entity))
return false;
_setDirectChildrenNoDraw(record.ServerGuid, false);
if (!IsCurrent(record, entity))
return false;
// Retail invokes the same PartArray boundary before
// CObjCell::unhide_object restores cell visibility.
_partArrayEnterWorld.HandleEnterWorld(entity.Id);
if (!IsCurrent(record, entity))
return false;
bool restored = RestoreShadow(record, entity);
if (!IsCurrent(record, entity))
return false;
if (restored)
_suspendedShadowOwners.Remove(RequireProjectionKey(record));
break;
}
}
return IsCurrent(record, record.WorldEntity);
}
finally
{
_drainingRecords.Remove(record);
}
}
private bool RestoreShadow(LiveEntityRecord record, AcDream.Core.World.WorldEntity entity)
{
if (!record.IsSpatiallyProjected
|| !record.IsSpatiallyVisible
|| record.FullCellId == 0
// #319 F2 (contract §3.2): a committed child never owns an
// independent broadphase row (route 7's P4 record,
// RuntimeEntityDirectory.cs:451-465). IsSpatiallyProjected and
// IsSpatiallyVisible do NOT exclude a child - its presentation-
// only rebucket sets IsSpatiallyProjected=true every frame
// (LiveEntityRuntime.RebucketLiveEntityPresentationOnly) - so
// parentage must be checked directly, the same predicate
// LiveEntityHydrationController.OnLandblockLoaded's gate uses.
// CORRECTION (architecture review A4, 2026-08-05): the
// contract's §3.2 premise ("no-ops today on FullCellId == 0")
// holds only for PLAYER-parented children - route 7's D1
// already re-cells CREATURE-parented children to a nonzero
// cell, so this clause IS a live behavior change for that
// class (an NPC's wielded weapon no longer refreshes its
// shadow row on a Hidden->Visible edge). Right direction per
// route 7's P4 record; the connected gate's Half B must watch
// for it explicitly (issue #319 contract §7).
|| _liveEntities.ParentAttachments.HasCommittedParent(record.ServerGuid))
{
return false;
}
(int centerX, int centerY) = _liveCenter();
ShadowPositionSynchronizer.Sync(
_shadows,
entity.Id,
entity.Position,
entity.Rotation,
record.FullCellId,
centerX,
centerY);
_onShadowRestored?.Invoke(record.ServerGuid);
return true;
}
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (record.WorldEntity is not { } entity
|| !IsCurrent(record, entity))
{
return;
}
// exit_world removes an ordinary object's collision rows on the same
// projection edge that suspends its object clock. Keep the retained
// registration so a stationary object can be restored immediately on
// hydration; it may have no later movement quantum to repair itself.
// Projectiles own their matching suspend/restore transaction in their
// dedicated controller. C4 route 4b-3 deleted the standalone remote-
// teleport placement controller (and its `_activePlacementOwners`
// suspension gate here) — the canonical Runtime placement owner now
// handles that path, and this class no longer defers to a second
// authority.
if (!visible)
{
SuspendOrdinaryShadowOutsideProjection(record, entity);
return;
}
if ((record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
|| !TryGetProjectionKey(record, out RuntimeEntityKey key)
|| !_readyOwners.Contains(key)
|| !_suspendedShadowOwners.Contains(key))
{
return;
}
bool restored = RestoreShadow(record, entity);
if (!IsCurrent(record, entity))
return;
if (restored)
_suspendedShadowOwners.Remove(key);
}
private void SuspendOrdinaryShadowOutsideProjection(
LiveEntityRecord record,
AcDream.Core.World.WorldEntity entity)
{
if (record.IsSpatiallyVisible
|| record.ProjectileRuntime is not null
|| !TryGetProjectionKey(record, out RuntimeEntityKey key)
|| !_readyOwners.Contains(key)
|| !_shadows.Suspend(entity.Id))
{
return;
}
_suspendedShadowOwners.Add(key);
}
private bool IsCurrent(
LiveEntityRecord record,
AcDream.Core.World.WorldEntity? entity) =>
entity is not null
&& _liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
&& ReferenceEquals(current, record)
&& ReferenceEquals(current.WorldEntity, entity);
private bool TryGetCurrentProjectionKey(
uint serverGuid,
out RuntimeEntityKey key)
{
if (_liveEntities.TryGetRecord(
serverGuid,
out LiveEntityRecord record))
{
return TryGetProjectionKey(record, out key);
}
key = default;
return false;
}
private static bool TryGetProjectionKey(
LiveEntityRecord record,
out RuntimeEntityKey key)
{
if (record.ProjectionKey is { } projectionKey)
{
key = projectionKey;
return true;
}
key = default;
return false;
}
private static RuntimeEntityKey RequireProjectionKey(
LiveEntityRecord record) =>
record.ProjectionKey
?? throw new InvalidOperationException(
$"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " +
"has no exact projection key.");
}