fix(animation): retain trailing PartArray poses

Port CPartArray::UpdateParts minimum-count behavior: preserve the prior visual and rigid transforms for setup parts absent from a short authored AnimFrame, including the initial hydrated rest pose.

Co-Authored-By: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-21 09:41:15 +02:00
parent f004ac5562
commit a2fd61684a
6 changed files with 326 additions and 59 deletions

View file

@ -194,6 +194,29 @@ InterpolationManager.adjust_offset(delta, dt):
relative.frame.set_heading(0) relative.frame.set_heading(0)
delta = relative.frame // replaces both origin and orientation delta = relative.frame // replaces both origin and orientation
## Part-count retention (`CPartArray::UpdateParts` @ `0x005190F0`)
```text
count = min(partArray.num_parts, animationFrame.num_parts)
for i in 0 .. count - 1:
part[i].frame = combine_scaled(animationFrame.part[i], partArray.scale)
// No loop touches [count .. partArray.num_parts).
// Those CPhysicsPart frames therefore retain their complete prior state.
```
The retained state has two presentation channels in acdream:
- the visible mesh transform retains Setup `DefaultScale`, animated frame, and
object `ObjScale`;
- the indexed rigid/effect pose retains animated orientation and the
ObjScale-adjusted origin, without Setup `DefaultScale`.
On the first presentation, "prior" means the already-hydrated Setup/rest
frames installed by PartArray construction. A short later AnimFrame retains
the most recently presented transform in both channels. Resetting missing
parts to origin/identity is not retail behavior.
CPhysicsObj.SetPositionInternal(transition): CPhysicsObj.SetPositionInternal(transition):
if transition.current_cell is null: if transition.current_cell is null:
prepare_to_leave_visibility() prepare_to_leave_visibility()

View file

@ -181,29 +181,36 @@ internal sealed class LiveEntityAnimationPresenter
{ {
int partCount = animation.PartTemplate.Count; int partCount = animation.PartTemplate.Count;
EmitPartDiagnostics(record, animation, sequenceFrames, partCount); EmitPartDiagnostics(record, animation, sequenceFrames, partCount);
EnsureRetainedPoses(animation);
List<MeshRef> meshRefs = animation.MeshRefsScratch; List<MeshRef> meshRefs = animation.MeshRefsScratch;
meshRefs.Clear(); meshRefs.Clear();
List<Matrix4x4> rigidPoses = animation.EffectPartPosesScratch; List<Matrix4x4> rigidPoses = animation.EffectPartPosesScratch;
rigidPoses.Clear(); List<Matrix4x4> visualPoses = animation.VisualPartPosesScratch;
Matrix4x4 scale = animation.Scale == 1f Matrix4x4 scale = animation.Scale == 1f
? Matrix4x4.Identity ? Matrix4x4.Identity
: Matrix4x4.CreateScale(animation.Scale); : Matrix4x4.CreateScale(animation.Scale);
for (int i = 0; i < partCount; i++) for (int i = 0; i < partCount; i++)
{ {
ResolvePartFrame(animation, sequenceFrames, i, out Vector3 origin, out Quaternion orientation); if (TryResolvePartFrame(
Vector3 defaultScale = i < animation.Setup.DefaultScale.Count animation,
? animation.Setup.DefaultScale[i] sequenceFrames,
: Vector3.One; i,
Matrix4x4 visual = Matrix4x4.CreateScale(defaultScale) out Vector3 origin,
* Matrix4x4.CreateFromQuaternion(orientation) out Quaternion orientation))
* Matrix4x4.CreateTranslation(origin); {
if (animation.Scale != 1f) Vector3 defaultScale = i < animation.Setup.DefaultScale.Count
visual *= scale; ? animation.Setup.DefaultScale[i]
: Vector3.One;
Matrix4x4 rigid = Matrix4x4.CreateFromQuaternion(orientation) Matrix4x4 visual = Matrix4x4.CreateScale(defaultScale)
* Matrix4x4.CreateTranslation(origin * animation.Scale); * Matrix4x4.CreateFromQuaternion(orientation)
rigidPoses.Add(rigid); * Matrix4x4.CreateTranslation(origin);
if (animation.Scale != 1f)
visual *= scale;
visualPoses[i] = visual;
rigidPoses[i] = Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(origin * animation.Scale);
}
LiveAnimationPartTemplate template = animation.PartTemplate[i]; LiveAnimationPartTemplate template = animation.PartTemplate[i];
if (!template.IsDrawable if (!template.IsDrawable
@ -211,7 +218,7 @@ internal sealed class LiveEntityAnimationPresenter
{ {
continue; continue;
} }
meshRefs.Add(new MeshRef(template.GfxObjId, visual) meshRefs.Add(new MeshRef(template.GfxObjId, visualPoses[i])
{ {
SurfaceOverrides = template.SurfaceOverrides, SurfaceOverrides = template.SurfaceOverrides,
}); });
@ -226,7 +233,7 @@ internal sealed class LiveEntityAnimationPresenter
_effectPoses.Publish(entity, rigidPoses, animation.PartAvailability); _effectPoses.Publish(entity, rigidPoses, animation.PartAvailability);
} }
private static void ResolvePartFrame( private static bool TryResolvePartFrame(
LiveEntityAnimationState animation, LiveEntityAnimationState animation,
IReadOnlyList<PartTransform>? sequenceFrames, IReadOnlyList<PartTransform>? sequenceFrames,
int partIndex, int partIndex,
@ -239,13 +246,11 @@ internal sealed class LiveEntityAnimationPresenter
{ {
origin = sequenceFrames[partIndex].Origin; origin = sequenceFrames[partIndex].Origin;
orientation = sequenceFrames[partIndex].Orientation; orientation = sequenceFrames[partIndex].Orientation;
return true;
} }
else origin = default;
{ orientation = default;
origin = Vector3.Zero; return false;
orientation = Quaternion.Identity;
}
return;
} }
int frameIndex = (int)Math.Floor(animation.CurrFrame); int frameIndex = (int)Math.Floor(animation.CurrFrame);
@ -270,12 +275,59 @@ internal sealed class LiveEntityAnimationPresenter
var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first; var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first;
origin = Vector3.Lerp(first.Origin, next.Origin, t); origin = Vector3.Lerp(first.Origin, next.Origin, t);
orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t); orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t);
return true;
} }
else origin = default;
orientation = default;
return false;
}
private static void EnsureRetainedPoses(LiveEntityAnimationState animation)
{
int partCount = animation.PartTemplate.Count;
if (animation.PresentationPosesInitialized
&& animation.VisualPartPosesScratch.Count == partCount
&& animation.EffectPartPosesScratch.Count == partCount)
{ {
origin = Vector3.Zero; return;
orientation = Quaternion.Identity;
} }
List<Matrix4x4> visual = animation.VisualPartPosesScratch;
List<Matrix4x4> rigid = animation.EffectPartPosesScratch;
visual.Clear();
rigid.Clear();
bool[] consumed = new bool[animation.Entity.MeshRefs.Count];
for (int i = 0; i < partCount; i++)
{
Matrix4x4 rigidRest = i < animation.Entity.IndexedPartTransforms.Count
? animation.Entity.IndexedPartTransforms[i]
: Matrix4x4.Identity;
rigid.Add(rigidRest);
LiveAnimationPartTemplate template = animation.PartTemplate[i];
Matrix4x4 visualRest = default;
bool found = false;
for (int meshIndex = 0; meshIndex < animation.Entity.MeshRefs.Count; meshIndex++)
{
MeshRef mesh = animation.Entity.MeshRefs[meshIndex];
if (consumed[meshIndex] || mesh.GfxObjId != template.GfxObjId)
continue;
consumed[meshIndex] = true;
visualRest = mesh.PartTransform;
found = true;
break;
}
if (!found)
{
Vector3 defaultScale = i < animation.Setup.DefaultScale.Count
? animation.Setup.DefaultScale[i]
: Vector3.One;
visualRest = Matrix4x4.CreateScale(defaultScale * animation.Scale)
* rigidRest;
}
visual.Add(visualRest);
}
animation.PresentationPosesInitialized = true;
} }
private void EmitSequenceDiagnostics( private void EmitSequenceDiagnostics(

View file

@ -39,6 +39,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
public readonly Frame RootFrameScratch = new(); public readonly Frame RootFrameScratch = new();
public readonly List<MeshRef> MeshRefs = new(); public readonly List<MeshRef> MeshRefs = new();
public readonly List<Matrix4x4> PartPoses = new(); public readonly List<Matrix4x4> PartPoses = new();
public readonly List<Matrix4x4> VisualPartPoses = new();
} }
private readonly IAnimationLoader _animationLoader; private readonly IAnimationLoader _animationLoader;
@ -389,8 +390,8 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
private static void Compose(Owner owner, IReadOnlyList<PartTransform> frames) private static void Compose(Owner owner, IReadOnlyList<PartTransform> frames)
{ {
EnsureRetainedPoses(owner);
owner.MeshRefs.Clear(); owner.MeshRefs.Clear();
owner.PartPoses.Clear();
Matrix4x4 objectScale = owner.Entity.Scale == 1f Matrix4x4 objectScale = owner.Entity.Scale == 1f
? Matrix4x4.Identity ? Matrix4x4.Identity
: Matrix4x4.CreateScale(owner.Entity.Scale); : Matrix4x4.CreateScale(owner.Entity.Scale);
@ -398,28 +399,27 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
int partCount = owner.Setup.Parts.Count; int partCount = owner.Setup.Parts.Count;
for (int i = 0; i < partCount; i++) for (int i = 0; i < partCount; i++)
{ {
Vector3 origin = i < frames.Count ? frames[i].Origin : Vector3.Zero; if (i < frames.Count)
Quaternion orientation = i < frames.Count {
? frames[i].Orientation Vector3 origin = frames[i].Origin;
: Quaternion.Identity; Quaternion orientation = frames[i].Orientation;
Vector3 defaultScale = i < owner.Setup.DefaultScale.Count Vector3 defaultScale = i < owner.Setup.DefaultScale.Count
? owner.Setup.DefaultScale[i] ? owner.Setup.DefaultScale[i]
: Vector3.One; : Vector3.One;
Matrix4x4 visual = Matrix4x4 visual = Matrix4x4.CreateScale(defaultScale)
Matrix4x4.CreateScale(defaultScale) * Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateFromQuaternion(orientation) * Matrix4x4.CreateTranslation(origin);
* Matrix4x4.CreateTranslation(origin); if (owner.Entity.Scale != 1f)
if (owner.Entity.Scale != 1f) visual *= objectScale;
visual *= objectScale; owner.VisualPartPoses[i] = visual;
owner.PartPoses[i] = Matrix4x4.CreateFromQuaternion(orientation)
owner.PartPoses.Add( * Matrix4x4.CreateTranslation(origin * owner.Entity.Scale);
Matrix4x4.CreateFromQuaternion(orientation) }
* Matrix4x4.CreateTranslation(origin * owner.Entity.Scale));
if (!owner.PartAvailable[i]) if (!owner.PartAvailable[i])
continue; continue;
owner.MeshRefs.Add(new MeshRef(owner.PartGfxIds[i], visual) owner.MeshRefs.Add(new MeshRef(owner.PartGfxIds[i], owner.VisualPartPoses[i])
{ {
SurfaceOverrides = owner.SurfaceOverrides[i], SurfaceOverrides = owner.SurfaceOverrides[i],
}); });
@ -429,6 +429,49 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
owner.Entity.SetIndexedPartPoses(owner.PartPoses, owner.PartAvailable); owner.Entity.SetIndexedPartPoses(owner.PartPoses, owner.PartAvailable);
} }
private static void EnsureRetainedPoses(Owner owner)
{
int partCount = owner.Setup.Parts.Count;
if (owner.VisualPartPoses.Count == partCount
&& owner.PartPoses.Count == partCount)
{
return;
}
owner.VisualPartPoses.Clear();
owner.PartPoses.Clear();
bool[] consumed = new bool[owner.Entity.MeshRefs.Count];
for (int i = 0; i < partCount; i++)
{
Matrix4x4 rigid = i < owner.Entity.IndexedPartTransforms.Count
? owner.Entity.IndexedPartTransforms[i]
: Matrix4x4.Identity;
owner.PartPoses.Add(rigid);
Matrix4x4 visual = default;
bool found = false;
for (int meshIndex = 0; meshIndex < owner.Entity.MeshRefs.Count; meshIndex++)
{
MeshRef mesh = owner.Entity.MeshRefs[meshIndex];
if (consumed[meshIndex] || mesh.GfxObjId != owner.PartGfxIds[i])
continue;
consumed[meshIndex] = true;
visual = mesh.PartTransform;
found = true;
break;
}
if (!found)
{
Vector3 defaultScale = i < owner.Setup.DefaultScale.Count
? owner.Setup.DefaultScale[i]
: Vector3.One;
visual = Matrix4x4.CreateScale(defaultScale * owner.Entity.Scale)
* rigid;
}
owner.VisualPartPoses.Add(visual);
}
}
private static uint[] BuildPartGfxIds(Setup setup, WorldEntity entity) private static uint[] BuildPartGfxIds(Setup setup, WorldEntity entity)
{ {
var result = new uint[setup.Parts.Count]; var result = new uint[setup.Parts.Count];

View file

@ -253,6 +253,7 @@ public sealed class AnimationSequencer
// consume the returned IReadOnlyList<PartTransform> synchronously within // consume the returned IReadOnlyList<PartTransform> synchronously within
// the same loop iteration and never cache it across Advance() calls. // the same loop iteration and never cache it across Advance() calls.
private readonly PartTransform[] _partTransformScratch; private readonly PartTransform[] _partTransformScratch;
private readonly PartTransformBuffer _partTransformView;
// R1-P6: root motion flows through the Advance(dt, Frame) overload // R1-P6: root motion flows through the Advance(dt, Frame) overload
// (retail CSequence::update(Frame*), 0x00525b80) — the old adapter // (retail CSequence::update(Frame*), 0x00525b80) — the old adapter
@ -279,6 +280,7 @@ public sealed class AnimationSequencer
_mtable = motionTable; _mtable = motionTable;
_loader = loader; _loader = loader;
_partTransformScratch = new PartTransform[_setup.Parts.Count]; _partTransformScratch = new PartTransform[_setup.Parts.Count];
_partTransformView = new PartTransformBuffer(_partTransformScratch);
_core = new CSequence(loader); _core = new CSequence(loader);
_core.HookObj = new AdapterHookQueue(this); _core.HookObj = new AdapterHookQueue(this);
_table = new CMotionTable(motionTable); _table = new CMotionTable(motionTable);
@ -714,25 +716,20 @@ public sealed class AnimationSequencer
// instead of allocating a fresh PartTransform[partCount] every call. // instead of allocating a fresh PartTransform[partCount] every call.
// Sized once in the constructor to _setup.Parts.Count, which never // Sized once in the constructor to _setup.Parts.Count, which never
// changes, so partCount here always matches the buffer length. // changes, so partCount here always matches the buffer length.
int authoredPartCount = Math.Min(partCount, f0Parts.Count);
var result = _partTransformScratch; var result = _partTransformScratch;
for (int i = 0; i < partCount; i++) for (int i = 0; i < authoredPartCount; i++)
{ {
if (i < f0Parts.Count) var p0 = f0Parts[i];
{ var p1 = i < f1Parts.Count ? f1Parts[i] : p0;
var p0 = f0Parts[i];
var p1 = i < f1Parts.Count ? f1Parts[i] : p0;
result[i] = new PartTransform( result[i] = new PartTransform(
Vector3.Lerp(p0.Origin, p1.Origin, t), Vector3.Lerp(p0.Origin, p1.Origin, t),
SlerpRetailClient(p0.Orientation, p1.Orientation, t)); SlerpRetailClient(p0.Orientation, p1.Orientation, t));
}
else
{
result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity);
}
} }
return result; _partTransformView.SetCount(authoredPartCount);
return _partTransformView;
} }
private IReadOnlyList<PartTransform> BuildIdentityFrame(int partCount) private IReadOnlyList<PartTransform> BuildIdentityFrame(int partCount)
@ -742,7 +739,36 @@ public sealed class AnimationSequencer
var result = _partTransformScratch; var result = _partTransformScratch;
for (int i = 0; i < partCount; i++) for (int i = 0; i < partCount; i++)
result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity); result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity);
return result; _partTransformView.SetCount(partCount);
return _partTransformView;
}
/// <summary>
/// Allocation-free view whose Count preserves the authored AnimFrame part
/// count. Retail UpdateParts touches only that prefix and retains every
/// trailing CPhysicsPart pose.
/// </summary>
private sealed class PartTransformBuffer : IReadOnlyList<PartTransform>
{
private readonly PartTransform[] _items;
public PartTransformBuffer(PartTransform[] items) => _items = items;
public int Count { get; private set; }
public PartTransform this[int index] => index >= 0 && index < Count
? _items[index]
: throw new ArgumentOutOfRangeException(nameof(index));
public void SetCount(int count) => Count = count;
public IEnumerator<PartTransform> GetEnumerator()
{
for (int i = 0; i < Count; i++)
yield return _items[i];
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() =>
GetEnumerator();
} }
// R2-Q4: IsLocomotionCycleLowByte DELETED with Fix B - the cyclic-to- // R2-Q4: IsLocomotionCycleLowByte DELETED with Fix B - the cyclic-to-

