Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/SiegeFX.Core/Save/SaveFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,9 @@ public sealed class HeroVariantSnapshot
/// <summary>Two-digit zero-padded skin suffix (e.g. "07"). Null = no override.</summary>
public string? SkinSuffix { get; set; }

/// <summary>Three-digit zero-padded hair suffix (e.g. "001"). Null = no override.</summary>
public string? HairSuffix { get; set; }

/// <summary>Three-digit zero-padded pants suffix (e.g. "015"). Null = no override.</summary>
public string? PantsSuffix { get; set; }

Expand Down
34 changes: 21 additions & 13 deletions src/SiegeFX.Runtime/HeroVariantPicker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
Expand All @@ -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');
Expand Down
24 changes: 15 additions & 9 deletions src/SiegeFX.Runtime/Render/Hud/HeroPreviewRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
18 changes: 15 additions & 3 deletions src/SiegeFX.Runtime/Render/RenderHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -27677,6 +27685,7 @@ private static void HeroLookEnv(IDictionary<string, string?> 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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
Loading