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

@ -9,6 +9,7 @@ using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering;
@ -28,6 +29,9 @@ public sealed class EquippedChildRenderController : IDisposable
private readonly Func<ParentEvent.Parsed, bool> _acceptParent;
private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
/// <summary>Raised after the attached projection is fully registered.</summary>
public event Action<uint>? EntityReady;
private readonly Dictionary<uint, AttachedChild> _attachedByChild = new();
public IEnumerable<uint> AttachedEntityIds
@ -283,6 +287,43 @@ public sealed class EquippedChildRenderController : IDisposable
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
$"location={parentLocation} placement={placement}");
Relations.MarkProjected(childGuid);
EntityReady?.Invoke(childGuid);
}
/// <summary>
/// Resolves retail's parent Setup part index to the currently attached
/// child local ID for DefaultScriptPartHook.
/// </summary>
public uint? FindChildLocalIdAtPart(uint parentLocalId, uint partIndex)
{
foreach (AttachedChild child in _attachedByChild.Values)
{
if (!_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent)
|| parent.Id != parentLocalId
|| !child.ParentSetup.HoldingLocations.TryGetValue(
child.ParentLocation,
out LocationType? holding)
|| holding.PartId != partIndex)
{
continue;
}
return child.Entity.Id;
}
return null;
}
/// <summary>Returns the live parent whose UpdateChild path owns this child.</summary>
public uint? FindParentLocalId(uint childLocalId)
{
foreach (AttachedChild child in _attachedByChild.Values)
{
if (child.Entity.Id != childLocalId)
continue;
if (_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent))
return parent.Id;
return null;
}
return null;
}
private void ResolveRelations(uint childGuid)

View file

