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..cb2466d4 100644
--- a/src/SiegeFX.Core/Actors/ActorSpawner.cs
+++ b/src/SiegeFX.Core/Actors/ActorSpawner.cs
@@ -178,6 +178,17 @@ public IReadOnlyList Spawn(
return null;
}
+ // SC-NPC-ARMOR — worn body armor selects the pos_aN body mesh (plate =
+ // a3). Only for plain spawns: hero/avatar picks carry their own model.
+ if (DressArmoredBodies && overrides is null
+ && SiegeFX.Core.Assets.EquipmentResolver.ArmoredBodyModel(_store, modelName,
+ SiegeFX.Core.Assets.EquipmentResolver.CollectActorSlots(_store, template, inst.Node)) is { } armored
+ && GetOrLoadMesh(armored) is { } armoredMesh)
+ {
+ modelName = armored;
+ mesh = armoredMesh;
+ }
+
// chore_default is every actor's idle. Its {prefix, stance list, suffix} triple
// composes one PRS clip; the skrit lives alongside under `skrit = NAME`.
var chorePrefix = _store.GetAttribute(template, "body", "chore_dictionary", "chore_prefix");
@@ -295,17 +306,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 +318,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;
}
@@ -353,6 +373,21 @@ Matrix4x4 ComposeWorldTransform(NodePlacement p)
return local * nodeWorld;
}
+ /// SC-NPC-ARMOR — when true (default), a plain spawn whose worn
+ /// chest armor names another body type (plate = a3) spawns on that pos_aN
+ /// body mesh, as DS1 does. The host turns it off around the hero spawn,
+ /// whose armor is a live texture swap on a fixed body.
+ public bool DressArmoredBodies { get; set; } = true;
+
+ /// The model name a spawned actor's was
+ /// loaded from (meshes are cached per name), or null.
+ public string? ModelNameOf(AspMesh mesh)
+ {
+ foreach (var (name, m) in _meshCache)
+ if (ReferenceEquals(m, mesh)) return name;
+ return null;
+ }
+
AspMesh? GetOrLoadMesh(string modelName)
{
if (_meshCache.TryGetValue(modelName, out var cached)) return cached;
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/Assets/EquipmentResolver.cs b/src/SiegeFX.Core/Assets/EquipmentResolver.cs
index 98ab6fe8..ae819569 100644
--- a/src/SiegeFX.Core/Assets/EquipmentResolver.cs
+++ b/src/SiegeFX.Core/Assets/EquipmentResolver.cs
@@ -113,14 +113,19 @@ private static void EnsureLookupLoaded(AssetResolver resolver)
/// the template — picked-up items override / extend the template's authored
/// loadout. Slots that fail to resolve (missing item template, no mesh on
/// disk) are returned with so the caller can log
- /// them without crashing the spawn path.
+ /// them without crashing the spawn path.
+ /// names the body mesh actually drawn when it differs from the template's
+ /// aspect.model.
public static List Resolve(
TemplateStore templates, AssetResolver resolver, Template playerTemplate,
- IReadOnlyDictionary? liveSlots = null)
+ IReadOnlyDictionary? liveSlots = null,
+ string? bodyModelOverride = null)
{
var layers = new List();
var armorVersion = templates.GetAttribute(playerTemplate, "body", "armor_version");
- var bodyModel = templates.GetAttribute(playerTemplate, "aspect", "model");
+ // The body actually drawn — an NPC in plate spawns on its armored
+ // pos_aN mesh, and texture + boot/gauntlet subtypes must follow it.
+ var bodyModel = bodyModelOverride ?? templates.GetAttribute(playerTemplate, "aspect", "model");
var bodyType = ExtractBodyType(bodyModel); // e.g. m_c_gah_fb_pos_a1 → a1
var weaponBone = templates.GetAttribute(playerTemplate, "body", "bone_translator", "weapon_bone");
var shieldBone = templates.GetAttribute(playerTemplate, "body", "bone_translator", "shield_bone");
@@ -152,6 +157,58 @@ public static List Resolve(
return layers;
}
+ /// The es_* slots an NPC actually wears: the template chain's
+ /// [inventory][equipment], overlaid by the map instance's own
+ /// [inventory][equipment] block (instance wins per slot). Used to
+ /// feed for non-player actors, whose worn armor
+ /// (Elddim's Legionnaire guards: plate chest, helm, boots, gauntlets) lives
+ /// only in these blocks — their [aspect][textures] carries just the
+ /// skin, so without this they render in the bare citizen body.
+ public static Dictionary CollectActorSlots(
+ TemplateStore templates, Template template, GasNode? instance)
+ {
+ var slots = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ void Add(GasNode? equip)
+ {
+ if (equip is null) return;
+ foreach (var attr in equip.Attributes)
+ {
+ var name = attr.Name.Trim();
+ var value = attr.Value?.Trim();
+ if (!name.StartsWith("es_", StringComparison.OrdinalIgnoreCase)) continue;
+ if (string.IsNullOrEmpty(value)) continue;
+ slots[name] = value;
+ }
+ }
+ Add(templates.GetSection(template, "inventory", "equipment"));
+ if (instance is not null
+ && TemplateStore.FindChild(instance, "inventory") is { } inv)
+ Add(TemplateStore.FindChild(inv, "equipment"));
+ return slots;
+ }
+
+ /// DS1 body armor picks the body MESH as well as the texture: the
+ /// chest item's defend.armor_type (a1..a7) names the
+ /// pos_aN body it is drawn on — Legionnaire plate (a3) puts
+ /// the bulky m_c_gah_fb_pos_a3 body under b_c_pos_a3_014,
+ /// where the a1 body would show a thin shirt. Returns the armored model
+ /// for (m_c_gah_fb_pos_a1 →
+ /// m_c_gah_fb_pos_a3), or null when there is no chest armor, the
+ /// armor type is not a body type, it already matches, or the model does
+ /// not carry an aN suffix (monsters, dwarves' b1).
+ public static string? ArmoredBodyModel(
+ TemplateStore templates, string? bodyModel, IReadOnlyDictionary slots)
+ {
+ var bodyType = ExtractBodyType(bodyModel);
+ if (bodyType is null || bodyModel is null) return null;
+ if (!slots.TryGetValue("es_chest", out var chestRef) || string.IsNullOrEmpty(chestRef)) return null;
+ if (!templates.TryGet(chestRef.Trim(), out var chest)) return null;
+ var armorType = templates.GetAttribute(chest, "defend", "armor_type")?.Trim();
+ if (ExtractBodyType("_" + armorType) is not { } wanted) return null;
+ if (string.Equals(wanted, bodyType, StringComparison.OrdinalIgnoreCase)) return null;
+ return bodyModel[..^bodyType.Length] + wanted;
+ }
+
private static EquipmentLayer ResolveSlot(
TemplateStore templates, AssetResolver resolver,
string slotName, string itemRef,
diff --git a/src/SiegeFX.Core/Nav/NavMesh.cs b/src/SiegeFX.Core/Nav/NavMesh.cs
index 36311996..d0d867fd 100644
--- a/src/SiegeFX.Core/Nav/NavMesh.cs
+++ b/src/SiegeFX.Core/Nav/NavMesh.cs
@@ -356,6 +356,12 @@ private static bool EdgeWithinDiskXZ(Vector3 e0, Vector3 e1, float cx, float cz,
/// slot-starved fallback (see ).
public int DoorSeamOverflowCount { get; }
+ /// SC-NAV-SEAM-BARRIER — overflow links the door stitcher dropped
+ /// because every candidate hop crossed a barrier (an Ignored nav face standing
+ /// above the floor — e.g. a wooden fence piece's boards between its two floor
+ /// strips) and the two ends were connected some other way anyway.
+ public int DoorSeamBarrierRejectCount { get; private init; }
+
public int TriangleCount => Indices.Length / 3;
// XZ uniform-grid spatial index. Built once at construction and queried by
@@ -585,7 +591,7 @@ int Intern(Vector3 p)
// Runs BEFORE land↔water stitching so this pass's newly-paired
// edges aren't counted as boundary candidates by the water pass.
var extraLinksBuild = new Dictionary>();
- int doorSeams = StitchSnoDoorSeams(graph, layout, resolveSno, sourceSnodeGuid.ToArray(), indices, neighbors, vertsArr, extraLinksBuild, out int doorSeamOverflow);
+ int doorSeams = StitchSnoDoorSeams(graph, layout, resolveSno, sourceSnodeGuid.ToArray(), indices, neighbors, vertsArr, extraLinksBuild, out int doorSeamOverflow, out int doorSeamBarrierRejects);
// Land↔water seam stitching: shoreline Floor and Water SNOs are authored in
// separate meshes whose vertices don't fall inside the WeldToleranceUnits bucket,
// so the manifold pass leaves them on disconnected components. Wire cross-kind
@@ -681,7 +687,7 @@ int Intern(Vector3 p)
originZ,
cellsX,
cellsZ,
- grid);
+ grid) { DoorSeamBarrierRejectCount = doorSeamBarrierRejects };
}
/// SC-NAV-CROSS-SNO-STITCH — door-anchored cross-SNO nav
@@ -710,6 +716,36 @@ int Intern(Vector3 p)
// pairings (tread side edge vs. threshold edge). Matches the shoreline
// stitcher's |cos θ| gate.
private const float DoorSeamCollinearityCosMin = 0.85f;
+ // SC-NAV-SEAM-BARRIER — an Ignored edge crossing an overflow hop counts as a
+ // barrier when, at the crossing, it stands between these heights above the
+ // hop: above a kerb or a floor decal, below a lintel, arch or ceiling.
+ private const float BarrierMinRise = 0.25f;
+ private const float BarrierMaxRise = 2.0f;
+
+ /// SC-NAV-SEAM-BARRIER — does the hop →
+ /// cross (in XZ) one of the (flat endpoint pairs) at a
+ /// point where the edge stands between and
+ /// above the hop? A vertical fence board projects to
+ /// a line in XZ, so its top and diagonal edges are what register.
+ public static bool CrossesBarrier(Vector3 p, Vector3 q, Vector3[] edges)
+ {
+ float rx = q.X - p.X, rz = q.Z - p.Z;
+ for (int i = 0; i + 1 < edges.Length; i += 2)
+ {
+ var e0 = edges[i];
+ var e1 = edges[i + 1];
+ float sx = e1.X - e0.X, sz = e1.Z - e0.Z;
+ float denom = Cross2(rx, rz, sx, sz);
+ if (MathF.Abs(denom) < 1e-8f) continue; // parallel, or an edge seen end-on
+ float qpx = e0.X - p.X, qpz = e0.Z - p.Z;
+ float t = Cross2(qpx, qpz, sx, sz) / denom; // along the hop
+ float u = Cross2(qpx, qpz, rx, rz) / denom; // along the edge
+ if (t < 0f || t > 1f || u < 0f || u > 1f) continue;
+ float rise = (e0.Y + u * (e1.Y - e0.Y)) - (p.Y + t * (q.Y - p.Y));
+ if (rise >= BarrierMinRise && rise <= BarrierMaxRise) return true;
+ }
+ return false;
+ }
private static int StitchSnoDoorSeams(
RegionGraph graph,
@@ -720,9 +756,11 @@ private static int StitchSnoDoorSeams(
int[] neighbors,
Vector3[] verts,
Dictionary> extraLinks,
- out int overflowLinks)
+ out int overflowLinks,
+ out int overflowBarrierRejects)
{
overflowLinks = 0;
+ overflowBarrierRejects = 0;
int triCount = indices.Length / 3;
// Index unwelded edges by snode guid. Each entry is (tri, slot,
// midpoint, unit XZ direction, XZ length) — slot s is the edge
@@ -767,6 +805,56 @@ private static int StitchSnoDoorSeams(
}
Vector3 TriCentroid(int t) =>
(verts[indices[3 * t + 0]] + verts[indices[3 * t + 1]] + verts[indices[3 * t + 2]]) / 3f;
+ // SC-NAV-SEAM-BARRIER — edges of a snode's Ignored (non-walkable) nav faces,
+ // lifted to the region frame on first use: the authored solid parts of a
+ // piece (fence boards, rails, wall stubs). Flat pairs of edge endpoints.
+ var barrierEdges = new Dictionary();
+ var hops = new List<(int a, int b, float score)>();
+ // Hops that found no barrier-free pair: kept only if dropping them would
+ // cut the mesh apart (see the restore pass at the end).
+ var deferredHops = new List<(int a, int b, float score)>();
+ var hopCheckNodes = new HashSet();
+ // Barriers of both door snodes and of their door neighbours: at a fence
+ // corner the boards a hop crosses can belong to a third piece.
+ bool HopBlocked(Vector3 p, Vector3 q, RegionGraph.NodeInstance a, RegionGraph.NodeInstance b)
+ {
+ hopCheckNodes.Clear();
+ foreach (var end in (ReadOnlySpan)[a, b])
+ {
+ if (hopCheckNodes.Add(end.Guid) && CrossesBarrier(p, q, BarrierEdges(end))) return true;
+ foreach (var d in end.Doors)
+ {
+ if (!hopCheckNodes.Add(d.FarGuid) || !graph.TryGetNode(d.FarGuid, out var nb)) continue;
+ if (CrossesBarrier(p, q, BarrierEdges(nb))) return true;
+ }
+ }
+ return false;
+ }
+ Vector3[] BarrierEdges(RegionGraph.NodeInstance n)
+ {
+ if (barrierEdges.TryGetValue(n.Guid, out var cached)) return cached;
+ var list = new List();
+ var nodeSno = resolveSno(n.MeshGuid);
+ if (nodeSno is not null && layout.TryGetTransform(n.Guid, out var xf))
+ {
+ foreach (var g in nodeSno.LogicalGroupings)
+ {
+ if (g.Kind != SnoModel.FloorKind.Ignored) continue;
+ foreach (var f in g.Faces)
+ {
+ var a = Vector3.Transform(f.A, xf);
+ var b = Vector3.Transform(f.B, xf);
+ var c = Vector3.Transform(f.C, xf);
+ list.Add(a); list.Add(b);
+ list.Add(b); list.Add(c);
+ list.Add(c); list.Add(a);
+ }
+ }
+ }
+ var arr = list.ToArray();
+ barrierEdges[n.Guid] = arr;
+ return arr;
+ }
// Split a small tri set into its slot-connected local components.
// Stepped pieces fragment internally: riser faces are degenerate in XZ
// and dropped from the mesh, so each tread strip is its own island —
@@ -907,8 +995,7 @@ bool AnyLink(List ca, List cb)
foreach (var compB in LocalComponents(bNear))
{
if (AnyLink(compA, compB)) continue;
- int bestA = -1, bestB = -1;
- float bestScore = float.MaxValue;
+ hops.Clear();
foreach (var tA in compA)
{
var ca = TriCentroid(tA);
@@ -918,22 +1005,76 @@ bool AnyLink(List ca, List cb)
float dy = MathF.Abs(ca.Y - cb.Y);
if (dy > 1.0f) continue;
float ddx = ca.X - cb.X, ddz = ca.Z - cb.Z;
- float score = MathF.Sqrt(ddx * ddx + ddz * ddz) + dy;
- if (score < bestScore) { bestScore = score; bestA = tA; bestB = tB; }
+ hops.Add((tA, tB, MathF.Sqrt(ddx * ddx + ddz * ddz) + dy));
}
}
- if (bestA < 0) continue;
- if (!extraLinks.TryGetValue(bestA, out var la)) extraLinks[bestA] = la = new List();
- if (!la.Contains(bestB)) la.Add(bestB);
- if (!extraLinks.TryGetValue(bestB, out var lb)) extraLinks[bestB] = lb = new List();
- if (!lb.Contains(bestA)) lb.Add(bestA);
+ if (hops.Count == 0) continue;
+ // SC-NAV-SEAM-BARRIER — the link is a straight hop between two
+ // centroids; never let it jump a barrier either snode authors
+ // between them. Field case: multiplayer_world town_center, the
+ // wooden pig-pen fence by the co-op start (players walked
+ // straight through it, and through house walls). Each
+ // t_grs01_fence_generic_wood-* piece has a floor strip either
+ // side of its Ignored boards, every door off it lands here (the
+ // strips weld to their neighbours), and the nearest-centroid hop
+ // tied one side of the fence to the other. Best-first, so a
+ // barrier-free hop around an obstacle still wins over none.
+ hops.Sort((x, y) => x.score.CompareTo(y.score));
+ int bestA = -1, bestB = -1;
+ foreach (var (hA, hB, _) in hops)
+ {
+ if (HopBlocked(TriCentroid(hA), TriCentroid(hB), node, farNode)) continue;
+ bestA = hA;
+ bestB = hB;
+ break;
+ }
+ if (bestA < 0) { deferredHops.Add(hops[0]); continue; }
+ AddExtraLink(extraLinks, bestA, bestB);
overflowLinks++;
}
}
}
+
+ // SC-NAV-SEAM-BARRIER restore — a hop that only crossed barriers is dropped
+ // when its two ends are joined some other way (the fence pen has its gate),
+ // but kept when it is the only thing holding the two pieces of mesh
+ // together: an over-eager barrier verdict must not strand floor that the
+ // pre-barrier stitcher left reachable.
+ if (deferredHops.Count > 0)
+ {
+ var parent = new int[triCount];
+ for (int i = 0; i < triCount; i++) parent[i] = i;
+ int Find(int x)
+ {
+ while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; }
+ return x;
+ }
+ void Union(int x, int y) { x = Find(x); y = Find(y); if (x != y) parent[x] = y; }
+ for (int t = 0; t < triCount; t++)
+ for (int s = 0; s < 3; s++)
+ if (neighbors[3 * t + s] >= 0) Union(t, neighbors[3 * t + s]);
+ foreach (var (t, links) in extraLinks)
+ foreach (var nb in links) Union(t, nb);
+ deferredHops.Sort((x, y) => x.score.CompareTo(y.score));
+ foreach (var (a, b, _) in deferredHops)
+ {
+ if (Find(a) == Find(b)) { overflowBarrierRejects++; continue; }
+ Union(a, b);
+ AddExtraLink(extraLinks, a, b);
+ overflowLinks++;
+ }
+ }
return stitched;
}
+ private static void AddExtraLink(Dictionary> extraLinks, int a, int b)
+ {
+ if (!extraLinks.TryGetValue(a, out var la)) extraLinks[a] = la = new List();
+ if (!la.Contains(b)) la.Add(b);
+ if (!extraLinks.TryGetValue(b, out var lb)) extraLinks[b] = lb = new List();
+ if (!lb.Contains(a)) lb.Add(a);
+ }
+
/// Wires cross-kind adjacencies for Floor↔Water boundary edges that visually
/// share a shoreline. DS1 authors water surfaces in separate SNOs whose vertices don't
/// weld to the floor under , so a vertex-equality
diff --git a/src/SiegeFX.Core/Net/MpSession.cs b/src/SiegeFX.Core/Net/MpSession.cs
index fba7b93d..b61b03ef 100644
--- a/src/SiegeFX.Core/Net/MpSession.cs
+++ b/src/SiegeFX.Core/Net/MpSession.cs
@@ -114,9 +114,16 @@ public sealed class MpSession : IDisposable
public MpSession(ISessionTransport transport, bool isHost, string localName)
{
_t = transport; IsHost = isHost; _localName = localName ?? "Player";
- _t.PeerDisconnected += OnPeerGone;
+ _t.PeerDisconnected += QueuePeerGone;
}
+ // Transports raise PeerDisconnected from their own threads (the LAN
+ // keepalive task, EOS callbacks). OnPeerGone fires OnPlayerLeft, whose
+ // handlers edit the render host's actor list, so it must run on the
+ // thread that ticks the session — queue it and drain it in Tick.
+ readonly System.Collections.Concurrent.ConcurrentQueue _gonePeers = new();
+ void QueuePeerGone(int peer) => _gonePeers.Enqueue(peer);
+
void OnPeerGone(int peer)
{
if (IsHost)
@@ -297,6 +304,8 @@ public void SendRecruitGrant(uint scid, int owningPlayer)
/// Call once per engine frame with the frame dt.
public void Tick(double dt)
{
+ while (_gonePeers.TryDequeue(out int gone))
+ OnPeerGone(gone);
while (_t.TryReceive(out int peer, out var data))
Handle(peer, data);
@@ -684,6 +693,6 @@ void RelayChat(int fromPlayer, string text)
public void Dispose()
{
- _t.PeerDisconnected -= OnPeerGone;
+ _t.PeerDisconnected -= QueuePeerGone;
}
}
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/NetSelfTest.cs b/src/SiegeFX.Runtime/NetSelfTest.cs
index fa72a8d1..b94685b8 100644
--- a/src/SiegeFX.Runtime/NetSelfTest.cs
+++ b/src/SiegeFX.Runtime/NetSelfTest.cs
@@ -198,6 +198,7 @@ void Assert(bool cond, string what)
"in-region: GameStart delivered region + difficulty to the client");
c2.Dispose(); // client drops — host must be told player 1 left
+ hs.Tick(0); // (on its next tick: transports report leaves off-thread)
Assert(hostSawLeave == 1, $"in-region: host fired OnPlayerLeft on disconnect (got {hostSawLeave})");
hs.Dispose(); cs.Dispose(); h2.Dispose();
}
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 88d1fcc7..9470aae5 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;
@@ -1644,6 +1644,13 @@ private sealed record ItemMesh(StaticMesh Mesh, GlTexture? Texture, string? Disp
// TrySpawnPlayer; rebuilt on every equipment change.
private readonly List _equippedLayers = new();
private string? _chestTexOverrideName;
+ // SC-NPC-ARMOR — NPC worn armor. Chest = slot-1 texture override keyed by
+ // actor (weak, so despawned actors drop out); layered meshes are cached per
+ // (mesh, texture) and shared by every NPC wearing the same piece.
+ private readonly System.Runtime.CompilerServices.ConditionalWeakTable
+ _npcChestTex = new();
+ private readonly Dictionary<(string Mesh, string? Tex), EquippedLayer?> _npcArmorLayerCache = new();
+ private readonly HashSet _npcChestLogged = new(StringComparer.OrdinalIgnoreCase);
// 21d-2a-viii — character creator picks resolved at spawn. Skin = body
// subset 0 (face/hair/arms); pants = body subset 1 (clothing strip). Both
// are nulled out for NPCs and for player templates without a matching env
@@ -2077,6 +2084,11 @@ private sealed class ActorRenderState
public int ShieldBoneIdx = -1;
public string? WieldedWeaponRef;
public bool WeaponDroppedOnDeath;
+ // SC-NPC-ARMOR — worn helm/boots/gauntlets, skinned to the body's
+ // skeleton and posed with the body's clip each frame. Entries are
+ // SHARED via _npcArmorLayerCache (never disposed per-actor). Chest
+ // armor is a body slot-1 texture swap kept in _npcChestTex.
+ public List? ArmorLayers;
// SC-ALIGNMENT — actor_evil in the specializes chain = a valid
// player attack target. DS1 never lets you attack good/neutral
// NPCs (Norick, guards, kings) — no attack cursor, no swing.
@@ -15648,6 +15660,14 @@ private void AlphaOver(byte[] dst, string? overlayName, int w, int h)
$"player slot {textureIndex}: no picker -> texset fallback");
}
}
+ // SC-NPC-ARMOR — an NPC's worn chest armor replaces the clothing strip
+ // (Legionnaire plate instead of the citizen shirt), same rule as the
+ // player's override above. A missing .raw falls through to the texset.
+ if (textureIndex == 1 && _npcChestTex.TryGetValue(actor, out var npcChest))
+ {
+ var ct = LoadEquipmentTexture("__chest__", npcChest);
+ if (ct is not null) return ct;
+ }
var key = (actor.Template, actor.Mesh, textureIndex);
if (_actorTextureCache.TryGetValue(key, out var cached)) return cached;
string? baseName = null;
@@ -25460,6 +25480,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;
@@ -25483,6 +25504,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);
}
}
}
@@ -28829,7 +28852,12 @@ private void TrySpawnPlayerWithPicker(
$"=> model={heroOverride?.ModelName ?? ""}");
var diagsBefore = spawner.Diagnostics.Count;
- var spawned = spawner.Spawn(new[] { inst }, preferredStance, overrides);
+ // SC-NPC-ARMOR — the hero keeps his picked body; worn chest armor is
+ // a live slot-1 texture swap on it (TryLoadPlayerEquipment).
+ spawner.DressArmoredBodies = false;
+ IReadOnlyList spawned;
+ try { spawned = spawner.Spawn(new[] { inst }, preferredStance, overrides); }
+ finally { spawner.DressArmoredBodies = true; }
if (spawned.Count == 0)
{
Console.WriteLine($" player: '{playerTemplate}' did not spawn (spawner diagnostics follow)");
@@ -29588,6 +29616,8 @@ private void ResolveNpcEquippedGear(ActorRenderState s)
var tpl = s.Actor.Template;
var inst = s.Actor.Instance.Node;
+ ResolveNpcWornArmor(s, SiegeFX.Core.Assets.EquipmentResolver.CollectActorSlots(store, tpl, inst));
+
string? FromInstanceEquipment(string slot)
{
if (SiegeFX.Core.Assets.TemplateStore.FindChild(inst, "inventory") is not { } inv) return null;
@@ -29702,6 +29732,84 @@ int FindBone(string name)
}
}
+ /// SC-NPC-ARMOR — give a non-player actor the worn armor its
+ /// es_* slots author, through the same
+ /// rules as the player: es_chest swaps body slot 1 to b_c_pos_*,
+ /// helm/boots/gauntlets layer skinned meshes. Weapons and shields stay
+ /// with . Without this Elddim's
+ /// Legionnaire guards (whose [aspect][textures] author only the skin)
+ /// rendered in the bare citizen body.
+ private void ResolveNpcWornArmor(ActorRenderState s, IReadOnlyDictionary slots)
+ {
+ _npcChestTex.Remove(s.Actor);
+ s.ArmorLayers = null;
+ if (s.IsPlayer) return;
+ if (_templateStore is null || _gl is null || _playResolver is null || slots.Count == 0) return;
+ // The body mesh actually drawn (plate NPCs spawn on pos_a3), so the
+ // chest texture and boot/gauntlet subtypes match its UV layout.
+ var bodyModel = _actorSpawner?.ModelNameOf(s.Actor.Mesh);
+ var layers = SiegeFX.Core.Assets.EquipmentResolver.Resolve(
+ _templateStore, _playResolver, s.Actor.Template, slots, bodyModel);
+ foreach (var layer in layers)
+ {
+ switch (layer.Strategy)
+ {
+ case SiegeFX.Core.Assets.EquipmentResolver.Strategy.ChestTexture
+ when !string.IsNullOrEmpty(layer.OverrideBaseName):
+ _npcChestTex.AddOrUpdate(s.Actor, layer.OverrideBaseName!);
+ if (_npcChestLogged.Add(layer.OverrideBaseName!))
+ Console.WriteLine($" npc-armor: {s.Actor.Template.Name} chest '{layer.ItemRef}' -> slot-1 '{layer.OverrideBaseName}'");
+ break;
+ case SiegeFX.Core.Assets.EquipmentResolver.Strategy.SkinnedLayer
+ when !string.IsNullOrEmpty(layer.MeshBaseName):
+ if (GetNpcArmorLayer(layer) is { } el)
+ (s.ArmorLayers ??= new List()).Add(el);
+ break;
+ }
+ }
+ }
+
+ /// SC-NPC-ARMOR — load (once) the skinned mesh + texture for a
+ /// worn armor piece; shared by every NPC wearing it. Null = mesh missing
+ /// or unskinned (logged once).
+ private EquippedLayer? GetNpcArmorLayer(SiegeFX.Core.Assets.EquipmentResolver.EquipmentLayer layer)
+ {
+ var meshName = layer.MeshBaseName!;
+ var key = (meshName, layer.TextureBaseName);
+ if (_npcArmorLayerCache.TryGetValue(key, out var hit)) return hit;
+ EquippedLayer? built = null;
+ try
+ {
+ if (_gl is not null && _playResolver is not null
+ && _playResolver.TryLoadModel(meshName, out var aspBytes))
+ {
+ var asp = SiegeFX.Core.Assets.AspMesh.Load(aspBytes);
+ if (asp.HasSkin && asp.BoneCount > 0 && asp.BoneCount <= SkinnedMesh.MaxBones)
+ {
+ var texName = layer.TextureBaseName;
+ if (string.IsNullOrEmpty(texName) && asp.TextureNames.Count > 0)
+ texName = asp.TextureNames[0];
+ built = new EquippedLayer
+ {
+ Asp = asp, Mesh = new SkinnedMesh(_gl, asp),
+ Texture = LoadEquipmentTexture(meshName, texName),
+ SlotName = layer.SlotName, ItemRef = layer.ItemRef,
+ MeshBaseName = meshName,
+ };
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($" npc-armor: '{meshName}.asp' load failed: {ex.Message}");
+ }
+ Console.WriteLine(built is null
+ ? $" npc-armor: '{layer.SlotName}' = '{layer.ItemRef}' mesh '{meshName}' unavailable"
+ : $" npc-armor: '{layer.SlotName}' = '{layer.ItemRef}' mesh '{meshName}' tex '{layer.TextureBaseName}' ({(built.Texture is null ? "MISS" : "OK")})");
+ _npcArmorLayerCache[key] = built;
+ return built;
+ }
+
/// SC-ALIGNMENT / SC-SELECTABLE — resolve the authored
/// interaction flags once at spawn: hostile-to-player = actor_evil in
/// the specializes chain (DS1 SP never lets you attack good NPCs), and
@@ -29769,6 +29877,11 @@ private void RefreshCompanionGearVisuals(ActorRenderState s, int partyIndex)
{
if (_templateStore is null) return;
var dict = GetEquipmentDict(partyIndex);
+ // SC-NPC-ARMOR — worn armor follows the paperdoll too.
+ var worn = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (slot, itemRef) in dict)
+ if (!string.IsNullOrWhiteSpace(itemRef)) worn[slot] = ResolveItemRef(itemRef);
+ ResolveNpcWornArmor(s, worn);
s.WeaponMesh = null; s.WeaponTexture = null; s.WeaponBoneIdx = -1; s.WieldedWeaponRef = null;
s.ShieldMesh = null; s.ShieldTexture = null; s.ShieldBoneIdx = -1;
int FindBone(string name)
@@ -39671,6 +39784,8 @@ private void OnRender(double dt)
var clips = s.Actor.Clips;
int boneCount = s.Actor.Mesh.BoneCount;
ReadOnlySpan skin;
+ SiegeFX.Core.Assets.PrsAnimation? drawClip = null;
+ float drawT = 0f;
if (clips.Length == 0)
{
// No parsable PRS for this actor (shipped 0x0202 clips we don't support
@@ -39717,6 +39832,8 @@ private void OnRender(double dt)
_skinScratch = new Matrix4x4[Math.Max(boneCount, 64)];
AnimationRuntime.ComputeSkinMatrices(s.Actor.Mesh, clip, t, _skinScratch);
skin = _skinScratch.AsSpan(0, boneCount);
+ drawClip = clip;
+ drawT = t;
}
// SC-ACTOR-SCALE — apply the creature's authored [aspect] scale_base
// (× scale_multiplier) to the visual model only. Most mobs are 1.0
@@ -39769,6 +39886,31 @@ private void OnRender(double dt)
if (_subsetTintActive)
_skinShader.SetInt("uSubsetTintActive", 0);
}
+ // SC-NPC-ARMOR — worn helm/boots/gauntlets share the body's
+ // skeleton: re-skin each layer against the body's clip + time
+ // (same uModel) and draw it over the body.
+ if (s.ArmorLayers is { Count: > 0 } armor)
+ {
+ if (_skinLayer2Active) { ResetAnimatedTextureBinding(_skinShader); _skinLayer2Active = false; }
+ foreach (var layer in armor)
+ {
+ int lb = layer.Asp.BoneCount;
+ if (lb == 0 || lb > SkinnedMesh.MaxBones) continue;
+ if (_skinScratch.Length < lb) _skinScratch = new Matrix4x4[Math.Max(lb, 64)];
+ if (drawClip is not null)
+ AnimationRuntime.ComputeSkinMatrices(layer.Asp, drawClip, drawT, _skinScratch);
+ else
+ for (int i = 0; i < lb; i++) _skinScratch[i] = Matrix4x4.Identity;
+ _skinShader.SetMatrix4Array("uBones[0]", _skinScratch.AsSpan(0, lb));
+ if (layer.Texture is not null)
+ {
+ layer.Texture.Bind(TextureUnit.Texture0);
+ _skinShader.SetInt("uHasTexture", 1);
+ }
+ else _skinShader.SetInt("uHasTexture", 0);
+ layer.Mesh.Draw();
+ }
+ }
}
// SC-ACTOR-TSD — never let a lava-spirit layer-2 binding leak into the
// equipment/weapon passes below (they bind Texture0 only and would
@@ -42675,6 +42817,8 @@ private void OnClosing()
// Phase 21d-2a-vii — layered equipment GL resources.
foreach (var l in _equippedLayers) l.Mesh.Dispose();
_equippedLayers.Clear();
+ foreach (var l in _npcArmorLayerCache.Values) l?.Mesh.Dispose();
+ _npcArmorLayerCache.Clear();
var disposedEquipTex = new HashSet(ReferenceEqualityComparer.Instance);
foreach (var tex in _equipTexCache.Values)
if (tex is not null && disposedEquipTex.Add(tex)) tex.Dispose();
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..f20cd6c6 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;
@@ -8879,6 +8881,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));
+ }
+}
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/MpDisconnectTests.cs b/tests/SiegeFX.Tests/MpDisconnectTests.cs
new file mode 100644
index 00000000..92594108
--- /dev/null
+++ b/tests/SiegeFX.Tests/MpDisconnectTests.cs
@@ -0,0 +1,31 @@
+using SiegeFX.Core.Net;
+using Xunit;
+
+namespace SiegeFX.Tests;
+
+public class MpDisconnectTests
+{
+ [Fact]
+ public void ADisconnectFromATransportThreadIsHandledOnTick()
+ {
+ // The LAN keepalive drops a silent peer from a pool thread; the
+ // render host's OnPlayerLeft edits its actor list, so the session
+ // must hold the leave until the main thread ticks it.
+ var (h, c) = LoopbackTransport.CreatePair();
+ var host = new MpSession(h, isHost: true, "Host") { BuildSnapshot = () => [] };
+ var client = new MpSession(c, isHost: false, "Client");
+ client.SendJoinRequest();
+ for (int i = 0; i < 4; i++) { host.Tick(0); client.Tick(0); }
+
+ int left = -1, leftOnThread = -1, mainThread = Environment.CurrentManagedThreadId;
+ host.OnPlayerLeft = p => { left = p; leftOnThread = Environment.CurrentManagedThreadId; };
+
+ var t = new Thread(c.Dispose);
+ t.Start(); t.Join();
+ Assert.Equal(-1, left);
+
+ host.Tick(0);
+ Assert.Equal(1, left);
+ Assert.Equal(mainThread, leftOnThread);
+ }
+}
diff --git a/tests/SiegeFX.Tests/NavFenceSeamTests.cs b/tests/SiegeFX.Tests/NavFenceSeamTests.cs
new file mode 100644
index 00000000..1a69c357
--- /dev/null
+++ b/tests/SiegeFX.Tests/NavFenceSeamTests.cs
@@ -0,0 +1,131 @@
+using System.Numerics;
+using System.Text;
+using SiegeFX.Core.Assets;
+using SiegeFX.Core.Nav;
+
+namespace SiegeFX.Tests;
+
+// The door stitcher's overflow fallback must not hop a fence. Layout (XZ, all floor at y=0):
+//
+// z 8 +-------+-------+
+// | B | | F = fence piece: floor strip S1 (z 0..1.8) and S0 (z 2.2..4)
+// z 4 +-------+ C | either side of Ignored boards standing at z=2 (0..1.5 high),
+// | S0 | | like t_grs01_fence_generic_wood-04x04-a.
+// |=boards| | B = plain tile welded to S0, door-linked to F.
+// | S1 | | C = the way round (a gate): welds to S1, to S0 and to B.
+// z 0 +-------+-------+
+// x 0 4 8
+public class NavFenceSeamTests
+{
+ const uint Fence = 0x10, Tile = 0x20, Round = 0x30;
+
+ static SnoModel.NavFace F(Vector3 a, Vector3 b, Vector3 c) => new(a, b, c, Vector3.UnitY);
+
+ static IEnumerable Quad(float x0, float x1, float z0, float z1)
+ {
+ yield return F(new(x0, 0, z0), new(x1, 0, z0), new(x1, 0, z1));
+ yield return F(new(x0, 0, z0), new(x1, 0, z1), new(x0, 0, z1));
+ }
+
+ static SnoModel.LogicalGrouping Group(byte id, SnoModel.FloorKind kind, IEnumerable faces) =>
+ new() { Id = id, Kind = kind, Faces = faces.ToArray() };
+
+ static SnoModel.Door Door(uint id, Vector3 at) =>
+ new(id, new SnoModel.Xform4x3(Vector3.UnitX, Vector3.UnitY, Vector3.UnitZ, at), []);
+
+ static NavMesh Build(bool boards, bool wayRound)
+ {
+ var groups = new List
+ {
+ Group(0, SnoModel.FloorKind.Floor, Quad(0, 4, 2.2f, 4)), // S0
+ Group(1, SnoModel.FloorKind.Floor, Quad(0, 4, 0, 1.8f)), // S1
+ };
+ if (boards)
+ groups.Add(Group(2, SnoModel.FloorKind.Ignored, new[]
+ {
+ F(new(0, 0, 2), new(4, 0, 2), new(4, 1.5f, 2)),
+ F(new(0, 0, 2), new(4, 1.5f, 2), new(0, 1.5f, 2)),
+ }));
+ var snos = new Dictionary
+ {
+ [Fence] = new() { LogicalGroupings = groups.ToArray(), Doors = [Door(1, new(2, 0, 4))] },
+ [Tile] = new()
+ {
+ LogicalGroupings = [Group(0, SnoModel.FloorKind.Floor, Quad(0, 4, 4, 8))],
+ Doors = [Door(1, new(2, 0, 4))],
+ },
+ // C's x=4 edge is cut where S1, S0 and B end so it welds to all three.
+ [Round] = new()
+ {
+ LogicalGroupings = [Group(0, SnoModel.FloorKind.Floor,
+ Quad(4, 8, 0, 1.8f).Concat(Quad(4, 8, 1.8f, 2.2f)).Concat(Quad(4, 8, 2.2f, 4)).Concat(Quad(4, 8, 4, 8)))],
+ },
+ };
+
+ var gas = new StringBuilder("[t:terrain_nodes,n:siege_node_list]\n{\n x targetnode = 0x00000001;\n");
+ void Node(uint guid, uint mesh, string doors) =>
+ gas.Append($" [t:snode,n:0x{guid:x8}]\n {{\n x guid = 0x{guid:X8};\n x mesh_guid = 0x{mesh:X8};\n texsetabbr = grs01;\n{doors} }}\n");
+ Node(1, Fence, " [door*] { i fardoor = 1; x farguid = 0x00000002; i id = 1; }\n");
+ Node(2, Tile, " [door*] { i fardoor = 1; x farguid = 0x00000001; i id = 1; }\n");
+ if (wayRound) Node(3, Round, "");
+ gas.Append("}\n");
+ var graph = RegionGraph.Load(Encoding.UTF8.GetBytes(gas.ToString()));
+
+ var transforms = new Dictionary { [1] = Matrix4x4.Identity, [2] = Matrix4x4.Identity };
+ if (wayRound) transforms[3] = Matrix4x4.Identity;
+ return NavMesh.BuildForRegion(graph, RegionLayout.FromTransforms(1, transforms),
+ g => snos.TryGetValue(g, out var s) ? s : null);
+ }
+
+ static readonly Vector3 InPen = new(2.7f, 0, 1.2f); // an S1 triangle centroid
+ static readonly Vector3 OutsideTile = new(1.3f, 0, 5.3f); // a B triangle centroid
+
+ [Fact]
+ public void OverflowHopDoesNotJumpTheFenceWhenThereIsAWayRound()
+ {
+ var mesh = Build(boards: true, wayRound: true);
+ // The F->B and B->F door entries each offer the hop; both are refused.
+ Assert.Equal(0, mesh.DoorSeamOverflowCount);
+ Assert.Equal(2, mesh.DoorSeamBarrierRejectCount);
+ Assert.Null(mesh.ExtraLinks);
+ // Still reachable — the long way, through C.
+ Assert.True(mesh.TryFindTriangle(InPen, out var a));
+ Assert.True(mesh.TryFindTriangle(OutsideTile, out var b));
+ var path = new List();
+ Assert.True(NavPathfinder.TryFindPath(mesh, a, b, path));
+ Assert.Contains(path, t => mesh.SourceSnodeGuid[t] == 3);
+ }
+
+ [Fact]
+ public void WithoutBoardsTheHopIsStillWired()
+ {
+ var mesh = Build(boards: false, wayRound: true);
+ Assert.Equal(0, mesh.DoorSeamBarrierRejectCount);
+ Assert.True(mesh.DoorSeamOverflowCount > 0);
+ }
+
+ [Fact]
+ public void AHopThatIsTheOnlyConnectionIsKept()
+ {
+ // No way round: dropping the hop would strand S1, so the pre-barrier link stays
+ // (once — the mirror door entry's copy is then redundant and refused).
+ var mesh = Build(boards: true, wayRound: false);
+ Assert.Equal(1, mesh.DoorSeamOverflowCount);
+ Assert.Equal(1, mesh.DoorSeamBarrierRejectCount);
+ Assert.True(mesh.TryFindTriangle(InPen, out var a));
+ Assert.True(mesh.TryFindTriangle(OutsideTile, out var b));
+ Assert.Equal(mesh.ComponentOf(a), mesh.ComponentOf(b));
+ }
+
+ [Fact]
+ public void BarrierTestNeedsTheEdgeToStandAboveTheHop()
+ {
+ Vector3[] board = [new(0, 0, 2), new(4, 1.5f, 2)];
+ Assert.True(NavMesh.CrossesBarrier(new(2, 0, 1), new(2, 0, 3), board));
+ Assert.False(NavMesh.CrossesBarrier(new(2, 0, 1), new(2, 0, 1.9f), board)); // stops short
+ Vector3[] kerb = [new(0, 0.1f, 2), new(4, 0.1f, 2)];
+ Assert.False(NavMesh.CrossesBarrier(new(2, 0, 1), new(2, 0, 3), kerb)); // too low
+ Vector3[] lintel = [new(0, 3, 2), new(4, 3, 2)];
+ Assert.False(NavMesh.CrossesBarrier(new(2, 0, 1), new(2, 0, 3), lintel)); // overhead
+ }
+}
diff --git a/tests/SiegeFX.Tests/NpcArmorTests.cs b/tests/SiegeFX.Tests/NpcArmorTests.cs
new file mode 100644
index 00000000..15e510b2
--- /dev/null
+++ b/tests/SiegeFX.Tests/NpcArmorTests.cs
@@ -0,0 +1,89 @@
+using SiegeFX.Core.Assets;
+
+namespace SiegeFX.Tests;
+
+public class NpcArmorTests
+{
+ // Trimmed copy of the shipped Elddim Legionnaire: the skin is the only
+ // [aspect][textures] entry, so the look has to come from [inventory][equipment].
+ const string Gas = """
+ [t:template,n:actor] { [aspect] { model = m_c_gah_fb_pos_a1; } [body] { armor_version = gah_fb; } }
+ [t:template,n:mp_guard]
+ {
+ specializes = actor;
+ [aspect] { [textures] { 0 = b_c_gah_fb_skin_02; } }
+ [inventory]
+ {
+ [equipment]
+ {
+ es_chest = bd_un_pl_f_g_c_fun;
+ es_head = he_op_pl_eyes;
+ es_weapon_hand = #sd_g_c_st_1h_avg:o_avg;
+ }
+ }
+ }
+ [t:template,n:bd_un_pl_f_g_c_fun] { [defend] { armor_style = 014; armor_type = a3; } }
+ [t:template,n:he_op_pl_eyes] { [defend] { armor_style = 020; armor_type = type20; } }
+ [t:template,n:he_op_pl_roman] { [defend] { armor_style = 021; armor_type = type21; } }
+ """;
+
+ static (TemplateStore Store, Template Guard) Load()
+ {
+ var store = new TemplateStore();
+ store.RegisterFromGasText(Gas);
+ Assert.True(store.TryGet("mp_guard", out var guard));
+ return (store, guard);
+ }
+
+ [Fact]
+ public void GuardWearsPlateChestAndHelm()
+ {
+ var (store, guard) = Load();
+ var slots = EquipmentResolver.CollectActorSlots(store, guard, instance: null);
+ var layers = EquipmentResolver.Resolve(store, new AssetResolver(), guard, slots);
+
+ var chest = Assert.Single(layers, l => l.SlotName == "es_chest");
+ Assert.Equal(EquipmentResolver.Strategy.ChestTexture, chest.Strategy);
+ Assert.Equal(1, chest.OverrideTextureSlot);
+ Assert.Equal("b_c_pos_a1_014", chest.OverrideBaseName);
+
+ var helm = Assert.Single(layers, l => l.SlotName == "es_head");
+ Assert.Equal(EquipmentResolver.Strategy.SkinnedLayer, helm.Strategy);
+ Assert.Equal("m_c_gah_fb_hlmt_type20", helm.MeshBaseName);
+ Assert.Equal("b_a_hlmt_020", helm.TextureBaseName);
+ }
+
+ [Fact]
+ public void InstanceEquipmentOverridesTheTemplate()
+ {
+ var (store, guard) = Load();
+ var inst = GasDocument.Load("""
+ [t:mp_guard,n:0x01]
+ {
+ [inventory] { [equipment] { es_head = he_op_pl_roman; } }
+ }
+ """u8.ToArray()).Roots[0];
+ var slots = EquipmentResolver.CollectActorSlots(store, guard, inst);
+ Assert.Equal("he_op_pl_roman", slots["es_head"]);
+ Assert.Equal("bd_un_pl_f_g_c_fun", slots["es_chest"]);
+ Assert.Equal(3, slots.Count);
+ }
+
+ [Fact]
+ public void PlateMovesTheGuardOntoTheArmoredBody()
+ {
+ var (store, guard) = Load();
+ var slots = EquipmentResolver.CollectActorSlots(store, guard, instance: null);
+ // Plate is armor_type a3: DS1 draws it on the bulky pos_a3 body...
+ Assert.Equal("m_c_gah_fb_pos_a3",
+ EquipmentResolver.ArmoredBodyModel(store, "m_c_gah_fb_pos_a1", slots));
+ // ...and the chest texture follows the body actually drawn.
+ var layers = EquipmentResolver.Resolve(store, new AssetResolver(), guard, slots, "m_c_gah_fb_pos_a3");
+ Assert.Equal("b_c_pos_a3_014", Assert.Single(layers, l => l.SlotName == "es_chest").OverrideBaseName);
+ // Already on that body, or no body-type suffix: nothing to swap.
+ Assert.Null(EquipmentResolver.ArmoredBodyModel(store, "m_c_gah_fb_pos_a3", slots));
+ Assert.Null(EquipmentResolver.ArmoredBodyModel(store, "m_c_gan_df_pos_b1", slots));
+ Assert.Null(EquipmentResolver.ArmoredBodyModel(store, "m_c_gah_fb_pos_a1",
+ new Dictionary { ["es_head"] = "he_op_pl_eyes" }));
+ }
+}
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);
+ }
+}