fix(world): honour SetOmega — the birds and butterflies fly again

Ambient flyers played their wing animation and stayed put.

A Static object whose Setup declares a DefaultAnimation joins retail's
CPhysics::static_animating_objects workset (CPhysicsObj::InitDefaults
@0x00513A7B) and is driven by animate_static_object @0x00513DF0. That function
has exactly one motion step:

    CPartArray::Update(part_array, dt, nullptr);            // animate
    Frame::grotate(&this->m_position.frame, &this->m_omegaVector);

Note the nullptr: unlike UpdatePositionInternal @0x00512C30, which combines the
animation's accumulated frame into the object's position, the static branch
DISCARDS it. These objects cannot move by animation translation at all. The
omega vector is the whole mechanism, and one thing writes it —
SetOmegaHook::Execute @0x00526F30 -> CPhysicsObj::set_omega @0x0050F6D0.

We decoded that hook and then dropped it on the floor: IAnimationHookSink's own
docs list SetOmegaHook among the unwired ones, and PhysicsBody.Omega was
assigned nowhere outside projectiles. The scheduler's GRotate call was already
correct — it was multiplying by a permanent zero.

The hook is now applied to the owning body at process_hooks time. Retail runs
process_hooks AFTER the grotate in the same pass, so a newly-set omega first
takes effect on the following frame; our Tick/ProcessHooks split already had
that order.

Scoped from the data rather than guessed. tools/AnimHookScan (new) walks the
dat: of 2,066 animations exactly 8 contain SetOmega, and all 8 are the
DefaultAnimation of one of the 8 setups that use it. No creature animation uses
it, so this belongs precisely where body.Omega is read and nowhere else.

The same scan is why the fix is believable as FLIGHT rather than a pirouette.
Every authored omega is pure yaw, and the setups' parts sit 5.6m, 4.2m, 12m and
36.8m from the origin they spin about. Rotating a frame whose mesh hangs 12m
off-axis carries it around a 12m circle — that offset IS the flight radius. An
installed-DAT test pins both properties, because the fix is only correct while
they hold and neither is visible from the code.

Also checked and deliberately NOT conflated: CSequence::set_omega @0x005248A0
writes CSequence::omega, a different field from CPhysicsObj::m_omegaVector,
fed by the motion table for creature turning. Only the latter drives grotate.

Solution builds clean; 14,473 tests pass on the standard hermetic lane filter
plus the new installed-DAT test, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 05:42:59 +02:00
parent 255b0aaeda
commit 0c552eecac
7 changed files with 643 additions and 0 deletions

View file

@ -516,6 +516,16 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
continue;
}
// SetOmegaHook::Execute 0x00526F30 -> CPhysicsObj::set_omega
// 0x0050F6D0 writes m_omegaVector on the owning physics object,
// which animate_static_object then feeds to Frame::grotate every
// frame. Apply it BEFORE the presentation sink drains the queue.
//
// Retail runs process_hooks AFTER the grotate in the same pass, so
// a newly-set omega first takes effect on the following frame; our
// Tick/ProcessHooks split preserves that ordering.
ApplyOmegaHooks(owner, sequencer.PendingHooks);
// Clear before the callback: hook delivery may unregister or
// replace the owner, and a nested caller must not replay this tail.
owner.PendingProcessHooks = null;
@ -523,6 +533,46 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
}
}
private static void ApplyOmegaHooks(Owner owner, IReadOnlyList<AnimationHook> hooks)
{
if (owner.Body is not { } body)
return;
if (TryResolveOmega(hooks, out Vector3 omega))
body.Omega = omega;
}
/// <summary>
/// The omega a hook batch leaves on the physics object, if any.
/// </summary>
/// <remarks>
/// <para>
/// This is the mechanism behind AC's circling birds and flitting
/// butterflies, and it is not a translation: every authored omega in the
/// dat is pure yaw, and the setup's parts sit well off the origin (5.6 m to
/// 36.8 m across the eight setups that use it). Spinning a frame whose mesh
/// hangs metres off the axis carries that mesh around a circle of the same
/// radius, which is what reads as flight.
/// </para>
/// <para>
/// Last hook wins: retail executes a batch in order and every
/// <c>set_omega</c> overwrites the vector outright rather than accumulating.
/// </para>
/// </remarks>
internal static bool TryResolveOmega(
IReadOnlyList<AnimationHook> hooks, out Vector3 omega)
{
omega = default;
bool found = false;
for (int i = 0; i < hooks.Count; i++)
{
if (hooks[i] is not SetOmegaHook set)
continue;
omega = new Vector3(set.Axis.X, set.Axis.Y, set.Axis.Z);
found = true;
}
return found;
}
private bool IsResidentAtVersion(Owner owner, ulong version) =>
_isResident(owner.Entity)
&& _residencyVersion(owner.Entity) == version;

View file

@ -548,6 +548,20 @@ public sealed class AnimationSequencer
/// Empty when no frame boundary was crossed. Safe to call multiple
/// times per frame; second and subsequent calls return an empty list.
/// </summary>
/// <summary>
/// The hooks that have fired since the last <see cref="ConsumePendingHooks"/>,
/// WITHOUT draining them.
/// </summary>
/// <remarks>
/// Retail's <c>CPhysicsObj::process_hooks</c> executes every queued hook
/// against the owning <c>CPhysicsObj</c>, and a hook may act on BOTH the
/// physics object and presentation — <c>SetOmegaHook::Execute</c>
/// (<c>0x00526F30</c>) writes <c>m_omegaVector</c>. Draining the queue for
/// the presentation sink would hide those hooks from the physics owner, so
/// the owner peeks here and the sink still consumes the complete stream.
/// </remarks>
public IReadOnlyList<AnimationHook> PendingHooks => _pendingHooks;
public IReadOnlyList<AnimationHook> ConsumePendingHooks()
{
if (_pendingHooks.Count == 0)