feat(vfx): port retail effect scheduling and delivery

This commit is contained in:
Erik 2026-07-14 09:25:44 +02:00
parent 363e046112
commit 96ddfdf175
28 changed files with 2473 additions and 691 deletions

View 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);
}
}

View file

@ -22,10 +22,12 @@ public sealed class EntityEffectProfile : ILiveEntityEffectProfile
{
SetupDefaultScriptDid = NormalizePhysicsScriptDid(setup.DefaultScript.DataId);
CurrentPhysicsScriptTableDid = NormalizeTableDid((uint)setup.DefaultScriptTable);
CurrentSoundTableDid = NormalizeSoundTableDid((uint)setup.DefaultSoundTable);
}
public uint? SetupDefaultScriptDid { get; }
public uint? CurrentPhysicsScriptTableDid { get; private set; }
public uint? CurrentSoundTableDid { get; private set; }
public uint RawDefaultScriptType { get; private set; }
public float DefaultScriptIntensity { get; private set; }
public bool HasNetworkDescription { get; private set; }
@ -60,6 +62,8 @@ public sealed class EntityEffectProfile : ILiveEntityEffectProfile
{
CurrentPhysicsScriptTableDid = NormalizeTableDid(
physics.PhysicsScriptTableId.GetValueOrDefault());
CurrentSoundTableDid = NormalizeSoundTableDid(
physics.SoundTableId.GetValueOrDefault());
RawDefaultScriptType = physics.DefaultScriptType.GetValueOrDefault();
DefaultScriptIntensity = physics.DefaultScriptIntensity.GetValueOrDefault();
HasNetworkDescription = true;
@ -70,4 +74,7 @@ public sealed class EntityEffectProfile : ILiveEntityEffectProfile
private static uint? NormalizeTableDid(uint did) =>
PhysicsScriptTableResolver.IsPhysicsScriptTableDid(did) ? did : null;
private static uint? NormalizeSoundTableDid(uint did) =>
(did & 0xFF000000u) == 0x20000000u ? did : null;
}

View file

