fix(world): rotate the DAT-scenery root too — the flyers actually orbit now

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 05:52:44 +02:00
parent 0c552eecac
commit 80a3a25594
2 changed files with 112 additions and 2 deletions

View file

@ -32,6 +32,14 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
public required IReadOnlyDictionary<uint, uint>?[] SurfaceOverrides;
public required bool[] PartAvailable;
public PhysicsBody? Body;
/// <summary>
/// Retail <c>CPhysicsObj::m_omegaVector</c>. It lives on the owner
/// rather than on <see cref="Body"/> because DAT scenery — which is
/// most of this workset, and every ambient flyer in it — never gets a
/// PhysicsBody at all: <c>BindLiveOwner</c> refuses a zero ServerGuid.
/// </summary>
public Vector3 Omega;
public AnimationSequencer? PendingProcessHooks;
public ulong PendingResidencyVersion;
public readonly List<PartTransform> 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<AnimationHook> 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;
}

View file

@ -790,6 +790,94 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
Physics: physics);
}
/// <summary>
/// The ambient flyers: a SetOmega hook must rotate the root of a DAT
/// scenery object, which has no PhysicsBody at all.
/// </summary>
/// <remarks>
/// The first attempt at this fix applied the omega only to
/// <c>owner.Body</c>, 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.
/// </remarks>
[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();