diff --git a/src/SiegeFX.Core/Save/SaveFile.cs b/src/SiegeFX.Core/Save/SaveFile.cs
index 073bf704..36613661 100644
--- a/src/SiegeFX.Core/Save/SaveFile.cs
+++ b/src/SiegeFX.Core/Save/SaveFile.cs
@@ -501,6 +501,9 @@ public sealed class HeroVariantSnapshot
/// Two-digit zero-padded skin suffix (e.g. "07"). Null = no override.
public string? SkinSuffix { get; set; }
+ /// Three-digit zero-padded hair suffix (e.g. "001"). Null = no override.
+ public string? HairSuffix { get; set; }
+
/// Three-digit zero-padded pants suffix (e.g. "015"). Null = no override.
public string? PantsSuffix { get; set; }
diff --git a/src/SiegeFX.Runtime/HeroVariantPicker.cs b/src/SiegeFX.Runtime/HeroVariantPicker.cs
index 7b255c31..ca226351 100644
--- a/src/SiegeFX.Runtime/HeroVariantPicker.cs
+++ b/src/SiegeFX.Runtime/HeroVariantPicker.cs
@@ -163,7 +163,8 @@ static int EnvIdx(string name)
{
if (!HeroBodies.IsFarm(Gender)) return null; // the dwarf and skeleton keep their own look
if (BodyTypeIdx < 0 && SkinSuffix is null && HairSuffix is null
- && PantsSuffix is null && ShirtIdx == 0) return null;
+ && PantsSuffix is null && ShirtIdx == 0 && FaceIdx == 0
+ && StyleIdx == 0 && ColorIdx == 0 && PantsIdx == 0) return null;
var armorVersion = store.GetAttribute(template, "body", "armor_version");
if (string.IsNullOrEmpty(armorVersion)) return null;
@@ -185,19 +186,23 @@ static int EnvIdx(string name)
// trampling Face's neighbour. Both buttons therefore drive a visible
// change without runtime atlas composition (which broke face
// details when we tried it).
+ // The composite indices (FaceIdx, ColorIdx) are the primary drivers —
+ // they are what the UI spinners modify. SkinSuffix/HairSuffix provide
+ // the base offset when set (e.g. from a saved character), but the
+ // composite indices always determine the final visual result.
string? skinOverride = null;
- if (SkinSuffix is not null)
{
- int faceIdx = 1;
- int.TryParse(SkinSuffix, System.Globalization.NumberStyles.Integer,
- System.Globalization.CultureInfo.InvariantCulture, out faceIdx);
- int hairIdx = 1;
+ int faceBase = 0;
+ if (SkinSuffix is not null)
+ int.TryParse(SkinSuffix, System.Globalization.NumberStyles.Integer,
+ System.Globalization.CultureInfo.InvariantCulture, out faceBase);
+ int hairBase = 0;
if (HairSuffix is not null)
int.TryParse(HairSuffix, System.Globalization.NumberStyles.Integer,
- System.Globalization.CultureInfo.InvariantCulture, out hairIdx);
+ System.Globalization.CultureInfo.InvariantCulture, out hairBase);
const int SkinRange = 32;
const int HairStride = 7;
- int folded = ((faceIdx - 1) + (hairIdx - 1) * HairStride) % SkinRange;
+ int folded = ((faceBase + FaceIdx) + (hairBase + ColorIdx) * HairStride) % SkinRange;
if (folded < 0) folded += SkinRange;
int finalIdx = folded + 1;
string finalSuffix = finalIdx.ToString(System.Globalization.CultureInfo.InvariantCulture).PadLeft(2, '0');
@@ -206,15 +211,18 @@ static int EnvIdx(string name)
// Pants atlas folds Shirt and Pants identically — DS1 packs both into
// one b_c_pos_aN_NNN.raw with no separate shirt file.
+ // The composite indices (ShirtIdx, PantsIdx) are the primary drivers.
+ // PantsSuffix provides the base offset when set (e.g. from a saved
+ // character), but the composite indices always determine the final result.
string? pantsOverride = null;
- if (PantsSuffix is not null)
{
- int basePants = 1;
- int.TryParse(PantsSuffix, System.Globalization.NumberStyles.Integer,
- System.Globalization.CultureInfo.InvariantCulture, out basePants);
+ int basePants = 0;
+ if (PantsSuffix is not null)
+ int.TryParse(PantsSuffix, System.Globalization.NumberStyles.Integer,
+ System.Globalization.CultureInfo.InvariantCulture, out basePants);
const int PantsRange = 41;
const int ShirtStride = 7;
- int folded = ((basePants - 1) + ShirtIdx * ShirtStride) % PantsRange;
+ int folded = ((basePants + PantsIdx) + ShirtIdx * ShirtStride) % PantsRange;
if (folded < 0) folded += PantsRange;
int finalIdx = folded + 1;
string finalSuffix = finalIdx.ToString(System.Globalization.CultureInfo.InvariantCulture).PadLeft(3, '0');
diff --git a/src/SiegeFX.Runtime/Render/Hud/HeroPreviewRenderer.cs b/src/SiegeFX.Runtime/Render/Hud/HeroPreviewRenderer.cs
index 50e6c00b..be91ec2c 100644
--- a/src/SiegeFX.Runtime/Render/Hud/HeroPreviewRenderer.cs
+++ b/src/SiegeFX.Runtime/Render/Hud/HeroPreviewRenderer.cs
@@ -451,15 +451,21 @@ private static (int Dx, int Dy, float Scale) Letterbox(int vw, int vh)
private static long HashPicker(HeroVariantPicker p)
{
- // 6-axis pack: gender(1) | bodyIdx(8) | skin(10) | hair(10) | shirt(8) |
- // pants(10). Each subfield is sized for its real range; long has 64 bits
- // total so this fits comfortably.
- long h = (long)p.Gender;
- h = (h << 8) | (long)(byte)(p.BodyTypeIdx & 0xFF);
- h = (h << 10) | (ParseSuffixOrZero(p.SkinSuffix) & 0x3FF);
- h = (h << 10) | (ParseSuffixOrZero(p.HairSuffix) & 0x3FF);
- h = (h << 8) | (long)(byte)(p.ShirtIdx & 0xFF);
- h = (h << 10) | (ParseSuffixOrZero(p.PantsSuffix) & 0x3FF);
+ // Combine all picker axes into a long hash so any spinner change
+ // invalidates the cached preview actor. Use a simple multiplicative
+ // hash — each field is mixed in with a prime multiplier so the
+ // result changes whenever any field changes.
+ long h = 0;
+ h = h * 1000000007 + (long)p.Gender;
+ h = h * 1000000009 + p.BodyTypeIdx;
+ h = h * 1000000021 + p.StyleIdx;
+ h = h * 1000000033 + p.FaceIdx;
+ h = h * 1000000057 + p.ColorIdx;
+ h = h * 1000000069 + p.ShirtIdx;
+ h = h * 1000000081 + p.PantsIdx;
+ h = h * 1000000093 + ParseSuffixOrZero(p.SkinSuffix);
+ h = h * 1000000103 + ParseSuffixOrZero(p.HairSuffix);
+ h = h * 1000000113 + ParseSuffixOrZero(p.PantsSuffix);
return h;
}
diff --git a/src/SiegeFX.Runtime/Render/RenderHost.cs b/src/SiegeFX.Runtime/Render/RenderHost.cs
index 201ae696..b010bf3f 100644
--- a/src/SiegeFX.Runtime/Render/RenderHost.cs
+++ b/src/SiegeFX.Runtime/Render/RenderHost.cs
@@ -9405,7 +9405,7 @@ private void MpCharacterPanelActions()
var look = new SiegeFX.Core.Save.HeroVariantSnapshot
{
Gender = HeroBodies.Word(pk.Gender),
- BodyTypeIdx = pk.BodyTypeIdx, SkinSuffix = pk.SkinSuffix, PantsSuffix = pk.PantsSuffix,
+ BodyTypeIdx = pk.BodyTypeIdx, SkinSuffix = pk.SkinSuffix, HairSuffix = pk.HairSuffix, PantsSuffix = pk.PantsSuffix,
FaceIdx = pk.FaceIdx, StyleIdx = pk.StyleIdx, ColorIdx = pk.ColorIdx, ShirtIdx = pk.ShirtIdx, PantsIdx = pk.PantsIdx,
};
string name = panel.NewName.Trim();
@@ -9967,6 +9967,9 @@ private static string LocalIpBestEffort()
// it needs _templateStore + _playResolver + _skinShader, all of which
// come online during LoadPlayActors.
private HeroPreviewRenderer? _heroPreview;
+ // The picker currently driving the hero preview (singleplayer or multiplayer).
+ // Set before EnsurePreview so ResolveActorTexture uses the correct one.
+ private HeroVariantPicker? _previewPicker;
// Args captured on the first frame the creator is open so Begin can re-enter
// the spawn path with the panel-mutated picker.
private ActorSpawner? _pendingSpawner;
@@ -17034,8 +17037,13 @@ private void AlphaOver(byte[] dst, string? overlayName, int w, int h)
{
// SC-CD-COMPOSITE — the live creator preview composites its two slots
// from the panel's current picks (Head/Hair/Face/Shirt/Pants).
- var t = ResolveCreatorTexture(_creator.Picker, textureIndex);
- if (t is not null) return t;
+ // Use _previewPicker so both singleplayer and multiplayer previews
+ // resolve textures from the correct picker.
+ if (_previewPicker is not null)
+ {
+ var t = ResolveCreatorTexture(_previewPicker, textureIndex);
+ if (t is not null) return t;
+ }
}
// SC-MP-PLAYERS — remote avatars composite their owner's streamed
// appearance card, so friends see the character its player actually
@@ -27677,6 +27685,7 @@ private static void HeroLookEnv(IDictionary env, SiegeFX.Core.S
env["SIEGEFX_HERO_GENDER"] = v.Gender;
if (v.BodyTypeIdx >= 0) env["SIEGEFX_HERO_BODY"] = (v.BodyTypeIdx + 1).ToString();
if (!string.IsNullOrEmpty(v.SkinSuffix)) env["SIEGEFX_HERO_SKIN"] = v.SkinSuffix;
+ if (!string.IsNullOrEmpty(v.HairSuffix)) env["SIEGEFX_HERO_HAIR"] = v.HairSuffix;
if (!string.IsNullOrEmpty(v.PantsSuffix)) env["SIEGEFX_HERO_PANTS"] = v.PantsSuffix;
// SC-CD-PERSIST (load side) — the composite axes are the look
// the body actually renders with. Forward the full set so EVERY
@@ -29889,6 +29898,7 @@ private void DrawBootScene(float dt, int viewportW, int viewportH)
// Drawn inside the chrome (FrontendScene.DrawInBox),
// between the stone wall and the frame.
_heroPreview.Where = HeroPreviewRenderer.Placement.Creator;
+ _previewPicker = _creator.Picker;
_heroPreview.EnsurePreview(_creator.Picker);
_heroPreview.Tick(dt);
}
@@ -29995,6 +30005,7 @@ private void DrawBootScene(float dt, int viewportW, int viewportH)
var box = Hud.MpCharacterPanel.PreviewBox;
_heroPreview.Where = new HeroPreviewRenderer.Placement(box.X0, box.Y0, box.X1, box.Y1,
(box.X0 + box.X1) / 2f, box.Y1 - 8, box.Y1 - box.Y0 - 22);
+ _previewPicker = _mpStaging.Characters.Picker;
_heroPreview.EnsurePreview(_mpStaging.Characters.Picker);
_heroPreview.Tick(dt);
_heroPreview.Draw(viewportW, viewportH);
@@ -43444,6 +43455,7 @@ void DrawGear(StaticMesh? gearMesh, GlTexture? gearTex, int boneIdx)
// Drawn inside the chrome (FrontendScene.DrawInBox),
// between the stone wall and the frame.
_heroPreview.Where = HeroPreviewRenderer.Placement.Creator;
+ _previewPicker = _creator.Picker;
_heroPreview.EnsurePreview(_creator.Picker);
_heroPreview.Tick((float)dt);
}