From eedb436389afe01b1666d6e3ed73d10cc8b2d070 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sat, 30 Dec 2023 03:47:42 +0300
Subject: [PATCH 01/10] Move combo counter to ruleset-specific HUD components
 target

---
 .../Argon/CatchArgonSkinTransformer.cs        |  2 +-
 .../Skinning/Argon/OsuArgonSkinTransformer.cs |  2 +-
 .../Argon/TaikoArgonSkinTransformer.cs        |  8 +--
 osu.Game/Rulesets/Ruleset.cs                  | 14 ++++-
 osu.Game/Skinning/ArgonSkin.cs                | 16 +-----
 osu.Game/Skinning/ArgonSkinTransformer.cs     | 53 +++++++++++++++++++
 osu.Game/Skinning/LegacySkin.cs               |  1 -
 osu.Game/Skinning/LegacySkinTransformer.cs    | 44 +++++++++++++--
 8 files changed, 113 insertions(+), 27 deletions(-)
 create mode 100644 osu.Game/Skinning/ArgonSkinTransformer.cs

diff --git a/osu.Game.Rulesets.Catch/Skinning/Argon/CatchArgonSkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Argon/CatchArgonSkinTransformer.cs
index 520c2de2480a..a67945df98eb 100644
--- a/osu.Game.Rulesets.Catch/Skinning/Argon/CatchArgonSkinTransformer.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Argon/CatchArgonSkinTransformer.cs
@@ -6,7 +6,7 @@
 
 namespace osu.Game.Rulesets.Catch.Skinning.Argon
 {
-    public class CatchArgonSkinTransformer : SkinTransformer
+    public class CatchArgonSkinTransformer : ArgonSkinTransformer
     {
         public CatchArgonSkinTransformer(ISkin skin)
             : base(skin)
diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs
index 0f9c97059c40..9526ea05c965 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs
@@ -7,7 +7,7 @@
 
 namespace osu.Game.Rulesets.Osu.Skinning.Argon
 {
-    public class OsuArgonSkinTransformer : SkinTransformer
+    public class OsuArgonSkinTransformer : ArgonSkinTransformer
     {
         public OsuArgonSkinTransformer(ISkin skin)
             : base(skin)
diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs
index 9fcecd2b1af6..7d38d6c9e5d0 100644
--- a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs
+++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs
@@ -7,16 +7,16 @@
 
 namespace osu.Game.Rulesets.Taiko.Skinning.Argon
 {
-    public class TaikoArgonSkinTransformer : SkinTransformer
+    public class TaikoArgonSkinTransformer : ArgonSkinTransformer
     {
         public TaikoArgonSkinTransformer(ISkin skin)
             : base(skin)
         {
         }
 
-        public override Drawable? GetDrawableComponent(ISkinComponentLookup component)
+        public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
         {
-            switch (component)
+            switch (lookup)
             {
                 case GameplaySkinComponentLookup<HitResult> resultComponent:
                     // This should eventually be moved to a skin setting, when supported.
@@ -75,7 +75,7 @@ public TaikoArgonSkinTransformer(ISkin skin)
                     break;
             }
 
-            return base.GetDrawableComponent(component);
+            return base.GetDrawableComponent(lookup);
         }
     }
 }
diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs
index 37a35fd3ae6c..c7d4779064d4 100644
--- a/osu.Game/Rulesets/Ruleset.cs
+++ b/osu.Game/Rulesets/Ruleset.cs
@@ -212,7 +212,19 @@ public virtual LegacyMods ConvertToLegacyMods(Mod[] mods)
         /// <param name="skin">The source skin.</param>
         /// <param name="beatmap">The current beatmap.</param>
         /// <returns>A skin with a transformer applied, or null if no transformation is provided by this ruleset.</returns>
-        public virtual ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap) => null;
+        public virtual ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap)
+        {
+            switch (skin)
+            {
+                case LegacySkin:
+                    return new LegacySkinTransformer(skin);
+
+                case ArgonSkin:
+                    return new ArgonSkinTransformer(skin);
+            }
+
+            return null;
+        }
 
         protected Ruleset()
         {
diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs
index 6fcab6a9771e..bdb65713a054 100644
--- a/osu.Game/Skinning/ArgonSkin.cs
+++ b/osu.Game/Skinning/ArgonSkin.cs
@@ -111,14 +111,13 @@ public ArgonSkin(SkinInfo skin, IStorageResourceProvider resources)
                             return songSelectComponents;
 
                         case SkinComponentsContainerLookup.TargetArea.MainHUDComponents:
-                            var skinnableTargetWrapper = new DefaultSkinComponentsContainer(container =>
+                            var mainHUDComponents = new DefaultSkinComponentsContainer(container =>
                             {
                                 var health = container.OfType<ArgonHealthDisplay>().FirstOrDefault();
                                 var healthLine = container.OfType<BoxElement>().FirstOrDefault();
                                 var wedgePieces = container.OfType<ArgonWedgePiece>().ToArray();
                                 var score = container.OfType<ArgonScoreCounter>().FirstOrDefault();
                                 var accuracy = container.OfType<ArgonAccuracyCounter>().FirstOrDefault();
-                                var combo = container.OfType<ArgonComboCounter>().FirstOrDefault();
                                 var songProgress = container.OfType<ArgonSongProgress>().FirstOrDefault();
                                 var keyCounter = container.OfType<ArgonKeyCounterDisplay>().FirstOrDefault();
 
@@ -192,13 +191,6 @@ public ArgonSkin(SkinInfo skin, IStorageResourceProvider resources)
                                             keyCounter.Origin = Anchor.BottomRight;
                                             keyCounter.Position = new Vector2(-(hitError.Width + padding), -(padding * 2 + song_progress_offset_height));
                                         }
-
-                                        if (combo != null && hitError != null)
-                                        {
-                                            combo.Anchor = Anchor.BottomLeft;
-                                            combo.Origin = Anchor.BottomLeft;
-                                            combo.Position = new Vector2((hitError.Width + padding), -(padding * 2 + song_progress_offset_height));
-                                        }
                                     }
                                 }
                             })
@@ -224,10 +216,6 @@ public ArgonSkin(SkinInfo skin, IStorageResourceProvider resources)
                                         CornerRadius = { Value = 0.5f }
                                     },
                                     new ArgonAccuracyCounter(),
-                                    new ArgonComboCounter
-                                    {
-                                        Scale = new Vector2(1.3f)
-                                    },
                                     new BarHitErrorMeter(),
                                     new BarHitErrorMeter(),
                                     new ArgonSongProgress(),
@@ -235,7 +223,7 @@ public ArgonSkin(SkinInfo skin, IStorageResourceProvider resources)
                                 }
                             };
 
-                            return skinnableTargetWrapper;
+                            return mainHUDComponents;
                     }
 
                     return null;
diff --git a/osu.Game/Skinning/ArgonSkinTransformer.cs b/osu.Game/Skinning/ArgonSkinTransformer.cs
new file mode 100644
index 000000000000..387a7a9c0b18
--- /dev/null
+++ b/osu.Game/Skinning/ArgonSkinTransformer.cs
@@ -0,0 +1,53 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using osu.Framework.Graphics;
+using osu.Game.Screens.Play.HUD;
+using osuTK;
+
+namespace osu.Game.Skinning
+{
+    public class ArgonSkinTransformer : SkinTransformer
+    {
+        public ArgonSkinTransformer(ISkin skin)
+            : base(skin)
+        {
+        }
+
+        public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
+        {
+            switch (lookup)
+            {
+                case SkinComponentsContainerLookup containerLookup:
+                    switch (containerLookup.Target)
+                    {
+                        case SkinComponentsContainerLookup.TargetArea.MainHUDComponents when containerLookup.Ruleset != null:
+                            var rulesetHUDComponents = Skin.GetDrawableComponent(lookup);
+
+                            rulesetHUDComponents ??= new DefaultSkinComponentsContainer(container =>
+                            {
+                                var combo = container.OfType<ArgonComboCounter>().FirstOrDefault();
+
+                                if (combo != null)
+                                {
+                                    combo.Anchor = Anchor.BottomLeft;
+                                    combo.Origin = Anchor.BottomLeft;
+                                    combo.Position = new Vector2(36, -66);
+                                    combo.Scale = new Vector2(1.3f);
+                                }
+                            })
+                            {
+                                new ArgonComboCounter(),
+                            };
+
+                            return rulesetHUDComponents;
+                    }
+
+                    break;
+            }
+
+            return base.GetDrawableComponent(lookup);
+        }
+    }
+}
diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs
index 8f0cd59b68c2..b8e721165eb2 100644
--- a/osu.Game/Skinning/LegacySkin.cs
+++ b/osu.Game/Skinning/LegacySkin.cs
@@ -399,7 +399,6 @@ protected override void ParseConfigurationStream(Stream stream)
                             {
                                 Children = new Drawable[]
                                 {
-                                    new LegacyComboCounter(),
                                     new LegacyScoreCounter(),
                                     new LegacyAccuracyCounter(),
                                     new LegacySongProgress(),
diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs
index 367e5bae0178..3ea316c0c794 100644
--- a/osu.Game/Skinning/LegacySkinTransformer.cs
+++ b/osu.Game/Skinning/LegacySkinTransformer.cs
@@ -1,28 +1,62 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
+using System.Linq;
 using osu.Framework.Audio.Sample;
+using osu.Framework.Graphics;
 using osu.Game.Audio;
 using osu.Game.Rulesets.Objects.Legacy;
+using osuTK;
 using static osu.Game.Skinning.SkinConfiguration;
 
 namespace osu.Game.Skinning
 {
-    /// <summary>
-    /// Transformer used to handle support of legacy features for individual rulesets.
-    /// </summary>
-    public abstract class LegacySkinTransformer : SkinTransformer
+    public class LegacySkinTransformer : SkinTransformer
     {
         /// <summary>
         /// Whether the skin being transformed is able to provide legacy resources for the ruleset.
         /// </summary>
         public virtual bool IsProvidingLegacyResources => this.HasFont(LegacyFont.Combo);
 
-        protected LegacySkinTransformer(ISkin skin)
+        public LegacySkinTransformer(ISkin skin)
             : base(skin)
         {
         }
 
+        public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
+        {
+            switch (lookup)
+            {
+                case SkinComponentsContainerLookup containerLookup:
+                    switch (containerLookup.Target)
+                    {
+                        case SkinComponentsContainerLookup.TargetArea.MainHUDComponents when containerLookup.Ruleset != null:
+                            var rulesetHUDComponents = base.GetDrawableComponent(lookup);
+
+                            rulesetHUDComponents ??= new DefaultSkinComponentsContainer(container =>
+                            {
+                                var combo = container.OfType<LegacyComboCounter>().FirstOrDefault();
+
+                                if (combo != null)
+                                {
+                                    combo.Anchor = Anchor.BottomLeft;
+                                    combo.Origin = Anchor.BottomLeft;
+                                    combo.Scale = new Vector2(1.28f);
+                                }
+                            })
+                            {
+                                new LegacyComboCounter()
+                            };
+
+                            return rulesetHUDComponents;
+                    }
+
+                    break;
+            }
+
+            return base.GetDrawableComponent(lookup);
+        }
+
         public override ISample? GetSample(ISampleInfo sampleInfo)
         {
             if (!(sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample))

From e469e06271e4e4b23a93b5d0c30bf7693fb947e8 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sat, 30 Dec 2023 03:54:53 +0300
Subject: [PATCH 02/10] Refactor `CatchLegacySkinTransformer` logic and remove
 `HiddenByRulesetImplementation` entirely

---
 .../TestSceneCatchPlayerLegacySkin.cs         |   8 +-
 .../Legacy/CatchLegacySkinTransformer.cs      | 104 ++++++++----------
 .../TestSceneSkinnableComboCounter.cs         |  13 ---
 osu.Game/Skinning/LegacyComboCounter.cs       |  12 --
 4 files changed, 50 insertions(+), 87 deletions(-)

diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs
index 540623035905..99325e14c8a5 100644
--- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs
@@ -4,7 +4,6 @@
 using System.Linq;
 using NUnit.Framework;
 using osu.Framework.Extensions.IEnumerableExtensions;
-using osu.Framework.Graphics.Containers;
 using osu.Framework.Screens;
 using osu.Framework.Testing;
 using osu.Game.Skinning;
@@ -19,7 +18,7 @@ public partial class TestSceneCatchPlayerLegacySkin : LegacySkinPlayerTestScene
         protected override Ruleset CreatePlayerRuleset() => new CatchRuleset();
 
         [Test]
-        public void TestLegacyHUDComboCounterHidden([Values] bool withModifiedSkin)
+        public void TestLegacyHUDComboCounterNotExistent([Values] bool withModifiedSkin)
         {
             if (withModifiedSkin)
             {
@@ -29,10 +28,7 @@ public void TestLegacyHUDComboCounterHidden([Values] bool withModifiedSkin)
                 CreateTest();
             }
 
-            AddAssert("legacy HUD combo counter hidden", () =>
-            {
-                return Player.ChildrenOfType<LegacyComboCounter>().All(c => c.ChildrenOfType<Container>().Single().Alpha == 0f);
-            });
+            AddAssert("legacy HUD combo counter not added", () => !Player.ChildrenOfType<LegacyComboCounter>().Any());
         }
     }
 }
diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs
index fb8af9bdb69b..675c61a2c565 100644
--- a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs
@@ -1,10 +1,9 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
-using System.Linq;
+using System.Diagnostics;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
 using osu.Game.Skinning;
 using osuTK.Graphics;
 
@@ -28,76 +27,69 @@ public CatchLegacySkinTransformer(ISkin skin)
 
         public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
         {
-            if (lookup is SkinComponentsContainerLookup containerLookup)
+            switch (lookup)
             {
-                switch (containerLookup.Target)
-                {
-                    case SkinComponentsContainerLookup.TargetArea.MainHUDComponents:
-                        var components = base.GetDrawableComponent(lookup) as Container;
-
-                        if (providesComboCounter && components != null)
-                        {
-                            // catch may provide its own combo counter; hide the default.
-                            // todo: this should be done in an elegant way per ruleset, defining which HUD skin components should be displayed.
-                            foreach (var legacyComboCounter in components.OfType<LegacyComboCounter>())
-                                legacyComboCounter.HiddenByRulesetImplementation = false;
-                        }
-
-                        return components;
-                }
-            }
+                case SkinComponentsContainerLookup containerLookup:
+                    switch (containerLookup.Target)
+                    {
+                        case SkinComponentsContainerLookup.TargetArea.MainHUDComponents when containerLookup.Ruleset != null:
+                            Debug.Assert(containerLookup.Ruleset.ShortName == CatchRuleset.SHORT_NAME);
+                            // todo: remove CatchSkinComponents.CatchComboCounter and refactor LegacyCatchComboCounter to be added here instead.
+                            return Skin.GetDrawableComponent(lookup);
+                    }
 
-            if (lookup is CatchSkinComponentLookup catchSkinComponent)
-            {
-                switch (catchSkinComponent.Component)
-                {
-                    case CatchSkinComponents.Fruit:
-                        if (hasPear)
-                            return new LegacyFruitPiece();
+                    break;
 
-                        return null;
+                case CatchSkinComponentLookup catchSkinComponent:
+                    switch (catchSkinComponent.Component)
+                    {
+                        case CatchSkinComponents.Fruit:
+                            if (hasPear)
+                                return new LegacyFruitPiece();
 
-                    case CatchSkinComponents.Banana:
-                        if (GetTexture("fruit-bananas") != null)
-                            return new LegacyBananaPiece();
+                            return null;
 
-                        return null;
+                        case CatchSkinComponents.Banana:
+                            if (GetTexture("fruit-bananas") != null)
+                                return new LegacyBananaPiece();
 
-                    case CatchSkinComponents.Droplet:
-                        if (GetTexture("fruit-drop") != null)
-                            return new LegacyDropletPiece();
+                            return null;
 
-                        return null;
+                        case CatchSkinComponents.Droplet:
+                            if (GetTexture("fruit-drop") != null)
+                                return new LegacyDropletPiece();
 
-                    case CatchSkinComponents.Catcher:
-                        decimal version = GetConfig<SkinConfiguration.LegacySetting, decimal>(SkinConfiguration.LegacySetting.Version)?.Value ?? 1;
+                            return null;
 
-                        if (version < 2.3m)
-                        {
-                            if (hasOldStyleCatcherSprite())
-                                return new LegacyCatcherOld();
-                        }
+                        case CatchSkinComponents.Catcher:
+                            decimal version = GetConfig<SkinConfiguration.LegacySetting, decimal>(SkinConfiguration.LegacySetting.Version)?.Value ?? 1;
 
-                        if (hasNewStyleCatcherSprite())
-                            return new LegacyCatcherNew();
+                            if (version < 2.3m)
+                            {
+                                if (hasOldStyleCatcherSprite())
+                                    return new LegacyCatcherOld();
+                            }
 
-                        return null;
+                            if (hasNewStyleCatcherSprite())
+                                return new LegacyCatcherNew();
 
-                    case CatchSkinComponents.CatchComboCounter:
-                        if (providesComboCounter)
-                            return new LegacyCatchComboCounter();
+                            return null;
 
-                        return null;
+                        case CatchSkinComponents.CatchComboCounter:
+                            if (providesComboCounter)
+                                return new LegacyCatchComboCounter();
 
-                    case CatchSkinComponents.HitExplosion:
-                        if (hasOldStyleCatcherSprite() || hasNewStyleCatcherSprite())
-                            return new LegacyHitExplosion();
+                            return null;
 
-                        return null;
+                        case CatchSkinComponents.HitExplosion:
+                            if (hasOldStyleCatcherSprite() || hasNewStyleCatcherSprite())
+                                return new LegacyHitExplosion();
+
+                            return null;
 
-                    default:
-                        throw new UnsupportedSkinComponentException(lookup);
-                }
+                        default:
+                            throw new UnsupportedSkinComponentException(lookup);
+                    }
             }
 
             return base.GetDrawableComponent(lookup);
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs
index 72f40d9c6f8e..a15a3197c578 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs
@@ -4,7 +4,6 @@
 using NUnit.Framework;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics;
-using osu.Framework.Testing;
 using osu.Game.Rulesets.Osu;
 using osu.Game.Rulesets.Scoring;
 using osu.Game.Screens.Play.HUD;
@@ -28,17 +27,5 @@ public void TestComboCounterIncrementing()
 
             AddStep("reset combo", () => scoreProcessor.Combo.Value = 0);
         }
-
-        [Test]
-        public void TestLegacyComboCounterHiddenByRulesetImplementation()
-        {
-            AddToggleStep("toggle legacy hidden by ruleset", visible =>
-            {
-                foreach (var legacyCounter in this.ChildrenOfType<LegacyComboCounter>())
-                    legacyCounter.HiddenByRulesetImplementation = visible;
-            });
-
-            AddRepeatStep("increase combo", () => scoreProcessor.Combo.Value++, 10);
-        }
     }
 }
diff --git a/osu.Game/Skinning/LegacyComboCounter.cs b/osu.Game/Skinning/LegacyComboCounter.cs
index cd72055fce66..d77a39f6074e 100644
--- a/osu.Game/Skinning/LegacyComboCounter.cs
+++ b/osu.Game/Skinning/LegacyComboCounter.cs
@@ -43,18 +43,6 @@ public partial class LegacyComboCounter : CompositeDrawable, ISerialisableDrawab
 
         private readonly Container counterContainer;
 
-        /// <summary>
-        /// Hides the combo counter internally without affecting its <see cref="SerialisedDrawableInfo"/>.
-        /// </summary>
-        /// <remarks>
-        /// This is used for rulesets that provide their own combo counter and don't want this HUD one to be visible,
-        /// without potentially affecting the user's selected skin.
-        /// </remarks>
-        public bool HiddenByRulesetImplementation
-        {
-            set => counterContainer.Alpha = value ? 1 : 0;
-        }
-
         public bool UsesFixedAnchor { get; set; }
 
         public LegacyComboCounter()

From fbc99894279ab1da456cef2ceebc0615eefe24b4 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Tue, 25 Jun 2024 01:01:26 +0300
Subject: [PATCH 03/10] Simplify default layout initialisation

---
 osu.Game/Skinning/ArgonSkinTransformer.cs  | 41 ++++++++--------------
 osu.Game/Skinning/LegacySkinTransformer.cs | 37 +++++--------------
 2 files changed, 23 insertions(+), 55 deletions(-)

diff --git a/osu.Game/Skinning/ArgonSkinTransformer.cs b/osu.Game/Skinning/ArgonSkinTransformer.cs
index 387a7a9c0b18..8ca8f79b4169 100644
--- a/osu.Game/Skinning/ArgonSkinTransformer.cs
+++ b/osu.Game/Skinning/ArgonSkinTransformer.cs
@@ -1,8 +1,8 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
-using System.Linq;
 using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
 using osu.Game.Screens.Play.HUD;
 using osuTK;
 
@@ -17,34 +17,21 @@ public ArgonSkinTransformer(ISkin skin)
 
         public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
         {
-            switch (lookup)
+            if (lookup is SkinComponentsContainerLookup containerLookup
+                && containerLookup.Target == SkinComponentsContainerLookup.TargetArea.MainHUDComponents
+                && containerLookup.Ruleset != null)
             {
-                case SkinComponentsContainerLookup containerLookup:
-                    switch (containerLookup.Target)
+                return base.GetDrawableComponent(lookup) ?? new Container
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Child = new ArgonComboCounter
                     {
-                        case SkinComponentsContainerLookup.TargetArea.MainHUDComponents when containerLookup.Ruleset != null:
-                            var rulesetHUDComponents = Skin.GetDrawableComponent(lookup);
-
-                            rulesetHUDComponents ??= new DefaultSkinComponentsContainer(container =>
-                            {
-                                var combo = container.OfType<ArgonComboCounter>().FirstOrDefault();
-
-                                if (combo != null)
-                                {
-                                    combo.Anchor = Anchor.BottomLeft;
-                                    combo.Origin = Anchor.BottomLeft;
-                                    combo.Position = new Vector2(36, -66);
-                                    combo.Scale = new Vector2(1.3f);
-                                }
-                            })
-                            {
-                                new ArgonComboCounter(),
-                            };
-
-                            return rulesetHUDComponents;
-                    }
-
-                    break;
+                        Anchor = Anchor.BottomLeft,
+                        Origin = Anchor.BottomLeft,
+                        Position = new Vector2(36, -66),
+                        Scale = new Vector2(1.3f),
+                    },
+                };
             }
 
             return base.GetDrawableComponent(lookup);
diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs
index 3ea316c0c794..dbfa52de84e0 100644
--- a/osu.Game/Skinning/LegacySkinTransformer.cs
+++ b/osu.Game/Skinning/LegacySkinTransformer.cs
@@ -1,12 +1,11 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
-using System.Linq;
 using osu.Framework.Audio.Sample;
 using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
 using osu.Game.Audio;
 using osu.Game.Rulesets.Objects.Legacy;
-using osuTK;
 using static osu.Game.Skinning.SkinConfiguration;
 
 namespace osu.Game.Skinning
@@ -25,33 +24,15 @@ public LegacySkinTransformer(ISkin skin)
 
         public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
         {
-            switch (lookup)
+            if (lookup is SkinComponentsContainerLookup containerLookup
+                && containerLookup.Target == SkinComponentsContainerLookup.TargetArea.MainHUDComponents
+                && containerLookup.Ruleset != null)
             {
-                case SkinComponentsContainerLookup containerLookup:
-                    switch (containerLookup.Target)
-                    {
-                        case SkinComponentsContainerLookup.TargetArea.MainHUDComponents when containerLookup.Ruleset != null:
-                            var rulesetHUDComponents = base.GetDrawableComponent(lookup);
-
-                            rulesetHUDComponents ??= new DefaultSkinComponentsContainer(container =>
-                            {
-                                var combo = container.OfType<LegacyComboCounter>().FirstOrDefault();
-
-                                if (combo != null)
-                                {
-                                    combo.Anchor = Anchor.BottomLeft;
-                                    combo.Origin = Anchor.BottomLeft;
-                                    combo.Scale = new Vector2(1.28f);
-                                }
-                            })
-                            {
-                                new LegacyComboCounter()
-                            };
-
-                            return rulesetHUDComponents;
-                    }
-
-                    break;
+                return base.GetDrawableComponent(lookup) ?? new Container
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Child = new LegacyComboCounter(),
+                };
             }
 
             return base.GetDrawableComponent(lookup);

From e8de293be5f813731f97ab8132c569c914dca59a Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Tue, 25 Jun 2024 01:01:32 +0300
Subject: [PATCH 04/10] Remove pointless assert

---
 .../Skinning/Legacy/CatchLegacySkinTransformer.cs               | 2 --
 1 file changed, 2 deletions(-)

diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs
index 462fd5ab6448..17218b459aca 100644
--- a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs
@@ -1,7 +1,6 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
-using System.Diagnostics;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Game.Skinning;
@@ -33,7 +32,6 @@ public CatchLegacySkinTransformer(ISkin skin)
                     switch (containerLookup.Target)
                     {
                         case SkinComponentsContainerLookup.TargetArea.MainHUDComponents when containerLookup.Ruleset != null:
-                            Debug.Assert(containerLookup.Ruleset.ShortName == CatchRuleset.SHORT_NAME);
                             // todo: remove CatchSkinComponents.CatchComboCounter and refactor LegacyCatchComboCounter to be added here instead.
                             return Skin.GetDrawableComponent(lookup);
                     }

From 78e0126f16e90aca5459b288e42443bbf2b3387e Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Tue, 25 Jun 2024 04:24:58 +0300
Subject: [PATCH 05/10] Migrate combo counter layouts in custom skins to
 correct target-ruleset pairs

---
 osu.Game/Database/RealmAccess.cs  |  3 ++-
 osu.Game/Skinning/ArgonSkin.cs    | 19 +++++++++++++++++++
 osu.Game/Skinning/LegacySkin.cs   | 19 +++++++++++++++++++
 osu.Game/Skinning/SkinImporter.cs |  2 ++
 osu.Game/Skinning/SkinInfo.cs     | 15 +++++++++++++++
 5 files changed, 57 insertions(+), 1 deletion(-)

diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index 1ece81be5095..606bc5e10cab 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -93,8 +93,9 @@ public class RealmAccess : IDisposable
         /// 39   2023-12-19    Migrate any EndTimeObjectCount and TotalObjectCount values of 0 to -1 to better identify non-calculated values.
         /// 40   2023-12-21    Add ScoreInfo.Version to keep track of which build scores were set on.
         /// 41   2024-04-17    Add ScoreInfo.TotalScoreWithoutMods for future mod multiplier rebalances.
+        /// 42   2024-06-25    Add SkinInfo.LayoutVersion to allow performing migrations of components on structural changes.
         /// </summary>
-        private const int schema_version = 41;
+        private const int schema_version = 42;
 
         /// <summary>
         /// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs
index 707281db3107..743ce388106e 100644
--- a/osu.Game/Skinning/ArgonSkin.cs
+++ b/osu.Game/Skinning/ArgonSkin.cs
@@ -12,6 +12,7 @@
 using osu.Game.Beatmaps.Formats;
 using osu.Game.Extensions;
 using osu.Game.IO;
+using osu.Game.Rulesets;
 using osu.Game.Screens.Play;
 using osu.Game.Screens.Play.HUD;
 using osu.Game.Screens.Play.HUD.HitErrorMeters;
@@ -69,6 +70,24 @@ public ArgonSkin(SkinInfo skin, IStorageResourceProvider resources)
                 // Purple
                 new Color4(92, 0, 241, 255),
             };
+
+            if (skin.LayoutVersion < 20240625
+                && LayoutInfos.TryGetValue(SkinComponentsContainerLookup.TargetArea.MainHUDComponents, out var hudLayout)
+                && hudLayout.TryGetDrawableInfo(null, out var hudComponents))
+            {
+                var comboCounters = hudComponents.Where(h => h.Type.Name == nameof(ArgonComboCounter)).ToArray();
+
+                if (comboCounters.Any())
+                {
+                    hudLayout.Update(null, hudComponents.Except(comboCounters).ToArray());
+
+                    resources.RealmAccess.Run(r =>
+                    {
+                        foreach (var ruleset in r.All<RulesetInfo>())
+                            hudLayout.Update(ruleset, comboCounters);
+                    });
+                }
+            }
         }
 
         public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT);
diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs
index b71b626b4ed0..c3e619431ef9 100644
--- a/osu.Game/Skinning/LegacySkin.cs
+++ b/osu.Game/Skinning/LegacySkin.cs
@@ -19,6 +19,7 @@
 using osu.Game.Beatmaps.Formats;
 using osu.Game.Extensions;
 using osu.Game.IO;
+using osu.Game.Rulesets;
 using osu.Game.Rulesets.Objects.Types;
 using osu.Game.Rulesets.Scoring;
 using osu.Game.Screens.Play.HUD;
@@ -56,6 +57,24 @@ public LegacySkin(SkinInfo skin, IStorageResourceProvider resources)
         protected LegacySkin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore<byte[]>? fallbackStore, string configurationFilename = @"skin.ini")
             : base(skin, resources, fallbackStore, configurationFilename)
         {
+            if (resources != null
+                && skin.LayoutVersion < 20240625
+                && LayoutInfos.TryGetValue(SkinComponentsContainerLookup.TargetArea.MainHUDComponents, out var hudLayout)
+                && hudLayout.TryGetDrawableInfo(null, out var hudComponents))
+            {
+                var comboCounters = hudComponents.Where(h => h.Type.Name == nameof(LegacyComboCounter)).ToArray();
+
+                if (comboCounters.Any())
+                {
+                    hudLayout.Update(null, hudComponents.Except(comboCounters).ToArray());
+
+                    resources.RealmAccess.Run(r =>
+                    {
+                        foreach (var ruleset in r.All<RulesetInfo>())
+                            hudLayout.Update(ruleset, comboCounters);
+                    });
+                }
+            }
         }
 
         protected override IResourceStore<TextureUpload> CreateTextureLoaderStore(IStorageResourceProvider resources, IResourceStore<byte[]> storage)
diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs
index 59c7f0ba2679..714427f40da6 100644
--- a/osu.Game/Skinning/SkinImporter.cs
+++ b/osu.Game/Skinning/SkinImporter.cs
@@ -223,6 +223,8 @@ public bool Save(Skin skin)
                     }
                 }
 
+                s.LayoutVersion = SkinInfo.LATEST_LAYOUT_VERSION;
+
                 string newHash = ComputeHash(s);
 
                 hadChanges = newHash != s.Hash;
diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs
index 9763d3b57e9a..a3d5771b5e66 100644
--- a/osu.Game/Skinning/SkinInfo.cs
+++ b/osu.Game/Skinning/SkinInfo.cs
@@ -39,6 +39,21 @@ public class SkinInfo : RealmObject, IHasRealmFiles, IEquatable<SkinInfo>, IHasG
 
         public bool Protected { get; set; }
 
+        /// <summary>
+        /// The latest version in YYYYMMDD format for skin layout migrations.
+        /// </summary>
+        /// <remarks>
+        /// <list type="bullet">
+        /// <item><description>20240625: Moves combo counters from ruleset-agnostic to ruleset-specific HUD targets.</description></item>
+        /// </list>
+        /// </remarks>
+        public const int LATEST_LAYOUT_VERSION = 20240625;
+
+        /// <summary>
+        /// A version in YYYYMMDD format for applying skin layout migrations.
+        /// </summary>
+        public int LayoutVersion { get; set; }
+
         public virtual Skin CreateInstance(IStorageResourceProvider resources)
         {
             var type = string.IsNullOrEmpty(InstantiationInfo)

From fc2202e0cc9cdf1191675272607aa7e51f2037e4 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Tue, 25 Jun 2024 05:54:56 +0300
Subject: [PATCH 06/10] Fix migration logic overwriting existing components in
 ruleset targets

---
 osu.Game/Skinning/ArgonSkin.cs  | 6 +++++-
 osu.Game/Skinning/LegacySkin.cs | 6 +++++-
 2 files changed, 10 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs
index 743ce388106e..4cd54c06f073 100644
--- a/osu.Game/Skinning/ArgonSkin.cs
+++ b/osu.Game/Skinning/ArgonSkin.cs
@@ -84,7 +84,11 @@ public ArgonSkin(SkinInfo skin, IStorageResourceProvider resources)
                     resources.RealmAccess.Run(r =>
                     {
                         foreach (var ruleset in r.All<RulesetInfo>())
-                            hudLayout.Update(ruleset, comboCounters);
+                        {
+                            hudLayout.Update(ruleset, hudLayout.TryGetDrawableInfo(ruleset, out var rulesetComponents)
+                                ? rulesetComponents.Concat(comboCounters).ToArray()
+                                : comboCounters);
+                        }
                     });
                 }
             }
diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs
index c3e619431ef9..f148bad96e75 100644
--- a/osu.Game/Skinning/LegacySkin.cs
+++ b/osu.Game/Skinning/LegacySkin.cs
@@ -71,7 +71,11 @@ protected LegacySkin(SkinInfo skin, IStorageResourceProvider? resources, IResour
                     resources.RealmAccess.Run(r =>
                     {
                         foreach (var ruleset in r.All<RulesetInfo>())
-                            hudLayout.Update(ruleset, comboCounters);
+                        {
+                            hudLayout.Update(ruleset, hudLayout.TryGetDrawableInfo(ruleset, out var rulesetComponents)
+                                ? rulesetComponents.Concat(comboCounters).ToArray()
+                                : comboCounters);
+                        }
                     });
                 }
             }

From dc1fb4fdca462a8d8123e695177aa61f951a78da Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Tue, 25 Jun 2024 05:54:59 +0300
Subject: [PATCH 07/10] Add test coverage

---
 .../Visual/Gameplay/TestSceneSkinEditor.cs    | 84 +++++++++++++++++++
 1 file changed, 84 insertions(+)

diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs
index 3c97700fb082..2470c320cc71 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs
@@ -13,12 +13,14 @@
 using osu.Framework.Graphics.UserInterface;
 using osu.Framework.Input;
 using osu.Framework.Testing;
