diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs index 23823d86..b8205292 100644 --- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -32,6 +32,14 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram public required IReadOnlyDictionary?[] SurfaceOverrides; public required bool[] PartAvailable; public PhysicsBody? Body; + + /// + /// Retail CPhysicsObj::m_omegaVector. It lives on the owner + /// rather than on because DAT scenery — which is + /// most of this workset, and every ambient flyer in it — never gets a + /// PhysicsBody at all: BindLiveOwner refuses a zero ServerGuid. + /// + public Vector3 Omega; public AnimationSequencer? PendingProcessHooks; public ulong PendingResidencyVersion; public readonly List PreparedLivePartFrames = new(); @@ -454,6 +462,19 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram } } + else if (owner.Omega != Vector3.Zero) + { + // Same grotate, on the only root these objects have. A DAT + // static has no CPhysicsObj of its own, so the WorldEntity IS + // the frame retail would be rotating — and the render + // projection re-reads entity.Rotation every frame, so the + // parts composed below orbit it. + owner.RootFrameScratch.Origin = owner.Entity.Position; + owner.RootFrameScratch.Orientation = owner.Entity.Rotation; + FrameOps.GRotate(owner.RootFrameScratch, owner.Omega); + owner.Entity.Rotation = owner.RootFrameScratch.Orientation; + } + if (!IsResidentAtVersion(owner, residencyVersion)) { InvalidatePending(owner); @@ -535,9 +556,10 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram private static void ApplyOmegaHooks(Owner owner, IReadOnlyList hooks) { - if (owner.Body is not { } body) + if (!TryResolveOmega(hooks, out Vector3 omega)) return; - if (TryResolveOmega(hooks, out Vector3 omega)) + owner.Omega = omega; + if (owner.Body is { } body) body.Omega = omega; } diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs index 163d1770..1a7378eb 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs @@ -790,6 +790,94 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests Physics: physics); } + /// + /// The ambient flyers: a SetOmega hook must rotate the root of a DAT + /// scenery object, which has no PhysicsBody at all. + /// + /// + /// The first attempt at this fix applied the omega only to + /// owner.Body, and every test passed — because nothing exercised a + /// root rotation on the ServerGuid==0 branch, and BindLiveOwner REFUSES a + /// zero ServerGuid, so DAT scenery never has a body to carry it. Live in + /// the world the birds kept flapping in place. This asserts the rotation on + /// the branch those objects actually take. + /// + [Fact] + public void SetOmegaHook_RotatesTheRootOfDatSceneryWithNoPhysicsBody() + { + var loader = new Loader(); + loader.Add(AnimationId, OmegaAnimation(new Vector3(0f, 0f, -0.027f))); + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, sequencer) => sequencer.ConsumePendingHooks(), + (_, _, _) => { }); + WorldEntity entity = MakeEntity(); + Assert.Equal(0u, entity.ServerGuid); // DAT scenery: no body + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: MakeSetup(), + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + + // Retail runs process_hooks AFTER the grotate, so the first frame + // rotates by the still-zero omega and only banks the hook. + scheduler.Tick(1f / 30f); + Assert.Equal(Quaternion.Identity, entity.Rotation); + scheduler.ProcessHooks(); + + // ...and from the next frame on it turns. + scheduler.Tick(1f / 30f); + Assert.NotEqual(Quaternion.Identity, entity.Rotation); + + // Pure yaw: the authored omegas are all Z, so X and Y stay clean. + Assert.Equal(0f, entity.Rotation.X, 5); + Assert.Equal(0f, entity.Rotation.Y, 5); + Assert.NotEqual(0f, entity.Rotation.Z, 5); + + // It keeps turning — this is a continuous circuit, not a one-shot. + Quaternion afterFirst = entity.Rotation; + scheduler.Tick(1f / 30f); + Assert.NotEqual(afterFirst, entity.Rotation); + } + + [Fact] + public void WithoutASetOmegaHookTheRootNeverTurns() + { + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, sequencer) => sequencer.ConsumePendingHooks(), + (_, _, _) => { }); + WorldEntity entity = MakeEntity(); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: MakeSetup(), + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + + for (int i = 0; i < 4; i++) + { + scheduler.Tick(1f / 30f); + scheduler.ProcessHooks(); + } + + // Ordinary animated scenery (a swinging sign, a waterwheel's parts) + // must not acquire a spin it never asked for. + Assert.Equal(Quaternion.Identity, entity.Rotation); + } + + private static Animation OmegaAnimation(Vector3 axis) + { + Animation animation = TwoFrameAnimation(); + animation.PartFrames[0].Hooks.Add(new SetOmegaHook { Axis = axis }); + return animation; + } + private static Animation TwoFrameAnimation() { var animation = new Animation();