@ -333,10 +333,13 @@ public sealed class GameWindow : IDisposable
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
// from the server and schedules the dat-defined hooks (particle spawns,
// sounds, light toggles) at their StartTime offsets.
// and typed PlayScriptType (0xF755) events through one effect owner, then
// fans every dat-defined hook to particles, audio, lights, translucency,
// and nested/default-script routing at its StartTime offset.
private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner;
private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader;
private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects;
private double _physicsScriptGameTime;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
// Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but
// never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime.
@ -1451,7 +1454,8 @@ public sealed class GameWindow : IDisposable
_physicsScriptLoader = new AcDream.Content.Vfx.RetailPhysicsScriptLoader(_dats);
_scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(
_physicsScriptLoader.LoadPhysicsScript,
_particleSink);
_hookRouter,
canAdvanceOwner: ownerId => _entityEffects?.CanAdvanceOwner(ownerId) ?? true);
// Phase G.2 lighting hooks: SetLightHook flips IsLit on
// owner-tagged lights so ignite-torch animations light up,
@ -2295,16 +2299,22 @@ public sealed class GameWindow : IDisposable
// _particleSink are initialised earlier in OnLoad (line ~1083); both
// are non-null here. The resolver lambda captures _dats and swallows
// dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale.
AcDream.App.Rendering.Vfx.EntityEffectController? entityEffects = null;
AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e)
{
try
{
var setup = capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
DatReaderWriter.DBObjs.Setup? setup =
capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(
e.SourceGfxObjOrSetupId);
if (setup is null) return null;
uint scriptId = setup.DefaultScript.DataId;
if (scriptId == 0) return null;
var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(scriptId, parts);
var profile = AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup);
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(
scriptId,
parts,
profile);
}
catch
{
@ -2312,7 +2322,17 @@ public sealed class GameWindow : IDisposable
}
}
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
_scriptRunner!, _particleSink!, ResolveActivation);
_scriptRunner!,
_particleSink!,
ResolveActivation,
(ownerId, entity, profile) =>
entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile),
ownerId =>
{
entityEffects?.OnDatStaticEntityRemoved(ownerId);
_lightingSink?.UnregisterOwner(ownerId);
_translucencyFades.ClearEntity(ownerId);
});
_entityScriptActivator = entityScriptActivator;
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
@ -2336,7 +2356,7 @@ public sealed class GameWindow : IDisposable
{
try
{
entityScriptActivator.OnRemove(entity.Id);
entityScriptActivator.OnRemove(entity);
}
finally
{
@ -2352,6 +2372,27 @@ public sealed class GameWindow : IDisposable
_liveEntities,
TryAcceptParentForRender);
var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver(
id => _dats!.Get<DatReaderWriter.DBObjs.PhysicsScriptTable>(id));
entityEffects = new AcDream.App.Rendering.Vfx.EntityEffectController(
_liveEntities,
_scriptRunner!,
tableResolver,
(parentLocalId, partIndex) =>
_equippedChildRenderer.FindChildLocalIdAtPart(parentLocalId, partIndex),
childLocalId => _equippedChildRenderer.FindParentLocalId(childLocalId),
ownerId => _entitySoundTables?.Remove(ownerId),
(ownerId, soundTableDid) =>
{
_entitySoundTables?.Remove(ownerId);
if (soundTableDid is { } did)
_entitySoundTables?.Set(ownerId, did);
});
_entityEffects = entityEffects;
_equippedChildRenderer.EntityReady += guid =>
entityEffects.OnLiveEntityReady(guid);
_hookRouter.Register(entityEffects);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
_classificationCache, _translucencyFades);
@ -2575,9 +2616,9 @@ public sealed class GameWindow : IDisposable
}
finally
{
// F754 can precede CreateObject, so some temporary owners have no
// LiveEntityRecord for the normal logical teardown to visit.
_entityScriptActivator?.ClearLegacyPendingOwners();
// F754/F755 can precede CreateObject, so the mixed pending FIFO
// may contain owners with no LiveEntityRecord to tear down.
_entityEffects?.ClearNetworkState();
}
}
@ -2617,6 +2658,8 @@ public sealed class GameWindow : IDisposable
// gestures, AND — per Agent #5 research — lightning
// flashes during stormy weather.
_liveSession.PlayPhysicsScriptReceived += OnPlayScriptReceived;
_liveSession.PlayPhysicsScriptTypeReceived += message =>
_entityEffects?.HandleTyped(message);
// Phase 5d — AdminEnvirons (0xEA60): fog presets + sound
// cues. Fog types (0x00..0x06) set WeatherSystem.Override;
@ -3886,16 +3929,6 @@ public sealed class GameWindow : IDisposable
Sequencer = sequencer,
};
// Phase E.2: register entity's SoundTable so SoundTableHook can
// resolve creature-specific sounds (footsteps, attack vocalizations,
// damage grunts, etc). Server-sent SoundTable override would take
// precedence here when the wire layer delivers it.
if (_entitySoundTables is not null)
{
uint soundTableId = (uint)setup.DefaultSoundTable;
if (soundTableId != 0)
_entitySoundTables.Set(entity.Id, soundTableId);
}
}
else if (!retainedAnimationRuntime && _animLoader is not null)
{
@ -3964,6 +3997,11 @@ public sealed class GameWindow : IDisposable
}
}
// Renderer, script owner, optional animation owner, collision body,
// and effect profile are now all installed. Only at this boundary may
// the mixed pre-Create F754/F755 FIFO replay against the local ID.
_entityEffects?.OnLiveEntityReady(spawn.Guid);
// Dump a summary periodically so we can see drop breakdowns without
// waiting for a graceful shutdown.
if (dumpLiveSpawns && _liveSpawnReceived % 20 == 0)
@ -4007,12 +4045,11 @@ public sealed class GameWindow : IDisposable
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
{
// A Delete can arrive before CreateObject, in which case no instance
// timestamp owner exists and the tracked F754 alias must be cleaned
// directly. For a known record, cleanup belongs after the runtime's
// generation gate: a stale Delete must not cancel the current
// incarnation's effect.
// timestamp owner exists and its mixed pending effect FIFO must be
// discarded directly. For a known record, cleanup belongs after the
// generation gate: a stale Delete must not cancel the current owner.
if (_liveEntities?.TryGetRecord(delete.Guid, out _) != true)
_entityScriptActivator?.OnRemoveLegacyOwner(delete.Guid, 0u);
_entityEffects?.ForgetUnknownOwner(delete.Guid);
bool removed = _liveEntities!.UnregisterLiveEntity(
delete,
isLocalPlayer: delete.Guid == _playerServerGuid,
@ -4379,6 +4416,7 @@ public sealed class GameWindow : IDisposable
&& effectProfile is AcDream.App.Rendering.Vfx.EntityEffectProfile liveProfile)
{
liveProfile.ApplyNetworkDescription(refresh.Description);
_entityEffects?.OnLiveEntityDescriptionChanged(refresh.Appearance.Guid);
}
OnLiveAppearanceUpdated(refresh.Appearance);
@ -4785,15 +4823,7 @@ public sealed class GameWindow : IDisposable
private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record)
{
// AD-32 transitional F754 ownership: before Step 4's mixed pending
// packet FIFO, a direct script received before materialization is
// temporarily keyed by server GUID. It is still part of this logical
// incarnation and must be stopped even when no WorldEntity was ever
// materialized. Normal Setup/F754 owners use the local ID and are
// stopped by the resource lifecycle after this callback.
_entityScriptActivator?.OnRemoveLegacyOwner(
record.ServerGuid,
record.LocalEntityId ?? 0u);
_entityEffects?.OnLiveEntityUnregistered(record);
if (record.WorldEntity is not { } existingEntity)
return;
@ -6565,13 +6595,8 @@ public sealed class GameWindow : IDisposable
}
/// <summary>
/// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the
/// <c>(guid, scriptId)</c> pair into <see cref="_scriptRunner"/>
/// Known server GUIDs are translated to the canonical local entity ID so
/// logical teardown stops both Setup and network-triggered scripts through
/// one owner key. Their current entity position is the anchor. Unknown
/// owners retain the legacy camera anchor until Step 4's pending FIFO can
/// replay them after materialization.
/// Server-sent direct PhysicsScript (F754). EntityEffectController owns
/// GUID translation and pre-materialization queueing.
///
/// <para>
/// F754 remains a direct PhysicsScript DID. It is never resolved through a
@ -6580,27 +6605,7 @@ public sealed class GameWindow : IDisposable
/// </summary>
private void OnPlayScriptReceived(AcDream.Core.Net.Messages.PlayPhysicsScript message)
{
if (_scriptRunner is null) return;
var camWorldPos = System.Numerics.Vector3.Zero;
if (_cameraController is not null)
{
System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var iv);
camWorldPos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
}
System.Numerics.Vector3 anchor = camWorldPos;
if (_liveEntities?.TryGetWorldEntity(message.Guid, out var entity) == true)
{
anchor = entity.Position;
_scriptRunner.Play(message.ScriptDid, entity.Id, anchor);
return;
}
_entityScriptActivator?.PlayLegacyPending(
message.Guid,
message.ScriptDid,
anchor);
_entityEffects?.HandleDirect(message);
}
private void UpdateSkyPes(
@ -6628,6 +6633,7 @@ public sealed class GameWindow : IDisposable
continue;
uint skyEntityId = SkyPesEntityId(key);
_entityEffects?.RegisterSyntheticOwner(skyEntityId);
var renderPass = obj.IsPostScene
? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene
: AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene;
@ -6652,6 +6658,7 @@ public sealed class GameWindow : IDisposable
else
{
_missingSkyPes.Add(key);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.ClearEntityRenderPass(skyEntityId);
}
}
@ -6663,7 +6670,8 @@ public sealed class GameWindow : IDisposable
continue;
uint skyEntityId = SkyPesEntityId(key);
_scriptRunner.Stop(key.PesObjectId, skyEntityId);
_scriptRunner.StopAllForEntity(skyEntityId);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.StopAllForEntity(skyEntityId, fadeOut: true);
_activeSkyPes.Remove(key);
}
@ -6687,8 +6695,8 @@ public sealed class GameWindow : IDisposable
return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu);
}
private static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity)
=> entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id;
internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) =>
entity.Id;
// #131 [outstage-pt] probe state (throwaway — strip when #131 closes).
private string? _lastOutStagePtSig;
@ -7221,6 +7229,10 @@ public sealed class GameWindow : IDisposable
}
}
if (localIndex > 0xFFu)
throw new InvalidDataException(
$"Landblock 0x{lb.LandblockId:X8} exceeds the 256-entry procedural scenery id namespace.");
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = sceneryIdBase + localIndex++,
@ -7292,7 +7304,6 @@ public sealed class GameWindow : IDisposable
// why this is safe (nothing decodes X/Y back out of an entity id).
uint interiorLbX = (landblockId >> 24) & 0xFFu;
uint interiorLbY = (landblockId >> 16) & 0xFFu;
uint interiorIdBase = AcDream.Core.World.InteriorEntityIdAllocator.Base(interiorLbX, interiorLbY);
uint localCounter = 0;
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
@ -7481,20 +7492,12 @@ public sealed class GameWindow : IDisposable
var worldPos = stab.Frame.Origin + lbOffset;
var worldRot = stab.Frame.Orientation;
// #190: never silently wrap past the counter budget — that's exactly how
// this bug hid the first time (see the InteriorEntityIdAllocator doc
// comment). Log once per landblock the moment it happens; the id below
// still aliases into the next Y-slot when this fires (4096 is generous
// but not infinite), but at least it's visible in launch.log instead of
// silently breaking entity.Id-keyed systems (scripts, particles, shadows).
if (localCounter == AcDream.Core.World.InteriorEntityIdAllocator.MaxCounter + 1)
Console.WriteLine(
$"[id-overflow] landblock 0x{landblockId:X8} exceeded {AcDream.Core.World.InteriorEntityIdAllocator.MaxCounter + 1} " +
"interior entities — ids are now aliasing into the next landblock's reserved range (#190 class)");
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = interiorIdBase + localCounter++,
Id = AcDream.Core.World.InteriorEntityIdAllocator.Allocate(
interiorLbX,
interiorLbY,
ref localCounter),
SourceGfxObjOrSetupId = stab.Id,
Position = worldPos,
Rotation = worldRot,
@ -7927,11 +7930,12 @@ public sealed class GameWindow : IDisposable
// over time, and the stacked ties destabilize the 128-cap pool
// sort). Clear the owner's previous registration first: the
// re-apply becomes idempotent, and a first apply is a no-op.
_lightingSink.UnregisterOwner(entity.Id);
_translucencyFades.ClearEntity(entity.Id); // #188
uint effectOwnerId = entity.Id;
_lightingSink.UnregisterOwner(effectOwnerId);
_translucencyFades.ClearEntity(effectOwnerId); // #188
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
datSetup,
ownerId: entity.Id,
ownerId: effectOwnerId,
entityPosition: entity.Position,
entityRotation: entity.Rotation,
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
@ -8212,6 +8216,13 @@ public sealed class GameWindow : IDisposable
{
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
// Retail ScriptManager::AddScriptInternal (0x0051B310) stamps
// Timer::cur_time at the packet/default-script call site. Publish this
// update frame's clock before streaming and network dispatch, then
// reuse the same timestamp for animation hooks and the later drain.
_physicsScriptGameTime += Math.Max(0.0, dt);
_scriptRunner?.PublishTime(_physicsScriptGameTime);
// [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick;
// flush the PREVIOUS OnUpdate's accumulated apply cost here (a clean per-frame
// boundary, independent of where in OnUpdate the applies landed) and reset.
@ -9090,6 +9101,12 @@ public sealed class GameWindow : IDisposable
// Phase 6.4: advance per-entity animation playback before drawing
// so the renderer always sees the up-to-date per-part transforms.
// PhysicsScript timed hooks and animation hooks read the same current
// retail update-frame clock published before network dispatch. Drain
// the script pass later after final root/part poses are composed.
// Re-publish without advancing so an extra render between updates still
// retains retail's animation-hook versus object-hook phase barrier.
_scriptRunner?.PublishTime(_physicsScriptGameTime);
if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds);
_equippedChildRenderer?.Tick();
@ -9259,7 +9276,8 @@ public sealed class GameWindow : IDisposable
// debug-only and disabled for normal retail rendering.
if (_options.EnableSkyPesDebug)
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
_scriptRunner?.Tick((float)deltaSeconds);
_entityEffects?.RefreshLiveOwnerAnchors();
_scriptRunner?.Tick(_physicsScriptGameTime);
_particleSystem?.Tick((float)deltaSeconds);
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
@ -14001,7 +14019,7 @@ public sealed class GameWindow : IDisposable
}
finally
{
_entityScriptActivator?.ClearLegacyPendingOwners();
_entityEffects?.ClearNetworkState();
}
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose();

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

