Store layout version in SkinLayoutVersion instead and refactor migration code

This commit is contained in:
Salman Ahmed 2024-07-01 06:48:05 +03:00
parent dc1fb4fdca
commit 0c34e7bebb
11 changed files with 165 additions and 154 deletions

View File

@ -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<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", () =>
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<ArgonComboCounter>().Count() == 1);
AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<ArgonComboCounter>().Count() == 1);
}
[Test]
public void TestMigrationTriangles()
{
Live<SkinInfo> 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<DefaultComboCounter>().Any());
AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<DefaultComboCounter>().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<DefaultComboCounter>().Count() == 1);
AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<DefaultComboCounter>().Count() == 1);
}
[Test]
public void TestMigrationLegacy()
{
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 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
{
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<SkinInfo> 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<LegacyComboCounter>().Count() == 1);
AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<LegacyComboCounter>().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();

View File

@ -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.
/// </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.

View File

@ -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<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);

View File

@ -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<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)

View File

@ -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;
/// <summary>
/// A texture store which can be used to perform user file lookups for this skin.
/// </summary>
@ -68,6 +72,8 @@ namespace osu.Game.Skinning
/// <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 @@ 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<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 @@ 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<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()

View File

@ -223,8 +223,6 @@ namespace osu.Game.Skinning
}
}
s.LayoutVersion = SkinInfo.LATEST_LAYOUT_VERSION;
string newHash = ComputeHash(s);
hadChanges = newHash != s.Hash;

View File

@ -39,21 +39,6 @@ namespace osu.Game.Skinning
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)

View File

@ -19,12 +19,26 @@ namespace osu.Game.Skinning
{
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);