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

@ -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;
}