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:
parent
255b0aaeda
commit
0c552eecac
7 changed files with 643 additions and 0 deletions
105
tools/AnimHookScan/Program.cs
Normal file
105
tools/AnimHookScan/Program.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// What does retail actually put in the animations of STATIC ANIMATING objects?
|
||||
//
|
||||
// A Static object whose Setup declares a DefaultAnimation or DefaultScript
|
||||
// joins CPhysics::static_animating_objects (CPhysicsObj::InitDefaults
|
||||
// @0x00513A7B) and is driven every frame by animate_static_object
|
||||
// @0x00513DF0. This walks every Setup in portal.dat, follows those defaults,
|
||||
// and tallies the hook types they carry -- so "which hooks must be honoured
|
||||
// for this class of object to behave" is read from the data, not assumed.
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Options;
|
||||
using DatReaderWriter.Types;
|
||||
using SysEnv = System.Environment;
|
||||
|
||||
string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var animHookCounts = new Dictionary<AnimationHookType, int>();
|
||||
var scriptHookCounts = new Dictionary<AnimationHookType, int>();
|
||||
var omegaSetups = new List<(uint Setup, uint Anim)>();
|
||||
int setups = 0, withAnim = 0, withScript = 0;
|
||||
|
||||
foreach (uint id in dats.Portal.Tree.Select(e => e.Id).Where(i => (i >> 24) == 0x02))
|
||||
{
|
||||
if (!dats.Portal.TryGet<Setup>(id, out var setup) || setup is null) continue;
|
||||
setups++;
|
||||
|
||||
uint animId = setup.DefaultAnimation?.DataId ?? 0u;
|
||||
uint scriptId = setup.DefaultScript?.DataId ?? 0u;
|
||||
if (animId != 0) withAnim++;
|
||||
if (scriptId != 0) withScript++;
|
||||
|
||||
if (animId != 0 && dats.Portal.TryGet<Animation>(animId, out var anim) && anim is not null)
|
||||
{
|
||||
bool omega = false;
|
||||
foreach (var frame in anim.PartFrames)
|
||||
foreach (var hook in frame.Hooks)
|
||||
{
|
||||
animHookCounts[hook.HookType] = animHookCounts.GetValueOrDefault(hook.HookType) + 1;
|
||||
if (hook.HookType == AnimationHookType.SetOmega) omega = true;
|
||||
}
|
||||
if (omega) omegaSetups.Add((id, animId));
|
||||
}
|
||||
|
||||
if (scriptId != 0
|
||||
&& dats.Portal.TryGet<PhysicsScript>(scriptId, out var script) && script is not null)
|
||||
{
|
||||
foreach (var d in script.ScriptData)
|
||||
{
|
||||
AnimationHookType t = d.Hook.HookType;
|
||||
scriptHookCounts[t] = scriptHookCounts.GetValueOrDefault(t) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every animation in the dat, not just the Setup defaults: tells us whether
|
||||
// SetOmega is exclusively a static-animating-scenery mechanism or whether
|
||||
// creatures use it too (which would widen where the hook must be honoured).
|
||||
int allAnims = 0, animsWithOmega = 0;
|
||||
foreach (uint id in dats.Portal.Tree.Select(e => e.Id).Where(i => (i >> 24) == 0x03))
|
||||
{
|
||||
if (!dats.Portal.TryGet<Animation>(id, out var a2) || a2 is null) continue;
|
||||
allAnims++;
|
||||
bool has = false;
|
||||
foreach (var fr in a2.PartFrames)
|
||||
foreach (var h in fr.Hooks)
|
||||
if (h.HookType == AnimationHookType.SetOmega) has = true;
|
||||
if (has) animsWithOmega++;
|
||||
}
|
||||
Console.WriteLine($"ALL animations={allAnims} containingSetOmega={animsWithOmega}");
|
||||
|
||||
Console.WriteLine($"setups={setups} withDefaultAnimation={withAnim} withDefaultScript={withScript}");
|
||||
Console.WriteLine("\n-- hook types in DefaultAnimation --");
|
||||
foreach (var kv in animHookCounts.OrderByDescending(k => k.Value))
|
||||
Console.WriteLine($" {kv.Key,-28} {kv.Value}");
|
||||
Console.WriteLine("\n-- hook types in DefaultScript --");
|
||||
foreach (var kv in scriptHookCounts.OrderByDescending(k => k.Value))
|
||||
Console.WriteLine($" {kv.Key,-28} {kv.Value}");
|
||||
Console.WriteLine($"\n-- setups whose DefaultAnimation carries SetOmega: {omegaSetups.Count} --");
|
||||
foreach (var (s, a) in omegaSetups)
|
||||
{
|
||||
Console.Write($" setup 0x{s:X8} -> anim 0x{a:X8}");
|
||||
if (dats.Portal.TryGet<Setup>(s, out var su) && su is not null)
|
||||
{
|
||||
Console.Write($" parts={su.Parts.Count}");
|
||||
// How far the authored part frames sit from the setup origin: a mesh
|
||||
// offset from the point it spins about traces a CIRCLE under grotate.
|
||||
double maxR = 0;
|
||||
foreach (var f in su.PlacementFrames.Values)
|
||||
foreach (var af in f.Frames)
|
||||
maxR = Math.Max(maxR, Math.Sqrt(
|
||||
af.Origin.X * af.Origin.X + af.Origin.Y * af.Origin.Y));
|
||||
Console.Write($" maxPartRadius={maxR:0.###}m");
|
||||
}
|
||||
if (dats.Portal.TryGet<Animation>(a, out var an) && an is not null)
|
||||
foreach (var fr in an.PartFrames)
|
||||
foreach (var h in fr.Hooks)
|
||||
if (h is SetOmegaHook so)
|
||||
Console.Write($" omega=({so.Axis.X:0.###},{so.Axis.Y:0.###},{so.Axis.Z:0.###})");
|
||||
Console.WriteLine();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue