From 20a814033447797e80f537c5b7de6489e0a5425d Mon Sep 17 00:00:00 2001 From: qwerin Date: Wed, 4 Nov 2026 17:37:26 +0100 Subject: [PATCH] Idle actors fidget: the blacksmith hammers at his anvil again Zabar Treseck (Elddim, multiplayer_world) stood rigid at the anvil. His chore_default (a_c_gbn_bs_fs2_ds) is a single 0.08 s frame; the hammering is his chore_fidget (fs2_dsf, whose note drives the blacksmith_hammer sfx trigger). DS1 never shows the frozen frame: with actor_auto_fidgets the fidget job re-queues chore_fidget on every WE_ANIM_DONE, so the fidget loop is the idle. Nearly every shipped chore_default is such a one-frame stance. Two things kept him on it. The spawner only ever picked an idle other than chore_default from an authored initial_chore. And even that idle lost: infinite_loop asks for index 0 the moment chore_default starts, and CurrentClipIndex only fell back to IdleClipIndex before that first dispatch (or for an awakened burrower). Now an actor with no initial_chore idles in chore_fidget when its default is static, its fidget moves, and its mind doesn't turn auto-fidgeting off; and the idle clip wins over a skrit-driven 0 (an explicit override of clip 0 still shows). The hero keeps slot 0 as before. Host and client spawn through the same path, so both show it. The SP Stonebridge and Glacern blacksmiths share the template base and were frozen the same way. region spawn prints which actors idle in something other than chore_default. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit e6492a9c253f300ad9abf2a48b9922c40088a18f) --- src/SiegeFX.Core/Actors/Actor.cs | 42 +++++++----- src/SiegeFX.Core/Actors/ActorIdle.cs | 66 +++++++++++++++++++ src/SiegeFX.Core/Actors/ActorSpawner.cs | 31 +++++---- src/SiegeFX.Tools/Program.cs | 8 +++ tests/SiegeFX.Tests/ActorIdleTests.cs | 87 +++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 26 deletions(-) create mode 100644 src/SiegeFX.Core/Actors/ActorIdle.cs create mode 100644 tests/SiegeFX.Tests/ActorIdleTests.cs diff --git a/src/SiegeFX.Core/Actors/Actor.cs b/src/SiegeFX.Core/Actors/Actor.cs index 8870459c..43310219 100644 --- a/src/SiegeFX.Core/Actors/Actor.cs +++ b/src/SiegeFX.Core/Actors/Actor.cs @@ -67,8 +67,9 @@ public int GetClipIndex(string choreName) => public ActorCombatState Combat { get; } /// SC-IDLE-FIDGET — the clip an undirected actor idles on: the - /// template's authored initial_chore when it loaded, else clip 0 - /// (chore_default). DS1 authors initial_chore = chore_fidget on + /// template's authored initial_chore when it loaded, else + /// chore_fidget when chore_default is a frozen one-frame stance (see + /// ), else clip 0 (chore_default). DS1 authors initial_chore = chore_fidget on /// actors whose default stance is a STATIC pose — the phrak's default /// dfs is a frozen glide; its fidget dff is the wing-flap /// hover. Falling back to clip 0 left idle flyers frozen mid-air. @@ -80,20 +81,31 @@ public int GetClipIndex(string choreName) => /// Current blender-selected clip. Clamped to the legal range so the caller /// can index blindly; defaults to /// before the first dispatch resolves. - public int CurrentClipIndex + public int CurrentClipIndex => + ResolveClipIndex(Host.CurrentAnimIndex, Clips.Length, IdleClipIndex, + Buried, Awakened, Host.IsOverrideActive); + + /// The clip-pick rule behind , kept + /// pure so it is testable without a mesh. is the + /// blender's index (-1 before the first dispatch). + public static int ResolveClipIndex(int h, int clipCount, int idleClipIndex, + bool buried, bool awakened, bool overrideActive) { - get - { - int idle = IdleClipIndex >= 0 && IdleClipIndex < Clips.Length ? IdleClipIndex : 0; - int h = Host.CurrentAnimIndex; - if (Buried && !Host.IsOverrideActive) return 0; - if (h < 0 || h >= Clips.Length) return idle; - // SC-BURROWER — once awake, an actor whose chore_default is its - // buried pose idles in its initial chore (the skrubb peeking - // from its mound) when the script asks for the default. - if (h == 0 && Awakened) return idle; - return h; - } + int idle = idleClipIndex >= 0 && idleClipIndex < clipCount ? idleClipIndex : 0; + if (buried && !overrideActive) return 0; + if (h < 0 || h >= clipCount) return idle; + // SC-BURROWER — once awake, an actor whose chore_default is its + // buried pose idles in its initial chore (the skrubb peeking + // from its mound) when the script asks for the default. + if (h == 0 && awakened) return idle; + // SC-IDLE-FIDGET — the chore_default skrit (infinite_loop) asks for + // index 0 the moment it starts, so the idle clip has to win over a + // skrit-driven 0 too. Before, IdleClipIndex only showed in the + // instant before the first dispatch, and an actor whose default is a + // frozen one-frame stance (the blacksmith's ds) never left it. An + // explicit override of clip 0 still shows clip 0. + if (h == 0 && !overrideActive) return idle; + return h; } /// SC-BURROWER — a startup_reveal monster before it wakes: it diff --git a/src/SiegeFX.Core/Actors/ActorIdle.cs b/src/SiegeFX.Core/Actors/ActorIdle.cs new file mode 100644 index 00000000..47317b97 --- /dev/null +++ b/src/SiegeFX.Core/Actors/ActorIdle.cs @@ -0,0 +1,66 @@ +using System.Numerics; +using SiegeFX.Core.Assets; + +namespace SiegeFX.Core.Actors; + +/// SC-IDLE-FIDGET — which chore an undirected actor idles in. +/// +/// In DS1 an idle actor whose mind has actor_auto_fidgets (the actor +/// base template sets it true) runs job_fidget / the talk jobs' +/// StartFidgetIfRequired$: chore_fidget is re-queued on every +/// WE_ANIM_DONE, so the fidget loop IS the idle. chore_default only shows +/// between chores. We idle on chore_default because for nearly every actor +/// it is a breathing loop, but some templates author it as a single frozen +/// frame and put the real idle in chore_fidget: the blacksmith's +/// a_c_gbn_bs_fs2_ds is one 0.08 s frame, his fs2_dsf is the +/// hammering loop (its note fires the "blacksmith_hammer" sfx trigger). +/// Idling on clip 0 left him standing rigid at the anvil. +public static class ActorIdle +{ + /// The chore name to idle in, or null for chore_default. + /// An authored initial_chore wins; otherwise chore_fidget when + /// chore_default is a static pose, the fidget clip actually moves, and the + /// mind doesn't turn auto-fidgeting off. + public static string? PickIdleChore(string? initialChore, PrsAnimation? defaultClip, + PrsAnimation? fidgetClip, bool autoFidgets) => + PickIdleChore(initialChore, + defaultIsStatic: defaultClip is not null && IsStaticPose(defaultClip), + fidgetMoves: fidgetClip is not null && !IsStaticPose(fidgetClip), + autoFidgets); + + /// The same decision over precomputed clip facts. + public static string? PickIdleChore(string? initialChore, bool defaultIsStatic, + bool fidgetMoves, bool autoFidgets) + { + if (!string.IsNullOrWhiteSpace(initialChore)) return initialChore.Trim(); + return autoFidgets && defaultIsStatic && fidgetMoves ? "chore_fidget" : null; + } + + public static bool IsStaticPose(PrsAnimation clip) => IsStaticPose(clip.RootKeys, clip.BoneKeys); + + /// True when no track in the clip changes: every rotation and + /// position key of the root and of each bone equals that track's first. + public static bool IsStaticPose(PrsAnimation.KeyList? root, IReadOnlyList bones) + { + if (!IsStaticTrack(root)) return false; + foreach (var b in bones) + if (!IsStaticTrack(b)) return false; + return true; + } + + static bool IsStaticTrack(PrsAnimation.KeyList? keys) + { + if (keys is null) return true; + const float eps = 1e-4f; + for (int i = 1; i < keys.RotKeys.Count; i++) + { + // q and -q are the same rotation. + float dot = MathF.Abs(Quaternion.Dot(keys.RotKeys[0].Rotation, keys.RotKeys[i].Rotation)); + if (dot < 1f - eps) return false; + } + for (int i = 1; i < keys.PosKeys.Count; i++) + if (Vector3.DistanceSquared(keys.PosKeys[0].Position, keys.PosKeys[i].Position) > eps * eps) + return false; + return true; + } +} diff --git a/src/SiegeFX.Core/Actors/ActorSpawner.cs b/src/SiegeFX.Core/Actors/ActorSpawner.cs index c40c1289..d0990be2 100644 --- a/src/SiegeFX.Core/Actors/ActorSpawner.cs +++ b/src/SiegeFX.Core/Actors/ActorSpawner.cs @@ -295,17 +295,6 @@ public IReadOnlyList Spawn( // max_life / scale_multiplier) wins over the template chain. var stats = ActorStats.FromTemplate(_store, template, inst.Node); var actor = new Actor(inst, template, world, mesh, clips, skrit, host, stats, walkIdx, clipIndexByName); - // SC-IDLE-FIDGET — honor the template's authored [body] initial_chore - // as the idle fallback clip. DS1 starts actors in it; for flyers - // (phrak) and other static-default creatures that's chore_fidget — - // the hover/flap loop — while chore_default (clip 0) is a frozen - // stance pose. Without this, undirected actors idled on clip 0 and - // flyers hung motionless mid-air. - var initialChore = (_store.GetAttribute(template, "body", "initial_chore") - ?? _store.GetAttribute(template, "mind", "initial_chore"))?.Trim(); - if (!string.IsNullOrEmpty(initialChore) - && clipIndexByName.TryGetValue(initialChore!, out var idleIdx)) - actor.IdleClipIndex = idleIdx; // SC-BURROWER — a startup_reveal monster (the skrubb) lies in its // chore_default pose, below ground, until it wakes. if ((_store.GetAttribute(template, "mind", "jat_startup") ?? "") @@ -318,6 +307,26 @@ public IReadOnlyList Spawn( // attack-variant set + qffg pad + authored base duration for every // spawned combatant (clip cache makes the second pass cheap). RefreshMotionClips(actor, preferredStance ?? DeriveStanceFromEquipment(template)); + // SC-IDLE-FIDGET — honor the template's authored [body] initial_chore + // as the idle clip. DS1 starts actors in it; for flyers (phrak) and + // other static-default creatures that's chore_fidget — the hover/flap + // loop — while chore_default (clip 0) is a frozen stance pose. With + // no initial_chore, a one-frame chore_default and a moving + // chore_fidget (the blacksmith's ds / hammering dsf) idle in the + // fidget too, as DS1's auto-fidget job does. Picked after the stance + // refresh so the test sees the clips the actor will actually play. + var autoFidgets = !string.Equals( + _store.GetAttribute(template, "mind", "actor_auto_fidgets")?.Trim(), "false", + StringComparison.OrdinalIgnoreCase); + var idleChore = ActorIdle.PickIdleChore( + (_store.GetAttribute(template, "body", "initial_chore") + ?? _store.GetAttribute(template, "mind", "initial_chore"))?.Trim(), + clips.Length > 0 ? clips[0] : null, + clipIndexByName.TryGetValue("chore_fidget", out var fidIdx) ? clips[fidIdx] : null, + autoFidgets); + if (!string.IsNullOrEmpty(idleChore) + && clipIndexByName.TryGetValue(idleChore!, out var idleIdx)) + actor.IdleClipIndex = idleIdx; return actor; } diff --git a/src/SiegeFX.Tools/Program.cs b/src/SiegeFX.Tools/Program.cs index c1b9c2af..60210824 100644 --- a/src/SiegeFX.Tools/Program.cs +++ b/src/SiegeFX.Tools/Program.cs @@ -8879,6 +8879,14 @@ static int CmdRegionSpawn(string[] a) } Console.WriteLine($" bus posted : {spawner.MessageBus.PostedCount}"); Console.WriteLine($" bus delivered : {delivered} (undelivered: {spawner.MessageBus.UndeliveredCount})"); + // SC-IDLE-FIDGET — actors that idle in something other than chore_default. + var nonDefaultIdle = actors + .Where(x => x.IdleClipIndex != 0) + .GroupBy(x => (x.Template.Name, + Chore: x.ClipIndexByName.FirstOrDefault(kv => kv.Value == x.IdleClipIndex).Key ?? $"#{x.IdleClipIndex}")) + .Select(g => $"{g.Key.Name}→{g.Key.Chore}×{g.Count()}") + .ToList(); + Console.WriteLine($" non-default idle : {(nonDefaultIdle.Count == 0 ? "none" : string.Join(", ", nonDefaultIdle))}"); Console.WriteLine(); Console.WriteLine("sample actors (first 5):"); diff --git a/tests/SiegeFX.Tests/ActorIdleTests.cs b/tests/SiegeFX.Tests/ActorIdleTests.cs new file mode 100644 index 00000000..3876f0e9 --- /dev/null +++ b/tests/SiegeFX.Tests/ActorIdleTests.cs @@ -0,0 +1,87 @@ +using System.Numerics; +using SiegeFX.Core.Actors; +using SiegeFX.Core.Assets; + +namespace SiegeFX.Tests; + +public class ActorIdleTests +{ + static PrsAnimation.KeyList Track(params (Quaternion r, Vector3 p)[] keys) => + new(keys.Select((k, i) => new PrsAnimation.RotKey(i, k.r)).ToArray(), + keys.Select((k, i) => new PrsAnimation.PosKey(i, k.p)).ToArray()); + + static readonly Quaternion Bent = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.6f); + + [Fact] + public void AOneFrameStanceIsStatic() + { + // The blacksmith's a_c_gbn_bs_fs2_ds: one key per track. + var root = Track((Quaternion.Identity, Vector3.Zero)); + var bones = new[] { Track((Bent, Vector3.UnitY)), null, Track((Quaternion.Identity, Vector3.One)) }; + Assert.True(ActorIdle.IsStaticPose(root, bones)); + } + + [Fact] + public void RepeatedIdenticalKeysAreStaticAndNegatedQuaternionsAreTheSameRotation() + { + var bones = new[] { Track((Bent, Vector3.UnitY), (Quaternion.Negate(Bent), Vector3.UnitY)) }; + Assert.True(ActorIdle.IsStaticPose(null, bones)); + } + + [Fact] + public void AnArmThatSwingsIsNotStatic() + { + // The hammering fidget: the arm rotates across its keys. + var bones = new[] { Track((Quaternion.Identity, Vector3.Zero), (Bent, Vector3.Zero)) }; + Assert.False(ActorIdle.IsStaticPose(null, bones)); + var moved = new[] { Track((Quaternion.Identity, Vector3.Zero), (Quaternion.Identity, Vector3.UnitZ)) }; + Assert.False(ActorIdle.IsStaticPose(null, moved)); + } + + [Fact] + public void AFrozenDefaultWithAMovingFidgetIdlesInTheFidget() + { + Assert.Equal("chore_fidget", + ActorIdle.PickIdleChore(null, defaultIsStatic: true, fidgetMoves: true, autoFidgets: true)); + } + + [Theory] + [InlineData(false, true, true)] // a moving default stays the idle + [InlineData(true, false, true)] // nothing better to show + [InlineData(true, true, false)] // actor_auto_fidgets = false + public void OtherwiseTheDefaultStays(bool defaultIsStatic, bool fidgetMoves, bool autoFidgets) + { + Assert.Null(ActorIdle.PickIdleChore(null, defaultIsStatic, fidgetMoves, autoFidgets)); + } + + [Fact] + public void AnAuthoredInitialChoreWins() + { + Assert.Equal("chore_misc", + ActorIdle.PickIdleChore(" chore_misc ", defaultIsStatic: false, fidgetMoves: false, autoFidgets: false)); + } + + [Fact] + public void TheIdleClipWinsOverTheDefaultLoopsRequestForClipZero() + { + // infinite_loop asks for index 0 as soon as chore_default starts; + // before this the idle clip only showed until that first dispatch. + Assert.Equal(4, Actor.ResolveClipIndex(h: 0, clipCount: 6, idleClipIndex: 4, + buried: false, awakened: false, overrideActive: false)); + Assert.Equal(4, Actor.ResolveClipIndex(h: -1, clipCount: 6, idleClipIndex: 4, + buried: false, awakened: false, overrideActive: false)); + } + + [Fact] + public void OverridesAndOtherChoresStillShow() + { + // A pinned clip-0 override (a scripted pose) shows clip 0. + Assert.Equal(0, Actor.ResolveClipIndex(0, 6, 4, buried: false, awakened: false, overrideActive: true)); + // A swing or any other chore is untouched. + Assert.Equal(2, Actor.ResolveClipIndex(2, 6, 4, buried: false, awakened: false, overrideActive: false)); + // An actor with no idle of its own keeps chore_default. + Assert.Equal(0, Actor.ResolveClipIndex(0, 6, 0, buried: false, awakened: false, overrideActive: false)); + // A buried skrubb holds its default until it wakes. + Assert.Equal(0, Actor.ResolveClipIndex(3, 6, 4, buried: true, awakened: false, overrideActive: false)); + } +}