From f53ce5aedfd17d18eaa9a882e14707636a806a92 Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Sun, 23 Jan 2022 11:11:12 +0800
Subject: [PATCH 001/113] Fix max combo calculation in osu diffcalc

---
 osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
index c5b1baaad1..c80b19e1d3 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
@@ -63,8 +63,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
             double drainRate = beatmap.Difficulty.DrainRate;
 
             int maxCombo = beatmap.HitObjects.Count;
-            // Add the ticks + tail of the slider. 1 is subtracted because the head circle would be counted twice (once for the slider itself in the line above)
-            maxCombo += beatmap.HitObjects.OfType<Slider>().Sum(s => s.NestedHitObjects.Count - 1);
+            // Add the ticks + tail of the slider
+            // 1 is subtracted because the head circle would be counted twice (once for the slider itself in the line above)
+            // an additional 1 is subtracted if only nested objects are judged because the hit result of the entire slider would not contribute to combo
+            maxCombo += beatmap.HitObjects.OfType<Slider>().Sum(s => s.NestedHitObjects.Count - 1 - (s.OnlyJudgeNestedObjects ? 1 : 0));
 
             int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle);
             int sliderCount = beatmap.HitObjects.Count(h => h is Slider);

From 44311c1f4e3e18548f9b574de968468d52f8c282 Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Sun, 23 Jan 2022 11:25:22 +0800
Subject: [PATCH 002/113] Add tests for diffcalc max combo

---
 .../CatchDifficultyCalculatorTest.cs          | 12 +++++------
 .../ManiaDifficultyCalculatorTest.cs          | 12 +++++------
 .../OsuDifficultyCalculatorTest.cs            | 21 ++++++++++++-------
 .../TaikoDifficultyCalculatorTest.cs          | 16 +++++++-------
 .../Beatmaps/DifficultyCalculatorTest.cs      |  7 +++++--
 5 files changed, 38 insertions(+), 30 deletions(-)

diff --git a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs
index 7e8d567fbe..48d46636df 100644
--- a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs
@@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Catch.Tests
     {
         protected override string ResourceAssembly => "osu.Game.Rulesets.Catch";
 
-        [TestCase(4.0505463516206195d, "diffcalc-test")]
-        public void Test(double expected, string name)
-            => base.Test(expected, name);
+        [TestCase(4.0505463516206195d, 127, "diffcalc-test")]
+        public void Test(double expectedStarRating, int expectedMaxCombo, string name)
+            => base.Test(expectedStarRating, expectedMaxCombo, name);
 
-        [TestCase(5.1696411260785498d, "diffcalc-test")]
-        public void TestClockRateAdjusted(double expected, string name)
-            => Test(expected, name, new CatchModDoubleTime());
+        [TestCase(5.1696411260785498d, 127, "diffcalc-test")]
+        public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
+            => Test(expectedStarRating, expectedMaxCombo, name, new CatchModDoubleTime());
 
         protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new CatchDifficultyCalculator(new CatchRuleset().RulesetInfo, beatmap);
 
diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs
index 6ec49d7634..715614a201 100644
--- a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs
@@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Mania.Tests
     {
         protected override string ResourceAssembly => "osu.Game.Rulesets.Mania";
 
-        [TestCase(2.3449735700206298d, "diffcalc-test")]
-        public void Test(double expected, string name)
-            => base.Test(expected, name);
+        [TestCase(2.3449735700206298d, 151, "diffcalc-test")]
+        public void Test(double expectedStarRating, int expectedMaxCombo, string name)
+            => base.Test(expectedStarRating, expectedMaxCombo, name);
 
-        [TestCase(2.7879104989252959d, "diffcalc-test")]
-        public void TestClockRateAdjusted(double expected, string name)
-            => Test(expected, name, new ManiaModDoubleTime());
+        [TestCase(2.7879104989252959d, 151, "diffcalc-test")]
+        public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
+            => Test(expectedStarRating, expectedMaxCombo, name, new ManiaModDoubleTime());
 
         protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new ManiaDifficultyCalculator(new ManiaRuleset().RulesetInfo, beatmap);
 
diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
index b7984e6995..df577ea8d3 100644
--- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
@@ -15,15 +15,20 @@ namespace osu.Game.Rulesets.Osu.Tests
     {
         protected override string ResourceAssembly => "osu.Game.Rulesets.Osu";
 
-        [TestCase(6.6972307565739273d, "diffcalc-test")]
-        [TestCase(1.4484754139145539d, "zero-length-sliders")]
-        public void Test(double expected, string name)
-            => base.Test(expected, name);
+        [TestCase(6.6972307565739273d, 206, "diffcalc-test")]
+        [TestCase(1.4484754139145539d, 45, "zero-length-sliders")]
+        public void Test(double expectedStarRating, int expectedMaxCombo, string name)
+            => base.Test(expectedStarRating, expectedMaxCombo, name);
 
-        [TestCase(8.9382559208689809d, "diffcalc-test")]
-        [TestCase(1.7548875851757628d, "zero-length-sliders")]
-        public void TestClockRateAdjusted(double expected, string name)
-            => Test(expected, name, new OsuModDoubleTime());
+        [TestCase(8.9382559208689809d, 206, "diffcalc-test")]
+        [TestCase(1.7548875851757628d, 45, "zero-length-sliders")]
+        public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
+            => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
+
+        [TestCase(6.6972307218715166d, 239, "diffcalc-test")]
+        [TestCase(1.4484754139145537d, 54, "zero-length-sliders")]
+        public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
+            => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());
 
         protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset().RulesetInfo, beatmap);
 
diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs
index 2b1cbc580e..226da7df09 100644
--- a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs
@@ -14,15 +14,15 @@ namespace osu.Game.Rulesets.Taiko.Tests
     {
         protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko";
 
-        [TestCase(2.2420075288523802d, "diffcalc-test")]
-        [TestCase(2.2420075288523802d, "diffcalc-test-strong")]
-        public void Test(double expected, string name)
-            => base.Test(expected, name);
+        [TestCase(2.2420075288523802d, 200, "diffcalc-test")]
+        [TestCase(2.2420075288523802d, 200, "diffcalc-test-strong")]
+        public void Test(double expectedStarRating, int expectedMaxCombo, string name)
+            => base.Test(expectedStarRating, expectedMaxCombo, name);
 
-        [TestCase(3.134084469440479d, "diffcalc-test")]
-        [TestCase(3.134084469440479d, "diffcalc-test-strong")]
-        public void TestClockRateAdjusted(double expected, string name)
-            => Test(expected, name, new TaikoModDoubleTime());
+        [TestCase(3.134084469440479d, 200, "diffcalc-test")]
+        [TestCase(3.134084469440479d, 200, "diffcalc-test-strong")]
+        public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
+            => Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime());
 
         protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new TaikoDifficultyCalculator(new TaikoRuleset().RulesetInfo, beatmap);
 
diff --git a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs
index 9f8811c7f9..ed00c7959b 100644
--- a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs
+++ b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs
@@ -22,10 +22,13 @@ namespace osu.Game.Tests.Beatmaps
 
         protected abstract string ResourceAssembly { get; }
 
-        protected void Test(double expected, string name, params Mod[] mods)
+        protected void Test(double expectedStarRating, int expectedMaxCombo, string name, params Mod[] mods)
         {
+            var attributes = CreateDifficultyCalculator(getBeatmap(name)).Calculate(mods);
+
             // Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences.
-            Assert.That(CreateDifficultyCalculator(getBeatmap(name)).Calculate(mods).StarRating, Is.EqualTo(expected).Within(0.00001));
+            Assert.That(attributes.StarRating, Is.EqualTo(expectedStarRating).Within(0.00001));
+            Assert.That(attributes.MaxCombo, Is.EqualTo(expectedMaxCombo));
         }
 
         private IWorkingBeatmap getBeatmap(string name)

From 74a55ead7711108c4d6b856e11433b476459c35a Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Sun, 23 Jan 2022 13:00:54 +0800
Subject: [PATCH 003/113] Simplify combo counting logic

---
 .../Difficulty/OsuDifficultyCalculator.cs     | 20 ++++++++++++++-----
 1 file changed, 15 insertions(+), 5 deletions(-)

diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
index c80b19e1d3..d04d0872d8 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
@@ -9,6 +9,7 @@ using osu.Game.Rulesets.Difficulty;
 using osu.Game.Rulesets.Difficulty.Preprocessing;
 using osu.Game.Rulesets.Difficulty.Skills;
 using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Objects;
 using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
 using osu.Game.Rulesets.Osu.Difficulty.Skills;
 using osu.Game.Rulesets.Osu.Mods;
@@ -62,11 +63,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty
             double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
             double drainRate = beatmap.Difficulty.DrainRate;
 
-            int maxCombo = beatmap.HitObjects.Count;
-            // Add the ticks + tail of the slider
-            // 1 is subtracted because the head circle would be counted twice (once for the slider itself in the line above)
-            // an additional 1 is subtracted if only nested objects are judged because the hit result of the entire slider would not contribute to combo
-            maxCombo += beatmap.HitObjects.OfType<Slider>().Sum(s => s.NestedHitObjects.Count - 1 - (s.OnlyJudgeNestedObjects ? 1 : 0));
+            int maxCombo = 0;
+
+            void countCombo(HitObject ho)
+            {
+                if (ho.CreateJudgement().MaxResult.AffectsCombo())
+                    maxCombo++;
+            }
+
+            foreach (HitObject ho in beatmap.HitObjects)
+            {
+                countCombo(ho);
+                foreach (HitObject nested in ho.NestedHitObjects)
+                    countCombo(nested);
+            }
 
             int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle);
             int sliderCount = beatmap.HitObjects.Count(h => h is Slider);

From 215da7e933ded0423618e6fb6f58e28c65ea2339 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Wed, 16 Feb 2022 12:05:55 +0900
Subject: [PATCH 004/113] Reimplement as extension method on IBeatmap

Implementation has changed slightly to support arbitrary levels of
nested hitobjects.
---
 .../Difficulty/OsuDifficultyCalculator.cs     | 17 +------------
 osu.Game/Beatmaps/IBeatmap.cs                 | 24 +++++++++++++++++++
 2 files changed, 25 insertions(+), 16 deletions(-)

diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
index d04d0872d8..df6fd19d36 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
@@ -9,7 +9,6 @@ using osu.Game.Rulesets.Difficulty;
 using osu.Game.Rulesets.Difficulty.Preprocessing;
 using osu.Game.Rulesets.Difficulty.Skills;
 using osu.Game.Rulesets.Mods;
-using osu.Game.Rulesets.Objects;
 using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
 using osu.Game.Rulesets.Osu.Difficulty.Skills;
 using osu.Game.Rulesets.Osu.Mods;
@@ -62,21 +61,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
 
             double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
             double drainRate = beatmap.Difficulty.DrainRate;
-
-            int maxCombo = 0;
-
-            void countCombo(HitObject ho)
-            {
-                if (ho.CreateJudgement().MaxResult.AffectsCombo())
-                    maxCombo++;
-            }
-
-            foreach (HitObject ho in beatmap.HitObjects)
-            {
-                countCombo(ho);
-                foreach (HitObject nested in ho.NestedHitObjects)
-                    countCombo(nested);
-            }
+            int maxCombo = beatmap.GetMaxCombo();
 
             int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle);
             int sliderCount = beatmap.HitObjects.Count(h => h is Slider);
diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs
index 3f598cd1e5..dec1ef4294 100644
--- a/osu.Game/Beatmaps/IBeatmap.cs
+++ b/osu.Game/Beatmaps/IBeatmap.cs
@@ -5,6 +5,7 @@ using System.Collections.Generic;
 using osu.Game.Beatmaps.ControlPoints;
 using osu.Game.Beatmaps.Timing;
 using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Scoring;
 
 namespace osu.Game.Beatmaps
 {
@@ -70,4 +71,27 @@ namespace osu.Game.Beatmaps
         /// </summary>
         new IReadOnlyList<T> HitObjects { get; }
     }
+
+    public static class BeatmapExtensions
+    {
+        /// <summary>
+        /// Finds the maximum achievable combo by hitting all <see cref="HitObject"/>s in a beatmap.
+        /// </summary>
+        public static int GetMaxCombo(this IBeatmap beatmap)
+        {
+            int combo = 0;
+            foreach (var h in beatmap.HitObjects)
+                addCombo(h, ref combo);
+            return combo;
+
+            static void addCombo(HitObject hitObject, ref int combo)
+            {
+                if (hitObject.CreateJudgement().MaxResult.AffectsCombo())
+                    combo++;
+
+                foreach (var nested in hitObject.NestedHitObjects)
+                    addCombo(nested, ref combo);
+            }
+        }
+    }
 }

From 3945cd24ebb84579fe1492c083d820155581f652 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Thu, 17 Feb 2022 21:14:49 +0900
Subject: [PATCH 005/113] wip

---
 .../Rulesets/Difficulty/DifficultyCalculator.cs    | 14 ++++++++++----
 1 file changed, 10 insertions(+), 4 deletions(-)

diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
index 6b61dd3efb..7d6c235fc1 100644
--- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
+++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
@@ -15,6 +15,7 @@ using osu.Game.Rulesets.Difficulty.Preprocessing;
 using osu.Game.Rulesets.Difficulty.Skills;
 using osu.Game.Rulesets.Mods;
 using osu.Game.Rulesets.Objects;
+using osu.Game.Utils;
 
 namespace osu.Game.Rulesets.Difficulty
 {
@@ -122,12 +123,17 @@ namespace osu.Game.Rulesets.Difficulty
         /// <returns>A collection of structures describing the difficulty of the beatmap for each mod combination.</returns>
         public IEnumerable<DifficultyAttributes> CalculateAll(CancellationToken cancellationToken = default)
         {
+            var rulesetInstance = ruleset.CreateInstance();
+
             foreach (var combination in CreateDifficultyAdjustmentModCombinations())
             {
-                if (combination is MultiMod multi)
-                    yield return Calculate(multi.Mods, cancellationToken);
-                else
-                    yield return Calculate(combination.Yield(), cancellationToken);
+                Mod classicMod = rulesetInstance.CreateAllMods().SingleOrDefault(m => m is ModClassic);
+
+                var finalCombination = ModUtils.FlattenMod(combination);
+                if (classicMod != null)
+                    finalCombination = finalCombination.Append(classicMod);
+
+                yield return Calculate(finalCombination.ToArray(), cancellationToken);
             }
         }
 

From bedd07d2e4dca93e160949614f9f1a75f03a2fe2 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Tue, 22 Feb 2022 18:12:55 +0900
Subject: [PATCH 006/113] Add remark about usage of CalculateAll()

---
 osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
index 7d6c235fc1..0935f26de6 100644
--- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
+++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
@@ -120,6 +120,9 @@ namespace osu.Game.Rulesets.Difficulty
         /// <summary>
         /// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap.
         /// </summary>
+        /// <remarks>
+        /// This should only be used to compute difficulties for legacy mod combinations.
+        /// </remarks>
         /// <returns>A collection of structures describing the difficulty of the beatmap for each mod combination.</returns>
         public IEnumerable<DifficultyAttributes> CalculateAll(CancellationToken cancellationToken = default)
         {

From 37328f8d245fc9d50e3a14bfe57a3d9c73d0f1d1 Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Wed, 9 Mar 2022 20:36:31 +0800
Subject: [PATCH 007/113] Extract hit object positioning logic to a separate
 class

It is intentional to not rename the identifiers at this point to produce a cleaner diff.
---
 .../Mods/OsuHitObjectPositionModifier.cs      | 346 ++++++++++++++++++
 osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs    | 325 +---------------
 2 files changed, 354 insertions(+), 317 deletions(-)
 create mode 100644 osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs

diff --git a/osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs b/osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs
new file mode 100644
index 0000000000..3242b99755
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs
@@ -0,0 +1,346 @@
+// 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;
+using System.Collections.Generic;
+using System.Linq;
+using osu.Framework.Graphics.Primitives;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.UI;
+using osu.Game.Rulesets.Osu.Utils;
+using osuTK;
+
+#nullable enable
+
+namespace osu.Game.Rulesets.Osu.Mods
+{
+    /// <summary>
+    /// Places hit objects according to information in <see cref="RandomObjects"/> while keeping objects inside the playfield.
+    /// </summary>
+    public class OsuHitObjectPositionModifier
+    {
+        /// <summary>
+        /// Number of previous hitobjects to be shifted together when an object is being moved.
+        /// </summary>
+        private const int preceding_hitobjects_to_shift = 10;
+
+        private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE / 2;
+
+        private readonly List<OsuHitObject> hitObjects;
+
+        private readonly List<RandomObjectInfo> randomObjects = new List<RandomObjectInfo>();
+
+        /// <summary>
+        /// Contains information specifying how each hit object should be placed.
+        /// <para>The default values correspond to how objects are originally placed in the beatmap.</para>
+        /// </summary>
+        public IReadOnlyList<IRandomObjectInfo> RandomObjects => randomObjects;
+
+        public OsuHitObjectPositionModifier(List<OsuHitObject> hitObjects)
+        {
+            this.hitObjects = hitObjects;
+            populateHitObjectPositions();
+        }
+
+        private void populateHitObjectPositions()
+        {
+            Vector2 previousPosition = playfield_centre;
+            float previousAngle = 0;
+
+            foreach (OsuHitObject hitObject in hitObjects)
+            {
+                Vector2 relativePosition = hitObject.Position - previousPosition;
+                float absoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
+                float relativeAngle = absoluteAngle - previousAngle;
+
+                randomObjects.Add(new RandomObjectInfo(hitObject)
+                {
+                    RelativeAngle = relativeAngle,
+                    DistanceFromPrevious = relativePosition.Length
+                });
+
+                previousPosition = hitObject.EndPosition;
+                previousAngle = absoluteAngle;
+            }
+        }
+
+        /// <summary>
+        /// Reposition the hit objects according to the information in <see cref="RandomObjects"/>.
+        /// </summary>
+        public void ApplyRandomisation()
+        {
+            RandomObjectInfo? previous = null;
+
+            for (int i = 0; i < hitObjects.Count; i++)
+            {
+                var hitObject = hitObjects[i];
+
+                var current = randomObjects[i];
+
+                if (hitObject is Spinner)
+                {
+                    previous = null;
+                    continue;
+                }
+
+                computeRandomisedPosition(current, previous, i > 1 ? randomObjects[i - 2] : null);
+
+                // Move hit objects back into the playfield if they are outside of it
+                Vector2 shift = Vector2.Zero;
+
+                switch (hitObject)
+                {
+                    case HitCircle circle:
+                        shift = clampHitCircleToPlayfield(circle, current);
+                        break;
+
+                    case Slider slider:
+                        shift = clampSliderToPlayfield(slider, current);
+                        break;
+                }
+
+                if (shift != Vector2.Zero)
+                {
+                    var toBeShifted = new List<OsuHitObject>();
+
+                    for (int j = i - 1; j >= i - preceding_hitobjects_to_shift && j >= 0; j--)
+                    {
+                        // only shift hit circles
+                        if (!(hitObjects[j] is HitCircle)) break;
+
+                        toBeShifted.Add(hitObjects[j]);
+                    }
+
+                    if (toBeShifted.Count > 0)
+                        applyDecreasingShift(toBeShifted, shift);
+                }
+
+                previous = current;
+            }
+        }
+
+        /// <summary>
+        /// Compute the randomised position of a hit object while attempting to keep it inside the playfield.
+        /// </summary>
+        /// <param name="current">The <see cref="RandomObjectInfo"/> representing the hit object to have the randomised position computed for.</param>
+        /// <param name="previous">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the current one.</param>
+        /// <param name="beforePrevious">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
+        private void computeRandomisedPosition(RandomObjectInfo current, RandomObjectInfo? previous, RandomObjectInfo? beforePrevious)
+        {
+            float previousAbsoluteAngle = 0f;
+
+            if (previous != null)
+            {
+                Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre;
+                Vector2 relativePosition = previous.HitObject.Position - earliestPosition;
+                previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
+            }
+
+            float absoluteAngle = previousAbsoluteAngle + current.RelativeAngle;
+
+            var posRelativeToPrev = new Vector2(
+                current.DistanceFromPrevious * (float)Math.Cos(absoluteAngle),
+                current.DistanceFromPrevious * (float)Math.Sin(absoluteAngle)
+            );
+
+            Vector2 lastEndPosition = previous?.EndPositionRandomised ?? playfield_centre;
+
+            posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
+
+            current.PositionRandomised = lastEndPosition + posRelativeToPrev;
+        }
+
+        /// <summary>
+        /// Move the randomised position of a hit circle so that it fits inside the playfield.
+        /// </summary>
+        /// <returns>The deviation from the original randomised position in order to fit within the playfield.</returns>
+        private Vector2 clampHitCircleToPlayfield(HitCircle circle, RandomObjectInfo objectInfo)
+        {
+            var previousPosition = objectInfo.PositionRandomised;
+            objectInfo.EndPositionRandomised = objectInfo.PositionRandomised = clampToPlayfieldWithPadding(
+                objectInfo.PositionRandomised,
+                (float)circle.Radius
+            );
+
+            circle.Position = objectInfo.PositionRandomised;
+
+            return objectInfo.PositionRandomised - previousPosition;
+        }
+
+        /// <summary>
+        /// Moves the <see cref="Slider"/> and all necessary nested <see cref="OsuHitObject"/>s into the <see cref="OsuPlayfield"/> if they aren't already.
+        /// </summary>
+        /// <returns>The deviation from the original randomised position in order to fit within the playfield.</returns>
+        private Vector2 clampSliderToPlayfield(Slider slider, RandomObjectInfo objectInfo)
+        {
+            var possibleMovementBounds = calculatePossibleMovementBounds(slider);
+
+            var previousPosition = objectInfo.PositionRandomised;
+
+            // Clamp slider position to the placement area
+            // If the slider is larger than the playfield, force it to stay at the original position
+            float newX = possibleMovementBounds.Width < 0
+                ? objectInfo.PositionOriginal.X
+                : Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right);
+
+            float newY = possibleMovementBounds.Height < 0
+                ? objectInfo.PositionOriginal.Y
+                : Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom);
+
+            slider.Position = objectInfo.PositionRandomised = new Vector2(newX, newY);
+            objectInfo.EndPositionRandomised = slider.EndPosition;
+
+            shiftNestedObjects(slider, objectInfo.PositionRandomised - objectInfo.PositionOriginal);
+
+            return objectInfo.PositionRandomised - previousPosition;
+        }
+
+        /// <summary>
+        /// Decreasingly shift a list of <see cref="OsuHitObject"/>s by a specified amount.
+        /// The first item in the list is shifted by the largest amount, while the last item is shifted by the smallest amount.
+        /// </summary>
+        /// <param name="hitObjects">The list of hit objects to be shifted.</param>
+        /// <param name="shift">The amount to be shifted.</param>
+        private void applyDecreasingShift(IList<OsuHitObject> hitObjects, Vector2 shift)
+        {
+            for (int i = 0; i < hitObjects.Count; i++)
+            {
+                var hitObject = hitObjects[i];
+                // The first object is shifted by a vector slightly smaller than shift
+                // The last object is shifted by a vector slightly larger than zero
+                Vector2 position = hitObject.Position + shift * ((hitObjects.Count - i) / (float)(hitObjects.Count + 1));
+
+                hitObject.Position = clampToPlayfieldWithPadding(position, (float)hitObject.Radius);
+            }
+        }
+
+        /// <summary>
+        /// Calculates a <see cref="RectangleF"/> which contains all of the possible movements of the slider (in relative X/Y coordinates)
+        /// such that the entire slider is inside the playfield.
+        /// </summary>
+        /// <remarks>
+        /// If the slider is larger than the playfield, the returned <see cref="RectangleF"/> may have negative width/height.
+        /// </remarks>
+        private RectangleF calculatePossibleMovementBounds(Slider slider)
+        {
+            var pathPositions = new List<Vector2>();
+            slider.Path.GetPathToProgress(pathPositions, 0, 1);
+
+            float minX = float.PositiveInfinity;
+            float maxX = float.NegativeInfinity;
+
+            float minY = float.PositiveInfinity;
+            float maxY = float.NegativeInfinity;
+
+            // Compute the bounding box of the slider.
+            foreach (var pos in pathPositions)
+            {
+                minX = MathF.Min(minX, pos.X);
+                maxX = MathF.Max(maxX, pos.X);
+
+                minY = MathF.Min(minY, pos.Y);
+                maxY = MathF.Max(maxY, pos.Y);
+            }
+
+            // Take the circle radius into account.
+            float radius = (float)slider.Radius;
+
+            minX -= radius;
+            minY -= radius;
+
+            maxX += radius;
+            maxY += radius;
+
+            // Given the bounding box of the slider (via min/max X/Y),
+            // the amount that the slider can move to the left is minX (with the sign flipped, since positive X is to the right),
+            // and the amount that it can move to the right is WIDTH - maxX.
+            // Same calculation applies for the Y axis.
+            float left = -minX;
+            float right = OsuPlayfield.BASE_SIZE.X - maxX;
+            float top = -minY;
+            float bottom = OsuPlayfield.BASE_SIZE.Y - maxY;
+
+            return new RectangleF(left, top, right - left, bottom - top);
+        }
+
+        /// <summary>
+        /// Shifts all nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s by the specified shift.
+        /// </summary>
+        /// <param name="slider"><see cref="Slider"/> whose nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted</param>
+        /// <param name="shift">The <see cref="Vector2"/> the <see cref="Slider"/>'s nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted by</param>
+        private void shiftNestedObjects(Slider slider, Vector2 shift)
+        {
+            foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat))
+            {
+                if (!(hitObject is OsuHitObject osuHitObject))
+                    continue;
+
+                osuHitObject.Position += shift;
+            }
+        }
+
+        /// <summary>
+        /// Clamp a position to playfield, keeping a specified distance from the edges.
+        /// </summary>
+        /// <param name="position">The position to be clamped.</param>
+        /// <param name="padding">The minimum distance allowed from playfield edges.</param>
+        /// <returns>The clamped position.</returns>
+        private Vector2 clampToPlayfieldWithPadding(Vector2 position, float padding)
+        {
+            return new Vector2(
+                Math.Clamp(position.X, padding, OsuPlayfield.BASE_SIZE.X - padding),
+                Math.Clamp(position.Y, padding, OsuPlayfield.BASE_SIZE.Y - padding)
+            );
+        }
+
+        public interface IRandomObjectInfo
+        {
+            /// <summary>
+            /// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle.
+            /// </summary>
+            /// <remarks>
+            /// <see cref="RelativeAngle"/> of the first hit object in a beatmap represents the absolute angle from playfield center to the object.
+            /// </remarks>
+            /// <example>
+            /// If <see cref="RelativeAngle"/> is 0, the player's cursor doesn't need to change its direction of movement when passing
+            /// the previous object to reach this one.
+            /// </example>
+            float RelativeAngle { get; set; }
+
+            /// <summary>
+            /// The jump distance from the previous hit object to this one.
+            /// </summary>
+            /// <remarks>
+            /// <see cref="DistanceFromPrevious"/> of the first hit object in a beatmap is relative to the playfield center.
+            /// </remarks>
+            float DistanceFromPrevious { get; set; }
+
+            /// <summary>
+            /// The hit object associated with this <see cref="IRandomObjectInfo"/>.
+            /// </summary>
+            OsuHitObject HitObject { get; }
+        }
+
+        private class RandomObjectInfo : IRandomObjectInfo
+        {
+            public float RelativeAngle { get; set; }
+
+            public float DistanceFromPrevious { get; set; }
+
+            public Vector2 PositionOriginal { get; }
+            public Vector2 PositionRandomised { get; set; }
+
+            public Vector2 EndPositionOriginal { get; }
+            public Vector2 EndPositionRandomised { get; set; }
+
+            public OsuHitObject HitObject { get; }
+
+            public RandomObjectInfo(OsuHitObject hitObject)
+            {
+                PositionRandomised = PositionOriginal = hitObject.Position;
+                EndPositionRandomised = EndPositionOriginal = hitObject.EndPosition;
+                HitObject = hitObject;
+            }
+        }
+    }
+}
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
index 7479c3120a..2c38be6c16 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
@@ -4,19 +4,13 @@
 #nullable enable
 
 using System;
-using System.Collections.Generic;
-using System.Diagnostics;
 using System.Linq;
-using osu.Framework.Graphics.Primitives;
 using osu.Framework.Utils;
 using osu.Game.Beatmaps;
 using osu.Game.Rulesets.Mods;
 using osu.Game.Rulesets.Objects;
 using osu.Game.Rulesets.Osu.Beatmaps;
-using osu.Game.Rulesets.Osu.Objects;
 using osu.Game.Rulesets.Osu.UI;
-using osu.Game.Rulesets.Osu.Utils;
-using osuTK;
 
 namespace osu.Game.Rulesets.Osu.Mods
 {
@@ -28,12 +22,6 @@ namespace osu.Game.Rulesets.Osu.Mods
         public override string Description => "It never gets boring!";
 
         private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast;
-        private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE / 2;
-
-        /// <summary>
-        /// Number of previous hitobjects to be shifted together when another object is being moved.
-        /// </summary>
-        private const int preceding_hitobjects_to_shift = 10;
 
         private Random? rng;
 
@@ -42,330 +30,33 @@ namespace osu.Game.Rulesets.Osu.Mods
             if (!(beatmap is OsuBeatmap osuBeatmap))
                 return;
 
-            var hitObjects = osuBeatmap.HitObjects;
-
             Seed.Value ??= RNG.Next();
 
             rng = new Random((int)Seed.Value);
 
-            var randomObjects = randomiseObjects(hitObjects);
+            var positionModifier = new OsuHitObjectPositionModifier(osuBeatmap.HitObjects);
 
-            applyRandomisation(hitObjects, randomObjects);
-        }
-
-        /// <summary>
-        /// Randomise the position of each hit object and return a list of <see cref="RandomObjectInfo"/>s describing how each hit object should be placed.
-        /// </summary>
-        /// <param name="hitObjects">A list of <see cref="OsuHitObject"/>s to have their positions randomised.</param>
-        /// <returns>A list of <see cref="RandomObjectInfo"/>s describing how each hit object should be placed.</returns>
-        private List<RandomObjectInfo> randomiseObjects(IEnumerable<OsuHitObject> hitObjects)
-        {
-            Debug.Assert(rng != null, $"{nameof(ApplyToBeatmap)} was not called before randomising objects");
-
-            var randomObjects = new List<RandomObjectInfo>();
-            RandomObjectInfo? previous = null;
             float rateOfChangeMultiplier = 0;
 
-            foreach (OsuHitObject hitObject in hitObjects)
+            foreach (var positionInfo in positionModifier.RandomObjects)
             {
-                var current = new RandomObjectInfo(hitObject);
-                randomObjects.Add(current);
-
                 // rateOfChangeMultiplier only changes every 5 iterations in a combo
                 // to prevent shaky-line-shaped streams
-                if (hitObject.IndexInCurrentCombo % 5 == 0)
+                if (positionInfo.HitObject.IndexInCurrentCombo % 5 == 0)
                     rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1;
 
-                if (previous == null)
+                if (positionInfo == positionModifier.RandomObjects.First())
                 {
-                    current.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2);
-                    current.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
+                    positionInfo.DistanceFromPrevious = (float)rng.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2;
+                    positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
                 }
                 else
                 {
-                    current.DistanceFromPrevious = Vector2.Distance(previous.EndPositionOriginal, current.PositionOriginal);
-
-                    // The max. angle (relative to the angle of the vector pointing from the 2nd last to the last hit object)
-                    // is proportional to the distance between the last and the current hit object
-                    // to allow jumps and prevent too sharp turns during streams.
-
-                    // Allow maximum jump angle when jump distance is more than half of playfield diagonal length
-                    current.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, current.DistanceFromPrevious / (playfield_diagonal * 0.5f));
+                    positionInfo.RelativeAngle = (float)(rateOfChangeMultiplier * 2 * Math.PI * Math.Min(1f, positionInfo.DistanceFromPrevious / (playfield_diagonal * 0.5f)));
                 }
-
-                previous = current;
             }
 
-            return randomObjects;
-        }
-
-        /// <summary>
-        /// Reposition the hit objects according to the information in <paramref name="randomObjects"/>.
-        /// </summary>
-        /// <param name="hitObjects">The hit objects to be repositioned.</param>
-        /// <param name="randomObjects">A list of <see cref="RandomObjectInfo"/> describing how each hit object should be placed.</param>
-        private void applyRandomisation(IReadOnlyList<OsuHitObject> hitObjects, IReadOnlyList<RandomObjectInfo> randomObjects)
-        {
-            RandomObjectInfo? previous = null;
-
-            for (int i = 0; i < hitObjects.Count; i++)
-            {
-                var hitObject = hitObjects[i];
-
-                var current = randomObjects[i];
-
-                if (hitObject is Spinner)
-                {
-                    previous = null;
-                    continue;
-                }
-
-                computeRandomisedPosition(current, previous, i > 1 ? randomObjects[i - 2] : null);
-
-                // Move hit objects back into the playfield if they are outside of it
-                Vector2 shift = Vector2.Zero;
-
-                switch (hitObject)
-                {
-                    case HitCircle circle:
-                        shift = clampHitCircleToPlayfield(circle, current);
-                        break;
-
-                    case Slider slider:
-                        shift = clampSliderToPlayfield(slider, current);
-                        break;
-                }
-
-                if (shift != Vector2.Zero)
-                {
-                    var toBeShifted = new List<OsuHitObject>();
-
-                    for (int j = i - 1; j >= i - preceding_hitobjects_to_shift && j >= 0; j--)
-                    {
-                        // only shift hit circles
-                        if (!(hitObjects[j] is HitCircle)) break;
-
-                        toBeShifted.Add(hitObjects[j]);
-                    }
-
-                    if (toBeShifted.Count > 0)
-                        applyDecreasingShift(toBeShifted, shift);
-                }
-
-                previous = current;
-            }
-        }
-
-        /// <summary>
-        /// Compute the randomised position of a hit object while attempting to keep it inside the playfield.
-        /// </summary>
-        /// <param name="current">The <see cref="RandomObjectInfo"/> representing the hit object to have the randomised position computed for.</param>
-        /// <param name="previous">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the current one.</param>
-        /// <param name="beforePrevious">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
-        private void computeRandomisedPosition(RandomObjectInfo current, RandomObjectInfo? previous, RandomObjectInfo? beforePrevious)
-        {
-            float previousAbsoluteAngle = 0f;
-
-            if (previous != null)
-            {
-                Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre;
-                Vector2 relativePosition = previous.HitObject.Position - earliestPosition;
-                previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
-            }
-
-            float absoluteAngle = previousAbsoluteAngle + current.RelativeAngle;
-
-            var posRelativeToPrev = new Vector2(
-                current.DistanceFromPrevious * (float)Math.Cos(absoluteAngle),
-                current.DistanceFromPrevious * (float)Math.Sin(absoluteAngle)
-            );
-
-            Vector2 lastEndPosition = previous?.EndPositionRandomised ?? playfield_centre;
-
-            posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
-
-            current.PositionRandomised = lastEndPosition + posRelativeToPrev;
-        }
-
-        /// <summary>
-        /// Move the randomised position of a hit circle so that it fits inside the playfield.
-        /// </summary>
-        /// <returns>The deviation from the original randomised position in order to fit within the playfield.</returns>
-        private Vector2 clampHitCircleToPlayfield(HitCircle circle, RandomObjectInfo objectInfo)
-        {
-            var previousPosition = objectInfo.PositionRandomised;
-            objectInfo.EndPositionRandomised = objectInfo.PositionRandomised = clampToPlayfieldWithPadding(
-                objectInfo.PositionRandomised,
-                (float)circle.Radius
-            );
-
-            circle.Position = objectInfo.PositionRandomised;
-
-            return objectInfo.PositionRandomised - previousPosition;
-        }
-
-        /// <summary>
-        /// Moves the <see cref="Slider"/> and all necessary nested <see cref="OsuHitObject"/>s into the <see cref="OsuPlayfield"/> if they aren't already.
-        /// </summary>
-        /// <returns>The deviation from the original randomised position in order to fit within the playfield.</returns>
-        private Vector2 clampSliderToPlayfield(Slider slider, RandomObjectInfo objectInfo)
-        {
-            var possibleMovementBounds = calculatePossibleMovementBounds(slider);
-
-            var previousPosition = objectInfo.PositionRandomised;
-
-            // Clamp slider position to the placement area
-            // If the slider is larger than the playfield, force it to stay at the original position
-            float newX = possibleMovementBounds.Width < 0
-                ? objectInfo.PositionOriginal.X
-                : Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right);
-
-            float newY = possibleMovementBounds.Height < 0
-                ? objectInfo.PositionOriginal.Y
-                : Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom);
-
-            slider.Position = objectInfo.PositionRandomised = new Vector2(newX, newY);
-            objectInfo.EndPositionRandomised = slider.EndPosition;
-
-            shiftNestedObjects(slider, objectInfo.PositionRandomised - objectInfo.PositionOriginal);
-
-            return objectInfo.PositionRandomised - previousPosition;
-        }
-
-        /// <summary>
-        /// Decreasingly shift a list of <see cref="OsuHitObject"/>s by a specified amount.
-        /// The first item in the list is shifted by the largest amount, while the last item is shifted by the smallest amount.
-        /// </summary>
-        /// <param name="hitObjects">The list of hit objects to be shifted.</param>
-        /// <param name="shift">The amount to be shifted.</param>
-        private void applyDecreasingShift(IList<OsuHitObject> hitObjects, Vector2 shift)
-        {
-            for (int i = 0; i < hitObjects.Count; i++)
-            {
-                var hitObject = hitObjects[i];
-                // The first object is shifted by a vector slightly smaller than shift
-                // The last object is shifted by a vector slightly larger than zero
-                Vector2 position = hitObject.Position + shift * ((hitObjects.Count - i) / (float)(hitObjects.Count + 1));
-
-                hitObject.Position = clampToPlayfieldWithPadding(position, (float)hitObject.Radius);
-            }
-        }
-
-        /// <summary>
-        /// Calculates a <see cref="RectangleF"/> which contains all of the possible movements of the slider (in relative X/Y coordinates)
-        /// such that the entire slider is inside the playfield.
-        /// </summary>
-        /// <remarks>
-        /// If the slider is larger than the playfield, the returned <see cref="RectangleF"/> may have negative width/height.
-        /// </remarks>
-        private RectangleF calculatePossibleMovementBounds(Slider slider)
-        {
-            var pathPositions = new List<Vector2>();
-            slider.Path.GetPathToProgress(pathPositions, 0, 1);
-
-            float minX = float.PositiveInfinity;
-            float maxX = float.NegativeInfinity;
-
-            float minY = float.PositiveInfinity;
-            float maxY = float.NegativeInfinity;
-
-            // Compute the bounding box of the slider.
-            foreach (var pos in pathPositions)
-            {
-                minX = MathF.Min(minX, pos.X);
-                maxX = MathF.Max(maxX, pos.X);
-
-                minY = MathF.Min(minY, pos.Y);
-                maxY = MathF.Max(maxY, pos.Y);
-            }
-
-            // Take the circle radius into account.
-            float radius = (float)slider.Radius;
-
-            minX -= radius;
-            minY -= radius;
-
-            maxX += radius;
-            maxY += radius;
-
-            // Given the bounding box of the slider (via min/max X/Y),
-            // the amount that the slider can move to the left is minX (with the sign flipped, since positive X is to the right),
-            // and the amount that it can move to the right is WIDTH - maxX.
-            // Same calculation applies for the Y axis.
-            float left = -minX;
-            float right = OsuPlayfield.BASE_SIZE.X - maxX;
-            float top = -minY;
-            float bottom = OsuPlayfield.BASE_SIZE.Y - maxY;
-
-            return new RectangleF(left, top, right - left, bottom - top);
-        }
-
-        /// <summary>
-        /// Shifts all nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s by the specified shift.
-        /// </summary>
-        /// <param name="slider"><see cref="Slider"/> whose nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted</param>
-        /// <param name="shift">The <see cref="Vector2"/> the <see cref="Slider"/>'s nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted by</param>
-        private void shiftNestedObjects(Slider slider, Vector2 shift)
-        {
-            foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat))
-            {
-                if (!(hitObject is OsuHitObject osuHitObject))
-                    continue;
-
-                osuHitObject.Position += shift;
-            }
-        }
-
-        /// <summary>
-        /// Clamp a position to playfield, keeping a specified distance from the edges.
-        /// </summary>
-        /// <param name="position">The position to be clamped.</param>
-        /// <param name="padding">The minimum distance allowed from playfield edges.</param>
-        /// <returns>The clamped position.</returns>
-        private Vector2 clampToPlayfieldWithPadding(Vector2 position, float padding)
-        {
-            return new Vector2(
-                Math.Clamp(position.X, padding, OsuPlayfield.BASE_SIZE.X - padding),
-                Math.Clamp(position.Y, padding, OsuPlayfield.BASE_SIZE.Y - padding)
-            );
-        }
-
-        private class RandomObjectInfo
-        {
-            /// <summary>
-            /// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle.
-            /// </summary>
-            /// <remarks>
-            /// <see cref="RelativeAngle"/> of the first hit object in a beatmap represents the absolute angle from playfield center to the object.
-            /// </remarks>
-            /// <example>
-            /// If <see cref="RelativeAngle"/> is 0, the player's cursor doesn't need to change its direction of movement when passing
-            /// the previous object to reach this one.
-            /// </example>
-            public float RelativeAngle { get; set; }
-
-            /// <summary>
-            /// The jump distance from the previous hit object to this one.
-            /// </summary>
-            /// <remarks>
-            /// <see cref="DistanceFromPrevious"/> of the first hit object in a beatmap is relative to the playfield center.
-            /// </remarks>
-            public float DistanceFromPrevious { get; set; }
-
-            public Vector2 PositionOriginal { get; }
-            public Vector2 PositionRandomised { get; set; }
-
-            public Vector2 EndPositionOriginal { get; }
-            public Vector2 EndPositionRandomised { get; set; }
-
-            public OsuHitObject HitObject { get; }
-
-            public RandomObjectInfo(OsuHitObject hitObject)
-            {
-                PositionRandomised = PositionOriginal = hitObject.Position;
-                EndPositionRandomised = EndPositionOriginal = hitObject.EndPosition;
-                HitObject = hitObject;
-            }
+            positionModifier.ApplyRandomisation();
         }
     }
 }

From 6a507ca11bdf4c95cba876be61bacbc4a9e1a9d5 Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Wed, 9 Mar 2022 20:52:11 +0800
Subject: [PATCH 008/113] Rename identifiers to remove references to random mod

---
 .../Mods/OsuHitObjectPositionModifier.cs      | 86 +++++++++----------
 osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs    |  6 +-
 2 files changed, 46 insertions(+), 46 deletions(-)

diff --git a/osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs b/osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs
index 3242b99755..84ad198951 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs
@@ -15,7 +15,7 @@ using osuTK;
 namespace osu.Game.Rulesets.Osu.Mods
 {
     /// <summary>
-    /// Places hit objects according to information in <see cref="RandomObjects"/> while keeping objects inside the playfield.
+    /// Places hit objects according to information in <see cref="ObjectPositionInfos"/> while keeping objects inside the playfield.
     /// </summary>
     public class OsuHitObjectPositionModifier
     {
@@ -28,21 +28,21 @@ namespace osu.Game.Rulesets.Osu.Mods
 
         private readonly List<OsuHitObject> hitObjects;
 
-        private readonly List<RandomObjectInfo> randomObjects = new List<RandomObjectInfo>();
+        private readonly List<ObjectPositionInfo> objectPositionInfos = new List<ObjectPositionInfo>();
 
         /// <summary>
         /// Contains information specifying how each hit object should be placed.
         /// <para>The default values correspond to how objects are originally placed in the beatmap.</para>
         /// </summary>
-        public IReadOnlyList<IRandomObjectInfo> RandomObjects => randomObjects;
+        public IReadOnlyList<IObjectPositionInfo> ObjectPositionInfos => objectPositionInfos;
 
         public OsuHitObjectPositionModifier(List<OsuHitObject> hitObjects)
         {
             this.hitObjects = hitObjects;
-            populateHitObjectPositions();
+            populateObjectPositionInfos();
         }
 
-        private void populateHitObjectPositions()
+        private void populateObjectPositionInfos()
         {
             Vector2 previousPosition = playfield_centre;
             float previousAngle = 0;
@@ -53,7 +53,7 @@ namespace osu.Game.Rulesets.Osu.Mods
                 float absoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
                 float relativeAngle = absoluteAngle - previousAngle;
 
-                randomObjects.Add(new RandomObjectInfo(hitObject)
+                objectPositionInfos.Add(new ObjectPositionInfo(hitObject)
                 {
                     RelativeAngle = relativeAngle,
                     DistanceFromPrevious = relativePosition.Length
@@ -65,17 +65,17 @@ namespace osu.Game.Rulesets.Osu.Mods
         }
 
         /// <summary>
-        /// Reposition the hit objects according to the information in <see cref="RandomObjects"/>.
+        /// Reposition the hit objects according to the information in <see cref="ObjectPositionInfos"/>.
         /// </summary>
-        public void ApplyRandomisation()
+        public void ApplyModifications()
         {
-            RandomObjectInfo? previous = null;
+            ObjectPositionInfo? previous = null;
 
             for (int i = 0; i < hitObjects.Count; i++)
             {
                 var hitObject = hitObjects[i];
 
-                var current = randomObjects[i];
+                var current = objectPositionInfos[i];
 
                 if (hitObject is Spinner)
                 {
@@ -83,7 +83,7 @@ namespace osu.Game.Rulesets.Osu.Mods
                     continue;
                 }
 
-                computeRandomisedPosition(current, previous, i > 1 ? randomObjects[i - 2] : null);
+                computeModifiedPosition(current, previous, i > 1 ? objectPositionInfos[i - 2] : null);
 
                 // Move hit objects back into the playfield if they are outside of it
                 Vector2 shift = Vector2.Zero;
@@ -120,12 +120,12 @@ namespace osu.Game.Rulesets.Osu.Mods
         }
 
         /// <summary>
-        /// Compute the randomised position of a hit object while attempting to keep it inside the playfield.
+        /// Compute the modified position of a hit object while attempting to keep it inside the playfield.
         /// </summary>
-        /// <param name="current">The <see cref="RandomObjectInfo"/> representing the hit object to have the randomised position computed for.</param>
-        /// <param name="previous">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the current one.</param>
-        /// <param name="beforePrevious">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
-        private void computeRandomisedPosition(RandomObjectInfo current, RandomObjectInfo? previous, RandomObjectInfo? beforePrevious)
+        /// <param name="current">The <see cref="ObjectPositionInfo"/> representing the hit object to have the modified position computed for.</param>
+        /// <param name="previous">The <see cref="ObjectPositionInfo"/> representing the hit object immediately preceding the current one.</param>
+        /// <param name="beforePrevious">The <see cref="ObjectPositionInfo"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
+        private void computeModifiedPosition(ObjectPositionInfo current, ObjectPositionInfo? previous, ObjectPositionInfo? beforePrevious)
         {
             float previousAbsoluteAngle = 0f;
 
@@ -143,56 +143,56 @@ namespace osu.Game.Rulesets.Osu.Mods
                 current.DistanceFromPrevious * (float)Math.Sin(absoluteAngle)
             );
 
-            Vector2 lastEndPosition = previous?.EndPositionRandomised ?? playfield_centre;
+            Vector2 lastEndPosition = previous?.EndPositionModified ?? playfield_centre;
 
             posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
 
-            current.PositionRandomised = lastEndPosition + posRelativeToPrev;
+            current.PositionModified = lastEndPosition + posRelativeToPrev;
         }
 
         /// <summary>
-        /// Move the randomised position of a hit circle so that it fits inside the playfield.
+        /// Move the modified position of a hit circle so that it fits inside the playfield.
         /// </summary>
-        /// <returns>The deviation from the original randomised position in order to fit within the playfield.</returns>
-        private Vector2 clampHitCircleToPlayfield(HitCircle circle, RandomObjectInfo objectInfo)
+        /// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
+        private Vector2 clampHitCircleToPlayfield(HitCircle circle, ObjectPositionInfo objectPositionInfo)
         {
-            var previousPosition = objectInfo.PositionRandomised;
-            objectInfo.EndPositionRandomised = objectInfo.PositionRandomised = clampToPlayfieldWithPadding(
-                objectInfo.PositionRandomised,
+            var previousPosition = objectPositionInfo.PositionModified;
+            objectPositionInfo.EndPositionModified = objectPositionInfo.PositionModified = clampToPlayfieldWithPadding(
+                objectPositionInfo.PositionModified,
                 (float)circle.Radius
             );
 
-            circle.Position = objectInfo.PositionRandomised;
+            circle.Position = objectPositionInfo.PositionModified;
 
-            return objectInfo.PositionRandomised - previousPosition;
+            return objectPositionInfo.PositionModified - previousPosition;
         }
 
         /// <summary>
         /// Moves the <see cref="Slider"/> and all necessary nested <see cref="OsuHitObject"/>s into the <see cref="OsuPlayfield"/> if they aren't already.
         /// </summary>
-        /// <returns>The deviation from the original randomised position in order to fit within the playfield.</returns>
-        private Vector2 clampSliderToPlayfield(Slider slider, RandomObjectInfo objectInfo)
+        /// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
+        private Vector2 clampSliderToPlayfield(Slider slider, ObjectPositionInfo objectPositionInfo)
         {
             var possibleMovementBounds = calculatePossibleMovementBounds(slider);
 
-            var previousPosition = objectInfo.PositionRandomised;
+            var previousPosition = objectPositionInfo.PositionModified;
 
             // Clamp slider position to the placement area
             // If the slider is larger than the playfield, force it to stay at the original position
             float newX = possibleMovementBounds.Width < 0
-                ? objectInfo.PositionOriginal.X
+                ? objectPositionInfo.PositionOriginal.X
                 : Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right);
 
             float newY = possibleMovementBounds.Height < 0
-                ? objectInfo.PositionOriginal.Y
+                ? objectPositionInfo.PositionOriginal.Y
                 : Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom);
 
-            slider.Position = objectInfo.PositionRandomised = new Vector2(newX, newY);
-            objectInfo.EndPositionRandomised = slider.EndPosition;
+            slider.Position = objectPositionInfo.PositionModified = new Vector2(newX, newY);
+            objectPositionInfo.EndPositionModified = slider.EndPosition;
 
-            shiftNestedObjects(slider, objectInfo.PositionRandomised - objectInfo.PositionOriginal);
+            shiftNestedObjects(slider, objectPositionInfo.PositionModified - objectPositionInfo.PositionOriginal);
 
-            return objectInfo.PositionRandomised - previousPosition;
+            return objectPositionInfo.PositionModified - previousPosition;
         }
 
         /// <summary>
@@ -293,7 +293,7 @@ namespace osu.Game.Rulesets.Osu.Mods
             );
         }
 
-        public interface IRandomObjectInfo
+        public interface IObjectPositionInfo
         {
             /// <summary>
             /// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle.
@@ -316,29 +316,29 @@ namespace osu.Game.Rulesets.Osu.Mods
             float DistanceFromPrevious { get; set; }
 
             /// <summary>
-            /// The hit object associated with this <see cref="IRandomObjectInfo"/>.
+            /// The hit object associated with this <see cref="IObjectPositionInfo"/>.
             /// </summary>
             OsuHitObject HitObject { get; }
         }
 
-        private class RandomObjectInfo : IRandomObjectInfo
+        private class ObjectPositionInfo : IObjectPositionInfo
         {
             public float RelativeAngle { get; set; }
 
             public float DistanceFromPrevious { get; set; }
 
             public Vector2 PositionOriginal { get; }
-            public Vector2 PositionRandomised { get; set; }
+            public Vector2 PositionModified { get; set; }
 
             public Vector2 EndPositionOriginal { get; }
-            public Vector2 EndPositionRandomised { get; set; }
+            public Vector2 EndPositionModified { get; set; }
 
             public OsuHitObject HitObject { get; }
 
-            public RandomObjectInfo(OsuHitObject hitObject)
+            public ObjectPositionInfo(OsuHitObject hitObject)
             {
-                PositionRandomised = PositionOriginal = hitObject.Position;
-                EndPositionRandomised = EndPositionOriginal = hitObject.EndPosition;
+                PositionModified = PositionOriginal = hitObject.Position;
+                EndPositionModified = EndPositionOriginal = hitObject.EndPosition;
                 HitObject = hitObject;
             }
         }
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
index 2c38be6c16..59abc73ed9 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
@@ -38,14 +38,14 @@ namespace osu.Game.Rulesets.Osu.Mods
 
             float rateOfChangeMultiplier = 0;
 
-            foreach (var positionInfo in positionModifier.RandomObjects)
+            foreach (var positionInfo in positionModifier.ObjectPositionInfos)
             {
                 // rateOfChangeMultiplier only changes every 5 iterations in a combo
                 // to prevent shaky-line-shaped streams
                 if (positionInfo.HitObject.IndexInCurrentCombo % 5 == 0)
                     rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1;
 
-                if (positionInfo == positionModifier.RandomObjects.First())
+                if (positionInfo == positionModifier.ObjectPositionInfos.First())
                 {
                     positionInfo.DistanceFromPrevious = (float)rng.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2;
                     positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
@@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.Osu.Mods
                 }
             }
 
-            positionModifier.ApplyRandomisation();
+            positionModifier.ApplyModifications();
         }
     }
 }

From 8e12a067dfb1695c984d1d4705aff15c5af18b8b Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Wed, 9 Mar 2022 21:04:35 +0800
Subject: [PATCH 009/113] Remove an unused property

---
 osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs b/osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs
index 84ad198951..16ec25f389 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs
@@ -329,8 +329,6 @@ namespace osu.Game.Rulesets.Osu.Mods
 
             public Vector2 PositionOriginal { get; }
             public Vector2 PositionModified { get; set; }
-
-            public Vector2 EndPositionOriginal { get; }
             public Vector2 EndPositionModified { get; set; }
 
             public OsuHitObject HitObject { get; }
@@ -338,7 +336,7 @@ namespace osu.Game.Rulesets.Osu.Mods
             public ObjectPositionInfo(OsuHitObject hitObject)
             {
                 PositionModified = PositionOriginal = hitObject.Position;
-                EndPositionModified = EndPositionOriginal = hitObject.EndPosition;
+                EndPositionModified = hitObject.EndPosition;
                 HitObject = hitObject;
             }
         }

From e8dbed738e4b819f5a68bf9b1df6a2d1b2a824a7 Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Wed, 9 Mar 2022 21:52:15 +0800
Subject: [PATCH 010/113] Move `OsuHitObjectPositionModifier` to `Utils/`

---
 osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs                     | 1 +
 .../{Mods => Utils}/OsuHitObjectPositionModifier.cs            | 3 +--
 2 files changed, 2 insertions(+), 2 deletions(-)
 rename osu.Game.Rulesets.Osu/{Mods => Utils}/OsuHitObjectPositionModifier.cs (99%)

diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
index 59abc73ed9..cdaa8fa3d5 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
@@ -11,6 +11,7 @@ using osu.Game.Rulesets.Mods;
 using osu.Game.Rulesets.Objects;
 using osu.Game.Rulesets.Osu.Beatmaps;
 using osu.Game.Rulesets.Osu.UI;
+using osu.Game.Rulesets.Osu.Utils;
 
 namespace osu.Game.Rulesets.Osu.Mods
 {
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectPositionModifier.cs
similarity index 99%
rename from osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs
rename to osu.Game.Rulesets.Osu/Utils/OsuHitObjectPositionModifier.cs
index 16ec25f389..428866623f 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuHitObjectPositionModifier.cs
+++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectPositionModifier.cs
@@ -7,12 +7,11 @@ using System.Linq;
 using osu.Framework.Graphics.Primitives;
 using osu.Game.Rulesets.Osu.Objects;
 using osu.Game.Rulesets.Osu.UI;
-using osu.Game.Rulesets.Osu.Utils;
 using osuTK;
 
 #nullable enable
 
-namespace osu.Game.Rulesets.Osu.Mods
+namespace osu.Game.Rulesets.Osu.Utils
 {
     /// <summary>
     /// Places hit objects according to information in <see cref="ObjectPositionInfos"/> while keeping objects inside the playfield.

From ede838c4b3ebaa2fa2471de68ec11bbf7fbd21e5 Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Thu, 10 Mar 2022 11:23:52 +0800
Subject: [PATCH 011/113] Use `ObjectPositionInfo.HitObject`

---
 osu.Game.Rulesets.Osu/Utils/OsuHitObjectPositionModifier.cs | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectPositionModifier.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectPositionModifier.cs
index 428866623f..32f547dfe7 100644
--- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectPositionModifier.cs
+++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectPositionModifier.cs
@@ -70,11 +70,10 @@ namespace osu.Game.Rulesets.Osu.Utils
         {
             ObjectPositionInfo? previous = null;
 
-            for (int i = 0; i < hitObjects.Count; i++)
+            for (int i = 0; i < objectPositionInfos.Count; i++)
             {
-                var hitObject = hitObjects[i];
-
                 var current = objectPositionInfos[i];
+                var hitObject = current.HitObject;
 
                 if (hitObject is Spinner)
                 {

From 3a71d817758e8387568a0a8f974f6c8ac22e43e7 Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Thu, 10 Mar 2022 11:53:03 +0800
Subject: [PATCH 012/113] Convert the position modifier to stateless methods

---
 osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs    |  8 +--
 .../Utils/OsuHitObjectGenerationUtils.cs      |  2 +-
 ...OsuHitObjectGenerationUtils_Reposition.cs} | 65 +++++++++----------
 3 files changed, 35 insertions(+), 40 deletions(-)
 rename osu.Game.Rulesets.Osu/Utils/{OsuHitObjectPositionModifier.cs => OsuHitObjectGenerationUtils_Reposition.cs} (84%)

diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
index cdaa8fa3d5..3c2c5d7759 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
@@ -35,18 +35,18 @@ namespace osu.Game.Rulesets.Osu.Mods
 
             rng = new Random((int)Seed.Value);
 
-            var positionModifier = new OsuHitObjectPositionModifier(osuBeatmap.HitObjects);
+            var positionInfos = OsuHitObjectGenerationUtils.GeneratePositionInfos(osuBeatmap.HitObjects);
 
             float rateOfChangeMultiplier = 0;
 
-            foreach (var positionInfo in positionModifier.ObjectPositionInfos)
+            foreach (var positionInfo in positionInfos)
             {
                 // rateOfChangeMultiplier only changes every 5 iterations in a combo
                 // to prevent shaky-line-shaped streams
                 if (positionInfo.HitObject.IndexInCurrentCombo % 5 == 0)
                     rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1;
 
-                if (positionInfo == positionModifier.ObjectPositionInfos.First())
+                if (positionInfo == positionInfos.First())
                 {
                     positionInfo.DistanceFromPrevious = (float)rng.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2;
                     positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
@@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Mods
                 }
             }
 
-            positionModifier.ApplyModifications();
+            osuBeatmap.HitObjects = OsuHitObjectGenerationUtils.RepositionHitObjects(positionInfos);
         }
     }
 }
diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs
index 97a4b14a62..da73c2addb 100644
--- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs
+++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs
@@ -11,7 +11,7 @@ using osuTK;
 
 namespace osu.Game.Rulesets.Osu.Utils
 {
-    public static class OsuHitObjectGenerationUtils
+    public static partial class OsuHitObjectGenerationUtils
     {
         // The relative distance to the edge of the playfield before objects' positions should start to "turn around" and curve towards the middle.
         // The closer the hit objects draw to the border, the sharper the turn
diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectPositionModifier.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs
similarity index 84%
rename from osu.Game.Rulesets.Osu/Utils/OsuHitObjectPositionModifier.cs
rename to osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs
index 32f547dfe7..2a735c89d9 100644
--- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectPositionModifier.cs
+++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs
@@ -13,10 +13,7 @@ using osuTK;
 
 namespace osu.Game.Rulesets.Osu.Utils
 {
-    /// <summary>
-    /// Places hit objects according to information in <see cref="ObjectPositionInfos"/> while keeping objects inside the playfield.
-    /// </summary>
-    public class OsuHitObjectPositionModifier
+    public static partial class OsuHitObjectGenerationUtils
     {
         /// <summary>
         /// Number of previous hitobjects to be shifted together when an object is being moved.
@@ -25,24 +22,15 @@ namespace osu.Game.Rulesets.Osu.Utils
 
         private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE / 2;
 
-        private readonly List<OsuHitObject> hitObjects;
-
-        private readonly List<ObjectPositionInfo> objectPositionInfos = new List<ObjectPositionInfo>();
-
         /// <summary>
-        /// Contains information specifying how each hit object should be placed.
-        /// <para>The default values correspond to how objects are originally placed in the beatmap.</para>
+        /// Generate a list of <see cref="IObjectPositionInfo"/>s containing information for how the given list of
+        /// <see cref="OsuHitObject"/>s are positioned.
         /// </summary>
-        public IReadOnlyList<IObjectPositionInfo> ObjectPositionInfos => objectPositionInfos;
-
-        public OsuHitObjectPositionModifier(List<OsuHitObject> hitObjects)
-        {
-            this.hitObjects = hitObjects;
-            populateObjectPositionInfos();
-        }
-
-        private void populateObjectPositionInfos()
+        /// <param name="hitObjects">A list of <see cref="OsuHitObject"/>s to process.</param>
+        /// <returns>A list of <see cref="IObjectPositionInfo"/>s describing how each hit object is positioned relative to the previous one.</returns>
+        public static List<IObjectPositionInfo> GeneratePositionInfos(IEnumerable<OsuHitObject> hitObjects)
         {
+            var positionInfos = new List<IObjectPositionInfo>();
             Vector2 previousPosition = playfield_centre;
             float previousAngle = 0;
 
@@ -52,7 +40,7 @@ namespace osu.Game.Rulesets.Osu.Utils
                 float absoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
                 float relativeAngle = absoluteAngle - previousAngle;
 
-                objectPositionInfos.Add(new ObjectPositionInfo(hitObject)
+                positionInfos.Add(new ObjectPositionInfo(hitObject)
                 {
                     RelativeAngle = relativeAngle,
                     DistanceFromPrevious = relativePosition.Length
@@ -61,18 +49,23 @@ namespace osu.Game.Rulesets.Osu.Utils
                 previousPosition = hitObject.EndPosition;
                 previousAngle = absoluteAngle;
             }
+
+            return positionInfos;
         }
 
         /// <summary>
-        /// Reposition the hit objects according to the information in <see cref="ObjectPositionInfos"/>.
+        /// Reposition the hit objects according to the information in <paramref name="objectPositionInfos"/>.
         /// </summary>
-        public void ApplyModifications()
+        /// <param name="objectPositionInfos"></param>
+        /// <returns>The repositioned hit objects.</returns>
+        public static List<OsuHitObject> RepositionHitObjects(IEnumerable<IObjectPositionInfo> objectPositionInfos)
         {
+            List<ObjectPositionInfo> positionInfos = objectPositionInfos.Cast<ObjectPositionInfo>().ToList();
             ObjectPositionInfo? previous = null;
 
-            for (int i = 0; i < objectPositionInfos.Count; i++)
+            for (int i = 0; i < positionInfos.Count; i++)
             {
-                var current = objectPositionInfos[i];
+                var current = positionInfos[i];
                 var hitObject = current.HitObject;
 
                 if (hitObject is Spinner)
@@ -81,7 +74,7 @@ namespace osu.Game.Rulesets.Osu.Utils
                     continue;
                 }
 
-                computeModifiedPosition(current, previous, i > 1 ? objectPositionInfos[i - 2] : null);
+                computeModifiedPosition(current, previous, i > 1 ? positionInfos[i - 2] : null);
 
                 // Move hit objects back into the playfield if they are outside of it
                 Vector2 shift = Vector2.Zero;
@@ -104,9 +97,9 @@ namespace osu.Game.Rulesets.Osu.Utils
                     for (int j = i - 1; j >= i - preceding_hitobjects_to_shift && j >= 0; j--)
                     {
                         // only shift hit circles
-                        if (!(hitObjects[j] is HitCircle)) break;
+                        if (!(positionInfos[j].HitObject is HitCircle)) break;
 
-                        toBeShifted.Add(hitObjects[j]);
+                        toBeShifted.Add(positionInfos[j].HitObject);
                     }
 
                     if (toBeShifted.Count > 0)
@@ -115,6 +108,8 @@ namespace osu.Game.Rulesets.Osu.Utils
 
                 previous = current;
             }
+
+            return positionInfos.Select(p => p.HitObject).ToList();
         }
 
         /// <summary>
@@ -123,7 +118,7 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// <param name="current">The <see cref="ObjectPositionInfo"/> representing the hit object to have the modified position computed for.</param>
         /// <param name="previous">The <see cref="ObjectPositionInfo"/> representing the hit object immediately preceding the current one.</param>
         /// <param name="beforePrevious">The <see cref="ObjectPositionInfo"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
-        private void computeModifiedPosition(ObjectPositionInfo current, ObjectPositionInfo? previous, ObjectPositionInfo? beforePrevious)
+        private static void computeModifiedPosition(ObjectPositionInfo current, ObjectPositionInfo? previous, ObjectPositionInfo? beforePrevious)
         {
             float previousAbsoluteAngle = 0f;
 
@@ -143,7 +138,7 @@ namespace osu.Game.Rulesets.Osu.Utils
 
             Vector2 lastEndPosition = previous?.EndPositionModified ?? playfield_centre;
 
-            posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
+            posRelativeToPrev = RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
 
             current.PositionModified = lastEndPosition + posRelativeToPrev;
         }
@@ -152,7 +147,7 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// Move the modified position of a hit circle so that it fits inside the playfield.
         /// </summary>
         /// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
-        private Vector2 clampHitCircleToPlayfield(HitCircle circle, ObjectPositionInfo objectPositionInfo)
+        private static Vector2 clampHitCircleToPlayfield(HitCircle circle, ObjectPositionInfo objectPositionInfo)
         {
             var previousPosition = objectPositionInfo.PositionModified;
             objectPositionInfo.EndPositionModified = objectPositionInfo.PositionModified = clampToPlayfieldWithPadding(
@@ -169,7 +164,7 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// Moves the <see cref="Slider"/> and all necessary nested <see cref="OsuHitObject"/>s into the <see cref="OsuPlayfield"/> if they aren't already.
         /// </summary>
         /// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
-        private Vector2 clampSliderToPlayfield(Slider slider, ObjectPositionInfo objectPositionInfo)
+        private static Vector2 clampSliderToPlayfield(Slider slider, ObjectPositionInfo objectPositionInfo)
         {
             var possibleMovementBounds = calculatePossibleMovementBounds(slider);
 
@@ -199,7 +194,7 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// </summary>
         /// <param name="hitObjects">The list of hit objects to be shifted.</param>
         /// <param name="shift">The amount to be shifted.</param>
-        private void applyDecreasingShift(IList<OsuHitObject> hitObjects, Vector2 shift)
+        private static void applyDecreasingShift(IList<OsuHitObject> hitObjects, Vector2 shift)
         {
             for (int i = 0; i < hitObjects.Count; i++)
             {
@@ -219,7 +214,7 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// <remarks>
         /// If the slider is larger than the playfield, the returned <see cref="RectangleF"/> may have negative width/height.
         /// </remarks>
-        private RectangleF calculatePossibleMovementBounds(Slider slider)
+        private static RectangleF calculatePossibleMovementBounds(Slider slider)
         {
             var pathPositions = new List<Vector2>();
             slider.Path.GetPathToProgress(pathPositions, 0, 1);
@@ -266,7 +261,7 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// </summary>
         /// <param name="slider"><see cref="Slider"/> whose nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted</param>
         /// <param name="shift">The <see cref="Vector2"/> the <see cref="Slider"/>'s nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted by</param>
-        private void shiftNestedObjects(Slider slider, Vector2 shift)
+        private static void shiftNestedObjects(Slider slider, Vector2 shift)
         {
             foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat))
             {
@@ -283,7 +278,7 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// <param name="position">The position to be clamped.</param>
         /// <param name="padding">The minimum distance allowed from playfield edges.</param>
         /// <returns>The clamped position.</returns>
-        private Vector2 clampToPlayfieldWithPadding(Vector2 position, float padding)
+        private static Vector2 clampToPlayfieldWithPadding(Vector2 position, float padding)
         {
             return new Vector2(
                 Math.Clamp(position.X, padding, OsuPlayfield.BASE_SIZE.X - padding),

From 5e36383258b8a5cb01ab6298d6d2283ef86e6c4f Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Thu, 10 Mar 2022 12:02:25 +0800
Subject: [PATCH 013/113] Convert `IObjectPositionInfo` to a class

---
 .../OsuHitObjectGenerationUtils_Reposition.cs | 69 ++++++++++---------
 1 file changed, 35 insertions(+), 34 deletions(-)

diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs
index 2a735c89d9..37a12b20b4 100644
--- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs
+++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs
@@ -23,14 +23,14 @@ namespace osu.Game.Rulesets.Osu.Utils
         private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE / 2;
 
         /// <summary>
-        /// Generate a list of <see cref="IObjectPositionInfo"/>s containing information for how the given list of
+        /// Generate a list of <see cref="ObjectPositionInfo"/>s containing information for how the given list of
         /// <see cref="OsuHitObject"/>s are positioned.
         /// </summary>
         /// <param name="hitObjects">A list of <see cref="OsuHitObject"/>s to process.</param>
-        /// <returns>A list of <see cref="IObjectPositionInfo"/>s describing how each hit object is positioned relative to the previous one.</returns>
-        public static List<IObjectPositionInfo> GeneratePositionInfos(IEnumerable<OsuHitObject> hitObjects)
+        /// <returns>A list of <see cref="ObjectPositionInfo"/>s describing how each hit object is positioned relative to the previous one.</returns>
+        public static List<ObjectPositionInfo> GeneratePositionInfos(IEnumerable<OsuHitObject> hitObjects)
         {
-            var positionInfos = new List<IObjectPositionInfo>();
+            var positionInfos = new List<ObjectPositionInfo>();
             Vector2 previousPosition = playfield_centre;
             float previousAngle = 0;
 
@@ -56,12 +56,12 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// <summary>
         /// Reposition the hit objects according to the information in <paramref name="objectPositionInfos"/>.
         /// </summary>
-        /// <param name="objectPositionInfos"></param>
+        /// <param name="objectPositionInfos">Position information for each hit object.</param>
         /// <returns>The repositioned hit objects.</returns>
-        public static List<OsuHitObject> RepositionHitObjects(IEnumerable<IObjectPositionInfo> objectPositionInfos)
+        public static List<OsuHitObject> RepositionHitObjects(IEnumerable<ObjectPositionInfo> objectPositionInfos)
         {
-            List<ObjectPositionInfo> positionInfos = objectPositionInfos.Cast<ObjectPositionInfo>().ToList();
-            ObjectPositionInfo? previous = null;
+            List<ObjectPositionInfoInternal> positionInfos = objectPositionInfos.Select(o => new ObjectPositionInfoInternal(o)).ToList();
+            ObjectPositionInfoInternal? previous = null;
 
             for (int i = 0; i < positionInfos.Count; i++)
             {
@@ -115,10 +115,10 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// <summary>
         /// Compute the modified position of a hit object while attempting to keep it inside the playfield.
         /// </summary>
-        /// <param name="current">The <see cref="ObjectPositionInfo"/> representing the hit object to have the modified position computed for.</param>
-        /// <param name="previous">The <see cref="ObjectPositionInfo"/> representing the hit object immediately preceding the current one.</param>
-        /// <param name="beforePrevious">The <see cref="ObjectPositionInfo"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
-        private static void computeModifiedPosition(ObjectPositionInfo current, ObjectPositionInfo? previous, ObjectPositionInfo? beforePrevious)
+        /// <param name="current">The <see cref="ObjectPositionInfoInternal"/> representing the hit object to have the modified position computed for.</param>
+        /// <param name="previous">The <see cref="ObjectPositionInfoInternal"/> representing the hit object immediately preceding the current one.</param>
+        /// <param name="beforePrevious">The <see cref="ObjectPositionInfoInternal"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
+        private static void computeModifiedPosition(ObjectPositionInfoInternal current, ObjectPositionInfoInternal? previous, ObjectPositionInfoInternal? beforePrevious)
         {
             float previousAbsoluteAngle = 0f;
 
@@ -147,7 +147,7 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// Move the modified position of a hit circle so that it fits inside the playfield.
         /// </summary>
         /// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
-        private static Vector2 clampHitCircleToPlayfield(HitCircle circle, ObjectPositionInfo objectPositionInfo)
+        private static Vector2 clampHitCircleToPlayfield(HitCircle circle, ObjectPositionInfoInternal objectPositionInfo)
         {
             var previousPosition = objectPositionInfo.PositionModified;
             objectPositionInfo.EndPositionModified = objectPositionInfo.PositionModified = clampToPlayfieldWithPadding(
@@ -164,7 +164,7 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// Moves the <see cref="Slider"/> and all necessary nested <see cref="OsuHitObject"/>s into the <see cref="OsuPlayfield"/> if they aren't already.
         /// </summary>
         /// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
-        private static Vector2 clampSliderToPlayfield(Slider slider, ObjectPositionInfo objectPositionInfo)
+        private static Vector2 clampSliderToPlayfield(Slider slider, ObjectPositionInfoInternal objectPositionInfo)
         {
             var possibleMovementBounds = calculatePossibleMovementBounds(slider);
 
@@ -286,7 +286,7 @@ namespace osu.Game.Rulesets.Osu.Utils
             );
         }
 
-        public interface IObjectPositionInfo
+        public class ObjectPositionInfo
         {
             /// <summary>
             /// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle.
@@ -298,7 +298,7 @@ namespace osu.Game.Rulesets.Osu.Utils
             /// If <see cref="RelativeAngle"/> is 0, the player's cursor doesn't need to change its direction of movement when passing
             /// the previous object to reach this one.
             /// </example>
-            float RelativeAngle { get; set; }
+            public float RelativeAngle { get; set; }
 
             /// <summary>
             /// The jump distance from the previous hit object to this one.
@@ -306,32 +306,33 @@ namespace osu.Game.Rulesets.Osu.Utils
             /// <remarks>
             /// <see cref="DistanceFromPrevious"/> of the first hit object in a beatmap is relative to the playfield center.
             /// </remarks>
-            float DistanceFromPrevious { get; set; }
-
-            /// <summary>
-            /// The hit object associated with this <see cref="IObjectPositionInfo"/>.
-            /// </summary>
-            OsuHitObject HitObject { get; }
-        }
-
-        private class ObjectPositionInfo : IObjectPositionInfo
-        {
-            public float RelativeAngle { get; set; }
-
             public float DistanceFromPrevious { get; set; }
 
-            public Vector2 PositionOriginal { get; }
-            public Vector2 PositionModified { get; set; }
-            public Vector2 EndPositionModified { get; set; }
-
+            /// <summary>
+            /// The hit object associated with this <see cref="ObjectPositionInfo"/>.
+            /// </summary>
             public OsuHitObject HitObject { get; }
 
             public ObjectPositionInfo(OsuHitObject hitObject)
             {
-                PositionModified = PositionOriginal = hitObject.Position;
-                EndPositionModified = hitObject.EndPosition;
                 HitObject = hitObject;
             }
         }
+
+        private class ObjectPositionInfoInternal : ObjectPositionInfo
+        {
+            public Vector2 PositionOriginal { get; }
+            public Vector2 PositionModified { get; set; }
+            public Vector2 EndPositionModified { get; set; }
+
+            public ObjectPositionInfoInternal(ObjectPositionInfo original)
+                : base(original.HitObject)
+            {
+                RelativeAngle = original.RelativeAngle;
+                DistanceFromPrevious = original.DistanceFromPrevious;
+                PositionModified = PositionOriginal = HitObject.Position;
+                EndPositionModified = HitObject.EndPosition;
+            }
+        }
     }
 }

From 6657d93b29e2dbeda325333c674fd53c572b0d66 Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Mon, 14 Mar 2022 20:18:30 +0800
Subject: [PATCH 014/113] Separate the two nested classes

---
 .../OsuHitObjectGenerationUtils_Reposition.cs | 45 ++++++++++---------
 1 file changed, 23 insertions(+), 22 deletions(-)

diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs
index 37a12b20b4..94f4f154bd 100644
--- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs
+++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs
@@ -60,12 +60,12 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// <returns>The repositioned hit objects.</returns>
         public static List<OsuHitObject> RepositionHitObjects(IEnumerable<ObjectPositionInfo> objectPositionInfos)
         {
-            List<ObjectPositionInfoInternal> positionInfos = objectPositionInfos.Select(o => new ObjectPositionInfoInternal(o)).ToList();
-            ObjectPositionInfoInternal? previous = null;
+            List<WorkingObject> workingObjects = objectPositionInfos.Select(o => new WorkingObject(o)).ToList();
+            WorkingObject? previous = null;
 
-            for (int i = 0; i < positionInfos.Count; i++)
+            for (int i = 0; i < workingObjects.Count; i++)
             {
-                var current = positionInfos[i];
+                var current = workingObjects[i];
                 var hitObject = current.HitObject;
 
                 if (hitObject is Spinner)
@@ -74,7 +74,7 @@ namespace osu.Game.Rulesets.Osu.Utils
                     continue;
                 }
 
-                computeModifiedPosition(current, previous, i > 1 ? positionInfos[i - 2] : null);
+                computeModifiedPosition(current, previous, i > 1 ? workingObjects[i - 2] : null);
 
                 // Move hit objects back into the playfield if they are outside of it
                 Vector2 shift = Vector2.Zero;
@@ -97,9 +97,9 @@ namespace osu.Game.Rulesets.Osu.Utils
                     for (int j = i - 1; j >= i - preceding_hitobjects_to_shift && j >= 0; j--)
                     {
                         // only shift hit circles
-                        if (!(positionInfos[j].HitObject is HitCircle)) break;
+                        if (!(workingObjects[j].HitObject is HitCircle)) break;
 
-                        toBeShifted.Add(positionInfos[j].HitObject);
+                        toBeShifted.Add(workingObjects[j].HitObject);
                     }
 
                     if (toBeShifted.Count > 0)
@@ -109,16 +109,16 @@ namespace osu.Game.Rulesets.Osu.Utils
                 previous = current;
             }
 
-            return positionInfos.Select(p => p.HitObject).ToList();
+            return workingObjects.Select(p => p.HitObject).ToList();
         }
 
         /// <summary>
         /// Compute the modified position of a hit object while attempting to keep it inside the playfield.
         /// </summary>
-        /// <param name="current">The <see cref="ObjectPositionInfoInternal"/> representing the hit object to have the modified position computed for.</param>
-        /// <param name="previous">The <see cref="ObjectPositionInfoInternal"/> representing the hit object immediately preceding the current one.</param>
-        /// <param name="beforePrevious">The <see cref="ObjectPositionInfoInternal"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
-        private static void computeModifiedPosition(ObjectPositionInfoInternal current, ObjectPositionInfoInternal? previous, ObjectPositionInfoInternal? beforePrevious)
+        /// <param name="current">The <see cref="WorkingObject"/> representing the hit object to have the modified position computed for.</param>
+        /// <param name="previous">The <see cref="WorkingObject"/> representing the hit object immediately preceding the current one.</param>
+        /// <param name="beforePrevious">The <see cref="WorkingObject"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
+        private static void computeModifiedPosition(WorkingObject current, WorkingObject? previous, WorkingObject? beforePrevious)
         {
             float previousAbsoluteAngle = 0f;
 
@@ -129,11 +129,11 @@ namespace osu.Game.Rulesets.Osu.Utils
                 previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
             }
 
-            float absoluteAngle = previousAbsoluteAngle + current.RelativeAngle;
+            float absoluteAngle = previousAbsoluteAngle + current.PositionInfo.RelativeAngle;
 
             var posRelativeToPrev = new Vector2(
-                current.DistanceFromPrevious * (float)Math.Cos(absoluteAngle),
-                current.DistanceFromPrevious * (float)Math.Sin(absoluteAngle)
+                current.PositionInfo.DistanceFromPrevious * (float)Math.Cos(absoluteAngle),
+                current.PositionInfo.DistanceFromPrevious * (float)Math.Sin(absoluteAngle)
             );
 
             Vector2 lastEndPosition = previous?.EndPositionModified ?? playfield_centre;
@@ -147,7 +147,7 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// Move the modified position of a hit circle so that it fits inside the playfield.
         /// </summary>
         /// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
-        private static Vector2 clampHitCircleToPlayfield(HitCircle circle, ObjectPositionInfoInternal objectPositionInfo)
+        private static Vector2 clampHitCircleToPlayfield(HitCircle circle, WorkingObject objectPositionInfo)
         {
             var previousPosition = objectPositionInfo.PositionModified;
             objectPositionInfo.EndPositionModified = objectPositionInfo.PositionModified = clampToPlayfieldWithPadding(
@@ -164,7 +164,7 @@ namespace osu.Game.Rulesets.Osu.Utils
         /// Moves the <see cref="Slider"/> and all necessary nested <see cref="OsuHitObject"/>s into the <see cref="OsuPlayfield"/> if they aren't already.
         /// </summary>
         /// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
-        private static Vector2 clampSliderToPlayfield(Slider slider, ObjectPositionInfoInternal objectPositionInfo)
+        private static Vector2 clampSliderToPlayfield(Slider slider, WorkingObject objectPositionInfo)
         {
             var possibleMovementBounds = calculatePossibleMovementBounds(slider);
 
@@ -319,17 +319,18 @@ namespace osu.Game.Rulesets.Osu.Utils
             }
         }
 
-        private class ObjectPositionInfoInternal : ObjectPositionInfo
+        private class WorkingObject
         {
             public Vector2 PositionOriginal { get; }
             public Vector2 PositionModified { get; set; }
             public Vector2 EndPositionModified { get; set; }
 
-            public ObjectPositionInfoInternal(ObjectPositionInfo original)
-                : base(original.HitObject)
+            public ObjectPositionInfo PositionInfo { get; }
+            public OsuHitObject HitObject => PositionInfo.HitObject;
+
+            public WorkingObject(ObjectPositionInfo positionInfo)
             {
-                RelativeAngle = original.RelativeAngle;
-                DistanceFromPrevious = original.DistanceFromPrevious;
+                PositionInfo = positionInfo;
                 PositionModified = PositionOriginal = HitObject.Position;
                 EndPositionModified = HitObject.EndPosition;
             }

From 76021c76278cf3a91a6f38e6b9b047a93f5dadad Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Mon, 14 Mar 2022 20:23:35 +0800
Subject: [PATCH 015/113] Remove extra parameters

---
 .../OsuHitObjectGenerationUtils_Reposition.cs | 41 ++++++++++---------
 1 file changed, 21 insertions(+), 20 deletions(-)

diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs
index 94f4f154bd..d1bc3b45df 100644
--- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs
+++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs
@@ -81,12 +81,12 @@ namespace osu.Game.Rulesets.Osu.Utils
 
                 switch (hitObject)
                 {
-                    case HitCircle circle:
-                        shift = clampHitCircleToPlayfield(circle, current);
+                    case HitCircle _:
+                        shift = clampHitCircleToPlayfield(current);
                         break;
 
-                    case Slider slider:
-                        shift = clampSliderToPlayfield(slider, current);
+                    case Slider _:
+                        shift = clampSliderToPlayfield(current);
                         break;
                 }
 
@@ -144,48 +144,49 @@ namespace osu.Game.Rulesets.Osu.Utils
         }
 
         /// <summary>
-        /// Move the modified position of a hit circle so that it fits inside the playfield.
+        /// Move the modified position of a <see cref="HitCircle"/> so that it fits inside the playfield.
         /// </summary>
         /// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
-        private static Vector2 clampHitCircleToPlayfield(HitCircle circle, WorkingObject objectPositionInfo)
+        private static Vector2 clampHitCircleToPlayfield(WorkingObject workingObject)
         {
-            var previousPosition = objectPositionInfo.PositionModified;
-            objectPositionInfo.EndPositionModified = objectPositionInfo.PositionModified = clampToPlayfieldWithPadding(
-                objectPositionInfo.PositionModified,
-                (float)circle.Radius
+            var previousPosition = workingObject.PositionModified;
+            workingObject.EndPositionModified = workingObject.PositionModified = clampToPlayfieldWithPadding(
+                workingObject.PositionModified,
+                (float)workingObject.HitObject.Radius
             );
 
-            circle.Position = objectPositionInfo.PositionModified;
+            workingObject.HitObject.Position = workingObject.PositionModified;
 
-            return objectPositionInfo.PositionModified - previousPosition;
+            return workingObject.PositionModified - previousPosition;
         }
 
         /// <summary>
         /// Moves the <see cref="Slider"/> and all necessary nested <see cref="OsuHitObject"/>s into the <see cref="OsuPlayfield"/> if they aren't already.
         /// </summary>
         /// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
-        private static Vector2 clampSliderToPlayfield(Slider slider, WorkingObject objectPositionInfo)
+        private static Vector2 clampSliderToPlayfield(WorkingObject workingObject)
         {
+            var slider = (Slider)workingObject.HitObject;
             var possibleMovementBounds = calculatePossibleMovementBounds(slider);
 
-            var previousPosition = objectPositionInfo.PositionModified;
+            var previousPosition = workingObject.PositionModified;
 
             // Clamp slider position to the placement area
             // If the slider is larger than the playfield, force it to stay at the original position
             float newX = possibleMovementBounds.Width < 0
-                ? objectPositionInfo.PositionOriginal.X
+                ? workingObject.PositionOriginal.X
                 : Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right);
 
             float newY = possibleMovementBounds.Height < 0
-                ? objectPositionInfo.PositionOriginal.Y
+                ? workingObject.PositionOriginal.Y
                 : Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom);
 
-            slider.Position = objectPositionInfo.PositionModified = new Vector2(newX, newY);
-            objectPositionInfo.EndPositionModified = slider.EndPosition;
+            slider.Position = workingObject.PositionModified = new Vector2(newX, newY);
+            workingObject.EndPositionModified = slider.EndPosition;
 
-            shiftNestedObjects(slider, objectPositionInfo.PositionModified - objectPositionInfo.PositionOriginal);
+            shiftNestedObjects(slider, workingObject.PositionModified - workingObject.PositionOriginal);
 
-            return objectPositionInfo.PositionModified - previousPosition;
+            return workingObject.PositionModified - previousPosition;
         }
 
         /// <summary>

From e44db4e726784b846c5d7b3b3213bfd59a8e16f6 Mon Sep 17 00:00:00 2001
From: Henry Lin <henry.ys.lin@gmail.com>
Date: Fri, 25 Mar 2022 15:13:25 +0800
Subject: [PATCH 016/113] Revert unintentional behavior change of random mod

Actually, using OsuPlayfield.BASE_SIZE.Y makes a touch more sense since it is the short side of the playfield, but I guess it is better to preserve replays than to introduce pointless breaking changes.
---
 osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
index 3c2c5d7759..fea9246035 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
@@ -48,12 +48,12 @@ namespace osu.Game.Rulesets.Osu.Mods
 
                 if (positionInfo == positionInfos.First())
                 {
-                    positionInfo.DistanceFromPrevious = (float)rng.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2;
+                    positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2);
                     positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
                 }
                 else
                 {
-                    positionInfo.RelativeAngle = (float)(rateOfChangeMultiplier * 2 * Math.PI * Math.Min(1f, positionInfo.DistanceFromPrevious / (playfield_diagonal * 0.5f)));
+                    positionInfo.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, positionInfo.DistanceFromPrevious / (playfield_diagonal * 0.5f));
                 }
             }
 

From de1fbda648a1ce13522b5f0a8faa602161df5e58 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sun, 27 Mar 2022 20:54:56 +0300
Subject: [PATCH 017/113] Clarify that searching includes both issues and Q&A
 discussions

---
 .github/ISSUE_TEMPLATE/bug-issue.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml
index 5b19c3732c..d77b28316a 100644
--- a/.github/ISSUE_TEMPLATE/bug-issue.yml
+++ b/.github/ISSUE_TEMPLATE/bug-issue.yml
@@ -9,7 +9,7 @@ body:
         Important to note that your issue may have already been reported before. Please check:
         - Pinned issues, at the top of https://github.com/ppy/osu/issues.
         - Current open `priority:0` issues, filterable [here](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Apriority%3A0).
-        - And most importantly, search for your issue. If you find that it already exists, respond with a reaction or add any further information that may be helpful.
+        - And most importantly, search across the [issue listing](https://github.com/ppy/osu/issues) and [Q&A discussion listing](https://github.com/ppy/osu/discussions/categories/q-a) for your issue. If you find that it already exists, respond with a reaction or add any further information that may be helpful.
 
   - type: dropdown
     attributes:

From f847f9a31592d98e5070eaad37ef8e48cf313ae9 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sun, 27 Mar 2022 20:57:00 +0300
Subject: [PATCH 018/113] Exclude "open osu! folder" logs procedure from mobile
 platforms

---
 .github/ISSUE_TEMPLATE/bug-issue.yml | 16 ++++++++++++----
 1 file changed, 12 insertions(+), 4 deletions(-)

diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml
index d77b28316a..8f54b5d1c8 100644
--- a/.github/ISSUE_TEMPLATE/bug-issue.yml
+++ b/.github/ISSUE_TEMPLATE/bug-issue.yml
@@ -48,19 +48,27 @@ body:
 
         Attaching log files is required for every reported bug. See instructions below on how to find them.
 
+        **Logs are reset when you reopen the game.** If the game crashed or has been closed since you found the bug, retrieve the logs using the file explorer instead.
+
+        ### Desktop platforms
+
         If the game has not yet been closed since you found the bug:
           1. Head on to game settings and click on "Open osu! folder"
           2. Then open the `logs` folder located there
 
-        **Logs are reset when you reopen the game.** If the game crashed or has been closed since you found the bug, retrieve the logs using the file explorer instead.
-
-        The default places to find the logs are as follows:
+        The default places to find the logs on desktop platforms are as follows:
           - `%AppData%/osu/logs` *on Windows*
           - `~/.local/share/osu/logs` *on Linux & macOS*
+
+        If you have selected a custom location for the game files, you can find the `logs` folder there.
+
+        ### Mobile platforms
+
+        The places to find the logs on mobile platforms are as follows:
           - `Android/data/sh.ppy.osulazer/files/logs` *on Android*
           - *On iOS*, they can be obtained by connecting your device to your desktop and copying the `logs` directory from the app's own document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer)
 
-        If you have selected a custom location for the game files, you can find the `logs` folder there.
+        ---
 
         After locating the `logs` folder, select all log files inside and drag them into the "Logs" box below.
 

From ebf520921520d486fbf99cd8c07b0e611b81fc0b Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sun, 27 Mar 2022 21:38:55 +0300
Subject: [PATCH 019/113] Reword issue searching note
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
---
 .github/ISSUE_TEMPLATE/bug-issue.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml
index 8f54b5d1c8..ea5ee298fb 100644
--- a/.github/ISSUE_TEMPLATE/bug-issue.yml
+++ b/.github/ISSUE_TEMPLATE/bug-issue.yml
@@ -9,7 +9,7 @@ body:
         Important to note that your issue may have already been reported before. Please check:
         - Pinned issues, at the top of https://github.com/ppy/osu/issues.
         - Current open `priority:0` issues, filterable [here](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Apriority%3A0).
-        - And most importantly, search across the [issue listing](https://github.com/ppy/osu/issues) and [Q&A discussion listing](https://github.com/ppy/osu/discussions/categories/q-a) for your issue. If you find that it already exists, respond with a reaction or add any further information that may be helpful.
+        - And most importantly, search for your issue both in the [issue listing](https://github.com/ppy/osu/issues) and the [Q&A discussion listing](https://github.com/ppy/osu/discussions/categories/q-a). If you find that it already exists, respond with a reaction or add any further information that may be helpful.
 
   - type: dropdown
     attributes:

From f049d7cb677e899f9984611ae19410b01a18c978 Mon Sep 17 00:00:00 2001
From: Jai Sharma <jai@jai.moe>
Date: Tue, 29 Mar 2022 21:36:08 +0100
Subject: [PATCH 020/113] Implement `ChatTextBox` for new chat design

Reference design: https://www.figma.com/file/f8b2dHp9LJCMOqYP4mdrPZ/Client%2FChat?node-id=1%3A297

Adds new component `ChatTextBox`.
Exposes `BindableBool` `ShowSearch` to change text input behaviour
between normal and search behaviour.

Adds new component `ChatTextBar`.
Exposes `BindableBool` `ShowSearch` which toggles between showing current
chat channel or search icon.
Additionally binds to child `ChatTextBox` components.
Requires a cached `Bindable<Channel>` instance to be managed by a parent
component.
---
 InspectCode.sh                                |   2 +-
 .../Visual/Online/TestSceneChatTextBox.cs     |  96 ++++++++++
 osu.Game/Overlays/Chat/ChatTextBar.cs         | 174 ++++++++++++++++++
 osu.Game/Overlays/Chat/ChatTextBox.cs         |  36 ++++
 4 files changed, 307 insertions(+), 1 deletion(-)
 create mode 100644 osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
 create mode 100644 osu.Game/Overlays/Chat/ChatTextBar.cs
 create mode 100644 osu.Game/Overlays/Chat/ChatTextBox.cs

diff --git a/InspectCode.sh b/InspectCode.sh
index cf2bc18175..5a72324dd4 100755
--- a/InspectCode.sh
+++ b/InspectCode.sh
@@ -2,5 +2,5 @@
 
 dotnet tool restore
 dotnet CodeFileSanity
-dotnet jb inspectcode "osu.Desktop.slnf" --output="inspectcodereport.xml" --caches-home="inspectcode" --verbosity=WARN
+dotnet jb inspectcode "osu.Game/osu.Game.csproj" --no-build --output="inspectcodereport.xml" --caches-home="inspectcode" --verbosity=WARN
 dotnet nvika parsereport "inspectcodereport.xml" --treatwarningsaserrors
diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
new file mode 100644
index 0000000000..e72a1d6652
--- /dev/null
+++ b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
@@ -0,0 +1,96 @@
+// 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 NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Online.Chat;
+using osu.Game.Overlays;
+using osu.Game.Overlays.Chat;
+
+namespace osu.Game.Tests.Visual.Online
+{
+    [TestFixture]
+    public class TestSceneChatTextBox : OsuTestScene
+    {
+        [Cached]
+        private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink);
+
+        [Cached]
+        private readonly Bindable<Channel> currentChannel = new Bindable<Channel>();
+
+        private OsuSpriteText commitText;
+        private ChatTextBar bar;
+
+        [SetUp]
+        public void SetUp()
+        {
+            Schedule(() =>
+            {
+                Child = new GridContainer
+                {
+                    Anchor = Anchor.Centre,
+                    Origin = Anchor.Centre,
+                    RelativeSizeAxes = Axes.X,
+                    AutoSizeAxes = Axes.Y,
+                    RowDimensions = new[]
+                    {
+                        new Dimension(GridSizeMode.Absolute, 30),
+                        new Dimension(GridSizeMode.AutoSize),
+                    },
+                    Content = new[]
+                    {
+                        new Drawable[]
+                        {
+                            commitText = new OsuSpriteText
+                            {
+                                Anchor = Anchor.TopCentre,
+                                Origin = Anchor.TopCentre,
+                                Font = OsuFont.Default.With(size: 20),
+                            },
+                        },
+                        new Drawable[]
+                        {
+                            bar = new ChatTextBar
+                            {
+                                Anchor = Anchor.TopCentre,
+                                Origin = Anchor.TopCentre,
+                                Width = 0.99f,
+                            },
+                        },
+                    },
+                };
+
+                bar.TextBox.OnCommit += (sender, newText) =>
+                {
+                    commitText.Text = $"Commit: {sender.Text}";
+                    commitText.FadeOutFromOne(1000, Easing.InQuint);
+                    sender.Text = string.Empty;
+                };
+            });
+        }
+
+        [Test]
+        public void TestVisual()
+        {
+            AddStep("Public Channel", () => currentChannel.Value = createPublicChannel("#osu"));
+            AddStep("Public Channel Long Name", () => currentChannel.Value = createPublicChannel("#public-channel-long-name"));
+            AddStep("Private Channel", () => currentChannel.Value = createPrivateChannel("peppy", 2));
+            AddStep("Private Long Name", () => currentChannel.Value = createPrivateChannel("test user long name", 3));
+
+            AddStep("Chat Mode Channel", () => bar.ShowSearch.Value = false);
+            AddStep("Chat Mode Search", () => bar.ShowSearch.Value = true);
+        }
+
+        private static Channel createPublicChannel(string name)
+            => new Channel { Name = name, Type = ChannelType.Public, Id = 1234 };
+
+        private static Channel createPrivateChannel(string username, int id)
+            => new Channel(new APIUser { Id = id, Username = username });
+    }
+}
diff --git a/osu.Game/Overlays/Chat/ChatTextBar.cs b/osu.Game/Overlays/Chat/ChatTextBar.cs
new file mode 100644
index 0000000000..00284fdd33
--- /dev/null
+++ b/osu.Game/Overlays/Chat/ChatTextBar.cs
@@ -0,0 +1,174 @@
+// 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.
+
+#nullable enable
+
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Online.Chat;
+using osuTK;
+
+namespace osu.Game.Overlays.Chat
+{
+    public class ChatTextBar : Container
+    {
+        public readonly BindableBool ShowSearch = new BindableBool();
+
+        public ChatTextBox TextBox => chatTextBox;
+
+        [Resolved]
+        private Bindable<Channel> currentChannel { get; set; } = null!;
+
+        private OsuTextFlowContainer chattingTextContainer = null!;
+        private Container searchIconContainer = null!;
+        private ChatTextBox chatTextBox = null!;
+        private Container enterContainer = null!;
+
+        private const float chatting_text_width = 180;
+        private const float search_icon_width = 40;
+
+        [BackgroundDependencyLoader]
+        private void load(OverlayColourProvider colourProvider)
+        {
+            RelativeSizeAxes = Axes.X;
+            Height = 60;
+
+            Children = new Drawable[]
+            {
+                new Box
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Colour = colourProvider.Background5,
+                },
+                new GridContainer
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    ColumnDimensions = new[]
+                    {
+                        new Dimension(GridSizeMode.AutoSize),
+                        new Dimension(GridSizeMode.AutoSize),
+                        new Dimension(),
+                        new Dimension(GridSizeMode.AutoSize),
+                    },
+                    Content = new[]
+                    {
+                        new Drawable[]
+                        {
+                            chattingTextContainer = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 20))
+                            {
+                                Masking = true,
+                                Width = chatting_text_width,
+                                Padding = new MarginPadding { Left = 10 },
+                                RelativeSizeAxes = Axes.Y,
+                                TextAnchor = Anchor.CentreRight,
+                                Anchor = Anchor.CentreLeft,
+                                Origin = Anchor.CentreLeft,
+                                Colour = colourProvider.Background1,
+                            },
+                            searchIconContainer = new Container
+                            {
+                                RelativeSizeAxes = Axes.Y,
+                                Width = search_icon_width,
+                                Child = new SpriteIcon
+                                {
+                                    Icon = FontAwesome.Solid.Search,
+                                    Origin = Anchor.CentreRight,
+                                    Anchor = Anchor.CentreRight,
+                                    Size = new Vector2(20),
+                                    Margin = new MarginPadding { Right = 2 },
+                                },
+                            },
+                            new Container
+                            {
+                                RelativeSizeAxes = Axes.Both,
+                                Padding = new MarginPadding { Right = 5 },
+                                Child = chatTextBox =  new ChatTextBox
+                                {
+                                    Anchor = Anchor.CentreLeft,
+                                    Origin = Anchor.CentreLeft,
+                                    RelativeSizeAxes = Axes.X,
+                                    ShowSearch = { BindTarget = ShowSearch },
+                                    HoldFocus = true,
+                                    ReleaseFocusOnCommit = false,
+                                },
+                            },
+                            enterContainer = new Container
+                            {
+                                Origin = Anchor.CentreLeft,
+                                Anchor = Anchor.CentreLeft,
+                                Masking = true,
+                                BorderColour = colourProvider.Background1,
+                                BorderThickness = 2,
+                                CornerRadius = 10,
+                                Margin = new MarginPadding { Right = 10 },
+                                Size = new Vector2(60, 30),
+                                Children = new Drawable[]
+                                {
+                                    new Box
+                                    {
+                                        RelativeSizeAxes = Axes.Both,
+                                        Colour = Colour4.Transparent,
+                                    },
+                                    new OsuSpriteText
+                                    {
+                                        Text = "Enter",
+                                        Origin = Anchor.Centre,
+                                        Anchor = Anchor.Centre,
+                                        Colour = colourProvider.Background1,
+                                        Font = OsuFont.Torus.With(size: 20),
+                                        Margin = new MarginPadding { Bottom = 2 },
+                                    },
+                                },
+                            },
+                        },
+                    },
+                },
+            };
+        }
+
+        protected override void LoadComplete()
+        {
+            base.LoadComplete();
+
+            ShowSearch.BindValueChanged(change =>
+            {
+                if (change.NewValue)
+                {
+                    chattingTextContainer.Hide();
+                    enterContainer.Hide();
+                    searchIconContainer.Show();
+                }
+                else
+                {
+                    chattingTextContainer.Show();
+                    enterContainer.Show();
+                    searchIconContainer.Hide();
+                }
+            }, true);
+
+            currentChannel.BindValueChanged(change =>
+            {
+                Channel newChannel = change.NewValue;
+                switch (newChannel?.Type)
+                {
+                    case ChannelType.Public:
+                        chattingTextContainer.Text = $"chatting in {newChannel.Name}";
+                        break;
+                    case ChannelType.PM:
+                        chattingTextContainer.Text = $"chatting with {newChannel.Name}";
+                        break;
+                    default:
+                        chattingTextContainer.Text = "";
+                        break;
+                }
+            }, true);
+        }
+    }
+}
diff --git a/osu.Game/Overlays/Chat/ChatTextBox.cs b/osu.Game/Overlays/Chat/ChatTextBox.cs
new file mode 100644
index 0000000000..35ed26cda3
--- /dev/null
+++ b/osu.Game/Overlays/Chat/ChatTextBox.cs
@@ -0,0 +1,36 @@
+// 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.
+
+#nullable enable
+
+using osu.Framework.Bindables;
+using osu.Game.Graphics.UserInterface;
+
+namespace osu.Game.Overlays.Chat
+{
+    public class ChatTextBox : FocusedTextBox
+    {
+        public readonly BindableBool ShowSearch = new BindableBool();
+
+        public override bool HandleLeftRightArrows => !ShowSearch.Value;
+
+        protected override void LoadComplete()
+        {
+            base.LoadComplete();
+
+            ShowSearch.BindValueChanged(change =>
+            {
+                PlaceholderText = change.NewValue ? "type here to search" : "type here";
+                Schedule(() => Text = string.Empty);
+            }, true);
+        }
+
+        protected override void Commit()
+        {
+            if (ShowSearch.Value)
+                return;
+
+            base.Commit();
+        }
+    }
+}

From 06c32aa1362c5f31d0ca068f9b2cf799d00997ad Mon Sep 17 00:00:00 2001
From: Jai Sharma <jai@jai.moe>
Date: Tue, 29 Mar 2022 22:50:24 +0100
Subject: [PATCH 021/113] Remove changes to `InspectCode.sh`

---
 InspectCode.sh | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InspectCode.sh b/InspectCode.sh
index 5a72324dd4..cf2bc18175 100755
--- a/InspectCode.sh
+++ b/InspectCode.sh
@@ -2,5 +2,5 @@
 
 dotnet tool restore
 dotnet CodeFileSanity
-dotnet jb inspectcode "osu.Game/osu.Game.csproj" --no-build --output="inspectcodereport.xml" --caches-home="inspectcode" --verbosity=WARN
+dotnet jb inspectcode "osu.Desktop.slnf" --output="inspectcodereport.xml" --caches-home="inspectcode" --verbosity=WARN
 dotnet nvika parsereport "inspectcodereport.xml" --treatwarningsaserrors

From e7d2d94eeef6b11cc13660855b8267247ffc202c Mon Sep 17 00:00:00 2001
From: Jai Sharma <jai@jai.moe>
Date: Wed, 30 Mar 2022 02:16:50 +0100
Subject: [PATCH 022/113] Fix code quality issues in `ChatTextBar`

---
 osu.Game/Overlays/Chat/ChatTextBar.cs | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/osu.Game/Overlays/Chat/ChatTextBar.cs b/osu.Game/Overlays/Chat/ChatTextBar.cs
index 00284fdd33..66f9f281c9 100644
--- a/osu.Game/Overlays/Chat/ChatTextBar.cs
+++ b/osu.Game/Overlays/Chat/ChatTextBar.cs
@@ -21,14 +21,13 @@ namespace osu.Game.Overlays.Chat
     {
         public readonly BindableBool ShowSearch = new BindableBool();
 
-        public ChatTextBox TextBox => chatTextBox;
+        public ChatTextBox TextBox { get; private set; } = null!;
 
         [Resolved]
         private Bindable<Channel> currentChannel { get; set; } = null!;
 
         private OsuTextFlowContainer chattingTextContainer = null!;
         private Container searchIconContainer = null!;
-        private ChatTextBox chatTextBox = null!;
         private Container enterContainer = null!;
 
         private const float chatting_text_width = 180;
@@ -89,7 +88,7 @@ namespace osu.Game.Overlays.Chat
                             {
                                 RelativeSizeAxes = Axes.Both,
                                 Padding = new MarginPadding { Right = 5 },
-                                Child = chatTextBox =  new ChatTextBox
+                                Child = TextBox = new ChatTextBox
                                 {
                                     Anchor = Anchor.CentreLeft,
                                     Origin = Anchor.CentreLeft,
@@ -156,16 +155,19 @@ namespace osu.Game.Overlays.Chat
             currentChannel.BindValueChanged(change =>
             {
                 Channel newChannel = change.NewValue;
+
                 switch (newChannel?.Type)
                 {
                     case ChannelType.Public:
                         chattingTextContainer.Text = $"chatting in {newChannel.Name}";
                         break;
+
                     case ChannelType.PM:
                         chattingTextContainer.Text = $"chatting with {newChannel.Name}";
                         break;
+
                     default:
-                        chattingTextContainer.Text = "";
+                        chattingTextContainer.Text = string.Empty;
                         break;
                 }
             }, true);

From eec3fef7a66382337c2c2ee99c7f28ebb951407e Mon Sep 17 00:00:00 2001
From: Jai Sharma <jai@jai.moe>
Date: Wed, 30 Mar 2022 20:25:23 +0100
Subject: [PATCH 023/113] Remove the enter box in `ChatTextBar`

---
 osu.Game/Overlays/Chat/ChatTextBar.cs | 32 ---------------------------
 1 file changed, 32 deletions(-)

diff --git a/osu.Game/Overlays/Chat/ChatTextBar.cs b/osu.Game/Overlays/Chat/ChatTextBar.cs
index 66f9f281c9..7ff1b8d1d3 100644
--- a/osu.Game/Overlays/Chat/ChatTextBar.cs
+++ b/osu.Game/Overlays/Chat/ChatTextBar.cs
@@ -28,7 +28,6 @@ namespace osu.Game.Overlays.Chat
 
         private OsuTextFlowContainer chattingTextContainer = null!;
         private Container searchIconContainer = null!;
-        private Container enterContainer = null!;
 
         private const float chatting_text_width = 180;
         private const float search_icon_width = 40;
@@ -54,7 +53,6 @@ namespace osu.Game.Overlays.Chat
                         new Dimension(GridSizeMode.AutoSize),
                         new Dimension(GridSizeMode.AutoSize),
                         new Dimension(),
-                        new Dimension(GridSizeMode.AutoSize),
                     },
                     Content = new[]
                     {
@@ -98,34 +96,6 @@ namespace osu.Game.Overlays.Chat
                                     ReleaseFocusOnCommit = false,
                                 },
                             },
-                            enterContainer = new Container
-                            {
-                                Origin = Anchor.CentreLeft,
-                                Anchor = Anchor.CentreLeft,
-                                Masking = true,
-                                BorderColour = colourProvider.Background1,
-                                BorderThickness = 2,
-                                CornerRadius = 10,
-                                Margin = new MarginPadding { Right = 10 },
-                                Size = new Vector2(60, 30),
-                                Children = new Drawable[]
-                                {
-                                    new Box
-                                    {
-                                        RelativeSizeAxes = Axes.Both,
-                                        Colour = Colour4.Transparent,
-                                    },
-                                    new OsuSpriteText
-                                    {
-                                        Text = "Enter",
-                                        Origin = Anchor.Centre,
-                                        Anchor = Anchor.Centre,
-                                        Colour = colourProvider.Background1,
-                                        Font = OsuFont.Torus.With(size: 20),
-                                        Margin = new MarginPadding { Bottom = 2 },
-                                    },
-                                },
-                            },
                         },
                     },
                 },
@@ -141,13 +111,11 @@ namespace osu.Game.Overlays.Chat
                 if (change.NewValue)
                 {
                     chattingTextContainer.Hide();
-                    enterContainer.Hide();
                     searchIconContainer.Show();
                 }
                 else
                 {
                     chattingTextContainer.Show();
-                    enterContainer.Show();
                     searchIconContainer.Hide();
                 }
             }, true);

From fff30e8a6ea20b9189f87547e86be90cd01ddfa7 Mon Sep 17 00:00:00 2001
From: Jai Sharma <jai@jai.moe>
Date: Wed, 30 Mar 2022 21:01:28 +0100
Subject: [PATCH 024/113] Simplify show/hide of text and search in
 `ChatTextBar`

---
 osu.Game/Overlays/Chat/ChatTextBar.cs | 14 ++++----------
 1 file changed, 4 insertions(+), 10 deletions(-)

diff --git a/osu.Game/Overlays/Chat/ChatTextBar.cs b/osu.Game/Overlays/Chat/ChatTextBar.cs
index 7ff1b8d1d3..d7edbb83b6 100644
--- a/osu.Game/Overlays/Chat/ChatTextBar.cs
+++ b/osu.Game/Overlays/Chat/ChatTextBar.cs
@@ -108,16 +108,10 @@ namespace osu.Game.Overlays.Chat
 
             ShowSearch.BindValueChanged(change =>
             {
-                if (change.NewValue)
-                {
-                    chattingTextContainer.Hide();
-                    searchIconContainer.Show();
-                }
-                else
-                {
-                    chattingTextContainer.Show();
-                    searchIconContainer.Hide();
-                }
+                bool showSearch = change.NewValue;
+
+                chattingTextContainer.FadeTo(showSearch ? 0 : 1);
+                searchIconContainer.FadeTo(showSearch ? 1 : 0);
             }, true);
 
             currentChannel.BindValueChanged(change =>

From 3ac0da2da305feac1ea30999e257027ffe37e51f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sat, 26 Mar 2022 21:33:52 +0100
Subject: [PATCH 025/113] Implement sheared toggle button

---
 .../TestSceneShearedToggleButton.cs           |  88 ++++++++++
 .../UserInterface/ShearedToggleButton.cs      | 155 ++++++++++++++++++
 2 files changed, 243 insertions(+)
 create mode 100644 osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs
 create mode 100644 osu.Game/Graphics/UserInterface/ShearedToggleButton.cs

diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs
new file mode 100644
index 0000000000..a969858157
--- /dev/null
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs
@@ -0,0 +1,88 @@
+// 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 NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Overlays;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.UserInterface
+{
+    [TestFixture]
+    public class TestSceneShearedToggleButton : OsuManualInputManagerTestScene
+    {
+        [Cached]
+        private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
+
+        [Test]
+        public void TestShearedToggleButton()
+        {
+            ShearedToggleButton button = null;
+
+            AddStep("create button", () =>
+            {
+                Child = button = new ShearedToggleButton(0.2f)
+                {
+                    Anchor = Anchor.Centre,
+                    Origin = Anchor.Centre,
+                    Text = "Toggle me",
+                    Width = 200
+                };
+            });
+
+            AddToggleStep("toggle button", active => button.Active.Value = active);
+            AddToggleStep("toggle disabled", disabled => button.Active.Disabled = disabled);
+        }
+
+        [Test]
+        public void TestDisabledState()
+        {
+            ShearedToggleButton button = null;
+
+            AddStep("create button", () =>
+            {
+                Child = button = new ShearedToggleButton(0.2f)
+                {
+                    Anchor = Anchor.Centre,
+                    Origin = Anchor.Centre,
+                    Text = "Toggle me",
+                    Width = 200
+                };
+            });
+
+            clickToggle();
+            assertToggleState(true);
+
+            clickToggle();
+            assertToggleState(false);
+
+            setToggleDisabledState(true);
+
+            assertToggleState(false);
+            clickToggle();
+            assertToggleState(false);
+
+            setToggleDisabledState(false);
+            assertToggleState(false);
+            clickToggle();
+            assertToggleState(true);
+
+            setToggleDisabledState(true);
+            assertToggleState(true);
+            clickToggle();
+            assertToggleState(true);
+
+            void clickToggle() => AddStep("click toggle", () =>
+            {
+                InputManager.MoveMouseTo(button);
+                InputManager.Click(MouseButton.Left);
+            });
+
+            void assertToggleState(bool active) => AddAssert($"toggle is {(active ? "" : "not ")}active", () => button.Active.Value == active);
+
+            void setToggleDisabledState(bool disabled) => AddStep($"{(disabled ? "disable" : "enable")} toggle", () => button.Active.Disabled = disabled);
+        }
+    }
+}
diff --git a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs
new file mode 100644
index 0000000000..acbed29279
--- /dev/null
+++ b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs
@@ -0,0 +1,155 @@
+// 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.
+
+#nullable enable
+
+using System;
+using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Audio.Sample;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Colour;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Input.Events;
+using osu.Framework.Localisation;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Overlays;
+using osuTK;
+
+namespace osu.Game.Graphics.UserInterface
+{
+    public class ShearedToggleButton : OsuClickableContainer
+    {
+        public BindableBool Active { get; } = new BindableBool();
+
+        public LocalisableString Text
+        {
+            get => text.Text;
+            set => text.Text = value;
+        }
+
+        private readonly Box background;
+        private readonly OsuSpriteText text;
+
+        private Sample? sampleOff;
+        private Sample? sampleOn;
+
+        [Resolved]
+        private OverlayColourProvider colourProvider { get; set; } = null!;
+
+        public ShearedToggleButton(float shear)
+        {
+            Height = 50;
+            Padding = new MarginPadding { Horizontal = shear * 50 };
+
+            Content.CornerRadius = 7;
+            Content.Shear = new Vector2(shear, 0);
+            Content.Masking = true;
+            Content.BorderThickness = 2;
+            Content.Anchor = Content.Origin = Anchor.Centre;
+
+            Children = new Drawable[]
+            {
+                background = new Box
+                {
+                    RelativeSizeAxes = Axes.Both
+                },
+                text = new OsuSpriteText
+                {
+                    Anchor = Anchor.Centre,
+                    Origin = Anchor.Centre,
+                    Font = OsuFont.TorusAlternate.With(size: 17),
+                    Shear = new Vector2(-shear, 0)
+                }
+            };
+        }
+
+        [BackgroundDependencyLoader]
+        private void load(AudioManager audio)
+        {
+            sampleOn = audio.Samples.Get(@"UI/check-on");
+            sampleOff = audio.Samples.Get(@"UI/check-off");
+        }
+
+        protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(sampleSet);
+
+        protected override void LoadComplete()
+        {
+            base.LoadComplete();
+
+            Active.BindValueChanged(_ =>
+            {
+                updateState();
+                playSample();
+            });
+            Active.BindDisabledChanged(disabled =>
+            {
+                updateState();
+                Action = disabled ? (Action?)null : Active.Toggle;
+            }, true);
+
+            FinishTransforms(true);
+        }
+
+        protected override bool OnHover(HoverEvent e)
+        {
+            updateState();
+            return base.OnHover(e);
+        }
+
+        protected override void OnHoverLost(HoverLostEvent e)
+        {
+            updateState();
+            base.OnHoverLost(e);
+        }
+
+        protected override bool OnMouseDown(MouseDownEvent e)
+        {
+            Content.ScaleTo(0.8f, 2000, Easing.OutQuint);
+            return base.OnMouseDown(e);
+        }
+
+        protected override void OnMouseUp(MouseUpEvent e)
+        {
+            Content.ScaleTo(1, 1000, Easing.OutElastic);
+            base.OnMouseUp(e);
+        }
+
+        private void updateState()
+        {
+            var darkerColour = Active.Value ? colourProvider.Highlight1 : colourProvider.Background3;
+            var lighterColour = Active.Value ? colourProvider.Colour0 : colourProvider.Background1;
+
+            if (Active.Disabled)
+            {
+                darkerColour = darkerColour.Darken(0.3f);
+                lighterColour = lighterColour.Darken(0.3f);
+            }
+            else if (IsHovered)
+            {
+                darkerColour = darkerColour.Lighten(0.3f);
+                lighterColour = lighterColour.Lighten(0.3f);
+            }
+
+            background.FadeColour(darkerColour, 150, Easing.OutQuint);
+            Content.TransformTo(nameof(BorderColour), ColourInfo.GradientVertical(darkerColour, lighterColour), 150, Easing.OutQuint);
+
+            var textColour = Active.Value ? colourProvider.Background6 : colourProvider.Content1;
+            if (Active.Disabled)
+                textColour = textColour.Opacity(0.6f);
+
+            text.FadeColour(textColour, 150, Easing.OutQuint);
+        }
+
+        private void playSample()
+        {
+            if (Active.Value)
+                sampleOn?.Play();
+            else
+                sampleOff?.Play();
+        }
+    }
+}

From 40b6f3ff0a1f21da5f2b98b7ed6398906e8500c0 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Thu, 31 Mar 2022 15:09:06 +0900
Subject: [PATCH 026/113] Rename method to CalculateAllLegacyCombinations()

---
 osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
index 0935f26de6..b5aec0d659 100644
--- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
+++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
@@ -121,10 +121,10 @@ namespace osu.Game.Rulesets.Difficulty
         /// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap.
         /// </summary>
         /// <remarks>
-        /// This should only be used to compute difficulties for legacy mod combinations.
+        /// This can only be used to compute difficulties for legacy mod combinations.
         /// </remarks>
         /// <returns>A collection of structures describing the difficulty of the beatmap for each mod combination.</returns>
-        public IEnumerable<DifficultyAttributes> CalculateAll(CancellationToken cancellationToken = default)
+        public IEnumerable<DifficultyAttributes> CalculateAllLegacyCombinations(CancellationToken cancellationToken = default)
         {
             var rulesetInstance = ruleset.CreateInstance();
 

From 0a34ce2509d1df942034704a9baa057010b1af47 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 31 Mar 2022 19:07:17 +0900
Subject: [PATCH 027/113] Increase font weight for runtime clock

Fonts this small are required to be `SemiBold` by design guidelines.
Somehow missed this.
---
 osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs
index 090f8c4a0f..81a362450c 100644
--- a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs
+++ b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs
@@ -6,7 +6,6 @@ using osu.Framework.Allocation;
 using osu.Framework.Graphics;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Sprites;
-using osuTK;
 
 namespace osu.Game.Overlays.Toolbar
 {
@@ -42,7 +41,7 @@ namespace osu.Game.Overlays.Toolbar
                 {
                     Y = 14,
                     Colour = colours.PinkLight,
-                    Scale = new Vector2(0.6f)
+                    Font = OsuFont.Default.With(size: 10, weight: FontWeight.SemiBold),
                 }
             };
 

From f1aa60c0f1c2fec417db5bc05d8d6dd2eb3dd391 Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Thu, 31 Mar 2022 16:26:53 +0200
Subject: [PATCH 028/113] Make the clock feel more like a button

---
 osu.Game/Overlays/Toolbar/ToolbarClock.cs | 92 +++++++++++++++++------
 1 file changed, 68 insertions(+), 24 deletions(-)

diff --git a/osu.Game/Overlays/Toolbar/ToolbarClock.cs b/osu.Game/Overlays/Toolbar/ToolbarClock.cs
index ad5c9ac7a1..02230c13cf 100644
--- a/osu.Game/Overlays/Toolbar/ToolbarClock.cs
+++ b/osu.Game/Overlays/Toolbar/ToolbarClock.cs
@@ -3,54 +3,83 @@
 
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
+using osu.Framework.Extensions.Color4Extensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
 using osu.Framework.Input.Events;
 using osu.Game.Configuration;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.UserInterface;
 using osuTK;
+using osuTK.Graphics;
+
 
 namespace osu.Game.Overlays.Toolbar
 {
-    public class ToolbarClock : CompositeDrawable
+    public class ToolbarClock : OsuClickableContainer
     {
         private Bindable<ToolbarClockDisplayMode> clockDisplayMode;
 
+        protected Box HoverBackground;
+        private readonly Box flashBackground;
+        private readonly FillFlowContainer clockContainer;
+
         private DigitalClockDisplay digital;
         private AnalogClockDisplay analog;
 
         public ToolbarClock()
+            : base(HoverSampleSet.Toolbar)
         {
             RelativeSizeAxes = Axes.Y;
             AutoSizeAxes = Axes.X;
 
-            Padding = new MarginPadding(10);
+            Children = new Drawable[]
+            {
+                HoverBackground = new Box
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Colour = OsuColour.Gray(80).Opacity(180),
+                    Blending = BlendingParameters.Additive,
+                    Alpha = 0,
+                },
+                flashBackground = new Box
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Alpha = 0,
+                    Colour = Color4.White.Opacity(100),
+                    Blending = BlendingParameters.Additive,
+                },
+
+                clockContainer = new FillFlowContainer
+                {
+                    RelativeSizeAxes = Axes.Y,
+                    AutoSizeAxes = Axes.X,
+                    Direction = FillDirection.Horizontal,
+                    Spacing = new Vector2(5),
+                    Padding = new MarginPadding(10),
+                    Children = new Drawable[]
+                    {
+                        analog = new AnalogClockDisplay
+                        {
+                            Anchor = Anchor.CentreLeft,
+                            Origin = Anchor.CentreLeft,
+                        },
+                        digital = new DigitalClockDisplay
+                        {
+                            Anchor = Anchor.CentreLeft,
+                            Origin = Anchor.CentreLeft,
+                        }
+                    }
+                }
+            };
         }
 
         [BackgroundDependencyLoader]
         private void load(OsuConfigManager config)
         {
             clockDisplayMode = config.GetBindable<ToolbarClockDisplayMode>(OsuSetting.ToolbarClockDisplayMode);
-
-            InternalChild = new FillFlowContainer
-            {
-                RelativeSizeAxes = Axes.Y,
-                AutoSizeAxes = Axes.X,
-                Direction = FillDirection.Horizontal,
-                Spacing = new Vector2(5),
-                Children = new Drawable[]
-                {
-                    analog = new AnalogClockDisplay
-                    {
-                        Anchor = Anchor.CentreLeft,
-                        Origin = Anchor.CentreLeft,
-                    },
-                    digital = new DigitalClockDisplay
-                    {
-                        Anchor = Anchor.CentreLeft,
-                        Origin = Anchor.CentreLeft,
-                    }
-                }
-            };
         }
 
         protected override void LoadComplete()
@@ -72,8 +101,23 @@ namespace osu.Game.Overlays.Toolbar
 
         protected override bool OnClick(ClickEvent e)
         {
+            flashBackground.FadeOutFromOne(800, Easing.OutQuint);
+
             cycleDisplayMode();
-            return true;
+
+            return base.OnClick(e);
+        }
+
+        protected override bool OnHover(HoverEvent e)
+        {
+            HoverBackground.FadeIn(200);
+
+            return base.OnHover(e);
+        }
+
+        protected override void OnHoverLost(HoverLostEvent e)
+        {
+            HoverBackground.FadeOut(200);
         }
 
         private void cycleDisplayMode()

From c64a90b39e86a38b7ce5a54ff950ee8f7b986321 Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Thu, 31 Mar 2022 17:21:50 +0200
Subject: [PATCH 029/113] Remove a newline to comply with codefactor

---
 osu.Game/Overlays/Toolbar/ToolbarClock.cs | 1 -
 1 file changed, 1 deletion(-)

diff --git a/osu.Game/Overlays/Toolbar/ToolbarClock.cs b/osu.Game/Overlays/Toolbar/ToolbarClock.cs
index 02230c13cf..48de803b3a 100644
--- a/osu.Game/Overlays/Toolbar/ToolbarClock.cs
+++ b/osu.Game/Overlays/Toolbar/ToolbarClock.cs
@@ -15,7 +15,6 @@ using osu.Game.Graphics.UserInterface;
 using osuTK;
 using osuTK.Graphics;
 
-
 namespace osu.Game.Overlays.Toolbar
 {
     public class ToolbarClock : OsuClickableContainer

From 52d723aaa6ee71876054fcb240f8f2bcc182338a Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Thu, 31 Mar 2022 20:11:07 +0200
Subject: [PATCH 030/113] Remove BPM slider

---
 osu.Game/Screens/Edit/Timing/TimingSection.cs | 50 -------------------
 1 file changed, 50 deletions(-)

diff --git a/osu.Game/Screens/Edit/Timing/TimingSection.cs b/osu.Game/Screens/Edit/Timing/TimingSection.cs
index cd0b56d338..f3c468de4c 100644
--- a/osu.Game/Screens/Edit/Timing/TimingSection.cs
+++ b/osu.Game/Screens/Edit/Timing/TimingSection.cs
@@ -13,7 +13,6 @@ namespace osu.Game.Screens.Edit.Timing
 {
     internal class TimingSection : Section<TimingControlPoint>
     {
-        private SettingsSlider<double> bpmSlider;
         private LabelledTimeSignature timeSignature;
         private BPMTextBox bpmTextEntry;
 
@@ -23,7 +22,6 @@ namespace osu.Game.Screens.Edit.Timing
             Flow.AddRange(new Drawable[]
             {
                 bpmTextEntry = new BPMTextBox(),
-                bpmSlider = new BPMSlider(),
                 timeSignature = new LabelledTimeSignature
                 {
                     Label = "Time Signature"
@@ -35,9 +33,6 @@ namespace osu.Game.Screens.Edit.Timing
         {
             if (point.NewValue != null)
             {
-                bpmSlider.Current = point.NewValue.BeatLengthBindable;
-                bpmSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
-
                 bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable;
                 // no need to hook change handler here as it's the same bindable as above
 
@@ -102,51 +97,6 @@ namespace osu.Game.Screens.Edit.Timing
             }
         }
 
-        private class BPMSlider : SettingsSlider<double>
-        {
-            private const double sane_minimum = 60;
-            private const double sane_maximum = 240;
-
-            private readonly BindableNumber<double> beatLengthBindable = new TimingControlPoint().BeatLengthBindable;
-
-            private readonly BindableDouble bpmBindable = new BindableDouble(60000 / TimingControlPoint.DEFAULT_BEAT_LENGTH)
-            {
-                MinValue = sane_minimum,
-                MaxValue = sane_maximum,
-            };
-
-            public BPMSlider()
-            {
-                beatLengthBindable.BindValueChanged(beatLength => updateCurrent(beatLengthToBpm(beatLength.NewValue)), true);
-                bpmBindable.BindValueChanged(bpm => beatLengthBindable.Value = beatLengthToBpm(bpm.NewValue));
-
-                base.Current = bpmBindable;
-
-                TransferValueOnCommit = true;
-            }
-
-            public override Bindable<double> Current
-            {
-                get => base.Current;
-                set
-                {
-                    // incoming will be beat length, not bpm
-                    beatLengthBindable.UnbindBindings();
-                    beatLengthBindable.BindTo(value);
-                }
-            }
-
-            private void updateCurrent(double newValue)
-            {
-                // we use a more sane range for the slider display unless overridden by the user.
-                // if a value comes in outside our range, we should expand temporarily.
-                bpmBindable.MinValue = Math.Min(newValue, sane_minimum);
-                bpmBindable.MaxValue = Math.Max(newValue, sane_maximum);
-
-                bpmBindable.Value = newValue;
-            }
-        }
-
         private static double beatLengthToBpm(double beatLength) => 60000 / beatLength;
     }
 }

From bdb21b17f721ab0a9b9f302439cd62033eafae14 Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Thu, 31 Mar 2022 20:39:26 +0200
Subject: [PATCH 031/113] Fix my code according to the changes @bdach requested

---
 osu.Game/Overlays/Toolbar/ToolbarClock.cs | 28 +++++++++++------------
 1 file changed, 14 insertions(+), 14 deletions(-)

diff --git a/osu.Game/Overlays/Toolbar/ToolbarClock.cs b/osu.Game/Overlays/Toolbar/ToolbarClock.cs
index 48de803b3a..22a96603dc 100644
--- a/osu.Game/Overlays/Toolbar/ToolbarClock.cs
+++ b/osu.Game/Overlays/Toolbar/ToolbarClock.cs
@@ -21,9 +21,8 @@ namespace osu.Game.Overlays.Toolbar
     {
         private Bindable<ToolbarClockDisplayMode> clockDisplayMode;
 
-        protected Box HoverBackground;
-        private readonly Box flashBackground;
-        private readonly FillFlowContainer clockContainer;
+        private Box hoverBackground;
+        private Box flashBackground;
 
         private DigitalClockDisplay digital;
         private AnalogClockDisplay analog;
@@ -33,10 +32,16 @@ namespace osu.Game.Overlays.Toolbar
         {
             RelativeSizeAxes = Axes.Y;
             AutoSizeAxes = Axes.X;
+        }
+
+        [BackgroundDependencyLoader]
+        private void load(OsuConfigManager config)
+        {
+            clockDisplayMode = config.GetBindable<ToolbarClockDisplayMode>(OsuSetting.ToolbarClockDisplayMode);
 
             Children = new Drawable[]
             {
-                HoverBackground = new Box
+                hoverBackground = new Box
                 {
                     RelativeSizeAxes = Axes.Both,
                     Colour = OsuColour.Gray(80).Opacity(180),
@@ -50,8 +55,7 @@ namespace osu.Game.Overlays.Toolbar
                     Colour = Color4.White.Opacity(100),
                     Blending = BlendingParameters.Additive,
                 },
-
-                clockContainer = new FillFlowContainer
+                new FillFlowContainer
                 {
                     RelativeSizeAxes = Axes.Y,
                     AutoSizeAxes = Axes.X,
@@ -75,12 +79,6 @@ namespace osu.Game.Overlays.Toolbar
             };
         }
 
-        [BackgroundDependencyLoader]
-        private void load(OsuConfigManager config)
-        {
-            clockDisplayMode = config.GetBindable<ToolbarClockDisplayMode>(OsuSetting.ToolbarClockDisplayMode);
-        }
-
         protected override void LoadComplete()
         {
             base.LoadComplete();
@@ -109,14 +107,16 @@ namespace osu.Game.Overlays.Toolbar
 
         protected override bool OnHover(HoverEvent e)
         {
-            HoverBackground.FadeIn(200);
+            hoverBackground.FadeIn(200);
 
             return base.OnHover(e);
         }
 
         protected override void OnHoverLost(HoverLostEvent e)
         {
-            HoverBackground.FadeOut(200);
+            hoverBackground.FadeOut(200);
+
+            base.OnHoverLost(e);
         }
 
         private void cycleDisplayMode()

From a6875383fcd5da6d50304bc40a660f35112e3f3f Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Thu, 31 Mar 2022 21:06:05 +0200
Subject: [PATCH 032/113] Rebind `SaveState()` to bpmTextEntry

---
 osu.Game/Screens/Edit/Timing/TimingSection.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Screens/Edit/Timing/TimingSection.cs b/osu.Game/Screens/Edit/Timing/TimingSection.cs
index f3c468de4c..07f8f2ba3a 100644
--- a/osu.Game/Screens/Edit/Timing/TimingSection.cs
+++ b/osu.Game/Screens/Edit/Timing/TimingSection.cs
@@ -34,7 +34,7 @@ namespace osu.Game.Screens.Edit.Timing
             if (point.NewValue != null)
             {
                 bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable;
-                // no need to hook change handler here as it's the same bindable as above
+                bpmTextEntry.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
 
                 timeSignature.Current = point.NewValue.TimeSignatureBindable;
                 timeSignature.Current.BindValueChanged(_ => ChangeHandler?.SaveState());

From e14d5b8adbc26bff6b9f9f184a9a9444ba18bb8d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Thu, 31 Mar 2022 21:20:30 +0200
Subject: [PATCH 033/113] Remove unused using directives

---
 osu.Game/Screens/Edit/Timing/TimingSection.cs | 2 --
 1 file changed, 2 deletions(-)

diff --git a/osu.Game/Screens/Edit/Timing/TimingSection.cs b/osu.Game/Screens/Edit/Timing/TimingSection.cs
index 07f8f2ba3a..13af04cd4b 100644
--- a/osu.Game/Screens/Edit/Timing/TimingSection.cs
+++ b/osu.Game/Screens/Edit/Timing/TimingSection.cs
@@ -1,13 +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;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Game.Beatmaps.ControlPoints;
 using osu.Game.Graphics.UserInterfaceV2;
-using osu.Game.Overlays.Settings;
 
 namespace osu.Game.Screens.Edit.Timing
 {

From b3896257ca0721e50ce5e21f8572ade3da40dccc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Thu, 31 Mar 2022 22:09:03 +0200
Subject: [PATCH 034/113] Move shear amount to constant

---
 .../Visual/UserInterface/TestSceneShearedToggleButton.cs      | 4 ++--
 osu.Game/Graphics/UserInterface/ShearedToggleButton.cs        | 4 +++-
 2 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs
index a969858157..5082e93f37 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs
@@ -23,7 +23,7 @@ namespace osu.Game.Tests.Visual.UserInterface
 
             AddStep("create button", () =>
             {
-                Child = button = new ShearedToggleButton(0.2f)
+                Child = button = new ShearedToggleButton
                 {
                     Anchor = Anchor.Centre,
                     Origin = Anchor.Centre,
@@ -43,7 +43,7 @@ namespace osu.Game.Tests.Visual.UserInterface
 
             AddStep("create button", () =>
             {
-                Child = button = new ShearedToggleButton(0.2f)
+                Child = button = new ShearedToggleButton
                 {
                     Anchor = Anchor.Centre,
                     Origin = Anchor.Centre,
diff --git a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs
index acbed29279..27d2611983 100644
--- a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs
+++ b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs
@@ -37,10 +37,12 @@ namespace osu.Game.Graphics.UserInterface
         private Sample? sampleOff;
         private Sample? sampleOn;
 
+        private const float shear = 0.2f;
+
         [Resolved]
         private OverlayColourProvider colourProvider { get; set; } = null!;
 
-        public ShearedToggleButton(float shear)
+        public ShearedToggleButton()
         {
             Height = 50;
             Padding = new MarginPadding { Horizontal = shear * 50 };

From e180db145dcee071d8e559345a1bdb25f66856ec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Thu, 31 Mar 2022 22:19:08 +0200
Subject: [PATCH 035/113] Add constructor argument to facilitate fixed
 width/autosizing

---
 .../TestSceneShearedToggleButton.cs           | 25 +++++++++++++++++--
 .../UserInterface/ShearedToggleButton.cs      | 22 +++++++++++++++-
 2 files changed, 44 insertions(+), 3 deletions(-)

diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs
index 5082e93f37..f12cbc4979 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs
@@ -23,12 +23,11 @@ namespace osu.Game.Tests.Visual.UserInterface
 
             AddStep("create button", () =>
             {
-                Child = button = new ShearedToggleButton
+                Child = button = new ShearedToggleButton(200)
                 {
                     Anchor = Anchor.Centre,
                     Origin = Anchor.Centre,
                     Text = "Toggle me",
-                    Width = 200
                 };
             });
 
@@ -36,6 +35,28 @@ namespace osu.Game.Tests.Visual.UserInterface
             AddToggleStep("toggle disabled", disabled => button.Active.Disabled = disabled);
         }
 
+        [Test]
+        public void TestSizing()
+        {
+            ShearedToggleButton toggleButton = null;
+
+            AddStep("create fixed width button", () => Child = toggleButton = new ShearedToggleButton(200)
+            {
+                Anchor = Anchor.Centre,
+                Origin = Anchor.Centre,
+                Text = "Fixed width"
+            });
+            AddStep("change text", () => toggleButton.Text = "New text");
+
+            AddStep("create auto-sizing button", () => Child = toggleButton = new ShearedToggleButton
+            {
+                Anchor = Anchor.Centre,
+                Origin = Anchor.Centre,
+                Text = "This button autosizes to its text!"
+            });
+            AddStep("change text", () => toggleButton.Text = "New text");
+        }
+
         [Test]
         public void TestDisabledState()
         {
diff --git a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs
index 27d2611983..aed3be20a0 100644
--- a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs
+++ b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs
@@ -42,7 +42,17 @@ namespace osu.Game.Graphics.UserInterface
         [Resolved]
         private OverlayColourProvider colourProvider { get; set; } = null!;
 
-        public ShearedToggleButton()
+        /// <summary>
+        /// Creates a new <see cref="ShearedToggleButton"/>
+        /// </summary>
+        /// <param name="width">
+        /// The width of the button.
+        /// <list type="bullet">
+        /// <item>If a non-<see langword="null"/> value is provided, this button will have a fixed width equal to the provided value.</item>
+        /// <item>If a <see langword="null"/> value is provided (or the argument is omitted entirely), the button will autosize in width to fit the text.</item>
+        /// </list>
+        /// </param>
+        public ShearedToggleButton(float? width = null)
         {
             Height = 50;
             Padding = new MarginPadding { Horizontal = shear * 50 };
@@ -67,6 +77,16 @@ namespace osu.Game.Graphics.UserInterface
                     Shear = new Vector2(-shear, 0)
                 }
             };
+
+            if (width != null)
+            {
+                Width = width.Value;
+            }
+            else
+            {
+                AutoSizeAxes = Axes.X;
+                text.Margin = new MarginPadding { Horizontal = 15 };
+            }
         }
 
         [BackgroundDependencyLoader]

From 058350dfd8bf7c7754ccc25bb20357185a750817 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 10:53:14 +0900
Subject: [PATCH 036/113] Fix failing test due to incorrect sizing
 specification

---
 .../Visual/UserInterface/TestSceneShearedToggleButton.cs       | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs
index f12cbc4979..b5109aa58d 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs
@@ -64,12 +64,11 @@ namespace osu.Game.Tests.Visual.UserInterface
 
             AddStep("create button", () =>
             {
-                Child = button = new ShearedToggleButton
+                Child = button = new ShearedToggleButton(200)
                 {
                     Anchor = Anchor.Centre,
                     Origin = Anchor.Centre,
                     Text = "Toggle me",
-                    Width = 200
                 };
             });
 

From a987cda30daa1a235b035fafeb4add95618ddcc8 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 12:15:26 +0900
Subject: [PATCH 037/113] Rename "Aim Assist" to "Magnetised" to better suit
 the mod's behaviour

As proposed in https://github.com/ppy/osu/discussions/17375.
---
 ...uModAimAssist.cs => TestSceneOsuModMagnetised.cs} |  6 +++---
 osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs        |  2 +-
 osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs         |  2 +-
 osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs           |  2 +-
 .../Mods/{OsuModAimAssist.cs => OsuModMagnetised.cs} | 12 ++++++------
 osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs            |  2 +-
 osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs        |  2 +-
 osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs           |  2 +-
 osu.Game.Rulesets.Osu/OsuRuleset.cs                  |  2 +-
 9 files changed, 16 insertions(+), 16 deletions(-)
 rename osu.Game.Rulesets.Osu.Tests/Mods/{TestSceneOsuModAimAssist.cs => TestSceneOsuModMagnetised.cs} (79%)
 rename osu.Game.Rulesets.Osu/Mods/{OsuModAimAssist.cs => OsuModMagnetised.cs} (87%)

diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAimAssist.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs
similarity index 79%
rename from osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAimAssist.cs
rename to osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs
index b8310bc4e7..e7a40d6337 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAimAssist.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs
@@ -6,16 +6,16 @@ using osu.Game.Rulesets.Osu.Mods;
 
 namespace osu.Game.Rulesets.Osu.Tests.Mods
 {
-    public class TestSceneOsuModAimAssist : OsuModTestScene
+    public class TestSceneOsuModMagnetised : OsuModTestScene
     {
         [TestCase(0.1f)]
         [TestCase(0.5f)]
         [TestCase(1)]
-        public void TestAimAssist(float strength)
+        public void TestMagnetised(float strength)
         {
             CreateModTest(new ModTestData
             {
-                Mod = new OsuModAimAssist
+                Mod = new OsuModMagnetised
                 {
                     AssistStrength = { Value = strength },
                 },
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs
index 983964d639..aaf455e95f 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs
@@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Mods
         public override ModType Type => ModType.Automation;
         public override string Description => @"Automatic cursor movement - just follow the rhythm.";
         public override double ScoreMultiplier => 1;
-        public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModAimAssist) };
+        public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModMagnetised) };
 
         public bool PerformFail() => false;
 
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs
index 31179cdf4a..b31ef5d2fd 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs
@@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods
 {
     public class OsuModAutoplay : ModAutoplay
     {
-        public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAimAssist), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray();
+        public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray();
 
         public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods)
             => new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" });
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs
index d677ab43d0..5b42772358 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs
@@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Mods
 {
     public class OsuModCinema : ModCinema<OsuHitObject>
     {
-        public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAimAssist), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray();
+        public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray();
 
         public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods)
             => new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" });
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs
similarity index 87%
rename from osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs
rename to osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs
index 1abbd67d8f..31598c50e7 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs
@@ -16,19 +16,19 @@ using osuTK;
 
 namespace osu.Game.Rulesets.Osu.Mods
 {
-    internal class OsuModAimAssist : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset<OsuHitObject>
+    internal class OsuModMagnetised : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset<OsuHitObject>
     {
-        public override string Name => "Aim Assist";
-        public override string Acronym => "AA";
-        public override IconUsage? Icon => FontAwesome.Solid.MousePointer;
+        public override string Name => "Magnetised";
+        public override string Acronym => "MG";
+        public override IconUsage? Icon => FontAwesome.Solid.Magnet;
         public override ModType Type => ModType.Fun;
-        public override string Description => "No need to chase the circle – the circle chases you!";
+        public override string Description => "No need to chase the circles – your cursor is a magnet!";
         public override double ScoreMultiplier => 1;
         public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax) };
 
         private IFrameStableClock gameplayClock;
 
-        [SettingSource("Assist strength", "How much this mod will assist you.", 0)]
+        [SettingSource("Attraction strength", "How strong the pull is.", 0)]
         public BindableFloat AssistStrength { get; } = new BindableFloat(0.5f)
         {
             Precision = 0.05f,
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs
index 9719de441e..6b81efdca6 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs
@@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods
     public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset<OsuHitObject>, IApplicableToPlayer
     {
         public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things.";
-        public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModAimAssist) }).ToArray();
+        public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised) }).ToArray();
 
         /// <summary>
         /// How early before a hitobject's start time to trigger a hit.
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs
index 28c3b069b6..45ce4d555a 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs
@@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods
         public override ModType Type => ModType.Fun;
         public override string Description => "Everything rotates. EVERYTHING.";
         public override double ScoreMultiplier => 1;
-        public override Type[] IncompatibleMods => new[] { typeof(OsuModWiggle), typeof(OsuModAimAssist) };
+        public override Type[] IncompatibleMods => new[] { typeof(OsuModWiggle), typeof(OsuModMagnetised) };
 
         private float theta;
 
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs
index 40a05400ea..693a5bee0b 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs
@@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods
         public override ModType Type => ModType.Fun;
         public override string Description => "They just won't stay still...";
         public override double ScoreMultiplier => 1;
-        public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModAimAssist) };
+        public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModMagnetised) };
 
         private const int wiggle_duration = 90; // (ms) Higher = fewer wiggles
         private const int wiggle_strength = 10; // Higher = stronger wiggles
diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs
index 47a2618ddd..207e7a4ab0 100644
--- a/osu.Game.Rulesets.Osu/OsuRuleset.cs
+++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs
@@ -195,7 +195,7 @@ namespace osu.Game.Rulesets.Osu
                         new OsuModApproachDifferent(),
                         new OsuModMuted(),
                         new OsuModNoScope(),
-                        new OsuModAimAssist(),
+                        new OsuModMagnetised(),
                         new ModAdaptiveSpeed()
                     };
 

From ea672745b0d026ce7ca08d45239dca083e614635 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 12:44:49 +0900
Subject: [PATCH 038/113] Add ability to switch between most common tournament
 scenes using key bindings

---
 osu.Game.Tournament/TournamentSceneManager.cs | 66 ++++++++++++++++---
 1 file changed, 58 insertions(+), 8 deletions(-)

diff --git a/osu.Game.Tournament/TournamentSceneManager.cs b/osu.Game.Tournament/TournamentSceneManager.cs
index 80a9c07cde..98338244e4 100644
--- a/osu.Game.Tournament/TournamentSceneManager.cs
+++ b/osu.Game.Tournament/TournamentSceneManager.cs
@@ -7,8 +7,10 @@ using osu.Framework.Allocation;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
+using osu.Framework.Input.Events;
 using osu.Framework.Threading;
 using osu.Game.Graphics;
+using osu.Game.Graphics.Sprites;
 using osu.Game.Tournament.Components;
 using osu.Game.Tournament.Screens;
 using osu.Game.Tournament.Screens.Drawings;
@@ -23,6 +25,7 @@ using osu.Game.Tournament.Screens.TeamIntro;
 using osu.Game.Tournament.Screens.TeamWin;
 using osuTK;
 using osuTK.Graphics;
+using osuTK.Input;
 
 namespace osu.Game.Tournament
 {
@@ -123,16 +126,16 @@ namespace osu.Game.Tournament
                                 new ScreenButton(typeof(RoundEditorScreen)) { Text = "Rounds Editor", RequestSelection = SetScreen },
                                 new ScreenButton(typeof(LadderEditorScreen)) { Text = "Bracket Editor", RequestSelection = SetScreen },
                                 new Separator(),
-                                new ScreenButton(typeof(ScheduleScreen)) { Text = "Schedule", RequestSelection = SetScreen },
-                                new ScreenButton(typeof(LadderScreen)) { Text = "Bracket", RequestSelection = SetScreen },
+                                new ScreenButton(typeof(ScheduleScreen), Key.S) { Text = "Schedule", RequestSelection = SetScreen },
+                                new ScreenButton(typeof(LadderScreen), Key.B) { Text = "Bracket", RequestSelection = SetScreen },
                                 new Separator(),
-                                new ScreenButton(typeof(TeamIntroScreen)) { Text = "Team Intro", RequestSelection = SetScreen },
-                                new ScreenButton(typeof(SeedingScreen)) { Text = "Seeding", RequestSelection = SetScreen },
+                                new ScreenButton(typeof(TeamIntroScreen), Key.I) { Text = "Team Intro", RequestSelection = SetScreen },
+                                new ScreenButton(typeof(SeedingScreen), Key.D) { Text = "Seeding", RequestSelection = SetScreen },
                                 new Separator(),
-                                new ScreenButton(typeof(MapPoolScreen)) { Text = "Map Pool", RequestSelection = SetScreen },
-                                new ScreenButton(typeof(GameplayScreen)) { Text = "Gameplay", RequestSelection = SetScreen },
+                                new ScreenButton(typeof(MapPoolScreen), Key.M) { Text = "Map Pool", RequestSelection = SetScreen },
+                                new ScreenButton(typeof(GameplayScreen), Key.G) { Text = "Gameplay", RequestSelection = SetScreen },
                                 new Separator(),
-                                new ScreenButton(typeof(TeamWinScreen)) { Text = "Win", RequestSelection = SetScreen },
+                                new ScreenButton(typeof(TeamWinScreen), Key.W) { Text = "Win", RequestSelection = SetScreen },
                                 new Separator(),
                                 new ScreenButton(typeof(DrawingsScreen)) { Text = "Drawings", RequestSelection = SetScreen },
                                 new ScreenButton(typeof(ShowcaseScreen)) { Text = "Showcase", RequestSelection = SetScreen },
@@ -231,13 +234,60 @@ namespace osu.Game.Tournament
         {
             public readonly Type Type;
 
-            public ScreenButton(Type type)
+            private readonly Key? shortcutKey;
+
+            public ScreenButton(Type type, Key? shortcutKey = null)
             {
+                this.shortcutKey = shortcutKey;
+
                 Type = type;
+
                 BackgroundColour = OsuColour.Gray(0.2f);
                 Action = () => RequestSelection?.Invoke(type);
 
                 RelativeSizeAxes = Axes.X;
+
+                if (shortcutKey != null)
+                {
+                    Add(new Container
+                    {
+                        Anchor = Anchor.CentreLeft,
+                        Origin = Anchor.CentreLeft,
+                        Size = new Vector2(24),
+                        Margin = new MarginPadding(5),
+                        Masking = true,
+                        CornerRadius = 4,
+                        Alpha = 0.5f,
+                        Blending = BlendingParameters.Additive,
+                        Children = new Drawable[]
+                        {
+                            new Box
+                            {
+                                Colour = OsuColour.Gray(0.1f),
+                                RelativeSizeAxes = Axes.Both,
+                            },
+                            new OsuSpriteText
+                            {
+                                Font = OsuFont.Default.With(size: 24),
+                                Y = -2,
+                                Anchor = Anchor.Centre,
+                                Origin = Anchor.Centre,
+                                Text = shortcutKey.ToString(),
+                            }
+                        }
+                    });
+                }
+            }
+
+            protected override bool OnKeyDown(KeyDownEvent e)
+            {
+                if (e.Key == shortcutKey)
+                {
+                    TriggerClick();
+                    return true;
+                }
+
+                return base.OnKeyDown(e);
             }
 
             private bool isSelected;

From de625125d6d04b97d8571d62267e6e04328dfde8 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 13:03:48 +0900
Subject: [PATCH 039/113] Rename magnetised mod attraction strength property to
 match new naming

---
 osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs | 2 +-
 osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs                | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs
index e7a40d6337..9b49e60363 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs
@@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
             {
                 Mod = new OsuModMagnetised
                 {
-                    AssistStrength = { Value = strength },
+                    AttractionStrength = { Value = strength },
                 },
                 PassCondition = () => true,
                 Autoplay = false,
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs
index 31598c50e7..ca6e9cfb1d 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs
@@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Osu.Mods
         private IFrameStableClock gameplayClock;
 
         [SettingSource("Attraction strength", "How strong the pull is.", 0)]
-        public BindableFloat AssistStrength { get; } = new BindableFloat(0.5f)
+        public BindableFloat AttractionStrength { get; } = new BindableFloat(0.5f)
         {
             Precision = 0.05f,
             MinValue = 0.05f,
@@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Osu.Mods
 
         private void easeTo(DrawableHitObject hitObject, Vector2 destination)
         {
-            double dampLength = Interpolation.Lerp(3000, 40, AssistStrength.Value);
+            double dampLength = Interpolation.Lerp(3000, 40, AttractionStrength.Value);
 
             float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, gameplayClock.ElapsedFrameTime);
             float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, gameplayClock.ElapsedFrameTime);

From 69d4f8612268ab280408f765d44256e385f25f9a Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 14:11:53 +0900
Subject: [PATCH 040/113] Fix automatically created "(modified)" skins getting
 conflicting names

Applies the already tested and proven method that is used in the editor
to the mutable skin creation flow.
---
 osu.Game/Skinning/SkinManager.cs | 15 +++++++++++----
 1 file changed, 11 insertions(+), 4 deletions(-)

diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs
index bad559d9fe..44b9c69794 100644
--- a/osu.Game/Skinning/SkinManager.cs
+++ b/osu.Game/Skinning/SkinManager.cs
@@ -24,6 +24,7 @@ using osu.Game.Database;
 using osu.Game.IO;
 using osu.Game.IO.Archives;
 using osu.Game.Overlays.Notifications;
+using osu.Game.Utils;
 
 namespace osu.Game.Skinning
 {
@@ -144,20 +145,26 @@ namespace osu.Game.Skinning
                 if (!s.Protected)
                     return;
 
+                var existingSkinNames = realm.Run(r => r.All<SkinInfo>()
+                                                        .Where(skin => !skin.DeletePending)
+                                                        .AsEnumerable()
+                                                        .Select(skin => skin.Name));
+
                 // if the user is attempting to save one of the default skin implementations, create a copy first.
-                var result = skinModelManager.Import(new SkinInfo
+                var skinInfo = new SkinInfo
                 {
-                    Name = s.Name + @" (modified)",
                     Creator = s.Creator,
                     InstantiationInfo = s.InstantiationInfo,
-                });
+                    Name = NamingUtils.GetNextBestName(existingSkinNames, $"{s.Name} (modified)")
+                };
+
+                var result = skinModelManager.Import(skinInfo);
 
                 if (result != null)
                 {
                     // save once to ensure the required json content is populated.
                     // currently this only happens on save.
                     result.PerformRead(skin => Save(skin.CreateInstance(this)));
-
                     CurrentSkinInfo.Value = result;
                 }
             });

From 88306a61804c4d960f0c1b06090f9c914fb3f6b3 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 14:22:26 +0900
Subject: [PATCH 041/113] Disable ability to select random skin from within the
 skin editor

Reasoning is explained in inline comment.

I knowingly only applied this to the shortcut key. It's still feasible a
user can choose the option from the skin dropdown while the editor is
open, but that's less of an issue (because a user won't get the same
compulsion that I get to mash the key, only to be greeted with 100 new
mutable skins created).
---
 osu.Game/OsuGame.cs | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index 4cd954a646..73121f6e7d 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -1061,6 +1061,12 @@ namespace osu.Game
                     return true;
 
                 case GlobalAction.RandomSkin:
+                    // Don't allow random skin selection while in the skin editor.
+                    // This is mainly to stop many "osu! default (modified)" skins being created via the SkinManager.EnsureMutableSkin() path.
+                    // If people want this to work we can potentially avoid selecting default skins when the editor is open, or allow a maximum of one mutable skin somehow.
+                    if (skinEditor.State.Value == Visibility.Visible)
+                        return false;
+
                     SkinManager.SelectRandomSkin();
                     return true;
             }

From 01829cf2d89ebec7056a5e5cb082b8d91f5cd46d Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 14:30:02 +0900
Subject: [PATCH 042/113] Move `SkinnableInfo` error handling to lower level

Handling was recently added to handle the usage in
`Skin.GetDrawableCompoent`, but it turns out this is also required for
`DrawableExtensions.ApplySkinnableInfo` which can throw in a similar
fashion.

Found while working on sprite support for the editor, where this becomes
an actual issue (ie. switching to a branch where the new sprite support
is not present can cause unexpected crashes).
---
 osu.Game/Screens/Play/HUD/SkinnableInfo.cs | 15 ++++++++++++---
 osu.Game/Skinning/Skin.cs                  | 11 +----------
 2 files changed, 13 insertions(+), 13 deletions(-)

diff --git a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs
index 95395f8181..1f659fd5bf 100644
--- a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs
+++ b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs
@@ -9,6 +9,7 @@ using Newtonsoft.Json;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
+using osu.Framework.Logging;
 using osu.Game.Configuration;
 using osu.Game.Extensions;
 using osu.Game.Skinning;
@@ -84,9 +85,17 @@ namespace osu.Game.Screens.Play.HUD
         /// <returns>The new instance.</returns>
         public Drawable CreateInstance()
         {
-            Drawable d = (Drawable)Activator.CreateInstance(Type);
-            d.ApplySkinnableInfo(this);
-            return d;
+            try
+            {
+                Drawable d = (Drawable)Activator.CreateInstance(Type);
+                d.ApplySkinnableInfo(this);
+                return d;
+            }
+            catch (Exception e)
+            {
+                Logger.Error(e, $"Unable to create skin component {Type.Name}");
+                return Drawable.Empty();
+            }
         }
     }
 }
diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs
index 2f01bb7301..5d4afc00c4 100644
--- a/osu.Game/Skinning/Skin.cs
+++ b/osu.Game/Skinning/Skin.cs
@@ -155,16 +155,7 @@ namespace osu.Game.Skinning
                     var components = new List<Drawable>();
 
                     foreach (var i in skinnableInfo)
-                    {
-                        try
-                        {
-                            components.Add(i.CreateInstance());
-                        }
-                        catch (Exception e)
-                        {
-                            Logger.Error(e, $"Unable to create skin component {i.Type.Name}");
-                        }
-                    }
+                        components.Add(i.CreateInstance());
 
                     return new SkinnableTargetComponentsContainer
                     {

From 3a16483214bb564dbf75f5bd61f6c7623e0783b5 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 23 Mar 2022 15:10:26 +0900
Subject: [PATCH 043/113] Add prioritised user lookups for default skin

This allows user resources to be consumed before falling back to the
game bundled assets.
---
 osu.Game/Skinning/DefaultSkin.cs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs
index 7c6d138f4c..43ada59bcb 100644
--- a/osu.Game/Skinning/DefaultSkin.cs
+++ b/osu.Game/Skinning/DefaultSkin.cs
@@ -46,13 +46,13 @@ namespace osu.Game.Skinning
             this.resources = resources;
         }
 
-        public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null;
+        public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT);
 
         public override ISample GetSample(ISampleInfo sampleInfo)
         {
             foreach (string lookup in sampleInfo.LookupNames)
             {
-                var sample = resources.AudioManager.Samples.Get(lookup);
+                var sample = Samples?.Get(lookup) ?? resources.AudioManager.Samples.Get(lookup);
                 if (sample != null)
                     return sample;
             }

From fca9faac9b87eb9c24d86e2d52764619bbe2ddf0 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 23 Mar 2022 15:11:35 +0900
Subject: [PATCH 044/113] Add `SkinnableSprite` for arbitrary sprite additions

---
 .../Skinning/Components/SkinnableSprite.cs    | 49 ++++++++++++++++
 osu.Game/Skinning/Editor/SkinEditor.cs        | 56 +++++++++++++++++--
 osu.Game/Skinning/SkinManager.cs              | 44 ++++++++++++++-
 osu.Game/Skinning/SkinnableSprite.cs          |  4 +-
 4 files changed, 147 insertions(+), 6 deletions(-)
 create mode 100644 osu.Game/Skinning/Components/SkinnableSprite.cs

diff --git a/osu.Game/Skinning/Components/SkinnableSprite.cs b/osu.Game/Skinning/Components/SkinnableSprite.cs
new file mode 100644
index 0000000000..292bbe2321
--- /dev/null
+++ b/osu.Game/Skinning/Components/SkinnableSprite.cs
@@ -0,0 +1,49 @@
+// 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 JetBrains.Annotations;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Configuration;
+
+namespace osu.Game.Skinning.Components
+{
+    /// <summary>
+    /// Intended to be a test bed for skinning. May be removed at some point in the future.
+    /// </summary>
+    [UsedImplicitly]
+    public class SkinSprite : CompositeDrawable, ISkinnableDrawable
+    {
+        public bool UsesFixedAnchor { get; set; }
+
+        [SettingSource("Sprite name", "The filename of the sprite")]
+        public Bindable<string> SpriteName { get; } = new Bindable<string>(string.Empty);
+
+        [Resolved]
+        private ISkinSource source { get; set; }
+
+        public SkinSprite()
+        {
+            AutoSizeAxes = Axes.Both;
+        }
+
+        protected override void LoadComplete()
+        {
+            base.LoadComplete();
+
+            SpriteName.BindValueChanged(spriteName =>
+            {
+                InternalChildren = new Drawable[]
+                {
+                    new Sprite
+                    {
+                        Texture = source.GetTexture(SpriteName.Value),
+                    }
+                };
+            }, true);
+        }
+    }
+}
diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs
index 7bf4e94662..7701fafbfc 100644
--- a/osu.Game/Skinning/Editor/SkinEditor.cs
+++ b/osu.Game/Skinning/Editor/SkinEditor.cs
@@ -3,7 +3,9 @@
 
 using System;
 using System.Collections.Generic;
+using System.IO;
 using System.Linq;
+using System.Threading.Tasks;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
@@ -11,6 +13,7 @@ using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.UserInterface;
 using osu.Framework.Input.Events;
 using osu.Framework.Testing;
+using osu.Game.Database;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Containers;
 using osu.Game.Graphics.Cursor;
@@ -18,11 +21,12 @@ using osu.Game.Graphics.UserInterface;
 using osu.Game.Overlays;
 using osu.Game.Screens.Edit.Components;
 using osu.Game.Screens.Edit.Components.Menus;
+using osu.Game.Skinning.Components;
 
 namespace osu.Game.Skinning.Editor
 {
     [Cached(typeof(SkinEditor))]
-    public class SkinEditor : VisibilityContainer
+    public class SkinEditor : VisibilityContainer, ICanAcceptFiles
     {
         public const double TRANSITION_DURATION = 500;
 
@@ -36,6 +40,9 @@ namespace osu.Game.Skinning.Editor
 
         private Bindable<Skin> currentSkin;
 
+        [Resolved(canBeNull: true)]
+        private OsuGame game { get; set; }
+
         [Resolved]
         private SkinManager skins { get; set; }
 
@@ -171,6 +178,8 @@ namespace osu.Game.Skinning.Editor
 
             Show();
 
+            game?.RegisterImportHandler(this);
+
             // as long as the skin editor is loaded, let's make sure we can modify the current skin.
             currentSkin = skins.CurrentSkin.GetBoundCopy();
 
@@ -186,6 +195,13 @@ namespace osu.Game.Skinning.Editor
             SelectedComponents.BindCollectionChanged((_, __) => Scheduler.AddOnce(populateSettings), true);
         }
 
+        protected override void Dispose(bool isDisposing)
+        {
+            base.Dispose(isDisposing);
+
+            game?.UnregisterImportHandler(this);
+        }
+
         public void UpdateTargetScreen(Drawable targetScreen)
         {
             this.targetScreen = targetScreen;
@@ -229,15 +245,20 @@ namespace osu.Game.Skinning.Editor
         }
 
         private void placeComponent(Type type)
+        {
+            if (!(Activator.CreateInstance(type) is ISkinnableDrawable component))
+                throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}.");
+
+            placeComponent(component);
+        }
+
+        private void placeComponent(ISkinnableDrawable component)
         {
             var targetContainer = getFirstTarget();
 
             if (targetContainer == null)
                 return;
 
-            if (!(Activator.CreateInstance(type) is ISkinnableDrawable component))
-                throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}.");
-
             var drawableComponent = (Drawable)component;
 
             // give newly added components a sane starting location.
@@ -313,5 +334,32 @@ namespace osu.Game.Skinning.Editor
             foreach (var item in items)
                 availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item);
         }
+
+        public Task Import(params string[] paths)
+        {
+            Schedule(() =>
+            {
+                var file = new FileInfo(paths.First());
+
+                // import to skin
+                currentSkin.Value.SkinInfo.PerformWrite(skinInfo =>
+                {
+                    using (var contents = file.OpenRead())
+                        skins.AddFile(skinInfo, contents, file.Name);
+                });
+
+                // place component
+                placeComponent(new SkinSprite
+                {
+                    SpriteName = { Value = Path.GetFileNameWithoutExtension(file.Name) }
+                });
+            });
+
+            return Task.CompletedTask;
+        }
+
+        public Task Import(params ImportTask[] tasks) => throw new NotImplementedException();
+
+        public IEnumerable<string> HandledExtensions => new[] { ".jpg", ".jpeg", ".png" };
     }
 }
diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs
index bad559d9fe..5333b58625 100644
--- a/osu.Game/Skinning/SkinManager.cs
+++ b/osu.Game/Skinning/SkinManager.cs
@@ -3,6 +3,7 @@
 
 using System;
 using System.Collections.Generic;
+using System.IO;
 using System.Linq;
 using System.Linq.Expressions;
 using System.Threading;
@@ -23,6 +24,7 @@ using osu.Game.Audio;
 using osu.Game.Database;
 using osu.Game.IO;
 using osu.Game.IO.Archives;
+using osu.Game.Models;
 using osu.Game.Overlays.Notifications;
 
 namespace osu.Game.Skinning
@@ -35,7 +37,7 @@ namespace osu.Game.Skinning
     /// For gameplay components, see <see cref="RulesetSkinProvidingContainer"/> which adds extra legacy and toggle logic that may affect the lookup process.
     /// </remarks>
     [ExcludeFromDynamicCompile]
-    public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>
+    public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>, IModelManager<SkinInfo>, IModelFileManager<SkinInfo, RealmNamedFileUsage>
     {
         private readonly AudioManager audio;
 
@@ -306,5 +308,45 @@ namespace osu.Game.Skinning
         }
 
         #endregion
+
+        public bool Delete(SkinInfo item)
+        {
+            return skinModelManager.Delete(item);
+        }
+
+        public void Delete(List<SkinInfo> items, bool silent = false)
+        {
+            skinModelManager.Delete(items, silent);
+        }
+
+        public void Undelete(List<SkinInfo> items, bool silent = false)
+        {
+            skinModelManager.Undelete(items, silent);
+        }
+
+        public void Undelete(SkinInfo item)
+        {
+            skinModelManager.Undelete(item);
+        }
+
+        public bool IsAvailableLocally(SkinInfo model)
+        {
+            return skinModelManager.IsAvailableLocally(model);
+        }
+
+        public void ReplaceFile(SkinInfo model, RealmNamedFileUsage file, Stream contents)
+        {
+            skinModelManager.ReplaceFile(model, file, contents);
+        }
+
+        public void DeleteFile(SkinInfo model, RealmNamedFileUsage file)
+        {
+            skinModelManager.DeleteFile(model, file);
+        }
+
+        public void AddFile(SkinInfo model, Stream contents, string filename)
+        {
+            skinModelManager.AddFile(model, contents, filename);
+        }
     }
 }
diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs
index 56e576d081..38803bd8e3 100644
--- a/osu.Game/Skinning/SkinnableSprite.cs
+++ b/osu.Game/Skinning/SkinnableSprite.cs
@@ -11,7 +11,7 @@ namespace osu.Game.Skinning
     /// <summary>
     /// A skinnable element which uses a stable sprite and can therefore share implementation logic.
     /// </summary>
-    public class SkinnableSprite : SkinnableDrawable
+    public class SkinnableSprite : SkinnableDrawable, ISkinnableDrawable
     {
         protected override bool ApplySizeRestrictionsToDefault => true;
 
@@ -42,5 +42,7 @@ namespace osu.Game.Skinning
 
             public string LookupName { get; }
         }
+
+        public bool UsesFixedAnchor { get; set; }
     }
 }

From 66f5eae530cac55975b46655825d2b159e73976d Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 24 Mar 2022 19:32:34 +0900
Subject: [PATCH 045/113] Hook up a dropdown to show all available sprites for
 the current skin

---
 .../Configuration/SettingSourceAttribute.cs   |  1 +
 osu.Game/Overlays/Settings/SettingsItem.cs    |  6 ++++++
 .../Skinning/Components/SkinnableSprite.cs    | 19 ++++++++++++++++++-
 osu.Game/Skinning/Editor/SkinEditor.cs        |  2 +-
 4 files changed, 26 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs
index 4111a67b24..8c84707b88 100644
--- a/osu.Game/Configuration/SettingSourceAttribute.cs
+++ b/osu.Game/Configuration/SettingSourceAttribute.cs
@@ -88,6 +88,7 @@ namespace osu.Game.Configuration
                         throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} had an unsupported custom control type ({controlType.ReadableName()})");
 
                     var control = (Drawable)Activator.CreateInstance(controlType);
+                    controlType.GetProperty(nameof(SettingsItem<object>.Source))?.SetValue(control, obj);
                     controlType.GetProperty(nameof(SettingsItem<object>.LabelText))?.SetValue(control, attr.Label);
                     controlType.GetProperty(nameof(SettingsItem<object>.TooltipText))?.SetValue(control, attr.Description);
                     controlType.GetProperty(nameof(SettingsItem<object>.Current))?.SetValue(control, value);
diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs
index e709be1343..6ac5351270 100644
--- a/osu.Game/Overlays/Settings/SettingsItem.cs
+++ b/osu.Game/Overlays/Settings/SettingsItem.cs
@@ -11,6 +11,7 @@ using osu.Framework.Graphics.Cursor;
 using osu.Framework.Graphics.Sprites;
 using osu.Framework.Graphics.UserInterface;
 using osu.Framework.Localisation;
+using osu.Game.Configuration;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Sprites;
 using osu.Game.Graphics.Containers;
@@ -24,6 +25,11 @@ namespace osu.Game.Overlays.Settings
 
         protected Drawable Control { get; }
 
+        /// <summary>
+        /// The source component if this <see cref="SettingsItem{T}"/> was created via <see cref="SettingSourceAttribute"/>.
+        /// </summary>
+        public Drawable Source { get; internal set; }
+
         private IHasCurrentValue<T> controlWithCurrent => Control as IHasCurrentValue<T>;
 
         protected override Container<Drawable> Content => FlowContent;
diff --git a/osu.Game/Skinning/Components/SkinnableSprite.cs b/osu.Game/Skinning/Components/SkinnableSprite.cs
index 292bbe2321..aa23e428d1 100644
--- a/osu.Game/Skinning/Components/SkinnableSprite.cs
+++ b/osu.Game/Skinning/Components/SkinnableSprite.cs
@@ -1,6 +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.Collections.Generic;
+using System.Linq;
 using JetBrains.Annotations;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
@@ -8,6 +10,7 @@ using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Sprites;
 using osu.Game.Configuration;
+using osu.Game.Overlays.Settings;
 
 namespace osu.Game.Skinning.Components
 {
@@ -19,12 +22,14 @@ namespace osu.Game.Skinning.Components
     {
         public bool UsesFixedAnchor { get; set; }
 
-        [SettingSource("Sprite name", "The filename of the sprite")]
+        [SettingSource("Sprite name", "The filename of the sprite", SettingControlType = typeof(SpriteSelectorControl))]
         public Bindable<string> SpriteName { get; } = new Bindable<string>(string.Empty);
 
         [Resolved]
         private ISkinSource source { get; set; }
 
+        public IEnumerable<string> AvailableFiles => (source.AllSources.First() as Skin)?.SkinInfo.PerformRead(s => s.Files.Select(f => f.Filename));
+
         public SkinSprite()
         {
             AutoSizeAxes = Axes.Both;
@@ -45,5 +50,17 @@ namespace osu.Game.Skinning.Components
                 };
             }, true);
         }
+
+        public class SpriteSelectorControl : SettingsDropdown<string>
+        {
+            public SkinSprite Source { get; set; }
+
+            protected override void LoadComplete()
+            {
+                base.LoadComplete();
+
+                Items = Source.AvailableFiles;
+            }
+        }
     }
 }
diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs
index 7701fafbfc..392cb2f32b 100644
--- a/osu.Game/Skinning/Editor/SkinEditor.cs
+++ b/osu.Game/Skinning/Editor/SkinEditor.cs
@@ -351,7 +351,7 @@ namespace osu.Game.Skinning.Editor
                 // place component
                 placeComponent(new SkinSprite
                 {
-                    SpriteName = { Value = Path.GetFileNameWithoutExtension(file.Name) }
+                    SpriteName = { Value = file.Name }
                 });
             });
 

From 9c3dad9fbf72f63cbb79fdc8f2ebc687a2d66f69 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 24 Mar 2022 19:09:17 +0900
Subject: [PATCH 046/113] Add proof of concept flow to ensure
 `RealmBackedResourceStore` is invalidated on realm file changes

I'm not at all happy with this, but it does work so let's go with it for
now.
---
 osu.Game/Skinning/LegacyBeatmapSkin.cs        |  5 +-
 osu.Game/Skinning/RealmBackedResourceStore.cs | 57 ++++++++++++-------
 osu.Game/Skinning/Skin.cs                     |  8 ++-
 osu.Game/Skinning/SkinManager.cs              | 21 ++++++-
 4 files changed, 67 insertions(+), 24 deletions(-)

diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs
index 16a05f4197..70f5b35d00 100644
--- a/osu.Game/Skinning/LegacyBeatmapSkin.cs
+++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs
@@ -11,6 +11,7 @@ using osu.Framework.IO.Stores;
 using osu.Game.Audio;
 using osu.Game.Beatmaps;
 using osu.Game.Beatmaps.Formats;
+using osu.Game.Database;
 using osu.Game.IO;
 using osu.Game.Rulesets.Objects.Legacy;
 using osu.Game.Rulesets.Objects.Types;
@@ -37,11 +38,11 @@ namespace osu.Game.Skinning
 
         private static IResourceStore<byte[]> createRealmBackedStore(BeatmapInfo beatmapInfo, IStorageResourceProvider? resources)
         {
-            if (resources == null)
+            if (resources == null || beatmapInfo.BeatmapSet == null)
                 // should only ever be used in tests.
                 return new ResourceStore<byte[]>();
 
-            return new RealmBackedResourceStore(beatmapInfo.BeatmapSet, resources.Files, new[] { @"ogg" });
+            return new RealmBackedResourceStore<BeatmapSetInfo>(beatmapInfo.BeatmapSet.ToLive(resources.RealmAccess), resources.Files, resources.RealmAccess);
         }
 
         public override Drawable? GetDrawableComponent(ISkinComponent component)
diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs
index fc9036727f..115d563575 100644
--- a/osu.Game/Skinning/RealmBackedResourceStore.cs
+++ b/osu.Game/Skinning/RealmBackedResourceStore.cs
@@ -1,51 +1,68 @@
 // 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.
 
+#nullable enable
+
+using System;
 using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
 using osu.Framework.Extensions;
 using osu.Framework.IO.Stores;
 using osu.Game.Database;
 using osu.Game.Extensions;
+using Realms;
 
 namespace osu.Game.Skinning
 {
-    public class RealmBackedResourceStore : ResourceStore<byte[]>
+    public class RealmBackedResourceStore<T> : ResourceStore<byte[]>
+        where T : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey
     {
-        private readonly Dictionary<string, string> fileToStoragePathMapping = new Dictionary<string, string>();
+        private Lazy<Dictionary<string, string>> fileToStoragePathMapping;
 
-        public RealmBackedResourceStore(IHasRealmFiles source, IResourceStore<byte[]> underlyingStore, string[] extensions = null)
+        private readonly Live<T> liveSource;
+
+        public RealmBackedResourceStore(Live<T> source, IResourceStore<byte[]> underlyingStore, RealmAccess? realm)
             : base(underlyingStore)
         {
-            // Must be initialised before the file cache.
-            if (extensions != null)
-            {
-                foreach (string extension in extensions)
-                    AddExtension(extension);
-            }
+            liveSource = source;
 
-            initialiseFileCache(source);
+            invalidateCache();
+            Debug.Assert(fileToStoragePathMapping != null);
         }
 
-        private void initialiseFileCache(IHasRealmFiles source)
-        {
-            fileToStoragePathMapping.Clear();
-            foreach (var f in source.Files)
-                fileToStoragePathMapping[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath();
-        }
+        public void Invalidate() => invalidateCache();
 
         protected override IEnumerable<string> GetFilenames(string name)
         {
             foreach (string filename in base.GetFilenames(name))
             {
-                string path = getPathForFile(filename.ToStandardisedPath());
+                string? path = getPathForFile(filename.ToStandardisedPath());
                 if (path != null)
                     yield return path;
             }
         }
 
-        private string getPathForFile(string filename) =>
-            fileToStoragePathMapping.TryGetValue(filename.ToLower(), out string path) ? path : null;
+        private string? getPathForFile(string filename)
+        {
+            if (fileToStoragePathMapping.Value.TryGetValue(filename.ToLowerInvariant(), out string path))
+                return path;
 
-        public override IEnumerable<string> GetAvailableResources() => fileToStoragePathMapping.Keys;
+            return null;
+        }
+
+        private void invalidateCache() => fileToStoragePathMapping = new Lazy<Dictionary<string, string>>(initialiseFileCache, LazyThreadSafetyMode.ExecutionAndPublication);
+
+        private Dictionary<string, string> initialiseFileCache() => liveSource.PerformRead(source =>
+        {
+            var dictionary = new Dictionary<string, string>();
+            dictionary.Clear();
+            foreach (var f in source.Files)
+                dictionary[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath();
+
+            return dictionary;
+        });
+
+        public override IEnumerable<string> GetAvailableResources() => fileToStoragePathMapping.Value.Keys;
     }
 }
diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs
index 2f01bb7301..fb9914cd9e 100644
--- a/osu.Game/Skinning/Skin.cs
+++ b/osu.Game/Skinning/Skin.cs
@@ -54,6 +54,10 @@ namespace osu.Game.Skinning
             where TLookup : notnull
             where TValue : notnull;
 
+        public void InvalidateCaches() => realmBackedStorage?.Invalidate();
+
+        private readonly RealmBackedResourceStore<SkinInfo> realmBackedStorage;
+
         /// <summary>
         /// Construct a new skin.
         /// </summary>
@@ -67,7 +71,9 @@ namespace osu.Game.Skinning
             {
                 SkinInfo = skin.ToLive(resources.RealmAccess);
 
-                storage ??= new RealmBackedResourceStore(skin, resources.Files, new[] { @"ogg" });
+                storage ??= realmBackedStorage = new RealmBackedResourceStore<SkinInfo>(SkinInfo, resources.Files, resources.RealmAccess);
+
+                (storage as ResourceStore<byte[]>)?.AddExtension("ogg");
 
                 var samples = resources.AudioManager?.GetSampleStore(storage);
                 if (samples != null)
diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs
index 5333b58625..bafb088f68 100644
--- a/osu.Game/Skinning/SkinManager.cs
+++ b/osu.Game/Skinning/SkinManager.cs
@@ -16,6 +16,7 @@ using osu.Framework.Graphics;
 using osu.Framework.Graphics.OpenGL.Textures;
 using osu.Framework.Graphics.Textures;
 using osu.Framework.IO.Stores;
+using osu.Framework.Logging;
 using osu.Framework.Platform;
 using osu.Framework.Testing;
 using osu.Framework.Threading;
@@ -26,6 +27,7 @@ using osu.Game.IO;
 using osu.Game.IO.Archives;
 using osu.Game.Models;
 using osu.Game.Overlays.Notifications;
+using Realms;
 
 namespace osu.Game.Skinning
 {
@@ -59,6 +61,8 @@ namespace osu.Game.Skinning
 
         private readonly IResourceStore<byte[]> userFiles;
 
+        private IDisposable currentSkinSubscription;
+
         /// <summary>
         /// The default skin.
         /// </summary>
@@ -97,7 +101,16 @@ namespace osu.Game.Skinning
                 }
             });
 
-            CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin);
+            CurrentSkinInfo.ValueChanged += skin =>
+            {
+                CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin);
+
+                scheduler.Add(() =>
+                {
+                    currentSkinSubscription?.Dispose();
+                    currentSkinSubscription = realm.RegisterForNotifications(r => r.All<SkinInfo>().Where(s => s.ID == skin.NewValue.ID), realmSkinChanged);
+                });
+            };
 
             CurrentSkin.Value = DefaultSkin;
             CurrentSkin.ValueChanged += skin =>
@@ -109,6 +122,12 @@ namespace osu.Game.Skinning
             };
         }
 
+        private void realmSkinChanged<T>(IRealmCollection<T> sender, ChangeSet changes, Exception error) where T : RealmObjectBase
+        {
+            Logger.Log("Detected a skin change");
+            CurrentSkin.Value.InvalidateCaches();
+        }
+
         public void SelectRandomSkin()
         {
             realm.Run(r =>

From 762de3cc9780e76e744ea4e3c5d532e16117e4fd Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 24 Mar 2022 22:53:49 +0900
Subject: [PATCH 047/113] Replace invalidation logic with local realm
 notification subscription

---
 osu.Game/Skinning/RealmBackedResourceStore.cs | 12 +++++++++++-
 osu.Game/Skinning/Skin.cs                     |  4 ++--
 osu.Game/Skinning/SkinManager.cs              | 16 ----------------
 3 files changed, 13 insertions(+), 19 deletions(-)

diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs
index 115d563575..e727a7e59a 100644
--- a/osu.Game/Skinning/RealmBackedResourceStore.cs
+++ b/osu.Game/Skinning/RealmBackedResourceStore.cs
@@ -6,6 +6,7 @@
 using System;
 using System.Collections.Generic;
 using System.Diagnostics;
+using System.Linq;
 using System.Threading;
 using osu.Framework.Extensions;
 using osu.Framework.IO.Stores;
@@ -21,6 +22,7 @@ namespace osu.Game.Skinning
         private Lazy<Dictionary<string, string>> fileToStoragePathMapping;
 
         private readonly Live<T> liveSource;
+        private readonly IDisposable? realmSubscription;
 
         public RealmBackedResourceStore(Live<T> source, IResourceStore<byte[]> underlyingStore, RealmAccess? realm)
             : base(underlyingStore)
@@ -29,9 +31,17 @@ namespace osu.Game.Skinning
 
             invalidateCache();
             Debug.Assert(fileToStoragePathMapping != null);
+
+            realmSubscription = realm?.RegisterForNotifications(r => r.All<T>().Where(s => s.ID == source.ID), skinChanged);
         }
 
-        public void Invalidate() => invalidateCache();
+        protected override void Dispose(bool disposing)
+        {
+            base.Dispose(disposing);
+            realmSubscription?.Dispose();
+        }
+
+        private void skinChanged(IRealmCollection<T> sender, ChangeSet changes, Exception error) => invalidateCache();
 
         protected override IEnumerable<string> GetFilenames(string name)
         {
diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs
index fb9914cd9e..4cd1d952db 100644
--- a/osu.Game/Skinning/Skin.cs
+++ b/osu.Game/Skinning/Skin.cs
@@ -54,8 +54,6 @@ namespace osu.Game.Skinning
             where TLookup : notnull
             where TValue : notnull;
 
-        public void InvalidateCaches() => realmBackedStorage?.Invalidate();
-
         private readonly RealmBackedResourceStore<SkinInfo> realmBackedStorage;
 
         /// <summary>
@@ -206,6 +204,8 @@ namespace osu.Game.Skinning
 
             Textures?.Dispose();
             Samples?.Dispose();
+
+            realmBackedStorage?.Dispose();
         }
 
         #endregion
diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs
index bafb088f68..5e85f9e4ca 100644
--- a/osu.Game/Skinning/SkinManager.cs
+++ b/osu.Game/Skinning/SkinManager.cs
@@ -16,7 +16,6 @@ using osu.Framework.Graphics;
 using osu.Framework.Graphics.OpenGL.Textures;
 using osu.Framework.Graphics.Textures;
 using osu.Framework.IO.Stores;
-using osu.Framework.Logging;
 using osu.Framework.Platform;
 using osu.Framework.Testing;
 using osu.Framework.Threading;
@@ -27,7 +26,6 @@ using osu.Game.IO;
 using osu.Game.IO.Archives;
 using osu.Game.Models;
 using osu.Game.Overlays.Notifications;
-using Realms;
 
 namespace osu.Game.Skinning
 {
@@ -61,8 +59,6 @@ namespace osu.Game.Skinning
 
         private readonly IResourceStore<byte[]> userFiles;
 
-        private IDisposable currentSkinSubscription;
-
         /// <summary>
         /// The default skin.
         /// </summary>
@@ -104,12 +100,6 @@ namespace osu.Game.Skinning
             CurrentSkinInfo.ValueChanged += skin =>
             {
                 CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin);
-
-                scheduler.Add(() =>
-                {
-                    currentSkinSubscription?.Dispose();
-                    currentSkinSubscription = realm.RegisterForNotifications(r => r.All<SkinInfo>().Where(s => s.ID == skin.NewValue.ID), realmSkinChanged);
-                });
             };
 
             CurrentSkin.Value = DefaultSkin;
@@ -122,12 +112,6 @@ namespace osu.Game.Skinning
             };
         }
 
-        private void realmSkinChanged<T>(IRealmCollection<T> sender, ChangeSet changes, Exception error) where T : RealmObjectBase
-        {
-            Logger.Log("Detected a skin change");
-            CurrentSkin.Value.InvalidateCaches();
-        }
-
         public void SelectRandomSkin()
         {
             realm.Run(r =>

From d1be229d74806794f8f136f3919885f915c118fc Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 25 Mar 2022 17:31:03 +0900
Subject: [PATCH 048/113] Combine `SkinSprite` into `SkinnableSprite`

---
 .../Skinning/Components/SkinnableSprite.cs    | 66 -------------------
 osu.Game/Skinning/DefaultSkin.cs              |  4 ++
 osu.Game/Skinning/Editor/SkinEditor.cs        |  5 +-
 osu.Game/Skinning/Skin.cs                     |  2 +-
 osu.Game/Skinning/SkinnableDrawable.cs        |  8 +--
 osu.Game/Skinning/SkinnableSprite.cs          | 47 ++++++++++++-
 6 files changed, 55 insertions(+), 77 deletions(-)
 delete mode 100644 osu.Game/Skinning/Components/SkinnableSprite.cs

diff --git a/osu.Game/Skinning/Components/SkinnableSprite.cs b/osu.Game/Skinning/Components/SkinnableSprite.cs
deleted file mode 100644
index aa23e428d1..0000000000
--- a/osu.Game/Skinning/Components/SkinnableSprite.cs
+++ /dev/null
@@ -1,66 +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 System.Collections.Generic;
-using System.Linq;
-using JetBrains.Annotations;
-using osu.Framework.Allocation;
-using osu.Framework.Bindables;
-using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
-using osu.Framework.Graphics.Sprites;
-using osu.Game.Configuration;
-using osu.Game.Overlays.Settings;
-
-namespace osu.Game.Skinning.Components
-{
-    /// <summary>
-    /// Intended to be a test bed for skinning. May be removed at some point in the future.
-    /// </summary>
-    [UsedImplicitly]
-    public class SkinSprite : CompositeDrawable, ISkinnableDrawable
-    {
-        public bool UsesFixedAnchor { get; set; }
-
-        [SettingSource("Sprite name", "The filename of the sprite", SettingControlType = typeof(SpriteSelectorControl))]
-        public Bindable<string> SpriteName { get; } = new Bindable<string>(string.Empty);
-
-        [Resolved]
-        private ISkinSource source { get; set; }
-
-        public IEnumerable<string> AvailableFiles => (source.AllSources.First() as Skin)?.SkinInfo.PerformRead(s => s.Files.Select(f => f.Filename));
-
-        public SkinSprite()
-        {
-            AutoSizeAxes = Axes.Both;
-        }
-
-        protected override void LoadComplete()
-        {
-            base.LoadComplete();
-
-            SpriteName.BindValueChanged(spriteName =>
-            {
-                InternalChildren = new Drawable[]
-                {
-                    new Sprite
-                    {
-                        Texture = source.GetTexture(SpriteName.Value),
-                    }
-                };
-            }, true);
-        }
-
-        public class SpriteSelectorControl : SettingsDropdown<string>
-        {
-            public SkinSprite Source { get; set; }
-
-            protected override void LoadComplete()
-            {
-                base.LoadComplete();
-
-                Items = Source.AvailableFiles;
-            }
-        }
-    }
-}
diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs
index 43ada59bcb..c645b0fae4 100644
--- a/osu.Game/Skinning/DefaultSkin.cs
+++ b/osu.Game/Skinning/DefaultSkin.cs
@@ -8,6 +8,7 @@ using osu.Framework.Audio.Sample;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.OpenGL.Textures;
+using osu.Framework.Graphics.Sprites;
 using osu.Framework.Graphics.Textures;
 using osu.Game.Audio;
 using osu.Game.Beatmaps.Formats;
@@ -157,6 +158,9 @@ namespace osu.Game.Skinning
                     break;
             }
 
+            if (GetTexture(component.LookupName) is Texture t)
+                return new Sprite { Texture = t };
+
             return null;
         }
 
diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs
index 392cb2f32b..484faebdc0 100644
--- a/osu.Game/Skinning/Editor/SkinEditor.cs
+++ b/osu.Game/Skinning/Editor/SkinEditor.cs
@@ -21,7 +21,6 @@ using osu.Game.Graphics.UserInterface;
 using osu.Game.Overlays;
 using osu.Game.Screens.Edit.Components;
 using osu.Game.Screens.Edit.Components.Menus;
-using osu.Game.Skinning.Components;
 
 namespace osu.Game.Skinning.Editor
 {
@@ -349,9 +348,9 @@ namespace osu.Game.Skinning.Editor
                 });
 
                 // place component
-                placeComponent(new SkinSprite
+                placeComponent(new SkinnableSprite
                 {
-                    SpriteName = { Value = file.Name }
+                    SpriteName = { Value = Path.GetFileNameWithoutExtension(file.Name) }
                 });
             });
 
diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs
index 4cd1d952db..f2d095f880 100644
--- a/osu.Game/Skinning/Skin.cs
+++ b/osu.Game/Skinning/Skin.cs
@@ -54,7 +54,7 @@ namespace osu.Game.Skinning
             where TLookup : notnull
             where TValue : notnull;
 
-        private readonly RealmBackedResourceStore<SkinInfo> realmBackedStorage;
+        private readonly RealmBackedResourceStore<SkinInfo>? realmBackedStorage;
 
         /// <summary>
         /// Construct a new skin.
diff --git a/osu.Game/Skinning/SkinnableDrawable.cs b/osu.Game/Skinning/SkinnableDrawable.cs
index 72f64e2e12..45409694b5 100644
--- a/osu.Game/Skinning/SkinnableDrawable.cs
+++ b/osu.Game/Skinning/SkinnableDrawable.cs
@@ -31,7 +31,7 @@ namespace osu.Game.Skinning
             set => base.AutoSizeAxes = value;
         }
 
-        private readonly ISkinComponent component;
+        protected readonly ISkinComponent Component;
 
         private readonly ConfineMode confineMode;
 
@@ -49,7 +49,7 @@ namespace osu.Game.Skinning
 
         protected SkinnableDrawable(ISkinComponent component, ConfineMode confineMode = ConfineMode.NoScaling)
         {
-            this.component = component;
+            Component = component;
             this.confineMode = confineMode;
 
             RelativeSizeAxes = Axes.Both;
@@ -75,13 +75,13 @@ namespace osu.Game.Skinning
 
         protected override void SkinChanged(ISkinSource skin)
         {
-            Drawable = skin.GetDrawableComponent(component);
+            Drawable = skin.GetDrawableComponent(Component);
 
             isDefault = false;
 
             if (Drawable == null)
             {
-                Drawable = CreateDefault(component);
+                Drawable = CreateDefault(Component);
                 isDefault = true;
             }
 
diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs
index 38803bd8e3..aa3001fe45 100644
--- a/osu.Game/Skinning/SkinnableSprite.cs
+++ b/osu.Game/Skinning/SkinnableSprite.cs
@@ -1,10 +1,16 @@
 // 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.Collections.Generic;
+using System.IO;
+using System.Linq;
 using osu.Framework.Allocation;
+using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Sprites;
 using osu.Framework.Graphics.Textures;
+using osu.Game.Configuration;
+using osu.Game.Overlays.Settings;
 
 namespace osu.Game.Skinning
 {
@@ -18,9 +24,32 @@ namespace osu.Game.Skinning
         [Resolved]
         private TextureStore textures { get; set; }
 
+        [SettingSource("Sprite name", "The filename of the sprite", SettingControlType = typeof(SpriteSelectorControl))]
+        public Bindable<string> SpriteName { get; } = new Bindable<string>(string.Empty);
+
+        [Resolved]
+        private ISkinSource source { get; set; }
+
+        public IEnumerable<string> AvailableFiles => (source.AllSources.First() as Skin)?.SkinInfo.PerformRead(s => s.Files.Select(f => Path.GetFileNameWithoutExtension(f.Filename)).Distinct());
+
         public SkinnableSprite(string textureName, ConfineMode confineMode = ConfineMode.NoScaling)
             : base(new SpriteComponent(textureName), confineMode)
         {
+            SpriteName.Value = textureName;
+        }
+
+        public SkinnableSprite()
+            : base(new SpriteComponent(string.Empty), ConfineMode.NoScaling)
+        {
+            RelativeSizeAxes = Axes.None;
+            AutoSizeAxes = Axes.Both;
+
+            SpriteName.BindValueChanged(name =>
+            {
+                ((SpriteComponent)Component).LookupName = name.NewValue ?? string.Empty;
+                if (IsLoaded)
+                    SkinChanged(CurrentSkin);
+            });
         }
 
         protected override Drawable CreateDefault(ISkinComponent component)
@@ -33,16 +62,28 @@ namespace osu.Game.Skinning
             return new Sprite { Texture = texture };
         }
 
+        public bool UsesFixedAnchor { get; set; }
+
         private class SpriteComponent : ISkinComponent
         {
+            public string LookupName { get; set; }
+
             public SpriteComponent(string textureName)
             {
                 LookupName = textureName;
             }
-
-            public string LookupName { get; }
         }
 
-        public bool UsesFixedAnchor { get; set; }
+        public class SpriteSelectorControl : SettingsDropdown<string>
+        {
+            public SkinnableSprite Source { get; set; }
+
+            protected override void LoadComplete()
+            {
+                base.LoadComplete();
+
+                Items = Source.AvailableFiles;
+            }
+        }
     }
 }

From 52eeaffce333df069e325c9b4bc69b0ed00bc4d3 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 25 Mar 2022 17:36:02 +0900
Subject: [PATCH 049/113] Limit lookup resources to images

---
 osu.Game/Skinning/SkinnableSprite.cs | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs
index aa3001fe45..f36ae89e25 100644
--- a/osu.Game/Skinning/SkinnableSprite.cs
+++ b/osu.Game/Skinning/SkinnableSprite.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;
 using System.Collections.Generic;
-using System.IO;
 using System.Linq;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
@@ -30,7 +30,12 @@ namespace osu.Game.Skinning
         [Resolved]
         private ISkinSource source { get; set; }
 
-        public IEnumerable<string> AvailableFiles => (source.AllSources.First() as Skin)?.SkinInfo.PerformRead(s => s.Files.Select(f => Path.GetFileNameWithoutExtension(f.Filename)).Distinct());
+        public IEnumerable<string> AvailableFiles => (source.AllSources.First() as Skin)?.SkinInfo.PerformRead(s => s.Files
+                                                                                                                     .Where(f =>
+                                                                                                                         f.Filename.EndsWith(".png", StringComparison.Ordinal)
+                                                                                                                         || f.Filename.EndsWith(".jpg", StringComparison.Ordinal)
+                                                                                                                     )
+                                                                                                                     .Select(f => f.Filename).Distinct());
 
         public SkinnableSprite(string textureName, ConfineMode confineMode = ConfineMode.NoScaling)
             : base(new SpriteComponent(textureName), confineMode)

From 2b7105ac4fc0ab4663a5e7d3fd72c836028a4714 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 14:46:14 +0900
Subject: [PATCH 050/113] Add a default sprite representation to allow better
 placeholder display in skin editor toolbox

---
 osu.Game/Skinning/SkinnableSprite.cs | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs
index f36ae89e25..0005045c00 100644
--- a/osu.Game/Skinning/SkinnableSprite.cs
+++ b/osu.Game/Skinning/SkinnableSprite.cs
@@ -11,6 +11,7 @@ using osu.Framework.Graphics.Sprites;
 using osu.Framework.Graphics.Textures;
 using osu.Game.Configuration;
 using osu.Game.Overlays.Settings;
+using osuTK;
 
 namespace osu.Game.Skinning
 {
@@ -62,7 +63,13 @@ namespace osu.Game.Skinning
             var texture = textures.Get(component.LookupName);
 
             if (texture == null)
-                return null;
+            {
+                return new SpriteIcon
+                {
+                    Size = new Vector2(100),
+                    Icon = FontAwesome.Solid.QuestionCircle
+                };
+            }
 
             return new Sprite { Texture = texture };
         }
@@ -87,7 +94,8 @@ namespace osu.Game.Skinning
             {
                 base.LoadComplete();
 
-                Items = Source.AvailableFiles;
+                if (Source.AvailableFiles.Any())
+                    Items = Source.AvailableFiles;
             }
         }
     }

From 314ad63c6eee361142d0129bab387301bf442231 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 15:14:53 +0900
Subject: [PATCH 051/113] Simplify available file lookup and include file
 extension

---
 osu.Game/Skinning/Editor/SkinEditor.cs |  2 +-
 osu.Game/Skinning/SkinnableSprite.cs   | 24 ++++++++++++------------
 2 files changed, 13 insertions(+), 13 deletions(-)

diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs
index 484faebdc0..d36806a1b3 100644
--- a/osu.Game/Skinning/Editor/SkinEditor.cs
+++ b/osu.Game/Skinning/Editor/SkinEditor.cs
@@ -350,7 +350,7 @@ namespace osu.Game.Skinning.Editor
                 // place component
                 placeComponent(new SkinnableSprite
                 {
-                    SpriteName = { Value = Path.GetFileNameWithoutExtension(file.Name) }
+                    SpriteName = { Value = file.Name }
                 });
             });
 
diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs
index 0005045c00..87490b4397 100644
--- a/osu.Game/Skinning/SkinnableSprite.cs
+++ b/osu.Game/Skinning/SkinnableSprite.cs
@@ -2,7 +2,6 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System;
-using System.Collections.Generic;
 using System.Linq;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
@@ -31,13 +30,6 @@ namespace osu.Game.Skinning
         [Resolved]
         private ISkinSource source { get; set; }
 
-        public IEnumerable<string> AvailableFiles => (source.AllSources.First() as Skin)?.SkinInfo.PerformRead(s => s.Files
-                                                                                                                     .Where(f =>
-                                                                                                                         f.Filename.EndsWith(".png", StringComparison.Ordinal)
-                                                                                                                         || f.Filename.EndsWith(".jpg", StringComparison.Ordinal)
-                                                                                                                     )
-                                                                                                                     .Select(f => f.Filename).Distinct());
-
         public SkinnableSprite(string textureName, ConfineMode confineMode = ConfineMode.NoScaling)
             : base(new SpriteComponent(textureName), confineMode)
         {
@@ -88,14 +80,22 @@ namespace osu.Game.Skinning
 
         public class SpriteSelectorControl : SettingsDropdown<string>
         {
-            public SkinnableSprite Source { get; set; }
-
             protected override void LoadComplete()
             {
                 base.LoadComplete();
 
-                if (Source.AvailableFiles.Any())
-                    Items = Source.AvailableFiles;
+                // Round-about way of getting the user's skin to find available resources.
+                // In the future we'll probably want to allow access to resources from the fallbacks, or potentially other skins
+                // but that requires further thought.
+                var highestPrioritySkin = ((SkinnableSprite)Source).source.AllSources.First() as Skin;
+
+                string[] availableFiles = highestPrioritySkin?.SkinInfo.PerformRead(s => s.Files
+                                                                                          .Where(f => f.Filename.EndsWith(".png", StringComparison.Ordinal)
+                                                                                                      || f.Filename.EndsWith(".jpg", StringComparison.Ordinal))
+                                                                                          .Select(f => f.Filename).Distinct()).ToArray();
+
+                if (availableFiles?.Length > 0)
+                    Items = availableFiles;
             }
         }
     }

From bfd3406f5f69149a251112ce7f3d94ee81fecce0 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 15:49:05 +0900
Subject: [PATCH 052/113] Ensure that file is imported and caches are
 invalidated before placing new sprites

---
 osu.Game/Skinning/Editor/SkinEditor.cs | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs
index d36806a1b3..df0bb7a70c 100644
--- a/osu.Game/Skinning/Editor/SkinEditor.cs
+++ b/osu.Game/Skinning/Editor/SkinEditor.cs
@@ -48,6 +48,9 @@ namespace osu.Game.Skinning.Editor
         [Resolved]
         private OsuColour colours { get; set; }
 
+        [Resolved]
+        private RealmAccess realm { get; set; }
+
         [Resolved(canBeNull: true)]
         private SkinEditorOverlay skinEditorOverlay { get; set; }
 
@@ -347,6 +350,11 @@ namespace osu.Game.Skinning.Editor
                         skins.AddFile(skinInfo, contents, file.Name);
                 });
 
+                // Even though we are 100% on an update thread, we need to wait for realm callbacks to fire (to correctly invalidate caches in RealmBackedResourceStore).
+                // See https://github.com/realm/realm-dotnet/discussions/2634#discussioncomment-2483573 for further discussion.
+                // This is the best we can do for now.
+                realm.Run(r => r.Refresh());
+
                 // place component
                 placeComponent(new SkinnableSprite
                 {

From 6afed5e865ff278970db643719f1377c8cb08660 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 16:00:51 +0900
Subject: [PATCH 053/113] Fix new `SettingsItem` attribute not playing well
 with non-`Drawable`s

---
 osu.Game/Configuration/SettingSourceAttribute.cs | 2 +-
 osu.Game/Overlays/Settings/SettingsItem.cs       | 2 +-
 osu.Game/Skinning/SkinnableSprite.cs             | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs
index 8c84707b88..89f0e73f4f 100644
--- a/osu.Game/Configuration/SettingSourceAttribute.cs
+++ b/osu.Game/Configuration/SettingSourceAttribute.cs
@@ -88,7 +88,7 @@ namespace osu.Game.Configuration
                         throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} had an unsupported custom control type ({controlType.ReadableName()})");
 
                     var control = (Drawable)Activator.CreateInstance(controlType);
-                    controlType.GetProperty(nameof(SettingsItem<object>.Source))?.SetValue(control, obj);
+                    controlType.GetProperty(nameof(SettingsItem<object>.SettingSourceObject))?.SetValue(control, obj);
                     controlType.GetProperty(nameof(SettingsItem<object>.LabelText))?.SetValue(control, attr.Label);
                     controlType.GetProperty(nameof(SettingsItem<object>.TooltipText))?.SetValue(control, attr.Description);
                     controlType.GetProperty(nameof(SettingsItem<object>.Current))?.SetValue(control, value);
diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs
index 6ac5351270..098090bf78 100644
--- a/osu.Game/Overlays/Settings/SettingsItem.cs
+++ b/osu.Game/Overlays/Settings/SettingsItem.cs
@@ -28,7 +28,7 @@ namespace osu.Game.Overlays.Settings
         /// <summary>
         /// The source component if this <see cref="SettingsItem{T}"/> was created via <see cref="SettingSourceAttribute"/>.
         /// </summary>
-        public Drawable Source { get; internal set; }
+        public object SettingSourceObject { get; internal set; }
 
         private IHasCurrentValue<T> controlWithCurrent => Control as IHasCurrentValue<T>;
 
diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs
index 87490b4397..c6cc4c1bdd 100644
--- a/osu.Game/Skinning/SkinnableSprite.cs
+++ b/osu.Game/Skinning/SkinnableSprite.cs
@@ -87,7 +87,7 @@ namespace osu.Game.Skinning
                 // Round-about way of getting the user's skin to find available resources.
                 // In the future we'll probably want to allow access to resources from the fallbacks, or potentially other skins
                 // but that requires further thought.
-                var highestPrioritySkin = ((SkinnableSprite)Source).source.AllSources.First() as Skin;
+                var highestPrioritySkin = ((SkinnableSprite)SettingSourceObject).source.AllSources.First() as Skin;
 
                 string[] availableFiles = highestPrioritySkin?.SkinInfo.PerformRead(s => s.Files
                                                                                           .Where(f => f.Filename.EndsWith(".png", StringComparison.Ordinal)

From f0821ce1fc9ca72c3e1c1534e50ebd913e768a77 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 16:16:49 +0900
Subject: [PATCH 054/113] Import new skin editor sprites to the cursor location

---
 osu.Game/Skinning/Editor/SkinEditor.cs        | 25 +++++++++++++------
 .../Skinning/Editor/SkinSelectionHandler.cs   |  8 +++---
 2 files changed, 21 insertions(+), 12 deletions(-)

diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs
index df0bb7a70c..0208f109da 100644
--- a/osu.Game/Skinning/Editor/SkinEditor.cs
+++ b/osu.Game/Skinning/Editor/SkinEditor.cs
@@ -254,7 +254,7 @@ namespace osu.Game.Skinning.Editor
             placeComponent(component);
         }
 
-        private void placeComponent(ISkinnableDrawable component)
+        private void placeComponent(ISkinnableDrawable component, bool applyDefaults = true)
         {
             var targetContainer = getFirstTarget();
 
@@ -263,10 +263,13 @@ namespace osu.Game.Skinning.Editor
 
             var drawableComponent = (Drawable)component;
 
-            // give newly added components a sane starting location.
-            drawableComponent.Origin = Anchor.TopCentre;
-            drawableComponent.Anchor = Anchor.TopCentre;
-            drawableComponent.Y = targetContainer.DrawSize.Y / 2;
+            if (applyDefaults)
+            {
+                // give newly added components a sane starting location.
+                drawableComponent.Origin = Anchor.TopCentre;
+                drawableComponent.Anchor = Anchor.TopCentre;
+                drawableComponent.Y = targetContainer.DrawSize.Y / 2;
+            }
 
             targetContainer.Add(component);
 
@@ -356,10 +359,16 @@ namespace osu.Game.Skinning.Editor
                 realm.Run(r => r.Refresh());
 
                 // place component
-                placeComponent(new SkinnableSprite
+                var sprite = new SkinnableSprite
                 {
-                    SpriteName = { Value = file.Name }
-                });
+                    SpriteName = { Value = file.Name },
+                    Origin = Anchor.Centre,
+                    Position = getFirstTarget().ToLocalSpace(GetContainingInputManager().CurrentState.Mouse.Position),
+                };
+
+                placeComponent(sprite, false);
+
+                SkinSelectionHandler.ApplyClosestAnchor(sprite);
             });
 
             return Task.CompletedTask;
diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs
index d7fb5c0498..943425e099 100644
--- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs
+++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs
@@ -157,13 +157,13 @@ namespace osu.Game.Skinning.Editor
 
                 if (item.UsesFixedAnchor) continue;
 
-                applyClosestAnchor(drawable);
+                ApplyClosestAnchor(drawable);
             }
 
             return true;
         }
 
-        private static void applyClosestAnchor(Drawable drawable) => applyAnchor(drawable, getClosestAnchor(drawable));
+        public static void ApplyClosestAnchor(Drawable drawable) => applyAnchor(drawable, getClosestAnchor(drawable));
 
         protected override void OnSelectionChanged()
         {
@@ -252,7 +252,7 @@ namespace osu.Game.Skinning.Editor
 
                 if (item.UsesFixedAnchor) continue;
 
-                applyClosestAnchor(drawable);
+                ApplyClosestAnchor(drawable);
             }
         }
 
@@ -279,7 +279,7 @@ namespace osu.Game.Skinning.Editor
             foreach (var item in SelectedItems)
             {
                 item.UsesFixedAnchor = false;
-                applyClosestAnchor((Drawable)item);
+                ApplyClosestAnchor((Drawable)item);
             }
         }
 

From 01681ee8749c3562672e20fe603597c39a467410 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 16:19:00 +0900
Subject: [PATCH 055/113] Add missing `ToArray` call

Not sure where this went, was there in my original commit.
---
 osu.Game/Skinning/SkinManager.cs | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs
index 44b9c69794..832cb01d22 100644
--- a/osu.Game/Skinning/SkinManager.cs
+++ b/osu.Game/Skinning/SkinManager.cs
@@ -145,10 +145,10 @@ namespace osu.Game.Skinning
                 if (!s.Protected)
                     return;
 
-                var existingSkinNames = realm.Run(r => r.All<SkinInfo>()
-                                                        .Where(skin => !skin.DeletePending)
-                                                        .AsEnumerable()
-                                                        .Select(skin => skin.Name));
+                string[] existingSkinNames = realm.Run(r => r.All<SkinInfo>()
+                                                             .Where(skin => !skin.DeletePending)
+                                                             .AsEnumerable()
+                                                             .Select(skin => skin.Name)).ToArray();
 
                 // if the user is attempting to save one of the default skin implementations, create a copy first.
                 var skinInfo = new SkinInfo

From fc3ebe9b510d34b5d75aae931076328b09851d9d Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Fri, 1 Apr 2022 10:40:16 +0300
Subject: [PATCH 056/113] Reword log retrieval steps on mobile platforms

Co-authored-by: Dean Herbert <pe@ppy.sh>
---
 .github/ISSUE_TEMPLATE/bug-issue.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml
index ea5ee298fb..91ca622f55 100644
--- a/.github/ISSUE_TEMPLATE/bug-issue.yml
+++ b/.github/ISSUE_TEMPLATE/bug-issue.yml
@@ -65,8 +65,8 @@ body:
         ### Mobile platforms
 
         The places to find the logs on mobile platforms are as follows:
-          - `Android/data/sh.ppy.osulazer/files/logs` *on Android*
-          - *On iOS*, they can be obtained by connecting your device to your desktop and copying the `logs` directory from the app's own document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer)
+          - *On Android*, navigate to `Android/data/sh.ppy.osulazer/files/logs` using a file browser app.
+          - *On iOS*, connect your device to a PC and copy the `logs` directory from the app's document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer)
 
         ---
 

From 37dea0ff211db68d591a98a008cc3e4c76879ebb Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Fri, 1 Apr 2022 17:05:11 +0900
Subject: [PATCH 057/113] Add failing test case

---
 .../TestSceneMultiplayerPlaylist.cs           | 37 +++++++++++++++++--
 1 file changed, 33 insertions(+), 4 deletions(-)

diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
index cbd8b472b8..f8c0939ea9 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
@@ -2,6 +2,7 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System;
+using System.Collections.Generic;
 using System.Linq;
 using NUnit.Framework;
 using osu.Framework.Allocation;
@@ -13,6 +14,7 @@ using osu.Framework.Testing;
 using osu.Game.Beatmaps;
 using osu.Game.Graphics.Sprites;
 using osu.Game.Graphics.UserInterface;
+using osu.Game.Online.API.Requests.Responses;
 using osu.Game.Online.Multiplayer;
 using osu.Game.Online.Rooms;
 using osu.Game.Rulesets;
@@ -183,14 +185,41 @@ namespace osu.Game.Tests.Visual.Multiplayer
             assertItemInHistoryListStep(2, 0);
         }
 
+        [Test]
+        public void TestInsertedItemDoesNotRefreshAllOthers()
+        {
+            AddStep("change to round robin queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayersRoundRobin }).WaitSafely());
+
+            // Add a few items for the local user.
+            addItemStep();
+            addItemStep();
+            addItemStep();
+            addItemStep();
+            addItemStep();
+
+            DrawableRoomPlaylistItem[] drawableItems = null;
+            AddStep("get drawable items", () => drawableItems = this.ChildrenOfType<DrawableRoomPlaylistItem>().ToArray());
+
+            // Add 1 item for another user.
+            AddStep("join second user", () => MultiplayerClient.AddUser(new APIUser { Id = 10 }));
+            addItemStep(userId: 10);
+
+            // New item inserted towards the top of the list.
+            assertItemInQueueListStep(7, 1);
+            AddAssert("all previous playlist items remained", () => drawableItems.All(this.ChildrenOfType<DrawableRoomPlaylistItem>().Contains));
+        }
+
         /// <summary>
         /// Adds a step to create a new playlist item.
         /// </summary>
-        private void addItemStep(bool expired = false) => AddStep("add item", () => MultiplayerClient.AddPlaylistItem(new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)
+        private void addItemStep(bool expired = false, int? userId = null) => AddStep("add item", () =>
         {
-            Expired = expired,
-            PlayedAt = DateTimeOffset.Now
-        })));
+            MultiplayerClient.AddUserPlaylistItem(userId ?? API.LocalUser.Value.OnlineID, new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)
+            {
+                Expired = expired,
+                PlayedAt = DateTimeOffset.Now
+            })).WaitSafely();
+        });
 
         /// <summary>
         /// Asserts the position of a given playlist item in the queue list.

From 16d4544ff9b4ca72fcd0899c9cd56a1a92f91246 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Fri, 1 Apr 2022 17:06:37 +0900
Subject: [PATCH 058/113] Prevent reloads when playlist item order changes

---
 osu.Game/Online/Rooms/PlaylistItem.cs         |  6 ++++--
 .../Match/Playlist/MultiplayerPlaylist.cs     | 20 +++++++++++++++++--
 2 files changed, 22 insertions(+), 4 deletions(-)

diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs
index f696362cbb..a56851cfe6 100644
--- a/osu.Game/Online/Rooms/PlaylistItem.cs
+++ b/osu.Game/Online/Rooms/PlaylistItem.cs
@@ -11,6 +11,7 @@ using osu.Framework.Bindables;
 using osu.Game.Beatmaps;
 using osu.Game.Online.API;
 using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Utils;
 
 namespace osu.Game.Online.Rooms
 {
@@ -101,13 +102,13 @@ namespace osu.Game.Online.Rooms
 
         #endregion
 
-        public PlaylistItem With(IBeatmapInfo beatmap) => new PlaylistItem(beatmap)
+        public PlaylistItem With(Optional<IBeatmapInfo> beatmap = default, Optional<ushort?> playlistOrder = default) => new PlaylistItem(beatmap.GetOr(Beatmap))
         {
             ID = ID,
             OwnerID = OwnerID,
             RulesetID = RulesetID,
             Expired = Expired,
-            PlaylistOrder = PlaylistOrder,
+            PlaylistOrder = playlistOrder.GetOr(PlaylistOrder),
             PlayedAt = PlayedAt,
             AllowedMods = AllowedMods,
             RequiredMods = RequiredMods,
@@ -119,6 +120,7 @@ namespace osu.Game.Online.Rooms
                && Beatmap.OnlineID == other.Beatmap.OnlineID
                && RulesetID == other.RulesetID
                && Expired == other.Expired
+               && PlaylistOrder == other.PlaylistOrder
                && AllowedMods.SequenceEqual(other.AllowedMods)
                && RequiredMods.SequenceEqual(other.RequiredMods);
     }
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs
index 879a21e7c1..41f548a630 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs
@@ -117,8 +117,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
         {
             base.PlaylistItemChanged(item);
 
-            removeItemFromLists(item.ID);
-            addItemToLists(item);
+            var newApiItem = Playlist.SingleOrDefault(i => i.ID == item.ID);
+            var existingApiItemInQueue = queueList.Items.SingleOrDefault(i => i.ID == item.ID);
+
+            // Test if the only change between the two playlist items is the order.
+            if (newApiItem != null && existingApiItemInQueue != null && existingApiItemInQueue.With(playlistOrder: newApiItem.PlaylistOrder).Equals(newApiItem))
+            {
+                // Set the new playlist order directly without refreshing the DrawablePlaylistItem.
+                existingApiItemInQueue.PlaylistOrder = newApiItem.PlaylistOrder;
+
+                // The following isn't really required, but is here for safety and explicitness.
+                // MultiplayerQueueList internally binds to changes in Playlist to invalidate its own layout, which is mutated on every playlist operation.
+                queueList.Invalidate();
+            }
+            else
+            {
+                removeItemFromLists(item.ID);
+                addItemToLists(item);
+            }
         }
 
         private void addItemToLists(MultiplayerPlaylistItem item)

From 6e6271d0c0ac4e00b0b2fd18b124e3cd4a0a6ca2 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Fri, 1 Apr 2022 18:31:17 +0900
Subject: [PATCH 059/113] Fix "server-side" room playlist not updated

Remove unused using
---
 .../TestSceneMultiplayerPlaylist.cs           |  1 -
 osu.Game/Online/Rooms/PlaylistItem.cs         | 13 ++++++++
 .../Multiplayer/TestMultiplayerClient.cs      | 33 +++++++++++++++----
 3 files changed, 39 insertions(+), 8 deletions(-)

diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
index f8c0939ea9..1231866b36 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
@@ -2,7 +2,6 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System;
-using System.Collections.Generic;
 using System.Linq;
 using NUnit.Framework;
 using osu.Framework.Allocation;
diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs
index a56851cfe6..6ec884d79c 100644
--- a/osu.Game/Online/Rooms/PlaylistItem.cs
+++ b/osu.Game/Online/Rooms/PlaylistItem.cs
@@ -85,6 +85,19 @@ namespace osu.Game.Online.Rooms
             Beatmap = beatmap;
         }
 
+        public PlaylistItem(MultiplayerPlaylistItem item)
+            : this(new APIBeatmap { OnlineID = item.BeatmapID })
+        {
+            ID = item.ID;
+            OwnerID = item.OwnerID;
+            RulesetID = item.RulesetID;
+            Expired = item.Expired;
+            PlaylistOrder = item.PlaylistOrder;
+            PlayedAt = item.PlayedAt;
+            RequiredMods = item.RequiredMods.ToArray();
+            AllowedMods = item.AllowedMods.ToArray();
+        }
+
         public void MarkInvalid() => valid.Value = false;
 
         #region Newtonsoft.Json implicit ShouldSerialize() methods
diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
index b9304f713d..0efaf16f99 100644
--- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
+++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
@@ -31,7 +31,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
         public override IBindable<bool> IsConnected => isConnected;
         private readonly Bindable<bool> isConnected = new Bindable<bool>(true);
 
+        /// <summary>
+        /// The local client's <see cref="Room"/>. This is not always equivalent to the server-side room.
+        /// </summary>
         public new Room? APIRoom => base.APIRoom;
+
         public Action<MultiplayerRoom>? RoomSetupAction;
 
         public bool RoomJoined { get; private set; }
@@ -46,6 +50,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
         /// </summary>
         private readonly List<MultiplayerPlaylistItem> serverSidePlaylist = new List<MultiplayerPlaylistItem>();
 
+        /// <summary>
+        /// Guaranteed up-to-date API room.
+        /// </summary>
+        private Room? serverSideAPIRoom;
+
         private MultiplayerPlaylistItem? currentItem => Room?.Playlist[currentIndex];
         private int currentIndex;
         private long lastPlaylistItemId;
@@ -192,13 +201,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
 
         protected override async Task<MultiplayerRoom> JoinRoom(long roomId, string? password = null)
         {
-            var apiRoom = roomManager.ServerSideRooms.Single(r => r.RoomID.Value == roomId);
+            serverSideAPIRoom = roomManager.ServerSideRooms.Single(r => r.RoomID.Value == roomId);
 
-            if (password != apiRoom.Password.Value)
+            if (password != serverSideAPIRoom.Password.Value)
                 throw new InvalidOperationException("Invalid password.");
 
             serverSidePlaylist.Clear();
-            serverSidePlaylist.AddRange(apiRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)));
+            serverSidePlaylist.AddRange(serverSideAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)));
             lastPlaylistItemId = serverSidePlaylist.Max(item => item.ID);
 
             var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id)
@@ -210,11 +219,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
             {
                 Settings =
                 {
-                    Name = apiRoom.Name.Value,
-                    MatchType = apiRoom.Type.Value,
+                    Name = serverSideAPIRoom.Name.Value,
+                    MatchType = serverSideAPIRoom.Type.Value,
                     Password = password,
-                    QueueMode = apiRoom.QueueMode.Value,
-                    AutoStartDuration = apiRoom.AutoStartDuration.Value
+                    QueueMode = serverSideAPIRoom.QueueMode.Value,
+                    AutoStartDuration = serverSideAPIRoom.AutoStartDuration.Value
                 },
                 Playlist = serverSidePlaylist.ToList(),
                 Users = { localUser },
@@ -479,6 +488,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
         {
             Debug.Assert(Room != null);
             Debug.Assert(APIRoom != null);
+            Debug.Assert(serverSideAPIRoom != null);
 
             var item = serverSidePlaylist.Find(i => i.ID == playlistItemId);
 
@@ -495,6 +505,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
                 throw new InvalidOperationException("Attempted to remove an item which has already been played.");
 
             serverSidePlaylist.Remove(item);
+            serverSideAPIRoom.Playlist.RemoveAll(i => i.ID == item.ID);
             await ((IMultiplayerClient)this).PlaylistItemRemoved(playlistItemId).ConfigureAwait(false);
 
             await updateCurrentItem(Room).ConfigureAwait(false);
@@ -576,10 +587,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
         private async Task addItem(MultiplayerPlaylistItem item)
         {
             Debug.Assert(Room != null);
+            Debug.Assert(serverSideAPIRoom != null);
 
             item.ID = ++lastPlaylistItemId;
 
             serverSidePlaylist.Add(item);
+            serverSideAPIRoom.Playlist.Add(new PlaylistItem(item));
             await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false);
 
             await updatePlaylistOrder(Room).ConfigureAwait(false);
@@ -603,6 +616,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
 
         private async Task updatePlaylistOrder(MultiplayerRoom room)
         {
+            Debug.Assert(serverSideAPIRoom != null);
+
             List<MultiplayerPlaylistItem> orderedActiveItems;
 
             switch (room.Settings.QueueMode)
@@ -648,6 +663,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
 
                 await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false);
             }
+
+            // Also ensure that the API room's playlist is correct.
+            foreach (var item in serverSideAPIRoom.Playlist)
+                item.PlaylistOrder = serverSidePlaylist.Single(i => i.ID == item.ID).PlaylistOrder;
         }
     }
 }

From 43d03f28255858659aac9c2f5078593ba0ecfd24 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Apr 2022 19:30:16 +0900
Subject: [PATCH 060/113] Put `ToArray` call in correct place in brackets

---
 osu.Game/Skinning/SkinManager.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs
index 832cb01d22..71920fb166 100644
--- a/osu.Game/Skinning/SkinManager.cs
+++ b/osu.Game/Skinning/SkinManager.cs
@@ -148,7 +148,7 @@ namespace osu.Game.Skinning
                 string[] existingSkinNames = realm.Run(r => r.All<SkinInfo>()
                                                              .Where(skin => !skin.DeletePending)
                                                              .AsEnumerable()
-                                                             .Select(skin => skin.Name)).ToArray();
+                                                             .Select(skin => skin.Name).ToArray());
 
                 // if the user is attempting to save one of the default skin implementations, create a copy first.
                 var skinInfo = new SkinInfo

From 0f4b75ab15241c71ac89389a19586cd04ee821b7 Mon Sep 17 00:00:00 2001
From: Jamie Taylor <me@nekodex.net>
Date: Fri, 1 Apr 2022 21:33:57 +0900
Subject: [PATCH 061/113] Add multiplayer lobby countdown SFX

---
 .../Match/MultiplayerReadyButton.cs           | 23 +++++++++++++++++++
 1 file changed, 23 insertions(+)

diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
index 62be9ad3bd..8068e80534 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
@@ -5,6 +5,8 @@ using System;
 using System.Linq;
 using JetBrains.Annotations;
 using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Audio.Sample;
 using osu.Framework.Localisation;
 using osu.Framework.Threading;
 using osu.Game.Graphics;
@@ -27,6 +29,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
         [CanBeNull]
         private MultiplayerRoom room => multiplayerClient.Room;
 
+        private Sample countdownTickSample;
+        private Sample countdownTickFinalSample;
+        private int? lastTickPlayed;
+
+        [BackgroundDependencyLoader]
+        private void load(AudioManager audio)
+        {
+            countdownTickSample = audio.Samples.Get(@"Multiplayer/countdown-tick");
+            countdownTickFinalSample = audio.Samples.Get(@"Multiplayer/countdown-tick-final");
+        }
+
         protected override void LoadComplete()
         {
             base.LoadComplete();
@@ -83,6 +96,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
                 else
                     countdownRemaining = countdown.TimeRemaining - timeElapsed;
 
+                if (countdownRemaining.Seconds <= 10 && (lastTickPlayed == null || lastTickPlayed != countdownRemaining.Seconds))
+                {
+                    countdownTickSample?.Play();
+
+                    if (countdownRemaining.Seconds <= 3)
+                        countdownTickFinalSample?.Play();
+
+                    lastTickPlayed = countdownRemaining.Seconds;
+                }
+
                 string countdownText = $"Starting in {countdownRemaining:mm\\:ss}";
 
                 switch (localUser?.State)

From b07152a119d3df101600ff18356c476ffdce9326 Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Sat, 2 Apr 2022 02:35:37 +0200
Subject: [PATCH 062/113] Initial attempt at writing a test for the
 toolbarClock

I'll add more once I know if this code passes review or not
---
 .../Visual/Menus/TestSceneToolbarClock.cs     | 20 ++++++++++++++++++-
 1 file changed, 19 insertions(+), 1 deletion(-)

diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
index 064d6f82fd..0b5904aa2b 100644
--- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
+++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
@@ -16,6 +16,7 @@ namespace osu.Game.Tests.Visual.Menus
     public class TestSceneToolbarClock : OsuManualInputManagerTestScene
     {
         private readonly Container mainContainer;
+        private readonly ToolbarClock toolbarClock;
 
         public TestSceneToolbarClock()
         {
@@ -49,7 +50,7 @@ namespace osu.Game.Tests.Visual.Menus
                                     RelativeSizeAxes = Axes.Y,
                                     Width = 2,
                                 },
-                                new ToolbarClock(),
+                                toolbarClock = new ToolbarClock(),
                                 new Box
                                 {
                                     Colour = Color4.DarkRed,
@@ -76,5 +77,22 @@ namespace osu.Game.Tests.Visual.Menus
         {
             AddStep("Set game time long", () => mainContainer.Clock = new FramedOffsetClock(Clock, false) { Offset = 3600.0 * 24 * 1000 * 98 });
         }
+
+        [Test]
+        public void TestHoverBackground()
+        {
+            Box hoverBackground = null;
+
+            AddStep("Retrieve hover background", () => hoverBackground = (Box)toolbarClock.Children[0]);
+
+            AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(mainContainer, new Vector2(0,200)));
+            AddAssert("Hover background is not visible", () => hoverBackground.Alpha == 0);
+
+            AddStep("Move mouse on top of clock", () => InputManager.MoveMouseTo(mainContainer));
+            AddAssert("Hover background is visible", () => hoverBackground.Alpha != 0);
+            
+            AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(mainContainer, new Vector2(0,200)));
+            AddUntilStep("Hover background is not visible", () => hoverBackground.Alpha == 0);
+        }
     }
 }

From 6685c97147a92e71fa27b8632a69ae592ad535f4 Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Sat, 2 Apr 2022 02:43:44 +0200
Subject: [PATCH 063/113] mainContainer -> toolbarClock

---
 osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
index 0b5904aa2b..a17e4030ff 100644
--- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
+++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
@@ -85,13 +85,13 @@ namespace osu.Game.Tests.Visual.Menus
 
             AddStep("Retrieve hover background", () => hoverBackground = (Box)toolbarClock.Children[0]);
 
-            AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(mainContainer, new Vector2(0,200)));
+            AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(toolbarClock, new Vector2(0,200)));
             AddAssert("Hover background is not visible", () => hoverBackground.Alpha == 0);
 
-            AddStep("Move mouse on top of clock", () => InputManager.MoveMouseTo(mainContainer));
+            AddStep("Move mouse on top of clock", () => InputManager.MoveMouseTo(toolbarClock));
             AddAssert("Hover background is visible", () => hoverBackground.Alpha != 0);
-            
-            AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(mainContainer, new Vector2(0,200)));
+
+            AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(toolbarClock, new Vector2(0,200)));
             AddUntilStep("Hover background is not visible", () => hoverBackground.Alpha == 0);
         }
     }

From dc744f18ff7700d1cadd4ac9ad7cfa883a3ecabc Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Sat, 2 Apr 2022 02:43:51 +0200
Subject: [PATCH 064/113] Trim whitespace

---
 osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs | 2 --
 1 file changed, 2 deletions(-)

diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
index a17e4030ff..b20ec7cbe5 100644
--- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
+++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
@@ -87,10 +87,8 @@ namespace osu.Game.Tests.Visual.Menus
 
             AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(toolbarClock, new Vector2(0,200)));
             AddAssert("Hover background is not visible", () => hoverBackground.Alpha == 0);
-
             AddStep("Move mouse on top of clock", () => InputManager.MoveMouseTo(toolbarClock));
             AddAssert("Hover background is visible", () => hoverBackground.Alpha != 0);
-
             AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(toolbarClock, new Vector2(0,200)));
             AddUntilStep("Hover background is not visible", () => hoverBackground.Alpha == 0);
         }

From 9350f6f5f841684c70864ea64b059e29b0001f95 Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Sat, 2 Apr 2022 02:59:07 +0200
Subject: [PATCH 065/113] Add spaces around commas in Vector2 construction

---
 osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
index b20ec7cbe5..df61e56011 100644
--- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
+++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
@@ -85,11 +85,11 @@ namespace osu.Game.Tests.Visual.Menus
 
             AddStep("Retrieve hover background", () => hoverBackground = (Box)toolbarClock.Children[0]);
 
-            AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(toolbarClock, new Vector2(0,200)));
+            AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(toolbarClock, new Vector2(0, 200)));
             AddAssert("Hover background is not visible", () => hoverBackground.Alpha == 0);
             AddStep("Move mouse on top of clock", () => InputManager.MoveMouseTo(toolbarClock));
             AddAssert("Hover background is visible", () => hoverBackground.Alpha != 0);
-            AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(toolbarClock, new Vector2(0,200)));
+            AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(toolbarClock, new Vector2(0, 200)));
             AddUntilStep("Hover background is not visible", () => hoverBackground.Alpha == 0);
         }
     }

From 95ccac50d4c1f2dfbeefbea492f69f3b06979830 Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Sat, 2 Apr 2022 04:26:16 +0200
Subject: [PATCH 066/113] Add display mode changing test

Yup this is gonna fail horribly
---
 .../Visual/Menus/TestSceneToolbarClock.cs     | 28 +++++++++++++++++++
 1 file changed, 28 insertions(+)

diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
index df61e56011..7643e5032b 100644
--- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
+++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
@@ -2,10 +2,13 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
 using osu.Framework.Timing;
+using osu.Game.Configuration;
 using osu.Game.Overlays.Toolbar;
 using osuTK;
 using osuTK.Graphics;
@@ -15,6 +18,8 @@ namespace osu.Game.Tests.Visual.Menus
     [TestFixture]
     public class TestSceneToolbarClock : OsuManualInputManagerTestScene
     {
+        private Bindable<ToolbarClockDisplayMode> clockDisplayMode;
+
         private readonly Container mainContainer;
         private readonly ToolbarClock toolbarClock;
 
@@ -66,6 +71,12 @@ namespace osu.Game.Tests.Visual.Menus
             AddSliderStep("scale", 0.5, 4, 1, scale => mainContainer.Scale = new Vector2((float)scale));
         }
 
+        [BackgroundDependencyLoader]
+        private void load(OsuConfigManager config)
+        {
+            clockDisplayMode = config.GetBindable<ToolbarClockDisplayMode>(OsuSetting.ToolbarClockDisplayMode);
+        }
+
         [Test]
         public void TestRealGameTime()
         {
@@ -92,5 +103,22 @@ namespace osu.Game.Tests.Visual.Menus
             AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(toolbarClock, new Vector2(0, 200)));
             AddUntilStep("Hover background is not visible", () => hoverBackground.Alpha == 0);
         }
+
+        [Test]
+        public void TestDisplayModeChange()
+        {
+            ToolbarClockDisplayMode initialDisplayMode = 0;
+
+            AddStep("Retrieve current state", () => initialDisplayMode = (ToolbarClockDisplayMode)clockDisplayMode.Value);
+
+            AddStep("Trigger click", () => toolbarClock.TriggerClick());
+            AddAssert("State changed from initial", () => clockDisplayMode.Value != initialDisplayMode);
+            AddStep("Trigger click", () => toolbarClock.TriggerClick());
+            AddAssert("State changed from initial", () => clockDisplayMode.Value != initialDisplayMode);
+            AddStep("Trigger click", () => toolbarClock.TriggerClick());
+            AddAssert("State changed from initial", () => clockDisplayMode.Value != initialDisplayMode);
+            AddStep("Trigger click", () => toolbarClock.TriggerClick());
+            AddAssert("State is equal to initial", () => clockDisplayMode.Value == initialDisplayMode);
+        }
     }
 }

From 245e452d41fcc2af69007f36f1f33122f2805bd5 Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Sat, 2 Apr 2022 04:31:43 +0200
Subject: [PATCH 067/113] Remove redundant typecast I accidentally left in
 (Thanks InspectCode)

---
 osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
index 7643e5032b..c1b8cd919d 100644
--- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
+++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
@@ -109,7 +109,7 @@ namespace osu.Game.Tests.Visual.Menus
         {
             ToolbarClockDisplayMode initialDisplayMode = 0;
 
-            AddStep("Retrieve current state", () => initialDisplayMode = (ToolbarClockDisplayMode)clockDisplayMode.Value);
+            AddStep("Retrieve current state", () => initialDisplayMode = clockDisplayMode.Value);
 
             AddStep("Trigger click", () => toolbarClock.TriggerClick());
             AddAssert("State changed from initial", () => clockDisplayMode.Value != initialDisplayMode);

From ee9696855ba9e1098d33353179f903283a2cb69a Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Sat, 2 Apr 2022 04:41:05 +0200
Subject: [PATCH 068/113] Remove hover test

---
 .../Visual/Menus/TestSceneToolbarClock.cs         | 15 ---------------
 1 file changed, 15 deletions(-)

diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
index c1b8cd919d..269b3cbad0 100644
--- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
+++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
@@ -89,21 +89,6 @@ namespace osu.Game.Tests.Visual.Menus
             AddStep("Set game time long", () => mainContainer.Clock = new FramedOffsetClock(Clock, false) { Offset = 3600.0 * 24 * 1000 * 98 });
         }
 
-        [Test]
-        public void TestHoverBackground()
-        {
-            Box hoverBackground = null;
-
-            AddStep("Retrieve hover background", () => hoverBackground = (Box)toolbarClock.Children[0]);
-
-            AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(toolbarClock, new Vector2(0, 200)));
-            AddAssert("Hover background is not visible", () => hoverBackground.Alpha == 0);
-            AddStep("Move mouse on top of clock", () => InputManager.MoveMouseTo(toolbarClock));
-            AddAssert("Hover background is visible", () => hoverBackground.Alpha != 0);
-            AddStep("Move mouse away from clock", () => InputManager.MoveMouseTo(toolbarClock, new Vector2(0, 200)));
-            AddUntilStep("Hover background is not visible", () => hoverBackground.Alpha == 0);
-        }
-
         [Test]
         public void TestDisplayModeChange()
         {

From a1baced7774603a400e74c191ec6f3811a66f728 Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Sat, 2 Apr 2022 04:45:21 +0200
Subject: [PATCH 069/113] Change behavior of the display mode test

I remembered to run InspectCode this time, all good
---
 osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
index 269b3cbad0..2cf73f5442 100644
--- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
+++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
@@ -92,12 +92,10 @@ namespace osu.Game.Tests.Visual.Menus
         [Test]
         public void TestDisplayModeChange()
         {
-            ToolbarClockDisplayMode initialDisplayMode = 0;
-
-            AddStep("Retrieve current state", () => initialDisplayMode = clockDisplayMode.Value);
+            AddStep("Set clock display mode", () => clockDisplayMode.Value = ToolbarClockDisplayMode.Full);
 
             AddStep("Trigger click", () => toolbarClock.TriggerClick());
-            AddAssert("State changed from initial", () => clockDisplayMode.Value != initialDisplayMode);
+            AddAssert("State is digital with runtime", () => clockDisplayMode.Value == ToolbarClockDisplayMode.DigitalWithRuntime);
             AddStep("Trigger click", () => toolbarClock.TriggerClick());
             AddAssert("State changed from initial", () => clockDisplayMode.Value != initialDisplayMode);
             AddStep("Trigger click", () => toolbarClock.TriggerClick());

From c103cee1d5dce8e5b157f594ebc9c9af0825363e Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Sat, 2 Apr 2022 04:53:47 +0200
Subject: [PATCH 070/113] Need to commit the second half again because my Git
 UI messed up

---
 osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
index 2cf73f5442..87d836687f 100644
--- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
+++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs
@@ -97,11 +97,11 @@ namespace osu.Game.Tests.Visual.Menus
             AddStep("Trigger click", () => toolbarClock.TriggerClick());
             AddAssert("State is digital with runtime", () => clockDisplayMode.Value == ToolbarClockDisplayMode.DigitalWithRuntime);
             AddStep("Trigger click", () => toolbarClock.TriggerClick());
-            AddAssert("State changed from initial", () => clockDisplayMode.Value != initialDisplayMode);
+            AddAssert("State is digital", () => clockDisplayMode.Value == ToolbarClockDisplayMode.Digital);
             AddStep("Trigger click", () => toolbarClock.TriggerClick());
-            AddAssert("State changed from initial", () => clockDisplayMode.Value != initialDisplayMode);
+            AddAssert("State is analog", () => clockDisplayMode.Value == ToolbarClockDisplayMode.Analog);
             AddStep("Trigger click", () => toolbarClock.TriggerClick());
-            AddAssert("State is equal to initial", () => clockDisplayMode.Value == initialDisplayMode);
+            AddAssert("State is full", () => clockDisplayMode.Value == ToolbarClockDisplayMode.Full);
         }
     }
 }

From a252c4cad57cae5472adb69101e288e46ab2c18c Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sat, 2 Apr 2022 18:08:23 +0300
Subject: [PATCH 071/113] Add random pass/play count data in test scene

---
 .../Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs    | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs
index be3fc7aff9..82b34c50c2 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs
@@ -71,7 +71,9 @@ namespace osu.Game.Tests.Visual.Online
                 {
                     Fails = Enumerable.Range(1, 100).Select(_ => RNG.Next(10)).ToArray(),
                     Retries = Enumerable.Range(-2, 100).Select(_ => RNG.Next(10)).ToArray(),
-                }
+                },
+                PassCount = RNG.Next(0, 999),
+                PlayCount = RNG.Next(1000, 1999),
             };
         }
 

From ced5be33e6ac062126ba7352c31eddf00683f303 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sat, 2 Apr 2022 18:08:57 +0300
Subject: [PATCH 072/113] Display pass/play count in success rate percentage
 tooltip

---
 osu.Game/Overlays/BeatmapSet/SuccessRate.cs | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs
index e08f099226..fed3d7ddaa 100644
--- a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs
+++ b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs
@@ -5,6 +5,8 @@ using osu.Framework.Allocation;
 using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
+using osu.Framework.Localisation;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Sprites;
 using osu.Game.Graphics.UserInterface;
@@ -19,7 +21,7 @@ namespace osu.Game.Overlays.BeatmapSet
         protected readonly FailRetryGraph Graph;
 
         private readonly FillFlowContainer header;
-        private readonly OsuSpriteText successPercent;
+        private readonly SuccessRatePercentage successPercent;
         private readonly Bar successRate;
         private readonly Container percentContainer;
 
@@ -45,6 +47,7 @@ namespace osu.Game.Overlays.BeatmapSet
 
             float rate = playCount != 0 ? (float)passCount / playCount : 0;
             successPercent.Text = rate.ToLocalisableString(@"0.#%");
+            successPercent.TooltipText = $"{passCount} / {playCount}";
             successRate.Length = rate;
             percentContainer.ResizeWidthTo(successRate.Length, 250, Easing.InOutCubic);
 
@@ -80,7 +83,7 @@ namespace osu.Game.Overlays.BeatmapSet
                             RelativeSizeAxes = Axes.X,
                             AutoSizeAxes = Axes.Y,
                             Width = 0f,
-                            Child = successPercent = new OsuSpriteText
+                            Child = successPercent = new SuccessRatePercentage
                             {
                                 Anchor = Anchor.TopRight,
                                 Origin = Anchor.TopCentre,
@@ -121,5 +124,10 @@ namespace osu.Game.Overlays.BeatmapSet
 
             Graph.Padding = new MarginPadding { Top = header.DrawHeight };
         }
+
+        private class SuccessRatePercentage : OsuSpriteText, IHasTooltip
+        {
+            public LocalisableString TooltipText { get; set; }
+        }
     }
 }

From c4635f3c03f1ef7aac5a153459d0b55d8e068259 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sat, 2 Apr 2022 18:36:32 +0300
Subject: [PATCH 073/113] Add failing test case

---
 .../Online/TestSceneBeatmapListingOverlay.cs  | 37 ++++++++++++++++---
 1 file changed, 31 insertions(+), 6 deletions(-)

diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
index a056e0cd2c..159c49ebea 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
@@ -5,9 +5,11 @@ using System;
 using System.Collections.Generic;
 using System.Linq;
 using NUnit.Framework;
+using osu.Framework.Allocation;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Testing;
 using osu.Game.Beatmaps.Drawables.Cards;
+using osu.Game.Configuration;
 using osu.Game.Graphics.Containers;
 using osu.Game.Graphics.UserInterface;
 using osu.Game.Online.API;
@@ -29,6 +31,14 @@ namespace osu.Game.Tests.Visual.Online
 
         private BeatmapListingSearchControl searchControl => overlay.ChildrenOfType<BeatmapListingSearchControl>().Single();
 
+        private OsuConfigManager localConfig;
+
+        [BackgroundDependencyLoader]
+        private void load()
+        {
+            Dependencies.Cache(localConfig = new OsuConfigManager(LocalStorage));
+        }
+
         [SetUpSteps]
         public void SetUpSteps()
         {
@@ -61,6 +71,8 @@ namespace osu.Game.Tests.Visual.Online
                     Id = API.LocalUser.Value.Id + 1,
                 };
             });
+
+            AddStep("reset size", () => localConfig.SetValue(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal));
         }
 
         [Test]
@@ -120,24 +132,25 @@ namespace osu.Game.Tests.Visual.Online
             assertAllCardsOfType<BeatmapCardNormal>(30);
         }
 
-        [Test]
-        public void TestCardSizeSwitching()
+        [TestCase(false)]
+        [TestCase(true)]
+        public void TestCardSizeSwitching(bool viaConfig)
         {
             AddAssert("is visible", () => overlay.State.Value == Visibility.Visible);
 
             AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray()));
             assertAllCardsOfType<BeatmapCardNormal>(100);
 
-            setCardSize(BeatmapCardSize.Extra);
+            setCardSize(BeatmapCardSize.Extra, viaConfig);
             assertAllCardsOfType<BeatmapCardExtra>(100);
 
-            setCardSize(BeatmapCardSize.Normal);
+            setCardSize(BeatmapCardSize.Normal, viaConfig);
             assertAllCardsOfType<BeatmapCardNormal>(100);
 
             AddStep("fetch for 0 beatmaps", () => fetchFor());
             AddUntilStep("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true);
 
-            setCardSize(BeatmapCardSize.Extra);
+            setCardSize(BeatmapCardSize.Extra, viaConfig);
             AddAssert("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true);
         }
 
@@ -361,7 +374,13 @@ namespace osu.Game.Tests.Visual.Online
             AddUntilStep("\"no maps found\" placeholder not shown", () => !overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().Any(d => d.IsPresent));
         }
 
-        private void setCardSize(BeatmapCardSize cardSize) => AddStep($"set card size to {cardSize}", () => overlay.ChildrenOfType<BeatmapListingCardSizeTabControl>().Single().Current.Value = cardSize);
+        private void setCardSize(BeatmapCardSize cardSize, bool viaConfig) => AddStep($"set card size to {cardSize}", () =>
+        {
+            if (!viaConfig)
+                overlay.ChildrenOfType<BeatmapListingCardSizeTabControl>().Single().Current.Value = cardSize;
+            else
+                localConfig.SetValue(OsuSetting.BeatmapListingCardSize, cardSize);
+        });
 
         private void assertAllCardsOfType<T>(int expectedCount)
             where T : BeatmapCard =>
@@ -370,5 +389,11 @@ namespace osu.Game.Tests.Visual.Online
                 int loadedCorrectCount = this.ChildrenOfType<BeatmapCard>().Count(card => card.IsLoaded && card.GetType() == typeof(T));
                 return loadedCorrectCount > 0 && loadedCorrectCount == expectedCount;
             });
+
+        protected override void Dispose(bool isDisposing)
+        {
+            localConfig?.Dispose();
+            base.Dispose(isDisposing);
+        }
     }
 }

From beb8426d3b866743fdaebf84e82d2e518bc3769c Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sat, 2 Apr 2022 18:36:49 +0300
Subject: [PATCH 074/113] Save beatmap listing card size to game config

---
 osu.Game/Configuration/OsuConfigManager.cs             |  4 ++++
 .../BeatmapListing/BeatmapListingFilterControl.cs      | 10 +++++++++-
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs
index e8f13ba902..2f966ac0a9 100644
--- a/osu.Game/Configuration/OsuConfigManager.cs
+++ b/osu.Game/Configuration/OsuConfigManager.cs
@@ -10,6 +10,7 @@ using osu.Framework.Extensions.LocalisationExtensions;
 using osu.Framework.Localisation;
 using osu.Framework.Platform;
 using osu.Framework.Testing;
+using osu.Game.Beatmaps.Drawables.Cards;
 using osu.Game.Input;
 using osu.Game.Input.Bindings;
 using osu.Game.Localisation;
@@ -44,6 +45,8 @@ namespace osu.Game.Configuration
 
             SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f);
 
+            SetDefault(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal);
+
             SetDefault(OsuSetting.ToolbarClockDisplayMode, ToolbarClockDisplayMode.Full);
 
             // Online settings
@@ -297,6 +300,7 @@ namespace osu.Game.Configuration
         RandomSelectAlgorithm,
         ShowFpsDisplay,
         ChatDisplayHeight,
+        BeatmapListingCardSize,
         ToolbarClockDisplayMode,
         Version,
         ShowConvertedBeatmaps,
diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs
index 0f87f04270..e4628e3723 100644
--- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs
+++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs
@@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes;
 using osu.Framework.Localisation;
 using osu.Framework.Threading;
 using osu.Game.Beatmaps.Drawables.Cards;
+using osu.Game.Configuration;
 using osu.Game.Online.API;
 using osu.Game.Online.API.Requests;
 using osu.Game.Online.API.Requests.Responses;
@@ -53,7 +54,9 @@ namespace osu.Game.Overlays.BeatmapListing
         /// <summary>
         /// The currently selected <see cref="BeatmapCardSize"/>.
         /// </summary>
-        public IBindable<BeatmapCardSize> CardSize { get; } = new Bindable<BeatmapCardSize>();
+        public IBindable<BeatmapCardSize> CardSize => cardSize;
+
+        private readonly Bindable<BeatmapCardSize> cardSize = new Bindable<BeatmapCardSize>();
 
         private readonly BeatmapListingSearchControl searchControl;
         private readonly BeatmapListingSortTabControl sortControl;
@@ -128,6 +131,9 @@ namespace osu.Game.Overlays.BeatmapListing
             };
         }
 
+        [Resolved]
+        private OsuConfigManager config { get; set; }
+
         [BackgroundDependencyLoader]
         private void load(OverlayColourProvider colourProvider, IAPIProvider api)
         {
@@ -141,6 +147,8 @@ namespace osu.Game.Overlays.BeatmapListing
         {
             base.LoadComplete();
 
+            config.BindWith(OsuSetting.BeatmapListingCardSize, cardSize);
+
             var sortCriteria = sortControl.Current;
             var sortDirection = sortControl.SortDirection;
 

From b9421d1415af7e15c07b3f7237603170ea4a7a92 Mon Sep 17 00:00:00 2001
From: Jai Sharma <jai@jai.moe>
Date: Sat, 2 Apr 2022 17:14:27 +0100
Subject: [PATCH 075/113] Simplify text clear and placeholder change in
 `ChatTextBox`

---
 osu.Game/Overlays/Chat/ChatTextBox.cs | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Overlays/Chat/ChatTextBox.cs b/osu.Game/Overlays/Chat/ChatTextBox.cs
index 35ed26cda3..e0f949caba 100644
--- a/osu.Game/Overlays/Chat/ChatTextBox.cs
+++ b/osu.Game/Overlays/Chat/ChatTextBox.cs
@@ -20,8 +20,10 @@ namespace osu.Game.Overlays.Chat
 
             ShowSearch.BindValueChanged(change =>
             {
-                PlaceholderText = change.NewValue ? "type here to search" : "type here";
-                Schedule(() => Text = string.Empty);
+                bool showSearch = change.NewValue;
+
+                PlaceholderText = showSearch ? "type here to search" : "type here";
+                Text = string.Empty;
             }, true);
         }
 

From 2297073b7eec019a1553735e7fd846f9488aad34 Mon Sep 17 00:00:00 2001
From: Jai Sharma <jai@jai.moe>
Date: Sat, 2 Apr 2022 17:15:19 +0100
Subject: [PATCH 076/113] Use `OnChatMessageCommit` & `OnSearchTermsChanged`
 events in `ChatTextBar`

---
 .../Visual/Online/TestSceneChatTextBox.cs     | 39 ++++++++++++++++---
 osu.Game/Overlays/Chat/ChatTextBar.cs         | 31 +++++++++++++--
 2 files changed, 60 insertions(+), 10 deletions(-)

diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
index e72a1d6652..982fbc397d 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
@@ -25,6 +25,7 @@ namespace osu.Game.Tests.Visual.Online
         private readonly Bindable<Channel> currentChannel = new Bindable<Channel>();
 
         private OsuSpriteText commitText;
+        private OsuSpriteText searchText;
         private ChatTextBar bar;
 
         [SetUp]
@@ -47,11 +48,32 @@ namespace osu.Game.Tests.Visual.Online
                     {
                         new Drawable[]
                         {
-                            commitText = new OsuSpriteText
+                            new GridContainer
                             {
-                                Anchor = Anchor.TopCentre,
-                                Origin = Anchor.TopCentre,
-                                Font = OsuFont.Default.With(size: 20),
+                                RelativeSizeAxes = Axes.Both,
+                                ColumnDimensions = new[]
+                                {
+                                    new Dimension(),
+                                    new Dimension(),
+                                },
+                                Content = new[]
+                                {
+                                    new Drawable[]
+                                    {
+                                        commitText = new OsuSpriteText
+                                        {
+                                            Anchor = Anchor.TopCentre,
+                                            Origin = Anchor.TopCentre,
+                                            Font = OsuFont.Default.With(size: 20),
+                                        },
+                                        searchText = new OsuSpriteText
+                                        {
+                                            Anchor = Anchor.TopCentre,
+                                            Origin = Anchor.TopCentre,
+                                            Font = OsuFont.Default.With(size: 20),
+                                        },
+                                    },
+                                },
                             },
                         },
                         new Drawable[]
@@ -66,12 +88,17 @@ namespace osu.Game.Tests.Visual.Online
                     },
                 };
 
-                bar.TextBox.OnCommit += (sender, newText) =>
+                bar.OnChatMessageCommit += (sender, newText) =>
                 {
-                    commitText.Text = $"Commit: {sender.Text}";
+                    commitText.Text = $"OnChatMessageCommit: {sender.Text}";
                     commitText.FadeOutFromOne(1000, Easing.InQuint);
                     sender.Text = string.Empty;
                 };
+
+                bar.OnSearchTermsChanged += (text) =>
+                {
+                    searchText.Text = $"OnSearchTermsChanged: {text}";
+                };
             });
         }
 
diff --git a/osu.Game/Overlays/Chat/ChatTextBar.cs b/osu.Game/Overlays/Chat/ChatTextBar.cs
index d7edbb83b6..51c74ee0a5 100644
--- a/osu.Game/Overlays/Chat/ChatTextBar.cs
+++ b/osu.Game/Overlays/Chat/ChatTextBar.cs
@@ -3,15 +3,15 @@
 
 #nullable enable
 
+using System;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
 using osu.Framework.Graphics.Sprites;
-using osu.Game.Graphics;
+using osu.Framework.Graphics.UserInterface;
 using osu.Game.Graphics.Containers;
-using osu.Game.Graphics.Sprites;
 using osu.Game.Online.Chat;
 using osuTK;
 
@@ -21,13 +21,16 @@ namespace osu.Game.Overlays.Chat
     {
         public readonly BindableBool ShowSearch = new BindableBool();
 
-        public ChatTextBox TextBox { get; private set; } = null!;
+        public event TextBox.OnCommitHandler? OnChatMessageCommit;
+
+        public event Action<string>? OnSearchTermsChanged;
 
         [Resolved]
         private Bindable<Channel> currentChannel { get; set; } = null!;
 
         private OsuTextFlowContainer chattingTextContainer = null!;
         private Container searchIconContainer = null!;
+        private ChatTextBox chatTextBox = null!;
 
         private const float chatting_text_width = 180;
         private const float search_icon_width = 40;
@@ -86,7 +89,7 @@ namespace osu.Game.Overlays.Chat
                             {
                                 RelativeSizeAxes = Axes.Both,
                                 Padding = new MarginPadding { Right = 5 },
-                                Child = TextBox = new ChatTextBox
+                                Child = chatTextBox = new ChatTextBox
                                 {
                                     Anchor = Anchor.CentreLeft,
                                     Origin = Anchor.CentreLeft,
@@ -106,12 +109,20 @@ namespace osu.Game.Overlays.Chat
         {
             base.LoadComplete();
 
+            chatTextBox.Current.ValueChanged += chatTextBoxChange;
+            chatTextBox.OnCommit += chatTextBoxCommit;
+
             ShowSearch.BindValueChanged(change =>
             {
                 bool showSearch = change.NewValue;
 
                 chattingTextContainer.FadeTo(showSearch ? 0 : 1);
                 searchIconContainer.FadeTo(showSearch ? 1 : 0);
+
+                // Clear search terms if any exist when switching back to chat mode
+                if (!showSearch)
+                    OnSearchTermsChanged?.Invoke(string.Empty);
+
             }, true);
 
             currentChannel.BindValueChanged(change =>
@@ -134,5 +145,17 @@ namespace osu.Game.Overlays.Chat
                 }
             }, true);
         }
+
+        private void chatTextBoxChange(ValueChangedEvent<string> change)
+        {
+            if (ShowSearch.Value)
+                OnSearchTermsChanged?.Invoke(change.NewValue);
+        }
+
+        private void chatTextBoxCommit(TextBox sender, bool newText)
+        {
+            if (!ShowSearch.Value)
+                OnChatMessageCommit?.Invoke(sender, newText);
+        }
     }
 }

From 8534dd34632508c05edb0fd6132aca68023c845f Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sat, 2 Apr 2022 19:24:16 +0300
Subject: [PATCH 077/113] Simplify `TestCase` attributes to one `Values`
 attribute
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
---
 .../Visual/Online/TestSceneBeatmapListingOverlay.cs          | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
index 159c49ebea..9a7bd17902 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
@@ -132,9 +132,8 @@ namespace osu.Game.Tests.Visual.Online
             assertAllCardsOfType<BeatmapCardNormal>(30);
         }
 
-        [TestCase(false)]
-        [TestCase(true)]
-        public void TestCardSizeSwitching(bool viaConfig)
+        [Test]
+        public void TestCardSizeSwitching([Values] bool viaConfig)
         {
             AddAssert("is visible", () => overlay.State.Value == Visibility.Visible);
 

From 9e152cd3fd8aa48354e8bc34bfe3589d2ba77ad4 Mon Sep 17 00:00:00 2001
From: Jai Sharma <jai@jai.moe>
Date: Sat, 2 Apr 2022 17:27:44 +0100
Subject: [PATCH 078/113] Fix code quality issues

---
 osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs | 2 +-
 osu.Game/Overlays/Chat/ChatTextBar.cs                | 1 -
 2 files changed, 1 insertion(+), 2 deletions(-)

diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
index 982fbc397d..42d4a8efbd 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
@@ -95,7 +95,7 @@ namespace osu.Game.Tests.Visual.Online
                     sender.Text = string.Empty;
                 };
 
-                bar.OnSearchTermsChanged += (text) =>
+                bar.OnSearchTermsChanged += text =>
                 {
                     searchText.Text = $"OnSearchTermsChanged: {text}";
                 };
diff --git a/osu.Game/Overlays/Chat/ChatTextBar.cs b/osu.Game/Overlays/Chat/ChatTextBar.cs
index 51c74ee0a5..0eb8056d76 100644
--- a/osu.Game/Overlays/Chat/ChatTextBar.cs
+++ b/osu.Game/Overlays/Chat/ChatTextBar.cs
@@ -122,7 +122,6 @@ namespace osu.Game.Overlays.Chat
                 // Clear search terms if any exist when switching back to chat mode
                 if (!showSearch)
                     OnSearchTermsChanged?.Invoke(string.Empty);
-
             }, true);
 
             currentChannel.BindValueChanged(change =>

From b815f685fc67d869fa99ebd65a8ae49972122a22 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sat, 2 Apr 2022 19:28:33 +0300
Subject: [PATCH 079/113] Flip `viaConfig` conditional branch

---
 .../Visual/Online/TestSceneBeatmapListingOverlay.cs         | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
index 9a7bd17902..5999125013 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
@@ -375,10 +375,10 @@ namespace osu.Game.Tests.Visual.Online
 
         private void setCardSize(BeatmapCardSize cardSize, bool viaConfig) => AddStep($"set card size to {cardSize}", () =>
         {
-            if (!viaConfig)
-                overlay.ChildrenOfType<BeatmapListingCardSizeTabControl>().Single().Current.Value = cardSize;
-            else
+            if (viaConfig)
                 localConfig.SetValue(OsuSetting.BeatmapListingCardSize, cardSize);
+            else
+                overlay.ChildrenOfType<BeatmapListingCardSizeTabControl>().Single().Current.Value = cardSize;
         });
 
         private void assertAllCardsOfType<T>(int expectedCount)

From 0f9461689085cb790de5a3850d051a344d3f0fc2 Mon Sep 17 00:00:00 2001
From: Ame <ajiiisai@protonmail.com>
Date: Sat, 2 Apr 2022 19:41:15 +0200
Subject: [PATCH 080/113] Make overlay shortcuts able to be toggled instead of
 repeatable

---
 osu.Game/OsuGame.cs | 24 +++++++++++++++++++++++-
 1 file changed, 23 insertions(+), 1 deletion(-)

diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index 73121f6e7d..3703318e81 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -95,6 +95,8 @@ namespace osu.Game
 
         private SkinEditorOverlay skinEditor;
 
+        private NowPlayingOverlay nowPlayingOverlay;
+
         private Container overlayContent;
 
         private Container rightFloatingOverlayContent;
@@ -818,7 +820,7 @@ namespace osu.Game
                 Origin = Anchor.TopRight,
             }, rightFloatingOverlayContent.Add, true);
 
-            loadComponentSingleFile(new NowPlayingOverlay
+            loadComponentSingleFile(nowPlayingOverlay = new NowPlayingOverlay
             {
                 Anchor = Anchor.TopRight,
                 Origin = Anchor.TopRight,
@@ -1069,6 +1071,26 @@ namespace osu.Game
 
                     SkinManager.SelectRandomSkin();
                     return true;
+
+                case GlobalAction.ToggleChat:
+                    chatOverlay.ToggleVisibility();
+                    return true;
+
+                case GlobalAction.ToggleSocial:
+                    dashboard.ToggleVisibility();
+                    return true;
+
+                case GlobalAction.ToggleNowPlaying:
+                    nowPlayingOverlay.ToggleVisibility();
+                    return true;
+
+                case GlobalAction.ToggleBeatmapListing:
+                    beatmapListing.ToggleVisibility();
+                    return true;
+
+                case GlobalAction.ToggleSettings:
+                    Settings.ToggleVisibility();
+                    return true;
             }
 
             return false;

From 35629e9be815c41788633363c38ec9f6450669ea Mon Sep 17 00:00:00 2001
From: Ame <ajiiisai@protonmail.com>
Date: Sat, 2 Apr 2022 20:45:00 +0200
Subject: [PATCH 081/113] General fix for ToolbarButton toggle repetition

---
 osu.Game/OsuGame.cs                        | 24 +---------------------
 osu.Game/Overlays/Toolbar/ToolbarButton.cs |  2 +-
 2 files changed, 2 insertions(+), 24 deletions(-)

diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index 3703318e81..73121f6e7d 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -95,8 +95,6 @@ namespace osu.Game
 
         private SkinEditorOverlay skinEditor;
 
-        private NowPlayingOverlay nowPlayingOverlay;
-
         private Container overlayContent;
 
         private Container rightFloatingOverlayContent;
@@ -820,7 +818,7 @@ namespace osu.Game
                 Origin = Anchor.TopRight,
             }, rightFloatingOverlayContent.Add, true);
 
-            loadComponentSingleFile(nowPlayingOverlay = new NowPlayingOverlay
+            loadComponentSingleFile(new NowPlayingOverlay
             {
                 Anchor = Anchor.TopRight,
                 Origin = Anchor.TopRight,
@@ -1071,26 +1069,6 @@ namespace osu.Game
 
                     SkinManager.SelectRandomSkin();
                     return true;
-
-                case GlobalAction.ToggleChat:
-                    chatOverlay.ToggleVisibility();
-                    return true;
-
-                case GlobalAction.ToggleSocial:
-                    dashboard.ToggleVisibility();
-                    return true;
-
-                case GlobalAction.ToggleNowPlaying:
-                    nowPlayingOverlay.ToggleVisibility();
-                    return true;
-
-                case GlobalAction.ToggleBeatmapListing:
-                    beatmapListing.ToggleVisibility();
-                    return true;
-
-                case GlobalAction.ToggleSettings:
-                    Settings.ToggleVisibility();
-                    return true;
             }
 
             return false;
diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs
index c855b76680..670ce65c6b 100644
--- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs
+++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs
@@ -190,7 +190,7 @@ namespace osu.Game.Overlays.Toolbar
 
         public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
         {
-            if (e.Action == Hotkey)
+            if (!e.Repeat && e.Action == Hotkey)
             {
                 TriggerClick();
                 return true;

From 01b10e68d2fc8de98912f715bb5e02599d4ba9a9 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sat, 2 Apr 2022 23:47:20 +0300
Subject: [PATCH 082/113] Adjust taiko hit objects sizes to match osu!(stable)

---
 osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs           | 2 +-
 osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs
index f047c03f4b..1a1fde1990 100644
--- a/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs
@@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Objects
         /// <summary>
         /// Default size of a drawable taiko hit object.
         /// </summary>
-        public const float DEFAULT_SIZE = 0.45f;
+        public const float DEFAULT_SIZE = 0.475f;
 
         public override Judgement CreateJudgement() => new TaikoJudgement();
 
diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs
index 6c17573b50..43a099b900 100644
--- a/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs
@@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Taiko.Objects
         /// <summary>
         /// Scale multiplier for a strong drawable taiko hit object.
         /// </summary>
-        public const float STRONG_SCALE = 1.4f;
+        public const float STRONG_SCALE = 1.525f;
 
         /// <summary>
         /// Default size of a strong drawable taiko hit object.

From 94fa5e2ef20ab519484638d34be6bbb5238a256c Mon Sep 17 00:00:00 2001
From: Jai Sharma <jai@jai.moe>
Date: Sat, 2 Apr 2022 21:58:54 +0100
Subject: [PATCH 083/113] Use `Action<string>` for event
 `OnChatMessageCommitted` & clear textbox internally

---
 osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs | 5 ++---
 osu.Game/Overlays/Chat/ChatTextBar.cs                | 6 ++++--
 2 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
index 42d4a8efbd..e5dd492183 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
@@ -88,11 +88,10 @@ namespace osu.Game.Tests.Visual.Online
                     },
                 };
 
-                bar.OnChatMessageCommit += (sender, newText) =>
+                bar.OnChatMessageCommitted += text =>
                 {
-                    commitText.Text = $"OnChatMessageCommit: {sender.Text}";
+                    commitText.Text = $"OnChatMessageCommitted: {text}";
                     commitText.FadeOutFromOne(1000, Easing.InQuint);
-                    sender.Text = string.Empty;
                 };
 
                 bar.OnSearchTermsChanged += text =>
diff --git a/osu.Game/Overlays/Chat/ChatTextBar.cs b/osu.Game/Overlays/Chat/ChatTextBar.cs
index 0eb8056d76..531c417abc 100644
--- a/osu.Game/Overlays/Chat/ChatTextBar.cs
+++ b/osu.Game/Overlays/Chat/ChatTextBar.cs
@@ -21,7 +21,7 @@ namespace osu.Game.Overlays.Chat
     {
         public readonly BindableBool ShowSearch = new BindableBool();
 
-        public event TextBox.OnCommitHandler? OnChatMessageCommit;
+        public event Action<string>? OnChatMessageCommitted;
 
         public event Action<string>? OnSearchTermsChanged;
 
@@ -154,7 +154,9 @@ namespace osu.Game.Overlays.Chat
         private void chatTextBoxCommit(TextBox sender, bool newText)
         {
             if (!ShowSearch.Value)
-                OnChatMessageCommit?.Invoke(sender, newText);
+                OnChatMessageCommitted?.Invoke(sender.Text);
+
+            sender.Text = string.Empty;
         }
     }
 }

From 4ce69890d4fbad0f7990fe6ceb645cf05ae5252a Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sun, 3 Apr 2022 00:21:23 +0300
Subject: [PATCH 084/113] Use `TaikoHitObject.DEFAULT_SIZE` for default circle
 piece symbol size

---
 osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs
index a106c4f629..f2452ad88c 100644
--- a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs
+++ b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs
@@ -11,6 +11,7 @@ using osu.Game.Beatmaps.ControlPoints;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Backgrounds;
 using osu.Game.Graphics.Containers;
+using osu.Game.Rulesets.Taiko.Objects;
 using osuTK.Graphics;
 
 namespace osu.Game.Rulesets.Taiko.Skinning.Default
@@ -24,8 +25,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default
     /// </summary>
     public abstract class CirclePiece : BeatSyncedContainer, IHasAccentColour
     {
-        public const float SYMBOL_SIZE = 0.45f;
+        public const float SYMBOL_SIZE = TaikoHitObject.DEFAULT_SIZE;
         public const float SYMBOL_BORDER = 8;
+
         private const double pre_beat_transition_time = 80;
 
         private Color4 accentColour;

From 534cc18ff9ff6d1a8f5726bef122a3ff4c79c006 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Sun, 3 Apr 2022 01:21:48 +0300
Subject: [PATCH 085/113] Adjust osu!taiko legacy hit target size to match
 osu!(stable)

---
 .../Skinning/Legacy/TaikoLegacyHitTarget.cs                   | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyHitTarget.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyHitTarget.cs
index 9feb2054da..c4657fcc49 100644
--- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyHitTarget.cs
+++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyHitTarget.cs
@@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
                     new Sprite
                     {
                         Texture = skin.GetTexture("approachcircle"),
-                        Scale = new Vector2(0.73f),
+                        Scale = new Vector2(0.83f),
                         Alpha = 0.47f, // eyeballed to match stable
                         Anchor = Anchor.Centre,
                         Origin = Anchor.Centre,
@@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
                     new Sprite
                     {
                         Texture = skin.GetTexture("taikobigcircle"),
-                        Scale = new Vector2(0.7f),
+                        Scale = new Vector2(0.8f),
                         Alpha = 0.22f, // eyeballed to match stable
                         Anchor = Anchor.Centre,
                         Origin = Anchor.Centre,

From 970b1951ace07653526d5633f7975019ba006b68 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sun, 3 Apr 2022 14:23:45 +0200
Subject: [PATCH 086/113] Rewrite logic slightly to better convey meaning of
 textbox clear

---
 osu.Game/Overlays/Chat/ChatTextBar.cs | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Overlays/Chat/ChatTextBar.cs b/osu.Game/Overlays/Chat/ChatTextBar.cs
index 531c417abc..ef20149dac 100644
--- a/osu.Game/Overlays/Chat/ChatTextBar.cs
+++ b/osu.Game/Overlays/Chat/ChatTextBar.cs
@@ -153,9 +153,10 @@ namespace osu.Game.Overlays.Chat
 
         private void chatTextBoxCommit(TextBox sender, bool newText)
         {
-            if (!ShowSearch.Value)
-                OnChatMessageCommitted?.Invoke(sender.Text);
+            if (ShowSearch.Value)
+                return;
 
+            OnChatMessageCommitted?.Invoke(sender.Text);
             sender.Text = string.Empty;
         }
     }

From 6d1844adc3a9b01b8d33a07bdf56fcdc6d671c4d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sun, 3 Apr 2022 14:27:37 +0200
Subject: [PATCH 087/113] Use `nameof()` in test to reference event names

---
 osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
index e5dd492183..a241aa0517 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
@@ -90,13 +90,13 @@ namespace osu.Game.Tests.Visual.Online
 
                 bar.OnChatMessageCommitted += text =>
                 {
-                    commitText.Text = $"OnChatMessageCommitted: {text}";
+                    commitText.Text = $"{nameof(bar.OnChatMessageCommitted)}: {text}";
                     commitText.FadeOutFromOne(1000, Easing.InQuint);
                 };
 
                 bar.OnSearchTermsChanged += text =>
                 {
-                    searchText.Text = $"OnSearchTermsChanged: {text}";
+                    searchText.Text = $"{nameof(bar.OnSearchTermsChanged)}: {text}";
                 };
             });
         }

From 1393e3628be595cc9f2ab67cc7ee92d453e2acf0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sun, 3 Apr 2022 15:24:00 +0200
Subject: [PATCH 088/113] Invert operands for better readability

---
 osu.Game/Overlays/Toolbar/ToolbarButton.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs
index 670ce65c6b..4a839b048c 100644
--- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs
+++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs
@@ -190,7 +190,7 @@ namespace osu.Game.Overlays.Toolbar
 
         public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
         {
-            if (!e.Repeat && e.Action == Hotkey)
+            if (e.Action == Hotkey && !e.Repeat)
             {
                 TriggerClick();
                 return true;

From e1f147a207a42f47370ac42527226212e23d3f9c Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Mon, 4 Apr 2022 13:46:41 +0900
Subject: [PATCH 089/113] Mutate playlist in EditUserPlaylistItem

---
 osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
index 0efaf16f99..4a974cf61d 100644
--- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
+++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
@@ -458,8 +458,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
         public async Task EditUserPlaylistItem(int userId, MultiplayerPlaylistItem item)
         {
             Debug.Assert(Room != null);
-            Debug.Assert(APIRoom != null);
             Debug.Assert(currentItem != null);
+            Debug.Assert(serverSideAPIRoom != null);
 
             item.OwnerID = userId;
 
@@ -478,6 +478,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
             item.PlaylistOrder = existingItem.PlaylistOrder;
 
             serverSidePlaylist[serverSidePlaylist.IndexOf(existingItem)] = item;
+            serverSideAPIRoom.Playlist[serverSideAPIRoom.Playlist.IndexOf(serverSideAPIRoom.Playlist.Single(i => i.ID == item.ID))] = new PlaylistItem(item);
 
             await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false);
         }

From 39c6eed81969fe883264c04610c4b05ea0968816 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 4 Apr 2022 14:10:57 +0900
Subject: [PATCH 090/113] Update resources

---
 osu.Android.props        | 2 +-
 osu.Game/osu.Game.csproj | 2 +-
 osu.iOS.props            | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/osu.Android.props b/osu.Android.props
index 6a3b113fa2..200008017f 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -51,7 +51,7 @@
     <Reference Include="Java.Interop" />
   </ItemGroup>
   <ItemGroup>
-    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.325.0" />
+    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.404.0" />
     <PackageReference Include="ppy.osu.Framework.Android" Version="2022.325.0" />
   </ItemGroup>
   <ItemGroup Label="Transitive Dependencies">
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 3c01f29671..18faf318a6 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -37,7 +37,7 @@
     </PackageReference>
     <PackageReference Include="Realm" Version="10.10.0" />
     <PackageReference Include="ppy.osu.Framework" Version="2022.325.0" />
-    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.325.0" />
+    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.404.0" />
     <PackageReference Include="Sentry" Version="3.14.1" />
     <PackageReference Include="SharpCompress" Version="0.30.1" />
     <PackageReference Include="NUnit" Version="3.13.2" />
diff --git a/osu.iOS.props b/osu.iOS.props
index c8f170497d..b0c382c695 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -62,7 +62,7 @@
   </ItemGroup>
   <ItemGroup Label="Package References">
     <PackageReference Include="ppy.osu.Framework.iOS" Version="2022.325.0" />
-    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.325.0" />
+    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.404.0" />
   </ItemGroup>
   <!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) -->
   <PropertyGroup>

From 0abebe4d23a17cfd772fe1b700ddefb2a2427a57 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 4 Apr 2022 14:36:03 +0900
Subject: [PATCH 091/113] Stabilise countdown updates to be based on when whole
 seconds change

---
 .../Match/MultiplayerReadyButton.cs           | 50 ++++++++++++++-----
 1 file changed, 37 insertions(+), 13 deletions(-)

diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
index 62be9ad3bd..a20d250ea8 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
@@ -47,17 +47,33 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
                 countdownChangeTime = DateTimeOffset.Now;
             }
 
+            scheduleNextCountdownUpdate();
+
+            updateButtonText();
+            updateButtonColour();
+        });
+
+        private void scheduleNextCountdownUpdate()
+        {
             if (countdown != null)
-                countdownUpdateDelegate ??= Scheduler.AddDelayed(updateButtonText, 100, true);
+            {
+                // If a countdown is active, schedule relevant components to update on the next whole second.
+                double timeToNextSecond = countdownTimeRemaining.TotalMilliseconds % 1000;
+
+                countdownUpdateDelegate = Scheduler.AddDelayed(onCountdownTick, timeToNextSecond);
+            }
             else
             {
                 countdownUpdateDelegate?.Cancel();
                 countdownUpdateDelegate = null;
             }
 
-            updateButtonText();
-            updateButtonColour();
-        });
+            void onCountdownTick()
+            {
+                updateButtonText();
+                scheduleNextCountdownUpdate();
+            }
+        }
 
         private void updateButtonText()
         {
@@ -75,15 +91,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
 
             if (countdown != null)
             {
-                TimeSpan timeElapsed = DateTimeOffset.Now - countdownChangeTime;
-                TimeSpan countdownRemaining;
-
-                if (timeElapsed > countdown.TimeRemaining)
-                    countdownRemaining = TimeSpan.Zero;
-                else
-                    countdownRemaining = countdown.TimeRemaining - timeElapsed;
-
-                string countdownText = $"Starting in {countdownRemaining:mm\\:ss}";
+                string countdownText = $"Starting in {countdownTimeRemaining:mm\\:ss}";
 
                 switch (localUser?.State)
                 {
@@ -116,6 +124,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
             }
         }
 
+        private TimeSpan countdownTimeRemaining
+        {
+            get
+            {
+                TimeSpan timeElapsed = DateTimeOffset.Now - countdownChangeTime;
+                TimeSpan remaining;
+
+                if (timeElapsed > countdown.TimeRemaining)
+                    remaining = TimeSpan.Zero;
+                else
+                    remaining = countdown.TimeRemaining - timeElapsed;
+
+                return remaining;
+            }
+        }
+
         private void updateButtonColour()
         {
             if (room == null)

From 09e15f5496a8ecee0f0c77f3743a3b664a150da9 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 4 Apr 2022 20:27:03 +0900
Subject: [PATCH 092/113] Remove nullable on `RealmBackedResourceStore` realm
 parameter

---
 osu.Game/Skinning/RealmBackedResourceStore.cs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs
index e727a7e59a..c81e976a67 100644
--- a/osu.Game/Skinning/RealmBackedResourceStore.cs
+++ b/osu.Game/Skinning/RealmBackedResourceStore.cs
@@ -24,7 +24,7 @@ namespace osu.Game.Skinning
         private readonly Live<T> liveSource;
         private readonly IDisposable? realmSubscription;
 
-        public RealmBackedResourceStore(Live<T> source, IResourceStore<byte[]> underlyingStore, RealmAccess? realm)
+        public RealmBackedResourceStore(Live<T> source, IResourceStore<byte[]> underlyingStore, RealmAccess realm)
             : base(underlyingStore)
         {
             liveSource = source;
@@ -32,7 +32,7 @@ namespace osu.Game.Skinning
             invalidateCache();
             Debug.Assert(fileToStoragePathMapping != null);
 
-            realmSubscription = realm?.RegisterForNotifications(r => r.All<T>().Where(s => s.ID == source.ID), skinChanged);
+            realmSubscription = realm.RegisterForNotifications(r => r.All<T>().Where(s => s.ID == source.ID), skinChanged);
         }
 
         protected override void Dispose(bool disposing)

From 300feadf6a9adba8120db8dc3e50fcef5c33c99d Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 4 Apr 2022 20:27:46 +0900
Subject: [PATCH 093/113] Update `SkinnableSprite` to match more broad usage

---
 osu.Game/Skinning/SkinnableSprite.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs
index c6cc4c1bdd..e7c62302b1 100644
--- a/osu.Game/Skinning/SkinnableSprite.cs
+++ b/osu.Game/Skinning/SkinnableSprite.cs
@@ -15,7 +15,7 @@ using osuTK;
 namespace osu.Game.Skinning
 {
     /// <summary>
-    /// A skinnable element which uses a stable sprite and can therefore share implementation logic.
+    /// A skinnable element which uses a single texture backing.
     /// </summary>
     public class SkinnableSprite : SkinnableDrawable, ISkinnableDrawable
     {

From dac5dfde8f8cee98be41887a5b4c94444efe3953 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 4 Apr 2022 20:28:43 +0900
Subject: [PATCH 094/113] Remove unnecessary `LazyThreadSafetyMode`
 specification

---
 osu.Game/Skinning/RealmBackedResourceStore.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs
index c81e976a67..0353b8a64d 100644
--- a/osu.Game/Skinning/RealmBackedResourceStore.cs
+++ b/osu.Game/Skinning/RealmBackedResourceStore.cs
@@ -61,7 +61,7 @@ namespace osu.Game.Skinning
             return null;
         }
 
-        private void invalidateCache() => fileToStoragePathMapping = new Lazy<Dictionary<string, string>>(initialiseFileCache, LazyThreadSafetyMode.ExecutionAndPublication);
+        private void invalidateCache() => fileToStoragePathMapping = new Lazy<Dictionary<string, string>>(initialiseFileCache);
 
         private Dictionary<string, string> initialiseFileCache() => liveSource.PerformRead(source =>
         {

From de30a42558b7ab5d0e03cae2ed80d574cb694732 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 4 Apr 2022 20:30:14 +0900
Subject: [PATCH 095/113] Add `region` for import methods and move `Dispose` to
 end of time

---
 osu.Game/Skinning/Editor/SkinEditor.cs | 18 +++++++++++-------
 1 file changed, 11 insertions(+), 7 deletions(-)

diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs
index df0bb7a70c..607a881d28 100644
--- a/osu.Game/Skinning/Editor/SkinEditor.cs
+++ b/osu.Game/Skinning/Editor/SkinEditor.cs
@@ -197,13 +197,6 @@ namespace osu.Game.Skinning.Editor
             SelectedComponents.BindCollectionChanged((_, __) => Scheduler.AddOnce(populateSettings), true);
         }
 
-        protected override void Dispose(bool isDisposing)
-        {
-            base.Dispose(isDisposing);
-
-            game?.UnregisterImportHandler(this);
-        }
-
         public void UpdateTargetScreen(Drawable targetScreen)
         {
             this.targetScreen = targetScreen;
@@ -337,6 +330,8 @@ namespace osu.Game.Skinning.Editor
                 availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item);
         }
 
+        #region Drag & drop import handling
+
         public Task Import(params string[] paths)
         {
             Schedule(() =>
@@ -368,5 +363,14 @@ namespace osu.Game.Skinning.Editor
         public Task Import(params ImportTask[] tasks) => throw new NotImplementedException();
 
         public IEnumerable<string> HandledExtensions => new[] { ".jpg", ".jpeg", ".png" };
+
+        #endregion
+
+        protected override void Dispose(bool isDisposing)
+        {
+            base.Dispose(isDisposing);
+
+            game?.UnregisterImportHandler(this);
+        }
     }
 }

From 8185020f128dcda5d5a1feb236b02e7a3fbd51d7 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 4 Apr 2022 20:35:48 +0900
Subject: [PATCH 096/113] Improve the visual of the missing sprite sprite

---
 osu.Game/Skinning/RealmBackedResourceStore.cs |  1 -
 osu.Game/Skinning/SkinnableSprite.cs          | 33 +++++++++++++++----
 2 files changed, 26 insertions(+), 8 deletions(-)

diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs
index 0353b8a64d..0057132044 100644
--- a/osu.Game/Skinning/RealmBackedResourceStore.cs
+++ b/osu.Game/Skinning/RealmBackedResourceStore.cs
@@ -7,7 +7,6 @@ using System;
 using System.Collections.Generic;
 using System.Diagnostics;
 using System.Linq;
-using System.Threading;
 using osu.Framework.Extensions;
 using osu.Framework.IO.Stores;
 using osu.Game.Database;
diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs
index e7c62302b1..c5f110f908 100644
--- a/osu.Game/Skinning/SkinnableSprite.cs
+++ b/osu.Game/Skinning/SkinnableSprite.cs
@@ -6,9 +6,11 @@ using System.Linq;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Sprites;
 using osu.Framework.Graphics.Textures;
 using osu.Game.Configuration;
+using osu.Game.Graphics.Sprites;
 using osu.Game.Overlays.Settings;
 using osuTK;
 
@@ -55,13 +57,7 @@ namespace osu.Game.Skinning
             var texture = textures.Get(component.LookupName);
 
             if (texture == null)
-            {
-                return new SpriteIcon
-                {
-                    Size = new Vector2(100),
-                    Icon = FontAwesome.Solid.QuestionCircle
-                };
-            }
+                return new SpriteNotFound(component.LookupName);
 
             return new Sprite { Texture = texture };
         }
@@ -98,5 +94,28 @@ namespace osu.Game.Skinning
                     Items = availableFiles;
             }
         }
+
+        public class SpriteNotFound : CompositeDrawable
+        {
+            public SpriteNotFound(string lookup)
+            {
+                AutoSizeAxes = Axes.Both;
+
+                InternalChildren = new Drawable[]
+                {
+                    new SpriteIcon
+                    {
+                        Size = new Vector2(50),
+                        Icon = FontAwesome.Solid.QuestionCircle
+                    },
+                    new OsuSpriteText
+                    {
+                        Position = new Vector2(25, 50),
+                        Text = $"missing: {lookup}",
+                        Origin = Anchor.TopCentre,
+                    }
+                };
+            }
+        }
     }
 }

From 5f358a04e98ba580ce6fa7b7c32ee23a35d8cba3 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 4 Apr 2022 20:40:19 +0900
Subject: [PATCH 097/113] Return a valid "lighting" response from `DefaultSkin`

This is temporary to allow the new sprite lookup flow to potentially be
merged before hit lighting skinnability is addressed.
---
 osu.Game/Skinning/DefaultSkin.cs | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs
index c645b0fae4..119b0ec9ad 100644
--- a/osu.Game/Skinning/DefaultSkin.cs
+++ b/osu.Game/Skinning/DefaultSkin.cs
@@ -158,6 +158,13 @@ namespace osu.Game.Skinning
                     break;
             }
 
+            switch (component.LookupName)
+            {
+                // Temporary until default skin has a valid hit lighting.
+                case @"lighting":
+                    return Drawable.Empty();
+            }
+
             if (GetTexture(component.LookupName) is Texture t)
                 return new Sprite { Texture = t };
 

From 6b5ee6d89dbf64de2003f0b8cb9e77da75def66b Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Mon, 4 Apr 2022 20:44:05 +0900
Subject: [PATCH 098/113] Update framework

---
 osu.Android.props        | 2 +-
 osu.Game/osu.Game.csproj | 2 +-
 osu.iOS.props            | 4 ++--
 3 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/osu.Android.props b/osu.Android.props
index 6a3b113fa2..ff14c97cd9 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,7 +52,7 @@
   </ItemGroup>
   <ItemGroup>
     <PackageReference Include="ppy.osu.Game.Resources" Version="2022.325.0" />
-    <PackageReference Include="ppy.osu.Framework.Android" Version="2022.325.0" />
+    <PackageReference Include="ppy.osu.Framework.Android" Version="2022.404.0" />
   </ItemGroup>
   <ItemGroup Label="Transitive Dependencies">
     <!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 3c01f29671..7b0f8c72c5 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -36,7 +36,7 @@
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
     </PackageReference>
     <PackageReference Include="Realm" Version="10.10.0" />
-    <PackageReference Include="ppy.osu.Framework" Version="2022.325.0" />
+    <PackageReference Include="ppy.osu.Framework" Version="2022.404.0" />
     <PackageReference Include="ppy.osu.Game.Resources" Version="2022.325.0" />
     <PackageReference Include="Sentry" Version="3.14.1" />
     <PackageReference Include="SharpCompress" Version="0.30.1" />
diff --git a/osu.iOS.props b/osu.iOS.props
index c8f170497d..88daf2eda7 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -61,7 +61,7 @@
     <Reference Include="System.Net.Http" />
   </ItemGroup>
   <ItemGroup Label="Package References">
-    <PackageReference Include="ppy.osu.Framework.iOS" Version="2022.325.0" />
+    <PackageReference Include="ppy.osu.Framework.iOS" Version="2022.404.0" />
     <PackageReference Include="ppy.osu.Game.Resources" Version="2022.325.0" />
   </ItemGroup>
   <!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) -->
@@ -84,7 +84,7 @@
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" />
     <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
-    <PackageReference Include="ppy.osu.Framework" Version="2022.325.0" />
+    <PackageReference Include="ppy.osu.Framework" Version="2022.404.0" />
     <PackageReference Include="SharpCompress" Version="0.30.1" />
     <PackageReference Include="NUnit" Version="3.13.2" />
     <PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />

From 3708f2b744b46251c3d680b27ce7583109f3f2b2 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 5 Apr 2022 01:05:04 +0900
Subject: [PATCH 099/113] Update resources

---
 osu.Android.props        | 2 +-
 osu.Game/osu.Game.csproj | 2 +-
 osu.iOS.props            | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/osu.Android.props b/osu.Android.props
index 3203ffeac3..fbe13b11ee 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -51,7 +51,7 @@
     <Reference Include="Java.Interop" />
   </ItemGroup>
   <ItemGroup>
-    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.404.0" />
+    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.405.0" />
     <PackageReference Include="ppy.osu.Framework.Android" Version="2022.404.0" />
   </ItemGroup>
   <ItemGroup Label="Transitive Dependencies">
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 7e193a37f9..1bebf78d97 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -37,7 +37,7 @@
     </PackageReference>
     <PackageReference Include="Realm" Version="10.10.0" />
     <PackageReference Include="ppy.osu.Framework" Version="2022.404.0" />
-    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.404.0" />
+    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.405.0" />
     <PackageReference Include="Sentry" Version="3.14.1" />
     <PackageReference Include="SharpCompress" Version="0.30.1" />
     <PackageReference Include="NUnit" Version="3.13.2" />
diff --git a/osu.iOS.props b/osu.iOS.props
index a68bebeabe..efd5bac38e 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -62,7 +62,7 @@
   </ItemGroup>
   <ItemGroup Label="Package References">
     <PackageReference Include="ppy.osu.Framework.iOS" Version="2022.404.0" />
-    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.404.0" />
+    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.405.0" />
   </ItemGroup>
   <!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) -->
   <PropertyGroup>

From 9a07a95d39817ce408487253e410d845ee11c27d Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Mon, 4 Apr 2022 19:22:53 +0200
Subject: [PATCH 100/113] Make several delete confirmation buttons dangerous
 buttons

Includes:
- Mass deletion
- Beatmap deletion
- Local score deletion
---
 .../Sections/Maintenance/MassDeleteConfirmationDialog.cs        | 2 +-
 osu.Game/Screens/Select/BeatmapDeleteDialog.cs                  | 2 +-
 osu.Game/Screens/Select/LocalScoreDeleteDialog.cs               | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs
index 6380232bbb..c481c80d82 100644
--- a/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs
+++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs
@@ -17,7 +17,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
             HeaderText = @"Confirm deletion of";
             Buttons = new PopupDialogButton[]
             {
-                new PopupDialogOkButton
+                new PopupDialogDangerousButton
                 {
                     Text = @"Yes. Go for it.",
                     Action = deleteAction
diff --git a/osu.Game/Screens/Select/BeatmapDeleteDialog.cs b/osu.Game/Screens/Select/BeatmapDeleteDialog.cs
index 1ac278d045..b156c2485b 100644
--- a/osu.Game/Screens/Select/BeatmapDeleteDialog.cs
+++ b/osu.Game/Screens/Select/BeatmapDeleteDialog.cs
@@ -26,7 +26,7 @@ namespace osu.Game.Screens.Select
             HeaderText = @"Confirm deletion of";
             Buttons = new PopupDialogButton[]
             {
-                new PopupDialogOkButton
+                new PopupDialogDangerousButton
                 {
                     Text = @"Yes. Totally. Delete it.",
                     Action = () => manager?.Delete(beatmap),
diff --git a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs
index 1ae244281b..cb96e3f23e 100644
--- a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs
+++ b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs
@@ -38,7 +38,7 @@ namespace osu.Game.Screens.Select
             HeaderText = "Confirm deletion of local score";
             Buttons = new PopupDialogButton[]
             {
-                new PopupDialogOkButton
+                new PopupDialogDangerousButton
                 {
                     Text = "Yes. Please.",
                     Action = () => scoreManager?.Delete(score)

From a1ded66fd85bcc09e2cfe32cf2ca3d938726b2c7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Mon, 4 Apr 2022 21:59:09 +0200
Subject: [PATCH 101/113] Fix various breakage in delete local score test scene

---
 .../TestSceneDeleteLocalScore.cs              | 39 ++++++++++---------
 1 file changed, 21 insertions(+), 18 deletions(-)

diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs
index a0a1feff36..c8a8fd43fb 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs
@@ -44,9 +44,6 @@ namespace osu.Game.Tests.Visual.UserInterface
 
         private BeatmapInfo beatmapInfo;
 
-        [Resolved]
-        private RealmAccess realm { get; set; }
-
         [Cached]
         private readonly DialogOverlay dialogOverlay;
 
@@ -92,6 +89,12 @@ namespace osu.Game.Tests.Visual.UserInterface
             dependencies.Cache(scoreManager = new ScoreManager(dependencies.Get<RulesetStore>(), () => beatmapManager, LocalStorage, Realm, Scheduler));
             Dependencies.Cache(Realm);
 
+            return dependencies;
+        }
+
+        [BackgroundDependencyLoader]
+        private void load() => Schedule(() =>
+        {
             var imported = beatmapManager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).GetResultSafely();
 
             imported?.PerformRead(s =>
@@ -115,26 +118,26 @@ namespace osu.Game.Tests.Visual.UserInterface
                     importedScores.Add(scoreManager.Import(score).Value);
                 }
             });
-
-            return dependencies;
-        }
-
-        [SetUp]
-        public void Setup() => Schedule(() =>
-        {
-            realm.Run(r =>
-            {
-                // Due to soft deletions, we can re-use deleted scores between test runs
-                scoreManager.Undelete(r.All<ScoreInfo>().Where(s => s.DeletePending).ToList());
-            });
-
-            leaderboard.BeatmapInfo = beatmapInfo;
-            leaderboard.RefetchScores(); // Required in the case that the beatmap hasn't changed
         });
 
         [SetUpSteps]
         public void SetupSteps()
         {
+            AddUntilStep("ensure scores imported", () => importedScores.Count == 50);
+            AddStep("undelete scores", () =>
+            {
+                Realm.Run(r =>
+                {
+                    // Due to soft deletions, we can re-use deleted scores between test runs
+                    scoreManager.Undelete(r.All<ScoreInfo>().Where(s => s.DeletePending).ToList());
+                });
+            });
+            AddStep("set up leaderboard", () =>
+            {
+                leaderboard.BeatmapInfo = beatmapInfo;
+                leaderboard.RefetchScores(); // Required in the case that the beatmap hasn't changed
+            });
+
             // Ensure the leaderboard items have finished showing up
             AddStep("finish transforms", () => leaderboard.FinishTransforms(true));
             AddUntilStep("wait for drawables", () => leaderboard.ChildrenOfType<LeaderboardScore>().Any());

From f73062a0d6c141290c93f227db7dd63173175324 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Mon, 4 Apr 2022 22:22:55 +0200
Subject: [PATCH 102/113] Revert "Remove nullable on `RealmBackedResourceStore`
 realm parameter"

This reverts commit 09e15f5496a8ecee0f0c77f3743a3b664a150da9.
---
 osu.Game/Skinning/RealmBackedResourceStore.cs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs
index 0057132044..7fa24284ee 100644
--- a/osu.Game/Skinning/RealmBackedResourceStore.cs
+++ b/osu.Game/Skinning/RealmBackedResourceStore.cs
@@ -23,7 +23,7 @@ namespace osu.Game.Skinning
         private readonly Live<T> liveSource;
         private readonly IDisposable? realmSubscription;
 
-        public RealmBackedResourceStore(Live<T> source, IResourceStore<byte[]> underlyingStore, RealmAccess realm)
+        public RealmBackedResourceStore(Live<T> source, IResourceStore<byte[]> underlyingStore, RealmAccess? realm)
             : base(underlyingStore)
         {
             liveSource = source;
@@ -31,7 +31,7 @@ namespace osu.Game.Skinning
             invalidateCache();
             Debug.Assert(fileToStoragePathMapping != null);
 
-            realmSubscription = realm.RegisterForNotifications(r => r.All<T>().Where(s => s.ID == source.ID), skinChanged);
+            realmSubscription = realm?.RegisterForNotifications(r => r.All<T>().Where(s => s.ID == source.ID), skinChanged);
         }
 
         protected override void Dispose(bool disposing)

From da315f8a61b8e6d876dc47bfe709c17b8dc1241e Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Mon, 4 Apr 2022 22:44:35 +0200
Subject: [PATCH 103/113] Make the test hold the button instead of pressing it

---
 .../Visual/UserInterface/TestSceneDeleteLocalScore.cs           | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs
index a0a1feff36..02a37627d7 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs
@@ -169,7 +169,7 @@ namespace osu.Game.Tests.Visual.UserInterface
             AddStep("click delete button", () =>
             {
                 InputManager.MoveMouseTo(dialogOverlay.ChildrenOfType<DialogButton>().First());
-                InputManager.Click(MouseButton.Left);
+                InputManager.PressButton(MouseButton.Left);
             });
 
             AddUntilStep("wait for fetch", () => leaderboard.Scores != null);

From b2c822a3b1b1498513829c9d469062a52a4a12ec Mon Sep 17 00:00:00 2001
From: CenTdemeern1 <timo.herngreen@gmail.com>
Date: Mon, 4 Apr 2022 23:02:07 +0200
Subject: [PATCH 104/113] Release mouse button

---
 .../Visual/UserInterface/TestSceneDeleteLocalScore.cs          | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs
index 02a37627d7..26e7b5bdda 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs
@@ -174,6 +174,9 @@ namespace osu.Game.Tests.Visual.UserInterface
 
             AddUntilStep("wait for fetch", () => leaderboard.Scores != null);
             AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != scoreBeingDeleted.OnlineID));
+
+            // "Clean up"
+            AddStep("release left mouse button", () => InputManager.ReleaseButton(MouseButton.Left));
         }
 
         [Test]

From 32c89f8643633fa7880292aa7d6828d7e6acaefa Mon Sep 17 00:00:00 2001
From: Ame <ajiiisai@protonmail.com>
Date: Tue, 5 Apr 2022 00:33:41 +0200
Subject: [PATCH 105/113] Handle repeated `OnPressed()` on `FooterButton`
 (without `FooterButtonRandom`)

---
 osu.Game/Screens/Select/FooterButton.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs
index 8d2ea47757..9cb178ca8b 100644
--- a/osu.Game/Screens/Select/FooterButton.cs
+++ b/osu.Game/Screens/Select/FooterButton.cs
@@ -174,7 +174,7 @@ namespace osu.Game.Screens.Select
 
         public virtual bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
         {
-            if (e.Action == Hotkey)
+            if (e.Action == Hotkey && !e.Repeat)
             {
                 TriggerClick();
                 return true;

From 117d81d84f13c40fb7a782cfda8d1df6c5fa0c92 Mon Sep 17 00:00:00 2001
From: Salman Ahmed <frenzibyte@gmail.com>
Date: Tue, 5 Apr 2022 03:04:53 +0300
Subject: [PATCH 106/113] Use perfect osu!(stable) strong scale value

Co-authored-by: Dean Herbert <pe@ppy.sh>
---
 osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs
index 43a099b900..6e0f6a3109 100644
--- a/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs
@@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Taiko.Objects
         /// <summary>
         /// Scale multiplier for a strong drawable taiko hit object.
         /// </summary>
-        public const float STRONG_SCALE = 1.525f;
+        public const float STRONG_SCALE = 1 / 0.65f;
 
         /// <summary>
         /// Default size of a strong drawable taiko hit object.

From 174dc1641c3fcc03cf72d8f3d35105a07c732d3e Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 5 Apr 2022 11:49:57 +0900
Subject: [PATCH 107/113] Fix multiple issues with timekeeping

- Using realtime (`DateTimeOffset.Now`) meant that values would be
  changing in the same frame, causing misfirings or incorrect displays
- No debounce on sample playback meant that scheduling edge cases could
  potentially cause samples to be played more than once.
---
 .../Match/MultiplayerReadyButton.cs           | 30 ++++++++++++++-----
 1 file changed, 22 insertions(+), 8 deletions(-)

diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
index a9092bc25a..9b7e9a925e 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
@@ -48,7 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
         }
 
         private MultiplayerCountdown countdown;
-        private DateTimeOffset countdownChangeTime;
+        private double countdownChangeTime;
         private ScheduledDelegate countdownUpdateDelegate;
 
         private void onRoomUpdated() => Scheduler.AddOnce(() =>
@@ -56,7 +56,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
             if (countdown != room?.Countdown)
             {
                 countdown = room?.Countdown;
-                countdownChangeTime = DateTimeOffset.Now;
+                countdownChangeTime = Time.Current;
             }
 
             scheduleNextCountdownUpdate();
@@ -86,13 +86,27 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
 
                 int secondsRemaining = countdownTimeRemaining.Seconds;
 
-                if (secondsRemaining < 10) countdownTickSample?.Play();
-                if (secondsRemaining <= 3) countdownTickFinalSample?.Play();
+                playTickSound(secondsRemaining);
 
-                scheduleNextCountdownUpdate();
+                if (secondsRemaining > 0)
+                    scheduleNextCountdownUpdate();
             }
         }
 
+        private double? lastTickSampleTime;
+
+        private void playTickSound(int secondsRemaining)
+        {
+            // Simplified debounce. Ticks should only be played roughly once per second regardless of how often this function is called.
+            if (Time.Current - lastTickSampleTime < 500)
+                return;
+
+            lastTickSampleTime = Time.Current;
+
+            if (secondsRemaining < 10) countdownTickSample?.Play();
+            if (secondsRemaining <= 3) countdownTickFinalSample?.Play();
+        }
+
         private void updateButtonText()
         {
             if (room == null)
@@ -146,13 +160,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
         {
             get
             {
-                TimeSpan timeElapsed = DateTimeOffset.Now - countdownChangeTime;
+                double timeElapsed = Time.Current - countdownChangeTime;
                 TimeSpan remaining;
 
-                if (timeElapsed > countdown.TimeRemaining)
+                if (timeElapsed > countdown.TimeRemaining.TotalMilliseconds)
                     remaining = TimeSpan.Zero;
                 else
-                    remaining = countdown.TimeRemaining - timeElapsed;
+                    remaining = countdown.TimeRemaining - TimeSpan.FromMilliseconds(timeElapsed);
 
                 return remaining;
             }

From 31bf0c4a9b78bb40f5175ea0cb0388243d95c0c8 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 5 Apr 2022 13:16:06 +0900
Subject: [PATCH 108/113] Disable "final" sample in countdown for the time
 being

---
 .../OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs     | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
index 9b7e9a925e..6eed900963 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
@@ -104,7 +104,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
             lastTickSampleTime = Time.Current;
 
             if (secondsRemaining < 10) countdownTickSample?.Play();
-            if (secondsRemaining <= 3) countdownTickFinalSample?.Play();
+            // disabled for now pending further work on sound effect
+            // if (secondsRemaining <= 3) countdownTickFinalSample?.Play();
         }
 
         private void updateButtonText()

From d0f83885cea57adb37334b3646f5eb2e75da784f Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 5 Apr 2022 13:20:34 +0900
Subject: [PATCH 109/113] Appease the CI

---
 .../OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs    | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
index 6eed900963..7e092bd353 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
@@ -30,13 +30,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
         private MultiplayerRoom room => multiplayerClient.Room;
 
         private Sample countdownTickSample;
-        private Sample countdownTickFinalSample;
 
         [BackgroundDependencyLoader]
         private void load(AudioManager audio)
         {
             countdownTickSample = audio.Samples.Get(@"Multiplayer/countdown-tick");
-            countdownTickFinalSample = audio.Samples.Get(@"Multiplayer/countdown-tick-final");
+            // disabled for now pending further work on sound effect
+            // countdownTickFinalSample = audio.Samples.Get(@"Multiplayer/countdown-tick-final");
         }
 
         protected override void LoadComplete()

From 5f415cbe53b1d406aa83abf402ceb7249305ea61 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 5 Apr 2022 15:48:18 +0900
Subject: [PATCH 110/113] Full potential null reference and add better
 commentary on countdown scheduling

---
 .../OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
index 7e092bd353..4cde7e71c3 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
@@ -67,9 +67,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
 
         private void scheduleNextCountdownUpdate()
         {
+            countdownUpdateDelegate?.Cancel();
+
             if (countdown != null)
             {
-                // If a countdown is active, schedule relevant components to update on the next whole second.
+                // The remaining time on a countdown may be at a fractional portion between two seconds.
+                // We want to align certain audio/visual cues to the point at which integer seconds change.
+                // To do so, we schedule to the next whole second.
+                // Note that scheduler invocation isn't guaranteed to be accurate, so this may still occur slightly late.
                 double timeToNextSecond = countdownTimeRemaining.TotalMilliseconds % 1000;
 
                 countdownUpdateDelegate = Scheduler.AddDelayed(onCountdownTick, timeToNextSecond);

From 8e543204cdca8305cf224f338848876d31120982 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 5 Apr 2022 15:49:47 +0900
Subject: [PATCH 111/113] Remove debounce logic (not required after switching
 to `Update` clock time)

---
 .../Multiplayer/Match/MultiplayerReadyButton.cs           | 8 --------
 1 file changed, 8 deletions(-)

diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
index 4cde7e71c3..4860078a79 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
@@ -98,16 +98,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
             }
         }
 
-        private double? lastTickSampleTime;
-
         private void playTickSound(int secondsRemaining)
         {
-            // Simplified debounce. Ticks should only be played roughly once per second regardless of how often this function is called.
-            if (Time.Current - lastTickSampleTime < 500)
-                return;
-
-            lastTickSampleTime = Time.Current;
-
             if (secondsRemaining < 10) countdownTickSample?.Play();
             // disabled for now pending further work on sound effect
             // if (secondsRemaining <= 3) countdownTickFinalSample?.Play();

From 3d8ae0465f1313981960f10a949aadbd21bd6651 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 5 Apr 2022 15:51:04 +0900
Subject: [PATCH 112/113] Reword comment slightly

---
 .../OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs   | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
index 4860078a79..d275f309cb 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
@@ -73,8 +73,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
             {
                 // The remaining time on a countdown may be at a fractional portion between two seconds.
                 // We want to align certain audio/visual cues to the point at which integer seconds change.
-                // To do so, we schedule to the next whole second.
-                // Note that scheduler invocation isn't guaranteed to be accurate, so this may still occur slightly late.
+                // To do so, we schedule to the next whole second. Note that scheduler invocation isn't
+                // guaranteed to be accurate, so this may still occur slightly late, but even in such a case
+                // the next invocation will be roughly correct.
                 double timeToNextSecond = countdownTimeRemaining.TotalMilliseconds % 1000;
 
                 countdownUpdateDelegate = Scheduler.AddDelayed(onCountdownTick, timeToNextSecond);

From 2ec15a1ebe83c3e18bbde06f9f41c29db7780797 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Tue, 5 Apr 2022 16:47:15 +0900
Subject: [PATCH 113/113] Fix lookup through transformers

---
 osu.Game/Skinning/LegacySkinTransformer.cs |  2 +-
 osu.Game/Skinning/SkinnableSprite.cs       | 23 +++++++++++++++++++++-
 2 files changed, 23 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs
index 97084f34e0..9481fc7182 100644
--- a/osu.Game/Skinning/LegacySkinTransformer.cs
+++ b/osu.Game/Skinning/LegacySkinTransformer.cs
@@ -23,7 +23,7 @@ namespace osu.Game.Skinning
         /// The <see cref="ISkin"/> which is being transformed.
         /// </summary>
         [NotNull]
-        protected ISkin Skin { get; }
+        protected internal ISkin Skin { get; }
 
         protected LegacySkinTransformer([NotNull] ISkin skin)
         {
diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs
index c5f110f908..4b4d7fe2c6 100644
--- a/osu.Game/Skinning/SkinnableSprite.cs
+++ b/osu.Game/Skinning/SkinnableSprite.cs
@@ -2,6 +2,7 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System;
+using System.Collections.Generic;
 using System.Linq;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
@@ -83,7 +84,7 @@ namespace osu.Game.Skinning
                 // Round-about way of getting the user's skin to find available resources.
                 // In the future we'll probably want to allow access to resources from the fallbacks, or potentially other skins
                 // but that requires further thought.
-                var highestPrioritySkin = ((SkinnableSprite)SettingSourceObject).source.AllSources.First() as Skin;
+                var highestPrioritySkin = getHighestPriorityUserSkin(((SkinnableSprite)SettingSourceObject).source.AllSources) as Skin;
 
                 string[] availableFiles = highestPrioritySkin?.SkinInfo.PerformRead(s => s.Files
                                                                                           .Where(f => f.Filename.EndsWith(".png", StringComparison.Ordinal)
@@ -92,6 +93,26 @@ namespace osu.Game.Skinning
 
                 if (availableFiles?.Length > 0)
                     Items = availableFiles;
+
+                static ISkin getHighestPriorityUserSkin(IEnumerable<ISkin> skins)
+                {
+                    foreach (var skin in skins)
+                    {
+                        if (skin is LegacySkinTransformer transformer && isUserSkin(transformer.Skin))
+                            return transformer.Skin;
+
+                        if (isUserSkin(skin))
+                            return skin;
+                    }
+
+                    return null;
+                }
+
+                // Temporarily used to exclude undesirable ISkin implementations
+                static bool isUserSkin(ISkin skin)
+                    => skin.GetType() == typeof(DefaultSkin)
+                       || skin.GetType() == typeof(DefaultLegacySkin)
+                       || skin.GetType() == typeof(LegacySkin);
             }
         }