feat(vfx): port retail effect scheduling and delivery
This commit is contained in:
parent
363e046112
commit
96ddfdf175
28 changed files with 2473 additions and 691 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
408
src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
Normal file
408
src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Rendering.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Update-thread owner of live effect profiles, mixed pre-materialization
|
||||
/// F754/F755 delivery, typed-table resolution, and effect-producing animation
|
||||
/// hooks. Server GUIDs are translated here; downstream sinks see local IDs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Network pending delivery follows retail
|
||||
/// <c>SmartBox::HandlePlayScriptID</c> (<c>0x00452020</c>) and
|
||||
/// <c>SmartBox::HandlePlayScriptType</c> (<c>0x00452070</c>):
|
||||
/// queue only while the object is absent; an existing cell-less object no-ops.
|
||||
/// Typed/default playback ports <c>CPhysicsObj::play_script</c>
|
||||
/// (<c>0x00513260</c>) and both <c>play_default_script</c> overloads
|
||||
/// (<c>0x005132B0</c>, <c>0x00513300</c>).
|
||||
/// </remarks>
|
||||
public sealed class EntityEffectController : IAnimationHookSink
|
||||
{
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly PhysicsScriptRunner _runner;
|
||||
private readonly PhysicsScriptTableResolver _tables;
|
||||
private readonly Func<uint, uint, uint?> _childAtPart;
|
||||
private readonly Func<uint, uint?> _parentOfAttachedChild;
|
||||
private readonly Action<uint> _ownerUnregistered;
|
||||
private readonly Action<uint, uint?> _ownerSoundTableChanged;
|
||||
private readonly Dictionary<uint, EntityEffectProfile> _profilesByLocalId = new();
|
||||
private readonly HashSet<uint> _readyServerGuids = new();
|
||||
private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new();
|
||||
private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
|
||||
private readonly HashSet<uint> _syntheticOwners = new();
|
||||
private Action<string>? _diagnosticSink = Console.WriteLine;
|
||||
|
||||
public EntityEffectController(
|
||||
LiveEntityRuntime liveEntities,
|
||||
PhysicsScriptRunner runner,
|
||||
PhysicsScriptTableResolver tables,
|
||||
Func<uint, uint, uint?>? childAtPart = null,
|
||||
Func<uint, uint?>? parentOfAttachedChild = null,
|
||||
Action<uint>? ownerUnregistered = null,
|
||||
Action<uint, uint?>? ownerSoundTableChanged = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
|
||||
_tables = tables ?? throw new ArgumentNullException(nameof(tables));
|
||||
_childAtPart = childAtPart ?? ((_, _) => null);
|
||||
_parentOfAttachedChild = parentOfAttachedChild ?? (_ => null);
|
||||
_ownerUnregistered = ownerUnregistered ?? (_ => { });
|
||||
_ownerSoundTableChanged = ownerSoundTableChanged ?? ((_, _) => { });
|
||||
_runner.DiagnosticSink = message => _diagnosticSink?.Invoke(message);
|
||||
}
|
||||
|
||||
public Action<string>? DiagnosticSink
|
||||
{
|
||||
get => _diagnosticSink;
|
||||
set => _diagnosticSink = value;
|
||||
}
|
||||
|
||||
public int PendingPacketCount => _pendingByServerGuid.Values.Sum(queue => queue.Count);
|
||||
public int ReadyOwnerCount => _profilesByLocalId.Count;
|
||||
|
||||
public void HandleDirect(PlayPhysicsScript message)
|
||||
{
|
||||
if (message.Guid == 0)
|
||||
return;
|
||||
if (TryGetReadyLocalId(message.Guid, out uint localId))
|
||||
{
|
||||
RefreshLiveAnchor(message.Guid, localId);
|
||||
if (CanStartOwner(localId))
|
||||
PlayDirect(localId, message.ScriptDid);
|
||||
return;
|
||||
}
|
||||
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
|
||||
}
|
||||
|
||||
public void HandleTyped(PlayPhysicsScriptType message)
|
||||
{
|
||||
if (message.Guid == 0)
|
||||
return;
|
||||
if (TryGetReadyLocalId(message.Guid, out uint localId))
|
||||
{
|
||||
RefreshLiveAnchor(message.Guid, localId);
|
||||
if (CanStartOwner(localId))
|
||||
PlayTyped(localId, message.RawScriptType, message.Intensity);
|
||||
return;
|
||||
}
|
||||
Enqueue(message.Guid, PendingEffect.Typed(message.RawScriptType, message.Intensity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a live owner ready only after its projection, resource owners, and
|
||||
/// effect profile have all registered. Pending packets replay once in their
|
||||
/// original mixed F754/F755 order.
|
||||
/// </summary>
|
||||
public bool OnLiveEntityReady(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| !record.ResourcesRegistered
|
||||
|| record.EffectProfile is not EntityEffectProfile profile)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_readyServerGuids.Add(serverGuid);
|
||||
_profilesByLocalId[entity.Id] = profile;
|
||||
_runner.SetOwnerAnchor(entity.Id, entity.Position);
|
||||
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
|
||||
TryReplayPending(serverGuid, entity.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-publishes network-owned profile resources after a same-generation
|
||||
/// PhysicsDesc replacement. Retail <c>CPhysicsObj::set_description</c>
|
||||
/// (<c>0x00514F40</c>) releases and reinstalls <c>stable_id</c> on every
|
||||
/// description application, including present-zero clearing.
|
||||
/// </summary>
|
||||
public bool OnLiveEntityDescriptionChanged(uint serverGuid)
|
||||
{
|
||||
if (!TryGetReadyLocalId(serverGuid, out uint localId)
|
||||
|| !_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.EffectProfile is not EntityEffectProfile profile)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_profilesByLocalId[localId] = profile;
|
||||
_ownerSoundTableChanged(localId, profile.CurrentSoundTableDid);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void OnDatStaticEntityReady(
|
||||
uint ownerLocalId,
|
||||
WorldEntity entity,
|
||||
EntityEffectProfile profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
if (ownerLocalId == 0)
|
||||
return;
|
||||
_staticOwners[ownerLocalId] = entity;
|
||||
_profilesByLocalId[ownerLocalId] = profile;
|
||||
_runner.SetOwnerAnchor(ownerLocalId, entity.Position);
|
||||
_ownerSoundTableChanged(ownerLocalId, profile.CurrentSoundTableDid);
|
||||
}
|
||||
|
||||
public void OnDatStaticEntityRemoved(uint localId)
|
||||
{
|
||||
if (!_staticOwners.Remove(localId))
|
||||
return;
|
||||
_profilesByLocalId.Remove(localId);
|
||||
_runner.StopAllForEntity(localId);
|
||||
_ownerUnregistered(localId);
|
||||
}
|
||||
|
||||
public void RegisterSyntheticOwner(uint ownerLocalId)
|
||||
{
|
||||
if (ownerLocalId != 0)
|
||||
_syntheticOwners.Add(ownerLocalId);
|
||||
}
|
||||
|
||||
public void UnregisterSyntheticOwner(uint ownerLocalId) =>
|
||||
_syntheticOwners.Remove(ownerLocalId);
|
||||
|
||||
public void OnLiveEntityUnregistered(LiveEntityRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
_pendingByServerGuid.Remove(record.ServerGuid);
|
||||
_readyServerGuids.Remove(record.ServerGuid);
|
||||
if (record.LocalEntityId is not { } localId)
|
||||
return;
|
||||
_profilesByLocalId.Remove(localId);
|
||||
_runner.StopAllForEntity(localId);
|
||||
_ownerUnregistered(localId);
|
||||
}
|
||||
|
||||
/// <summary>Clears a pending owner that never reached CreateObject.</summary>
|
||||
public void ForgetUnknownOwner(uint serverGuid) =>
|
||||
_pendingByServerGuid.Remove(serverGuid);
|
||||
|
||||
public void ClearNetworkState()
|
||||
{
|
||||
foreach (uint serverGuid in _readyServerGuids.ToArray())
|
||||
{
|
||||
if (_liveEntities.TryGetLocalEntityId(serverGuid, out uint localId))
|
||||
{
|
||||
_profilesByLocalId.Remove(localId);
|
||||
_runner.StopAllForEntity(localId);
|
||||
_ownerUnregistered(localId);
|
||||
}
|
||||
}
|
||||
_readyServerGuids.Clear();
|
||||
_pendingByServerGuid.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes every live root anchor after animation/movement.</summary>
|
||||
public void RefreshLiveOwnerAnchors()
|
||||
{
|
||||
foreach (uint serverGuid in _readyServerGuids.ToArray())
|
||||
{
|
||||
if (!TryGetReadyLocalId(serverGuid, out uint localId))
|
||||
continue;
|
||||
RefreshLiveAnchor(serverGuid, localId);
|
||||
TryReplayPending(serverGuid, localId);
|
||||
}
|
||||
}
|
||||
|
||||
public bool PlayDirect(uint ownerLocalId, uint scriptDid)
|
||||
{
|
||||
if (!CanStartOwner(ownerLocalId))
|
||||
return false;
|
||||
return _runner.PlayDirect(ownerLocalId, scriptDid);
|
||||
}
|
||||
|
||||
public bool PlayTyped(uint ownerLocalId, uint rawScriptType, float intensity)
|
||||
{
|
||||
if (!CanStartOwner(ownerLocalId)
|
||||
|| !_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile)
|
||||
|| profile.CurrentPhysicsScriptTableDid is not { } tableDid)
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
$"No PhysicsScriptTable for owner 0x{ownerLocalId:X8}, type 0x{rawScriptType:X8}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint? scriptDid = _tables.Resolve(
|
||||
tableDid,
|
||||
rawScriptType,
|
||||
intensity,
|
||||
out Exception? loadFailure);
|
||||
if (scriptDid is not { } resolved)
|
||||
{
|
||||
string detail = loadFailure is null
|
||||
? string.Empty
|
||||
: $" Load failed: {loadFailure.GetType().Name}: {loadFailure.Message}";
|
||||
DiagnosticSink?.Invoke(
|
||||
$"No typed PhysicsScript for owner 0x{ownerLocalId:X8}, table 0x{tableDid:X8}, " +
|
||||
$"type 0x{rawScriptType:X8}, intensity {intensity:R}.{detail}");
|
||||
return false;
|
||||
}
|
||||
return _runner.PlayDirect(ownerLocalId, resolved);
|
||||
}
|
||||
|
||||
public bool PlayDefault(uint ownerLocalId)
|
||||
{
|
||||
if (!CanStartOwner(ownerLocalId)
|
||||
|| !_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile))
|
||||
return false;
|
||||
return PlayTyped(
|
||||
ownerLocalId,
|
||||
profile.RawDefaultScriptType,
|
||||
profile.DefaultScriptIntensity);
|
||||
}
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||
{
|
||||
_runner.SetOwnerAnchor(entityId, entityWorldPosition);
|
||||
switch (hook)
|
||||
{
|
||||
case CallPESHook call:
|
||||
if (CanStartOwner(entityId))
|
||||
_runner.ScheduleCallPes(entityId, call.PES, call.Pause);
|
||||
break;
|
||||
case DefaultScriptHook:
|
||||
PlayDefault(entityId);
|
||||
break;
|
||||
case DefaultScriptPartHook part:
|
||||
if (_childAtPart(entityId, part.PartIndex) is { } childLocalId)
|
||||
PlayDefault(childLocalId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) advances
|
||||
/// root scripts only with a cell and while not Frozen. Parented objects
|
||||
/// advance through their eligible parent's UpdateChild path instead of
|
||||
/// their own cell-less root update.
|
||||
/// </summary>
|
||||
public bool CanAdvanceOwner(uint ownerLocalId)
|
||||
{
|
||||
if (_staticOwners.ContainsKey(ownerLocalId) || _syntheticOwners.Contains(ownerLocalId))
|
||||
return true;
|
||||
|
||||
if (_parentOfAttachedChild(ownerLocalId) is { } parentLocalId)
|
||||
return CanAdvanceLiveRoot(parentLocalId);
|
||||
|
||||
return CanAdvanceLiveRoot(ownerLocalId);
|
||||
}
|
||||
|
||||
private bool CanStartOwner(uint ownerLocalId)
|
||||
{
|
||||
if (_staticOwners.ContainsKey(ownerLocalId) || _syntheticOwners.Contains(ownerLocalId))
|
||||
return true;
|
||||
if (_parentOfAttachedChild(ownerLocalId) is { } parentLocalId)
|
||||
return IsLiveRootInCell(parentLocalId);
|
||||
return IsLiveRootInCell(ownerLocalId);
|
||||
}
|
||||
|
||||
private bool CanAdvanceLiveRoot(uint ownerLocalId)
|
||||
{
|
||||
if (!TryGetLiveRoot(ownerLocalId, out LiveEntityRecord record))
|
||||
return false;
|
||||
return IsLiveRootInCell(record)
|
||||
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
|
||||
}
|
||||
|
||||
private bool IsLiveRootInCell(uint ownerLocalId) =>
|
||||
TryGetLiveRoot(ownerLocalId, out LiveEntityRecord record)
|
||||
&& IsLiveRootInCell(record);
|
||||
|
||||
private static bool IsLiveRootInCell(LiveEntityRecord record) =>
|
||||
record.ResourcesRegistered
|
||||
&& record.IsSpatiallyProjected
|
||||
&& record.IsSpatiallyVisible
|
||||
&& record.FullCellId != 0;
|
||||
|
||||
private bool TryGetLiveRoot(uint ownerLocalId, out LiveEntityRecord record)
|
||||
{
|
||||
if (_liveEntities.TryGetServerGuid(ownerLocalId, out uint serverGuid)
|
||||
&& _liveEntities.TryGetRecord(serverGuid, out record!))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
record = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetReadyLocalId(uint serverGuid, out uint localId)
|
||||
{
|
||||
if (_readyServerGuids.Contains(serverGuid)
|
||||
&& _liveEntities.TryGetLocalEntityId(serverGuid, out localId)
|
||||
&& _profilesByLocalId.ContainsKey(localId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
localId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void RefreshLiveAnchor(uint serverGuid, uint localId)
|
||||
{
|
||||
if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
&& record.WorldEntity is { } entity
|
||||
&& entity.Id == localId)
|
||||
{
|
||||
_runner.SetOwnerAnchor(localId, entity.Position);
|
||||
}
|
||||
}
|
||||
|
||||
private void Enqueue(uint serverGuid, PendingEffect effect)
|
||||
{
|
||||
if (!_pendingByServerGuid.TryGetValue(serverGuid, out Queue<PendingEffect>? queue))
|
||||
{
|
||||
queue = new Queue<PendingEffect>();
|
||||
_pendingByServerGuid.Add(serverGuid, queue);
|
||||
}
|
||||
queue.Enqueue(effect);
|
||||
}
|
||||
|
||||
private void TryReplayPending(uint serverGuid, uint localId)
|
||||
{
|
||||
if (!CanStartOwner(localId)
|
||||
|| !_pendingByServerGuid.Remove(serverGuid, out Queue<PendingEffect>? pending))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (pending.Count > 0)
|
||||
Execute(localId, pending.Dequeue());
|
||||
}
|
||||
|
||||
private void Execute(uint localId, PendingEffect effect)
|
||||
{
|
||||
if (effect.Kind is PendingEffectKind.Direct)
|
||||
PlayDirect(localId, effect.ScriptDid);
|
||||
else
|
||||
PlayTyped(localId, effect.RawScriptType, effect.Intensity);
|
||||
}
|
||||
|
||||
private enum PendingEffectKind
|
||||
{
|
||||
Direct,
|
||||
Typed,
|
||||
}
|
||||
|
||||
private readonly record struct PendingEffect(
|
||||
PendingEffectKind Kind,
|
||||
uint ScriptDid,
|
||||
uint RawScriptType,
|
||||
float Intensity)
|
||||
{
|
||||
public static PendingEffect Direct(uint scriptDid) =>
|
||||
new(PendingEffectKind.Direct, scriptDid, 0u, 0f);
|
||||
|
||||
public static PendingEffect Typed(uint rawScriptType, float intensity) =>
|
||||
new(PendingEffectKind.Typed, 0u, rawScriptType, intensity);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue