From f1f30b94a694204b84061e9b85e5b9fc2ecdabe4 Mon Sep 17 00:00:00 2001
From: timiimit <timi.korda@gmail.com>
Date: Sun, 14 May 2023 11:05:47 +0200
Subject: [PATCH 01/42] Add StarRating Property

---
 osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
index c45f703b05..ef71e0ee17 100644
--- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
+++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
@@ -53,6 +53,9 @@ namespace osu.Game.Online.Rooms
         [Key(9)]
         public DateTimeOffset? PlayedAt { get; set; }
 
+        [Key(10)]
+        public double StarRating { get; set; }
+
         public MultiplayerPlaylistItem()
         {
         }
@@ -69,6 +72,7 @@ namespace osu.Game.Online.Rooms
             Expired = item.Expired;
             PlaylistOrder = item.PlaylistOrder ?? 0;
             PlayedAt = item.PlayedAt;
+            // TODO: assign StarRating 
         }
     }
 }

From 5d687013214366aa6ff7fe09e998dea7f91cd79e Mon Sep 17 00:00:00 2001
From: timiimit <timi.korda@gmail.com>
Date: Sun, 14 May 2023 12:08:37 +0200
Subject: [PATCH 02/42] Copy StarRange when creating PlaylistItem

---
 osu.Game/Online/Multiplayer/MultiplayerClient.cs | 2 +-
 osu.Game/Online/Rooms/PlaylistItem.cs            | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
index 2be7327234..5716b7ad3b 100644
--- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs
+++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
@@ -783,7 +783,7 @@ namespace osu.Game.Online.Multiplayer
             RoomUpdated?.Invoke();
         }
 
-        private PlaylistItem createPlaylistItem(MultiplayerPlaylistItem item) => new PlaylistItem(new APIBeatmap { OnlineID = item.BeatmapID })
+        private PlaylistItem createPlaylistItem(MultiplayerPlaylistItem item) => new PlaylistItem(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating })
         {
             ID = item.ID,
             OwnerID = item.OwnerID,
diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs
index 2213311c67..a900d8f3d7 100644
--- a/osu.Game/Online/Rooms/PlaylistItem.cs
+++ b/osu.Game/Online/Rooms/PlaylistItem.cs
@@ -91,7 +91,7 @@ namespace osu.Game.Online.Rooms
         }
 
         public PlaylistItem(MultiplayerPlaylistItem item)
-            : this(new APIBeatmap { OnlineID = item.BeatmapID })
+            : this(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating })
         {
             ID = item.ID;
             OwnerID = item.OwnerID;

From fd80ffd52bd4741c82266e78d8e03f405d75b0c7 Mon Sep 17 00:00:00 2001
From: timiimit <timi.korda@gmail.com>
Date: Sun, 14 May 2023 12:09:15 +0200
Subject: [PATCH 03/42] Fix display update condition

---
 .../Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs
index 93c8faf0b0..2ee3bb30dd 100644
--- a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs
+++ b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs
@@ -85,15 +85,15 @@ namespace osu.Game.Screens.OnlinePlay.Components
             StarDifficulty minDifficulty;
             StarDifficulty maxDifficulty;
 
-            if (DifficultyRange.Value != null)
+            if (DifficultyRange.Value != null && Playlist.Count == 0)
             {
+                // When Playlist is empty (in lounge) we take retrieved range
                 minDifficulty = new StarDifficulty(DifficultyRange.Value.Min, 0);
                 maxDifficulty = new StarDifficulty(DifficultyRange.Value.Max, 0);
             }
             else
             {
-                // In multiplayer rooms, the beatmaps of playlist items will not be populated to a point this can be correct.
-                // Either populating them via BeatmapLookupCache or polling the API for the room's DifficultyRange will be required.
+                // When Playlist is not empty (in room) we compute actual range
                 var orderedDifficulties = Playlist.Select(p => p.Beatmap).OrderBy(b => b.StarRating).ToArray();
 
                 minDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[0].StarRating : 0, 0);

From 86a834fb568922ae975f014c20d410e71969ce44 Mon Sep 17 00:00:00 2001
From: timiimit <timi.korda@gmail.com>
Date: Sun, 14 May 2023 12:20:16 +0200
Subject: [PATCH 04/42] Fix TODO comment

---
 osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
index ef71e0ee17..64f971a2e4 100644
--- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
+++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
@@ -72,7 +72,7 @@ namespace osu.Game.Online.Rooms
             Expired = item.Expired;
             PlaylistOrder = item.PlaylistOrder ?? 0;
             PlayedAt = item.PlayedAt;
-            // TODO: assign StarRating 
+            StarRating = item.Beatmap.StarRating; // generally not available, but lets at least try to use it
         }
     }
 }

From cb8d5f459f40e4c0bd4f4da51867deb546d0c908 Mon Sep 17 00:00:00 2001
From: timiimit <timi.korda@gmail.com>
Date: Mon, 15 May 2023 07:34:27 +0200
Subject: [PATCH 05/42] Remove bad comment

---
 osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
index 64f971a2e4..a102d118fe 100644
--- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
+++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
@@ -72,7 +72,7 @@ namespace osu.Game.Online.Rooms
             Expired = item.Expired;
             PlaylistOrder = item.PlaylistOrder ?? 0;
             PlayedAt = item.PlayedAt;
-            StarRating = item.Beatmap.StarRating; // generally not available, but lets at least try to use it
+            StarRating = item.Beatmap.StarRating;
         }
     }
 }

From 83ceb3457f8cc800d320ceb1406a04f7f9e3ae55 Mon Sep 17 00:00:00 2001
From: timiimit <timi.korda@gmail.com>
Date: Mon, 15 May 2023 07:35:09 +0200
Subject: [PATCH 06/42] Add xmldoc comment

---
 osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
index a102d118fe..daf45c5aee 100644
--- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
+++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
@@ -60,6 +60,9 @@ namespace osu.Game.Online.Rooms
         {
         }
 
+        /// <summary>
+        /// This constructor should only be used for test purposes.
+        /// </summary>
         public MultiplayerPlaylistItem(PlaylistItem item)
         {
             ID = item.ID;

From 7a5349d747862fc4ad165b82f6b54035ced4156c Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 25 May 2023 20:09:40 +0900
Subject: [PATCH 07/42] Remove constructor from `MultiplayerPlaylistItem` which
 is only used in tests

---
 .../Multiplayer/TestSceneMatchStartControl.cs |  2 +-
 .../Multiplayer/TestSceneMultiplayer.cs       |  4 ++--
 .../TestSceneMultiplayerPlaylist.cs           |  2 +-
 .../TestSceneMultiplayerQueueList.cs          |  2 +-
 .../Online/Rooms/MultiplayerPlaylistItem.cs   | 19 +-----------------
 .../Multiplayer/TestMultiplayerClient.cs      | 20 +++++++++++++++++--
 6 files changed, 24 insertions(+), 25 deletions(-)

diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs
index 3efc7fbd30..6d309078e6 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs
@@ -129,7 +129,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
                 {
                     Playlist =
                     {
-                        new MultiplayerPlaylistItem(playlistItem),
+                        TestMultiplayerClient.CreateMultiplayerPlaylistItem(playlistItem),
                     },
                     Users = { localUser },
                     Host = localUser,
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs
index d747d23229..09624f63b7 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs
@@ -906,7 +906,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
 
             enterGameplay();
             AddStep("join other user", () => multiplayerClient.AddUser(new APIUser { Id = 1234 }));
-            AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, new MultiplayerPlaylistItem(
+            AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, TestMultiplayerClient.CreateMultiplayerPlaylistItem(
                 new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo)
                 {
                     RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
@@ -938,7 +938,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
             enterGameplay();
 
             AddStep("join other user", () => multiplayerClient.AddUser(new APIUser { Id = 1234 }));
-            AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, new MultiplayerPlaylistItem(
+            AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, TestMultiplayerClient.CreateMultiplayerPlaylistItem(
                 new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo)
                 {
                     RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
index d7578b4114..2100f82886 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
@@ -215,7 +215,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
         /// </summary>
         private void addItemStep(bool expired = false, int? userId = null) => AddStep("add item", () =>
         {
-            MultiplayerClient.AddUserPlaylistItem(userId ?? API.LocalUser.Value.OnlineID, new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)
+            MultiplayerClient.AddUserPlaylistItem(userId ?? API.LocalUser.Value.OnlineID, TestMultiplayerClient.CreateMultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)
             {
                 Expired = expired,
                 PlayedAt = DateTimeOffset.Now
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs
index bb37f1a5a7..47fb4e06ea 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs
@@ -130,7 +130,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
 
             AddStep("add playlist item", () =>
             {
-                MultiplayerPlaylistItem item = new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap));
+                MultiplayerPlaylistItem item = TestMultiplayerClient.CreateMultiplayerPlaylistItem(new PlaylistItem(importedBeatmap));
 
                 MultiplayerClient.AddUserPlaylistItem(userId(), item).WaitSafely();
 
diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
index daf45c5aee..8be703e620 100644
--- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
+++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
@@ -56,26 +56,9 @@ namespace osu.Game.Online.Rooms
         [Key(10)]
         public double StarRating { get; set; }
 
+        [SerializationConstructor]
         public MultiplayerPlaylistItem()
         {
         }
-
-        /// <summary>
-        /// This constructor should only be used for test purposes.
-        /// </summary>
-        public MultiplayerPlaylistItem(PlaylistItem item)
-        {
-            ID = item.ID;
-            OwnerID = item.OwnerID;
-            BeatmapID = item.Beatmap.OnlineID;
-            BeatmapChecksum = item.Beatmap.MD5Hash;
-            RulesetID = item.RulesetID;
-            RequiredMods = item.RequiredMods.ToArray();
-            AllowedMods = item.AllowedMods.ToArray();
-            Expired = item.Expired;
-            PlaylistOrder = item.PlaylistOrder ?? 0;
-            PlayedAt = item.PlayedAt;
-            StarRating = item.Beatmap.StarRating;
-        }
     }
 }
diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
index ad5e3f6c4d..0d9f91caa1 100644
--- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
+++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
@@ -108,7 +108,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
                     // simulate the server's automatic assignment of users to teams on join.
                     // the "best" team is the one with the least users on it.
                     int bestTeam = teamVersus.Teams
-                                             .Select(team => (teamID: team.ID, userCount: ServerRoom.Users.Count(u => (u.MatchState as TeamVersusUserState)?.TeamID == team.ID))).MinBy(pair => pair.userCount).teamID;
+                                             .Select(team => (teamID: team.ID, userCount: ServerRoom.Users.Count(u => (u.MatchState as TeamVersusUserState)?.TeamID == team.ID)))
+                                             .MinBy(pair => pair.userCount).teamID;
 
                     user.MatchState = new TeamVersusUserState { TeamID = bestTeam };
                     ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).WaitSafely();
@@ -232,7 +233,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
                     QueueMode = ServerAPIRoom.QueueMode.Value,
                     AutoStartDuration = ServerAPIRoom.AutoStartDuration.Value
                 },
-                Playlist = ServerAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)).ToList(),
+                Playlist = ServerAPIRoom.Playlist.Select(item => TestMultiplayerClient.CreateMultiplayerPlaylistItem(item)).ToList(),
                 Users = { localUser },
                 Host = localUser
             };
@@ -637,5 +638,20 @@ namespace osu.Game.Tests.Visual.Multiplayer
             byte[]? serialized = MessagePackSerializer.Serialize(typeof(T), incoming, SignalRUnionWorkaroundResolver.OPTIONS);
             return MessagePackSerializer.Deserialize<T>(serialized, SignalRUnionWorkaroundResolver.OPTIONS);
         }
+
+        public static MultiplayerPlaylistItem CreateMultiplayerPlaylistItem(PlaylistItem item) => new MultiplayerPlaylistItem
+        {
+            ID = item.ID,
+            OwnerID = item.OwnerID,
+            BeatmapID = item.Beatmap.OnlineID,
+            BeatmapChecksum = item.Beatmap.MD5Hash,
+            RulesetID = item.RulesetID,
+            RequiredMods = item.RequiredMods.ToArray(),
+            AllowedMods = item.AllowedMods.ToArray(),
+            Expired = item.Expired,
+            PlaylistOrder = item.PlaylistOrder ?? 0,
+            PlayedAt = item.PlayedAt,
+            StarRating = item.Beatmap.StarRating,
+        };
     }
 }

From 1c199b83e3453678888cfbe03b73cb123e43cfdb Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Mon, 29 May 2023 21:14:03 +0900
Subject: [PATCH 08/42] Replace mania scroll "time" with scroll "speed"

---
 .../ManiaRulesetConfigManager.cs              | 29 +++++++++++---
 .../ManiaSettingsSubsection.cs                |  9 ++---
 .../UI/DrawableManiaRuleset.cs                | 38 ++++++++++---------
 .../Localisation/RulesetSettingsStrings.cs    |  2 +-
 4 files changed, 49 insertions(+), 29 deletions(-)

diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs
index 99a80ef28d..5ba5125c2d 100644
--- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs
+++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs
@@ -15,24 +15,41 @@ namespace osu.Game.Rulesets.Mania.Configuration
         public ManiaRulesetConfigManager(SettingsStore? settings, RulesetInfo ruleset, int? variant = null)
             : base(settings, ruleset, variant)
         {
+            migrate();
         }
 
         protected override void InitialiseDefaults()
         {
             base.InitialiseDefaults();
 
-            SetDefault(ManiaRulesetSetting.ScrollTime, 1500.0, DrawableManiaRuleset.MIN_TIME_RANGE, DrawableManiaRuleset.MAX_TIME_RANGE, 5);
+#pragma warning disable CS0618
+            // Although obsolete, this is still required to populate the bindable from the database in case migration is required.
+            SetDefault<double?>(ManiaRulesetSetting.ScrollTime, null);
+#pragma warning restore CS0618
+
+            SetDefault(ManiaRulesetSetting.ScrollSpeed, 8, 1, 40);
             SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down);
             SetDefault(ManiaRulesetSetting.TimingBasedNoteColouring, false);
         }
 
+#pragma warning disable CS0618
+        private void migrate()
+        {
+            if (Get<double?>(ManiaRulesetSetting.ScrollTime) is double scrollTime)
+            {
+                SetValue(ManiaRulesetSetting.ScrollSpeed, (int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime));
+                SetValue<double?>(ManiaRulesetSetting.ScrollTime, null);
+            }
+        }
+#pragma warning restore CS0618
+
         public override TrackedSettings CreateTrackedSettings() => new TrackedSettings
         {
-            new TrackedSetting<double>(ManiaRulesetSetting.ScrollTime,
-                scrollTime => new SettingDescription(
-                    rawValue: scrollTime,
+            new TrackedSetting<int>(ManiaRulesetSetting.ScrollSpeed,
+                speed => new SettingDescription(
+                    rawValue: speed,
                     name: RulesetSettingsStrings.ScrollSpeed,
-                    value: RulesetSettingsStrings.ScrollSpeedTooltip(scrollTime, (int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime))
+                    value: RulesetSettingsStrings.ScrollSpeedTooltip(DrawableManiaRuleset.ComputeScrollTime(speed), speed)
                 )
             )
         };
@@ -40,7 +57,9 @@ namespace osu.Game.Rulesets.Mania.Configuration
 
     public enum ManiaRulesetSetting
     {
+        [Obsolete("Use ScrollSpeed instead.")] // Can be removed 2023-11-30
         ScrollTime,
+        ScrollSpeed,
         ScrollDirection,
         TimingBasedNoteColouring
     }
diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
index fc0b4a9ed9..a5434a36ab 100644
--- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
+++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
@@ -1,7 +1,6 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
-using System;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics;
 using osu.Framework.Localisation;
@@ -34,10 +33,10 @@ namespace osu.Game.Rulesets.Mania
                     LabelText = RulesetSettingsStrings.ScrollingDirection,
                     Current = config.GetBindable<ManiaScrollingDirection>(ManiaRulesetSetting.ScrollDirection)
                 },
-                new SettingsSlider<double, ManiaScrollSlider>
+                new SettingsSlider<int, ManiaScrollSlider>
                 {
                     LabelText = RulesetSettingsStrings.ScrollSpeed,
-                    Current = config.GetBindable<double>(ManiaRulesetSetting.ScrollTime),
+                    Current = config.GetBindable<int>(ManiaRulesetSetting.ScrollSpeed),
                     KeyboardStep = 5
                 },
                 new SettingsCheckbox
@@ -48,9 +47,9 @@ namespace osu.Game.Rulesets.Mania
             };
         }
 
-        private partial class ManiaScrollSlider : RoundedSliderBar<double>
+        private partial class ManiaScrollSlider : RoundedSliderBar<int>
         {
-            public override LocalisableString TooltipText => RulesetSettingsStrings.ScrollSpeedTooltip(Current.Value, (int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / Current.Value));
+            public override LocalisableString TooltipText => RulesetSettingsStrings.ScrollSpeedTooltip(DrawableManiaRuleset.ComputeScrollTime(Current.Value), Current.Value);
         }
     }
 }
diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
index af8758fb5e..2d373c0471 100644
--- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
@@ -33,12 +33,12 @@ namespace osu.Game.Rulesets.Mania.UI
     public partial class DrawableManiaRuleset : DrawableScrollingRuleset<ManiaHitObject>
     {
         /// <summary>
-        /// The minimum time range. This occurs at a <see cref="relativeTimeRange"/> of 40.
+        /// The minimum time range. This occurs at a <see cref="ManiaRulesetSetting.ScrollSpeed"/> of 40.
         /// </summary>
         public const double MIN_TIME_RANGE = 290;
 
         /// <summary>
-        /// The maximum time range. This occurs at a <see cref="relativeTimeRange"/> of 1.
+        /// The maximum time range. This occurs with a <see cref="ManiaRulesetSetting.ScrollSpeed"/> of 1.
         /// </summary>
         public const double MAX_TIME_RANGE = 11485;
 
@@ -69,7 +69,8 @@ namespace osu.Game.Rulesets.Mania.UI
         protected override ScrollVisualisationMethod VisualisationMethod => scrollMethod;
 
         private readonly Bindable<ManiaScrollingDirection> configDirection = new Bindable<ManiaScrollingDirection>();
-        private readonly BindableDouble configTimeRange = new BindableDouble();
+        private readonly BindableInt configScrollSpeed = new BindableInt();
+        private double smoothTimeRange;
 
         // Stores the current speed adjustment active in gameplay.
         private readonly Track speedAdjustmentTrack = new TrackVirtual(0);
@@ -78,6 +79,9 @@ namespace osu.Game.Rulesets.Mania.UI
             : base(ruleset, beatmap, mods)
         {
             BarLines = new BarLineGenerator<BarLine>(Beatmap).BarLines;
+
+            TimeRange.MinValue = 1;
+            TimeRange.MaxValue = MAX_TIME_RANGE;
         }
 
         [BackgroundDependencyLoader]
@@ -104,30 +108,28 @@ namespace osu.Game.Rulesets.Mania.UI
             Config.BindWith(ManiaRulesetSetting.ScrollDirection, configDirection);
             configDirection.BindValueChanged(direction => Direction.Value = (ScrollingDirection)direction.NewValue, true);
 
-            Config.BindWith(ManiaRulesetSetting.ScrollTime, configTimeRange);
-            TimeRange.MinValue = configTimeRange.MinValue;
-            TimeRange.MaxValue = configTimeRange.MaxValue;
+            Config.BindWith(ManiaRulesetSetting.ScrollSpeed, configScrollSpeed);
+            configScrollSpeed.BindValueChanged(speed => this.TransformTo(nameof(smoothTimeRange), ComputeScrollTime(speed.NewValue), 200, Easing.OutQuint));
+
+            TimeRange.Value = smoothTimeRange = ComputeScrollTime(configScrollSpeed.Value);
         }
 
-        protected override void AdjustScrollSpeed(int amount)
-        {
-            this.TransformTo(nameof(relativeTimeRange), relativeTimeRange + amount, 200, Easing.OutQuint);
-        }
-
-        private double relativeTimeRange
-        {
-            get => MAX_TIME_RANGE / configTimeRange.Value;
-            set => configTimeRange.Value = MAX_TIME_RANGE / value;
-        }
+        protected override void AdjustScrollSpeed(int amount) => configScrollSpeed.Value += amount;
 
         protected override void Update()
         {
             base.Update();
-
             updateTimeRange();
         }
 
-        private void updateTimeRange() => TimeRange.Value = configTimeRange.Value * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value;
+        private void updateTimeRange() => TimeRange.Value = smoothTimeRange * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value;
+
+        /// <summary>
+        /// Computes a scroll time (in milliseconds) from a scroll speed in the range of 1-40.
+        /// </summary>
+        /// <param name="scrollSpeed">The scroll speed.</param>
+        /// <returns>The scroll time.</returns>
+        public static double ComputeScrollTime(int scrollSpeed) => MAX_TIME_RANGE / scrollSpeed;
 
         public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new ManiaPlayfieldAdjustmentContainer();
 
diff --git a/osu.Game/Localisation/RulesetSettingsStrings.cs b/osu.Game/Localisation/RulesetSettingsStrings.cs
index 52e6a5eaac..91bbece004 100644
--- a/osu.Game/Localisation/RulesetSettingsStrings.cs
+++ b/osu.Game/Localisation/RulesetSettingsStrings.cs
@@ -82,7 +82,7 @@ namespace osu.Game.Localisation
         /// <summary>
         /// "{0}ms (speed {1})"
         /// </summary>
-        public static LocalisableString ScrollSpeedTooltip(double arg0, int arg1) => new TranslatableString(getKey(@"ruleset"), @"{0}ms (speed {1})", arg0, arg1);
+        public static LocalisableString ScrollSpeedTooltip(double scrollTime, int scrollSpeed) => new TranslatableString(getKey(@"ruleset"), @"{0:0}ms (speed {1})", scrollTime, scrollSpeed);
 
         private static string getKey(string key) => $@"{prefix}:{key}";
     }

From 79694897bef29810846bc156455880b19658723f Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 30 May 2023 12:58:22 +0900
Subject: [PATCH 09/42] Ensure a potential exception from
 `cleanupPendingDeletions` doesn't mark realm corrupt

The whole restructure here is to move the nested call out of the
`try-catch`. I noticed this while looking at a corrupt database issue a
user reported (https://github.com/ppy/osu/discussions/23694).

It's not the first time we've seen a corrupt database error where the
"corrupt" version works just fine on a second attempt.

Maybe this isn't the issue and it's just a transitive file access violation
but it definitely feels like this should be fixed regardless.
---
 osu.Game/Database/RealmAccess.cs | 79 +++++++++++++++++---------------
 1 file changed, 41 insertions(+), 38 deletions(-)

diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index 831e328439..55b5e0114d 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -179,43 +179,9 @@ namespace osu.Game.Database
                 applyFilenameSchemaSuffix(ref Filename);
 #endif
 
-            string newerVersionFilename = $"{Filename.Replace(realm_extension, string.Empty)}_newer_version{realm_extension}";
-
-            // Attempt to recover a newer database version if available.
-            if (storage.Exists(newerVersionFilename))
-            {
-                Logger.Log(@"A newer realm database has been found, attempting recovery...", LoggingTarget.Database);
-                attemptRecoverFromFile(newerVersionFilename);
-            }
-
-            try
-            {
+            using (var realm = prepareFirstRealmAccess())
                 // This method triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date.
-                cleanupPendingDeletions();
-            }
-            catch (Exception e)
-            {
-                // See https://github.com/realm/realm-core/blob/master/src%2Frealm%2Fobject-store%2Fobject_store.cpp#L1016-L1022
-                // This is the best way we can detect a schema version downgrade.
-                if (e.Message.StartsWith(@"Provided schema version", StringComparison.Ordinal))
-                {
-                    Logger.Error(e, "Your local database is too new to work with this version of osu!. Please close osu! and install the latest release to recover your data.");
-
-                    // If a newer version database already exists, don't backup again. We can presume that the first backup is the one we care about.
-                    if (!storage.Exists(newerVersionFilename))
-                        createBackup(newerVersionFilename);
-
-                    storage.Delete(Filename);
-                }
-                else
-                {
-                    Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made.");
-                    createBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}");
-                    storage.Delete(Filename);
-                }
-
-                cleanupPendingDeletions();
-            }
+                cleanupPendingDeletions(realm);
         }
 
         /// <summary>
@@ -312,9 +278,46 @@ namespace osu.Game.Database
             Logger.Log(@"Recovery complete!", LoggingTarget.Database);
         }
 
-        private void cleanupPendingDeletions()
+        private Realm prepareFirstRealmAccess()
+        {
+            string newerVersionFilename = $"{Filename.Replace(realm_extension, string.Empty)}_newer_version{realm_extension}";
+
+            // Attempt to recover a newer database version if available.
+            if (storage.Exists(newerVersionFilename))
+            {
+                Logger.Log(@"A newer realm database has been found, attempting recovery...", LoggingTarget.Database);
+                attemptRecoverFromFile(newerVersionFilename);
+            }
+
+            try
+            {
+                return getRealmInstance();
+            }
+            catch (Exception e)
+            {
+                // See https://github.com/realm/realm-core/blob/master/src%2Frealm%2Fobject-store%2Fobject_store.cpp#L1016-L1022
+                // This is the best way we can detect a schema version downgrade.
+                if (e.Message.StartsWith(@"Provided schema version", StringComparison.Ordinal))
+                {
+                    Logger.Error(e, "Your local database is too new to work with this version of osu!. Please close osu! and install the latest release to recover your data.");
+
+                    // If a newer version database already exists, don't backup again. We can presume that the first backup is the one we care about.
+                    if (!storage.Exists(newerVersionFilename))
+                        createBackup(newerVersionFilename);
+                }
+                else
+                {
+                    Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made.");
+                    createBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}");
+                }
+
+                storage.Delete(Filename);
+                return getRealmInstance();
+            }
+        }
+
+        private void cleanupPendingDeletions(Realm realm)
         {
-            using (var realm = getRealmInstance())
             using (var transaction = realm.BeginWrite())
             {
                 var pendingDeleteScores = realm.All<ScoreInfo>().Where(s => s.DeletePending);

From a0be52626639b8cbe2e6db7280fc3c106bdb9cd6 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 30 May 2023 13:04:32 +0900
Subject: [PATCH 10/42] Adjust realm backup procedure to hard fail if running
 out of attempts

Previously, if the backup procedure failed, startup would continue and
the user's realm database may be deleted. I think in such a fail case
I'd rather the game didn't startup so the user gets in touch (or reads
the log files themselves) rather than potentially losing data.
---
 osu.Game/Database/RealmAccess.cs | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index 55b5e0114d..2e66ad8b02 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -912,7 +912,7 @@ namespace osu.Game.Database
 
             int attempts = 10;
 
-            while (attempts-- > 0)
+            while (true)
             {
                 try
                 {
@@ -930,6 +930,9 @@ namespace osu.Game.Database
                 }
                 catch (IOException)
                 {
+                    if (attempts-- <= 0)
+                        throw;
+
                     // file may be locked during use.
                     Thread.Sleep(500);
                 }

From b456c36f6453309ad902c8a488696dd6798ed86f Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Tue, 30 May 2023 17:27:48 +0900
Subject: [PATCH 11/42] Migrate in InitialiseDefaults()

---
 .../Configuration/ManiaRulesetConfigManager.cs     | 14 ++++----------
 1 file changed, 4 insertions(+), 10 deletions(-)

diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs
index 5ba5125c2d..b2155968ea 100644
--- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs
+++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs
@@ -15,33 +15,27 @@ namespace osu.Game.Rulesets.Mania.Configuration
         public ManiaRulesetConfigManager(SettingsStore? settings, RulesetInfo ruleset, int? variant = null)
             : base(settings, ruleset, variant)
         {
-            migrate();
         }
 
         protected override void InitialiseDefaults()
         {
             base.InitialiseDefaults();
 
-#pragma warning disable CS0618
-            // Although obsolete, this is still required to populate the bindable from the database in case migration is required.
-            SetDefault<double?>(ManiaRulesetSetting.ScrollTime, null);
-#pragma warning restore CS0618
-
             SetDefault(ManiaRulesetSetting.ScrollSpeed, 8, 1, 40);
             SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down);
             SetDefault(ManiaRulesetSetting.TimingBasedNoteColouring, false);
-        }
 
 #pragma warning disable CS0618
-        private void migrate()
-        {
+            // Although obsolete, this is still required to populate the bindable from the database in case migration is required.
+            SetDefault<double?>(ManiaRulesetSetting.ScrollTime, null);
+
             if (Get<double?>(ManiaRulesetSetting.ScrollTime) is double scrollTime)
             {
                 SetValue(ManiaRulesetSetting.ScrollSpeed, (int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime));
                 SetValue<double?>(ManiaRulesetSetting.ScrollTime, null);
             }
-        }
 #pragma warning restore CS0618
+        }
 
         public override TrackedSettings CreateTrackedSettings() => new TrackedSettings
         {

From d119447a10a1ac0312a6aa9691348cbabed8c6ba Mon Sep 17 00:00:00 2001
From: Andrei Zavatski <megaman9919@gmail.com>
Date: Tue, 30 May 2023 16:41:42 +0300
Subject: [PATCH 12/42] Fix editor timeline hitobjects popping in

---
 .../Compose/Components/Timeline/TimelineHitObjectBlueprint.cs   | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
index ea063e9216..900f0ff4a2 100644
--- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
@@ -244,6 +244,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
 
         public override Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.TopLeft;
 
+        protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false;
+
         private partial class Tick : Circle
         {
             public Tick()

From 2e81cae201e77229445b97923aaa62be739ac47a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Tue, 30 May 2023 23:17:07 +0200
Subject: [PATCH 13/42] Move comment to more correct place

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

diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index 2e66ad8b02..1aef8f1c67 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -179,8 +179,8 @@ namespace osu.Game.Database
                 applyFilenameSchemaSuffix(ref Filename);
 #endif
 
+            // `prepareFirstRealmAccess()` triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date.
             using (var realm = prepareFirstRealmAccess())
-                // This method triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date.
                 cleanupPendingDeletions(realm);
         }
 

From 18eb15bfa5af004fc2e35f2ec6c017b0cf4a44f4 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 31 May 2023 19:39:43 +0900
Subject: [PATCH 14/42] Gracefully handle failures in cleaning up pending file
 deletions

---
 osu.Game/Database/RealmAccess.cs | 71 ++++++++++++++++++--------------
 1 file changed, 39 insertions(+), 32 deletions(-)

diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index 1aef8f1c67..94108531e8 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -318,46 +318,53 @@ namespace osu.Game.Database
 
         private void cleanupPendingDeletions(Realm realm)
         {
-            using (var transaction = realm.BeginWrite())
+            try
             {
-                var pendingDeleteScores = realm.All<ScoreInfo>().Where(s => s.DeletePending);
-
-                foreach (var score in pendingDeleteScores)
-                    realm.Remove(score);
-
-                var pendingDeleteSets = realm.All<BeatmapSetInfo>().Where(s => s.DeletePending);
-
-                foreach (var beatmapSet in pendingDeleteSets)
+                using (var transaction = realm.BeginWrite())
                 {
-                    foreach (var beatmap in beatmapSet.Beatmaps)
-                    {
-                        // Cascade delete related scores, else they will have a null beatmap against the model's spec.
-                        foreach (var score in beatmap.Scores)
-                            realm.Remove(score);
+                    var pendingDeleteScores = realm.All<ScoreInfo>().Where(s => s.DeletePending);
 
-                        realm.Remove(beatmap.Metadata);
-                        realm.Remove(beatmap);
+                    foreach (var score in pendingDeleteScores)
+                        realm.Remove(score);
+
+                    var pendingDeleteSets = realm.All<BeatmapSetInfo>().Where(s => s.DeletePending);
+
+                    foreach (var beatmapSet in pendingDeleteSets)
+                    {
+                        foreach (var beatmap in beatmapSet.Beatmaps)
+                        {
+                            // Cascade delete related scores, else they will have a null beatmap against the model's spec.
+                            foreach (var score in beatmap.Scores)
+                                realm.Remove(score);
+
+                            realm.Remove(beatmap.Metadata);
+                            realm.Remove(beatmap);
+                        }
+
+                        realm.Remove(beatmapSet);
                     }
 
-                    realm.Remove(beatmapSet);
+                    var pendingDeleteSkins = realm.All<SkinInfo>().Where(s => s.DeletePending);
+
+                    foreach (var s in pendingDeleteSkins)
+                        realm.Remove(s);
+
+                    var pendingDeletePresets = realm.All<ModPreset>().Where(s => s.DeletePending);
+
+                    foreach (var s in pendingDeletePresets)
+                        realm.Remove(s);
+
+                    transaction.Commit();
                 }
 
-                var pendingDeleteSkins = realm.All<SkinInfo>().Where(s => s.DeletePending);
-
-                foreach (var s in pendingDeleteSkins)
-                    realm.Remove(s);
-
-                var pendingDeletePresets = realm.All<ModPreset>().Where(s => s.DeletePending);
-
-                foreach (var s in pendingDeletePresets)
-                    realm.Remove(s);
-
-                transaction.Commit();
+                // clean up files after dropping any pending deletions.
+                // in the future we may want to only do this when the game is idle, rather than on every startup.
+                new RealmFileStore(this, storage).Cleanup();
+            }
+            catch (Exception e)
+            {
+                Logger.Error(e, "Failed to clean up unused files. This is not critical but please report if it happens regularly.");
             }
-
-            // clean up files after dropping any pending deletions.
-            // in the future we may want to only do this when the game is idle, rather than on every startup.
-            new RealmFileStore(this, storage).Cleanup();
         }
 
         /// <summary>

From 20439e80f6658937a940b3d603582212acb605c2 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 31 May 2023 23:24:15 +0900
Subject: [PATCH 15/42] Adjust background colour used in `LabelledDrawable`s to
 allow visibility of sliders

---
 osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs
index 9b7087ce6d..16bad5785f 100644
--- a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs
+++ b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs
@@ -152,7 +152,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
         [BackgroundDependencyLoader(true)]
         private void load(OverlayColourProvider? colourProvider, OsuColour osuColour)
         {
-            background.Colour = colourProvider?.Background5 ?? Color4Extensions.FromHex(@"1c2125");
+            background.Colour = colourProvider?.Background4 ?? Color4Extensions.FromHex(@"1c2125");
             descriptionText.Colour = osuColour.Yellow;
         }
 

From 7bc3b2072caed1124ddc3b722957a08e0084fd44 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 1 Jun 2023 00:44:13 +0900
Subject: [PATCH 16/42] Update framework

---
 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 6aebae665d..c88bea8265 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -11,7 +11,7 @@
     <AndroidManifestMerger>manifestmerger.jar</AndroidManifestMerger>
   </PropertyGroup>
   <ItemGroup>
-    <PackageReference Include="ppy.osu.Framework.Android" Version="2023.521.0" />
+    <PackageReference Include="ppy.osu.Framework.Android" Version="2023.531.0" />
   </ItemGroup>
   <ItemGroup>
     <AndroidManifestOverlay Include="$(MSBuildThisFileDirectory)osu.Android\Properties\AndroidManifestOverlay.xml" />
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 0fd2b0c2c5..8a941ca6c1 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.20.0" />
-    <PackageReference Include="ppy.osu.Framework" Version="2023.521.0" />
+    <PackageReference Include="ppy.osu.Framework" Version="2023.531.0" />
     <PackageReference Include="ppy.osu.Game.Resources" Version="2023.510.0" />
     <PackageReference Include="Sentry" Version="3.28.1" />
     <PackageReference Include="SharpCompress" Version="0.32.2" />
diff --git a/osu.iOS.props b/osu.iOS.props
index e4a169f8e5..1dcece7741 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -16,6 +16,6 @@
     <RuntimeIdentifier>iossimulator-x64</RuntimeIdentifier>
   </PropertyGroup>
   <ItemGroup>
-    <PackageReference Include="ppy.osu.Framework.iOS" Version="2023.521.0" />
+    <PackageReference Include="ppy.osu.Framework.iOS" Version="2023.531.0" />
   </ItemGroup>
 </Project>

From 310c54fe28ed32af6ac804c084c5c1de18d38651 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 1 Jun 2023 13:26:46 +0900
Subject: [PATCH 17/42] Add test coverage ensuring positional data is present
 in hit events

---
 .../Mods/TestSceneOsuModAutoplay.cs                  | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs
index 8fdab9f1f9..616a9c362d 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs
@@ -17,6 +17,18 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
 {
     public partial class TestSceneOsuModAutoplay : OsuModTestScene
     {
+        [Test]
+        public void TestCursorPositionStoredToJudgement()
+        {
+            CreateModTest(new ModTestData
+            {
+                Autoplay = true,
+                PassCondition = () =>
+                    Player.ScoreProcessor.JudgedHits >= 1
+                    && Player.ScoreProcessor.HitEvents.Any(e => e.Position != null)
+            });
+        }
+
         [Test]
         public void TestSpmUnaffectedByRateAdjust()
             => runSpmTest(new OsuModDaycore

From e830b96e6182308093e0ab61afcb6f3bf01db9cd Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 1 Jun 2023 13:09:47 +0900
Subject: [PATCH 18/42] Add back required override to make `AccuracyHeatmap`
 work

---
 osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs
index ab07ac3e9d..f97be0d7ff 100644
--- a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs
+++ b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs
@@ -2,6 +2,8 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System;
+using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Osu.Judgements;
 using osu.Game.Rulesets.Scoring;
 
 namespace osu.Game.Rulesets.Osu.Scoring
@@ -13,6 +15,9 @@ namespace osu.Game.Rulesets.Osu.Scoring
         {
         }
 
+        protected override HitEvent CreateHitEvent(JudgementResult result)
+            => base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit);
+
         protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion)
         {
             return 700000 * comboProgress

From dc595b83f12761a410240e404dc965a27258a37f Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 1 Jun 2023 14:25:18 +0900
Subject: [PATCH 19/42] Remove unused `Dimension` specification from
 `StatisticItem`

---
 osu.Game/Screens/Ranking/Statistics/StatisticItem.cs   | 10 +---------
 osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs |  2 +-
 2 files changed, 2 insertions(+), 10 deletions(-)

diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs
index 5bbd260d3f..77a40959ae 100644
--- a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs
+++ b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs
@@ -6,7 +6,6 @@
 using System;
 using JetBrains.Annotations;
 using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
 using osu.Framework.Localisation;
 
 namespace osu.Game.Screens.Ranking.Statistics
@@ -26,11 +25,6 @@ namespace osu.Game.Screens.Ranking.Statistics
         /// </summary>
         public readonly Func<Drawable> CreateContent;
 
-        /// <summary>
-        /// The <see cref="Dimension"/> of this row. This can be thought of as the column dimension of an encompassing <see cref="GridContainer"/>.
-        /// </summary>
-        public readonly Dimension Dimension;
-
         /// <summary>
         /// Whether this item requires hit events. If true, <see cref="CreateContent"/> will not be called if no hit events are available.
         /// </summary>
@@ -42,13 +36,11 @@ namespace osu.Game.Screens.Ranking.Statistics
         /// <param name="name">The name of the item. Can be <see langword="null"/> to hide the item header.</param>
         /// <param name="createContent">A function returning the <see cref="Drawable"/> content to be displayed.</param>
         /// <param name="requiresHitEvents">Whether this item requires hit events. If true, <see cref="CreateContent"/> will not be called if no hit events are available.</param>
-        /// <param name="dimension">The <see cref="Dimension"/> of this item. This can be thought of as the column dimension of an encompassing <see cref="GridContainer"/>.</param>
-        public StatisticItem(LocalisableString name, [NotNull] Func<Drawable> createContent, bool requiresHitEvents = false, [CanBeNull] Dimension dimension = null)
+        public StatisticItem(LocalisableString name, [NotNull] Func<Drawable> createContent, bool requiresHitEvents = false)
         {
             Name = name;
             RequiresHitEvents = requiresHitEvents;
             CreateContent = createContent;
-            Dimension = dimension;
         }
     }
 }
diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs
index 4c22afd8f7..c11c42e290 100644
--- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs
+++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs
@@ -168,7 +168,7 @@ namespace osu.Game.Screens.Ranking.Statistics
                                 Origin = Anchor.Centre,
                             });
 
-                            dimensions.Add(col.Dimension ?? new Dimension());
+                            dimensions.Add(new Dimension());
                         }
 
                         rows.Add(new GridContainer

From 985604fab5bb4a74ef7eacdcd842e8190c74047b Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 1 Jun 2023 14:35:14 +0900
Subject: [PATCH 20/42] Return `StatisticItem`s rather than `StatisticRow`s
 from ruleset

There were no usages of more than one column being provided per row, so
it seemed like unnecessarily complexity. I'm currently trying to reduce
complexity so we can improve the layout of the results screen, which
currently has up to three levels of nested `GridContainer`s.

Of note, I can't add backwards compatibility because the method
signature has not changed in `Ruleset` (only the return type). If we do
want to keep compatibility with other rulesets, we could designate a new
name for the updated method.
---
 osu.Game.Rulesets.Mania/ManiaRuleset.cs       | 44 ++++---------
 osu.Game.Rulesets.Osu/OsuRuleset.cs           | 58 +++++------------
 osu.Game.Rulesets.Taiko/TaikoRuleset.cs       | 44 ++++---------
 .../Ranking/TestSceneStatisticsPanel.cs       | 65 +++----------------
 osu.Game/Rulesets/Ruleset.cs                  |  4 +-
 .../Statistics/SimpleStatisticTable.cs        |  2 +-
 .../Ranking/Statistics/SoloStatisticsPanel.cs | 26 +++-----
 .../Ranking/Statistics/StatisticItem.cs       |  2 +-
 .../Ranking/Statistics/StatisticRow.cs        | 21 ------
 .../Ranking/Statistics/StatisticsPanel.cs     | 40 +++++-------
 10 files changed, 83 insertions(+), 223 deletions(-)
 delete mode 100644 osu.Game/Screens/Ranking/Statistics/StatisticRow.cs

diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
index d324682989..e8fda3ec80 100644
--- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
@@ -389,41 +389,23 @@ namespace osu.Game.Rulesets.Mania
             return base.GetDisplayNameForHitResult(result);
         }
 
-        public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[]
+        public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[]
         {
-            new StatisticRow
+            new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap)
             {
-                Columns = new[]
-                {
-                    new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap)
-                    {
-                        RelativeSizeAxes = Axes.X,
-                        AutoSizeAxes = Axes.Y
-                    }),
-                }
-            },
-            new StatisticRow
+                RelativeSizeAxes = Axes.X,
+                AutoSizeAxes = Axes.Y
+            }),
+            new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(score.HitEvents)
             {
-                Columns = new[]
-                {
-                    new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(score.HitEvents)
-                    {
-                        RelativeSizeAxes = Axes.X,
-                        Height = 250
-                    }, true),
-                }
-            },
-            new StatisticRow
+                RelativeSizeAxes = Axes.X,
+                Height = 250
+            }, true),
+            new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[]
             {
-                Columns = new[]
-                {
-                    new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[]
-                    {
-                        new AverageHitError(score.HitEvents),
-                        new UnstableRate(score.HitEvents)
-                    }), true)
-                }
-            }
+                new AverageHitError(score.HitEvents),
+                new UnstableRate(score.HitEvents)
+            }), true)
         };
 
         public override IRulesetFilterCriteria CreateRulesetFilterCriteria()
diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs
index 922594a93a..8ce55d78dd 100644
--- a/osu.Game.Rulesets.Osu/OsuRuleset.cs
+++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs
@@ -291,56 +291,32 @@ namespace osu.Game.Rulesets.Osu
             return base.GetDisplayNameForHitResult(result);
         }
 
-        public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap)
+        public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap)
         {
             var timedHitEvents = score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle)).ToList();
 
             return new[]
             {
-                new StatisticRow
+                new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap)
                 {
-                    Columns = new[]
-                    {
-                        new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap)
-                        {
-                            RelativeSizeAxes = Axes.X,
-                            AutoSizeAxes = Axes.Y
-                        }),
-                    }
-                },
-                new StatisticRow
+                    RelativeSizeAxes = Axes.X,
+                    AutoSizeAxes = Axes.Y
+                }),
+                new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(timedHitEvents)
                 {
-                    Columns = new[]
-                    {
-                        new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(timedHitEvents)
-                        {
-                            RelativeSizeAxes = Axes.X,
-                            Height = 250
-                        }, true),
-                    }
-                },
-                new StatisticRow
+                    RelativeSizeAxes = Axes.X,
+                    Height = 250
+                }, true),
+                new StatisticItem("Accuracy Heatmap", () => new AccuracyHeatmap(score, playableBeatmap)
                 {
-                    Columns = new[]
-                    {
-                        new StatisticItem("Accuracy Heatmap", () => new AccuracyHeatmap(score, playableBeatmap)
-                        {
-                            RelativeSizeAxes = Axes.X,
-                            Height = 250
-                        }, true),
-                    }
-                },
-                new StatisticRow
+                    RelativeSizeAxes = Axes.X,
+                    Height = 250
+                }, true),
+                new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[]
                 {
-                    Columns = new[]
-                    {
-                        new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[]
-                        {
-                            new AverageHitError(timedHitEvents),
-                            new UnstableRate(timedHitEvents)
-                        }), true)
-                    }
-                }
+                    new AverageHitError(timedHitEvents),
+                    new UnstableRate(timedHitEvents)
+                }), true)
             };
         }
 
diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs
index a35fdb890d..d6824109b3 100644
--- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs
+++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs
@@ -229,45 +229,27 @@ namespace osu.Game.Rulesets.Taiko
             return base.GetDisplayNameForHitResult(result);
         }
 
-        public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap)
+        public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap)
         {
             var timedHitEvents = score.HitEvents.Where(e => e.HitObject is Hit).ToList();
 
             return new[]
             {
-                new StatisticRow
+                new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap)
                 {
-                    Columns = new[]
-                    {
-                        new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap)
-                        {
-                            RelativeSizeAxes = Axes.X,
-                            AutoSizeAxes = Axes.Y
-                        }),
-                    }
-                },
-                new StatisticRow
+                    RelativeSizeAxes = Axes.X,
+                    AutoSizeAxes = Axes.Y
+                }),
+                new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(timedHitEvents)
                 {
-                    Columns = new[]
-                    {
-                        new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(timedHitEvents)
-                        {
-                            RelativeSizeAxes = Axes.X,
-                            Height = 250
-                        }, true),
-                    }
-                },
-                new StatisticRow
+                    RelativeSizeAxes = Axes.X,
+                    Height = 250
+                }, true),
+                new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[]
                 {
-                    Columns = new[]
-                    {
-                        new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[]
-                        {
-                            new AverageHitError(timedHitEvents),
-                            new UnstableRate(timedHitEvents)
-                        }), true)
-                    }
-                }
+                    new AverageHitError(timedHitEvents),
+                    new UnstableRate(timedHitEvents)
+                }), true)
             };
         }
     }
diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs
index fcd5f97fcc..67211a3b72 100644
--- a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs
+++ b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs
@@ -174,78 +174,33 @@ namespace osu.Game.Tests.Visual.Ranking
 
         private class TestRulesetAllStatsRequireHitEvents : TestRuleset
         {
-            public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap)
+            public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[]
             {
-                return new[]
-                {
-                    new StatisticRow
-                    {
-                        Columns = new[]
-                        {
-                            new StatisticItem("Statistic Requiring Hit Events 1",
-                                () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true)
-                        }
-                    },
-                    new StatisticRow
-                    {
-                        Columns = new[]
-                        {
-                            new StatisticItem("Statistic Requiring Hit Events 2",
-                                () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true)
-                        }
-                    }
-                };
-            }
+                new StatisticItem("Statistic Requiring Hit Events 1", () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true),
+                new StatisticItem("Statistic Requiring Hit Events 2", () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true)
+            };
         }
 
         private class TestRulesetNoStatsRequireHitEvents : TestRuleset
         {
-            public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap)
+            public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap)
             {
                 return new[]
                 {
-                    new StatisticRow
-                    {
-                        Columns = new[]
-                        {
-                            new StatisticItem("Statistic Not Requiring Hit Events 1",
-                                () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events"))
-                        }
-                    },
-                    new StatisticRow
-                    {
-                        Columns = new[]
-                        {
-                            new StatisticItem("Statistic Not Requiring Hit Events 2",
-                                () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events"))
-                        }
-                    }
+                    new StatisticItem("Statistic Not Requiring Hit Events 1", () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events")),
+                    new StatisticItem("Statistic Not Requiring Hit Events 2", () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events"))
                 };
             }
         }
 
         private class TestRulesetMixed : TestRuleset
         {
-            public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap)
+            public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap)
             {
                 return new[]
                 {
-                    new StatisticRow
-                    {
-                        Columns = new[]
-                        {
-                            new StatisticItem("Statistic Requiring Hit Events",
-                                () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true)
-                        }
-                    },
-                    new StatisticRow
-                    {
-                        Columns = new[]
-                        {
-                            new StatisticItem("Statistic Not Requiring Hit Events",
-                                () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events"))
-                        }
-                    }
+                    new StatisticItem("Statistic Requiring Hit Events", () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true),
+                    new StatisticItem("Statistic Not Requiring Hit Events", () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events"))
                 };
             }
         }
diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs
index a77068eb14..490ec1475c 100644
--- a/osu.Game/Rulesets/Ruleset.cs
+++ b/osu.Game/Rulesets/Ruleset.cs
@@ -321,8 +321,8 @@ namespace osu.Game.Rulesets
         /// </summary>
         /// <param name="score">The <see cref="ScoreInfo"/> to create the statistics for. The score is guaranteed to have <see cref="ScoreInfo.HitEvents"/> populated.</param>
         /// <param name="playableBeatmap">The <see cref="IBeatmap"/>, converted for this <see cref="Ruleset"/> with all relevant <see cref="Mod"/>s applied.</param>
-        /// <returns>The <see cref="StatisticRow"/>s to display. Each <see cref="StatisticRow"/> may contain 0 or more <see cref="StatisticItem"/>.</returns>
-        public virtual StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => Array.Empty<StatisticRow>();
+        /// <returns>The <see cref="StatisticItem"/>s to display.</returns>
+        public virtual StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => Array.Empty<StatisticItem>();
 
         /// <summary>
         /// Get all valid <see cref="HitResult"/>s for this ruleset.
diff --git a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs
index d10888be43..d68df4558a 100644
--- a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs
+++ b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs
@@ -17,7 +17,7 @@ namespace osu.Game.Screens.Ranking.Statistics
 {
     /// <summary>
     /// Represents a table with simple statistics (ones that only need textual display).
-    /// Richer visualisations should be done with <see cref="StatisticRow"/>s and <see cref="StatisticItem"/>s.
+    /// Richer visualisations should be done with <see cref="StatisticItem"/>s.
     /// </summary>
     public partial class SimpleStatisticTable : CompositeDrawable
     {
diff --git a/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs
index 57d072b7de..73b9897096 100644
--- a/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs
+++ b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs
@@ -23,32 +23,26 @@ namespace osu.Game.Screens.Ranking.Statistics
 
         public Bindable<SoloStatisticsUpdate?> StatisticsUpdate { get; } = new Bindable<SoloStatisticsUpdate?>();
 
-        protected override ICollection<StatisticRow> CreateStatisticRows(ScoreInfo newScore, IBeatmap playableBeatmap)
+        protected override ICollection<StatisticItem> CreateStatisticItems(ScoreInfo newScore, IBeatmap playableBeatmap)
         {
-            var rows = base.CreateStatisticRows(newScore, playableBeatmap);
+            var items = base.CreateStatisticItems(newScore, playableBeatmap);
 
             if (newScore.UserID > 1
                 && newScore.UserID == achievedScore.UserID
                 && newScore.OnlineID > 0
                 && newScore.OnlineID == achievedScore.OnlineID)
             {
-                rows = rows.Append(new StatisticRow
+                items = items.Append(new StatisticItem("Overall Ranking", () => new OverallRanking
                 {
-                    Columns = new[]
-                    {
-                        new StatisticItem("Overall Ranking", () => new OverallRanking
-                        {
-                            RelativeSizeAxes = Axes.X,
-                            Anchor = Anchor.Centre,
-                            Origin = Anchor.Centre,
-                            Width = 0.5f,
-                            StatisticsUpdate = { BindTarget = StatisticsUpdate }
-                        })
-                    }
-                }).ToArray();
+                    RelativeSizeAxes = Axes.X,
+                    Anchor = Anchor.Centre,
+                    Origin = Anchor.Centre,
+                    Width = 0.5f,
+                    StatisticsUpdate = { BindTarget = StatisticsUpdate }
+                })).ToArray();
             }
 
-            return rows;
+            return items;
         }
     }
 }
diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs
index 77a40959ae..c5bdc6f6f5 100644
--- a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs
+++ b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs
@@ -31,7 +31,7 @@ namespace osu.Game.Screens.Ranking.Statistics
         public readonly bool RequiresHitEvents;
 
         /// <summary>
-        /// Creates a new <see cref="StatisticItem"/>, to be displayed inside a <see cref="StatisticRow"/> in the results screen.
+        /// Creates a new <see cref="StatisticItem"/>, to be displayed in the results screen.
         /// </summary>
         /// <param name="name">The name of the item. Can be <see langword="null"/> to hide the item header.</param>
         /// <param name="createContent">A function returning the <see cref="Drawable"/> content to be displayed.</param>
diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs b/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs
deleted file mode 100644
index 9f5f44918e..0000000000
--- a/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs
+++ /dev/null
@@ -1,21 +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.
-
-#nullable disable
-
-using JetBrains.Annotations;
-
-namespace osu.Game.Screens.Ranking.Statistics
-{
-    /// <summary>
-    /// A row of statistics to be displayed in the results screen.
-    /// </summary>
-    public class StatisticRow
-    {
-        /// <summary>
-        /// The columns of this <see cref="StatisticRow"/>.
-        /// </summary>
-        [ItemNotNull]
-        public StatisticItem[] Columns;
-    }
-}
diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs
index c11c42e290..31dd5df27a 100644
--- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs
+++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs
@@ -100,9 +100,9 @@ namespace osu.Game.Screens.Ranking.Statistics
                 bool hitEventsAvailable = newScore.HitEvents.Count != 0;
                 Container<Drawable> container;
 
-                var statisticRows = CreateStatisticRows(newScore, task.GetResultSafely());
+                var statisticItems = CreateStatisticItems(newScore, task.GetResultSafely());
 
-                if (!hitEventsAvailable && statisticRows.SelectMany(r => r.Columns).All(c => c.RequiresHitEvents))
+                if (!hitEventsAvailable && statisticItems.All(c => c.RequiresHitEvents))
                 {
                     container = new FillFlowContainer
                     {
@@ -144,33 +144,25 @@ namespace osu.Game.Screens.Ranking.Statistics
 
                     bool anyRequiredHitEvents = false;
 
-                    foreach (var row in statisticRows)
+                    foreach (var item in statisticItems)
                     {
-                        var columns = row.Columns;
-
-                        if (columns.Length == 0)
-                            continue;
-
                         var columnContent = new List<Drawable>();
                         var dimensions = new List<Dimension>();
 
-                        foreach (var col in columns)
+                        if (!hitEventsAvailable && item.RequiresHitEvents)
                         {
-                            if (!hitEventsAvailable && col.RequiresHitEvents)
-                            {
-                                anyRequiredHitEvents = true;
-                                continue;
-                            }
-
-                            columnContent.Add(new StatisticContainer(col)
-                            {
-                                Anchor = Anchor.Centre,
-                                Origin = Anchor.Centre,
-                            });
-
-                            dimensions.Add(new Dimension());
+                            anyRequiredHitEvents = true;
+                            continue;
                         }
 
+                        columnContent.Add(new StatisticContainer(item)
+                        {
+                            Anchor = Anchor.Centre,
+                            Origin = Anchor.Centre,
+                        });
+
+                        dimensions.Add(new Dimension());
+
                         rows.Add(new GridContainer
                         {
                             Anchor = Anchor.TopCentre,
@@ -219,11 +211,11 @@ namespace osu.Game.Screens.Ranking.Statistics
         }
 
         /// <summary>
-        /// Creates the <see cref="StatisticRow"/>s to be displayed in this panel for a given <paramref name="newScore"/>.
+        /// Creates the <see cref="StatisticItem"/>s to be displayed in this panel for a given <paramref name="newScore"/>.
         /// </summary>
         /// <param name="newScore">The score to create the rows for.</param>
         /// <param name="playableBeatmap">The beatmap on which the score was set.</param>
-        protected virtual ICollection<StatisticRow> CreateStatisticRows(ScoreInfo newScore, IBeatmap playableBeatmap)
+        protected virtual ICollection<StatisticItem> CreateStatisticItems(ScoreInfo newScore, IBeatmap playableBeatmap)
             => newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap);
 
         protected override bool OnClick(ClickEvent e)

From 9f8a13480bcf6d760b11e92381b51d59d53a3e4a Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 1 Jun 2023 15:16:47 +0900
Subject: [PATCH 21/42] Automatically disable tablet support on error

Closes #23710.
---
 osu.Game/OsuGame.cs | 17 ++++++++++++-----
 1 file changed, 12 insertions(+), 5 deletions(-)

diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index fe6e479d19..33ac8cc101 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -1136,12 +1136,19 @@ namespace osu.Game
 
                 if (entry.Level == LogLevel.Error)
                 {
-                    Schedule(() => Notifications.Post(new SimpleNotification
+                    Schedule(() =>
                     {
-                        Text = $"Encountered tablet error: \"{message}\"",
-                        Icon = FontAwesome.Solid.PenSquare,
-                        IconColour = Colours.RedDark,
-                    }));
+                        Notifications.Post(new SimpleNotification
+                        {
+                            Text = $"Disabling tablet support due to error: \"{message}\"",
+                            Icon = FontAwesome.Solid.PenSquare,
+                            IconColour = Colours.RedDark,
+                        });
+
+                        var tabletHandler = Host.AvailableInputHandlers.OfType<ITabletHandler>().FirstOrDefault();
+                        if (tabletHandler != null)
+                            tabletHandler.Enabled.Value = false;
+                    });
                 }
                 else if (notifyOnWarning)
                 {

From 0a7d5a51d419055990d9da48345de1284408639f Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 1 Jun 2023 15:50:10 +0900
Subject: [PATCH 22/42] Fix mouse cursor potentially disappearing for good if
 screenshot capture fails

---
 osu.Game/Graphics/ScreenshotManager.cs | 115 +++++++++++++------------
 1 file changed, 60 insertions(+), 55 deletions(-)

diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs
index d799e82bc9..dd90cbc074 100644
--- a/osu.Game/Graphics/ScreenshotManager.cs
+++ b/osu.Game/Graphics/ScreenshotManager.cs
@@ -86,70 +86,75 @@ namespace osu.Game.Graphics
         {
             Interlocked.Increment(ref screenShotTasks);
 
-            if (!captureMenuCursor.Value)
+            try
             {
-                cursorVisibility.Value = false;
-
-                // We need to wait for at most 3 draw nodes to be drawn, following which we can be assured at least one DrawNode has been generated/drawn with the set value
-                const int frames_to_wait = 3;
-
-                int framesWaited = 0;
-
-                using (var framesWaitedEvent = new ManualResetEventSlim(false))
+                if (!captureMenuCursor.Value)
                 {
-                    ScheduledDelegate waitDelegate = host.DrawThread.Scheduler.AddDelayed(() =>
+                    cursorVisibility.Value = false;
+
+                    // We need to wait for at most 3 draw nodes to be drawn, following which we can be assured at least one DrawNode has been generated/drawn with the set value
+                    const int frames_to_wait = 3;
+
+                    int framesWaited = 0;
+
+                    using (var framesWaitedEvent = new ManualResetEventSlim(false))
                     {
-                        if (framesWaited++ >= frames_to_wait)
-                            // ReSharper disable once AccessToDisposedClosure
-                            framesWaitedEvent.Set();
-                    }, 10, true);
+                        ScheduledDelegate waitDelegate = host.DrawThread.Scheduler.AddDelayed(() =>
+                        {
+                            if (framesWaited++ >= frames_to_wait)
+                                // ReSharper disable once AccessToDisposedClosure
+                                framesWaitedEvent.Set();
+                        }, 10, true);
 
-                    if (!framesWaitedEvent.Wait(1000))
-                        throw new TimeoutException("Screenshot data did not arrive in a timely fashion");
+                        if (!framesWaitedEvent.Wait(1000))
+                            throw new TimeoutException("Screenshot data did not arrive in a timely fashion");
 
-                    waitDelegate.Cancel();
+                        waitDelegate.Cancel();
+                    }
+                }
+
+                using (var image = await host.TakeScreenshotAsync().ConfigureAwait(false))
+                {
+                    host.GetClipboard()?.SetImage(image);
+
+                    (string filename, var stream) = getWritableStream();
+
+                    if (filename == null) return;
+
+                    using (stream)
+                    {
+                        switch (screenshotFormat.Value)
+                        {
+                            case ScreenshotFormat.Png:
+                                await image.SaveAsPngAsync(stream).ConfigureAwait(false);
+                                break;
+
+                            case ScreenshotFormat.Jpg:
+                                const int jpeg_quality = 92;
+
+                                await image.SaveAsJpegAsync(stream, new JpegEncoder { Quality = jpeg_quality }).ConfigureAwait(false);
+                                break;
+
+                            default:
+                                throw new InvalidOperationException($"Unknown enum member {nameof(ScreenshotFormat)} {screenshotFormat.Value}.");
+                        }
+                    }
+
+                    notificationOverlay.Post(new SimpleNotification
+                    {
+                        Text = $"Screenshot {filename} saved!",
+                        Activated = () =>
+                        {
+                            storage.PresentFileExternally(filename);
+                            return true;
+                        }
+                    });
                 }
             }
-
-            using (var image = await host.TakeScreenshotAsync().ConfigureAwait(false))
+            finally
             {
-                if (Interlocked.Decrement(ref screenShotTasks) == 0 && cursorVisibility.Value == false)
+                if (Interlocked.Decrement(ref screenShotTasks) == 0)
                     cursorVisibility.Value = true;
-
-                host.GetClipboard()?.SetImage(image);
-
-                (string filename, var stream) = getWritableStream();
-
-                if (filename == null) return;
-
-                using (stream)
-                {
-                    switch (screenshotFormat.Value)
-                    {
-                        case ScreenshotFormat.Png:
-                            await image.SaveAsPngAsync(stream).ConfigureAwait(false);
-                            break;
-
-                        case ScreenshotFormat.Jpg:
-                            const int jpeg_quality = 92;
-
-                            await image.SaveAsJpegAsync(stream, new JpegEncoder { Quality = jpeg_quality }).ConfigureAwait(false);
-                            break;
-
-                        default:
-                            throw new InvalidOperationException($"Unknown enum member {nameof(ScreenshotFormat)} {screenshotFormat.Value}.");
-                    }
-                }
-
-                notificationOverlay.Post(new SimpleNotification
-                {
-                    Text = $"Screenshot {filename} saved!",
-                    Activated = () =>
-                    {
-                        storage.PresentFileExternally(filename);
-                        return true;
-                    }
-                });
             }
         });
 

From 289f0e9862d690cf40e7fcd081cd962a1791fb39 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 1 Jun 2023 15:50:37 +0900
Subject: [PATCH 23/42] Use `FireAndForget` to avoid unobserved exception

---
 osu.Game/Graphics/ScreenshotManager.cs | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs
index dd90cbc074..82f89d6889 100644
--- a/osu.Game/Graphics/ScreenshotManager.cs
+++ b/osu.Game/Graphics/ScreenshotManager.cs
@@ -19,6 +19,7 @@ using osu.Framework.Platform;
 using osu.Framework.Threading;
 using osu.Game.Configuration;
 using osu.Game.Input.Bindings;
+using osu.Game.Online.Multiplayer;
 using osu.Game.Overlays;
 using osu.Game.Overlays.Notifications;
 using SixLabors.ImageSharp;
@@ -69,7 +70,7 @@ namespace osu.Game.Graphics
             {
                 case GlobalAction.TakeScreenshot:
                     shutter.Play();
-                    TakeScreenshotAsync();
+                    TakeScreenshotAsync().FireAndForget();
                     return true;
             }
 

From 98f35f74811329500e558943464d003b3543f276 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 1 Jun 2023 16:06:34 +0900
Subject: [PATCH 24/42] Fix osu!mania hold notes snapping to judgement area too
 early on early hits

Closes https://github.com/ppy/osu/issues/23515.
---
 .../Objects/Drawables/DrawableHoldNote.cs                    | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
index ce34addeff..3f91328128 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
@@ -245,7 +245,10 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
             // As the note is being held, adjust the size of the sizing container. This has two effects:
             // 1. The contained masking container will mask the body and ticks.
             // 2. The head note will move along with the new "head position" in the container.
-            if (Head.IsHit && releaseTime == null && DrawHeight > 0)
+            //
+            // As per stable, this should not apply for early hits, waiting until the object starts to touch the
+            // judgement area first.
+            if (Head.IsHit && releaseTime == null && DrawHeight > 0 && Time.Current >= HitObject.StartTime)
             {
                 // How far past the hit target this hold note is.
                 float yOffset = Direction.Value == ScrollingDirection.Up ? -Y : Y;

From 949fe327402bb9418b2875aba0a0e7658f931b91 Mon Sep 17 00:00:00 2001
From: Andrei Zavatski <megaman9919@gmail.com>
Date: Wed, 31 May 2023 01:23:42 +0300
Subject: [PATCH 25/42] Use combined area of children as a mask instead

---
 .../Timeline/TimelineHitObjectBlueprint.cs    | 24 ++++++++++++++++---
 1 file changed, 21 insertions(+), 3 deletions(-)

diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
index 900f0ff4a2..31cf6ad766 100644
--- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
@@ -50,6 +50,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
 
         private readonly Container colouredComponents;
         private readonly OsuSpriteText comboIndexText;
+        private readonly SamplePointPiece samplePointPiece;
+        private readonly DifficultyPointPiece difficultyPointPiece = null!;
 
         [Resolved]
         private ISkinSource skin { get; set; } = null!;
@@ -101,7 +103,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
                         },
                     }
                 },
-                new SamplePointPiece(Item)
+                samplePointPiece = new SamplePointPiece(Item)
                 {
                     Anchor = Anchor.BottomLeft,
                     Origin = Anchor.TopCentre
@@ -118,7 +120,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
 
             if (item is IHasSliderVelocity)
             {
-                AddInternal(new DifficultyPointPiece(Item)
+                AddInternal(difficultyPointPiece = new DifficultyPointPiece(Item)
                 {
                     Anchor = Anchor.TopLeft,
                     Origin = Anchor.BottomCentre
@@ -244,7 +246,23 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
 
         public override Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.TopLeft;
 
-        protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false;
+        protected override bool ComputeIsMaskedAway(RectangleF maskingBounds)
+        {
+            // Since children are exceeding the component size, we need to use a custom quad to compute whether it should be masked away.
+
+            // When component isn't masked away there's no need to apply custom logic.
+            if (!base.ComputeIsMaskedAway(maskingBounds))
+                return false;
+
+            // If component is considered masked away we'll use children to create an extended quad.
+            var rect = RectangleF.Union(ScreenSpaceDrawQuad.AABBFloat, circle.ScreenSpaceDrawQuad.AABBFloat);
+            rect = RectangleF.Union(rect, samplePointPiece.ScreenSpaceDrawQuad.AABBFloat);
+
+            if (difficultyPointPiece != null)
+                rect = RectangleF.Union(rect, difficultyPointPiece.ScreenSpaceDrawQuad.AABBFloat);
+
+            return !Precision.AlmostIntersects(maskingBounds, rect);
+        }
 
         private partial class Tick : Circle
         {

From 03eb7c78300cd689d504dd8874b700a67dec1fa3 Mon Sep 17 00:00:00 2001
From: Andrei Zavatski <megaman9919@gmail.com>
Date: Thu, 1 Jun 2023 21:21:01 +0300
Subject: [PATCH 26/42] Fix nullability

---
 .../Compose/Components/Timeline/TimelineHitObjectBlueprint.cs   | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
index 31cf6ad766..638e2d43c8 100644
--- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
@@ -51,7 +51,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
         private readonly Container colouredComponents;
         private readonly OsuSpriteText comboIndexText;
         private readonly SamplePointPiece samplePointPiece;
-        private readonly DifficultyPointPiece difficultyPointPiece = null!;
+        private readonly DifficultyPointPiece? difficultyPointPiece;
 
         [Resolved]
         private ISkinSource skin { get; set; } = null!;

From c2d89a32a985515edb376b9abd3d23555dda5445 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Thu, 1 Jun 2023 21:18:00 +0200
Subject: [PATCH 27/42] Adjust inline comment

---
 .../Components/Timeline/TimelineHitObjectBlueprint.cs        | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
index 638e2d43c8..55f122669d 100644
--- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
@@ -250,11 +250,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
         {
             // Since children are exceeding the component size, we need to use a custom quad to compute whether it should be masked away.
 
-            // When component isn't masked away there's no need to apply custom logic.
+            // If the component isn't considered masked away by itself, there's no need to apply custom logic.
             if (!base.ComputeIsMaskedAway(maskingBounds))
                 return false;
 
-            // If component is considered masked away we'll use children to create an extended quad.
+            // If the component is considered masked away, we'll use children to create an extended quad that encapsulates all parts of this blueprint
+            // to ensure it doesn't pop in and out of existence abruptly when scrolling the timeline.
             var rect = RectangleF.Union(ScreenSpaceDrawQuad.AABBFloat, circle.ScreenSpaceDrawQuad.AABBFloat);
             rect = RectangleF.Union(rect, samplePointPiece.ScreenSpaceDrawQuad.AABBFloat);
 

From c33ddedca0b6cdfaf4054dc80bbdcd2f5f20e842 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 2 Jun 2023 08:48:47 +0900
Subject: [PATCH 28/42] Disable all tablet handlers to guard against a grim
 future

---
 osu.Game/OsuGame.cs | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index 33ac8cc101..3768dad370 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -1145,8 +1145,11 @@ namespace osu.Game
                             IconColour = Colours.RedDark,
                         });
 
-                        var tabletHandler = Host.AvailableInputHandlers.OfType<ITabletHandler>().FirstOrDefault();
-                        if (tabletHandler != null)
+                        // We only have one tablet handler currently.
+                        // The loop here is weakly guarding against a future where more than one is added.
+                        // If this is ever the case, this logic needs adjustment as it should probably only
+                        // disable the relevant tablet handler rather than all.
+                        foreach (var tabletHandler in Host.AvailableInputHandlers.OfType<ITabletHandler>())
                             tabletHandler.Enabled.Value = false;
                     });
                 }

From a44b20832372c0f509f7fda6bfe4b35e829d649f Mon Sep 17 00:00:00 2001
From: John Biddle <johntbiddle@gmail.com>
Date: Thu, 1 Jun 2023 21:25:49 -0700
Subject: [PATCH 29/42] Updated languages in Language.cs to match what is in
 osu-web/resources/lang/. Added Catalan, Persian, Filipino, Hebrew, Croatian,
 Lithuanian, Latvian, Malay, Slovenian, Serbian, Tajik, and Sinhala.

---
 osu.Game/Localisation/Language.cs | 39 +++++++++++++++++++++++++++++++
 1 file changed, 39 insertions(+)

diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs
index 6a4e5110e6..3f0f24f45f 100644
--- a/osu.Game/Localisation/Language.cs
+++ b/osu.Game/Localisation/Language.cs
@@ -22,6 +22,9 @@ namespace osu.Game.Localisation
         [Description(@"Български")]
         bg,
 
+        [Description(@"Català")]
+        ca,
+
         [Description(@"Česky")]
         cs,
 
@@ -37,12 +40,24 @@ namespace osu.Game.Localisation
         [Description(@"español")]
         es,
 
+        [Description(@"فارسی")]
+        fa_ir,
+
         [Description(@"Suomi")]
         fi,
 
+        [Description(@"Filipino")]
+        fil,
+
         [Description(@"français")]
         fr,
 
+        [Description(@"עברית")]
+        he,
+
+        [Description(@"Hrvatski")]
+        hr_hr,
+
         [Description(@"Magyar")]
         hu,
 
@@ -58,6 +73,15 @@ namespace osu.Game.Localisation
         [Description(@"한국어")]
         ko,
 
+        [Description(@"Lietuvių")]
+        lt,
+
+        [Description(@"Latviešu")]
+        lv_lv,
+
+        [Description(@"Melayu")]
+        ms_my,
+
         [Description(@"Nederlands")]
         nl,
 
@@ -79,12 +103,24 @@ namespace osu.Game.Localisation
         [Description(@"Русский")]
         ru,
 
+        [Description(@"සිංහල")]
+        si_lk,
+
         [Description(@"Slovenčina")]
         sk,
 
+        [Description(@"Slovenščina")]
+        sl,
+
+        [Description(@"Српски")]
+        sr,
+
         [Description(@"Svenska")]
         sv,
 
+        [Description(@"Тоҷикӣ")]
+        tg_tj,
+
         [Description(@"ไทย")]
         th,
 
@@ -105,6 +141,9 @@ namespace osu.Game.Localisation
         [Description(@"简体中文")]
         zh,
 
+        [Description(@"繁體中文")]
+        zh_tw,
+
         // Traditional Chinese (Hong Kong) is listed in web sources but has no associated localisations,
         // and was wrongly falling back to Simplified Chinese.
         // Can be revisited if localisations ever arrive.

From bfe80fe143249cefe1798db4675c024075d3db12 Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Fri, 2 Jun 2023 17:37:43 +0900
Subject: [PATCH 30/42] Fix legacy diffcalc creating all mods unnecessarily

---
 osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
index 8dd1b51cae..00c90bd317 100644
--- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
+++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
@@ -137,7 +137,7 @@ namespace osu.Game.Rulesets.Difficulty
 
             foreach (var combination in CreateDifficultyAdjustmentModCombinations())
             {
-                Mod classicMod = rulesetInstance.CreateAllMods().SingleOrDefault(m => m is ModClassic);
+                Mod classicMod = rulesetInstance.CreateMod<ModClassic>();
 
                 var finalCombination = ModUtils.FlattenMod(combination);
                 if (classicMod != null)

From a5fd833214566781159fa51ccc57df785c10817b Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 2 Jun 2023 23:01:03 +0900
Subject: [PATCH 31/42] Fix "bubbles" mod not adding pool to hierarchy (and
 constructing too early)

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

diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs
index 12e2090f89..b74b722bad 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs
@@ -42,14 +42,14 @@ namespace osu.Game.Rulesets.Osu.Mods
 
         private PlayfieldAdjustmentContainer bubbleContainer = null!;
 
+        private DrawablePool<BubbleDrawable> bubblePool = null!;
+
         private readonly Bindable<int> currentCombo = new BindableInt();
 
         private float maxSize;
         private float bubbleSize;
         private double bubbleFade;
 
-        private readonly DrawablePool<BubbleDrawable> bubblePool = new DrawablePool<BubbleDrawable>(100);
-
         public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank;
 
         public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor)
@@ -72,6 +72,7 @@ namespace osu.Game.Rulesets.Osu.Mods
             bubbleContainer = drawableRuleset.CreatePlayfieldAdjustmentContainer();
 
             drawableRuleset.Overlays.Add(bubbleContainer);
+            drawableRuleset.Overlays.Add(bubblePool = new DrawablePool<BubbleDrawable>(100));
         }
 
         public void ApplyToDrawableHitObject(DrawableHitObject drawableObject)

From 474f7c2bc075202e756aa8fe1141f9fe7faab1f5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sat, 3 Jun 2023 16:50:58 +0200
Subject: [PATCH 32/42] Fix code quality inspection

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

diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
index 0d9f91caa1..c27e30d5bb 100644
--- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
+++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
@@ -233,7 +233,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
                     QueueMode = ServerAPIRoom.QueueMode.Value,
                     AutoStartDuration = ServerAPIRoom.AutoStartDuration.Value
                 },
-                Playlist = ServerAPIRoom.Playlist.Select(item => TestMultiplayerClient.CreateMultiplayerPlaylistItem(item)).ToList(),
+                Playlist = ServerAPIRoom.Playlist.Select(CreateMultiplayerPlaylistItem).ToList(),
                 Users = { localUser },
                 Host = localUser
             };

From 602d5db3bb66e84b9ade2d99b004fbdd5e130be6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sat, 3 Jun 2023 19:40:01 +0200
Subject: [PATCH 33/42] Simplify column dimensions code

`dimensions` would always receive exactly one item, so might as well
inline it.

And yes, at this point the grid container is mostly a glorified
`FillFlowContainer { Direction = FlowDirection.Vertical }`, but I am not
touching that in this pull pending further decisions with respect to
direction.
---
 osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs | 5 +----
 1 file changed, 1 insertion(+), 4 deletions(-)

diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs
index 31dd5df27a..c36d7726dc 100644
--- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs
+++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs
@@ -147,7 +147,6 @@ namespace osu.Game.Screens.Ranking.Statistics
                     foreach (var item in statisticItems)
                     {
                         var columnContent = new List<Drawable>();
-                        var dimensions = new List<Dimension>();
 
                         if (!hitEventsAvailable && item.RequiresHitEvents)
                         {
@@ -161,8 +160,6 @@ namespace osu.Game.Screens.Ranking.Statistics
                             Origin = Anchor.Centre,
                         });
 
-                        dimensions.Add(new Dimension());
-
                         rows.Add(new GridContainer
                         {
                             Anchor = Anchor.TopCentre,
@@ -170,7 +167,7 @@ namespace osu.Game.Screens.Ranking.Statistics
                             RelativeSizeAxes = Axes.X,
                             AutoSizeAxes = Axes.Y,
                             Content = new[] { columnContent.ToArray() },
-                            ColumnDimensions = dimensions.ToArray(),
+                            ColumnDimensions = new[] { new Dimension() },
                             RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }
                         });
                     }

From a8b102ef72ef42706d870030b1935c5e3de15a8f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sat, 3 Jun 2023 21:32:12 +0200
Subject: [PATCH 34/42] Remove duplicated `zh_tw` language

`zh_hant` already covers it.
---
 osu.Game/Localisation/Language.cs | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs
index 3f0f24f45f..14dedf9a2e 100644
--- a/osu.Game/Localisation/Language.cs
+++ b/osu.Game/Localisation/Language.cs
@@ -141,9 +141,6 @@ namespace osu.Game.Localisation
         [Description(@"简体中文")]
         zh,
 
-        [Description(@"繁體中文")]
-        zh_tw,
-
         // Traditional Chinese (Hong Kong) is listed in web sources but has no associated localisations,
         // and was wrongly falling back to Simplified Chinese.
         // Can be revisited if localisations ever arrive.

From a8a4c02bb3ca788580eb770695c76fe13936ef4d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sat, 3 Jun 2023 21:48:03 +0200
Subject: [PATCH 35/42] Comment out languages with no glyph support

---
 osu.Game/Localisation/Language.cs | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)

diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs
index 14dedf9a2e..7104583f95 100644
--- a/osu.Game/Localisation/Language.cs
+++ b/osu.Game/Localisation/Language.cs
@@ -40,8 +40,9 @@ namespace osu.Game.Localisation
         [Description(@"español")]
         es,
 
-        [Description(@"فارسی")]
-        fa_ir,
+        // TODO: Requires Arabic glyphs to be added to resources (and possibly also RTL support).
+        // [Description(@"فارسی")]
+        // fa_ir,
 
         [Description(@"Suomi")]
         fi,
@@ -52,8 +53,9 @@ namespace osu.Game.Localisation
         [Description(@"français")]
         fr,
 
-        [Description(@"עברית")]
-        he,
+        // TODO: Requires Hebrew glyphs to be added to resources (and possibly also RTL support).
+        // [Description(@"עברית")]
+        // he,
 
         [Description(@"Hrvatski")]
         hr_hr,
@@ -103,8 +105,10 @@ namespace osu.Game.Localisation
         [Description(@"Русский")]
         ru,
 
-        [Description(@"සිංහල")]
-        si_lk,
+        // TODO: Requires Sinhala glyphs to be added to resources.
+        // Additionally, no translations available yet.
+        // [Description(@"සිංහල")]
+        // si_lk,
 
         [Description(@"Slovenčina")]
         sk,

From 14309ba951f01fa49a6cfb7e0786da5499279b57 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sat, 3 Jun 2023 23:42:08 +0200
Subject: [PATCH 36/42] Comment out Tajik due to lack of support on Windows
 ^<10

---
 osu.Game/Localisation/Language.cs | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs
index 7104583f95..962734e67a 100644
--- a/osu.Game/Localisation/Language.cs
+++ b/osu.Game/Localisation/Language.cs
@@ -122,8 +122,10 @@ namespace osu.Game.Localisation
         [Description(@"Svenska")]
         sv,
 
-        [Description(@"Тоҷикӣ")]
-        tg_tj,
+        // Tajik has no associated localisations yet, and is not supported on Windows versions <10.
+        // TODO: update language mapping in osu-resources to redirect tg-TJ to tg-Cyrl-TJ (which is supported on earlier Windows versions)
+        // [Description(@"Тоҷикӣ")]
+        // tg_tj,
 
         [Description(@"ไทย")]
         th,

From 4f5dfecbb850a41b532ce25b0bf24fd1772c2075 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sat, 3 Jun 2023 23:55:05 +0200
Subject: [PATCH 37/42] Comment out Filipino due to satellite assembly copy
 failure

---
 osu.Game/Localisation/Language.cs | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs
index 962734e67a..711e95486f 100644
--- a/osu.Game/Localisation/Language.cs
+++ b/osu.Game/Localisation/Language.cs
@@ -47,8 +47,9 @@ namespace osu.Game.Localisation
         [Description(@"Suomi")]
         fi,
 
-        [Description(@"Filipino")]
-        fil,
+        // TODO: Doesn't work as appropriate satellite assemblies aren't copied from resources (see: https://github.com/ppy/osu/discussions/18851#discussioncomment-3042170)
+        // [Description(@"Filipino")]
+        // fil,
 
         [Description(@"français")]
         fr,

From 3e308e4c278a01e94090a4c578fe5cf4970a1f32 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Sun, 4 Jun 2023 12:50:30 +0900
Subject: [PATCH 38/42] Add test coverage showing commit failure in manage
 collections dialog

---
 .../TestSceneManageCollectionsDialog.cs         | 17 ++++++++++++++---
 1 file changed, 14 insertions(+), 3 deletions(-)

diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs
index 1e9982f8d4..cfa45ec6ef 100644
--- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs
+++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs
@@ -264,8 +264,9 @@ namespace osu.Game.Tests.Visual.Collections
             assertCollectionName(1, "First");
         }
 
-        [Test]
-        public void TestCollectionRenamedOnTextChange()
+        [TestCase(false)]
+        [TestCase(true)]
+        public void TestCollectionRenamedOnTextChange(bool commitWithEnter)
         {
             BeatmapCollection first = null!;
             DrawableCollectionListItem firstItem = null!;
@@ -293,9 +294,19 @@ namespace osu.Game.Tests.Visual.Collections
             AddStep("change first collection name", () =>
             {
                 firstItem.ChildrenOfType<TextBox>().First().Text = "First";
-                InputManager.Key(Key.Enter);
             });
 
+            if (commitWithEnter)
+                AddStep("commit via enter", () => InputManager.Key(Key.Enter));
+            else
+            {
+                AddStep("commit via click away", () =>
+                {
+                    InputManager.MoveMouseTo(firstItem.ScreenSpaceDrawQuad.TopLeft - new Vector2(10));
+                    InputManager.Click(MouseButton.Left);
+                });
+            }
+
             AddUntilStep("collection has new name", () => first.Name == "First");
         }
 

From eb7586b517dd0f4794f94edd59fcaff7bc8597cd Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Sun, 4 Jun 2023 12:51:03 +0900
Subject: [PATCH 39/42] Ensure collection edit textbox commits on focus loss

As discussed in https://github.com/ppy/osu/discussions/23739
---
 osu.Game/Collections/DrawableCollectionListItem.cs | 1 +
 1 file changed, 1 insertion(+)

diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs
index 23156b1ad5..0ab0ff520d 100644
--- a/osu.Game/Collections/DrawableCollectionListItem.cs
+++ b/osu.Game/Collections/DrawableCollectionListItem.cs
@@ -86,6 +86,7 @@ namespace osu.Game.Collections
                                 RelativeSizeAxes = Axes.Both,
                                 Size = Vector2.One,
                                 CornerRadius = item_height / 2,
+                                CommitOnFocusLost = true,
                                 PlaceholderText = collection.IsManaged ? string.Empty : "Create a new collection"
                             },
                         }

From 4eb0f0261ce153539329360534eda00a551561ea Mon Sep 17 00:00:00 2001
From: Joseph Madamba <madamba.joehu@outlook.com>
Date: Sun, 4 Jun 2023 22:52:05 -0700
Subject: [PATCH 40/42] Fix song select beatmap panels not displaying correct
 background shown in web

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

diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
index 3975bb6bb6..d544db66b1 100644
--- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
+++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
@@ -108,7 +108,7 @@ namespace osu.Game.Screens.Select.Carousel
 
             Header.Children = new Drawable[]
             {
-                background = new DelayedLoadWrapper(() => new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.FirstOrDefault()))
+                background = new DelayedLoadWrapper(() => new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID)))
                 {
                     RelativeSizeAxes = Axes.Both,
                 }, 300)

From 5c1abdc7044d57ca24f6f3f8c09ba9ad8874015c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Mon, 5 Jun 2023 21:33:08 +0200
Subject: [PATCH 41/42] Fix screen navigation test hijacking dummy request
 handler

In an upcoming change, I stumbled upon a test failure mode wherein tests
in `TestSceneScreenNavigation` would die on the following exception:

	2023-05-07 17:58:42 [error]: System.ObjectDisposedException: Cannot access a closed Realm.
	2023-05-07 17:58:42 [error]: Object name: 'Realms.Realm'.
	2023-05-07 17:58:42 [error]: at Realms.Realm.ThrowIfDisposed()
	2023-05-07 17:58:42 [error]: at Realms.Realm.All[T]()
	2023-05-07 17:58:42 [error]: at osu.Game.Beatmaps.BeatmapManager.<>c__DisplayClass25_0.<QueryBeatmap>b__0(Realm r) in D:\a\osu\osu\osu.Game\Beatmaps\BeatmapManager.cs:line 282
	2023-05-07 17:58:42 [error]: at osu.Game.Database.RealmAccess.Run[T](Func`2 action) in D:\a\osu\osu\osu.Game\Database\RealmAccess.cs:line 387
	2023-05-07 17:58:42 [error]: at osu.Game.Beatmaps.BeatmapManager.QueryBeatmap(Expression`1 query) in D:\a\osu\osu\osu.Game\Beatmaps\BeatmapManager.cs:line 282
	2023-05-07 17:58:42 [error]: at osu.Game.Tests.Visual.OnlinePlay.TestRoomRequestsHandler.<HandleRequest>g__createResponseBeatmaps|6_0(Int32[] beatmapIds, <>c__DisplayClass6_0& ) in D:\a\osu\osu\osu.Game\Tests\Visual\OnlinePlay\TestRoomRequestsHandler.cs:line 174
	2023-05-07 17:58:42 [error]: at osu.Game.Tests.Visual.OnlinePlay.TestRoomRequestsHandler.HandleRequest(APIRequest request, APIUser localUser, BeatmapManager beatmapManager) in D:\a\osu\osu\osu.Game\Tests\Visual\OnlinePlay\TestRoomRequestsHandler.cs:line 140
	2023-05-07 17:58:42 [error]: at osu.Game.Tests.Visual.TestMultiplayerComponents.<>c__DisplayClass18_0.<load>b__0(APIRequest request) in D:\a\osu\osu\osu.Game.Tests\Visual\TestMultiplayerComponents.cs:line 80
	2023-05-07 17:58:42 [error]: at osu.Game.Online.API.DummyAPIAccess.<>c__DisplayClass32_0.<Queue>b__0() in D:\a\osu\osu\osu.Game\Online\API\DummyAPIAccess.cs:line 74

Upon closer inspection, one of the tests in the scene instantiates a
`TestMultiplayerComponents` instance. `TestMultiplayerComponents`
registers a custom request handler onto `DummyAPIAccess`. Normally, this
is not an issue; however, because `TestSceneScreenNavigation` is an
`OsuGameTestScene`, and therefore has its storage recycled after every
test, this leads to the error above in the following scenario:

1. `TestPushMatchSubScreenAndPressBackButtonImmediately()` passes.
2. The test is cleaned up, and the test case's storage is recycled,
   including the test case's realm database.
3. In a subsequent test, a web request handled by the dummy API request
   handler is fired. The dummy API request handler subsequently attempts
   to access a realm that does not exist anymore.

As the usage of `TestMultiplayerComponents` is highly unorthodox in this
particular case, I'm opting for a localised fix which ensures that the
request handler is cleaned up appropriately.
---
 .../Visual/Navigation/TestSceneScreenNavigation.cs          | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs
index 193cec8907..18aef99ccd 100644
--- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs
+++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs
@@ -17,6 +17,7 @@ using osu.Game.Beatmaps;
 using osu.Game.Collections;
 using osu.Game.Configuration;
 using osu.Game.Graphics.UserInterface;
+using osu.Game.Online.API;
 using osu.Game.Online.Leaderboards;
 using osu.Game.Overlays;
 using osu.Game.Overlays.BeatmapListing;
@@ -539,6 +540,11 @@ namespace osu.Game.Tests.Visual.Navigation
             AddStep("open room", () => multiplayerComponents.ChildrenOfType<LoungeSubScreen>().Single().Open());
             AddStep("press back button", () => Game.ChildrenOfType<BackButton>().First().Action());
             AddWaitStep("wait two frames", 2);
+
+            AddStep("exit lounge", () => Game.ScreenStack.Exit());
+            // `TestMultiplayerComponents` registers a request handler in its BDL, but never unregisters it.
+            // to prevent the handler living for longer than it should be, clean up manually.
+            AddStep("clean up multiplayer request handler", () => ((DummyAPIAccess)API).HandleRequest = null);
         }
 
         [Test]

From c54670aee1d715c7662517daab78ac591e8dd773 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 6 Jun 2023 13:30:56 +0900
Subject: [PATCH 42/42] Add comment explaining implementation

---
 osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs | 1 +
 1 file changed, 1 insertion(+)

diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
index d544db66b1..b97d37c854 100644
--- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
+++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
@@ -108,6 +108,7 @@ namespace osu.Game.Screens.Select.Carousel
 
             Header.Children = new Drawable[]
             {
+                // Choice of background image matches BSS implementation (always uses the lowest `beatmap_id` from the set).
                 background = new DelayedLoadWrapper(() => new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID)))
                 {
                     RelativeSizeAxes = Axes.Both,