View file

@ -365,7 +365,7 @@ public sealed class GpuWorldState
foreach (var entity in lb.Entities)
{
if (entity.ServerGuid == 0)
_entityScriptActivator.OnRemove(entity.Id);
_entityScriptActivator.OnRemove(entity);
}
}
}
@ -549,7 +549,7 @@ public sealed class GpuWorldState
foreach (var entity in lb.Entities)
{
if (entity.ServerGuid == 0)
_entityScriptActivator.OnRemove(entity.Id);
_entityScriptActivator.OnRemove(entity);
}
}

View file

@ -604,15 +604,18 @@ public sealed class LiveEntityRuntime
_inbound.IsFreshTeleportStart(localPlayerGuid, teleportSequence);
/// <summary>
/// Retail runs root movement/animation only while the object has world-cell
/// membership. A pickup or parent transition keeps script/effect owners but
/// clears the cell and withdraws the root projection until a fresh Position
/// re-enters it.
/// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) runs root
/// movement/animation only while the object has visible world-cell
/// membership and is not Frozen. A pickup or parent transition keeps
/// script/effect owners but clears the cell and withdraws the root
/// projection until a fresh Position re-enters it.
/// </summary>
public bool ShouldAdvanceRootRuntime(uint serverGuid) =>
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.IsSpatiallyProjected
&& record.FullCellId != 0;
&& record.IsSpatiallyVisible
&& record.FullCellId != 0
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
/// <summary>
/// Claims the one logical top-level spawn notification for this object

