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 0000000000..f767033eb1 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/argon-layout-version-0.osk differ 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 0000000000..8240510f7c Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/classic-layout-version-0.osk differ 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 0000000000..5601eb279b Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/triangles-layout-version-0.osk differ diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index 2470c320cc..f44daa1ecb 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -7,6 +7,7 @@ using System.Linq; 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; 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 @@ namespace osu.Game.Tests.Visual.Gameplay [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 importedSkin = null!; - Live 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().Any()); + AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType().Count() == 1); - AddStep("select another skin", () => + AddStep("add combo to global target", () => globalHUDTarget.Add(new ArgonComboCounter { - 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; + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2f), + })); + AddStep("save skin", () => skins.Save(skins.CurrentSkin.Value)); - 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); + 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().Count() == 1); + AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType().Count() == 1); + } + + [Test] + public void TestMigrationTriangles() + { + Live importedSkin = null!; + + 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().Any()); + AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType().Count() == 1); + + AddStep("add combo to global target", () => globalHUDTarget.Add(new DefaultComboCounter + { + 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().Count() == 1); + AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType().Count() == 1); } [Test] public void TestMigrationLegacy() { - AddStep("select legacy skin", () => skins.CurrentSkinInfo.Value = skins.DefaultClassicSkin.SkinInfo); + Live importedSkin = null!; - AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded); - AddStep("add combo to global hud target", () => + 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().Any()); + AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType().Count() == 1); + + AddStep("add combo to global target", () => globalHUDTarget.Add(new LegacyComboCounter { - 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)); - Live modifiedSkin = null!; + 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().Count() == 1); + AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType().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 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); + 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 606bc5e10c..1ece81be50 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -93,9 +93,8 @@ namespace osu.Game.Database /// 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. /// - private const int schema_version = 42; + private const int schema_version = 41; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs index 4cd54c06f0..707281db31 100644 --- a/osu.Game/Skinning/ArgonSkin.cs +++ b/osu.Game/Skinning/ArgonSkin.cs @@ -12,7 +12,6 @@ using osu.Game.Audio; 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 @@ namespace osu.Game.Skinning // 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()) - { - 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 f148bad96e..b71b626b4e 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -19,7 +19,6 @@ using osu.Game.Audio; 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 @@ namespace osu.Game.Skinning protected LegacySkin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore? 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()) - { - hudLayout.Update(ruleset, hudLayout.TryGetDrawableInfo(ruleset, out var rulesetComponents) - ? rulesetComponents.Concat(comboCounters).ToArray() - : comboCounters); - } - }); - } - } } protected override IResourceStore CreateTextureLoaderStore(IStorageResourceProvider resources, IResourceStore storage) diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index e4ca908d90..5bac5c3d81 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -21,11 +21,15 @@ using osu.Framework.Logging; 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; + /// /// A texture store which can be used to perform user file lookups for this skin. /// @@ -68,6 +72,8 @@ namespace osu.Game.Skinning /// An optional filename to read the skin configuration from. If not provided, the configuration will be retrieved from the storage using "skin.ini". protected Skin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore? fallbackStore = null, string configurationFilename = @"skin.ini") { + this.resources = resources; + Name = skin.Name; if (resources != null) @@ -131,40 +137,9 @@ namespace osu.Game.Skinning { 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(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>(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 @@ namespace osu.Game.Skinning 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(jsonContent); + } + catch + { + } + + // If deserialisation using SkinLayoutInfo fails, attempt to deserialise using the old naked list. + if (layout == null) + { + var deserializedContent = JsonConvert.DeserializeObject>(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()) + { + 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 714427f40d..59c7f0ba26 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -223,8 +223,6 @@ namespace osu.Game.Skinning } } - 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 a3d5771b5e..9763d3b57e 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -39,21 +39,6 @@ namespace osu.Game.Skinning public bool Protected { get; set; } - /// - /// The latest version in YYYYMMDD format for skin layout migrations. - /// - /// - /// - /// 20240625: Moves combo counters from ruleset-agnostic to ruleset-specific HUD targets. - /// - /// - public const int LATEST_LAYOUT_VERSION = 20240625; - - /// - /// A version in YYYYMMDD format for applying skin layout migrations. - /// - 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 115d59b9d0..22c876e5ad 100644 --- a/osu.Game/Skinning/SkinLayoutInfo.cs +++ b/osu.Game/Skinning/SkinLayoutInfo.cs @@ -19,12 +19,26 @@ namespace osu.Game.Skinning { private const string global_identifier = @"global"; - [JsonIgnore] - public IEnumerable AllDrawables => DrawableInfo.Values.SelectMany(v => v); + /// + /// Latest version representing the schema of the skin layout. + /// + /// + /// + /// 0: Initial version of all skin layouts. + /// 1: Moves existing combo counters from global to per-ruleset HUD targets. + /// + /// + public const int LATEST_VERSION = 1; + + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] + public int Version = LATEST_VERSION; [JsonProperty] public Dictionary DrawableInfo { get; set; } = new Dictionary(); + [JsonIgnore] + public IEnumerable AllDrawables => DrawableInfo.Values.SelectMany(v => v); + public bool TryGetDrawableInfo(RulesetInfo? ruleset, [NotNullWhen(true)] out SerialisedDrawableInfo[]? components) => DrawableInfo.TryGetValue(ruleset?.ShortName ?? global_identifier, out components);