diff --git a/src/SiegeFX.Core/Actors/ActorSpawner.cs b/src/SiegeFX.Core/Actors/ActorSpawner.cs index c40c1289..5017b669 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"); @@ -353,6 +364,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/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.Runtime/Render/RenderHost.cs b/src/SiegeFX.Runtime/Render/RenderHost.cs index dd4254b8..5df991b4 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 @@ -2089,6 +2096,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. @@ -17035,6 +17047,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; @@ -30472,7 +30492,12 @@ private void TrySpawnPlayerWithPicker( $"=> model={heroOverride?.ModelName ?? "