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>
100 lines
4 KiB
C#
100 lines
4 KiB
C#
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Options;
|
|
using DatReaderWriter.Types;
|
|
|
|
namespace AcDream.Content.Tests;
|
|
|
|
/// <summary>
|
|
/// Pins the data the ambient flyers (circling birds, flitting butterflies)
|
|
/// depend on, against the installed retail DATs.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A Static object whose Setup declares a DefaultAnimation joins retail's
|
|
/// <c>CPhysics::static_animating_objects</c> workset
|
|
/// (<c>CPhysicsObj::InitDefaults @ 0x00513A7B</c>) and is driven by
|
|
/// <c>animate_static_object @ 0x00513DF0</c>, whose only motion step is
|
|
/// <c>Frame::grotate(&m_position.frame, &m_omegaVector)</c>. That
|
|
/// vector is written by exactly one thing:
|
|
/// <c>SetOmegaHook::Execute @ 0x00526F30</c> → <c>CPhysicsObj::set_omega
|
|
/// @ 0x0050F6D0</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// So these objects move ONLY if the SetOmega hook is honoured. acdream
|
|
/// decoded the hook and then ignored it, leaving m_omegaVector at zero: the
|
|
/// wings animated and nothing flew.
|
|
/// </para>
|
|
/// <para>
|
|
/// This test exists because the fix rests on two claims about shipped data
|
|
/// that are invisible from the code: that the authored omegas are pure YAW,
|
|
/// and that the meshes hang far off the axis they spin about. Rotation only
|
|
/// reads as flight because of the second one — a bird spinning about its own
|
|
/// centre would just pirouette. If a dat ever contradicts either, the fix is
|
|
/// wrong and this should say so rather than the behaviour quietly changing.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Trait("Lane", "InstalledDat")]
|
|
public sealed class InstalledStaticAnimatingOmegaTests
|
|
{
|
|
[Fact]
|
|
public void EverySetOmegaAnimationIsPureYawOnAMeshOffsetFromItsAxis()
|
|
{
|
|
string? datDir = ContentConformanceDats.ResolveDatDir();
|
|
if (datDir is null)
|
|
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
|
|
var found = new List<(uint Setup, float Yaw, double Radius)>();
|
|
|
|
foreach (uint id in dats.Portal.Tree.Select(e => e.Id).Where(i => (i >> 24) == 0x02))
|
|
{
|
|
if (!dats.Portal.TryGet<Setup>(id, out Setup? setup) || setup is null)
|
|
continue;
|
|
uint animId = setup.DefaultAnimation?.DataId ?? 0u;
|
|
if (animId == 0
|
|
|| !dats.Portal.TryGet<Animation>(animId, out Animation? anim)
|
|
|| anim is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var frame in anim.PartFrames)
|
|
foreach (var hook in frame.Hooks)
|
|
{
|
|
if (hook is not SetOmegaHook set)
|
|
continue;
|
|
|
|
// Pure yaw: a non-zero X or Y would tumble the object, and
|
|
// the circle-tracing reading of this mechanism would fail.
|
|
Assert.Equal(0f, set.Axis.X, 5);
|
|
Assert.Equal(0f, set.Axis.Y, 5);
|
|
Assert.NotEqual(0f, set.Axis.Z);
|
|
|
|
double radius = 0d;
|
|
foreach (var placement in setup.PlacementFrames.Values)
|
|
foreach (var af in placement.Frames)
|
|
radius = Math.Max(
|
|
radius,
|
|
Math.Sqrt((af.Origin.X * af.Origin.X)
|
|
+ (af.Origin.Y * af.Origin.Y)));
|
|
|
|
found.Add((id, set.Axis.Z, radius));
|
|
}
|
|
}
|
|
|
|
Assert.NotEmpty(found);
|
|
|
|
// Every one of them hangs metres off its own spin axis — that offset IS
|
|
// the flight radius. One metre would be a pirouette, not a circuit.
|
|
foreach ((uint setupId, _, double radius) in found)
|
|
{
|
|
Assert.True(
|
|
radius > 1d,
|
|
$"setup 0x{setupId:X8} spins about an axis only {radius:0.###}m "
|
|
+ "from its mesh; rotation would not read as flight.");
|
|
}
|
|
}
|
|
}
|