feat(physics): port retail complete object frame pipeline
Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates. Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
31a0889f08
commit
f961d70023
77 changed files with 12513 additions and 1871 deletions
File diff suppressed because it is too large
Load diff
509
src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
Normal file
509
src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Types;
|
||||
using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity;
|
||||
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Owns retail's ordinary live-object workset. <c>CPhysics::UseTime</c>
|
||||
/// (0x00509950) walks the object table, not the render-animation table; an
|
||||
/// animation, MovementManager, projectile body, or effect owner is therefore
|
||||
/// an optional component of one canonical <see cref="LiveEntityRecord"/>.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityAnimationScheduler
|
||||
{
|
||||
private readonly Func<LiveEntityRuntime?> _liveEntities;
|
||||
private readonly Func<uint> _localPlayerGuid;
|
||||
private readonly RemotePhysicsUpdater _remotePhysics;
|
||||
private readonly LiveEntityOrdinaryPhysicsUpdater _ordinaryPhysics;
|
||||
private readonly Func<ProjectileController?> _projectiles;
|
||||
private readonly Action<WorldEntity> _publishRootPose;
|
||||
private readonly Action<uint, AnimationSequencer> _captureAnimationHooks;
|
||||
private readonly List<LiveEntityRecord> _rootSnapshot = new();
|
||||
private readonly Dictionary<uint, LiveEntityAnimationSchedule> _schedules = new();
|
||||
private readonly Frame _rootFrameScratch = new();
|
||||
private readonly MotionDeltaFrame _rootDeltaScratch = new();
|
||||
|
||||
public LiveEntityAnimationScheduler(
|
||||
Func<LiveEntityRuntime?> liveEntities,
|
||||
Func<uint> localPlayerGuid,
|
||||
RemotePhysicsUpdater remotePhysics,
|
||||
LiveEntityOrdinaryPhysicsUpdater ordinaryPhysics,
|
||||
Func<ProjectileController?> projectiles,
|
||||
Action<WorldEntity> publishRootPose,
|
||||
Action<uint, AnimationSequencer> captureAnimationHooks)
|
||||
{
|
||||
_liveEntities = liveEntities
|
||||
?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_localPlayerGuid = localPlayerGuid
|
||||
?? throw new ArgumentNullException(nameof(localPlayerGuid));
|
||||
_remotePhysics = remotePhysics
|
||||
?? throw new ArgumentNullException(nameof(remotePhysics));
|
||||
_ordinaryPhysics = ordinaryPhysics
|
||||
?? throw new ArgumentNullException(nameof(ordinaryPhysics));
|
||||
_projectiles = projectiles
|
||||
?? throw new ArgumentNullException(nameof(projectiles));
|
||||
_publishRootPose = publishRootPose
|
||||
?? throw new ArgumentNullException(nameof(publishRootPose));
|
||||
_captureAnimationHooks = captureAnimationHooks
|
||||
?? throw new ArgumentNullException(nameof(captureAnimationHooks));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances a stable snapshot of the complete ordinary-object table and
|
||||
/// returns pose work only for animation owners that survived every
|
||||
/// callback. The returned dictionary is reused and valid until the next
|
||||
/// call.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> Tick(
|
||||
float elapsedSeconds,
|
||||
Vector3? playerPosition,
|
||||
bool localHiddenPartPoseDirty,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
Action<uint, AnimatedEntity>? prepareAnimation = null)
|
||||
{
|
||||
_schedules.Clear();
|
||||
LiveEntityRuntime? runtime = _liveEntities();
|
||||
if (runtime is null)
|
||||
return _schedules;
|
||||
|
||||
runtime.CopySpatialRootObjectRecordsTo(_rootSnapshot);
|
||||
foreach (LiveEntityRecord record in _rootSnapshot)
|
||||
{
|
||||
if (!runtime.IsCurrentSpatialRootObject(record)
|
||||
|| record.WorldEntity is not { } entity)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity;
|
||||
RemoteMotion? remote = record.RemoteMotionRuntime as RemoteMotion;
|
||||
ProjectileController.Runtime? projectile =
|
||||
record.ProjectileRuntime as ProjectileController.Runtime;
|
||||
ulong objectClockEpoch = record.ObjectClockEpoch;
|
||||
|
||||
if (animation is not null)
|
||||
prepareAnimation?.Invoke(record.ServerGuid, animation);
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
animation,
|
||||
remote,
|
||||
projectile,
|
||||
objectClockEpoch))
|
||||
continue;
|
||||
|
||||
LiveEntityAnimationSchedule schedule = AdvanceRecord(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
animation,
|
||||
remote,
|
||||
projectile,
|
||||
elapsedSeconds,
|
||||
playerPosition,
|
||||
localHiddenPartPoseDirty,
|
||||
liveCenterX,
|
||||
liveCenterY,
|
||||
objectClockEpoch);
|
||||
|
||||
if (animation is not null
|
||||
&& schedule.ComposeParts
|
||||
&& IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
animation,
|
||||
remote,
|
||||
projectile,
|
||||
objectClockEpoch))
|
||||
{
|
||||
_schedules[entity.Id] = schedule;
|
||||
}
|
||||
}
|
||||
|
||||
return _schedules;
|
||||
}
|
||||
|
||||
private LiveEntityAnimationSchedule AdvanceRecord(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord record,
|
||||
WorldEntity entity,
|
||||
AnimatedEntity? animation,
|
||||
RemoteMotion? remote,
|
||||
ProjectileController.Runtime? projectile,
|
||||
float elapsedSeconds,
|
||||
Vector3? playerPosition,
|
||||
bool localHiddenPartPoseDirty,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
ulong objectClockEpoch)
|
||||
{
|
||||
uint serverGuid = record.ServerGuid;
|
||||
AnimationSequencer? sequencer = animation?.Sequencer;
|
||||
PhysicsStateFlags state = record.FinalPhysicsState;
|
||||
bool hidden = (state & PhysicsStateFlags.Hidden) != 0;
|
||||
|
||||
// PlayerMovementController owns this exact record clock and advances
|
||||
// its PartArray before local collision. Consume that prepared pose;
|
||||
// never tick the same clock or sequence a second time here.
|
||||
if (serverGuid == _localPlayerGuid())
|
||||
{
|
||||
IReadOnlyList<PartTransform>? prepared =
|
||||
animation?.PreparedSequenceFrames;
|
||||
bool advanced = animation?.SequenceAdvancedBeforeAnimationPass == true;
|
||||
if (animation is not null)
|
||||
{
|
||||
animation.PreparedSequenceFrames = null;
|
||||
animation.SequenceAdvancedBeforeAnimationPass = false;
|
||||
}
|
||||
|
||||
bool composeHidden = hidden && localHiddenPartPoseDirty;
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
animation,
|
||||
remote,
|
||||
projectile,
|
||||
objectClockEpoch))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
// Local projection interpolates the visible root on render frames
|
||||
// that are smaller than an admitted object quantum. Publish that
|
||||
// current root independently of PartArray recomposition so local
|
||||
// attached effects and lights never trail the rendered character.
|
||||
_publishRootPose(entity);
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
animation,
|
||||
remote,
|
||||
projectile,
|
||||
objectClockEpoch))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return new LiveEntityAnimationSchedule(
|
||||
prepared ?? (composeHidden ? sequencer?.SampleCurrentPose() : null),
|
||||
0f,
|
||||
ComposeParts: advanced || composeHidden);
|
||||
}
|
||||
|
||||
RetailObjectActivityResult activity = RetailObjectActivityGate.Evaluate(
|
||||
record.ObjectClock,
|
||||
remote?.Body ?? projectile?.Body ?? record.PhysicsBody,
|
||||
runtime.GetRootObjectClockDisposition(serverGuid)
|
||||
is RetailObjectClockDisposition.Advance,
|
||||
record.HasPartArray,
|
||||
(state & PhysicsStateFlags.Static) != 0,
|
||||
entity.Position,
|
||||
playerPosition,
|
||||
elapsedSeconds);
|
||||
if (activity is not RetailObjectActivityResult.Active)
|
||||
return default;
|
||||
|
||||
RetailObjectQuantumBatch batch = record.ObjectClock.Advance(elapsedSeconds);
|
||||
if (batch.Count == 0)
|
||||
return default;
|
||||
|
||||
IReadOnlyList<PartTransform>? frames = null;
|
||||
float legacyElapsed = 0f;
|
||||
bool completed = true;
|
||||
ProjectileController? projectileController = _projectiles();
|
||||
bool projectileHandlesMovement = projectile is not null
|
||||
&& projectileController?.HandlesMovement(serverGuid) == true;
|
||||
float objectScale = animation?.Scale
|
||||
?? record.Snapshot.Physics?.Scale
|
||||
?? record.Snapshot.ObjScale
|
||||
?? entity.Scale;
|
||||
|
||||
for (int qi = 0; qi < batch.Count; qi++)
|
||||
{
|
||||
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
|
||||
{
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
|
||||
float quantum = batch.GetQuantum(qi);
|
||||
if (hidden)
|
||||
{
|
||||
if (remote is not null)
|
||||
{
|
||||
if (!_remotePhysics.TickHidden(
|
||||
remote,
|
||||
entity,
|
||||
quantum,
|
||||
sequencer?.Manager,
|
||||
_captureAnimationHooks,
|
||||
sequencer,
|
||||
runtime,
|
||||
record,
|
||||
objectClockEpoch))
|
||||
{
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (sequencer is not null)
|
||||
{
|
||||
_captureAnimationHooks(entity.Id, sequencer);
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
animation,
|
||||
remote,
|
||||
projectile,
|
||||
objectClockEpoch))
|
||||
{
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
RunManagerTail(remote, sequencer?.Manager);
|
||||
}
|
||||
|
||||
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
|
||||
{
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
Frame rootFrame = animation?.RootMotionScratch ?? _rootFrameScratch;
|
||||
rootFrame.Origin = Vector3.Zero;
|
||||
rootFrame.Orientation = Quaternion.Identity;
|
||||
if (sequencer is not null)
|
||||
frames = sequencer.Advance(quantum, rootFrame);
|
||||
else if (animation is not null)
|
||||
legacyElapsed += quantum;
|
||||
|
||||
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
|
||||
{
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (remote is not null && !projectileHandlesMovement)
|
||||
{
|
||||
if (animation is not null && quantum > 0f)
|
||||
{
|
||||
float rootMotionSpeed = rootFrame.Origin.Length()
|
||||
* objectScale / quantum;
|
||||
remote.MaxRootMotionSpeedSinceLastUP = MathF.Max(
|
||||
remote.MaxRootMotionSpeedSinceLastUP,
|
||||
rootMotionSpeed);
|
||||
}
|
||||
|
||||
MotionDeltaFrame rootDelta = animation?.RootMotionDeltaScratch
|
||||
?? _rootDeltaScratch;
|
||||
rootDelta.Origin = rootFrame.Origin;
|
||||
rootDelta.Orientation = rootFrame.Orientation;
|
||||
if (!_remotePhysics.Tick(
|
||||
remote,
|
||||
entity,
|
||||
objectScale,
|
||||
sequencer,
|
||||
animation,
|
||||
quantum,
|
||||
rootDelta,
|
||||
liveCenterX,
|
||||
liveCenterY,
|
||||
_captureAnimationHooks,
|
||||
runtime,
|
||||
record,
|
||||
objectClockEpoch))
|
||||
{
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
bool ordinaryBodyHandlesMovement = projectile is null
|
||||
&& record.PhysicsBody is not null;
|
||||
if (ordinaryBodyHandlesMovement)
|
||||
{
|
||||
if (!_ordinaryPhysics.Tick(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
rootFrame,
|
||||
objectScale,
|
||||
quantum,
|
||||
liveCenterX,
|
||||
liveCenterY,
|
||||
objectClockEpoch,
|
||||
sequencer,
|
||||
_captureAnimationHooks))
|
||||
{
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyRootFrame(record, entity, rootFrame, objectScale);
|
||||
}
|
||||
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
|
||||
{
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
|
||||
ProjectileController.QuantumStep projectileStep = default;
|
||||
bool beganProjectile = projectileHandlesMovement
|
||||
&& projectileController?.TryBeginQuantum(
|
||||
record,
|
||||
quantum,
|
||||
out projectileStep) == true;
|
||||
|
||||
if (!ordinaryBodyHandlesMovement && sequencer is not null)
|
||||
_captureAnimationHooks(entity.Id, sequencer);
|
||||
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
|
||||
{
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (beganProjectile
|
||||
&& !projectileController!.CompleteQuantum(
|
||||
projectileStep,
|
||||
liveCenterX,
|
||||
liveCenterY))
|
||||
{
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
|
||||
{
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
|
||||
RunManagerTail(remote, sequencer?.Manager);
|
||||
}
|
||||
|
||||
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
|
||||
{
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!completed
|
||||
|| !IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
_publishRootPose(entity);
|
||||
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
|
||||
return default;
|
||||
|
||||
if (hidden)
|
||||
{
|
||||
return new LiveEntityAnimationSchedule(
|
||||
sequencer?.SampleCurrentPose(),
|
||||
0f,
|
||||
ComposeParts: animation is not null);
|
||||
}
|
||||
|
||||
return new LiveEntityAnimationSchedule(
|
||||
frames,
|
||||
legacyElapsed,
|
||||
ComposeParts: animation is not null);
|
||||
}
|
||||
|
||||
private static void ApplyRootFrame(
|
||||
LiveEntityRecord record,
|
||||
WorldEntity entity,
|
||||
Frame rootFrame,
|
||||
float objectScale)
|
||||
{
|
||||
PhysicsBody? body = record.PhysicsBody;
|
||||
Vector3 position = body?.Position ?? entity.Position;
|
||||
Quaternion orientation = body?.Orientation ?? entity.Rotation;
|
||||
Vector3 localOrigin = body?.OnWalkable == true
|
||||
? rootFrame.Origin * objectScale
|
||||
: Vector3.Zero;
|
||||
|
||||
if (localOrigin != Vector3.Zero)
|
||||
position += Vector3.Transform(localOrigin, orientation);
|
||||
if (!rootFrame.Orientation.IsIdentity)
|
||||
{
|
||||
orientation = FrameOps.SetRotate(
|
||||
position,
|
||||
orientation,
|
||||
orientation * rootFrame.Orientation);
|
||||
}
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
body.Position = position;
|
||||
body.Orientation = orientation;
|
||||
// Projectile integration follows this compose. Manager-less
|
||||
// ordinary bodies use LiveEntityOrdinaryPhysicsUpdater so their
|
||||
// complete Frame travels through Transition/SetPositionInternal.
|
||||
}
|
||||
|
||||
entity.SetPosition(position);
|
||||
entity.Rotation = orientation;
|
||||
}
|
||||
|
||||
private static bool IsCurrent(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord record,
|
||||
WorldEntity entity,
|
||||
AnimatedEntity? animation,
|
||||
RemoteMotion? remote,
|
||||
ProjectileController.Runtime? projectile,
|
||||
ulong objectClockEpoch) =>
|
||||
runtime.IsCurrentSpatialRootObject(record)
|
||||
&& record.ObjectClockEpoch == objectClockEpoch
|
||||
&& ReferenceEquals(record.WorldEntity, entity)
|
||||
&& ReferenceEquals(record.AnimationRuntime, animation)
|
||||
&& ReferenceEquals(record.RemoteMotionRuntime, remote)
|
||||
&& ReferenceEquals(record.ProjectileRuntime, projectile)
|
||||
&& (animation is null || runtime.IsCurrentSpatialAnimation(record, animation));
|
||||
|
||||
private static void RunManagerTail(
|
||||
RemoteMotion? remote,
|
||||
MotionTableManager? partArray)
|
||||
{
|
||||
if (remote is not null)
|
||||
{
|
||||
RetailObjectManagerTail.Run(
|
||||
remote.Host?.TargetManager,
|
||||
remote.Movement,
|
||||
partArray,
|
||||
remote.Host?.PositionManager);
|
||||
}
|
||||
else
|
||||
{
|
||||
partArray?.UseTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct LiveEntityAnimationSchedule(
|
||||
IReadOnlyList<PartTransform>? SequenceFrames,
|
||||
float LegacyAdvanceSeconds,
|
||||
bool ComposeParts);
|
||||
|
|
@ -0,0 +1,432 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Ports retail's separate <c>CPhysics::static_animating_objects</c> workset
|
||||
/// (<c>CPhysics::UseTime</c> 0x00509950 and
|
||||
/// <c>CPhysicsObj::animate_static_object</c> 0x00513DF0). Membership is
|
||||
/// Setup/state-driven: any physics-Static object with Setup.DefaultAnimation
|
||||
/// enters, whether DAT-hydrated or server-created, and leaves with its logical
|
||||
/// resource lifetime. Setup default installation itself is unconditional and
|
||||
/// belongs to PartArray construction; this class owns only the Static workset.
|
||||
/// </summary>
|
||||
internal sealed class RetailStaticAnimatingObjectScheduler
|
||||
{
|
||||
private const double FrameEpsilon = 0.000199999995;
|
||||
private const float MaximumElapsed = 2f;
|
||||
|
||||
private sealed class Owner
|
||||
{
|
||||
public required WorldEntity Entity;
|
||||
public required Setup Setup;
|
||||
public required AnimationSequencer? Sequencer;
|
||||
public required uint[] PartGfxIds;
|
||||
public required IReadOnlyDictionary<uint, uint>?[] SurfaceOverrides;
|
||||
public required bool[] PartAvailable;
|
||||
public PhysicsBody? Body;
|
||||
public AnimationSequencer? PendingProcessHooks;
|
||||
public ulong PendingResidencyVersion;
|
||||
public IReadOnlyList<PartTransform>? PreparedLivePartFrames;
|
||||
public double ElapsedSinceUpdate;
|
||||
public readonly Frame RootFrameScratch = new();
|
||||
public readonly List<MeshRef> MeshRefs = new();
|
||||
public readonly List<Matrix4x4> PartPoses = new();
|
||||
}
|
||||
|
||||
private readonly IAnimationLoader _animationLoader;
|
||||
private readonly Action<uint, AnimationSequencer> _captureHooks;
|
||||
private readonly Action<WorldEntity, IReadOnlyList<Matrix4x4>, IReadOnlyList<bool>>
|
||||
_publishPartPoses;
|
||||
private readonly Func<WorldEntity, bool> _isResident;
|
||||
private readonly Action<WorldEntity, PhysicsBody> _commitLiveRoot;
|
||||
private readonly Func<WorldEntity, ulong> _residencyVersion;
|
||||
private readonly Dictionary<uint, Owner> _owners = new();
|
||||
private readonly List<Owner> _snapshot = new();
|
||||
private readonly List<Owner> _hookSnapshot = new();
|
||||
|
||||
public RetailStaticAnimatingObjectScheduler(
|
||||
IAnimationLoader animationLoader,
|
||||
Action<uint, AnimationSequencer> captureHooks,
|
||||
Action<WorldEntity, IReadOnlyList<Matrix4x4>, IReadOnlyList<bool>> publishPartPoses,
|
||||
Func<WorldEntity, bool>? isResident = null,
|
||||
Action<WorldEntity, PhysicsBody>? commitLiveRoot = null,
|
||||
Func<WorldEntity, ulong>? residencyVersion = null)
|
||||
{
|
||||
_animationLoader = animationLoader
|
||||
?? throw new ArgumentNullException(nameof(animationLoader));
|
||||
_captureHooks = captureHooks
|
||||
?? throw new ArgumentNullException(nameof(captureHooks));
|
||||
_publishPartPoses = publishPartPoses
|
||||
?? throw new ArgumentNullException(nameof(publishPartPoses));
|
||||
_isResident = isResident ?? (_ => true);
|
||||
_commitLiveRoot = commitLiveRoot ?? ((_, _) => { });
|
||||
_residencyVersion = residencyVersion ?? (_ => 0UL);
|
||||
}
|
||||
|
||||
internal int Count => _owners.Count;
|
||||
|
||||
public void Register(WorldEntity entity, ScriptActivationInfo info)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
if (!info.UsesStaticAnimationWorkset
|
||||
|| info.Setup is not { } setup
|
||||
|| info.DefaultAnimationId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_owners.TryGetValue(entity.Id, out Owner? retained))
|
||||
{
|
||||
if (ReferenceEquals(retained.Entity, entity))
|
||||
return;
|
||||
throw new InvalidOperationException(
|
||||
$"DAT-static animation owner 0x{entity.Id:X8} is already registered.");
|
||||
}
|
||||
|
||||
// A live CPhysicsObj already owns its canonical PartArray through
|
||||
// AnimatedEntity. Resource registration occurs before that App owner
|
||||
// is constructed, so retain a pending workset member and bind the
|
||||
// exact sequencer later. DAT-only statics have no live owner and may
|
||||
// construct their PartArray here.
|
||||
AnimationSequencer? sequencer = null;
|
||||
if (entity.ServerGuid == 0)
|
||||
{
|
||||
sequencer = new AnimationSequencer(
|
||||
setup,
|
||||
new MotionTable(),
|
||||
_animationLoader);
|
||||
if (!sequencer.HasCurrentNode)
|
||||
return;
|
||||
}
|
||||
|
||||
int partCount = setup.Parts.Count;
|
||||
uint[] gfxIds = BuildPartGfxIds(setup, entity);
|
||||
bool[] available = new bool[partCount];
|
||||
if (info.PartAvailability is { } supplied)
|
||||
{
|
||||
for (int i = 0; i < partCount && i < supplied.Count; i++)
|
||||
available[i] = supplied[i];
|
||||
}
|
||||
var surfaces = MatchSurfaceOverrides(entity, gfxIds, available);
|
||||
_owners.Add(entity.Id, new Owner
|
||||
{
|
||||
Entity = entity,
|
||||
Setup = setup,
|
||||
Sequencer = sequencer,
|
||||
PartGfxIds = gfxIds,
|
||||
SurfaceOverrides = surfaces,
|
||||
PartAvailable = available,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes a live Physics-Static registration with the same sequencer
|
||||
/// and body owned by the canonical live entity.
|
||||
/// Retail's static workset stores a <c>CPhysicsObj*</c>; it never clones a
|
||||
/// second PartArray for this alternate scheduling path.
|
||||
/// </summary>
|
||||
public bool BindLiveOwner(
|
||||
WorldEntity entity,
|
||||
AnimationSequencer sequencer,
|
||||
PhysicsBody body)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(sequencer);
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
if (!_owners.TryGetValue(entity.Id, out Owner? owner))
|
||||
return false;
|
||||
if (!ReferenceEquals(owner.Entity, entity) || entity.ServerGuid == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Static animation owner 0x{entity.Id:X8} does not match this live incarnation.");
|
||||
}
|
||||
if (!sequencer.HasCurrentNode)
|
||||
return false;
|
||||
|
||||
owner.Sequencer = sequencer;
|
||||
owner.Body = body;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transfers the current live PartArray result to GameWindow's canonical
|
||||
/// appearance composer. The scheduler owns timing; AnimatedEntity remains
|
||||
/// the single owner of mesh identity, overrides, and appearance rebinding.
|
||||
/// </summary>
|
||||
public bool TryTakeLivePartFrames(
|
||||
uint ownerId,
|
||||
out IReadOnlyList<PartTransform> frames)
|
||||
{
|
||||
if (_owners.TryGetValue(ownerId, out Owner? owner)
|
||||
&& owner.Entity.ServerGuid != 0
|
||||
&& owner.PreparedLivePartFrames is { } prepared
|
||||
&& IsResidentAtVersion(owner, owner.PendingResidencyVersion))
|
||||
{
|
||||
owner.PreparedLivePartFrames = null;
|
||||
frames = prepared;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_owners.TryGetValue(ownerId, out owner))
|
||||
InvalidatePending(owner);
|
||||
|
||||
frames = Array.Empty<PartTransform>();
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Unregister(uint ownerId) => _owners.Remove(ownerId);
|
||||
|
||||
public void CopyAnimatedEntityIdsTo(HashSet<uint> destination)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
foreach ((uint ownerId, Owner owner) in _owners)
|
||||
{
|
||||
if (owner.Sequencer is not null)
|
||||
destination.Add(ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick(float elapsedSeconds)
|
||||
{
|
||||
if (!float.IsFinite(elapsedSeconds)
|
||||
|| elapsedSeconds <= 0f
|
||||
|| _owners.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_snapshot.Clear();
|
||||
_snapshot.AddRange(_owners.Values);
|
||||
foreach (Owner owner in _snapshot)
|
||||
{
|
||||
if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current)
|
||||
|| !ReferenceEquals(current, owner)
|
||||
|| owner.Sequencer is not { } sequencer)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
owner.ElapsedSinceUpdate += elapsedSeconds;
|
||||
if (!_isResident(owner.Entity))
|
||||
{
|
||||
InvalidatePending(owner);
|
||||
// animate_static_object returns before touching update_time
|
||||
// while cell == null. A short leave-world interval therefore
|
||||
// catches up on re-entry; a long one is discarded by the
|
||||
// retail two-second guard below.
|
||||
continue;
|
||||
}
|
||||
ulong residencyVersion = _residencyVersion(owner.Entity);
|
||||
|
||||
double ownerElapsed = owner.ElapsedSinceUpdate;
|
||||
owner.ElapsedSinceUpdate = 0d;
|
||||
if (ownerElapsed <= FrameEpsilon
|
||||
|| ownerElapsed > MaximumElapsed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IReadOnlyList<PartTransform> frames = sequencer.Advance(
|
||||
(float)ownerElapsed);
|
||||
|
||||
if (owner.Body is { } body)
|
||||
{
|
||||
// CPhysicsObj::animate_static_object 0x00513DF0 passes the
|
||||
// stored omega vector directly to Frame::grotate after the
|
||||
// PartArray update. Unlike UpdatePhysicsInternal, this odd
|
||||
// static branch does not multiply omega by elapsed time.
|
||||
Quaternion previousOrientation = body.Orientation;
|
||||
owner.RootFrameScratch.Origin = body.Position;
|
||||
owner.RootFrameScratch.Orientation = previousOrientation;
|
||||
FrameOps.GRotate(owner.RootFrameScratch, body.Omega);
|
||||
body.SetFrameInCurrentCell(
|
||||
body.Position,
|
||||
owner.RootFrameScratch.Orientation);
|
||||
owner.Entity.Rotation = body.Orientation;
|
||||
// animate_static_object rotates the root before
|
||||
// UpdatePartsInternal/UpdateChildrenInternal. Commit every
|
||||
// root consumer (collision shadows and effect anchors) at
|
||||
// that same boundary; a rendered-only rotation leaves the
|
||||
// canonical CPhysicsObj split across two orientations.
|
||||
// The common case is a zero omega. Do not turn that no-op
|
||||
// into a per-frame ShadowObjectRegistry reflood; only a real
|
||||
// root change has canonical consumers to commit.
|
||||
if (body.Orientation != previousOrientation)
|
||||
{
|
||||
_commitLiveRoot(owner.Entity, body);
|
||||
if (!_owners.TryGetValue(owner.Entity.Id, out current)
|
||||
|| !ReferenceEquals(current, owner)
|
||||
|| !IsResidentAtVersion(owner, residencyVersion))
|
||||
{
|
||||
InvalidatePending(owner);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!IsResidentAtVersion(owner, residencyVersion))
|
||||
{
|
||||
InvalidatePending(owner);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (owner.Entity.ServerGuid != 0)
|
||||
{
|
||||
owner.PreparedLivePartFrames = frames;
|
||||
}
|
||||
else
|
||||
{
|
||||
Compose(owner, frames);
|
||||
_publishPartPoses(owner.Entity, owner.PartPoses, owner.PartAvailable);
|
||||
if (!_owners.TryGetValue(owner.Entity.Id, out current)
|
||||
|| !ReferenceEquals(current, owner)
|
||||
|| !IsResidentAtVersion(owner, residencyVersion))
|
||||
{
|
||||
InvalidatePending(owner);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// animate_static_object runs process_hooks only after the root,
|
||||
// parts, and attached children have their final transforms. Live
|
||||
// part/child publication occurs outside this owner, so retain the
|
||||
// exact PartArray and let GameWindow call ProcessHooks at that
|
||||
// later boundary instead of completing AnimationDone here.
|
||||
owner.PendingProcessHooks = sequencer;
|
||||
owner.PendingResidencyVersion = residencyVersion;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the static workset's retail <c>process_hooks</c> tail after final
|
||||
/// root, part, and child pose publication. Presentation hooks remain in
|
||||
/// the shared frame queue until its normal drain.
|
||||
/// </summary>
|
||||
public void ProcessHooks()
|
||||
{
|
||||
_hookSnapshot.Clear();
|
||||
foreach (Owner owner in _owners.Values)
|
||||
{
|
||||
if (owner.PendingProcessHooks is not null)
|
||||
_hookSnapshot.Add(owner);
|
||||
}
|
||||
|
||||
foreach (Owner owner in _hookSnapshot)
|
||||
{
|
||||
if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current)
|
||||
|| !ReferenceEquals(current, owner)
|
||||
|| owner.PendingProcessHooks is not { } sequencer
|
||||
|| !ReferenceEquals(owner.Sequencer, sequencer)
|
||||
|| !IsResidentAtVersion(owner, owner.PendingResidencyVersion))
|
||||
{
|
||||
InvalidatePending(owner);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clear before the callback: hook delivery may unregister or
|
||||
// replace the owner, and a nested caller must not replay this tail.
|
||||
owner.PendingProcessHooks = null;
|
||||
_captureHooks(owner.Entity.Id, sequencer);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsResidentAtVersion(Owner owner, ulong version) =>
|
||||
_isResident(owner.Entity)
|
||||
&& _residencyVersion(owner.Entity) == version;
|
||||
|
||||
private static void InvalidatePending(Owner owner)
|
||||
{
|
||||
owner.PreparedLivePartFrames = null;
|
||||
owner.PendingProcessHooks = null;
|
||||
owner.PendingResidencyVersion = 0UL;
|
||||
}
|
||||
|
||||
private static void Compose(Owner owner, IReadOnlyList<PartTransform> frames)
|
||||
{
|
||||
owner.MeshRefs.Clear();
|
||||
owner.PartPoses.Clear();
|
||||
Matrix4x4 objectScale = owner.Entity.Scale == 1f
|
||||
? Matrix4x4.Identity
|
||||
: Matrix4x4.CreateScale(owner.Entity.Scale);
|
||||
|
||||
int partCount = owner.Setup.Parts.Count;
|
||||
for (int i = 0; i < partCount; i++)
|
||||
{
|
||||
Vector3 origin = i < frames.Count ? frames[i].Origin : Vector3.Zero;
|
||||
Quaternion orientation = i < frames.Count
|
||||
? frames[i].Orientation
|
||||
: Quaternion.Identity;
|
||||
Vector3 defaultScale = i < owner.Setup.DefaultScale.Count
|
||||
? owner.Setup.DefaultScale[i]
|
||||
: Vector3.One;
|
||||
|
||||
Matrix4x4 visual =
|
||||
Matrix4x4.CreateScale(defaultScale)
|
||||
* Matrix4x4.CreateFromQuaternion(orientation)
|
||||
* Matrix4x4.CreateTranslation(origin);
|
||||
if (owner.Entity.Scale != 1f)
|
||||
visual *= objectScale;
|
||||
|
||||
owner.PartPoses.Add(
|
||||
Matrix4x4.CreateFromQuaternion(orientation)
|
||||
* Matrix4x4.CreateTranslation(origin * owner.Entity.Scale));
|
||||
if (!owner.PartAvailable[i])
|
||||
continue;
|
||||
|
||||
owner.MeshRefs.Add(new MeshRef(owner.PartGfxIds[i], visual)
|
||||
{
|
||||
SurfaceOverrides = owner.SurfaceOverrides[i],
|
||||
});
|
||||
}
|
||||
|
||||
owner.Entity.MeshRefs = owner.MeshRefs;
|
||||
owner.Entity.SetIndexedPartPoses(owner.PartPoses, owner.PartAvailable);
|
||||
}
|
||||
|
||||
private static uint[] BuildPartGfxIds(Setup setup, WorldEntity entity)
|
||||
{
|
||||
var result = new uint[setup.Parts.Count];
|
||||
for (int i = 0; i < result.Length; i++)
|
||||
result[i] = (uint)setup.Parts[i];
|
||||
foreach (PartOverride replacement in entity.PartOverrides)
|
||||
{
|
||||
if (replacement.PartIndex < result.Length)
|
||||
result[replacement.PartIndex] = replacement.GfxObjId;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<uint, uint>?[] MatchSurfaceOverrides(
|
||||
WorldEntity entity,
|
||||
uint[] gfxIds,
|
||||
bool[] available)
|
||||
{
|
||||
var result = new IReadOnlyDictionary<uint, uint>?[gfxIds.Length];
|
||||
var consumed = new bool[entity.MeshRefs.Count];
|
||||
for (int partIndex = 0; partIndex < gfxIds.Length; partIndex++)
|
||||
{
|
||||
for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++)
|
||||
{
|
||||
if (consumed[meshIndex]
|
||||
|| entity.MeshRefs[meshIndex].GfxObjId != gfxIds[partIndex])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
consumed[meshIndex] = true;
|
||||
available[partIndex] = true;
|
||||
result[partIndex] = entity.MeshRefs[meshIndex].SurfaceOverrides;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
90
src/AcDream.App/Rendering/StaticLiveRootCommitter.cs
Normal file
90
src/AcDream.App/Rendering/StaticLiveRootCommitter.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
using AcDream.App.Physics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Commits a changed root produced by retail's
|
||||
/// <c>CPhysicsObj::animate_static_object</c> to the retained effect pose and
|
||||
/// collision-shadow projections of the same live object incarnation.
|
||||
/// </summary>
|
||||
internal sealed class StaticLiveRootCommitter
|
||||
{
|
||||
private readonly Func<LiveEntityRuntime?> _runtime;
|
||||
private readonly ShadowObjectRegistry _shadows;
|
||||
private readonly Func<(int X, int Y)> _liveCenter;
|
||||
private readonly Action<WorldEntity> _updateEffectRoot;
|
||||
|
||||
public StaticLiveRootCommitter(
|
||||
Func<LiveEntityRuntime?> runtime,
|
||||
ShadowObjectRegistry shadows,
|
||||
Func<(int X, int Y)> liveCenter,
|
||||
Action<WorldEntity> updateEffectRoot)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
|
||||
_liveCenter = liveCenter
|
||||
?? throw new ArgumentNullException(nameof(liveCenter));
|
||||
_updateEffectRoot = updateEffectRoot
|
||||
?? throw new ArgumentNullException(nameof(updateEffectRoot));
|
||||
}
|
||||
|
||||
public bool Commit(WorldEntity entity, PhysicsBody body)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
LiveEntityRuntime? runtime = _runtime();
|
||||
if (runtime is null
|
||||
|| !runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord record)
|
||||
|| !ReferenceEquals(record.WorldEntity, entity)
|
||||
|| !ReferenceEquals(record.PhysicsBody, body))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// The retained pose owner follows the CPhysicsObj root even while its
|
||||
// mesh is hidden. Hidden is presentation state, not logical teardown.
|
||||
_updateEffectRoot(entity);
|
||||
|
||||
// EffectPoseChanged observers run synchronously and may delete this
|
||||
// incarnation (or replace the GUID) before returning. Do not let the
|
||||
// stale root restore collision shadows for an owner which no longer
|
||||
// exists. This is the same callback-boundary lifetime rule used by the
|
||||
// live schedulers and movement controllers.
|
||||
if (!ReferenceEquals(_runtime(), runtime)
|
||||
|| !runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, record)
|
||||
|| !ReferenceEquals(current.WorldEntity, entity)
|
||||
|| !ReferenceEquals(current.PhysicsBody, body))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// CObjCell::hide_object removes shadows. Never let a later static
|
||||
// animation tick re-register collision for a hidden, withdrawn, or
|
||||
// pending projection; UnHide/re-entry owns restoration.
|
||||
if (record.ProjectionKind is not LiveEntityProjectionKind.World
|
||||
|| !record.IsSpatiallyProjected
|
||||
|| !record.IsSpatiallyVisible
|
||||
|| runtime.IsHidden(entity.ServerGuid))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
uint cellId = body.CellPosition.ObjCellId != 0
|
||||
? body.CellPosition.ObjCellId
|
||||
: record.FullCellId;
|
||||
(int centerX, int centerY) = _liveCenter();
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
cellId,
|
||||
centerX,
|
||||
centerY);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ public sealed class AnimationHookFrameQueue
|
|||
{
|
||||
private readonly AnimationHookRouter _router;
|
||||
private readonly IEntityEffectPoseSource _poses;
|
||||
private readonly IEntityEffectPoseLifetimeSource? _poseLifetimes;
|
||||
private readonly List<Entry> _entries = new();
|
||||
|
||||
public AnimationHookFrameQueue(
|
||||
|
|
@ -28,6 +29,7 @@ public sealed class AnimationHookFrameQueue
|
|||
{
|
||||
_router = router ?? throw new ArgumentNullException(nameof(router));
|
||||
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
|
||||
_poseLifetimes = poses as IEntityEffectPoseLifetimeSource;
|
||||
}
|
||||
|
||||
public int Count => _entries.Count;
|
||||
|
|
@ -45,6 +47,12 @@ public sealed class AnimationHookFrameQueue
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(sequencer);
|
||||
ArgumentNullException.ThrowIfNull(hooks);
|
||||
// Capture incarnation identity before AnimationDone: its semantic
|
||||
// callback can synchronously delete this owner and reuse the local ID.
|
||||
// Reading the version afterwards would mislabel the old hook batch as
|
||||
// belonging to the replacement.
|
||||
ulong ownerLifetimeVersion =
|
||||
_poseLifetimes?.GetPoseOwnerLifetimeVersion(ownerLocalId) ?? 0UL;
|
||||
|
||||
// Retail CPhysicsObj::process_hooks (0x00511550) executes AnimDone
|
||||
// inside UpdatePositionInternal, before the transition and every
|
||||
|
|
@ -55,12 +63,25 @@ public sealed class AnimationHookFrameQueue
|
|||
// authored hook stream after the final part poses are published.
|
||||
for (int i = 0; i < hooks.Count; i++)
|
||||
{
|
||||
// MotionDone can synchronously delete this owner and reuse the
|
||||
// local ID. Do not advance the displaced sequencer for later
|
||||
// AnimDone hooks from the old catch-up batch.
|
||||
if (_poseLifetimes is not null
|
||||
&& _poseLifetimes.GetPoseOwnerLifetimeVersion(
|
||||
ownerLocalId) != ownerLifetimeVersion)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (hooks[i] is AnimationDoneHook)
|
||||
sequencer.Manager.AnimationDone(success: true);
|
||||
}
|
||||
|
||||
if (hooks.Count == 0)
|
||||
return;
|
||||
|
||||
_entries.Add(new Entry(
|
||||
ownerLocalId,
|
||||
ownerLifetimeVersion,
|
||||
hooks));
|
||||
}
|
||||
|
||||
|
|
@ -69,19 +90,35 @@ public sealed class AnimationHookFrameQueue
|
|||
for (int i = 0; i < _entries.Count; i++)
|
||||
{
|
||||
Entry entry = _entries[i];
|
||||
bool hasRootPose = _poses.TryGetRootPose(
|
||||
entry.OwnerLocalId,
|
||||
out Matrix4x4 rootWorld);
|
||||
Vector3 worldPosition = hasRootPose
|
||||
? rootWorld.Translation
|
||||
: Vector3.Zero;
|
||||
if (_poseLifetimes is not null
|
||||
&& _poseLifetimes.GetPoseOwnerLifetimeVersion(
|
||||
entry.OwnerLocalId) != entry.OwnerLifetimeVersion)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (int hi = 0; hi < entry.Hooks.Count; hi++)
|
||||
{
|
||||
// A prior hook sink can tear down and replace this local ID.
|
||||
// Revalidate per hook so the remainder of the old PES/animation
|
||||
// batch can never spill into the replacement incarnation.
|
||||
if (_poseLifetimes is not null
|
||||
&& _poseLifetimes.GetPoseOwnerLifetimeVersion(
|
||||
entry.OwnerLocalId) != entry.OwnerLifetimeVersion)
|
||||
{
|
||||
break;
|
||||
}
|
||||
AnimationHook? hook = entry.Hooks[hi];
|
||||
if (hook is null)
|
||||
continue;
|
||||
if (hasRootPose)
|
||||
_router.OnHook(entry.OwnerLocalId, worldPosition, hook);
|
||||
if (_poses.TryGetRootPose(
|
||||
entry.OwnerLocalId,
|
||||
out Matrix4x4 rootWorld))
|
||||
{
|
||||
_router.OnHook(
|
||||
entry.OwnerLocalId,
|
||||
rootWorld.Translation,
|
||||
hook);
|
||||
}
|
||||
}
|
||||
}
|
||||
_entries.Clear();
|
||||
|
|
@ -91,5 +128,6 @@ public sealed class AnimationHookFrameQueue
|
|||
|
||||
private readonly record struct Entry(
|
||||
uint OwnerLocalId,
|
||||
ulong OwnerLifetimeVersion,
|
||||
IReadOnlyList<AnimationHook> Hooks);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ namespace AcDream.App.Rendering.Vfx;
|
|||
public sealed class EntityEffectPoseRegistry :
|
||||
IEntityEffectPoseSource,
|
||||
IEntityEffectCellSource,
|
||||
IEntityEffectPoseChangeSource
|
||||
IEntityEffectPoseChangeSource,
|
||||
IEntityEffectPoseLifetimeSource
|
||||
{
|
||||
private sealed class PoseRecord
|
||||
{
|
||||
|
|
@ -26,9 +27,11 @@ public sealed class EntityEffectPoseRegistry :
|
|||
public Matrix4x4[] PartLocal = Array.Empty<Matrix4x4>();
|
||||
public bool[] PartAvailable = Array.Empty<bool>();
|
||||
public uint CellId;
|
||||
public ulong LifetimeVersion;
|
||||
}
|
||||
|
||||
private readonly Dictionary<uint, PoseRecord> _poses = new();
|
||||
private ulong _nextLifetimeVersion;
|
||||
|
||||
public event Action<uint>? EffectPoseChanged;
|
||||
|
||||
|
|
@ -68,7 +71,7 @@ public sealed class EntityEffectPoseRegistry :
|
|||
bool changed;
|
||||
if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
|
||||
{
|
||||
record = new PoseRecord();
|
||||
record = new PoseRecord { LifetimeVersion = NextLifetimeVersion() };
|
||||
_poses.Add(entity.Id, record);
|
||||
changed = true;
|
||||
}
|
||||
|
|
@ -126,7 +129,7 @@ public sealed class EntityEffectPoseRegistry :
|
|||
bool changed;
|
||||
if (!_poses.TryGetValue(localEntityId, out PoseRecord? record))
|
||||
{
|
||||
record = new PoseRecord();
|
||||
record = new PoseRecord { LifetimeVersion = NextLifetimeVersion() };
|
||||
_poses.Add(localEntityId, record);
|
||||
changed = true;
|
||||
}
|
||||
|
|
@ -176,7 +179,9 @@ public sealed class EntityEffectPoseRegistry :
|
|||
uint[] removedOwners = _poses.Keys.ToArray();
|
||||
_poses.Clear();
|
||||
foreach (uint owner in removedOwners)
|
||||
{
|
||||
EffectPoseChanged?.Invoke(owner);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
|
||||
|
|
@ -248,6 +253,19 @@ public sealed class EntityEffectPoseRegistry :
|
|||
return false;
|
||||
}
|
||||
|
||||
public ulong GetPoseOwnerLifetimeVersion(uint localEntityId) =>
|
||||
_poses.TryGetValue(localEntityId, out PoseRecord? record)
|
||||
? record.LifetimeVersion
|
||||
: 0UL;
|
||||
|
||||
private ulong NextLifetimeVersion()
|
||||
{
|
||||
_nextLifetimeVersion++;
|
||||
if (_nextLifetimeVersion == 0UL)
|
||||
_nextLifetimeVersion++;
|
||||
return _nextLifetimeVersion;
|
||||
}
|
||||
|
||||
private static bool CopyParts(
|
||||
PoseRecord record,
|
||||
IReadOnlyList<Matrix4x4> partLocal,
|
||||
|
|
@ -277,3 +295,8 @@ public sealed class EntityEffectPoseRegistry :
|
|||
return changed;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IEntityEffectPoseLifetimeSource
|
||||
{
|
||||
ulong GetPoseOwnerLifetimeVersion(uint localEntityId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ public sealed record ScriptActivationInfo(
|
|||
uint ScriptId,
|
||||
IReadOnlyList<Matrix4x4> PartTransforms,
|
||||
EntityEffectProfile? EffectProfile = null,
|
||||
IReadOnlyList<bool>? PartAvailability = null);
|
||||
IReadOnlyList<bool>? PartAvailability = null,
|
||||
DatReaderWriter.DBObjs.Setup? Setup = null,
|
||||
uint DefaultAnimationId = 0,
|
||||
bool UsesStaticAnimationWorkset = false);
|
||||
|
||||
/// <summary>
|
||||
/// Owns create-time Setup script activation and its symmetric teardown for
|
||||
|
|
@ -25,13 +28,29 @@ public sealed record ScriptActivationInfo(
|
|||
/// </remarks>
|
||||
public sealed class EntityScriptActivator
|
||||
{
|
||||
private sealed class StaticOwnerState
|
||||
{
|
||||
public required WorldEntity Entity;
|
||||
public required ScriptActivationInfo Info;
|
||||
public bool PosePublished;
|
||||
public bool EffectOwnerRegistered;
|
||||
public bool AnimationOwnerRegistered;
|
||||
public bool ScriptStarted;
|
||||
public bool ReleaseStarted;
|
||||
public bool ScriptsStopped;
|
||||
public bool EmittersStopped;
|
||||
public bool PoseRemoved;
|
||||
}
|
||||
|
||||
private readonly PhysicsScriptRunner _scriptRunner;
|
||||
private readonly ParticleHookSink _particleSink;
|
||||
private readonly EntityEffectPoseRegistry _poses;
|
||||
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
|
||||
private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner;
|
||||
private readonly Action<uint>? _unregisterDatStaticEffectOwner;
|
||||
private readonly Dictionary<uint, WorldEntity> _activeStaticOwners = new();
|
||||
private readonly Action<WorldEntity, ScriptActivationInfo>? _registerDatStaticAnimationOwner;
|
||||
private readonly Action<uint>? _unregisterDatStaticAnimationOwner;
|
||||
private readonly Dictionary<uint, StaticOwnerState> _staticOwners = new();
|
||||
|
||||
public EntityScriptActivator(
|
||||
PhysicsScriptRunner scriptRunner,
|
||||
|
|
@ -39,7 +58,9 @@ public sealed class EntityScriptActivator
|
|||
EntityEffectPoseRegistry poses,
|
||||
Func<WorldEntity, ScriptActivationInfo?> resolver,
|
||||
Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null,
|
||||
Action<uint>? unregisterDatStaticEffectOwner = null)
|
||||
Action<uint>? unregisterDatStaticEffectOwner = null,
|
||||
Action<WorldEntity, ScriptActivationInfo>? registerDatStaticAnimationOwner = null,
|
||||
Action<uint>? unregisterDatStaticAnimationOwner = null)
|
||||
{
|
||||
_scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner));
|
||||
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
|
||||
|
|
@ -47,6 +68,8 @@ public sealed class EntityScriptActivator
|
|||
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
|
||||
_registerDatStaticEffectOwner = registerDatStaticEffectOwner;
|
||||
_unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner;
|
||||
_registerDatStaticAnimationOwner = registerDatStaticAnimationOwner;
|
||||
_unregisterDatStaticAnimationOwner = unregisterDatStaticAnimationOwner;
|
||||
}
|
||||
|
||||
public void OnCreate(WorldEntity entity)
|
||||
|
|
@ -57,33 +80,93 @@ public sealed class EntityScriptActivator
|
|||
return;
|
||||
|
||||
if (entity.ServerGuid == 0
|
||||
&& _activeStaticOwners.TryGetValue(key, out WorldEntity? existing))
|
||||
&& _staticOwners.TryGetValue(key, out StaticOwnerState? retained))
|
||||
{
|
||||
if (ReferenceEquals(existing, entity))
|
||||
return;
|
||||
throw new InvalidOperationException(
|
||||
$"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique.");
|
||||
if (!ReferenceEquals(retained.Entity, entity))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique.");
|
||||
}
|
||||
if (retained.ReleaseStarted)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Dat-static WorldEntity id 0x{key:X8} cannot reactivate while teardown is pending.");
|
||||
}
|
||||
|
||||
ActivateStaticOwner(retained);
|
||||
return;
|
||||
}
|
||||
|
||||
ScriptActivationInfo? info = _resolver(entity);
|
||||
if (info is null)
|
||||
return;
|
||||
|
||||
// Particle::Init (0x0051C930) samples the current root/part frames.
|
||||
// Publish before the Setup default script can execute; effects never
|
||||
// fall back to the camera or a cached spawn anchor.
|
||||
_poses.Publish(entity, info.PartTransforms, info.PartAvailability);
|
||||
|
||||
if (entity.ServerGuid == 0)
|
||||
_activeStaticOwners.Add(key, entity);
|
||||
|
||||
if (entity.ServerGuid == 0 && info.EffectProfile is { } profile)
|
||||
_registerDatStaticEffectOwner?.Invoke(key, entity, profile);
|
||||
{
|
||||
var state = new StaticOwnerState
|
||||
{
|
||||
Entity = entity,
|
||||
Info = info,
|
||||
};
|
||||
_staticOwners.Add(key, state);
|
||||
ActivateStaticOwner(state);
|
||||
return;
|
||||
}
|
||||
|
||||
// Live registration is an atomic logical-lifetime edge owned by
|
||||
// LiveEntityRuntime and is never retried at this activator in isolation.
|
||||
_poses.Publish(entity, info.PartTransforms, info.PartAvailability);
|
||||
if (info.UsesStaticAnimationWorkset
|
||||
&& info.DefaultAnimationId != 0)
|
||||
{
|
||||
_registerDatStaticAnimationOwner?.Invoke(entity, info);
|
||||
}
|
||||
if (info.ScriptId != 0)
|
||||
_scriptRunner.Play(info.ScriptId, key, entity.Position);
|
||||
}
|
||||
|
||||
private void ActivateStaticOwner(StaticOwnerState state)
|
||||
{
|
||||
WorldEntity entity = state.Entity;
|
||||
ScriptActivationInfo info = state.Info;
|
||||
uint key = entity.Id;
|
||||
|
||||
// Particle::Init (0x0051C930) samples the current root/part frames.
|
||||
// Commit each acquisition only after its callback returns. A later
|
||||
// failure can then retry without replaying completed create-time work.
|
||||
if (!state.PosePublished)
|
||||
{
|
||||
_poses.Publish(entity, info.PartTransforms, info.PartAvailability);
|
||||
state.PosePublished = true;
|
||||
}
|
||||
|
||||
if (!state.EffectOwnerRegistered
|
||||
&& info.EffectProfile is { } profile
|
||||
&& _registerDatStaticEffectOwner is not null)
|
||||
{
|
||||
_registerDatStaticEffectOwner(key, entity, profile);
|
||||
state.EffectOwnerRegistered = true;
|
||||
}
|
||||
|
||||
// TS-51: script-only statics still use the shared script runner. The
|
||||
// dedicated static animation scheduler owns only real DefaultAnimation
|
||||
// PartArray work, so script-only owners incur no empty frame scan.
|
||||
if (!state.AnimationOwnerRegistered
|
||||
&& info.UsesStaticAnimationWorkset
|
||||
&& info.DefaultAnimationId != 0
|
||||
&& _registerDatStaticAnimationOwner is not null)
|
||||
{
|
||||
_registerDatStaticAnimationOwner(entity, info);
|
||||
state.AnimationOwnerRegistered = true;
|
||||
}
|
||||
|
||||
if (!state.ScriptStarted && info.ScriptId != 0)
|
||||
{
|
||||
_scriptRunner.Play(info.ScriptId, key, entity.Position);
|
||||
state.ScriptStarted = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnRemove(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
|
|
@ -91,17 +174,48 @@ public sealed class EntityScriptActivator
|
|||
if (key == 0)
|
||||
return;
|
||||
|
||||
if (entity.ServerGuid == 0
|
||||
&& (!_activeStaticOwners.TryGetValue(key, out WorldEntity? existing)
|
||||
|| !ReferenceEquals(existing, entity)))
|
||||
if (entity.ServerGuid == 0)
|
||||
{
|
||||
if (!_staticOwners.TryGetValue(key, out StaticOwnerState? state)
|
||||
|| !ReferenceEquals(state.Entity, entity))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
state.ReleaseStarted = true;
|
||||
if (!state.ScriptsStopped)
|
||||
{
|
||||
_scriptRunner.StopAllForEntity(key);
|
||||
state.ScriptsStopped = true;
|
||||
}
|
||||
if (!state.EmittersStopped)
|
||||
{
|
||||
_particleSink.StopAllForEntity(key, fadeOut: false);
|
||||
state.EmittersStopped = true;
|
||||
}
|
||||
if (!state.PoseRemoved)
|
||||
{
|
||||
_poses.Remove(key);
|
||||
state.PoseRemoved = true;
|
||||
}
|
||||
if (state.AnimationOwnerRegistered)
|
||||
{
|
||||
_unregisterDatStaticAnimationOwner?.Invoke(key);
|
||||
state.AnimationOwnerRegistered = false;
|
||||
}
|
||||
if (state.EffectOwnerRegistered)
|
||||
{
|
||||
_unregisterDatStaticEffectOwner?.Invoke(key);
|
||||
state.EffectOwnerRegistered = false;
|
||||
}
|
||||
|
||||
_staticOwners.Remove(key);
|
||||
return;
|
||||
}
|
||||
|
||||
_unregisterDatStaticAnimationOwner?.Invoke(key);
|
||||
_scriptRunner.StopAllForEntity(key);
|
||||
_particleSink.StopAllForEntity(key, fadeOut: false);
|
||||
_poses.Remove(key);
|
||||
if (entity.ServerGuid == 0 && _activeStaticOwners.Remove(key))
|
||||
_unregisterDatStaticEffectOwner?.Invoke(key);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue