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