From e3f4756e7a654e5a8c3bc533c6ff85186620599f Mon Sep 17 00:00:00 2001 From: qwerin Date: Wed, 4 Nov 2026 17:34:26 +0100 Subject: [PATCH 1/6] Render NPC worn armor: chest texture + helm/boot/gauntlet layers NPCs only ever resolved es_weapon_hand/es_shield_hand; es_chest, es_head, es_feet and es_forearms were ignored, so Elddim's Legionnaire guards (whose [aspect][textures] authors just the skin) drew in the bare citizen body. Feed the template + instance [inventory][equipment] slots through the player's EquipmentResolver: chest armor swaps body slot 1 to b_c_pos_*, helm/boots/gauntlets draw as shared skinned layers posed with the body's clip. Companion paperdoll swaps refresh the armor too. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 805ae51984439dfc24f72f6586eab95872f65fd6) --- src/SiegeFX.Core/Assets/EquipmentResolver.cs | 30 +++++ src/SiegeFX.Runtime/Render/RenderHost.cs | 133 +++++++++++++++++++ tests/SiegeFX.Tests/NpcArmorTests.cs | 71 ++++++++++ 3 files changed, 234 insertions(+) create mode 100644 tests/SiegeFX.Tests/NpcArmorTests.cs diff --git a/src/SiegeFX.Core/Assets/EquipmentResolver.cs b/src/SiegeFX.Core/Assets/EquipmentResolver.cs index 98ab6fe8..d80f0675 100644 --- a/src/SiegeFX.Core/Assets/EquipmentResolver.cs +++ b/src/SiegeFX.Core/Assets/EquipmentResolver.cs @@ -152,6 +152,36 @@ 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; + } + private static EquipmentLayer ResolveSlot( TemplateStore templates, AssetResolver resolver, string slotName, string itemRef, diff --git a/src/SiegeFX.Runtime/Render/RenderHost.cs b/src/SiegeFX.Runtime/Render/RenderHost.cs index 88d1fcc7..3d3381d4 100644 --- a/src/SiegeFX.Runtime/Render/RenderHost.cs +++ b/src/SiegeFX.Runtime/Render/RenderHost.cs @@ -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; @@ -29588,6 +29608,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 +29724,81 @@ 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; + var layers = SiegeFX.Core.Assets.EquipmentResolver.Resolve( + _templateStore, _playResolver, s.Actor.Template, slots); + 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 +29866,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 +39773,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 +39821,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 +39875,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 +42806,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/tests/SiegeFX.Tests/NpcArmorTests.cs b/tests/SiegeFX.Tests/NpcArmorTests.cs new file mode 100644 index 00000000..eb53adde --- /dev/null +++ b/tests/SiegeFX.Tests/NpcArmorTests.cs @@ -0,0 +1,71 @@ +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); + } +} From 20d8e72810969f095a117a4967efb381c265ff22 Mon Sep 17 00:00:00 2001 From: qwerin Date: Wed, 4 Nov 2026 18:01:46 +0100 Subject: [PATCH 2/6] NPC armor: spawn plate wearers on the armored pos_aN body The first pass drew helm/boot/gauntlet layers and swapped body slot 1 to b_c_pos_a1_