acdream never parsed retail's Sound event, so every server-driven cue was silent: melee hits and wounds, wield/unwield, pickup/drop, lockpicking, lifestone bind, spell resist, trap triggers, item mana depletion. SoundEvent parses the 16-byte message (guid, SoundType, f32 volume) whose layout three oracles agree on: retail CM_Physics::DispatchSB_SoundEvent @0x006AC760 reading buf+4/+8/+0xC, ACE's GameMessageSound at declared length 16, and holtburger's PlaySoundData. Playback reuses EntityEffectController's existing per-guid queue rather than adding a second one, because retail routes sounds through the SAME CObjectMaint blob queue as F754/F755: an event for a guid the client does not know yet is parked and drained by HandleCreateObject, so a creature that spawns and immediately grunts still grunts. Dropping it — the obvious alternative — would silently lose the cue. Sound joins Direct and Typed as a third PendingEffect kind so one readiness edge releases the whole mixed stream in order. AudioHookSink.PlayServerSound reproduces two decoded asymmetries with the animation-hook path: the sound plays at the WIRE volume and the SoundTable entry's volume is ignored (the hook path does the opposite), while the entry's probability still gates it and its priority still drives eviction. An object with no SoundTable plays nothing, matching CPhysicsObj::play_sound @0x0050F460's early return. The no-window host parses and discards, exactly as it does for F754/F755 — sound is presentation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
708 lines
26 KiB
C#
708 lines
26 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Network pending delivery follows retail
|
|
/// <c>SmartBox::HandlePlayScriptID</c> (<c>0x00452020</c>) and
|
|
/// <c>SmartBox::HandlePlayScriptType</c> (<c>0x00452070</c>):
|
|
/// queue only while the object is absent; an existing cell-less object no-ops.
|
|
/// Typed/default playback ports <c>CPhysicsObj::play_script</c>
|
|
/// (<c>0x00513260</c>) and both <c>play_default_script</c> overloads
|
|
/// (<c>0x005132B0</c>, <c>0x00513300</c>).
|
|
/// </remarks>
|
|
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<uint, uint, uint?> _childAtPart;
|
|
private readonly Func<uint, uint?> _parentOfAttachedChild;
|
|
private readonly Action<uint> _ownerUnregistered;
|
|
private readonly Action<uint, uint?> _ownerSoundTableChanged;
|
|
private readonly Action<uint, Vector3, uint, float> _playServerSound;
|
|
private readonly Dictionary<RuntimeEntityKey, EntityEffectProfile> _liveProfiles = [];
|
|
private readonly HashSet<RuntimeEntityKey> _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<RuntimeEntityKey> _initialPresentationBarriers = [];
|
|
private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new();
|
|
private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
|
|
private readonly Dictionary<uint, EntityEffectProfile> _staticProfiles = new();
|
|
private readonly HashSet<uint> _syntheticOwners = new();
|
|
private readonly HashSet<LiveEntityRecord> _dirtyLiveOwners =
|
|
new(ReferenceEqualityComparer.Instance);
|
|
private readonly List<LiveEntityRecord> _dirtyLiveOwnerOrder = [];
|
|
private readonly List<LiveEntityRecord> _dirtyLiveOwnerSnapshot = [];
|
|
private readonly List<RuntimeEntityKey> _readyKeySnapshot = [];
|
|
private uint _posePublishLocalId;
|
|
private Action<string>? _diagnosticSink = Console.WriteLine;
|
|
|
|
public EntityEffectController(
|
|
LiveEntityRuntime liveEntities,
|
|
PhysicsScriptRunner runner,
|
|
PhysicsScriptTableResolver tables,
|
|
EntityEffectPoseRegistry poses,
|
|
Func<uint, uint, uint?>? childAtPart = null,
|
|
Func<uint, uint?>? parentOfAttachedChild = null,
|
|
Action<uint>? ownerUnregistered = null,
|
|
Action<uint, uint?>? ownerSoundTableChanged = null,
|
|
Action<uint, Vector3, uint, float>? 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<string>? 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail's server-driven sound channel, <c>SmartBox::HandleSoundEvent</c> @
|
|
/// <c>0x00451FC0</c>. 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 <c>CObjectMaint</c> blob queue
|
|
/// — an unknown guid parks the blob and <c>HandleCreateObject</c> drains it,
|
|
/// so a creature that spawns and immediately grunts still grunts.
|
|
/// </summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public bool OnLiveEntityReady(uint serverGuid)
|
|
{
|
|
if (!PrepareLiveEntityOwner(serverGuid))
|
|
return false;
|
|
ReplayPendingForLiveEntity(serverGuid);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Replays the mixed F754/F755 FIFO after full object construction.</summary>
|
|
public bool ReplayPendingForLiveEntity(uint serverGuid)
|
|
{
|
|
if (!TryGetReadyLocalId(serverGuid, out uint localId))
|
|
return false;
|
|
TryReplayPending(serverGuid, localId);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-publishes network-owned profile resources after a same-generation
|
|
/// PhysicsDesc replacement. Retail <c>CPhysicsObj::set_description</c>
|
|
/// (<c>0x00514F40</c>) releases and reinstalls <c>stable_id</c> on every
|
|
/// description application, including present-zero clearing.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Clears a pending owner that never reached CreateObject.</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Plays one server-addressed <c>SoundType</c> slot on a ready owner, at the
|
|
/// owner's current root pose. Retail's <c>CPhysicsObj::play_sound</c> @
|
|
/// <c>0x0050F460</c> drops the sound silently when the object carries no
|
|
/// SoundTable, which the sink below reproduces.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Queues a typed script from <c>CPhysicsObj::set_hidden</c> without the
|
|
/// ordinary <c>play_script</c> cell gate.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Retail <c>set_hidden</c> at <c>0x00514C60</c> resolves Hidden/UnHide
|
|
/// directly and calls <c>play_script_internal</c>. 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.
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) 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.
|
|
/// </summary>
|
|
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<PendingEffect>? queue))
|
|
{
|
|
queue = new Queue<PendingEffect>();
|
|
_pendingByServerGuid.Add(serverGuid, queue);
|
|
}
|
|
queue.Enqueue(effect);
|
|
}
|
|
|
|
private void TryReplayPending(uint serverGuid, uint localId)
|
|
{
|
|
if (!CanStartOwner(localId)
|
|
|| !_pendingByServerGuid.Remove(serverGuid, out Queue<PendingEffect>? 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);
|
|
}
|
|
}
|