diff --git a/src/SiegeFX.Core/Actors/AttackAnim.cs b/src/SiegeFX.Core/Actors/AttackAnim.cs
index 686cdfb0..235554f6 100644
--- a/src/SiegeFX.Core/Actors/AttackAnim.cs
+++ b/src/SiegeFX.Core/Actors/AttackAnim.cs
@@ -93,6 +93,50 @@ public static float[] Times(PrsAnimation clip, uint token)
hits.Sort();
return hits.ToArray();
}
+
+ /// The SFX1..SFX4 note number (1-4) of ,
+ /// or 0 when it is another note.
+ public static int SfxNumber(uint token) => token switch
+ {
+ 0x31584653 => 1, // 'SFX1'
+ 0x32584653 => 2, // 'SFX2'
+ 0x33584653 => 3, // 'SFX3'
+ 0x34584653 => 4, // 'SFX4'
+ _ => 0,
+ };
+
+ /// SC-ANIM-SFX — the SFX1..4 notes a clip passed while its
+ /// clock moved from to
+ /// seconds, in order, as note numbers (1-4). The clock is the unwrapped
+ /// play time: a looping idle keeps counting past the clip length, so a
+ /// note fires once per loop, every loop. The interval is (before, after],
+ /// plus a note at exactly 0 when the clip just started (before == 0).
+ /// DS1's chore skrits post WE_ANIM_SFX(n) to the actor for each of these
+ /// (select_fidget's ANIMEVENT_SFX_n tests).
+ public static void SfxCrossed(IReadOnlyList notes, float animLength,
+ double before, double after, List into)
+ {
+ if (notes.Count == 0 || animLength <= 0f || after <= before) return;
+ long first = (long)Math.Floor(before / animLength);
+ long last = (long)Math.Floor(after / animLength);
+ // A clock that jumped many loops at once (a hitch) fires one loop's worth.
+ if (last - first > 1) first = last - 1;
+ List<(double At, int Sfx)>? hits = null;
+ for (long k = first; k <= last; k++)
+ {
+ foreach (var n in notes)
+ {
+ int sfx = SfxNumber(n.Token);
+ if (sfx == 0) continue;
+ double at = k * (double)animLength + Math.Clamp(n.Time, 0f, 1f) * animLength;
+ if ((at > before && at <= after) || (before == 0 && at == 0))
+ (hits ??= new()).Add((at, sfx));
+ }
+ }
+ if (hits is null) return;
+ hits.Sort((a, b) => a.At.CompareTo(b.At));
+ foreach (var h in hits) into.Add(h.Sfx);
+ }
}
/// Phase 18 — one in-flight attack iteration, DS1's model: the clip
diff --git a/src/SiegeFX.Core/Actors/TriggerRuntime.cs b/src/SiegeFX.Core/Actors/TriggerRuntime.cs
index 5366ee3b..aeacd0a5 100644
--- a/src/SiegeFX.Core/Actors/TriggerRuntime.cs
+++ b/src/SiegeFX.Core/Actors/TriggerRuntime.cs
@@ -504,7 +504,7 @@ bool EvaluateCondition(TriggerInstance trig, TriggerCall cond, TriggerContext ct
// posts targeted at trig.Scid. Match is by message name (arg 0).
if (cond.Args.Count == 0) return false;
var name = cond.Args[0];
- return state.ConsumeReceivedMessage(name);
+ return state.ConsumeReceivedMessage(name, MessageQualifier(cond));
}
default:
return false;
@@ -553,7 +553,7 @@ void Dispatch(TriggerInstance trig, TriggerCall act, TriggerContext ctx, bool de
var scriptName = act.Args[0];
IReadOnlyList? scriptArgs =
act.Args.Count > 1 ? act.Args.Skip(1).ToList() : null;
- ctx.CallSfxScript(scriptName, scriptArgs, trig.Position);
+ ctx.CallSfxScript(scriptName, scriptArgs, trig.Position, trig.Scid);
return;
}
// ALPHA-2B — runtime camera-flag flips on a specific snode:
@@ -591,7 +591,10 @@ void Dispatch(TriggerInstance trig, TriggerCall act, TriggerContext ctx, bool de
/// message into every row's pending-message inbox; rows whose
/// receive_world_message condition matches will consume the stamp on their
/// next eval.
- public void PostInboundMessage(uint targetScid, string name)
+ /// SC-ANIM-SFX — the message's qualifier, matched
+ /// against a receive_world_message(name, N) condition's N
+ /// (WE_ANIM_SFX carries its SFX note number). Null = none.
+ public void PostInboundMessage(uint targetScid, string name, int? data = null)
{
// SC-TRIGGER-ARM — we_trigger_activate/_deactivate are ACTIVATION
// messages, not inbox messages: they flip the instance's IsActive.
@@ -605,10 +608,15 @@ public void PostInboundMessage(uint targetScid, string name)
if (_instances[i].Scid != targetScid) continue;
if (arm) _instances[i].IsActive = true;
else if (disarm) _instances[i].IsActive = false;
- else _instances[i].DepositMessage(name);
+ else _instances[i].DepositMessage(name, data);
}
}
+ /// The integer second argument of a receive_world_message
+ /// condition, or null when it authors none.
+ internal static int? MessageQualifier(TriggerCall cond) =>
+ TryIntArg(cond, 1, out var q) ? q : null;
+
static bool TryFloatArg(TriggerCall call, int idx, out float v)
{
v = 0;
@@ -681,8 +689,20 @@ public struct TriggerRowState
/// run more than a couple deep between ticks.
InboundMessages _inbox;
- public void DepositMessage(string name) => _inbox.Push(name);
- public bool ConsumeReceivedMessage(string name) => _inbox.TryConsume(name);
+ /// SC-ANIM-SFX — the message's qualifier (WE_ANIM_SFX
+ /// carries its note number 1-4); null when the poster sends none.
+ public void DepositMessage(string name, int? data = null) => _inbox.Push(new(name, data));
+ /// The condition's second argument
+ /// (receive_world_message("we_anim_sfx",1)). A message and a
+ /// condition that both carry one must agree; either side without one
+ /// matches by name alone (the pre-qualifier behavior).
+ public bool ConsumeReceivedMessage(string name, int? qualifier = null) => _inbox.TryConsume(name, qualifier);
+
+ readonly record struct Msg(string Name, int? Data)
+ {
+ public bool Matches(string name, int? qualifier) =>
+ Name == name && (qualifier is null || Data is null || Data == qualifier);
+ }
struct InboundMessages
{
@@ -690,23 +710,23 @@ struct InboundMessages
// dropped — DS1's tightest trigger chain is never that deep, and a runaway
// would be a content authoring bug we'd rather catch as "missed expected
// message" than silently grow an unbounded queue.
- string? _m0, _m1, _m2, _m3;
+ Msg? _m0, _m1, _m2, _m3;
- public void Push(string name)
+ public void Push(Msg m)
{
- if (_m0 is null) { _m0 = name; return; }
- if (_m1 is null) { _m1 = name; return; }
- if (_m2 is null) { _m2 = name; return; }
- if (_m3 is null) { _m3 = name; return; }
+ if (_m0 is null) { _m0 = m; return; }
+ if (_m1 is null) { _m1 = m; return; }
+ if (_m2 is null) { _m2 = m; return; }
+ if (_m3 is null) { _m3 = m; return; }
// Inbox full — silently drop. Future SC can add an overflow counter.
}
- public bool TryConsume(string name)
+ public bool TryConsume(string name, int? qualifier)
{
- if (_m0 is not null && _m0 == name) { Compact(0); return true; }
- if (_m1 is not null && _m1 == name) { Compact(1); return true; }
- if (_m2 is not null && _m2 == name) { Compact(2); return true; }
- if (_m3 is not null && _m3 == name) { Compact(3); return true; }
+ if (_m0 is { } a && a.Matches(name, qualifier)) { Compact(0); return true; }
+ if (_m1 is { } b && b.Matches(name, qualifier)) { Compact(1); return true; }
+ if (_m2 is { } c && c.Matches(name, qualifier)) { Compact(2); return true; }
+ if (_m3 is { } d && d.Matches(name, qualifier)) { Compact(3); return true; }
return false;
}
@@ -780,12 +800,28 @@ public TriggerInstance(uint scid, uint nodeGuid, Vector3 position, TriggerMatrix
public ref TriggerRowState RowStateAt(int rowIndex) => ref _rowStates[rowIndex];
- public void DepositMessage(string name)
+ public void DepositMessage(string name, int? data = null)
{
// Every row of the matrix sees the same inbox stream — DS1 trigger rows are
// sibling listeners, not exclusive. Each row consumes (or doesn't) on its
- // own evaluation pass.
- for (int i = 0; i < _rowStates.Length; i++) _rowStates[i].DepositMessage(name);
+ // own evaluation pass. SC-ANIM-SFX — only rows that listen for this message
+ // get it: a row never consumes what it doesn't listen for, so a looping
+ // anim's WE_ANIM_SFX(2) used to pile up in the SFX1 row's 4-slot inbox and,
+ // once full, the inbox dropped the messages that row did want.
+ for (int i = 0; i < _rowStates.Length; i++)
+ if (Listens(Matrix.Rows[i], name, data)) _rowStates[i].DepositMessage(name, data);
+ }
+
+ static bool Listens(TriggerRow row, string name, int? data)
+ {
+ foreach (var c in row.Conditions)
+ {
+ if (!c.Verb.Equals("receive_world_message", StringComparison.OrdinalIgnoreCase)) continue;
+ if (c.Args.Count == 0 || c.Args[0] != name) continue;
+ var q = TriggerRuntime.MessageQualifier(c);
+ if (q is null || data is null || q == data) return true;
+ }
+ return false;
}
}
@@ -839,5 +875,8 @@ public virtual void ChangeQuestState(IReadOnlyList args) { }
/// implementation is a no-op so the audit CLI's headless context can still
/// drive trigger fan-out without dragging the Runtime project in. Live
/// hosts override to dispatch into SfxRuntime.
- public virtual void CallSfxScript(string scriptName, IReadOnlyList? args, Vector3 origin) { }
+ /// The trigger's SCID. For an actor's own
+ /// [template_triggers] (the blacksmith's hammer sparks) the host plays the
+ /// script where that actor stands now, not where it spawned.
+ public virtual void CallSfxScript(string scriptName, IReadOnlyList? args, Vector3 origin, uint ownerScid) { }
}
diff --git a/src/SiegeFX.Core/Sfx/SfxContext.cs b/src/SiegeFX.Core/Sfx/SfxContext.cs
index fdc1e97f..445cd026 100644
--- a/src/SiegeFX.Core/Sfx/SfxContext.cs
+++ b/src/SiegeFX.Core/Sfx/SfxContext.cs
@@ -77,6 +77,14 @@ public Vector3 ResolveTargetBone(string boneToken)
/// have a single anchor — both source and target collapse to it.
public static SfxContext At(Vector3 origin) => new(origin, origin, origin);
+ /// SC-SFX-OWNER-FACING — the facing of the object the script
+ /// runs on when source and target are the same spot (an actor's own
+ /// [template_triggers] call_sfx_script). offset(x,y,z) is in that
+ /// object's frame: the blacksmith's hammer sparks author offset(0,0,.8),
+ /// 0.8 m in front of him, onto the anvil. Null = no owner facing; the
+ /// frame then falls back to world +Z.
+ public Vector3? OwnerForward { get; init; }
+
/// Resolve a DS1 logical bone name to a caster-skeleton world
/// position. Strips the leading @ if present, calls the
/// configured , and falls back to the per-bone
diff --git a/src/SiegeFX.Core/Sfx/SfxRuntime.cs b/src/SiegeFX.Core/Sfx/SfxRuntime.cs
index ea223181..6f89dd5c 100644
--- a/src/SiegeFX.Core/Sfx/SfxRuntime.cs
+++ b/src/SiegeFX.Core/Sfx/SfxRuntime.cs
@@ -1278,11 +1278,21 @@ struct DelayedStart
/// Phase 23d-2a — build the caster-frame world offset for
/// `offset(x,y,z)`: forward = source→target flattened to XZ (a caster
- /// faces its target during a cast), up = world Y, right = their cross.
- static Vector3 CasterFrameOffset(in SfxContext ctx, Vector3 off)
+ /// faces its target during a cast), up = world Y, right = their cross.
+ /// SC-SFX-OWNER-FACING — with no separate target (source == target, an
+ /// actor's own trigger script) forward is the owner's facing
+ /// (). It used to be world +Z, so
+ /// the blacksmith's offset(0,0,.8) anvil sparks landed 0.8 m along +Z
+ /// whichever way he faced — behind him at the Elddim smithy.
+ public static Vector3 CasterFrameOffset(in SfxContext ctx, Vector3 off)
{
var fwd = ctx.TargetPos - ctx.SourcePos;
fwd.Y = 0f;
+ if (fwd.LengthSquared() <= 0.0001f && ctx.OwnerForward is { } own)
+ {
+ fwd = own;
+ fwd.Y = 0f;
+ }
fwd = fwd.LengthSquared() > 0.0001f ? Vector3.Normalize(fwd) : Vector3.UnitZ;
var right = Vector3.Normalize(Vector3.Cross(Vector3.UnitY, fwd));
return right * off.X + Vector3.UnitY * off.Y + fwd * off.Z;
diff --git a/src/SiegeFX.Runtime/Render/RenderHost.AnimSfx.cs b/src/SiegeFX.Runtime/Render/RenderHost.AnimSfx.cs
new file mode 100644
index 00000000..681197ce
--- /dev/null
+++ b/src/SiegeFX.Runtime/Render/RenderHost.AnimSfx.cs
@@ -0,0 +1,62 @@
+using System.Numerics;
+using SiegeFX.Core.Actors;
+using SiegeFX.Core.Assets;
+
+namespace SiegeFX.Runtime.Render;
+
+// ── SC-ANIM-SFX — animation sound notes reach the actor's triggers ──
+// DS1's chore skrits watch the blender for ANIMEVENT_SFX_1..4 (the SFX1..SFX4
+// notes a clip authors) and post WE_ANIM_SFX(n) to the actor itself; the
+// template's [template_triggers] answer with receive_world_message
+// ("we_anim_sfx", n) → call_sfx_script(...). The Elddim / Stonebridge /
+// Glacern blacksmith's hammering fidget carries SFX1 at 62.5 %, and his row
+// plays "blacksmith_hammer" (sparks + s_e_env_hammer_anvil). Our skrit
+// bridge's UpdateBlender reports no events, so nothing ever posted the
+// message and he hammered in silence. The draw clock owns clip time here, so
+// the advance loop reports each crossed note instead.
+//
+// Local on every machine, like DS1's blender: a co-op host and client each
+// animate the actor and each play the sound — nothing goes over the wire.
+public sealed partial class RenderHost
+{
+ private readonly List _animSfxScratch = new(4);
+
+ /// Post WE_ANIM_SFX(n) to for every SFX
+ /// note its clip passed while its clock went from
+ /// to . The hero is left out: its swing/cast
+ /// sounds run off the attack system's own note handling.
+ private void DispatchAnimSfxNotes(ActorRenderState s, PrsAnimation clip, double before, double after)
+ {
+ if (s.IsPlayer || clip.Notes.Count == 0 || after <= before) return;
+ _animSfxScratch.Clear();
+ AnimNotes.SfxCrossed(clip.Notes, clip.AnimLength, before, after, _animSfxScratch);
+ if (_animSfxScratch.Count == 0) return;
+ uint scid = s.Actor.Instance.Scid;
+ foreach (int n in _animSfxScratch)
+ {
+ _actorBus?.Post("we_anim_sfx", scid, scid, n, 0);
+ _triggerRuntime?.PostInboundMessage(scid, "we_anim_sfx", n);
+ }
+ }
+
+ /// A trigger's call_sfx_script. When the trigger is a live
+ /// actor's own ([template_triggers]: the blacksmith's hammer), the script
+ /// runs where the actor stands NOW and in its facing, so offset(x,y,z)
+ /// lands in front of / beside it as authored. Region triggers and
+ /// emitters (no actor with that SCID) keep their placement position.
+ internal void OnTriggerCallSfxScript(string scriptName, IReadOnlyList? args, Vector3 origin, uint ownerScid)
+ {
+ if (ownerScid != 0)
+ foreach (var s in _actors)
+ {
+ if (s.IsPlayer || s.Actor.Instance.Scid != ownerScid) continue;
+ var xf = s.CurrentTransform;
+ // Actors face their local +Z (the authored-facing convention).
+ var fwd = Vector3.TransformNormal(Vector3.UnitZ, xf);
+ _sfxRuntime?.Spawn(scriptName,
+ SiegeFX.Core.Sfx.SfxContext.At(xf.Translation) with { OwnerForward = fwd }, args);
+ return;
+ }
+ OnTriggerCallSfxScript(scriptName, args, origin);
+ }
+}
diff --git a/src/SiegeFX.Runtime/Render/RenderHost.cs b/src/SiegeFX.Runtime/Render/RenderHost.cs
index dd4254b8..ca686fa1 100644
--- a/src/SiegeFX.Runtime/Render/RenderHost.cs
+++ b/src/SiegeFX.Runtime/Render/RenderHost.cs
@@ -17,7 +17,7 @@ namespace SiegeFX.Runtime.Render;
/// Phase 3 just draws a reference grid so WASD + mouse-look can be verified;
/// later phases will hang real scene objects off this.
///
-public sealed class RenderHost : IDisposable
+public sealed partial class RenderHost : IDisposable
{
private readonly IWindow _window;
private readonly string? _meshPath;
@@ -26881,6 +26881,7 @@ private void OnUpdate(double dt)
var clip = s.Actor.Clips[Math.Min(idx, s.Actor.Clips.Length - 1)];
if (clip.AnimLength > 0f)
{
+ double animBefore = s.AnimTime;
if (isDead)
{
float endHold = clip.AnimLength - 0.01f;
@@ -26904,6 +26905,8 @@ private void OnUpdate(double dt)
{
s.AnimTime += dt;
}
+ // SC-ANIM-SFX — SFX notes the clip just passed (RenderHost.AnimSfx.cs).
+ if (!isDead) DispatchAnimSfxNotes(s, clip, animBefore, s.AnimTime);
}
}
}
diff --git a/src/SiegeFX.Runtime/Render/RenderHostTriggerContext.cs b/src/SiegeFX.Runtime/Render/RenderHostTriggerContext.cs
index f07301eb..608e5f29 100644
--- a/src/SiegeFX.Runtime/Render/RenderHostTriggerContext.cs
+++ b/src/SiegeFX.Runtime/Render/RenderHostTriggerContext.cs
@@ -78,9 +78,9 @@ public override void SetInterestRadius(float radius)
_host.OnTriggerSetInterestRadius(radius);
}
- public override void CallSfxScript(string scriptName, IReadOnlyList? args, Vector3 origin)
+ public override void CallSfxScript(string scriptName, IReadOnlyList? args, Vector3 origin, uint ownerScid)
{
- _host.OnTriggerCallSfxScript(scriptName, args, origin);
+ _host.OnTriggerCallSfxScript(scriptName, args, origin, ownerScid);
}
public override void FadeNodes(string verb, IReadOnlyList args)
diff --git a/src/SiegeFX.Tools/Program.cs b/src/SiegeFX.Tools/Program.cs
index c1b9c2af..49eebc83 100644
--- a/src/SiegeFX.Tools/Program.cs
+++ b/src/SiegeFX.Tools/Program.cs
@@ -560,6 +560,8 @@ static int CmdPrsInfo(string[] a)
Console.WriteLine($"Length : {prs.AnimLength:F4} s");
Console.WriteLine($"Root travel : ({prs.RootTravel.X:F2}, {prs.RootTravel.Y:F2}, {prs.RootTravel.Z:F2})");
Console.WriteLine($"Notes : {prs.Notes.Count}");
+ foreach (var n in prs.Notes)
+ Console.WriteLine($" t={n.Time:F3} '{System.Text.Encoding.ASCII.GetString(BitConverter.GetBytes(n.Token))}' (0x{n.Token:X8})");
Console.WriteLine($"Tracers : {prs.TracerCount}");
int bonesKeyed = 0, totalRot = 0, totalPos = 0;
diff --git a/tests/SiegeFX.Tests/AnimSfxTests.cs b/tests/SiegeFX.Tests/AnimSfxTests.cs
new file mode 100644
index 00000000..77e036b5
--- /dev/null
+++ b/tests/SiegeFX.Tests/AnimSfxTests.cs
@@ -0,0 +1,128 @@
+using System.Numerics;
+using SiegeFX.Core.Actors;
+using SiegeFX.Core.Assets;
+
+namespace SiegeFX.Tests;
+
+public class AnimSfxTests
+{
+ const uint Sfx1 = 0x31584653, Sfx2 = 0x32584653, Fire = 0x45524946;
+ // a_c_gbn_bs_fs2_dsf: 1.333 s, SFX1 at 0.625.
+ static readonly PrsAnimation.NoteEvent[] Hammer = { new(0.625f, Sfx1) };
+ const float Len = 1.3333f;
+
+ static List Crossed(IReadOnlyList notes, double a, double b)
+ {
+ var l = new List();
+ AnimNotes.SfxCrossed(notes, Len, a, b, l);
+ return l;
+ }
+
+ [Fact]
+ public void TheHammerNoteFiresOncePerLoopEveryLoop()
+ {
+ // Step a looping idle at 60 fps for 10 loops: one strike each.
+ int strikes = 0;
+ double t = 0;
+ for (int i = 0; i < 800; i++)
+ {
+ double next = t + 1.0 / 60;
+ strikes += Crossed(Hammer, t, next).Count;
+ t = next;
+ }
+ Assert.Equal((int)Math.Floor((t - 0.625 * Len) / Len) + 1, strikes);
+ Assert.True(strikes >= 9);
+ }
+
+ [Fact]
+ public void OnlySfxNotesCountAndTheyKeepTheirNumbers()
+ {
+ var notes = new PrsAnimation.NoteEvent[] { new(0.8f, Sfx2), new(0.2f, Fire), new(0.1f, Sfx1) };
+ Assert.Equal(new[] { 1, 2 }, Crossed(notes, 0.01, Len));
+ }
+
+ [Fact]
+ public void NoTimePassingFiresNothing()
+ {
+ var atZero = new PrsAnimation.NoteEvent[] { new(0f, Sfx1) };
+ Assert.Empty(Crossed(atZero, 0, 0)); // paused on a fresh clip
+ Assert.Equal(new[] { 1 }, Crossed(atZero, 0, 0.02)); // the clip's first frame
+ Assert.Empty(Crossed(Hammer, 0.9, 0.9));
+ }
+
+ [Fact]
+ public void AHitchFiresAtMostOneLoopsWorth()
+ {
+ Assert.Single(Crossed(Hammer, 0.1, 0.1 + 20 * Len));
+ }
+
+ static (TriggerRuntime rt, TriggerInstance trig) BlacksmithLikeTrigger(string rows)
+ {
+ var doc = GasDocument.Parse("[common] { [template_triggers] { " + rows + " } }");
+ var triggers = doc.Roots.Single().Children.Single();
+ var matrix = new TriggerMatrix(triggers.Children
+ .Select(r => TriggerRow.Parse(r, "test", null)).ToList());
+ var trig = new TriggerInstance(0x03200c26, 0, Vector3.Zero, matrix, startActive: true);
+ var rt = new TriggerRuntime();
+ rt.Register(trig);
+ return (rt, trig);
+ }
+
+ sealed class SfxCtx : TriggerContext
+ {
+ public readonly List Calls = new();
+ public override void CallSfxScript(string scriptName, IReadOnlyList? args, Vector3 origin, uint ownerScid)
+ => Calls.Add($"{scriptName}@{ownerScid:X8}");
+ }
+
+ [Fact]
+ public void WeAnimSfxRunsTheTemplatesSfxScriptEveryTime()
+ {
+ var (rt, _) = BlacksmithLikeTrigger("""
+ [*] { action* = call_sfx_script("blacksmith_hammer"); condition* = receive_world_message("we_anim_sfx",1); }
+ """);
+ var ctx = new SfxCtx();
+ for (int loop = 0; loop < 8; loop++)
+ {
+ rt.PostInboundMessage(0x03200c26, "we_anim_sfx", 1);
+ rt.Tick(0.7, ctx);
+ rt.Tick(0.7, ctx);
+ }
+ Assert.Equal(8, ctx.Calls.Count);
+ Assert.All(ctx.Calls, c => Assert.Equal("blacksmith_hammer@03200C26", c));
+ }
+
+ [Fact]
+ public void TheNoteNumberPicksTheRowAndOtherNumbersDoNotClogIt()
+ {
+ // giant_spider-style: one row per foot note.
+ var (rt, _) = BlacksmithLikeTrigger("""
+ [*] { action* = call_sfx_script("spark_1"); condition* = receive_world_message("we_anim_sfx",1); }
+ [*] { action* = call_sfx_script("spark_2"); condition* = receive_world_message("we_anim_sfx",2); }
+ """);
+ var ctx = new SfxCtx();
+ for (int loop = 0; loop < 6; loop++)
+ {
+ rt.PostInboundMessage(0x03200c26, "we_anim_sfx", 2);
+ rt.Tick(0.1, ctx);
+ rt.Tick(0.1, ctx);
+ }
+ Assert.Equal(6, ctx.Calls.Count);
+ Assert.All(ctx.Calls, c => Assert.StartsWith("spark_2@", c));
+ rt.PostInboundMessage(0x03200c26, "we_anim_sfx", 1);
+ rt.Tick(0.1, ctx);
+ Assert.StartsWith("spark_1@", ctx.Calls[^1]);
+ }
+
+ [Fact]
+ public void AMessageWithoutAQualifierStillMatchesByName()
+ {
+ var (rt, _) = BlacksmithLikeTrigger("""
+ [*] { action* = call_sfx_script("x"); condition* = receive_world_message("we_req_activate",0); }
+ """);
+ var ctx = new SfxCtx();
+ rt.PostInboundMessage(0x03200c26, "we_req_activate");
+ rt.Tick(0.1, ctx);
+ Assert.Single(ctx.Calls);
+ }
+}
diff --git a/tests/SiegeFX.Tests/SfxOwnerFacingTests.cs b/tests/SiegeFX.Tests/SfxOwnerFacingTests.cs
new file mode 100644
index 00000000..145198be
--- /dev/null
+++ b/tests/SiegeFX.Tests/SfxOwnerFacingTests.cs
@@ -0,0 +1,34 @@
+using System.Numerics;
+using SiegeFX.Core.Sfx;
+
+namespace SiegeFX.Tests;
+
+public class SfxOwnerFacingTests
+{
+ static readonly Vector3 Smith = new(8.7f, 0.4f, -13.8f);
+
+ [Fact]
+ public void AnActorsOwnScriptOffsetsAlongItsFacing()
+ {
+ // The blacksmith's hammer sparks: offset(0,0,.8) is 0.8 m in front of him.
+ var ctx = SfxContext.At(Smith) with { OwnerForward = -Vector3.UnitX };
+ var off = SfxRuntime.CasterFrameOffset(ctx, new(0, 0, 0.8f));
+ Assert.Equal(-0.8f, off.X, 4);
+ Assert.Equal(0f, off.Z, 4);
+ }
+
+ [Fact]
+ public void WithoutAnOwnerFacingTheWorldZFrameStays()
+ {
+ var off = SfxRuntime.CasterFrameOffset(SfxContext.At(Smith), new(0, 0, 0.8f));
+ Assert.Equal(0.8f, off.Z, 4);
+ }
+
+ [Fact]
+ public void ACasterToTargetFrameStillWins()
+ {
+ var ctx = new SfxContext(Smith, Smith + new Vector3(0, 0, -5), Smith) { OwnerForward = Vector3.UnitX };
+ var off = SfxRuntime.CasterFrameOffset(ctx, new(0, 0, 1f));
+ Assert.Equal(-1f, off.Z, 4);
+ }
+}