From 80a3a255940a5ecf227731709e371281f84e85e6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 05:52:44 +0200 Subject: [PATCH] =?UTF-8?q?fix(world):=20rotate=20the=20DAT-scenery=20root?= =?UTF-8?q?=20too=20=E2=80=94=20the=20flyers=20actually=20orbit=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0c552eec wired the SetOmega hook and I called it done. The birds kept flapping in place, and the user's report — "flapping and moving up and down, not orbiting" — is what identified the miss: part animation working, root frozen. BindLiveOwner THROWS on a zero ServerGuid, so owner.Body is only ever assigned for server-spawned entities. Ambient flyers are DAT scenery with no ServerGuid and therefore no PhysicsBody at all. The whole if (owner.Body is { } body) { ... Frame::grotate ... } block — and the omega application I added inside it — silently skipped every object the fix was written for. It applied the mechanism to a branch these objects never take. So the omega now lives on the scheduler's own Owner record rather than on the PhysicsBody, because most of this workset has no body, and the same grotate is applied to entity.Rotation when there is none. That is not a shortcut around the physics owner: for a DAT static the WorldEntity IS the only root retail would be rotating. Verified rather than assumed this time, both halves: - StaticRenderProjectionJournal.SynchronizeActiveAnimatedSources re-projects from the live entity every frame through RenderTransform.FromRoot(entity.Position, entity.Rotation, entity.Scale), so a rotated root reaches the renderer. - Compose builds LOCAL part transforms, so the renderer composes root x part and the offset mesh is carried around its circle. Why it shipped broken: no test exercised a root rotation on the ServerGuid==0 branch, so applying omega body-only passed everything. The new test asserts the rotation on the branch these objects actually take, and fails with the exact production symptom (rotation stays identity) when the branch is disabled. Its sibling pins the other direction — scenery without a SetOmega hook must never acquire a spin. Solution builds clean; 14,475 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 --- .../RetailStaticAnimatingObjectScheduler.cs | 26 +++++- ...tailStaticAnimatingObjectSchedulerTests.cs | 88 +++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) 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();