View file

@ -73,6 +73,90 @@ public sealed class LiveEntityAnimationPresenterTests
Assert.Empty(replacement.EffectPartPosesScratch); Assert.Empty(replacement.EffectPartPosesScratch);
} }
[Fact]
public void ShortFirstFrame_RetainsDistinctVisualAndRigidRestPoses()
{
var fixture = Build(partCount: 2, scale: 2f);
Matrix4x4 visualRest = Matrix4x4.CreateScale(7f)
* Matrix4x4.CreateTranslation(8f, 9f, 10f);
Matrix4x4 rigidRest = Matrix4x4.CreateTranslation(11f, 12f, 13f);
fixture.Entity.MeshRefs =
[
new MeshRef(0x01000001u, Matrix4x4.Identity),
new MeshRef(0x01000002u, visualRest),
];
fixture.Entity.SetIndexedPartPoses(
[Matrix4x4.Identity, rigidRest],
[true, true]);
var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), new Context());
presenter.Present(Schedules(fixture,
[new PartTransform(Vector3.UnitX, Quaternion.Identity)]));
Assert.Equal(visualRest, fixture.Entity.MeshRefs[1].PartTransform);
Assert.Equal(rigidRest, fixture.Entity.IndexedPartTransforms[1]);
}
[Fact]
public void LaterShortFrame_RetainsPreviouslyPresentedTrailingPart()
{
var fixture = Build(partCount: 2);
var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), new Context());
presenter.Present(Schedules(fixture,
[
new PartTransform(Vector3.UnitX, Quaternion.Identity),
new PartTransform(new Vector3(4f, 5f, 6f), Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f)),
]));
Matrix4x4 visual = fixture.Entity.MeshRefs[1].PartTransform;
Matrix4x4 rigid = fixture.Entity.IndexedPartTransforms[1];
presenter.Present(Schedules(fixture,
[new PartTransform(Vector3.UnitY, Quaternion.Identity)]));
Assert.Equal(visual, fixture.Entity.MeshRefs[1].PartTransform);
Assert.Equal(rigid, fixture.Entity.IndexedPartTransforms[1]);
}
[Fact]
public void AppearanceShrink_ClearsAllTrailingPresentationState()
{
var fixture = Build(partCount: 3);
var poses = new EntityEffectPoseRegistry();
var presenter = Presenter(fixture.Live, poses, new Context());
presenter.Present(Schedules(fixture,
[
new PartTransform(Vector3.UnitX, Quaternion.Identity),
new PartTransform(Vector3.UnitY, Quaternion.Identity),
new PartTransform(Vector3.UnitZ, Quaternion.Identity),
]));
var replacementSetup = new Setup();
replacementSetup.Parts.Add(0x01000009u);
replacementSetup.DefaultScale.Add(Vector3.One);
fixture.Entity.MeshRefs = [new MeshRef(0x01000009u, Matrix4x4.Identity)];
fixture.Entity.SetIndexedPartPoses([Matrix4x4.Identity], [true]);
GameWindow.RebindAnimatedEntityForAppearance(
fixture.State,
fixture.Entity,
replacementSetup,
1f,
[new LiveAnimationPartTemplate(0x01000009u, null, true)],
[true]);
presenter.Present(Schedules(fixture,
[new PartTransform(new Vector3(9f, 0f, 0f), Quaternion.Identity)]));
Assert.Single(fixture.Entity.MeshRefs);
Assert.Single(fixture.Entity.IndexedPartTransforms);
Assert.Single(fixture.State.VisualPartPosesScratch);
Assert.Single(fixture.State.EffectPartPosesScratch);
Assert.True(poses.TryGetPartPoseSnapshot(
fixture.Entity.Id,
out IReadOnlyList<Matrix4x4> published,
out IReadOnlyList<bool> available));
Assert.Single(published);
Assert.Single(available);
}
[Fact] [Fact]
public void MotionDone_OldComponentCannotResolveReplacementByGuid() public void MotionDone_OldComponentCannotResolveReplacementByGuid()
{ {

View file

@ -61,6 +61,45 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
Assert.Equal(2, posePublishes); Assert.Equal(2, posePublishes);
} }
[Fact]
public void ShortStaticAnimFrame_RetainsCompleteTrailingPartState()
{
const uint trailingGfx = GfxId;
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => { },
(_, _, _) => { });
Setup setup = MakeSetup();
setup.Parts.Add(trailingGfx);
setup.DefaultScale.Add(new Vector3(3f, 4f, 5f));
Matrix4x4 visualRest = Matrix4x4.CreateScale(6f)
* Matrix4x4.CreateTranslation(7f, 8f, 9f);
Matrix4x4 rigidRest = Matrix4x4.CreateTranslation(10f, 11f, 12f);
WorldEntity entity = MakeEntity();
entity.MeshRefs =
[
entity.MeshRefs[0],
new MeshRef(trailingGfx, visualRest),
];
entity.SetIndexedPartPoses(
[Matrix4x4.Identity, rigidRest],
[true, true]);
scheduler.Register(entity, new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: entity.IndexedPartTransforms,
PartAvailability: entity.IndexedPartAvailable,
Setup: setup,
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true));
scheduler.Tick(1f / 60f);
Assert.Equal(visualRest, entity.MeshRefs[1].PartTransform);
Assert.Equal(rigidRest, entity.IndexedPartTransforms[1]);
}
[Fact] [Fact]
public void SubEpsilonElapsed_IsDiscardedInsteadOfAccumulated() public void SubEpsilonElapsed_IsDiscardedInsteadOfAccumulated()
{ {