View file

@ -82,9 +82,13 @@ public sealed class RetailPhysicsScriptLoader
uint count = reader.ReadUInt32();
for (uint i = 0; i < count; i++)
{
double startTime = reader.ReadDouble();
if (!double.IsFinite(startTime))
throw new InvalidDataException(
$"PhysicsScript 0x{script.Id:X8} hook {i} has non-finite StartTime {startTime}.");
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = reader.ReadDouble(),
StartTime = startTime,
Hook = RetailAnimationHookReader.Read(reader),
});
}

View file

@ -133,8 +133,8 @@ public sealed class ParticleHookSink : IAnimationHookSink
_system.StopEmitter(handleToStop, fadeOut: true);
break;
// DefaultScript / CallPES are routed by the entity-effect owner in
// Step 4. ParticleHookSink intentionally owns particles only.
// DefaultScript / CallPES are routed by EntityEffectController.
// ParticleHookSink intentionally owns particles only.
}
}

View file

@ -1,273 +1,400 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
// Local (AcDream.Core.Vfx) has its own stub `PhysicsScript` type in
// VfxModel.cs; alias the dat-reader type to avoid name collision.
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Vfx;
/// <summary>
/// Retail-verbatim port of the AC <c>PhysicsScript</c> runtime —
/// a time-ordered list of <see cref="AnimationHook"/>s scheduled by
/// <see cref="PhysicsScriptData.StartTime"/> (seconds from script
/// start). Every visible effect the server triggers via the
/// <c>PlayScript</c> opcode (0xF754) flows through this runner:
/// spell casts, emote gestures, combat flinches, AND — per the
/// 2026-04-23 lightning research — weather lightning flashes.
///
/// <para>
/// Decompile provenance (see
/// <c>docs/research/2026-04-23-physicsscript.md</c> and
/// <c>docs/research/2026-04-23-lightning-real.md</c>):
/// <list type="bullet">
/// <item><description><c>FUN_0051bed0</c> — <c>play_script(scriptId)</c>
/// public API: resolves the dat id, allocates a script node, inserts
/// into the owner <c>PhysicsObj</c>'s linked list at <c>+0x30</c>.
/// </description></item>
/// <item><description><c>FUN_0051be40</c> — <c>ScriptManager::Start</c>:
/// allocates the <c>{startTime, script*, next}</c> 16-byte node.
/// </description></item>
/// <item><description><c>FUN_0051bf20</c> — advances one hook,
/// schedules the next fire time based on the next hook's
/// <c>StartTime</c>.
/// </description></item>
/// <item><description><c>FUN_0051bfb0</c> — per-frame tick: while
/// <c>head.NextHookAbsTime &lt;= globalClock</c>, fire hooks via
/// vtable dispatch on the owner <c>PhysicsObj</c>.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// <b>Design choices vs retail:</b>
/// <list type="bullet">
/// <item><description>Flat list, not a linked list — iteration is
/// simpler and N is small (&lt; 100 active scripts in practice).
/// </description></item>
/// <item><description>Scripts are keyed by <c>(scriptId, entityId)</c>
/// — same pair re-played replaces the old instance so we don't
/// stack duplicates when the server retriggers.
/// </description></item>
/// <item><description>The anchor world position is cached at spawn
/// time. For long-running scripts on moving entities, the caller
/// can <see cref="Play"/> again with a fresh position each
/// frame — idempotent.
/// </description></item>
/// </list>
/// </para>
/// Retail PhysicsScript scheduler: one serial FIFO per owning physics object.
/// Duplicate plays append, different owners progress independently, and a
/// catch-up tick drains every due hook in order.
/// </summary>
/// <remarks>
/// Port of <c>ScriptManager::AddScriptInternal</c> (<c>0x0051B310</c>),
/// <c>ScriptManager::NextHook</c> (<c>0x0051B3F0</c>), and
/// <c>ScriptManager::UpdateScripts</c> (<c>0x0051B480</c>).
/// Delayed nested calls follow <c>CPhysicsObj::CallPES</c>
/// (<c>0x00511AF0</c>).
/// </remarks>
public sealed class PhysicsScriptRunner
{
public const float ImmediateCallPesThresholdSeconds = 0.0002f;
private readonly Func<uint, DatPhysicsScript?> _resolver;
private readonly IAnimationHookSink _sink;
private readonly Func<double> _clock;
private readonly Func<double> _randomUnit;
private readonly Func<uint, bool> _canAdvanceOwner;
private readonly Dictionary<uint, DatPhysicsScript?> _scriptCache = new();
private readonly Dictionary<uint, OwnerQueue> _owners = new();
private readonly Dictionary<uint, Vector3> _ownerAnchors = new();
private readonly List<DelayedCallPes> _delayedCalls = new();
private double _gameTime;
private long _tickGeneration;
private bool _frameTimePublished;
private bool _processingTimedHooks;
private DispatchContext? _dispatchContext;
// One active node per (scriptId, entityId) pair. Replaying replaces.
private readonly List<ActiveScript> _active = new();
private double _now; // absolute runtime in seconds
/// <summary>
/// When <c>ACDREAM_DUMP_PLAYSCRIPT=1</c> is set in the environment,
/// every <see cref="Play"/> call and every hook fire prints a line
/// prefixed with <c>[pes]</c>. Use this to confirm the server is
/// delivering PlayScript opcodes (lightning, spell casts, emotes)
/// and which script IDs those are. Off by default.
/// </summary>
public bool DiagEnabled { get; set; } =
System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
/// <summary>
/// Preferred ctor — resolver delegate lets this class stay
/// DAT-reader-free for testing. Production passes the centralized retail
/// compatibility loader so CreateBlockingParticle cannot misalign hooks.
/// </summary>
public PhysicsScriptRunner(Func<uint, DatPhysicsScript?> resolver, IAnimationHookSink sink)
public PhysicsScriptRunner(
Func<uint, DatPhysicsScript?> resolver,
IAnimationHookSink sink,
Func<double>? clock = null,
Func<double>? randomUnit = null,
Func<uint, bool>? canAdvanceOwner = null)
{
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
_clock = clock ?? (() => _gameTime);
_randomUnit = randomUnit ?? Random.Shared.NextDouble;
_canAdvanceOwner = canAdvanceOwner ?? (_ => true);
}
/// <summary>Number of scripts currently active (for telemetry).</summary>
public int ActiveScriptCount => _active.Count;
/// <summary>Queued PhysicsScripts across every owner, including tails.</summary>
public int ActiveScriptCount => _owners.Values.Sum(owner => owner.Scripts.Count);
/// <summary>
/// Start (or restart) a PhysicsScript on the given entity.
/// Retail-equivalent of <c>PhysicsObj::play_script</c>. Returns
/// <c>true</c> if the script was found and queued, <c>false</c>
/// if the dat lookup failed. Replaying the same
/// <c>(scriptId, entityId)</c> pair replaces the prior instance
/// instead of stacking.
/// </summary>
public bool Play(uint scriptId, uint entityId, Vector3 anchorWorldPos)
public int ActiveOwnerCount => _owners.Count;
public int ScheduledCallPesCount => _delayedCalls.Count;
public bool DiagEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
/// <summary>Always-on structural/load diagnostics; production supplies a logger.</summary>
public Action<string>? DiagnosticSink { get; set; }
/// <summary>Updates the root anchor used when this owner's hooks dispatch.</summary>
public void SetOwnerAnchor(uint ownerLocalId, Vector3 worldPosition)
{
if (scriptId == 0) return false;
if (ownerLocalId != 0)
_ownerAnchors[ownerLocalId] = worldPosition;
}
var script = ResolveScript(scriptId);
/// <summary>Enqueues a direct F754/Setup PhysicsScript DID.</summary>
public bool PlayDirect(uint ownerLocalId, uint scriptDid)
{
if (ownerLocalId == 0 || scriptDid == 0)
return false;
DatPhysicsScript? script = ResolveScript(scriptDid);
if (script is null || script.ScriptData.Count == 0)
{
ReportDiagnostic(
$"PhysicsScript 0x{scriptDid:X8} for owner 0x{ownerLocalId:X8} is missing or empty.");
if (DiagEnabled)
Console.WriteLine($"[pes] Play: script 0x{scriptId:X8} not found / empty");
Console.WriteLine($"[pes] missing/empty script=0x{scriptDid:X8} owner=0x{ownerLocalId:X8}");
return false;
}
if (script.ScriptData.Any(entry => !double.IsFinite(entry.StartTime)))
{
ReportDiagnostic(
$"PhysicsScript 0x{scriptDid:X8} for owner 0x{ownerLocalId:X8} has a non-finite hook time.");
return false;
}
// Dedupe: if this (scriptId, entityId) already has an active
// instance, replace it — retail's ScriptManager doesn't
// double-schedule the same script on the same object in the
// common path.
for (int i = _active.Count - 1; i >= 0; i--)
if (!_owners.TryGetValue(ownerLocalId, out OwnerQueue? owner))
{
if (_active[i].ScriptId == scriptId && _active[i].EntityId == entityId)
_active.RemoveAt(i);
owner = new OwnerQueue();
_owners.Add(ownerLocalId, owner);
}
AddActiveScript(script, scriptId, entityId, anchorWorldPos, delaySeconds: 0);
double start = owner.Scripts.Count == 0
? _clock()
: owner.Scripts[^1].StartTime + owner.Scripts[^1].Duration;
if (!double.IsFinite(start))
{
ReportDiagnostic(
$"PhysicsScript 0x{scriptDid:X8} for owner 0x{ownerLocalId:X8} produced a non-finite start time.");
return false;
}
HashSet<uint>? immediateAncestors = null;
if (_dispatchContext is { } dispatch
&& dispatch.OwnerLocalId == ownerLocalId
&& start <= dispatch.Script.StartTime)
{
immediateAncestors = dispatch.Script.ImmediateAncestors is null
? new HashSet<uint> { dispatch.Script.ScriptDid }
: new HashSet<uint>(dispatch.Script.ImmediateAncestors) { dispatch.Script.ScriptDid };
if (immediateAncestors.Contains(scriptDid))
{
ReportDiagnostic(
$"Rejected zero-time recursive PhysicsScript 0x{scriptDid:X8} for owner " +
$"0x{ownerLocalId:X8}; DAT CallPES chain would never yield.");
return false;
}
}
owner.Scripts.Add(new ScheduledScript(
scriptDid,
script,
start,
immediateAncestors,
EarliestScriptTickGeneration(ownerLocalId)));
if (DiagEnabled)
{
Console.WriteLine(
$"[pes] Play: scriptId=0x{scriptId:X8} entityId=0x{entityId:X8} " +
$"anchor=({anchorWorldPos.X:F2},{anchorWorldPos.Y:F2},{anchorWorldPos.Z:F2}) " +
$"hooks={script.ScriptData.Count}");
}
Console.WriteLine($"[pes] enqueue script=0x{scriptDid:X8} owner=0x{ownerLocalId:X8} start={start:R} depth={owner.Scripts.Count}");
return true;
}
private void AddActiveScript(
DatPhysicsScript script,
uint scriptId,
uint entityId,
Vector3 anchorWorldPos,
float delaySeconds)
/// <summary>
/// Compatibility seam for existing static/sky callers. The scheduler is
/// still keyed by canonical owner ID; the position merely refreshes its
/// hook anchor before enqueue.
/// </summary>
public bool Play(uint scriptId, uint entityId, Vector3 anchorWorldPos)
{
_active.Add(new ActiveScript
{
Script = script,
ScriptId = scriptId,
EntityId = entityId,
AnchorWorld = anchorWorldPos,
StartTimeAbs = _now + Math.Max(0f, delaySeconds),
NextHookIndex = 0,
});
SetOwnerAnchor(entityId, anchorWorldPos);
return PlayDirect(entityId, scriptId);
}
/// <summary>
/// Advance every active script by <paramref name="dtSeconds"/>.
/// Fires each hook whose <see cref="PhysicsScriptData.StartTime"/>
/// (measured from the script's <see cref="Play"/> moment) has been
/// reached. Removes scripts that have finished all their hooks.
/// Implements retail CallPES. Near-zero pauses append immediately; a
/// positive pause samples a deterministic injectable uniform delay in
/// <c>[0, maximumPause]</c> and retains the call until due.
/// </summary>
public void Tick(float dtSeconds)
public bool ScheduleCallPes(uint ownerLocalId, uint scriptDid, float maximumPause)
{
if (dtSeconds < 0) dtSeconds = 0;
_now += dtSeconds;
if (ownerLocalId == 0 || scriptDid == 0 || !float.IsFinite(maximumPause))
return false;
if (maximumPause < ImmediateCallPesThresholdSeconds)
return PlayDirect(ownerLocalId, scriptDid);
// Back-to-front so RemoveAt() is cheap and safe mid-iteration.
for (int i = _active.Count - 1; i >= 0; i--)
double sample = _randomUnit();
if (!double.IsFinite(sample))
return false;
sample = Math.Clamp(sample, 0.0, 1.0);
double due = _clock() + sample * maximumPause;
_delayedCalls.Add(new DelayedCallPes(
ownerLocalId,
scriptDid,
due,
EarliestTickGenerationForNewTimedHook()));
return true;
}
/// <summary>
/// Publishes the current frame clock before animation hooks are delivered,
/// without running retail's timed-hook/script update pass.
/// </summary>
public void PublishTime(double gameTime)
{
ValidateMonotonicTime(gameTime);
_gameTime = gameTime;
_frameTimePublished = true;
}
/// <summary>Advances to an absolute game-clock timestamp.</summary>
public void Tick(double gameTime)
{
ValidateMonotonicTime(gameTime);
_gameTime = gameTime;
_frameTimePublished = false;
_tickGeneration++;
DrainDueCallPes(gameTime);
// Snapshot owner IDs. Hooks may append to their current owner, create a
// different owner, or delete an owner without invalidating iteration.
uint[] ownerIds = _owners.Keys.ToArray();
for (int ownerIndex = 0; ownerIndex < ownerIds.Length; ownerIndex++)
{
var a = _active[i];
double elapsed = _now - a.StartTimeAbs;
uint ownerId = ownerIds[ownerIndex];
if (!_owners.TryGetValue(ownerId, out OwnerQueue? owner))
continue;
if (!_canAdvanceOwner(ownerId))
continue;
DrainOwner(ownerId, owner, gameTime);
}
}
// Fire every hook whose scheduled time has arrived.
while (a.NextHookIndex < a.Script.ScriptData.Count
&& a.Script.ScriptData[a.NextHookIndex].StartTime <= elapsed)
/// <summary>Compatibility delta-time overload used by existing callers.</summary>
public void Tick(float deltaSeconds)
{
if (deltaSeconds < 0f)
deltaSeconds = 0f;
Tick(_gameTime + deltaSeconds);
}
public void StopAllForEntity(uint ownerLocalId)
{
_owners.Remove(ownerLocalId);
_ownerAnchors.Remove(ownerLocalId);
_delayedCalls.RemoveAll(call => call.OwnerLocalId == ownerLocalId);
}
public void Clear()
{
_owners.Clear();
_ownerAnchors.Clear();
_delayedCalls.Clear();
}
public void RegisterScriptForTest(uint id, DatPhysicsScript script)
{
ArgumentNullException.ThrowIfNull(script);
_scriptCache[id] = script;
}
private void DrainDueCallPes(double gameTime)
{
// CPhysicsObj inserts timed object hooks at the head. Traversing in
// reverse insertion order reproduces newest-first observation.
_processingTimedHooks = true;
try
{
for (int i = _delayedCalls.Count - 1; i >= 0; i--)
{
var entry = a.Script.ScriptData[a.NextHookIndex];
DispatchHook(a, entry.Hook);
a.NextHookIndex++;
DelayedCallPes call = _delayedCalls[i];
if (call.DueTime > gameTime
|| call.EarliestTickGeneration > _tickGeneration
|| !_canAdvanceOwner(call.OwnerLocalId))
continue;
_delayedCalls.RemoveAt(i);
PlayDirect(call.OwnerLocalId, call.ScriptDid);
}
}
finally
{
_processingTimedHooks = false;
}
}
private void DrainOwner(uint ownerId, OwnerQueue owner, double gameTime)
{
while (owner.Scripts.Count > 0)
{
ScheduledScript current = owner.Scripts[0];
if (current.EarliestTickGeneration > _tickGeneration)
return;
while (current.NextHookIndex < current.Script.ScriptData.Count)
{
PhysicsScriptData entry = current.Script.ScriptData[current.NextHookIndex];
if (current.StartTime + entry.StartTime > gameTime)
break;
current.NextHookIndex++;
DispatchHook(ownerId, current, entry.Hook);
// A hook callback may delete the owner. Never continue through
// the detached queue object after logical teardown.
if (!_owners.TryGetValue(ownerId, out OwnerQueue? currentOwner)
|| !ReferenceEquals(currentOwner, owner))
return;
}
if (a.NextHookIndex >= a.Script.ScriptData.Count)
_active.RemoveAt(i);
else
_active[i] = a;
if (current.NextHookIndex < current.Script.ScriptData.Count)
return;
owner.Scripts.RemoveAt(0);
}
_owners.Remove(ownerId);
}
/// <summary>
/// Stop an active script instance by
/// <c>(scriptId, entityId)</c>. Used for cleanup when an entity
/// despawns. Not necessary to call on normal script completion —
/// scripts self-remove via <see cref="Tick"/>.
/// </summary>
public void Stop(uint scriptId, uint entityId)
{
for (int i = _active.Count - 1; i >= 0; i--)
{
if (_active[i].ScriptId == scriptId && _active[i].EntityId == entityId)
_active.RemoveAt(i);
}
}
/// <summary>Stop all scripts on an entity (e.g. on despawn).</summary>
public void StopAllForEntity(uint entityId)
{
for (int i = _active.Count - 1; i >= 0; i--)
{
if (_active[i].EntityId == entityId)
_active.RemoveAt(i);
}
}
private void DispatchHook(ActiveScript a, AnimationHook hook)
private void DispatchHook(uint ownerId, ScheduledScript script, AnimationHook hook)
{
if (DiagEnabled)
Console.WriteLine($"[pes] fire script=0x{script.ScriptDid:X8} owner=0x{ownerId:X8} hook={hook.HookType}");
_ownerAnchors.TryGetValue(ownerId, out Vector3 anchor);
DispatchContext? previous = _dispatchContext;
_dispatchContext = new DispatchContext(ownerId, script);
try
{
Console.WriteLine(
$"[pes] fire: scriptId=0x{a.ScriptId:X8} entityId=0x{a.EntityId:X8} " +
$"hook={hook.HookType}");
_sink.OnHook(ownerId, anchor, hook);
}
// Handle the nested-script hook inline — it needs our runner.
// Everything else delegates to the sink (ParticleHookSink
// handles CreateParticle, DestroyParticle, StopParticle,
// CreateBlockingParticle, etc).
if (hook is CallPESHook call)
finally
{
// CallPESHook.PES = sub-script id; Pause = delay before the
// sub-script starts. Retail links it into the active script
// list with StartTime = now + Pause; our flat list preserves
// that timing without replacing the currently running script.
var subScript = ResolveScript(call.PES);
if (subScript is null || subScript.ScriptData.Count == 0)
{
if (DiagEnabled)
Console.WriteLine($"[pes] CallPES: script 0x{call.PES:X8} not found / empty");
return;
}
AddActiveScript(subScript, call.PES, a.EntityId, a.AnchorWorld, call.Pause);
return;
_dispatchContext = previous;
}
_sink.OnHook(a.EntityId, a.AnchorWorld, hook);
}
private DatPhysicsScript? ResolveScript(uint id)
{
if (_scriptCache.TryGetValue(id, out var cached)) return cached;
var script = _resolver(id);
if (_scriptCache.TryGetValue(id, out DatPhysicsScript? cached))
return cached;
DatPhysicsScript? script;
try
{
script = _resolver(id);
}
catch (Exception error)
{
ReportDiagnostic($"Failed to load PhysicsScript 0x{id:X8}: {error.Message}");
if (DiagEnabled)
Console.WriteLine($"[pes] load failed script=0x{id:X8}: {error.Message}");
script = null;
}
_scriptCache[id] = script;
return script;
}
/// <summary>
/// Test-only seam: pre-seed the resolver cache with a hand-built
/// script so unit tests can exercise the scheduler without loading
/// dats. Production code never calls this (name carries the warning).
/// </summary>
public void RegisterScriptForTest(uint id, DatPhysicsScript script)
=> _scriptCache[id] = script;
private struct ActiveScript
private long EarliestTickGenerationForNewTimedHook()
{
public DatPhysicsScript Script;
public uint ScriptId;
public uint EntityId;
public Vector3 AnchorWorld;
public double StartTimeAbs;
public int NextHookIndex;
// A positive-pause CallPES adds a timed object hook. Retail does not
// revisit the active process_hooks traversal for a hook inserted by
// the animation phase, even when its sampled delay is zero. Near-zero
// CallPES bypasses this list and may reach the upcoming script pass.
return _frameTimePublished
? _tickGeneration + 2
: _tickGeneration + 1;
}
private long EarliestScriptTickGeneration(uint ownerLocalId)
{
if (_processingTimedHooks
|| _dispatchContext is { OwnerLocalId: var dispatchOwner }
&& dispatchOwner == ownerLocalId)
return _tickGeneration;
return _tickGeneration + 1;
}
private void ValidateMonotonicTime(double gameTime)
{
if (!double.IsFinite(gameTime))
throw new ArgumentOutOfRangeException(nameof(gameTime));
if (gameTime < _gameTime)
throw new ArgumentOutOfRangeException(nameof(gameTime), "PhysicsScript time must be monotonic.");
}
private void ReportDiagnostic(string message) => DiagnosticSink?.Invoke(message);
private sealed class OwnerQueue
{
public List<ScheduledScript> Scripts { get; } = new();
}
private sealed class ScheduledScript
{
public ScheduledScript(
uint scriptDid,
DatPhysicsScript script,
double startTime,
HashSet<uint>? immediateAncestors,
long earliestTickGeneration)
{
ScriptDid = scriptDid;
Script = script;
StartTime = startTime;
Duration = script.ScriptData[^1].StartTime;
ImmediateAncestors = immediateAncestors;
EarliestTickGeneration = earliestTickGeneration;
}
public uint ScriptDid { get; }
public DatPhysicsScript Script { get; }
public double StartTime { get; }
public double Duration { get; }
public HashSet<uint>? ImmediateAncestors { get; }
public long EarliestTickGeneration { get; }
public int NextHookIndex { get; set; }
}
private readonly record struct DelayedCallPes(
uint OwnerLocalId,
uint ScriptDid,
double DueTime,
long EarliestTickGeneration);
private sealed record DispatchContext(uint OwnerLocalId, ScheduledScript Script);
}

View file

@ -24,11 +24,33 @@ public sealed class PhysicsScriptTableResolver
/// type, an unordered/above-range intensity, or an invalid script DID.
/// </summary>
public uint? Resolve(uint tableDid, uint rawScriptType, float intensity)
=> Resolve(tableDid, rawScriptType, intensity, out _);
/// <summary>
/// Resolves a typed play while preserving a corrupt-DAT exception for the
/// App owner to diagnose with entity context. A load failure remains a
/// retail no-play and never corrupts the caller's script FIFO.
/// </summary>
public uint? Resolve(
uint tableDid,
uint rawScriptType,
float intensity,
out Exception? loadFailure)
{
loadFailure = null;
if (!IsPhysicsScriptTableDid(tableDid))
return null;
PhysicsScriptTable? table = _loadTable(tableDid);
PhysicsScriptTable? table;
try
{
table = _loadTable(tableDid);
}
catch (Exception error)
{
loadFailure = error;
return null;
}
if (table is null
|| table.Id != tableDid
|| !table.ScriptTable.TryGetValue(

View file

@ -37,8 +37,8 @@ public static class InteriorEntityIdAllocator
{
/// <summary>Per-landblock counter budget (12 bits). A landblock hydrating
/// more interior entities than this would alias into the next Y-slot —
/// exactly the #190 bug, just at a higher threshold. Callers should log
/// loudly (never silently wrap) if this is ever exceeded.</summary>
/// exactly the #190 bug, just at a higher threshold. <see cref="Allocate"/>
/// fails before that can happen.</summary>
public const uint MaxCounter = 0xFFFu;
/// <summary>The first id in this landblock's reserved range (counter=0).
@ -46,4 +46,18 @@ public static class InteriorEntityIdAllocator
/// within the landblock.</summary>
public static uint Base(uint landblockX, uint landblockY)
=> 0x40000000u | ((landblockX & 0xFFu) << 20) | ((landblockY & 0xFFu) << 12);
/// <summary>
/// Allocates the next canonical interior-static ID and advances
/// <paramref name="counter"/>. The full 12-bit range, including
/// <see cref="MaxCounter"/>, is valid; the next request fails rather than
/// aliasing the following landblock.
/// </summary>
public static uint Allocate(uint landblockX, uint landblockY, ref uint counter)
{
if (counter > MaxCounter)
throw new InvalidDataException(
$"Landblock ({landblockX & 0xFFu:X2},{landblockY & 0xFFu:X2}) exceeds the 4096-entry interior entity id namespace.");
return Base(landblockX, landblockY) + counter++;
}
}

View file

@ -47,22 +47,29 @@ public static class LandblockLoader
// legacy starting-from-1 monotonic Ids — compatible with their assertions
// which check uniqueness within a single landblock.
//
// Latent: if a landblock has >256 stabs (rare), nextId overflows the
// low byte and bleeds into the lbY byte → cross-LB collision. Same
// pattern + same limitation as scenery/interior. Document but don't
// fix in this commit — out of scope for the Tier 1 cache bug fix.
// The low byte reserves 0 for the namespace base and 1..255 for
// entities. Never wrap into the Y byte: a collision here would corrupt
// every renderer/physics/effect table keyed by WorldEntity.Id.
uint stabIdBase = landblockId == 0
? 0u
: 0xC0000000u | ((landblockId >> 24) & 0xFFu) << 16 | ((landblockId >> 16) & 0xFFu) << 8;
uint nextId = stabIdBase == 0 ? 1u : stabIdBase + 1u;
uint AllocateId()
{
if (stabIdBase != 0 && nextId > stabIdBase + 0xFFu)
throw new InvalidDataException(
$"Landblock 0x{landblockId:X8} exceeds the 255-entry static stab id namespace.");
return nextId++;
}
foreach (var stab in info.Objects)
{
if (!IsSupported(stab.Id))
continue;
var stabEntity = new WorldEntity
{
Id = nextId++,
Id = AllocateId(),
SourceGfxObjOrSetupId = stab.Id,
Position = stab.Frame.Origin,
Rotation = stab.Frame.Orientation,
@ -78,7 +85,7 @@ public static class LandblockLoader
continue;
var buildingEntity = new WorldEntity
{
Id = nextId++,
Id = AllocateId(),
SourceGfxObjOrSetupId = building.ModelId,
Position = building.Frame.Origin,
Rotation = building.Frame.Orientation,