@ -17,7 +17,8 @@ namespace AcDream.App.Rendering.Vfx;
/// </summary>
public sealed record ScriptActivationInfo(
uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms);
IReadOnlyList<Matrix4x4> PartTransforms,
EntityEffectProfile? EffectProfile = null);
/// <summary>
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
@ -27,8 +28,9 @@ public sealed record ScriptActivationInfo(
/// Stops the scripts and live emitters when the entity despawns.
///
/// <para>
/// Handles both server-spawned and dat-hydrated entities, always keyed by
/// canonical local <c>entity.Id</c>. The C.1.5a guard that early-returned for
/// Handles both server-spawned and dat-hydrated entities. Live owners use the
/// canonical local <c>entity.Id</c>. Dat-static IDs occupy disjoint, globally
/// unique ranges for interiors, scenery, and landblock stabs. The C.1.5a guard that early-returned for
/// <c>ServerGuid == 0</c> was relaxed in C.1.5b so EnvCell static objects
/// (which have no server guid because they come from the dat file, not
/// the network) also fire their DefaultScript.
@ -52,11 +54,12 @@ public sealed class EntityScriptActivator
private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink;
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
private readonly HashSet<uint> _legacyPendingOwners = new();
private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner;
private readonly Action<uint>? _unregisterDatStaticEffectOwner;
private readonly Dictionary<uint, WorldEntity> _activeStaticOwners = new();
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
/// (scriptId, entityId) instance table and schedules hooks at their
/// <c>StartTime</c> offsets.</param>
/// <param name="scriptRunner">Per-owner retail FIFO that schedules every
/// hook at its absolute script <c>StartTime</c>.</param>
/// <param name="particleSink">Already-shipped hook sink from C.1. The
/// activator pushes per-entity rotation + part transforms here, and
/// calls <see cref="ParticleHookSink.StopAllForEntity"/> to drop
@ -70,7 +73,9 @@ public sealed class EntityScriptActivator
public EntityScriptActivator(
PhysicsScriptRunner scriptRunner,
ParticleHookSink particleSink,
Func<WorldEntity, ScriptActivationInfo?> resolver)
Func<WorldEntity, ScriptActivationInfo?> resolver,
Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null,
Action<uint>? unregisterDatStaticEffectOwner = null)
{
ArgumentNullException.ThrowIfNull(scriptRunner);
ArgumentNullException.ThrowIfNull(particleSink);
@ -78,11 +83,13 @@ public sealed class EntityScriptActivator
_scriptRunner = scriptRunner;
_particleSink = particleSink;
_resolver = resolver;
_registerDatStaticEffectOwner = registerDatStaticEffectOwner;
_unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner;
}
/// <summary>
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
/// the script runner, keyed by canonical local <c>entity.Id</c>.
/// the script runner under its canonical runtime identity.
/// No-op if the entity has no DefaultScript (resolver returns null
/// or zero-script).
/// </summary>
@ -91,9 +98,17 @@ public sealed class EntityScriptActivator
ArgumentNullException.ThrowIfNull(entity);
uint key = entity.Id;
if (key == 0) return; // malformed entity
if (entity.ServerGuid == 0
&& _activeStaticOwners.TryGetValue(key, out WorldEntity? existing))
{
if (ReferenceEquals(existing, entity))
return;
throw new InvalidOperationException(
$"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique.");
}
var info = _resolver(entity);
if (info is null || info.ScriptId == 0) return;
if (info is null) return;
// Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin
// (in entity-local frame) transforms correctly to world space when the
@ -109,63 +124,39 @@ public sealed class EntityScriptActivator
// in a multi-part Setup collapses to the entity root.
_particleSink.SetEntityPartTransforms(key, info.PartTransforms);
_scriptRunner.Play(info.ScriptId, key, entity.Position);
if (entity.ServerGuid == 0)
_activeStaticOwners.Add(key, entity);
if (entity.ServerGuid == 0 && info.EffectProfile is { } profile)
_registerDatStaticEffectOwner?.Invoke(
key,
entity,
profile);
if (info.ScriptId != 0)
_scriptRunner.Play(info.ScriptId, key, entity.Position);
}
/// <summary>
/// Stop every script instance the runner is tracking for this key, and
/// kill every live emitter the sink has attributed to the canonical
/// local entity id. Idempotent for unknown keys.
/// kill every live emitter the sink has attributed to the runtime effect
/// owner. Idempotent for unknown entities.
/// </summary>
public void OnRemove(uint key)
public void OnRemove(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
uint key = entity.Id;
if (key == 0) return;
if (entity.ServerGuid == 0
&& (!_activeStaticOwners.TryGetValue(key, out WorldEntity? existing)
|| !ReferenceEquals(existing, entity)))
{
return;
}
_scriptRunner.StopAllForEntity(key);
_particleSink.StopAllForEntity(key, fadeOut: false);
if (entity.ServerGuid == 0 && _activeStaticOwners.Remove(key))
_unregisterDatStaticEffectOwner?.Invoke(key);
}
/// <summary>
/// Starts the temporary server-GUID owner used when F754 precedes the
/// target's CreateObject/materialization. Step 4 replaces this with the
/// retail mixed pending-packet FIFO. Tracking is required because no
/// LiveEntityRecord may exist yet for delete/session teardown.
/// </summary>
public bool PlayLegacyPending(
uint serverGuid,
uint scriptDid,
Vector3 anchorWorldPosition)
{
if (serverGuid == 0)
return false;
bool played = _scriptRunner.Play(scriptDid, serverGuid, anchorWorldPosition);
if (played)
_legacyPendingOwners.Add(serverGuid);
return played;
}
/// <summary>
/// Cleans the temporary server-GUID owner used when F754 arrives before a
/// live projection has a canonical local ID. Step 4 replaces this alias
/// with the retail mixed pending-packet FIFO; until then teardown must stop
/// the alias as well as the normal local owner so an early effect cannot
/// outlive its object incarnation.
/// </summary>
public void OnRemoveLegacyOwner(uint serverGuid, uint canonicalLocalId)
{
if (serverGuid == 0 || serverGuid == canonicalLocalId)
return;
if (!_legacyPendingOwners.Remove(serverGuid))
return;
OnRemove(serverGuid);
}
/// <summary>Stops every pre-Create F754 alias at session teardown.</summary>
public void ClearLegacyPendingOwners()
{
foreach (uint serverGuid in _legacyPendingOwners)
OnRemove(serverGuid);
_legacyPendingOwners.Clear();
}
public int LegacyPendingOwnerCount => _legacyPendingOwners.Count;
}