From c67d073ad304b51e8bb8780b930f34ad492ddced Mon Sep 17 00:00:00 2001 From: wudzu Date: Mon, 9 Nov 2026 03:55:29 +0100 Subject: [PATCH] Multiplayer: fix character creation rendering character and save after creation --- src/SiegeFX.Core/Save/SaveFile.cs | 3 ++ src/SiegeFX.Runtime/HeroVariantPicker.cs | 34 ++++++++++++------- .../Render/Hud/HeroPreviewRenderer.cs | 24 ++++++++----- src/SiegeFX.Runtime/Render/RenderHost.cs | 18 ++++++++-- 4 files changed, 54 insertions(+), 25 deletions(-) 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); }