+using osu.Game.Database;
 using osu.Game.Overlays;
 using osu.Game.Overlays.Settings;
 using osu.Game.Overlays.SkinEditor;
 using osu.Game.Rulesets;
 using osu.Game.Rulesets.Osu;
 using osu.Game.Screens.Edit;
+using osu.Game.Screens.Play.HUD;
 using osu.Game.Screens.Play.HUD.HitErrorMeters;
 using osu.Game.Skinning;
 using osu.Game.Skinning.Components;
@@ -39,6 +41,9 @@ public partial class TestSceneSkinEditor : PlayerTestScene
         [Cached]
         public readonly EditorClipboard Clipboard = new EditorClipboard();
 
+        [Resolved]
+        private SkinManager skins { get; set; } = null!;
+
         private SkinComponentsContainer targetContainer => Player.ChildrenOfType<SkinComponentsContainer>().First();
 
         [SetUpSteps]
@@ -46,6 +51,7 @@ public override void SetUpSteps()
         {
             base.SetUpSteps();
 
+            AddStep("reset skin", () => skins.CurrentSkinInfo.SetDefault());
             AddUntilStep("wait for hud load", () => targetContainer.ComponentsLoaded);
 
             AddStep("reload skin editor", () =>
@@ -369,6 +375,84 @@ public void TestCopyPaste()
                 () => Is.EqualTo(3));
         }
 
+        private SkinComponentsContainer globalHUDTarget => Player.ChildrenOfType<SkinComponentsContainer>()
+                                                                 .Single(c => c.Lookup.Target == SkinComponentsContainerLookup.TargetArea.MainHUDComponents && c.Lookup.Ruleset == null);
+
+        private SkinComponentsContainer rulesetHUDTarget => Player.ChildrenOfType<SkinComponentsContainer>()
+                                                                  .Single(c => c.Lookup.Target == SkinComponentsContainerLookup.TargetArea.MainHUDComponents && c.Lookup.Ruleset != null);
+
+        [Test]
+        public void TestMigrationArgon()
+        {
+            AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded);
+            AddStep("add combo to global hud target", () =>
+            {
+                globalHUDTarget.Add(new ArgonComboCounter
+                {
+                    Anchor = Anchor.Centre,
+                    Origin = Anchor.Centre,
+                });
+            });
+
+            Live<SkinInfo> modifiedSkin = null!;
+
+            AddStep("select another skin", () =>
+            {
+                modifiedSkin = skins.CurrentSkinInfo.Value;
+                skins.CurrentSkinInfo.SetDefault();
+            });
+            AddStep("modify version", () => modifiedSkin.PerformWrite(s => s.LayoutVersion = 0));
+            AddStep("select skin again", () => skins.CurrentSkinInfo.Value = modifiedSkin);
+            AddAssert("global hud target does not contain combo", () => !globalHUDTarget.Components.Any(c => c is ArgonComboCounter));
+            AddAssert("ruleset hud target contains both combos", () =>
+            {
+                var target = rulesetHUDTarget;
+
+                return target.Components.Count == 2 &&
+                       target.Components[0] is ArgonComboCounter one && one.Anchor == Anchor.BottomLeft && one.Origin == Anchor.BottomLeft &&
+                       target.Components[1] is ArgonComboCounter two && two.Anchor == Anchor.Centre && two.Origin == Anchor.Centre;
+            });
+            AddStep("save skin", () => skinEditor.Save());
+            AddAssert("version updated", () => modifiedSkin.PerformRead(s => s.LayoutVersion) == SkinInfo.LATEST_LAYOUT_VERSION);
+        }
+
+        [Test]
+        public void TestMigrationLegacy()
+        {
+            AddStep("select legacy skin", () => skins.CurrentSkinInfo.Value = skins.DefaultClassicSkin.SkinInfo);
+
+            AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded);
+            AddStep("add combo to global hud target", () =>
+            {
+                globalHUDTarget.Add(new LegacyComboCounter
+                {
+                    Anchor = Anchor.Centre,
+                    Origin = Anchor.Centre,
+                });
+            });
+
+            Live<SkinInfo> modifiedSkin = null!;
+
+            AddStep("select another skin", () =>
+            {
+                modifiedSkin = skins.CurrentSkinInfo.Value;
+                skins.CurrentSkinInfo.SetDefault();
+            });
+            AddStep("modify version", () => modifiedSkin.PerformWrite(s => s.LayoutVersion = 0));
+            AddStep("select skin again", () => skins.CurrentSkinInfo.Value = modifiedSkin);
+            AddAssert("global hud target does not contain combo", () => !globalHUDTarget.Components.Any(c => c is LegacyComboCounter));
+            AddAssert("ruleset hud target contains both combos", () =>
+            {
+                var target = rulesetHUDTarget;
+
+                return target.Components.Count == 2 &&
+                       target.Components[0] is LegacyComboCounter one && one.Anchor == Anchor.BottomLeft && one.Origin == Anchor.BottomLeft &&
+                       target.Components[1] is LegacyComboCounter two && two.Anchor == Anchor.Centre && two.Origin == Anchor.Centre;
+            });
+            AddStep("save skin", () => skinEditor.Save());
+            AddAssert("version updated", () => modifiedSkin.PerformRead(s => s.LayoutVersion) == SkinInfo.LATEST_LAYOUT_VERSION);
+        }
+
         protected override Ruleset CreatePlayerRuleset() => new OsuRuleset();
 
         private partial class TestSkinEditorChangeHandler : SkinEditorChangeHandler

From 0c34e7bebbce5a989af00d8daed25331ff33321a Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Mon, 1 Jul 2024 06:48:05 +0300
Subject: [PATCH 08/10] Store layout version in `SkinLayoutVersion` instead and
 refactor migration code

---
 .../Archives/argon-layout-version-0.osk       | Bin 0 -> 1550 bytes
 .../Archives/classic-layout-version-0.osk     | Bin 0 -> 1382 bytes
 .../Archives/triangles-layout-version-0.osk   | Bin 0 -> 1378 bytes
 .../Visual/Gameplay/TestSceneSkinEditor.cs    | 119 ++++++++++--------
 osu.Game/Database/RealmAccess.cs              |   3 +-
 osu.Game/Skinning/ArgonSkin.cs                |  23 ----
 osu.Game/Skinning/LegacySkin.cs               |  23 ----
 osu.Game/Skinning/Skin.cs                     | 116 ++++++++++++-----
 osu.Game/Skinning/SkinImporter.cs             |   2 -
 osu.Game/Skinning/SkinInfo.cs                 |  15 ---
 osu.Game/Skinning/SkinLayoutInfo.cs           |  18 ++-
 11 files changed, 165 insertions(+), 154 deletions(-)
 create mode 100644 osu.Game.Tests/Resources/Archives/argon-layout-version-0.osk
 create mode 100644 osu.Game.Tests/Resources/Archives/classic-layout-version-0.osk
 create mode 100644 osu.Game.Tests/Resources/Archives/triangles-layout-version-0.osk

diff --git a/osu.Game.Tests/Resources/Archives/argon-layout-version-0.osk b/osu.Game.Tests/Resources/Archives/argon-layout-version-0.osk
new file mode 100644
index 0000000000000000000000000000000000000000..f767033eb1de68d16bbb5c4fa9cb5a0fb145425d
GIT binary patch
literal 1550
zcmWIWW@Zs#U|`^2*qU%JLZU!8aS@Pr04&15P@J8ar<a+RsoJxV>yU#0>+j!P%R^ZV
zg}4>xPMtgTR>x7b?S}2;yNh-Te99H8o3uZn>FxoZ&$acRWS&pExZ=B&MdgApPaRI?
z(gk*z3!Y?ss$+>fEq0aT?Jw3G`R8|9rytwm=6Y8p^n%5Cr^borgHH=K3P*ZvG2E`q
z?f$8}cvjaZ`>3Fr6*?0|Z!d~a<j#tHe8X=?^i1dZQN5~qpG0Jj#8{U3Ep^#=bGCc{
zia*W-BsY}+{m=u%yg&@{M`m7HzFt;we%|V5K>-1uywCWCZVC!GrLPwn(h=yT<8}Ug
zcVN&310w^YOCc9dob^BHbK1w3(WuvSYKeo@obDiD?@!$hvz$I}`uyqiXN5=eDvzpm
zR;teIRGs;=a$aTT&w%8wcN)W9u%@R!dz$)e>1)fT9EOi2A_<ecVzx4%I`6Y!L6sCU
z1A~q<1A`dQdA^C6c^;uI&iT0o`FW{%CB<NOzMT=6f6GAN`2F9SO7jkKah^;FlDE3m
zn>nlPT3PLiRVP}MKN^@>S#MeMs(e1@6d@1uz@?tiQ^imGf3x}gznAY$ylqUq+BY%o
zGGqJtB$aIWwb6?uO&=9G?5y19Gk@*WdAH6z3pSWGD}?i>le&YSGmAji7t6Vm@BK8i
zJmk^EE#Y<{UBKz?F8QeKb&OhSM;9hr>prrUE9{8fzKioZ=b?oSF3L|!SI_6M;NE}m
zk58YYOjpb2sK1HawJT?s<z17Gdui^LQL(LX=gM=vx4v}jz4p#5=I@kQ>ta%$UG;N5
zrI_ECHt)Clp7;$5`+D!@rqBJ};JVSQjo;7BUMNxL;)}VwAK5o`bn1SLY5a92FJZ?#
zqeU?X(-;<*3QxS%*Vrk!fG>on#8$Wb^W7OA6%QpnJ-xbF_qR6FqNmUA%t+qPyz$a%
zrbi}IIAUjV@J8qf`Q`O}Sml}|=pZK;{cC?`!Cj{bJJL9mO|}c3(Bz#tZ}~#&I5iE?
z)2|yYGk!2y`I7Oem0em^){~-*CzmdJ<&tDS$>ZChWiwoi7DycV{(ATA_Nf{i0y``x
zOR{wRZA@R3cBaK(w{ooIOfN<!?N6Tf<`m4BasMgP<m27Zy862M^5W(Fn=WtRK6rFV
zJ)?wz>?W6`fz_!h6BZlzOlbS;rR9IPve7c-{;A2DMpviH^>U`HDJeaCupm-ZOzxDz
zYJQ7J77LYH_NAzO+@o+rtaj;73)aN6$=}WixmCZOG1YP6%4p_D-{?Deg15id=0DGp
zj%&XWcl-M%<1fLNrp%ta?2gmVOskzoPgm~#^JRve<CGt=Jl4zJE}Rj#D!(o+eD3Kl
zmagUY`zI$(*|vl+ory_ybAgV7?y;@glDJi;bFy`Rt2!nkZLsf2?(z>y_xL_Jvqn7N
z^YVFa6U=o?Rx7axcO_r0*R=KT-!W5ia?tODYWD3K1=34PGS-$ComMRWspj3aqTk4=
znCt51TNz^Rt9`Ai58t};zO`lY?9{nQ5xzUSCip#mzW#tF*E6y3z0s=cS4gd!|Ly0u
zyy|-&{)UIv#@cWH`+feIqxJbVfA>Z8Mcw(h_v5<<@AOe~lg!klDm7p((gb2YAP&e$
ztW3*H%}GJz_99I$9e<xwo?p*+pAO#?w1TaR(e-n;cjKHF5en<4tri33PevvY2HXW9
z&{zm)1W~YJ5nUI0u>;k^z|eRCstaBSp=(7i5)fL`8F3X5=w_e?1j3B1Ea<@!;LXYg
Pl4k+Jk3gE86~qGo@tS=8

literal 0
HcmV?d00001

diff --git a/osu.Game.Tests/Resources/Archives/classic-layout-version-0.osk b/osu.Game.Tests/Resources/Archives/classic-layout-version-0.osk
new file mode 100644
index 0000000000000000000000000000000000000000..8240510f7c20e7d192efbfbba03ff9243f6f51dc
GIT binary patch
literal 1382
zcmWIWW@Zs#U|`^2*p+ZEVoSr(w@ZM$LtqgGhT`nZJiW}kOx2!^T!$P4SbzWSS{}+`
zD8;Hcck0}!w>pl_y8Yte-F0_jIqGJ&&VQmWu~B+PqTTxP$fWw@EwA=|^IMk@V&v`S
z&v_-4cS@}DJ$uJ%n~px^^u5cmt$Y8S*7jpIM_Vr`gkG>Z@6;IhZ`m|uru0oyGSmE`
z#rmF{Pcs+!T5pnPv-HU<!>+7-5q<$ITi2E5u*dkGS$5jI*OaeF{OwU0OSR=ei)XDp
zD;|L2lhb)8+8cnrm<GhWKn(IpW?ovpURH5_-s)#T0Rf-9&-jLJ3JN%-uNNB95$JW!
zQ%CQdPhVir1p^}klWQRtPMq~W>2uo0m(j@AWNL{+)tv4iVee1f4!4{>Z~FY{^k;=f
z^D2+3c2=s+>{OlkvvOW#=FfoUuXh@!y<kmGfA%!>+0xfLmn>7A_H4?JCCk*+!kb)5
zMH0HE?8st7b?Q;ev-K%V3=Et27#PHWPW4U9%<~9!an8>z$j?j7D=7xM`RX*^{96tJ
zZSVhZb#IRR$=$47=&?{YG+MNEiLJ}Tt!B-8Q*ZW`UH1$)Qvd!|;Q_Yi7fx}taDTEs
z{D$ZJkFE2q+AhxJ_|u@w6@PP5xxIvc)a5Lv%9fS;_Gzx`4bo2vpKX0%qTY1LUrpB=
zo@pPFNaXy*$Ju9|ysz)ajC7wygWl;nmw&K237vE6ds=a#{MQM`X_-C7tBe?x*=7pO
zm~~92TcdOT!Dn6T8+s$$GOyp6Z})hTMr_*k?3MOe2i^Q0+}QSf*3}bk?p0AP^B#X~
z-+f|R(3eTtF=AUwf`1>dIVKWknN?BZrDt~Szpt3qRnDIVqLo}Ko>w>49egyUd(VR4
zz<RDp0%a@WyeG^*sjO3GAC;uF(27}O>B|!}Jz2qzr(9@l*z@RY?(+pN>mS(m?`)X-
zVckY8$&9tCN5r`Pw0&y4ua$H?*C{60zyF6)pK;)wTj@?WKTmiY9k$=}%HKx4^vL7q
zHuBFk|CV)s#}QGzjy;QaNG#s?`<hO}<`lIGC5KbnWBGF5FsE3)`|u;|tE~x7jdsS_
zkNwv#)E&)<y2tf?&6xzQ8LLV@?TK2xz3Cgze_alC--c%4&%LY+lQWi8E_(Tu#kcd9
z3X8bC!|c5-@*7uZzHh43w@`l7ceUm2wtus$w?4GLsnlp{AR2T?Aw;t?+()|b-wc}>
zy3eMhEA?#Pc;BfaxX|V4XRV2jx`E67D8_uQu8r$>@A!aa)}^G{RSgBbN4Z=yt}gr0
z{Wgm4sNjX#HBX;kc=*aMq4Vpfm%iHfL;W5F+wRWHuFjKZ{bek%NBMBk-(^?VuGEbV
z`&j#X&eX|uoO#x__oJpunW;%tYQXfU3B<sp$q<l}SecfYnv;UaLq(ciI{rSVJingt
zJ{`U(Xa!prqwD8x@5VVVA{5q7TP?;A;LXS+!hpMg0U8SdjUWnE+@R}1&&*Ig3=EB@
wpt|5$8(k}U=0a#KWyF=$(9J*(2!t8DEa<@!;LXYgl4k+Jk3c#esDgn30H7H={{R30

literal 0
HcmV?d00001

diff --git a/osu.Game.Tests/Resources/Archives/triangles-layout-version-0.osk b/osu.Game.Tests/Resources/Archives/triangles-layout-version-0.osk
new file mode 100644
index 0000000000000000000000000000000000000000..5601eb279b28d2460d12e3e118023947d0a142b6
GIT binary patch
literal 1378
zcmWIWW@Zs#U|`^2n3QlY;=zP~vrB-yBVZ8*hT`nZJiW}kOw}GkE+#_(*WYs<WiM4&
zm#3VQo|?YYEXi<IB(IwBq%)f;%((lXh{kNJ{vh}Mb5*!#nYr6)!z}qHUEN(9C91R*
zoHLo&%6;?)OXYdF=jvNZlz-^`|1?2vR&JZ|RL4~dcqg_BE&k<eo33K^DaEAxI4^g4
zm-=?uu4ndA(R?b`Po(yE?u}TtY=+9$g}iUr*DTMwd(*eLdG8(9>O%H&sw+%ZJlFot
z8i3*xCI8FTl|Wxi0b*Vt2Kgj2FD+j$t2jSz^|PRWfKT3Md_y+{1)S2?3k~TA)Y3fV
zbH@9;r*2<h&;<h{1C#3^7fzh@Kk0MY$CuHl*JNsm!>&2qLBigjx*epPKX3Z{>GWrX
zNAoI=s&-bY&g@j3`Ll9fW#-R-#b56<UVFisp8o7<>a(S<TMy(gcD~R!Afi!tm5Bk>
zjV`hsCh1H-HwrT_hymT`o0ysB5$fWcpIeZhmzq~n40i0>aNqn}4gz~WYwMibU?kzO
z@`z&Vwvf<J5s&H)rR-d`>eE%?XV*=R5d6P4`%%MOt9GS*pAH`VarVudp8IE_fA33*
zyvlef`c>f%wX{vYF77g);mE9Kd+O}ptFP{z`Y<(jlFosMRY6CeX!kbs)UpV4^;r0x
z_)z4)X0X`sdaY#TCjI1vjM>)~SD*dGx?+aN-eq@lCi-pmopoegWbI}pw-=lpra^I=
z-8LSd`CoQY=bYvjoijdX<xW24p|#9+M(HKVy7UIO)t*t?a?P_Vy_0nOX5YxpnaN$Q
zXnOY0O|`kbiEo1Wmd<{1chiU7^hN*Ef@P(uqg?ppn7Ez(>euQ02}qK9uhag|MQnq~
z!92b_Aq)0xu%5B*`WcPz3Hl6M(v$)+d4JCNrT93w#pJ=&(x;y1Zhx@-rg|vw$2RX3
z1#iL+lrY*Vt~y_9@AgXFJ#qKHQ^#)?Zd0?JZ*bP4Xt|FrPkrXgr8nR2v2JtN$;Zxr
zTP~inPn)IHboR82t=vCm-OY=1pR?+Lw_r~I8;954%Q0S`izmM?@lxNvdGg8dg?mbw
zmvoyL_8Q8hz2TjFv$9~y#r5W=b}jmMurkp7&^hU3`zic0|Mh5mWS7__`qO1&!=6&p
zAMeE(clUf{+O=2aPv>$*7lkdSXEKDhpPA3CeDkH|mnCNLMUsnO2bSc|NRoT(qdv1k
zL?&EF_@wma`QG^qnGC6U`)-OJ?A18O7`t@xRpmU98jG(D`8n0sR+(Sf@wNFy=<B`3
z-!JbhR%`K`<TD{qupnS|z4C#XM>@FV-6nCa^?SB@_hq%%&(_tRx2iwrzrXtR*FkO7
zKNokb(fQAWnlxpmCRM2clcXjP^8s-{PGV(RW@=6fBL5U=dg=K4obvp7#`|>mrl1vU
zU5u`uyS*FdyogX(KW()bFtIZ-i7?<UT!6+xKqH8P6*cI(&@(br4+BHvDX1=Zwno>A
pp1%-U^B8gEHgq%40|H?NCkuM81bDNuf#g|$@FS3p2C86S004m&DMkPQ

literal 0
HcmV?d00001

diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs
index 2470c320cc71..f44daa1ecbb5 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs
@@ -7,6 +7,7 @@
 using System.Text;
 using NUnit.Framework;
 using osu.Framework.Allocation;
+using osu.Framework.Extensions;
 using osu.Framework.Extensions.IEnumerableExtensions;
 using osu.Framework.Extensions.ObjectExtensions;
 using osu.Framework.Graphics;
@@ -24,6 +25,7 @@
 using osu.Game.Screens.Play.HUD.HitErrorMeters;
 using osu.Game.Skinning;
 using osu.Game.Skinning.Components;
+using osu.Game.Tests.Resources;
 using osuTK;
 using osuTK.Input;
 
@@ -384,73 +386,82 @@ public void TestCopyPaste()
         [Test]
         public void TestMigrationArgon()
         {
-            AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded);
-            AddStep("add combo to global hud target", () =>
-            {
-                globalHUDTarget.Add(new ArgonComboCounter
-                {
-                    Anchor = Anchor.Centre,
-                    Origin = Anchor.Centre,
-                });
-            });
+            Live<SkinInfo> importedSkin = null!;
 
-            Live<SkinInfo> modifiedSkin = null!;
+            AddStep("import old argon skin", () => skins.CurrentSkinInfo.Value = importedSkin = importSkinFromArchives(@"argon-layout-version-0.osk").SkinInfo);
+            AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded);
+            AddAssert("no combo in global target", () => !globalHUDTarget.Components.OfType<ArgonComboCounter>().Any());
+            AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<ArgonComboCounter>().Count() == 1);
 
-            AddStep("select another skin", () =>
-            {
-                modifiedSkin = skins.CurrentSkinInfo.Value;
-                skins.CurrentSkinInfo.SetDefault();
-            });
-            AddStep("modify version", () => modifiedSkin.PerformWrite(s => s.LayoutVersion = 0));
-            AddStep("select skin again", () => skins.CurrentSkinInfo.Value = modifiedSkin);
-            AddAssert("global hud target does not contain combo", () => !globalHUDTarget.Components.Any(c => c is ArgonComboCounter));
-            AddAssert("ruleset hud target contains both combos", () =>
+            AddStep("add combo to global target", () => globalHUDTarget.Add(new ArgonComboCounter
             {
-                var target = rulesetHUDTarget;
-
-                return target.Components.Count == 2 &&
-                       target.Components[0] is ArgonComboCounter one && one.Anchor == Anchor.BottomLeft && one.Origin == Anchor.BottomLeft &&
-                       target.Components[1] is ArgonComboCounter two && two.Anchor == Anchor.Centre && two.Origin == Anchor.Centre;
-            });
-            AddStep("save skin", () => skinEditor.Save());
-            AddAssert("version updated", () => modifiedSkin.PerformRead(s => s.LayoutVersion) == SkinInfo.LATEST_LAYOUT_VERSION);
+                Anchor = Anchor.Centre,
+                Origin = Anchor.Centre,
+                Scale = new Vector2(2f),
+            }));
+            AddStep("save skin", () => skins.Save(skins.CurrentSkin.Value));
+
+            AddStep("select another skin", () => skins.CurrentSkinInfo.SetDefault());
+            AddStep("select skin again", () => skins.CurrentSkinInfo.Value = importedSkin);
+            AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded);
+            AddAssert("combo placed in global target", () => globalHUDTarget.Components.OfType<ArgonComboCounter>().Count() == 1);
+            AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<ArgonComboCounter>().Count() == 1);
         }
 
         [Test]
-        public void TestMigrationLegacy()
+        public void TestMigrationTriangles()
         {
-            AddStep("select legacy skin", () => skins.CurrentSkinInfo.Value = skins.DefaultClassicSkin.SkinInfo);
+            Live<SkinInfo> importedSkin = null!;
 
-            AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded);
-            AddStep("add combo to global hud target", () =>
+            AddStep("import old triangles skin", () => skins.CurrentSkinInfo.Value = importedSkin = importSkinFromArchives(@"triangles-layout-version-0.osk").SkinInfo);
+            AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded);
+            AddAssert("no combo in global target", () => !globalHUDTarget.Components.OfType<DefaultComboCounter>().Any());
+            AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<DefaultComboCounter>().Count() == 1);
+
+            AddStep("add combo to global target", () => globalHUDTarget.Add(new DefaultComboCounter
             {
-                globalHUDTarget.Add(new LegacyComboCounter
-                {
-                    Anchor = Anchor.Centre,
-                    Origin = Anchor.Centre,
-                });
-            });
+                Anchor = Anchor.Centre,
+                Origin = Anchor.Centre,
+                Scale = new Vector2(2f),
+            }));
+            AddStep("save skin", () => skins.Save(skins.CurrentSkin.Value));
+
+            AddStep("select another skin", () => skins.CurrentSkinInfo.SetDefault());
+            AddStep("select skin again", () => skins.CurrentSkinInfo.Value = importedSkin);
+            AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded);
+            AddAssert("combo placed in global target", () => globalHUDTarget.Components.OfType<DefaultComboCounter>().Count() == 1);
+            AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<DefaultComboCounter>().Count() == 1);
+        }
 
-            Live<SkinInfo> modifiedSkin = null!;
+        [Test]
+        public void TestMigrationLegacy()
+        {
+            Live<SkinInfo> importedSkin = null!;
 
-            AddStep("select another skin", () =>
-            {
-                modifiedSkin = skins.CurrentSkinInfo.Value;
-                skins.CurrentSkinInfo.SetDefault();
-            });
-            AddStep("modify version", () => modifiedSkin.PerformWrite(s => s.LayoutVersion = 0));
-            AddStep("select skin again", () => skins.CurrentSkinInfo.Value = modifiedSkin);
-            AddAssert("global hud target does not contain combo", () => !globalHUDTarget.Components.Any(c => c is LegacyComboCounter));
-            AddAssert("ruleset hud target contains both combos", () =>
+            AddStep("import old classic skin", () => skins.CurrentSkinInfo.Value = importedSkin = importSkinFromArchives(@"classic-layout-version-0.osk").SkinInfo);
+            AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded);
+            AddAssert("no combo in global target", () => !globalHUDTarget.Components.OfType<LegacyComboCounter>().Any());
+            AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<LegacyComboCounter>().Count() == 1);
+
+            AddStep("add combo to global target", () => globalHUDTarget.Add(new LegacyComboCounter
             {
-                var target = rulesetHUDTarget;
+                Anchor = Anchor.Centre,
+                Origin = Anchor.Centre,
+                Scale = new Vector2(2f),
+            }));
+            AddStep("save skin", () => skins.Save(skins.CurrentSkin.Value));
+
+            AddStep("select another skin", () => skins.CurrentSkinInfo.SetDefault());
+            AddStep("select skin again", () => skins.CurrentSkinInfo.Value = importedSkin);
+            AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded);
+            AddAssert("combo placed in global target", () => globalHUDTarget.Components.OfType<LegacyComboCounter>().Count() == 1);
+            AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<LegacyComboCounter>().Count() == 1);
+        }
 
-                return target.Components.Count == 2 &&
-                       target.Components[0] is LegacyComboCounter one && one.Anchor == Anchor.BottomLeft && one.Origin == Anchor.BottomLeft &&
-                       target.Components[1] is LegacyComboCounter two && two.Anchor == Anchor.Centre && two.Origin == Anchor.Centre;
-            });
-            AddStep("save skin", () => skinEditor.Save());
-            AddAssert("version updated", () => modifiedSkin.PerformRead(s => s.LayoutVersion) == SkinInfo.LATEST_LAYOUT_VERSION);
+        private Skin importSkinFromArchives(string filename)
+        {
+            var imported = skins.Import(new ImportTask(TestResources.OpenResource($@"Archives/{filename}"), filename)).GetResultSafely();
+            return imported.PerformRead(skinInfo => skins.GetSkin(skinInfo));
         }
 
         protected override Ruleset CreatePlayerRuleset() => new OsuRuleset();
diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index 606bc5e10cab..1ece81be5095 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -93,9 +93,8 @@ public class RealmAccess : IDisposable
         /// 39   2023-12-19    Migrate any EndTimeObjectCount and TotalObjectCount values of 0 to -1 to better identify non-calculated values.
         /// 40   2023-12-21    Add ScoreInfo.Version to keep track of which build scores were set on.
         /// 41   2024-04-17    Add ScoreInfo.TotalScoreWithoutMods for future mod multiplier rebalances.
-        /// 42   2024-06-25    Add SkinInfo.LayoutVersion to allow performing migrations of components on structural changes.
         /// </summary>
-        private const int schema_version = 42;
+        private const int schema_version = 41;
 
         /// <summary>
         /// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs
index 4cd54c06f073..707281db3107 100644
--- a/osu.Game/Skinning/ArgonSkin.cs
+++ b/osu.Game/Skinning/ArgonSkin.cs
@@ -12,7 +12,6 @@
 using osu.Game.Beatmaps.Formats;
 using osu.Game.Extensions;
 using osu.Game.IO;
-using osu.Game.Rulesets;
 using osu.Game.Screens.Play;
 using osu.Game.Screens.Play.HUD;
 using osu.Game.Screens.Play.HUD.HitErrorMeters;
@@ -70,28 +69,6 @@ public ArgonSkin(SkinInfo skin, IStorageResourceProvider resources)
                 // Purple
                 new Color4(92, 0, 241, 255),
             };
-
-            if (skin.LayoutVersion < 20240625
-                && LayoutInfos.TryGetValue(SkinComponentsContainerLookup.TargetArea.MainHUDComponents, out var hudLayout)
-                && hudLayout.TryGetDrawableInfo(null, out var hudComponents))
-            {
-                var comboCounters = hudComponents.Where(h => h.Type.Name == nameof(ArgonComboCounter)).ToArray();
-
-                if (comboCounters.Any())
-                {
-                    hudLayout.Update(null, hudComponents.Except(comboCounters).ToArray());
-
-                    resources.RealmAccess.Run(r =>
-                    {
-                        foreach (var ruleset in r.All<RulesetInfo>())
-                        {
-                            hudLayout.Update(ruleset, hudLayout.TryGetDrawableInfo(ruleset, out var rulesetComponents)
-                                ? rulesetComponents.Concat(comboCounters).ToArray()
-                                : comboCounters);
-                        }
-                    });
-                }
-            }
         }
 
         public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT);
diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs
index f148bad96e75..b71b626b4ed0 100644
--- a/osu.Game/Skinning/LegacySkin.cs
+++ b/osu.Game/Skinning/LegacySkin.cs
@@ -19,7 +19,6 @@
 using osu.Game.Beatmaps.Formats;
 using osu.Game.Extensions;
 using osu.Game.IO;
-using osu.Game.Rulesets;
 using osu.Game.Rulesets.Objects.Types;
 using osu.Game.Rulesets.Scoring;
 using osu.Game.Screens.Play.HUD;
@@ -57,28 +56,6 @@ public LegacySkin(SkinInfo skin, IStorageResourceProvider resources)
         protected LegacySkin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore<byte[]>? fallbackStore, string configurationFilename = @"skin.ini")
             : base(skin, resources, fallbackStore, configurationFilename)
         {
-            if (resources != null
-                && skin.LayoutVersion < 20240625
-                && LayoutInfos.TryGetValue(SkinComponentsContainerLookup.TargetArea.MainHUDComponents, out var hudLayout)
-                && hudLayout.TryGetDrawableInfo(null, out var hudComponents))
-            {
-                var comboCounters = hudComponents.Where(h => h.Type.Name == nameof(LegacyComboCounter)).ToArray();
-
-                if (comboCounters.Any())
-                {
-                    hudLayout.Update(null, hudComponents.Except(comboCounters).ToArray());
-
-                    resources.RealmAccess.Run(r =>
-                    {
-                        foreach (var ruleset in r.All<RulesetInfo>())
-                        {
-                            hudLayout.Update(ruleset, hudLayout.TryGetDrawableInfo(ruleset, out var rulesetComponents)
-                                ? rulesetComponents.Concat(comboCounters).ToArray()
-                                : comboCounters);
-                        }
-                    });
-                }
-            }
         }
 
         protected override IResourceStore<TextureUpload> CreateTextureLoaderStore(IStorageResourceProvider resources, IResourceStore<byte[]> storage)
diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs
index e4ca908d901e..5bac5c3d81cb 100644
--- a/osu.Game/Skinning/Skin.cs
+++ b/osu.Game/Skinning/Skin.cs
@@ -21,11 +21,15 @@
 using osu.Game.Audio;
 using osu.Game.Database;
 using osu.Game.IO;
+using osu.Game.Rulesets;
+using osu.Game.Screens.Play.HUD;
 
 namespace osu.Game.Skinning
 {
     public abstract class Skin : IDisposable, ISkin
     {
+        private readonly IStorageResourceProvider? resources;
+
         /// <summary>
         /// A texture store which can be used to perform user file lookups for this skin.
         /// </summary>
@@ -68,6 +72,8 @@ public abstract class Skin : IDisposable, ISkin
         /// <param name="configurationFilename">An optional filename to read the skin configuration from. If not provided, the configuration will be retrieved from the storage using "skin.ini".</param>
         protected Skin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore<byte[]>? fallbackStore = null, string configurationFilename = @"skin.ini")
         {
+            this.resources = resources;
+
             Name = skin.Name;
 
             if (resources != null)
@@ -131,40 +137,9 @@ protected Skin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStor
                 {
                     string jsonContent = Encoding.UTF8.GetString(bytes);
 
-                    SkinLayoutInfo? layoutInfo = null;
-
-                    // handle namespace changes...
-                    jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.SongProgress", @"osu.Game.Screens.Play.HUD.DefaultSongProgress");
-                    jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.LegacyComboCounter", @"osu.Game.Skinning.LegacyComboCounter");
-                    jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.PerformancePointsCounter", @"osu.Game.Skinning.Triangles.TrianglesPerformancePointsCounter");
-
-                    try
-                    {
-                        // First attempt to deserialise using the new SkinLayoutInfo format
-                        layoutInfo = JsonConvert.DeserializeObject<SkinLayoutInfo>(jsonContent);
-                    }
-                    catch
-                    {
-                    }
-
-                    // Of note, the migration code below runs on read of skins, but there's nothing to
-                    // force a rewrite after migration. Let's not remove these migration rules until we
-                    // have something in place to ensure we don't end up breaking skins of users that haven't
-                    // manually saved their skin since a change was implemented.
-
-                    // If deserialisation using SkinLayoutInfo fails, attempt to deserialise using the old naked list.
+                    var layoutInfo = parseLayoutInfo(jsonContent, skinnableTarget);
                     if (layoutInfo == null)
-                    {
-                        var deserializedContent = JsonConvert.DeserializeObject<IEnumerable<SerialisedDrawableInfo>>(jsonContent);
-
-                        if (deserializedContent == null)
-                            continue;
-
-                        layoutInfo = new SkinLayoutInfo();
-                        layoutInfo.Update(null, deserializedContent.ToArray());
-
-                        Logger.Log($"Ferrying {deserializedContent.Count()} components in {skinnableTarget} to global section of new {nameof(SkinLayoutInfo)} format");
-                    }
+                        continue;
 
                     LayoutInfos[skinnableTarget] = layoutInfo;
                 }
@@ -230,6 +205,81 @@ public void UpdateDrawableTarget(SkinComponentsContainer targetContainer)
             return null;
         }
 
+        #region Deserialisation & Migration
+
+        private SkinLayoutInfo? parseLayoutInfo(string jsonContent, SkinComponentsContainerLookup.TargetArea target)
+        {
+            SkinLayoutInfo? layout = null;
+
+            // handle namespace changes...
+            jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.SongProgress", @"osu.Game.Screens.Play.HUD.DefaultSongProgress");
+            jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.LegacyComboCounter", @"osu.Game.Skinning.LegacyComboCounter");
+            jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.PerformancePointsCounter", @"osu.Game.Skinning.Triangles.TrianglesPerformancePointsCounter");
+
+            try
+            {
+                // First attempt to deserialise using the new SkinLayoutInfo format
+                layout = JsonConvert.DeserializeObject<SkinLayoutInfo>(jsonContent);
+            }
+            catch
+            {
+            }
+
+            // If deserialisation using SkinLayoutInfo fails, attempt to deserialise using the old naked list.
+            if (layout == null)
+            {
+                var deserializedContent = JsonConvert.DeserializeObject<IEnumerable<SerialisedDrawableInfo>>(jsonContent);
+                if (deserializedContent == null)
+                    return null;
+
+                layout = new SkinLayoutInfo { Version = 0 };
+                layout.Update(null, deserializedContent.ToArray());
+
+                Logger.Log($"Ferrying {deserializedContent.Count()} components in {target} to global section of new {nameof(SkinLayoutInfo)} format");
+            }
+
+            for (int i = layout.Version + 1; i <= SkinLayoutInfo.LATEST_VERSION; i++)
+                applyMigration(layout, target, i);
+
+            layout.Version = SkinLayoutInfo.LATEST_VERSION;
+            return layout;
+        }
+
+        private void applyMigration(SkinLayoutInfo layout, SkinComponentsContainerLookup.TargetArea target, int version)
+        {
+            switch (version)
+            {
+                case 1:
+                {
+                    if (target != SkinComponentsContainerLookup.TargetArea.MainHUDComponents ||
+                        !layout.TryGetDrawableInfo(null, out var globalHUDComponents) ||
+                        resources == null)
+                        break;
+
+                    var comboCounters = globalHUDComponents.Where(c =>
+                        c.Type.Name == nameof(LegacyComboCounter) ||
+                        c.Type.Name == nameof(DefaultComboCounter) ||
+                        c.Type.Name == nameof(ArgonComboCounter)).ToArray();
+
+                    layout.Update(null, globalHUDComponents.Except(comboCounters).ToArray());
+
+                    resources.RealmAccess.Run(r =>
+                    {
+                        foreach (var ruleset in r.All<RulesetInfo>())
+                        {
+                            layout.Update(ruleset, layout.TryGetDrawableInfo(ruleset, out var rulesetHUDComponents)
+                                ? rulesetHUDComponents.Concat(comboCounters).ToArray()
+                                : comboCounters);
+                        }
+                    });
+
+                    break;
+                }
+            }
+        }
+
+        #endregion
+
         #region Disposal
 
         ~Skin()
diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs
index 714427f40da6..59c7f0ba2679 100644
--- a/osu.Game/Skinning/SkinImporter.cs
+++ b/osu.Game/Skinning/SkinImporter.cs
@@ -223,8 +223,6 @@ public bool Save(Skin skin)
                     }
                 }
 
-                s.LayoutVersion = SkinInfo.LATEST_LAYOUT_VERSION;
-
                 string newHash = ComputeHash(s);
 
                 hadChanges = newHash != s.Hash;
diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs
index a3d5771b5e66..9763d3b57e9a 100644
--- a/osu.Game/Skinning/SkinInfo.cs
+++ b/osu.Game/Skinning/SkinInfo.cs
@@ -39,21 +39,6 @@ public class SkinInfo : RealmObject, IHasRealmFiles, IEquatable<SkinInfo>, IHasG
 
         public bool Protected { get; set; }
 
-        /// <summary>
-        /// The latest version in YYYYMMDD format for skin layout migrations.
-        /// </summary>
-        /// <remarks>
-        /// <list type="bullet">
-        /// <item><description>20240625: Moves combo counters from ruleset-agnostic to ruleset-specific HUD targets.</description></item>
-        /// </list>
-        /// </remarks>
-        public const int LATEST_LAYOUT_VERSION = 20240625;
-
-        /// <summary>
-        /// A version in YYYYMMDD format for applying skin layout migrations.
-        /// </summary>
-        public int LayoutVersion { get; set; }
-
         public virtual Skin CreateInstance(IStorageResourceProvider resources)
         {
             var type = string.IsNullOrEmpty(InstantiationInfo)
diff --git a/osu.Game/Skinning/SkinLayoutInfo.cs b/osu.Game/Skinning/SkinLayoutInfo.cs
index 115d59b9d044..22c876e5ad46 100644
--- a/osu.Game/Skinning/SkinLayoutInfo.cs
+++ b/osu.Game/Skinning/SkinLayoutInfo.cs
@@ -19,12 +19,26 @@ public class SkinLayoutInfo
     {
         private const string global_identifier = @"global";
 
-        [JsonIgnore]
-        public IEnumerable<SerialisedDrawableInfo> AllDrawables => DrawableInfo.Values.SelectMany(v => v);
+        /// <summary>
+        /// Latest version representing the schema of the skin layout.
+        /// </summary>
+        /// <remarks>
+        /// <list type="bullet">
+        /// <item><description>0: Initial version of all skin layouts.</description></item>
+        /// <item><description>1: Moves existing combo counters from global to per-ruleset HUD targets.</description></item>
+        /// </list>
+        /// </remarks>
+        public const int LATEST_VERSION = 1;
+
+        [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
+        public int Version = LATEST_VERSION;
 
         [JsonProperty]
         public Dictionary<string, SerialisedDrawableInfo[]> DrawableInfo { get; set; } = new Dictionary<string, SerialisedDrawableInfo[]>();
 
+        [JsonIgnore]
+        public IEnumerable<SerialisedDrawableInfo> AllDrawables => DrawableInfo.Values.SelectMany(v => v);
+
         public bool TryGetDrawableInfo(RulesetInfo? ruleset, [NotNullWhen(true)] out SerialisedDrawableInfo[]? components) =>
             DrawableInfo.TryGetValue(ruleset?.ShortName ?? global_identifier, out components);
 

From 60d383448f22f36e9cc6c95f568cd81bb0ca5bef Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 8 Aug 2024 16:29:54 +0900
Subject: [PATCH 09/10] Avoid making non-ruleset transformers in
 `Ruleset.CreateSkinTransformer`

This didn't make any sense, so let's do it a better way.
---
 .../Argon/CatchArgonSkinTransformer.cs        |  2 +-
 .../Legacy/CatchLegacySkinTransformer.cs      |  2 +-
 .../Skinning/Argon/OsuArgonSkinTransformer.cs |  2 +-
 .../Legacy/OsuLegacySkinTransformer.cs        |  3 +-
 .../Argon/TaikoArgonSkinTransformer.cs        |  2 +-
 osu.Game/Rulesets/Ruleset.cs                  | 14 +------
 osu.Game/Skinning/ArgonSkin.cs                | 21 ++++++++--
 osu.Game/Skinning/ArgonSkinTransformer.cs     | 40 -------------------
 osu.Game/Skinning/LegacySkin.cs               | 16 +++++---
 osu.Game/Skinning/LegacySkinTransformer.cs    | 22 +---------
 osu.Game/Skinning/Skin.cs                     |  5 +--
 osu.Game/Skinning/TrianglesSkin.cs            |  2 +-
 .../Skinning/UserConfiguredLayoutContainer.cs | 15 +++++++
 13 files changed, 55 insertions(+), 91 deletions(-)
 delete mode 100644 osu.Game/Skinning/ArgonSkinTransformer.cs
 create mode 100644 osu.Game/Skinning/UserConfiguredLayoutContainer.cs

diff --git a/osu.Game.Rulesets.Catch/Skinning/Argon/CatchArgonSkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Argon/CatchArgonSkinTransformer.cs
index a67945df98eb..520c2de2480a 100644
--- a/osu.Game.Rulesets.Catch/Skinning/Argon/CatchArgonSkinTransformer.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Argon/CatchArgonSkinTransformer.cs
@@ -6,7 +6,7 @@
 
 namespace osu.Game.Rulesets.Catch.Skinning.Argon
 {
-    public class CatchArgonSkinTransformer : ArgonSkinTransformer
+    public class CatchArgonSkinTransformer : SkinTransformer
     {
         public CatchArgonSkinTransformer(ISkin skin)
             : base(skin)
diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs
index abd321ddb10a..44fc3ecc0747 100644
--- a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs
@@ -40,7 +40,7 @@ public CatchLegacySkinTransformer(ISkin skin)
                         return base.GetDrawableComponent(lookup) as Container;
 
                     // Skin has configuration.
-                    if (base.GetDrawableComponent(lookup) is Drawable d)
+                    if (base.GetDrawableComponent(lookup) is UserConfiguredLayoutContainer d)
                         return d;
 
                     // Our own ruleset components default.
diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs
index 2cc36331ae34..ec63e1194d3b 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs
@@ -7,7 +7,7 @@
 
 namespace osu.Game.Rulesets.Osu.Skinning.Argon
 {
-    public class OsuArgonSkinTransformer : ArgonSkinTransformer
+    public class OsuArgonSkinTransformer : SkinTransformer
     {
         public OsuArgonSkinTransformer(ISkin skin)
             : base(skin)
diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs
index 2c2f228fae4b..9a8eaa7d7d31 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs
@@ -50,7 +50,7 @@ public OsuLegacySkinTransformer(ISkin skin)
                         return base.GetDrawableComponent(lookup);
 
                     // Skin has configuration.
-                    if (base.GetDrawableComponent(lookup) is Drawable d)
+                    if (base.GetDrawableComponent(lookup) is UserConfiguredLayoutContainer d)
                         return d;
 
                     // Our own ruleset components default.
@@ -74,6 +74,7 @@ public OsuLegacySkinTransformer(ISkin skin)
                             {
                                 Children = new Drawable[]
                                 {
+                                    new LegacyComboCounter(),
                                     new LegacyKeyCounterDisplay(),
                                 }
                             };
diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs
index 7d38d6c9e5d0..973b4a91ff25 100644
--- a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs
+++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs
@@ -7,7 +7,7 @@
 
 namespace osu.Game.Rulesets.Taiko.Skinning.Argon
 {
-    public class TaikoArgonSkinTransformer : ArgonSkinTransformer
+    public class TaikoArgonSkinTransformer : SkinTransformer
     {
         public TaikoArgonSkinTransformer(ISkin skin)
             : base(skin)
diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs
index ee010e9621d6..fb0e225c94f2 100644
--- a/osu.Game/Rulesets/Ruleset.cs
+++ b/osu.Game/Rulesets/Ruleset.cs
@@ -212,19 +212,7 @@ public virtual LegacyMods ConvertToLegacyMods(Mod[] mods)
         /// <param name="skin">The source skin.</param>
         /// <param name="beatmap">The current beatmap.</param>
         /// <returns>A skin with a transformer applied, or null if no transformation is provided by this ruleset.</returns>
-        public virtual ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap)
-        {
-            switch (skin)
-            {
-                case LegacySkin:
-                    return new LegacySkinTransformer(skin);
-
-                case ArgonSkin:
-                    return new ArgonSkinTransformer(skin);
-            }
-
-            return null;
-        }
+        public virtual ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap) => null;
 
         protected Ruleset()
         {
diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs
index 707281db3107..85abb1edcd4c 100644
--- a/osu.Game/Skinning/ArgonSkin.cs
+++ b/osu.Game/Skinning/ArgonSkin.cs
@@ -7,6 +7,7 @@
 using osu.Framework.Audio.Sample;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Textures;
 using osu.Game.Audio;
 using osu.Game.Beatmaps.Formats;
@@ -93,15 +94,12 @@ public ArgonSkin(SkinInfo skin, IStorageResourceProvider resources)
             // Temporary until default skin has a valid hit lighting.
             if ((lookup as SkinnableSprite.SpriteComponentLookup)?.LookupName == @"lighting") return Drawable.Empty();
 
-            if (base.GetDrawableComponent(lookup) is Drawable c)
+            if (base.GetDrawableComponent(lookup) is UserConfiguredLayoutContainer c)
                 return c;
 
             switch (lookup)
             {
                 case SkinComponentsContainerLookup containerLookup:
-                    // Only handle global level defaults for now.
-                    if (containerLookup.Ruleset != null)
-                        return null;
 
                     switch (containerLookup.Target)
                     {
@@ -114,6 +112,21 @@ public ArgonSkin(SkinInfo skin, IStorageResourceProvider resources)
                             return songSelectComponents;
 
                         case SkinComponentsContainerLookup.TargetArea.MainHUDComponents:
+                            if (containerLookup.Ruleset != null)
+                            {
+                                return new Container
+                                {
+                                    RelativeSizeAxes = Axes.Both,
+                                    Child = new ArgonComboCounter
+                                    {
+                                        Anchor = Anchor.BottomLeft,
+                                        Origin = Anchor.BottomLeft,
+                                        Position = new Vector2(36, -66),
+                                        Scale = new Vector2(1.3f),
+                                    },
+                                };
+                            }
+
                             var mainHUDComponents = new DefaultSkinComponentsContainer(container =>
                             {
                                 var health = container.OfType<ArgonHealthDisplay>().FirstOrDefault();
diff --git a/osu.Game/Skinning/ArgonSkinTransformer.cs b/osu.Game/Skinning/ArgonSkinTransformer.cs
deleted file mode 100644
index 8ca8f79b4169..000000000000
--- a/osu.Game/Skinning/ArgonSkinTransformer.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
-using osu.Game.Screens.Play.HUD;
-using osuTK;
-
-namespace osu.Game.Skinning
-{
-    public class ArgonSkinTransformer : SkinTransformer
-    {
-        public ArgonSkinTransformer(ISkin skin)
-            : base(skin)
-        {
-        }
-
-        public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
-        {
-            if (lookup is SkinComponentsContainerLookup containerLookup
-                && containerLookup.Target == SkinComponentsContainerLookup.TargetArea.MainHUDComponents
-                && containerLookup.Ruleset != null)
-            {
-                return base.GetDrawableComponent(lookup) ?? new Container
-                {
-                    RelativeSizeAxes = Axes.Both,
-                    Child = new ArgonComboCounter
-                    {
-                        Anchor = Anchor.BottomLeft,
-                        Origin = Anchor.BottomLeft,
-                        Position = new Vector2(36, -66),
-                        Scale = new Vector2(1.3f),
-                    },
-                };
-            }
-
-            return base.GetDrawableComponent(lookup);
-        }
-    }
-}
diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs
index 38bf1631b462..734e80d2ed3d 100644
--- a/osu.Game/Skinning/LegacySkin.cs
+++ b/osu.Game/Skinning/LegacySkin.cs
@@ -13,6 +13,7 @@
 using osu.Framework.Bindables;
 using osu.Framework.Extensions.ObjectExtensions;
 using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Textures;
 using osu.Framework.IO.Stores;
 using osu.Game.Audio;
@@ -349,19 +350,24 @@ protected override void ParseConfigurationStream(Stream stream)
 
         public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
         {
-            if (base.GetDrawableComponent(lookup) is Drawable c)
+            if (base.GetDrawableComponent(lookup) is UserConfiguredLayoutContainer c)
                 return c;
 
             switch (lookup)
             {
                 case SkinComponentsContainerLookup containerLookup:
-                    // Only handle global level defaults for now.
-                    if (containerLookup.Ruleset != null)
-                        return null;
-
                     switch (containerLookup.Target)
                     {
                         case SkinComponentsContainerLookup.TargetArea.MainHUDComponents:
+                            if (containerLookup.Ruleset != null)
+                            {
+                                return new Container
+                                {
+                                    RelativeSizeAxes = Axes.Both,
+                                    Child = new LegacyComboCounter(),
+                                };
+                            }
+
                             return new DefaultSkinComponentsContainer(container =>
                             {
                                 var score = container.OfType<LegacyScoreCounter>().FirstOrDefault();
diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs
index dbfa52de84e0..b54e9a1bdf39 100644
--- a/osu.Game/Skinning/LegacySkinTransformer.cs
+++ b/osu.Game/Skinning/LegacySkinTransformer.cs
@@ -2,42 +2,24 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using osu.Framework.Audio.Sample;
-using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
 using osu.Game.Audio;
 using osu.Game.Rulesets.Objects.Legacy;
 using static osu.Game.Skinning.SkinConfiguration;
 
 namespace osu.Game.Skinning
 {
-    public class LegacySkinTransformer : SkinTransformer
+    public abstract class LegacySkinTransformer : SkinTransformer
     {
         /// <summary>
         /// Whether the skin being transformed is able to provide legacy resources for the ruleset.
         /// </summary>
         public virtual bool IsProvidingLegacyResources => this.HasFont(LegacyFont.Combo);
 
-        public LegacySkinTransformer(ISkin skin)
+        protected LegacySkinTransformer(ISkin skin)
             : base(skin)
         {
         }
 
-        public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
-        {
-            if (lookup is SkinComponentsContainerLookup containerLookup
-                && containerLookup.Target == SkinComponentsContainerLookup.TargetArea.MainHUDComponents
-                && containerLookup.Ruleset != null)
-            {
-                return base.GetDrawableComponent(lookup) ?? new Container
-                {
-                    RelativeSizeAxes = Axes.Both,
-                    Child = new LegacyComboCounter(),
-                };
-            }
-
-            return base.GetDrawableComponent(lookup);
-        }
-
         public override ISample? GetSample(ISampleInfo sampleInfo)
         {
             if (!(sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample))
diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs
index 5bac5c3d81cb..226d2fcb89b9 100644
--- a/osu.Game/Skinning/Skin.cs
+++ b/osu.Game/Skinning/Skin.cs
@@ -14,7 +14,6 @@
 using osu.Framework.Bindables;
 using osu.Framework.Extensions.TypeExtensions;
 using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Textures;
 using osu.Framework.IO.Stores;
 using osu.Framework.Logging;
@@ -26,7 +25,7 @@
 
 namespace osu.Game.Skinning
 {
-    public abstract class Skin : IDisposable, ISkin
+    public abstract partial class Skin : IDisposable, ISkin
     {
         private readonly IStorageResourceProvider? resources;
 
@@ -195,7 +194,7 @@ public void UpdateDrawableTarget(SkinComponentsContainer targetContainer)
                     if (!LayoutInfos.TryGetValue(containerLookup.Target, out var layoutInfo)) return null;
                     if (!layoutInfo.TryGetDrawableInfo(containerLookup.Ruleset, out var drawableInfos)) return null;
 
-                    return new Container
+                    return new UserConfiguredLayoutContainer
                     {
                         RelativeSizeAxes = Axes.Both,
                         ChildrenEnumerable = drawableInfos.Select(i => i.CreateInstance())
diff --git a/osu.Game/Skinning/TrianglesSkin.cs b/osu.Game/Skinning/TrianglesSkin.cs
index 6158d4c7bffa..29abb1949fcf 100644
--- a/osu.Game/Skinning/TrianglesSkin.cs
+++ b/osu.Game/Skinning/TrianglesSkin.cs
@@ -64,7 +64,7 @@ public TrianglesSkin(SkinInfo skin, IStorageResourceProvider resources)
             // Temporary until default skin has a valid hit lighting.
             if ((lookup as SkinnableSprite.SpriteComponentLookup)?.LookupName == @"lighting") return Drawable.Empty();
 
-            if (base.GetDrawableComponent(lookup) is Drawable c)
+            if (base.GetDrawableComponent(lookup) is UserConfiguredLayoutContainer c)
                 return c;
 
             switch (lookup)
diff --git a/osu.Game/Skinning/UserConfiguredLayoutContainer.cs b/osu.Game/Skinning/UserConfiguredLayoutContainer.cs
new file mode 100644
index 000000000000..1b5a27b53be4
--- /dev/null
+++ b/osu.Game/Skinning/UserConfiguredLayoutContainer.cs
@@ -0,0 +1,15 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Graphics.Containers;
+
+namespace osu.Game.Skinning
+{
+    /// <summary>
+    /// This signifies that a <see cref="Skin.GetDrawableComponent"/> call resolved a configuration created
+    /// by a user in their skin. Generally this should be given priority over any local defaults or overrides.
+    /// </summary>
+    public partial class UserConfiguredLayoutContainer : Container
+    {
+    }
+}

From 88c5997cb36e9642ca7519e9a5a1e1d21bd6b51e Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 8 Aug 2024 16:36:34 +0900
Subject: [PATCH 10/10] Add back removed xmldoc

---
 osu.Game/Skinning/LegacySkinTransformer.cs | 3 +++
 osu.Game/Skinning/Skin.cs                  | 2 +-
 2 files changed, 4 insertions(+), 1 deletion(-)

diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs
index b54e9a1bdf39..367e5bae0178 100644
--- a/osu.Game/Skinning/LegacySkinTransformer.cs
+++ b/osu.Game/Skinning/LegacySkinTransformer.cs
@@ -8,6 +8,9 @@
 
 namespace osu.Game.Skinning
 {
+    /// <summary>
+    /// Transformer used to handle support of legacy features for individual rulesets.
+    /// </summary>
     public abstract class LegacySkinTransformer : SkinTransformer
     {
         /// <summary>
diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs
index 226d2fcb89b9..fa09d0c0877a 100644
--- a/osu.Game/Skinning/Skin.cs
+++ b/osu.Game/Skinning/Skin.cs
@@ -25,7 +25,7 @@
 
 namespace osu.Game.Skinning
 {
-    public abstract partial class Skin : IDisposable, ISkin
+    public abstract class Skin : IDisposable, ISkin
     {
         private readonly IStorageResourceProvider? resources;