using System.Numerics;
using AcDream.App.World;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering.Vfx;
///
/// Update-thread owner of live effect profiles, mixed pre-materialization
/// F754/F755 delivery, typed-table resolution, and effect-producing animation
/// hooks. Server GUIDs are translated here; downstream sinks see local IDs.
///
///
/// Network pending delivery follows retail
/// SmartBox::HandlePlayScriptID (0x00452020) and
/// SmartBox::HandlePlayScriptType (0x00452070):
/// queue only while the object is absent; an existing cell-less object no-ops.
/// Typed/default playback ports CPhysicsObj::play_script
/// (0x00513260) and both play_default_script overloads
/// (0x005132B0, 0x00513300).
///
public sealed class EntityEffectController : IAnimationHookSink,
IEntityEffectAdvanceSource
{
private readonly LiveEntityRuntime _liveEntities;
private readonly PhysicsScriptRunner _runner;
private readonly PhysicsScriptTableResolver _tables;
private readonly EntityEffectPoseRegistry _poses;
private readonly Func _childAtPart;
private readonly Func _parentOfAttachedChild;
private readonly Action _ownerUnregistered;
private readonly Action _ownerSoundTableChanged;
private readonly Action _playServerSound;
private readonly Dictionary _liveProfiles = [];
private readonly HashSet _readyLiveOwners = [];
// C3c constructs the App effect owner before Runtime's initial placement
// receipt binds its world presentation. During that one-time split-lifetime
// window, retail still considers the CPhysicsObj absent: SmartBox queues
// F754/F755 at 0x00452020/0x00452070, enters the object, then drains them
// through ProcessObjectNetBlobs in HandleCreateObject 0x00454C80. Keep the
// exact incarnation behind an equivalent barrier until the graphical
// placement publishes its pose and resource visibility.
private readonly HashSet _initialPresentationBarriers = [];
private readonly Dictionary> _pendingByServerGuid = new();
private readonly Dictionary _staticOwners = new();
private readonly Dictionary _staticProfiles = new();
private readonly HashSet _syntheticOwners = new();
private readonly HashSet _dirtyLiveOwners =
new(ReferenceEqualityComparer.Instance);
private readonly List _dirtyLiveOwnerOrder = [];
private readonly List _dirtyLiveOwnerSnapshot = [];
private readonly List _readyKeySnapshot = [];
private uint _posePublishLocalId;
private Action? _diagnosticSink = Console.WriteLine;
public EntityEffectController(
LiveEntityRuntime liveEntities,
PhysicsScriptRunner runner,
PhysicsScriptTableResolver tables,
EntityEffectPoseRegistry poses,
Func? childAtPart = null,
Func? parentOfAttachedChild = null,
Action? ownerUnregistered = null,
Action? ownerSoundTableChanged = null,
Action? playServerSound = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
_tables = tables ?? throw new ArgumentNullException(nameof(tables));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_childAtPart = childAtPart ?? ((_, _) => null);
_parentOfAttachedChild = parentOfAttachedChild ?? (_ => null);
_ownerUnregistered = ownerUnregistered ?? (_ => { });
_ownerSoundTableChanged = ownerSoundTableChanged ?? ((_, _) => { });
_playServerSound = playServerSound ?? ((_, _, _, _) => { });
_runner.DiagnosticSink = message => _diagnosticSink?.Invoke(message);
_poses.EffectPoseChanged += OnEffectPoseChanged;
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
}
public Action? DiagnosticSink
{
get => _diagnosticSink;
set => _diagnosticSink = value;
}
public int PendingPacketCount => _pendingByServerGuid.Values.Sum(queue => queue.Count);
public int ReadyOwnerCount => _liveProfiles.Count + _staticProfiles.Count;
internal int LastPoseRefreshOwnerVisitCount { get; private set; }
public void HandleDirect(PlayPhysicsScript message)
{
if (message.Guid == 0)
return;
if (TryGetReadyLocalId(message.Guid, out uint localId))
{
RefreshLiveAnchor(message.Guid, localId);
if (CanStartOwner(localId))
PlayDirect(localId, message.ScriptDid);
else if (IsWaitingForInitialPresentation(message.Guid))
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
return;
}
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
}
///
/// Retail's server-driven sound channel, SmartBox::HandleSoundEvent @
/// 0x00451FC0. The guid resolution, absent-object queueing, and
/// ordered replay are the same machinery the F754/F755 handlers above use,
/// because retail routes all three through one CObjectMaint blob queue
/// — an unknown guid parks the blob and HandleCreateObject drains it,
/// so a creature that spawns and immediately grunts still grunts.
///
public void HandleSound(SoundEvent message)
{
if (message.Guid == 0)
return;
if (TryGetReadyLocalId(message.Guid, out uint localId))
{
RefreshLiveAnchor(message.Guid, localId);
if (CanStartOwner(localId))
PlayServerSound(localId, message.SoundType, message.Volume);
else if (IsWaitingForInitialPresentation(message.Guid))
Enqueue(message.Guid, PendingEffect.Sound(message.SoundType, message.Volume));
return;
}
Enqueue(message.Guid, PendingEffect.Sound(message.SoundType, message.Volume));
}
public void HandleTyped(PlayPhysicsScriptType message)
{
if (message.Guid == 0)
return;
if (TryGetReadyLocalId(message.Guid, out uint localId))
{
RefreshLiveAnchor(message.Guid, localId);
if (CanStartOwner(localId))
PlayTyped(localId, message.RawScriptType, message.Intensity);
else if (IsWaitingForInitialPresentation(message.Guid))
{
Enqueue(
message.Guid,
PendingEffect.Typed(message.RawScriptType, message.Intensity));
}
return;
}
Enqueue(message.Guid, PendingEffect.Typed(message.RawScriptType, message.Intensity));
}
///
/// Marks a live owner ready only after its projection, resource owners, and
/// effect profile have all registered. Pending packets replay once in their
/// original mixed F754/F755 order.
///
public bool OnLiveEntityReady(uint serverGuid)
{
if (!PrepareLiveEntityOwner(serverGuid))
return false;
ReplayPendingForLiveEntity(serverGuid);
return true;
}
///
/// Registers the local effect owner without replaying pre-create packets.
/// Production uses this narrow barrier so constructor/set_description
/// state effects can run before SmartBox replays queued network blobs.
///
public bool PrepareLiveEntityOwner(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is not { } entity
|| !record.ResourcesRegistered
|| record.EffectProfile is not EntityEffectProfile profile)
{
return false;
}
RuntimeEntityKey key = RequireProjectionKey(record);
_readyLiveOwners.Add(key);
_liveProfiles[key] = profile;
if (record.MaterializationResidence is
LiveEntityMaterializationResidence.AwaitRuntimePlacement
&& !record.IsSpatiallyProjected)
{
_initialPresentationBarriers.Add(key);
}
else
{
_initialPresentationBarriers.Remove(key);
}
_runner.SetOwnerAnchor(entity.Id, entity.Position);
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
return true;
}
///
/// Opens the C3c-only network-script barrier after the initial placement
/// has published the entity pose and presentation resources, then replays
/// the retained mixed F754/F755 FIFO synchronously in arrival order.
///
public bool OnPresentationBound(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
if (record.ProjectionKey is not { } key
|| !_liveEntities.TryGetRecord(key, out LiveEntityRecord current)
|| !ReferenceEquals(current, record))
{
return false;
}
_initialPresentationBarriers.Remove(key);
if (!TryGetReadyLocalId(record.ServerGuid, out uint localId))
return true;
RefreshLiveAnchor(record.ServerGuid, localId);
TryReplayPending(record.ServerGuid, localId);
return true;
}
/// Replays the mixed F754/F755 FIFO after full object construction.
public bool ReplayPendingForLiveEntity(uint serverGuid)
{
if (!TryGetReadyLocalId(serverGuid, out uint localId))
return false;
TryReplayPending(serverGuid, localId);
return true;
}
///
/// Re-publishes network-owned profile resources after a same-generation
/// PhysicsDesc replacement. Retail CPhysicsObj::set_description
/// (0x00514F40) releases and reinstalls stable_id on every
/// description application, including present-zero clearing.
///
public bool OnLiveEntityDescriptionChanged(uint serverGuid)
{
if (!TryGetReadyLocalId(serverGuid, out uint localId)
|| !_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.EffectProfile is not EntityEffectProfile profile)
{
return false;
}
_liveProfiles[RequireProjectionKey(record)] = profile;
_ownerSoundTableChanged(localId, profile.CurrentSoundTableDid);
return true;
}
public void OnDatStaticEntityReady(
uint ownerLocalId,
WorldEntity entity,
EntityEffectProfile profile)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(profile);
if (ownerLocalId == 0)
return;
_staticOwners[ownerLocalId] = entity;
_staticProfiles[ownerLocalId] = profile;
_runner.SetOwnerAnchor(ownerLocalId, entity.Position);
_ownerSoundTableChanged(ownerLocalId, profile.CurrentSoundTableDid);
}
public void OnDatStaticEntityRemoved(uint localId)
{
if (!_staticOwners.Remove(localId))
return;
_staticProfiles.Remove(localId);
_runner.StopAllForEntity(localId);
_ownerUnregistered(localId);
}
public void RegisterSyntheticOwner(uint ownerLocalId)
{
if (ownerLocalId != 0)
_syntheticOwners.Add(ownerLocalId);
}
public void UnregisterSyntheticOwner(uint ownerLocalId) =>
_syntheticOwners.Remove(ownerLocalId);
public void OnLiveEntityUnregistered(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
if (!_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|| ReferenceEquals(current, record))
{
_pendingByServerGuid.Remove(record.ServerGuid);
}
if (record.ProjectionKey is { } key)
{
_readyLiveOwners.Remove(key);
_liveProfiles.Remove(key);
_initialPresentationBarriers.Remove(key);
}
if (record.LocalEntityId is not { } localId)
return;
_runner.StopAllForEntity(localId);
_ownerUnregistered(localId);
}
/// Clears a pending owner that never reached CreateObject.
public void ForgetUnknownOwner(uint serverGuid) =>
_pendingByServerGuid.Remove(serverGuid);
public void ClearNetworkState()
{
_readyKeySnapshot.Clear();
_readyKeySnapshot.AddRange(_readyLiveOwners);
foreach (RuntimeEntityKey key in _readyKeySnapshot)
{
_runner.StopAllForEntity(key.LocalEntityId);
_ownerUnregistered(key.LocalEntityId);
}
_readyLiveOwners.Clear();
_liveProfiles.Clear();
_initialPresentationBarriers.Clear();
_pendingByServerGuid.Clear();
_dirtyLiveOwners.Clear();
_dirtyLiveOwnerOrder.Clear();
_dirtyLiveOwnerSnapshot.Clear();
}
///
/// Publishes only roots dirtied by movement, animation, cell projection,
/// or an authoritative position update. The queue is detached before
/// callbacks run, so a callback-produced mutation is retained for the
/// following update rather than invalidating this traversal.
///
public void RefreshLiveOwnerPoses()
{
LastPoseRefreshOwnerVisitCount = 0;
if (_dirtyLiveOwnerOrder.Count == 0)
return;
_dirtyLiveOwnerSnapshot.Clear();
_dirtyLiveOwnerSnapshot.AddRange(_dirtyLiveOwnerOrder);
_dirtyLiveOwnerOrder.Clear();
_dirtyLiveOwners.Clear();
foreach (LiveEntityRecord record in _dirtyLiveOwnerSnapshot)
{
if (!_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, record)
|| !TryGetReadyLocalId(record.ServerGuid, out uint localId)
|| record.WorldEntity?.Id != localId)
{
continue;
}
LastPoseRefreshOwnerVisitCount++;
RefreshLiveAnchor(record.ServerGuid, localId);
TryReplayPending(record.ServerGuid, localId);
}
}
///
/// Marks an accepted authoritative root mutation. The record reference,
/// rather than only its server GUID, isolates a queued update from later
/// delete/recreate reuse of that GUID.
///
public void MarkLiveOwnerPoseDirty(uint serverGuid)
{
if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record))
MarkLiveOwnerPoseDirty(record);
}
public bool PlayDirect(uint ownerLocalId, uint scriptDid)
{
if (!CanStartOwner(ownerLocalId))
return false;
return _runner.PlayDirect(ownerLocalId, scriptDid);
}
///
/// Plays one server-addressed SoundType slot on a ready owner, at the
/// owner's current root pose. Retail's CPhysicsObj::play_sound @
/// 0x0050F460 drops the sound silently when the object carries no
/// SoundTable, which the sink below reproduces.
///
private void PlayServerSound(uint ownerLocalId, uint soundType, float volume)
{
if (!CanStartOwner(ownerLocalId))
return;
Vector3 anchor = _poses.TryGetRootPose(ownerLocalId, out Matrix4x4 rootWorld)
? rootWorld.Translation
: Vector3.Zero;
_playServerSound(ownerLocalId, anchor, soundType, volume);
}
public bool PlayTyped(uint ownerLocalId, uint rawScriptType, float intensity)
{
// Retail CPhysicsObj::play_script @ 0x00513260 does not enqueue for a
// cell-less object. Network F755/default-hook playback comes through
// this ordinary path.
if (!CanStartOwner(ownerLocalId))
return false;
return ResolveAndQueueTyped(ownerLocalId, rawScriptType, intensity);
}
///
/// Queues a typed script from CPhysicsObj::set_hidden without the
/// ordinary play_script cell gate.
///
///
/// Retail set_hidden at 0x00514C60 resolves Hidden/UnHide
/// directly and calls play_script_internal. This is load-bearing
/// during portal travel: the owner may already be cell-less while its
/// purple materialization script must remain queued for destination entry.
///
public bool PlayTypedFromHiddenTransition(
uint ownerLocalId,
uint rawScriptType,
float intensity)
=> ResolveAndQueueTyped(ownerLocalId, rawScriptType, intensity);
private bool ResolveAndQueueTyped(
uint ownerLocalId,
uint rawScriptType,
float intensity)
{
if (!TryGetProfile(ownerLocalId, out EntityEffectProfile? profile)
|| profile.CurrentPhysicsScriptTableDid is not { } tableDid)
{
DiagnosticSink?.Invoke(
$"No PhysicsScriptTable for owner 0x{ownerLocalId:X8}, type 0x{rawScriptType:X8}.");
return false;
}
uint? scriptDid = _tables.Resolve(
tableDid,
rawScriptType,
intensity,
out Exception? loadFailure);
if (scriptDid is not { } resolved)
{
string detail = loadFailure is null
? string.Empty
: $" Load failed: {loadFailure.GetType().Name}: {loadFailure.Message}";
DiagnosticSink?.Invoke(
$"No typed PhysicsScript for owner 0x{ownerLocalId:X8}, table 0x{tableDid:X8}, " +
$"type 0x{rawScriptType:X8}, intensity {intensity:R}.{detail}");
return false;
}
return _runner.PlayDirect(ownerLocalId, resolved);
}
public bool PlayDefault(uint ownerLocalId)
{
if (!CanStartOwner(ownerLocalId)
|| !TryGetProfile(ownerLocalId, out EntityEffectProfile? profile))
return false;
return PlayTyped(
ownerLocalId,
profile.RawDefaultScriptType,
profile.DefaultScriptIntensity);
}
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{
_runner.SetOwnerAnchor(entityId, entityWorldPosition);
switch (hook)
{
case CallPESHook call:
if (CanStartOwner(entityId))
_runner.ScheduleCallPes(entityId, call.PES, call.Pause);
break;
case DefaultScriptHook:
PlayDefault(entityId);
break;
case DefaultScriptPartHook part:
if (_childAtPart(entityId, part.PartIndex) is { } childLocalId)
PlayDefault(childLocalId);
break;
}
}
///
/// Retail CPhysicsObj::update_object (0x00515D40) advances
/// root scripts only with a cell and while not Frozen. Parented objects
/// advance through their eligible parent's UpdateChild path instead of
/// their own cell-less root update.
///
public bool CanAdvanceOwner(uint ownerLocalId)
{
if (_staticOwners.ContainsKey(ownerLocalId) || _syntheticOwners.Contains(ownerLocalId))
return true;
if (_parentOfAttachedChild(ownerLocalId) is { } parentLocalId)
return CanAdvanceLiveRoot(parentLocalId);
return CanAdvanceLiveRoot(ownerLocalId);
}
private bool CanStartOwner(uint ownerLocalId)
{
if (_staticOwners.ContainsKey(ownerLocalId) || _syntheticOwners.Contains(ownerLocalId))
return true;
if (_parentOfAttachedChild(ownerLocalId) is { } parentLocalId)
return IsLiveRootInCell(parentLocalId);
return IsLiveRootInCell(ownerLocalId);
}
private bool CanAdvanceLiveRoot(uint ownerLocalId)
{
if (!TryGetLiveRoot(ownerLocalId, out LiveEntityRecord record))
return false;
return IsLiveRootInCell(record)
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
}
private bool IsLiveRootInCell(uint ownerLocalId) =>
TryGetLiveRoot(ownerLocalId, out LiveEntityRecord record)
&& IsLiveRootInCell(record);
private static bool IsLiveRootInCell(LiveEntityRecord record) =>
record.ResourcesRegistered
&& record.IsSpatiallyProjected
&& record.IsSpatiallyVisible
&& record.FullCellId != 0;
private bool TryGetLiveRoot(uint ownerLocalId, out LiveEntityRecord record)
{
if (_liveEntities.TryGetRecordByLocalEntityId(
ownerLocalId,
out record!))
{
return true;
}
record = null!;
return false;
}
private bool TryGetReadyLocalId(uint serverGuid, out uint localId)
{
if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
&& record.ProjectionKey is { } key
&& _readyLiveOwners.Contains(key)
&& _liveProfiles.ContainsKey(key))
{
localId = key.LocalEntityId;
return true;
}
localId = 0;
return false;
}
private bool IsWaitingForInitialPresentation(uint serverGuid) =>
_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
&& record.ProjectionKey is { } key
&& _initialPresentationBarriers.Contains(key);
private void OnEffectPoseChanged(uint localId)
{
if (_posePublishLocalId == localId)
return;
if (_liveEntities.TryGetRecordByLocalEntityId(
localId,
out LiveEntityRecord record))
{
MarkLiveOwnerPoseDirty(record);
}
}
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (visible)
MarkLiveOwnerPoseDirty(record);
}
private void MarkLiveOwnerPoseDirty(LiveEntityRecord record)
{
if (record.ProjectionKey is not { } key
|| !_readyLiveOwners.Contains(key)
|| !_dirtyLiveOwners.Add(record))
{
return;
}
_dirtyLiveOwnerOrder.Add(record);
}
private void RefreshLiveAnchor(uint serverGuid, uint localId)
{
if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
&& record.WorldEntity is { } entity
&& entity.Id == localId)
{
// Top-level roots follow the WorldEntity directly. Attached roots
// were already composed through the parent's current part by
// EquippedChildRenderController; overwriting one with the child's
// bookkeeping Position would collapse a weapon effect to the
// parent's origin.
if (record.ProjectionKind is LiveEntityProjectionKind.World)
{
uint previousPublish = _posePublishLocalId;
_posePublishLocalId = localId;
try
{
_poses.UpdateRoot(entity);
}
finally
{
_posePublishLocalId = previousPublish;
}
}
Vector3 anchor = _poses.TryGetRootPose(localId, out Matrix4x4 rootWorld)
? rootWorld.Translation
: entity.Position;
_runner.SetOwnerAnchor(localId, anchor);
}
}
private void Enqueue(uint serverGuid, PendingEffect effect)
{
if (!_pendingByServerGuid.TryGetValue(serverGuid, out Queue? queue))
{
queue = new Queue();
_pendingByServerGuid.Add(serverGuid, queue);
}
queue.Enqueue(effect);
}
private void TryReplayPending(uint serverGuid, uint localId)
{
if (!CanStartOwner(localId)
|| !_pendingByServerGuid.Remove(serverGuid, out Queue? pending))
{
return;
}
while (pending.Count > 0)
Execute(localId, pending.Dequeue());
}
private void Execute(uint localId, PendingEffect effect)
{
switch (effect.Kind)
{
case PendingEffectKind.Direct:
PlayDirect(localId, effect.ScriptDid);
break;
case PendingEffectKind.Typed:
PlayTyped(localId, effect.RawScriptType, effect.Intensity);
break;
case PendingEffectKind.Sound:
PlayServerSound(localId, effect.RawScriptType, effect.Intensity);
break;
}
}
private bool TryGetProfile(
uint ownerLocalId,
out EntityEffectProfile profile)
{
if (_liveEntities.TryGetRecordByLocalEntityId(
ownerLocalId,
out LiveEntityRecord record)
&& record.ProjectionKey is { } key
&& _liveProfiles.TryGetValue(key, out profile!))
{
return true;
}
return _staticProfiles.TryGetValue(ownerLocalId, out profile!);
}
private static RuntimeEntityKey RequireProjectionKey(
LiveEntityRecord record) =>
record.ProjectionKey
?? throw new InvalidOperationException(
$"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " +
"has no exact projection key.");
private enum PendingEffectKind
{
Direct,
Typed,
Sound,
}
private readonly record struct PendingEffect(
PendingEffectKind Kind,
uint ScriptDid,
uint RawScriptType,
float Intensity)
{
public static PendingEffect Direct(uint scriptDid) =>
new(PendingEffectKind.Direct, scriptDid, 0u, 0f);
public static PendingEffect Typed(uint rawScriptType, float intensity) =>
new(PendingEffectKind.Typed, 0u, rawScriptType, intensity);
// RawScriptType carries the SoundType slot and Intensity the wire
// volume; the fields are reused rather than widening the struct, since a
// queued sound and a queued script share one ordered queue per guid the
// way retail's CObjectMaint blob queue does.
public static PendingEffect Sound(uint soundType, float volume) =>
new(PendingEffectKind.Sound, 0u, soundType, volume);
}
}