feat(vfx): port retail effect scheduling and delivery
This commit is contained in:
parent
363e046112
commit
96ddfdf175
28 changed files with 2473 additions and 691 deletions
408
src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
Normal file
408
src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
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 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
|
||||
{
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly PhysicsScriptRunner _runner;
|
||||
private readonly PhysicsScriptTableResolver _tables;
|
||||
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 Dictionary<uint, EntityEffectProfile> _profilesByLocalId = new();
|
||||
private readonly HashSet<uint> _readyServerGuids = new();
|
||||
private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new();
|
||||
private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
|
||||
private readonly HashSet<uint> _syntheticOwners = new();
|
||||
private Action<string>? _diagnosticSink = Console.WriteLine;
|
||||
|
||||
public EntityEffectController(
|
||||
LiveEntityRuntime liveEntities,
|
||||
PhysicsScriptRunner runner,
|
||||
PhysicsScriptTableResolver tables,
|
||||
Func<uint, uint, uint?>? childAtPart = null,
|
||||
Func<uint, uint?>? parentOfAttachedChild = null,
|
||||
Action<uint>? ownerUnregistered = null,
|
||||
Action<uint, uint?>? ownerSoundTableChanged = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
|
||||
_tables = tables ?? throw new ArgumentNullException(nameof(tables));
|
||||
_childAtPart = childAtPart ?? ((_, _) => null);
|
||||
_parentOfAttachedChild = parentOfAttachedChild ?? (_ => null);
|
||||
_ownerUnregistered = ownerUnregistered ?? (_ => { });
|
||||
_ownerSoundTableChanged = ownerSoundTableChanged ?? ((_, _) => { });
|
||||
_runner.DiagnosticSink = message => _diagnosticSink?.Invoke(message);
|
||||
}
|
||||
|
||||
public Action<string>? DiagnosticSink
|
||||
{
|
||||
get => _diagnosticSink;
|
||||
set => _diagnosticSink = value;
|
||||
}
|
||||
|
||||
public int PendingPacketCount => _pendingByServerGuid.Values.Sum(queue => queue.Count);
|
||||
public int ReadyOwnerCount => _profilesByLocalId.Count;
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
|
||||
}
|
||||
|
||||
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);
|
||||
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 (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| !record.ResourcesRegistered
|
||||
|| record.EffectProfile is not EntityEffectProfile profile)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_readyServerGuids.Add(serverGuid);
|
||||
_profilesByLocalId[entity.Id] = profile;
|
||||
_runner.SetOwnerAnchor(entity.Id, entity.Position);
|
||||
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
|
||||
TryReplayPending(serverGuid, entity.Id);
|
||||
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;
|
||||
}
|
||||
|
||||
_profilesByLocalId[localId] = 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;
|
||||
_profilesByLocalId[ownerLocalId] = profile;
|
||||
_runner.SetOwnerAnchor(ownerLocalId, entity.Position);
|
||||
_ownerSoundTableChanged(ownerLocalId, profile.CurrentSoundTableDid);
|
||||
}
|
||||
|
||||
public void OnDatStaticEntityRemoved(uint localId)
|
||||
{
|
||||
if (!_staticOwners.Remove(localId))
|
||||
return;
|
||||
_profilesByLocalId.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);
|
||||
_pendingByServerGuid.Remove(record.ServerGuid);
|
||||
_readyServerGuids.Remove(record.ServerGuid);
|
||||
if (record.LocalEntityId is not { } localId)
|
||||
return;
|
||||
_profilesByLocalId.Remove(localId);
|
||||
_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()
|
||||
{
|
||||
foreach (uint serverGuid in _readyServerGuids.ToArray())
|
||||
{
|
||||
if (_liveEntities.TryGetLocalEntityId(serverGuid, out uint localId))
|
||||
{
|
||||
_profilesByLocalId.Remove(localId);
|
||||
_runner.StopAllForEntity(localId);
|
||||
_ownerUnregistered(localId);
|
||||
}
|
||||
}
|
||||
_readyServerGuids.Clear();
|
||||
_pendingByServerGuid.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes every live root anchor after animation/movement.</summary>
|
||||
public void RefreshLiveOwnerAnchors()
|
||||
{
|
||||
foreach (uint serverGuid in _readyServerGuids.ToArray())
|
||||
{
|
||||
if (!TryGetReadyLocalId(serverGuid, out uint localId))
|
||||
continue;
|
||||
RefreshLiveAnchor(serverGuid, localId);
|
||||
TryReplayPending(serverGuid, localId);
|
||||
}
|
||||
}
|
||||
|
||||
public bool PlayDirect(uint ownerLocalId, uint scriptDid)
|
||||
{
|
||||
if (!CanStartOwner(ownerLocalId))
|
||||
return false;
|
||||
return _runner.PlayDirect(ownerLocalId, scriptDid);
|
||||
}
|
||||
|
||||
public bool PlayTyped(uint ownerLocalId, uint rawScriptType, float intensity)
|
||||
{
|
||||
if (!CanStartOwner(ownerLocalId)
|
||||
|| !_profilesByLocalId.TryGetValue(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)
|
||||
|| !_profilesByLocalId.TryGetValue(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.TryGetServerGuid(ownerLocalId, out uint serverGuid)
|
||||
&& _liveEntities.TryGetRecord(serverGuid, out record!))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
record = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetReadyLocalId(uint serverGuid, out uint localId)
|
||||
{
|
||||
if (_readyServerGuids.Contains(serverGuid)
|
||||
&& _liveEntities.TryGetLocalEntityId(serverGuid, out localId)
|
||||
&& _profilesByLocalId.ContainsKey(localId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
localId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void RefreshLiveAnchor(uint serverGuid, uint localId)
|
||||
{
|
||||
if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
&& record.WorldEntity is { } entity
|
||||
&& entity.Id == localId)
|
||||
{
|
||||
_runner.SetOwnerAnchor(localId, entity.Position);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (effect.Kind is PendingEffectKind.Direct)
|
||||
PlayDirect(localId, effect.ScriptDid);
|
||||
else
|
||||
PlayTyped(localId, effect.RawScriptType, effect.Intensity);
|
||||
}
|
||||
|
||||
private enum PendingEffectKind
|
||||
{
|
||||
Direct,
|
||||
Typed,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue