Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/SiegeFX.Core/Actors/ActorSpawner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,17 @@ public IReadOnlyList<Actor> 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");
Expand Down Expand Up @@ -353,6 +364,21 @@ Matrix4x4 ComposeWorldTransform(NodePlacement p)
return local * nodeWorld;
}

/// <summary>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.</summary>
public bool DressArmoredBodies { get; set; } = true;

/// <summary>The model name a spawned actor's <see cref="Actor.Mesh"/> was
/// loaded from (meshes are cached per name), or null.</summary>
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;
Expand Down
63 changes: 60 additions & 3 deletions src/SiegeFX.Core/Assets/EquipmentResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <see cref="Strategy.None"/> so the caller can log
/// them without crashing the spawn path.</summary>
/// them without crashing the spawn path. <paramref name="bodyModelOverride"/>
/// names the body mesh actually drawn when it differs from the template's
/// <c>aspect.model</c>.</summary>
public static List<EquipmentLayer> Resolve(
TemplateStore templates, AssetResolver resolver, Template playerTemplate,
IReadOnlyDictionary<string, string>? liveSlots = null)
IReadOnlyDictionary<string, string>? liveSlots = null,
string? bodyModelOverride = null)
{
var layers = new List<EquipmentLayer>();
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");
Expand Down Expand Up @@ -152,6 +157,58 @@ public static List<EquipmentLayer> Resolve(
return layers;
}

/// <summary>The <c>es_*</c> slots an NPC actually wears: the template chain's
/// <c>[inventory][equipment]</c>, overlaid by the map instance's own
/// <c>[inventory][equipment]</c> block (instance wins per slot). Used to
/// feed <see cref="Resolve"/> for non-player actors, whose worn armor
/// (Elddim's Legionnaire guards: plate chest, helm, boots, gauntlets) lives
/// only in these blocks — their <c>[aspect][textures]</c> carries just the
/// skin, so without this they render in the bare citizen body.</summary>
public static Dictionary<string, string> CollectActorSlots(
TemplateStore templates, Template template, GasNode? instance)
{
var slots = new Dictionary<string, string>(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;
}

/// <summary>DS1 body armor picks the body MESH as well as the texture: the
/// chest item's <c>defend.armor_type</c> (a1..a7) names the
/// <c>pos_aN</c> body it is drawn on — Legionnaire plate (<c>a3</c>) puts
/// the bulky <c>m_c_gah_fb_pos_a3</c> body under <c>b_c_pos_a3_014</c>,
/// where the a1 body would show a thin shirt. Returns the armored model
/// for <paramref name="bodyModel"/> (<c>m_c_gah_fb_pos_a1</c> →
/// <c>m_c_gah_fb_pos_a3</c>), 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).</summary>
public static string? ArmoredBodyModel(
TemplateStore templates, string? bodyModel, IReadOnlyDictionary<string, string> 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,
Expand Down
143 changes: 142 additions & 1 deletion src/SiegeFX.Runtime/Render/RenderHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1644,6 +1644,13 @@ private sealed record ItemMesh(StaticMesh Mesh, GlTexture? Texture, string? Disp
// TrySpawnPlayer; rebuilt on every equipment change.
private readonly List<EquippedLayer> _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<SiegeFX.Core.Actors.Actor, string>
_npcChestTex = new();
private readonly Dictionary<(string Mesh, string? Tex), EquippedLayer?> _npcArmorLayerCache = new();
private readonly HashSet<string> _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
Expand Down Expand Up @@ -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<EquippedLayer>? 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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -30472,7 +30492,12 @@ private void TrySpawnPlayerWithPicker(
$"=> model={heroOverride?.ModelName ?? "<template>"}");

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<SiegeFX.Core.Actors.Actor> 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)");
Expand Down Expand Up @@ -31231,6 +31256,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;
Expand Down Expand Up @@ -31345,6 +31372,84 @@ int FindBone(string name)
}
}

/// <summary>SC-NPC-ARMOR — give a non-player actor the worn armor its
/// <c>es_*</c> slots author, through the same <see cref="SiegeFX.Core.Assets.EquipmentResolver"/>
/// rules as the player: es_chest swaps body slot 1 to <c>b_c_pos_*</c>,
/// helm/boots/gauntlets layer skinned meshes. Weapons and shields stay
/// with <see cref="ResolveNpcEquippedGear"/>. Without this Elddim's
/// Legionnaire guards (whose [aspect][textures] author only the skin)
/// rendered in the bare citizen body.</summary>
private void ResolveNpcWornArmor(ActorRenderState s, IReadOnlyDictionary<string, string> 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<EquippedLayer>()).Add(el);
break;
}
}
}

/// <summary>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).</summary>
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;
}

/// <summary>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
Expand Down Expand Up @@ -31412,6 +31517,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<string, string>(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)
Expand Down Expand Up @@ -41472,6 +41582,8 @@ private void OnRender(double dt)
var clips = s.Actor.Clips;
int boneCount = s.Actor.Mesh.BoneCount;
ReadOnlySpan<Matrix4x4> 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
Expand Down Expand Up @@ -41518,6 +41630,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
Expand Down Expand Up @@ -41580,6 +41694,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();
}
}
if (ghostDraw)
{
_skinShader.SetFloat("uGhost", 0f);
Expand Down Expand Up @@ -44507,6 +44646,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<GlTexture>(ReferenceEqualityComparer.Instance);
foreach (var tex in _equipTexCache.Values)
if (tex is not null && disposedEquipTex.Add(tex)) tex.Dispose();
Expand Down
Loading
Loading