diff --git a/docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md b/docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md index b81635fc..2d3116dd 100644 --- a/docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md +++ b/docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md @@ -194,6 +194,29 @@ InterpolationManager.adjust_offset(delta, dt): relative.frame.set_heading(0) 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): if transition.current_cell is null: prepare_to_leave_visibility() diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs index e5673aea..5725c65c 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs @@ -181,29 +181,36 @@ internal sealed class LiveEntityAnimationPresenter { int partCount = animation.PartTemplate.Count; EmitPartDiagnostics(record, animation, sequenceFrames, partCount); + EnsureRetainedPoses(animation); List meshRefs = animation.MeshRefsScratch; meshRefs.Clear(); List rigidPoses = animation.EffectPartPosesScratch; - rigidPoses.Clear(); + List visualPoses = animation.VisualPartPosesScratch; Matrix4x4 scale = animation.Scale == 1f ? Matrix4x4.Identity : Matrix4x4.CreateScale(animation.Scale); for (int i = 0; i < partCount; i++) { - ResolvePartFrame(animation, sequenceFrames, i, out Vector3 origin, out Quaternion orientation); - Vector3 defaultScale = i < animation.Setup.DefaultScale.Count - ? animation.Setup.DefaultScale[i] - : Vector3.One; - Matrix4x4 visual = Matrix4x4.CreateScale(defaultScale) - * Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(origin); - if (animation.Scale != 1f) - visual *= scale; - - Matrix4x4 rigid = Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(origin * animation.Scale); - rigidPoses.Add(rigid); + if (TryResolvePartFrame( + animation, + sequenceFrames, + i, + out Vector3 origin, + out Quaternion orientation)) + { + Vector3 defaultScale = i < animation.Setup.DefaultScale.Count + ? animation.Setup.DefaultScale[i] + : Vector3.One; + Matrix4x4 visual = Matrix4x4.CreateScale(defaultScale) + * Matrix4x4.CreateFromQuaternion(orientation) + * 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]; if (!template.IsDrawable @@ -211,7 +218,7 @@ internal sealed class LiveEntityAnimationPresenter { continue; } - meshRefs.Add(new MeshRef(template.GfxObjId, visual) + meshRefs.Add(new MeshRef(template.GfxObjId, visualPoses[i]) { SurfaceOverrides = template.SurfaceOverrides, }); @@ -226,7 +233,7 @@ internal sealed class LiveEntityAnimationPresenter _effectPoses.Publish(entity, rigidPoses, animation.PartAvailability); } - private static void ResolvePartFrame( + private static bool TryResolvePartFrame( LiveEntityAnimationState animation, IReadOnlyList? sequenceFrames, int partIndex, @@ -239,13 +246,11 @@ internal sealed class LiveEntityAnimationPresenter { origin = sequenceFrames[partIndex].Origin; orientation = sequenceFrames[partIndex].Orientation; + return true; } - else - { - origin = Vector3.Zero; - orientation = Quaternion.Identity; - } - return; + origin = default; + orientation = default; + return false; } int frameIndex = (int)Math.Floor(animation.CurrFrame); @@ -270,12 +275,59 @@ internal sealed class LiveEntityAnimationPresenter var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first; origin = Vector3.Lerp(first.Origin, next.Origin, 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; - orientation = Quaternion.Identity; + return; } + + List visual = animation.VisualPartPosesScratch; + List 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( diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs index 89367ae6..7f7cb659 100644 --- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -39,6 +39,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram public readonly Frame RootFrameScratch = new(); public readonly List MeshRefs = new(); public readonly List PartPoses = new(); + public readonly List VisualPartPoses = new(); } private readonly IAnimationLoader _animationLoader; @@ -389,8 +390,8 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram private static void Compose(Owner owner, IReadOnlyList frames) { + EnsureRetainedPoses(owner); owner.MeshRefs.Clear(); - owner.PartPoses.Clear(); Matrix4x4 objectScale = owner.Entity.Scale == 1f ? Matrix4x4.Identity : Matrix4x4.CreateScale(owner.Entity.Scale); @@ -398,28 +399,27 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram 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; + if (i < frames.Count) + { + Vector3 origin = frames[i].Origin; + Quaternion orientation = frames[i].Orientation; + 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)); + Matrix4x4 visual = Matrix4x4.CreateScale(defaultScale) + * Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin); + if (owner.Entity.Scale != 1f) + visual *= objectScale; + owner.VisualPartPoses[i] = visual; + owner.PartPoses[i] = Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin * owner.Entity.Scale); + } if (!owner.PartAvailable[i]) 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], }); @@ -429,6 +429,49 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram 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) { var result = new uint[setup.Parts.Count]; diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index b8abad35..f1bcb1b5 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -253,6 +253,7 @@ public sealed class AnimationSequencer // consume the returned IReadOnlyList synchronously within // the same loop iteration and never cache it across Advance() calls. private readonly PartTransform[] _partTransformScratch; + private readonly PartTransformBuffer _partTransformView; // R1-P6: root motion flows through the Advance(dt, Frame) overload // (retail CSequence::update(Frame*), 0x00525b80) — the old adapter @@ -279,6 +280,7 @@ public sealed class AnimationSequencer _mtable = motionTable; _loader = loader; _partTransformScratch = new PartTransform[_setup.Parts.Count]; + _partTransformView = new PartTransformBuffer(_partTransformScratch); _core = new CSequence(loader); _core.HookObj = new AdapterHookQueue(this); _table = new CMotionTable(motionTable); @@ -714,25 +716,20 @@ public sealed class AnimationSequencer // instead of allocating a fresh PartTransform[partCount] every call. // Sized once in the constructor to _setup.Parts.Count, which never // changes, so partCount here always matches the buffer length. + int authoredPartCount = Math.Min(partCount, f0Parts.Count); 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( - Vector3.Lerp(p0.Origin, p1.Origin, t), - SlerpRetailClient(p0.Orientation, p1.Orientation, t)); - } - else - { - result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity); - } + result[i] = new PartTransform( + Vector3.Lerp(p0.Origin, p1.Origin, t), + SlerpRetailClient(p0.Orientation, p1.Orientation, t)); } - return result; + _partTransformView.SetCount(authoredPartCount); + return _partTransformView; } private IReadOnlyList BuildIdentityFrame(int partCount) @@ -742,7 +739,36 @@ public sealed class AnimationSequencer var result = _partTransformScratch; for (int i = 0; i < partCount; i++) result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity); - return result; + _partTransformView.SetCount(partCount); + return _partTransformView; + } + + /// + /// Allocation-free view whose Count preserves the authored AnimFrame part + /// count. Retail UpdateParts touches only that prefix and retains every + /// trailing CPhysicsPart pose. + /// + private sealed class PartTransformBuffer : IReadOnlyList + { + 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 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- diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs index 9eb93aa2..2420f877 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs @@ -73,6 +73,90 @@ public sealed class LiveEntityAnimationPresenterTests 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 published, + out IReadOnlyList available)); + Assert.Single(published); + Assert.Single(available); + } + [Fact] public void MotionDone_OldComponentCannotResolveReplacementByGuid() { diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs index eaa181d7..2a6b903e 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs @@ -61,6 +61,45 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests 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] public void SubEpsilonElapsed_IsDiscardedInsteadOfAccumulated() {