From 5d5e6a5ab750f380393a9c6ffd9cd3028e486f1f Mon Sep 17 00:00:00 2001
From: Hydria <liverpol2222@gmail.com>
Date: Mon, 3 Jul 2023 17:45:30 +0100
Subject: [PATCH 01/61] Finalised LN Adjustment Values

Spent a couple days discussing this on the pp rework server about values that were the most acceptable, these seemed to be the best from the community standpoint of top players.
Note: This is more to fix issues with the current system, not to be a final solution.
Related Google Sheets Page: https://docs.google.com/spreadsheets/d/1P0AxfdKvMHwWBQder4ZkFGO1fC9eADSGCryA5-UGriU/edit?usp=sharing
---
 osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
index 06c825e37d..0a4fec3a70 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
@@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
     {
         private const double individual_decay_base = 0.125;
         private const double overall_decay_base = 0.30;
-        private const double release_threshold = 24;
+        private const double release_threshold = 30;
 
         protected override double SkillMultiplier => 1;
         protected override double StrainDecayBase => 1;
@@ -70,7 +70,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
             // 0.0 +--------+-+---------------> Release Difference / ms
             //         release_threshold
             if (isOverlapping)
-                holdAddition = 1 / (1 + Math.Exp(0.5 * (release_threshold - closestEndTime)));
+                holdAddition = 1 / (1 + Math.Exp(0.27 * (release_threshold - closestEndTime)));
 
             // Decay and increase individualStrains in own column
             individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base);

From ef2134a92a32f0607d0f69bc04c8dbfd2a477acd Mon Sep 17 00:00:00 2001
From: Hydria <liverpol2222@gmail.com>
Date: Sat, 22 Jul 2023 10:45:48 +0100
Subject: [PATCH 02/61] Fix issue with processing LN orders

Related Issue: https://github.com/ppy/osu/issues/22756

The trigger in question happens when
(1) in a chord: a longer LN, then a shorter LN is processed respectively.
(2) in a chord: a long LN, then a note is processed respectively.

however, given the opposite processing step, it will fail to trigger.

We observe that both situations have the same pattern, however has undeterministic results, which only depends on the order the mapper placed each note.
---
 osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
index 0a4fec3a70..7d8d010da0 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
@@ -53,7 +53,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
                 isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) && Precision.DefinitelyBigger(endTime, endTimes[i], 1);
 
                 // We give a slight bonus to everything if something is held meanwhile
-                if (Precision.DefinitelyBigger(endTimes[i], endTime, 1))
+                if (Precision.DefinitelyBigger(endTimes[i], endTime, 1) &&
+                    Precision.DefinitelyBigger(startTime, startTimes[i], 1))
                     holdFactor = 1.25;
 
                 closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - endTimes[i]));

From 1e19def1538d718421e4d7c6802045011616d21e Mon Sep 17 00:00:00 2001
From: Hydria <liverpol2222@gmail.com>
Date: Sat, 22 Jul 2023 16:44:01 +0100
Subject: [PATCH 03/61] 2nd fix to cover all scenarios

---
 osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
index 7d8d010da0..a24fcaad8d 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
@@ -50,7 +50,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
             for (int i = 0; i < endTimes.Length; ++i)
             {
                 // The current note is overlapped if a previous note or end is overlapping the current note body
-                isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) && Precision.DefinitelyBigger(endTime, endTimes[i], 1);
+                isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) &&
+                                 Precision.DefinitelyBigger(endTime, endTimes[i], 1) &&
+                                 Precision.DefinitelyBigger(startTime, startTimes[i], 1);
 
                 // We give a slight bonus to everything if something is held meanwhile
                 if (Precision.DefinitelyBigger(endTimes[i], endTime, 1) &&

From 26c128a09323cb0ee00cdb3c93d0463367b2f451 Mon Sep 17 00:00:00 2001
From: QuantumSno <ben.czaja@protonmail.com>
Date: Fri, 28 Jul 2023 14:39:30 -0400
Subject: [PATCH 04/61] added keybind and localization string

---
 osu.Game/Input/Bindings/GlobalActionContainer.cs       | 6 +++++-
 osu.Game/Localisation/GlobalActionKeyBindingStrings.cs | 5 +++++
 2 files changed, 10 insertions(+), 1 deletion(-)

diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs
index 01c454e3f9..3d24afbb16 100644
--- a/osu.Game/Input/Bindings/GlobalActionContainer.cs
+++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs
@@ -116,9 +116,10 @@ namespace osu.Game.Input.Bindings
             new KeyBinding(new[] { InputKey.F3 }, GlobalAction.DecreaseScrollSpeed),
             new KeyBinding(new[] { InputKey.F4 }, GlobalAction.IncreaseScrollSpeed),
             new KeyBinding(new[] { InputKey.Shift, InputKey.Tab }, GlobalAction.ToggleInGameInterface),
+            new KeyBinding(InputKey.Tab, GlobalAction.ToggleInGameLeaderboard),
             new KeyBinding(InputKey.MouseMiddle, GlobalAction.PauseGameplay),
             new KeyBinding(InputKey.Control, GlobalAction.HoldForHUD),
-            new KeyBinding(InputKey.Tab, GlobalAction.ToggleChatFocus),
+            new KeyBinding(InputKey.Enter, GlobalAction.ToggleChatFocus),
             new KeyBinding(InputKey.F1, GlobalAction.SaveReplay),
             new KeyBinding(InputKey.F2, GlobalAction.ExportReplay),
         };
@@ -285,6 +286,9 @@ namespace osu.Game.Input.Bindings
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleInGameInterface))]
         ToggleInGameInterface,
 
+        [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleInGameLeaderboard))]
+        ToggleInGameLeaderboard,
+
         // Song select keybindings
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleModSelection))]
         ToggleModSelection,
diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs
index f93d86225c..ceefc27968 100644
--- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs
+++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs
@@ -219,6 +219,11 @@ namespace osu.Game.Localisation
         /// </summary>
         public static LocalisableString ToggleInGameInterface => new TranslatableString(getKey(@"toggle_in_game_interface"), @"Toggle in-game interface");
 
+        /// <summary>
+        /// "Toggle in-game leaderboard"
+        /// </summary>
+        public static LocalisableString ToggleInGameLeaderboard => new TranslatableString(getKey(@"toggle_in_game_leaderboard"), @"Toggle in-game leaderboard");
+
         /// <summary>
         /// "Toggle mod select"
         /// </summary>

From a4065486c198fd49fd538ba779f7e6cfb08fe79e Mon Sep 17 00:00:00 2001
From: QuantumSno <ben.czaja@protonmail.com>
Date: Fri, 28 Jul 2023 14:39:41 -0400
Subject: [PATCH 05/61] bound bind during gameplay

---
 osu.Game/Screens/Play/HUDOverlay.cs | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs
index d11171e3fe..9c001b3db3 100644
--- a/osu.Game/Screens/Play/HUDOverlay.cs
+++ b/osu.Game/Screens/Play/HUDOverlay.cs
@@ -78,6 +78,7 @@ namespace osu.Game.Screens.Play
         public Bindable<bool> ShowHud { get; } = new BindableBool();
 
         private Bindable<HUDVisibilityMode> configVisibilityMode;
+        private Bindable<bool> configLeaderboardVisibility;
         private Bindable<bool> configSettingsOverlay;
 
         private readonly BindableBool replayLoaded = new BindableBool();
@@ -179,6 +180,7 @@ namespace osu.Game.Screens.Play
             ModDisplay.Current.Value = mods;
 
             configVisibilityMode = config.GetBindable<HUDVisibilityMode>(OsuSetting.HUDVisibilityMode);
+            configLeaderboardVisibility = config.GetBindable<bool>(OsuSetting.GameplayLeaderboard);
             configSettingsOverlay = config.GetBindable<bool>(OsuSetting.ReplaySettingsOverlay);
 
             if (configVisibilityMode.Value == HUDVisibilityMode.Never && !hasShownNotificationOnce)
@@ -381,6 +383,10 @@ namespace osu.Game.Screens.Play
                     }
 
                     return true;
+
+                case GlobalAction.ToggleInGameLeaderboard:
+                    configLeaderboardVisibility.Value = !configLeaderboardVisibility.Value;
+                    return true;
             }
 
             return false;

From 65b4ae506ee563b9a2b940c4a72b9c9d34ac5dc2 Mon Sep 17 00:00:00 2001
From: QuantumSno <ben.czaja@protonmail.com>
Date: Sat, 29 Jul 2023 13:18:47 -0400
Subject: [PATCH 06/61] Moved enum to bottom of enumeration table

---
 osu.Game/Input/Bindings/GlobalActionContainer.cs | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs
index 3d24afbb16..79f098f90e 100644
--- a/osu.Game/Input/Bindings/GlobalActionContainer.cs
+++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs
@@ -286,9 +286,6 @@ namespace osu.Game.Input.Bindings
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleInGameInterface))]
         ToggleInGameInterface,
 
-        [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleInGameLeaderboard))]
-        ToggleInGameLeaderboard,
-
         // Song select keybindings
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleModSelection))]
         ToggleModSelection,
@@ -382,5 +379,9 @@ namespace osu.Game.Input.Bindings
 
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleReplaySettings))]
         ToggleReplaySettings,
+
+        // Editor (cont)
+        [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleInGameLeaderboard))]
+        ToggleInGameLeaderboard,
     }
 }

From 2e2b0c4e4109905f46e17f478ab1c0e936abfb23 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Tue, 1 Aug 2023 00:22:08 +0200
Subject: [PATCH 07/61] Move `SliderWithTextBoxInput` to more general namespace

---
 .../UserInterfaceV2}/SliderWithTextBoxInput.cs                 | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)
 rename osu.Game/{Screens/Edit/Timing => Graphics/UserInterfaceV2}/SliderWithTextBoxInput.cs (97%)

diff --git a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
similarity index 97%
rename from osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs
rename to osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
index 1bf0e5299d..3f4e133d45 100644
--- a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs
+++ b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
@@ -8,12 +8,11 @@ using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.UserInterface;
 using osu.Framework.Localisation;
-using osu.Game.Graphics.UserInterfaceV2;
 using osu.Game.Overlays.Settings;
 using osu.Game.Utils;
 using osuTK;
 
-namespace osu.Game.Screens.Edit.Timing
+namespace osu.Game.Graphics.UserInterfaceV2
 {
     public partial class SliderWithTextBoxInput<T> : CompositeDrawable, IHasCurrentValue<T>
         where T : struct, IEquatable<T>, IComparable<T>, IConvertible

From ca81f233563aca7d39822a6d551fc8824c1d3d48 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Tue, 1 Aug 2023 00:44:05 +0200
Subject: [PATCH 08/61] Add test covering non-instantaneous behaviour

---
 .../TestSceneSliderWithTextBoxInput.cs        | 63 +++++++++++++++++++
 .../UserInterfaceV2/SliderWithTextBoxInput.cs |  2 -
 2 files changed, 63 insertions(+), 2 deletions(-)
 create mode 100644 osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs

diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs
new file mode 100644
index 0000000000..dc70e2b9cb
--- /dev/null
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs
@@ -0,0 +1,63 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Testing;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Graphics.UserInterfaceV2;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.UserInterface
+{
+    public partial class TestSceneSliderWithTextBoxInput : OsuManualInputManagerTestScene
+    {
+        private SliderWithTextBoxInput<float> sliderWithTextBoxInput = null!;
+
+        private OsuSliderBar<float> slider => sliderWithTextBoxInput.ChildrenOfType<OsuSliderBar<float>>().Single();
+        private Nub nub => sliderWithTextBoxInput.ChildrenOfType<Nub>().Single();
+        private OsuTextBox textBox => sliderWithTextBoxInput.ChildrenOfType<OsuTextBox>().Single();
+
+        [SetUpSteps]
+        public void SetUpSteps()
+        {
+            AddStep("create slider", () => Child = sliderWithTextBoxInput = new SliderWithTextBoxInput<float>("Test Slider")
+            {
+                Anchor = Anchor.Centre,
+                Origin = Anchor.Centre,
+                Width = 0.5f,
+                Current = new BindableFloat
+                {
+                    MinValue = -5,
+                    MaxValue = 5,
+                    Precision = 0.2f
+                }
+            });
+        }
+
+        [Test]
+        public void TestNonInstantaneousMode()
+        {
+            AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
+            AddStep("change text", () => textBox.Text = "3");
+            AddAssert("slider not moved", () => slider.Current.Value, () => Is.Zero);
+            AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.Zero);
+
+            AddStep("commit text", () => InputManager.Key(Key.Enter));
+            AddAssert("slider moved", () => slider.Current.Value, () => Is.EqualTo(3));
+            AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3));
+
+            AddStep("move mouse to nub", () => InputManager.MoveMouseTo(nub));
+            AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left));
+            AddStep("move mouse to minimum", () => InputManager.MoveMouseTo(sliderWithTextBoxInput.ScreenSpaceDrawQuad.BottomLeft));
+            AddAssert("textbox not changed", () => textBox.Current.Value, () => Is.EqualTo("3"));
+            AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3));
+
+            AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left));
+            AddAssert("textbox changed", () => textBox.Current.Value, () => Is.EqualTo("-5"));
+            AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+        }
+    }
+}
diff --git a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
index 3f4e133d45..38de0ea94a 100644
--- a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
+++ b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
@@ -51,8 +51,6 @@ namespace osu.Game.Graphics.UserInterfaceV2
 
             textBox.OnCommit += (t, isNew) =>
             {
-                if (!isNew) return;
-
                 try
                 {
                     switch (slider.Current)

From 0b019f1d4526dbad742fb8d36d3d7923b575c091 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Tue, 1 Aug 2023 00:45:57 +0200
Subject: [PATCH 09/61] Add test covering desired instantaneous behaviour

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

diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs
index dc70e2b9cb..37f18cd3dd 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs
@@ -59,5 +59,28 @@ namespace osu.Game.Tests.Visual.UserInterface
             AddAssert("textbox changed", () => textBox.Current.Value, () => Is.EqualTo("-5"));
             AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
         }
+
+        [Test]
+        public void TestInstantaneousMode()
+        {
+            AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
+            AddStep("change text", () => textBox.Text = "3");
+            AddAssert("slider moved", () => slider.Current.Value, () => Is.EqualTo(3));
+            AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3));
+
+            AddStep("commit text", () => InputManager.Key(Key.Enter));
+            AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(3));
+            AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3));
+
+            AddStep("move mouse to nub", () => InputManager.MoveMouseTo(nub));
+            AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left));
+            AddStep("move mouse to minimum", () => InputManager.MoveMouseTo(sliderWithTextBoxInput.ScreenSpaceDrawQuad.BottomLeft));
+            AddAssert("textbox changed", () => textBox.Current.Value, () => Is.EqualTo("-5"));
+            AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+            AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left));
+            AddAssert("textbox not changed", () => textBox.Current.Value, () => Is.EqualTo("-5"));
+            AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+        }
     }
 }

From 4fb0ff8800281dc7c4fd97c99359791e1edd6390 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Tue, 1 Aug 2023 00:51:29 +0200
Subject: [PATCH 10/61] Implement instantaneous `SliderWithTextBoxInput` mode

---
 .../TestSceneSliderWithTextBoxInput.cs        |   4 +
 .../UserInterfaceV2/SliderWithTextBoxInput.cs | 128 ++++++++++++------
 2 files changed, 88 insertions(+), 44 deletions(-)

diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs
index 37f18cd3dd..797afccc77 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs
@@ -40,6 +40,8 @@ namespace osu.Game.Tests.Visual.UserInterface
         [Test]
         public void TestNonInstantaneousMode()
         {
+            AddStep("set instantaneous to false", () => sliderWithTextBoxInput.Instantaneous = false);
+
             AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
             AddStep("change text", () => textBox.Text = "3");
             AddAssert("slider not moved", () => slider.Current.Value, () => Is.Zero);
@@ -63,6 +65,8 @@ namespace osu.Game.Tests.Visual.UserInterface
         [Test]
         public void TestInstantaneousMode()
         {
+            AddStep("set instantaneous to true", () => sliderWithTextBoxInput.Instantaneous = true);
+
             AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
             AddStep("change text", () => textBox.Text = "3");
             AddAssert("slider moved", () => slider.Current.Value, () => Is.EqualTo(3));
diff --git a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
index 38de0ea94a..fc0e4d2083 100644
--- a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
+++ b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
@@ -17,12 +17,42 @@ namespace osu.Game.Graphics.UserInterfaceV2
     public partial class SliderWithTextBoxInput<T> : CompositeDrawable, IHasCurrentValue<T>
         where T : struct, IEquatable<T>, IComparable<T>, IConvertible
     {
+        /// <summary>
+        /// A custom step value for each key press which actuates a change on this control.
+        /// </summary>
+        public float KeyboardStep
+        {
+            get => slider.KeyboardStep;
+            set => slider.KeyboardStep = value;
+        }
+
+        public Bindable<T> Current
+        {
+            get => slider.Current;
+            set => slider.Current = value;
+        }
+
+        private bool instantaneous;
+
+        /// <summary>
+        /// Whether changes to the slider should instantaneously transfer to the text box (and vice versa).
+        /// If <see langword="false"/>, the transfer will happen on text box commit (explicit, or implicit via focus loss), or on slider drag end.
+        /// </summary>
+        public bool Instantaneous
+        {
+            get => instantaneous;
+            set
+            {
+                instantaneous = value;
+                slider.TransferValueOnCommit = !instantaneous;
+            }
+        }
+
         private readonly SettingsSlider<T> slider;
+        private readonly LabelledTextBox textBox;
 
         public SliderWithTextBoxInput(LocalisableString labelText)
         {
-            LabelledTextBox textBox;
-
             RelativeSizeAxes = Axes.X;
             AutoSizeAxes = Axes.Y;
 
@@ -49,55 +79,65 @@ namespace osu.Game.Graphics.UserInterfaceV2
                 },
             };
 
-            textBox.OnCommit += (t, isNew) =>
-            {
-                try
-                {
-                    switch (slider.Current)
-                    {
-                        case Bindable<int> bindableInt:
-                            bindableInt.Value = int.Parse(t.Text);
-                            break;
+            textBox.OnCommit += textCommitted;
+            textBox.Current.BindValueChanged(textChanged);
 
-                        case Bindable<double> bindableDouble:
-                            bindableDouble.Value = double.Parse(t.Text);
-                            break;
-
-                        default:
-                            slider.Current.Parse(t.Text);
-                            break;
-                    }
-                }
-                catch
-                {
-                    // TriggerChange below will restore the previous text value on failure.
-                }
-
-                // This is run regardless of parsing success as the parsed number may not actually trigger a change
-                // due to bindable clamping. Even in such a case we want to update the textbox to a sane visual state.
-                Current.TriggerChange();
-            };
-
-            Current.BindValueChanged(_ =>
-            {
-                decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo);
-                textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}");
-            }, true);
+            Current.BindValueChanged(updateTextBoxFromSlider, true);
         }
 
-        /// <summary>
-        /// A custom step value for each key press which actuates a change on this control.
-        /// </summary>
-        public float KeyboardStep
+        private bool updatingFromTextBox;
+
+        private void textChanged(ValueChangedEvent<string> change)
         {
-            get => slider.KeyboardStep;
-            set => slider.KeyboardStep = value;
+            if (!instantaneous) return;
+
+            tryUpdateSliderFromTextBox();
         }
 
-        public Bindable<T> Current
+        private void textCommitted(TextBox t, bool isNew)
         {
-            get => slider.Current;
-            set => slider.Current = value;
+            tryUpdateSliderFromTextBox();
+
+            // If the attempted update above failed, restore text box to match the slider.
+            Current.TriggerChange();
+        }
+
+        private void tryUpdateSliderFromTextBox()
+        {
+            updatingFromTextBox = true;
+
+            try
+            {
+                switch (slider.Current)
+                {
+                    case Bindable<int> bindableInt:
+                        bindableInt.Value = int.Parse(textBox.Current.Value);
+                        break;
+
+                    case Bindable<double> bindableDouble:
+                        bindableDouble.Value = double.Parse(textBox.Current.Value);
+                        break;
+
+                    default:
+                        slider.Current.Parse(textBox.Current.Value);
+                        break;
+                }
+            }
+            catch
+            {
+                // ignore parsing failures.
+                // sane state will eventually be restored by a commit (either explicit, or implicit via focus loss).
+            }
+
+            updatingFromTextBox = false;
+        }
+
+        private void updateTextBoxFromSlider(ValueChangedEvent<T> _)
+        {
+            if (updatingFromTextBox) return;
+
+            decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo);
+            textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}");
         }
     }
 }

From 049215f2dc1cbdd2ae17c10d28a0430b0dd28ee9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Tue, 1 Aug 2023 00:54:40 +0200
Subject: [PATCH 11/61] Add test coverage for invalid input scenarios

---
 .../TestSceneSliderWithTextBoxInput.cs        | 40 +++++++++++++++++++
 1 file changed, 40 insertions(+)

diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs
index 797afccc77..d23fcebae3 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs
@@ -60,6 +60,26 @@ namespace osu.Game.Tests.Visual.UserInterface
             AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left));
             AddAssert("textbox changed", () => textBox.Current.Value, () => Is.EqualTo("-5"));
             AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+            AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
+            AddStep("set text to invalid", () => textBox.Text = "garbage");
+            AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+            AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+            AddStep("commit text", () => InputManager.Key(Key.Enter));
+            AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5"));
+            AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+            AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+            AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
+            AddStep("set text to invalid", () => textBox.Text = "garbage");
+            AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+            AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+            AddStep("lose focus", () => InputManager.ChangeFocus(null));
+            AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5"));
+            AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+            AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
         }
 
         [Test]
@@ -85,6 +105,26 @@ namespace osu.Game.Tests.Visual.UserInterface
             AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left));
             AddAssert("textbox not changed", () => textBox.Current.Value, () => Is.EqualTo("-5"));
             AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+            AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
+            AddStep("set text to invalid", () => textBox.Text = "garbage");
+            AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+            AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+            AddStep("commit text", () => InputManager.Key(Key.Enter));
+            AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5"));
+            AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+            AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+            AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
+            AddStep("set text to invalid", () => textBox.Text = "garbage");
+            AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+            AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+            AddStep("lose focus", () => InputManager.ChangeFocus(null));
+            AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5"));
+            AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+            AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
         }
     }
 }

From 426bc52fd544a7512f27284a28ef6cc50225d286 Mon Sep 17 00:00:00 2001
From: QuantumSno <ben.czaja@protonmail.com>
Date: Tue, 1 Aug 2023 14:27:37 -0400
Subject: [PATCH 12/61] Updated schema_version number and added braces around
 some migration cases to help with re-using variable names

---
 osu.Game/Database/RealmAccess.cs | 15 ++++++++++++++-
 1 file changed, 14 insertions(+), 1 deletion(-)

diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index f32b161bb6..c949ccbe43 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -82,8 +82,9 @@ namespace osu.Game.Database
         /// 30   2023-06-16    Run migration of old lazer scores again. This time with more correct rounding considerations.
         /// 31   2023-06-26    Add Version and LegacyTotalScore to ScoreInfo, set Version to 30000002 and copy TotalScore into LegacyTotalScore for legacy scores.
         /// 32   2023-07-09    Populate legacy scores with the ScoreV2 mod (and restore TotalScore to the legacy total for such scores) using replay files.
+        /// 32   2023-08-01    Added a new keybind that toggles the in-game leaderboard on and off
         /// </summary>
-        private const int schema_version = 32;
+        private const int schema_version = 33;
 
         /// <summary>
         /// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
@@ -771,6 +772,7 @@ namespace osu.Game.Database
                     break;
 
                 case 8:
+                {
                     // Ctrl -/+ now adjusts UI scale so let's clear any bindings which overlap these combinations.
                     // New defaults will be populated by the key store afterwards.
                     var keyBindings = migration.NewRealm.All<RealmKeyBinding>();
@@ -784,6 +786,7 @@ namespace osu.Game.Database
                         migration.NewRealm.Remove(decreaseSpeedBinding);
 
                     break;
+                }
 
                 case 9:
                     // Pretty pointless to do this as beatmaps aren't really loaded via realm yet, but oh well.
@@ -838,6 +841,7 @@ namespace osu.Game.Database
                     break;
 
                 case 11:
+                {
                     string keyBindingClassName = getMappedOrOriginalName(typeof(RealmKeyBinding));
 
                     if (!migration.OldRealm.Schema.TryFindObjectSchema(keyBindingClassName, out _))
@@ -864,6 +868,7 @@ namespace osu.Game.Database
                     }
 
                     break;
+                }
 
                 case 14:
                     foreach (var beatmap in migration.NewRealm.All<BeatmapInfo>())
@@ -1012,6 +1017,14 @@ namespace osu.Game.Database
 
                     break;
                 }
+
+                case 33:
+                {
+                    var oldKeyBinds = migration.NewRealm.All<RealmKeyBinding>();
+                    var newKeyBinds = migration.NewRealm.All<RealmKeyBinding>();
+
+                    break;
+                }
             }
 
             Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms");

From 5443f89dca0823fac233d54d1dd850b793b4145c Mon Sep 17 00:00:00 2001
From: QuantumSno <ben.czaja@protonmail.com>
Date: Tue, 1 Aug 2023 16:58:52 -0400
Subject: [PATCH 13/61] Assuming its still set to tab, removes binding for
 toggle chat so itll be set to the new default

---
 osu.Game/Database/RealmAccess.cs | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index c949ccbe43..d751cce834 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -1020,8 +1020,14 @@ namespace osu.Game.Database
 
                 case 33:
                 {
-                    var oldKeyBinds = migration.NewRealm.All<RealmKeyBinding>();
-                    var newKeyBinds = migration.NewRealm.All<RealmKeyBinding>();
+                    // Get all current keybinds, and find the toggle chat bind
+                    // (by default it used to overlap the new toggle leaderboard)
+                    var newKeyBindings = migration.NewRealm.All<RealmKeyBinding>().ToList();
+                    var toggleChatBind = newKeyBindings.FirstOrDefault(bind => bind.ActionInt == (int)GlobalAction.ToggleChatFocus);
+
+                    // If we have a bind for it, and that bind is tab, remove it
+                    if (toggleChatBind != default && toggleChatBind.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Tab }))
+                        migration.NewRealm.Remove(toggleChatBind);
 
                     break;
                 }

From 87fee001c786b29db34063ef3350e9a9f024d3ab Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 2 Aug 2023 18:26:36 +0900
Subject: [PATCH 14/61] Fix multiplayer spectator potentially taking too long
 to start

When watching from the middle of gameplay, due to a series of failures,
`SpectatorClock` would not get seeked to the current time, causing all
clients to look like they were out of sync.

This is a hotfix for the issue. A better fix will require framework
changes or considerable restructuring.

I'd recommend testing this works in practice and agreeing that while it
is a hack, it's likely not going to cause issues and is something we
want to see fixed sooner rather than later.
---
 .../Multiplayer/Spectate/SpectatorPlayerClock.cs           | 2 ++
 osu.Game/Screens/Play/GameplayClockContainer.cs            | 7 +++++++
 2 files changed, 9 insertions(+)

diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs
index 45615d4e19..2ce78818a0 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs
@@ -2,6 +2,7 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System;
+using osu.Framework.Logging;
 using osu.Framework.Timing;
 using osu.Game.Screens.Play;
 
@@ -59,6 +60,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
 
         public bool Seek(double position)
         {
+            Logger.Log($"{nameof(SpectatorPlayerClock)} seeked to {position}");
             CurrentTime = position;
             return true;
         }
diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs
index 22e6884526..226108209a 100644
--- a/osu.Game/Screens/Play/GameplayClockContainer.cs
+++ b/osu.Game/Screens/Play/GameplayClockContainer.cs
@@ -160,6 +160,13 @@ namespace osu.Game.Screens.Play
 
             Seek(StartTime);
 
+            // This is a workaround for the fact that DecoupleableInterpolatingFramedClock doesn't seek the source
+            // if the source is not IsRunning. (see https://github.com/ppy/osu-framework/blob/2102638056dfcf85d21b4d85266d53b5dd018767/osu.Framework/Timing/DecoupleableInterpolatingFramedClock.cs#L209-L210)
+            //
+            // This breaks in multiplayer spectator.
+            // I hope to remove this once we knock some sense into clocks in general.
+            (SourceClock as IAdjustableClock)?.Seek(StartTime);
+
             if (!wasPaused || startClock)
                 Start();
         }

From 71c42167e7e8fce7e4671f67e672786e5584fe21 Mon Sep 17 00:00:00 2001
From: QuantumSno <ben.czaja@protonmail.com>
Date: Thu, 10 Aug 2023 10:17:12 -0400
Subject: [PATCH 15/61] Updated wording on realm comments

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

diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index d751cce834..6eb1e3bf9b 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -1021,11 +1021,11 @@ namespace osu.Game.Database
                 case 33:
                 {
                     // Get all current keybinds, and find the toggle chat bind
-                    // (by default it used to overlap the new toggle leaderboard)
+                    // (by default it would to overlap the new toggle leaderboard)
                     var newKeyBindings = migration.NewRealm.All<RealmKeyBinding>().ToList();
                     var toggleChatBind = newKeyBindings.FirstOrDefault(bind => bind.ActionInt == (int)GlobalAction.ToggleChatFocus);
 
-                    // If we have a bind for it, and that bind is tab, remove it
+                    // If we have a bind for it, and that bind is still tab, remove it
                     if (toggleChatBind != default && toggleChatBind.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Tab }))
                         migration.NewRealm.Remove(toggleChatBind);
 

From 5d6ebb4df9f1fffd6bdadfe454dff9d1249dd589 Mon Sep 17 00:00:00 2001
From: QuantumSno <ben.czaja@protonmail.com>
Date: Thu, 10 Aug 2023 10:18:15 -0400
Subject: [PATCH 16/61] Removed comments organizing GlobalActionContainer.cs,
 since they were incorrectly grouping actions

---
 osu.Game/Input/Bindings/GlobalActionContainer.cs | 5 -----
 1 file changed, 5 deletions(-)

diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs
index 79f098f90e..1090eeb462 100644
--- a/osu.Game/Input/Bindings/GlobalActionContainer.cs
+++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs
@@ -205,7 +205,6 @@ namespace osu.Game.Input.Bindings
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleMute))]
         ToggleMute,
 
-        // In-Game Keybindings
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.SkipCutscene))]
         SkipCutscene,
 
@@ -233,7 +232,6 @@ namespace osu.Game.Input.Bindings
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.QuickExit))]
         QuickExit,
 
-        // Game-wide beatmap music controller keybindings
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.MusicNext))]
         MusicNext,
 
@@ -261,7 +259,6 @@ namespace osu.Game.Input.Bindings
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.PauseGameplay))]
         PauseGameplay,
 
-        // Editor
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSetupMode))]
         EditorSetupMode,
 
@@ -286,7 +283,6 @@ namespace osu.Game.Input.Bindings
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleInGameInterface))]
         ToggleInGameInterface,
 
-        // Song select keybindings
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleModSelection))]
         ToggleModSelection,
 
@@ -380,7 +376,6 @@ namespace osu.Game.Input.Bindings
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleReplaySettings))]
         ToggleReplaySettings,
 
-        // Editor (cont)
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleInGameLeaderboard))]
         ToggleInGameLeaderboard,
     }

From 2b738edb93dbc34d1f850db710221656871c3e40 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Thu, 10 Aug 2023 22:55:02 +0200
Subject: [PATCH 17/61] Reword / fix comments

---
 osu.Game/Database/RealmAccess.cs | 7 +++----
 1 file changed, 3 insertions(+), 4 deletions(-)

diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index 6eb1e3bf9b..90e39da201 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -82,7 +82,7 @@ namespace osu.Game.Database
         /// 30   2023-06-16    Run migration of old lazer scores again. This time with more correct rounding considerations.
         /// 31   2023-06-26    Add Version and LegacyTotalScore to ScoreInfo, set Version to 30000002 and copy TotalScore into LegacyTotalScore for legacy scores.
         /// 32   2023-07-09    Populate legacy scores with the ScoreV2 mod (and restore TotalScore to the legacy total for such scores) using replay files.
-        /// 32   2023-08-01    Added a new keybind that toggles the in-game leaderboard on and off
+        /// 33   2023-08-01    Reset default chat toggle keybind to avoid conflict with leaderboard toggle keybind.
         /// </summary>
         private const int schema_version = 33;
 
@@ -1020,12 +1020,11 @@ namespace osu.Game.Database
 
                 case 33:
                 {
-                    // Get all current keybinds, and find the toggle chat bind
-                    // (by default it would to overlap the new toggle leaderboard)
+                    // Clear default bindings for the chat focus toggle,
+                    // as they would conflict with the newly-added leaderboard toggle.
                     var newKeyBindings = migration.NewRealm.All<RealmKeyBinding>().ToList();
                     var toggleChatBind = newKeyBindings.FirstOrDefault(bind => bind.ActionInt == (int)GlobalAction.ToggleChatFocus);
 
-                    // If we have a bind for it, and that bind is still tab, remove it
                     if (toggleChatBind != default && toggleChatBind.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Tab }))
                         migration.NewRealm.Remove(toggleChatBind);
 

From 562af8b46d1cbe3bf028e70cd044b3f93e71aaa4 Mon Sep 17 00:00:00 2001
From: QuantumSno <ben.czaja@protonmail.com>
Date: Sat, 12 Aug 2023 00:18:10 -0400
Subject: [PATCH 18/61] Updated test TestSceneGameplayChatDisplay.cs. Now uses
 new default bind for toggle chat (enter)

---
 .../Multiplayer/TestSceneGameplayChatDisplay.cs    | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs
index 979cb4424e..99b898efda 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs
@@ -84,12 +84,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
         }
 
         [Test]
-        public void TestFocusOnTabKeyWhenExpanded()
+        public void TestFocusOnEnterKeyWhenExpanded()
         {
             setLocalUserPlaying(true);
 
             assertChatFocused(false);
-            AddStep("press tab", () => InputManager.Key(Key.Tab));
+            AddStep("press enter", () => InputManager.Key(Key.Enter));
             assertChatFocused(true);
         }
 
@@ -99,19 +99,19 @@ namespace osu.Game.Tests.Visual.Multiplayer
             setLocalUserPlaying(true);
 
             assertChatFocused(false);
-            AddStep("press tab", () => InputManager.Key(Key.Tab));
+            AddStep("press enter", () => InputManager.Key(Key.Enter));
             assertChatFocused(true);
             AddStep("press escape", () => InputManager.Key(Key.Escape));
             assertChatFocused(false);
         }
 
         [Test]
-        public void TestFocusOnTabKeyWhenNotExpanded()
+        public void TestFocusOnEnterKeyWhenNotExpanded()
         {
             AddStep("set not expanded", () => chatDisplay.Expanded.Value = false);
             AddUntilStep("is not visible", () => !chatDisplay.IsPresent);
 
-            AddStep("press tab", () => InputManager.Key(Key.Tab));
+            AddStep("press enter", () => InputManager.Key(Key.Enter));
             assertChatFocused(true);
             AddUntilStep("is visible", () => chatDisplay.IsPresent);
 
@@ -126,11 +126,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
             AddStep("set not expanded", () => chatDisplay.Expanded.Value = false);
             AddUntilStep("is not visible", () => !chatDisplay.IsPresent);
 
-            AddStep("press tab", () => InputManager.Key(Key.Tab));
+            AddStep("press enter", () => InputManager.Key(Key.Enter));
             assertChatFocused(true);
             AddUntilStep("is visible", () => chatDisplay.IsPresent);
 
-            AddStep("press tab", () => InputManager.Key(Key.Tab));
+            AddStep("press enter", () => InputManager.Key(Key.Enter));
             assertChatFocused(false);
             AddUntilStep("is not visible", () => !chatDisplay.IsPresent);
         }

From 4152b4e5238e23ac50cee0c2d480b120fb224bc6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sat, 12 Aug 2023 22:46:18 +0200
Subject: [PATCH 19/61] Remove duplicated test case

---
 .../Multiplayer/TestSceneGameplayChatDisplay.cs   | 15 ---------------
 1 file changed, 15 deletions(-)

diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs
index 99b898efda..d1a914300f 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs
@@ -120,21 +120,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
             AddUntilStep("is not visible", () => !chatDisplay.IsPresent);
         }
 
-        [Test]
-        public void TestFocusToggleViaAction()
-        {
-            AddStep("set not expanded", () => chatDisplay.Expanded.Value = false);
-            AddUntilStep("is not visible", () => !chatDisplay.IsPresent);
-
-            AddStep("press enter", () => InputManager.Key(Key.Enter));
-            assertChatFocused(true);
-            AddUntilStep("is visible", () => chatDisplay.IsPresent);
-
-            AddStep("press enter", () => InputManager.Key(Key.Enter));
-            assertChatFocused(false);
-            AddUntilStep("is not visible", () => !chatDisplay.IsPresent);
-        }
-
         private void assertChatFocused(bool isFocused) =>
             AddAssert($"chat {(isFocused ? "focused" : "not focused")}", () => textBox.HasFocus == isFocused);
 

From 71a96e8be2e19397e7ab6b14e39a272517c9927e Mon Sep 17 00:00:00 2001
From: OliBomby <olivierschipper@gmail.com>
Date: Sun, 13 Aug 2023 21:47:13 +0200
Subject: [PATCH 20/61] Remove SV from DrumRoll

---
 .../Beatmaps/TaikoBeatmapConverter.cs         |  1 -
 osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs   | 19 +++----------------
 2 files changed, 3 insertions(+), 17 deletions(-)

diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs
index dddd1e3c5a..5f3d0f898e 100644
--- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs
+++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs
@@ -139,7 +139,6 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps
                             StartTime = obj.StartTime,
                             Samples = obj.Samples,
                             Duration = taikoDuration,
-                            SliderVelocity = obj is IHasSliderVelocity velocityData ? velocityData.SliderVelocity : 1
                         };
                     }
 
diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs
index 083b8cc547..5f47d486e6 100644
--- a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs
@@ -3,7 +3,6 @@
 
 using osu.Game.Rulesets.Objects.Types;
 using System.Threading;
-using osu.Framework.Bindables;
 using osu.Game.Beatmaps;
 using osu.Game.Beatmaps.ControlPoints;
 using osu.Game.Beatmaps.Formats;
@@ -14,7 +13,7 @@ using osuTK;
 
 namespace osu.Game.Rulesets.Taiko.Objects
 {
-    public class DrumRoll : TaikoStrongableHitObject, IHasPath, IHasSliderVelocity
+    public class DrumRoll : TaikoStrongableHitObject, IHasPath
     {
         /// <summary>
         /// Drum roll distance that results in a duration of 1 speed-adjusted beat length.
@@ -34,19 +33,6 @@ namespace osu.Game.Rulesets.Taiko.Objects
         /// </summary>
         public double Velocity { get; private set; }
 
-        public BindableNumber<double> SliderVelocityBindable { get; } = new BindableDouble(1)
-        {
-            Precision = 0.01,
-            MinValue = 0.1,
-            MaxValue = 10
-        };
-
-        public double SliderVelocity
-        {
-            get => SliderVelocityBindable.Value;
-            set => SliderVelocityBindable.Value = value;
-        }
-
         /// <summary>
         /// Numer of ticks per beat length.
         /// </summary>
@@ -63,8 +49,9 @@ namespace osu.Game.Rulesets.Taiko.Objects
             base.ApplyDefaultsToSelf(controlPointInfo, difficulty);
 
             TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime);
+            EffectControlPoint effectPoint = controlPointInfo.EffectPointAt(StartTime);
 
-            double scoringDistance = base_distance * difficulty.SliderMultiplier * SliderVelocity;
+            double scoringDistance = base_distance * difficulty.SliderMultiplier * effectPoint.ScrollSpeed;
             Velocity = scoringDistance / timingPoint.BeatLength;
 
             TickRate = difficulty.SliderTickRate == 3 ? 3 : 4;

From e34a9a00014d63249582320f5b08e9cce6ec29cc Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Tue, 15 Aug 2023 19:30:37 +0900
Subject: [PATCH 21/61] Disable hold end conversion for mania HoldOff mod

---
 osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs | 13 -------------
 1 file changed, 13 deletions(-)

diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs
index 2b0098744f..a94fdeda6b 100644
--- a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs
+++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs
@@ -46,19 +46,6 @@ namespace osu.Game.Rulesets.Mania.Mods
                     StartTime = h.StartTime,
                     Samples = h.GetNodeSamples(0)
                 });
-
-                // Don't add an end note if the duration is shorter than the threshold
-                double noteValue = GetNoteDurationInBeatLength(h, maniaBeatmap); // 1/1, 1/2, 1/4, etc.
-
-                if (noteValue >= END_NOTE_ALLOW_THRESHOLD)
-                {
-                    newObjects.Add(new Note
-                    {
-                        Column = h.Column,
-                        StartTime = h.EndTime,
-                        Samples = h.GetNodeSamples((h.NodeSamples?.Count - 1) ?? 1)
-                    });
-                }
             }
 
             maniaBeatmap.HitObjects = maniaBeatmap.HitObjects.OfType<Note>().Concat(newObjects).OrderBy(h => h.StartTime).ToList();

From 5678db4e707bf37736bb9b2ea8ad29538f886c4b Mon Sep 17 00:00:00 2001
From: Dan Balasescu <smoogipoo@smgi.me>
Date: Tue, 15 Aug 2023 19:42:32 +0900
Subject: [PATCH 22/61] Fix test + cleanup

---
 .../Mods/TestSceneManiaModHoldOff.cs          | 37 +------------------
 .../Mods/ManiaModHoldOff.cs                   |  8 ----
 2 files changed, 2 insertions(+), 43 deletions(-)

diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs
index 3011a93755..f5117b61af 100644
--- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs
@@ -6,7 +6,6 @@ using NUnit.Framework;
 using osu.Game.Beatmaps;
 using osu.Game.Rulesets.Mania.Mods;
 using osu.Game.Tests.Visual;
-using System.Collections.Generic;
 using osu.Game.Rulesets.Mania.Objects;
 using osu.Game.Beatmaps.ControlPoints;
 using osu.Game.Rulesets.Mania.Beatmaps;
@@ -24,21 +23,6 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
             Assert.False(testBeatmap.HitObjects.OfType<HoldNote>().Any());
         }
 
-        [Test]
-        public void TestCorrectNoteValues()
-        {
-            var testBeatmap = createRawBeatmap();
-            var noteValues = new List<double>(testBeatmap.HitObjects.OfType<HoldNote>().Count());
-
-            foreach (HoldNote h in testBeatmap.HitObjects.OfType<HoldNote>())
-            {
-                noteValues.Add(ManiaModHoldOff.GetNoteDurationInBeatLength(h, testBeatmap));
-            }
-
-            noteValues.Sort();
-            Assert.AreEqual(noteValues, new List<double> { 0.125, 0.250, 0.500, 1.000, 2.000 });
-        }
-
         [Test]
         public void TestCorrectObjectCount()
         {
@@ -47,25 +31,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
             var rawBeatmap = createRawBeatmap();
             var testBeatmap = createModdedBeatmap();
 
-            // Calculate expected number of objects
-            int expectedObjectCount = 0;
-
-            foreach (ManiaHitObject h in rawBeatmap.HitObjects)
-            {
-                // Both notes and hold notes account for at least one object
-                expectedObjectCount++;
-
-                if (h.GetType() == typeof(HoldNote))
-                {
-                    double noteValue = ManiaModHoldOff.GetNoteDurationInBeatLength((HoldNote)h, rawBeatmap);
-
-                    if (noteValue >= ManiaModHoldOff.END_NOTE_ALLOW_THRESHOLD)
-                    {
-                        // Should generate an end note if it's longer than the minimum note value
-                        expectedObjectCount++;
-                    }
-                }
-            }
+            // Both notes and hold notes account for at least one object
+            int expectedObjectCount = rawBeatmap.HitObjects.Count;
 
             Assert.That(testBeatmap.HitObjects.Count == expectedObjectCount);
         }
diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs
index a94fdeda6b..4e6cc4f1d6 100644
--- a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs
+++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs
@@ -29,8 +29,6 @@ namespace osu.Game.Rulesets.Mania.Mods
 
         public override Type[] IncompatibleMods => new[] { typeof(ManiaModInvert) };
 
-        public const double END_NOTE_ALLOW_THRESHOLD = 0.5;
-
         public void ApplyToBeatmap(IBeatmap beatmap)
         {
             var maniaBeatmap = (ManiaBeatmap)beatmap;
@@ -50,11 +48,5 @@ namespace osu.Game.Rulesets.Mania.Mods
 
             maniaBeatmap.HitObjects = maniaBeatmap.HitObjects.OfType<Note>().Concat(newObjects).OrderBy(h => h.StartTime).ToList();
         }
-
-        public static double GetNoteDurationInBeatLength(HoldNote holdNote, ManiaBeatmap beatmap)
-        {
-            double beatLength = beatmap.ControlPointInfo.TimingPointAt(holdNote.StartTime).BeatLength;
-            return holdNote.Duration / beatLength;
-        }
     }
 }

From 6001f7e5c783b67a9236f81aeda28214ad2a0832 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 16 Aug 2023 13:52:23 +0900
Subject: [PATCH 23/61] Rename "dummy" local user to something more descriptive

This created weird cases in logs which are very hard to understand. The
one which really got me was this:

```
[runtime] 2023-08-13 07:48:27 [verbose]: Invalidating working beatmap cache for unknown artist - unknown title (Dummy)
```

Which looks like a dummy working beatmap was invalidated, but it turns
out that's just the local user which was populated when creating a new
local beatmap.
---
 osu.Game/Online/API/DummyAPIAccess.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs
index 01169828b0..2764247f5c 100644
--- a/osu.Game/Online/API/DummyAPIAccess.cs
+++ b/osu.Game/Online/API/DummyAPIAccess.cs
@@ -20,7 +20,7 @@ namespace osu.Game.Online.API
 
         public Bindable<APIUser> LocalUser { get; } = new Bindable<APIUser>(new APIUser
         {
-            Username = @"Dummy",
+            Username = @"Local user",
             Id = DUMMY_USER_ID,
         });
 

From 502844a8583e6ee8537f0479d71f2e6a8ab62178 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 16 Aug 2023 14:23:01 +0900
Subject: [PATCH 24/61] Add ability to construct `RealmLive` from `ID`

---
 osu.Game/Database/RealmLive.cs | 20 +++++++++++++++++++-
 1 file changed, 19 insertions(+), 1 deletion(-)

diff --git a/osu.Game/Database/RealmLive.cs b/osu.Game/Database/RealmLive.cs
index 509fabec59..1134636756 100644
--- a/osu.Game/Database/RealmLive.cs
+++ b/osu.Game/Database/RealmLive.cs
@@ -30,7 +30,7 @@ namespace osu.Game.Database
         /// <summary>
         /// Construct a new instance of live realm data.
         /// </summary>
-        /// <param name="data">The realm data.</param>
+        /// <param name="data">The realm data. Must be managed (see <see cref="IRealmObject.IsManaged"/>).</param>
         /// <param name="realm">The realm factory the data was sourced from. May be null for an unmanaged object.</param>
         public RealmLive(T data, RealmAccess realm)
             : base(data.ID)
@@ -41,6 +41,24 @@ namespace osu.Game.Database
             dataIsFromUpdateThread = ThreadSafety.IsUpdateThread;
         }
 
+        /// <summary>
+        /// Construct a new instance of live realm data from an ID.
+        /// </summary>
+        /// <param name="id">The ID of an already-persisting realm instance.</param>
+        /// <param name="realm">The realm factory the data was sourced from. May be null for an unmanaged object.</param>
+        public RealmLive(Guid id, RealmAccess realm)
+            : base(id)
+        {
+            data = retrieveFromID(realm.Realm);
+
+            if (data.IsNull())
+                throw new ArgumentException("Realm instance for provided ID could not be found.", nameof(id));
+
+            this.realm = realm;
+
+            dataIsFromUpdateThread = ThreadSafety.IsUpdateThread;
+        }
+
         /// <summary>
         /// Perform a read operation on this live object.
         /// </summary>

From 5bd7370439c1e79a739be0d46dce060f57444ffe Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 16 Aug 2023 14:23:17 +0900
Subject: [PATCH 25/61] Add log output when editor is creating a fresh beatmap

---
 osu.Game/Screens/Edit/Editor.cs | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs
index 35d8bb4ab7..1cdca5754d 100644
--- a/osu.Game/Screens/Edit/Editor.cs
+++ b/osu.Game/Screens/Edit/Editor.cs
@@ -199,6 +199,8 @@ namespace osu.Game.Screens.Edit
 
             if (loadableBeatmap is DummyWorkingBeatmap)
             {
+                Logger.Log("Editor was loaded without a valid beatmap; creating a new beatmap.");
+
                 isNewBeatmap = true;
 
                 loadableBeatmap = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value);

From 531794b26b6af72f3f25d609694e135fc960cf64 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 16 Aug 2023 14:23:30 +0900
Subject: [PATCH 26/61] Fix `ModelManager` not correctly re-retrieving realm
 objects before performing operations

Falls into the age-old trap of attempting to retrieve an item from realm
without first ensuring that realm is in an up-to-date state.

Consider this scenario:

- Editor is entered from main menu, causing it to create a new beatmap
  from its async `load()` method.
- Editor opens correctly, then main thread performs a file operations on
  the same beatmap.
- Main thread is potentially not refreshed yet, and will result in `null`
  instance when performing the re-fetch in `performFileOperation`.

I've fixed this by using the safe implementation inside `RealmLive<T>`.
Feels like we want this is one place which is always used as the correct
method.

On a quick search, there are 10-20 other usages of `Realm.Find<T>` which
could also have similar issues, but it'll be a bit of a pain to go
through and fix each of these. In 99.9% of cases, the accesses are on
instances which couldn't have just been created (or the usage of
recently-imported/created is blocked by realm subscription flows, ie.
baetmap import) so I'm not touching them for now.

Something to keep in mind when working with realm going forward though.
---
 osu.Game/Database/ModelManager.cs | 11 ++++-------
 1 file changed, 4 insertions(+), 7 deletions(-)

diff --git a/osu.Game/Database/ModelManager.cs b/osu.Game/Database/ModelManager.cs
index 47feb8a8f9..6b538e62ea 100644
--- a/osu.Game/Database/ModelManager.cs
+++ b/osu.Game/Database/ModelManager.cs
@@ -48,16 +48,13 @@ namespace osu.Game.Database
             // This method should be removed as soon as all the surrounding pieces support non-detached operations.
             if (!item.IsManaged)
             {
-                // Importantly, begin the realm write *before* re-fetching, else the update realm may not be in a consistent state
-                // (ie. if an async import finished very recently).
-                Realm.Realm.Write(realm =>
+                // We use RealmLive here as it handled re-retrieval and refreshing of realm if required.
+                new RealmLive<TModel>(item.ID, Realm).PerformWrite(i =>
                 {
-                    var managed = realm.Find<TModel>(item.ID);
-                    Debug.Assert(managed != null);
-                    operation(managed);
+                    operation(i);
 
                     item.Files.Clear();
-                    item.Files.AddRange(managed.Files.Detach());
+                    item.Files.AddRange(i.Files.Detach());
                 });
             }
             else

From a8824c8c8ad4b6d8184f68a34ebc2672fb299716 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 16 Aug 2023 14:30:10 +0900
Subject: [PATCH 27/61] Remove flaky test documentation for fixed test

---
 .../Editing/TestSceneEditorBeatmapCreation.cs | 19 -------------------
 1 file changed, 19 deletions(-)

diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
index 37cb43a43a..920e560018 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
@@ -92,25 +92,6 @@ namespace osu.Game.Tests.Visual.Editing
         }
 
         [Test]
-        [FlakyTest]
-        /*
-         * Fail rate around 1.2%.
-         *
-         * Failing with realm refetch occasionally being null.
-         * My only guess is that the WorkingBeatmap at SetupScreen is dummy instead of the true one.
-         * If it's something else, we have larger issues with realm, but I don't think that's the case.
-         *
-         * at osu.Framework.Logging.ThrowingTraceListener.Fail(String message1, String message2)
-         * at System.Diagnostics.TraceInternal.Fail(String message, String detailMessage)
-         * at System.Diagnostics.TraceInternal.TraceProvider.Fail(String message, String detailMessage)
-         * at System.Diagnostics.Debug.Fail(String message, String detailMessage)
-         * at osu.Game.Database.ModelManager`1.<>c__DisplayClass8_0.<performFileOperation>b__0(Realm realm) ModelManager.cs:line 50
-         * at osu.Game.Database.RealmExtensions.Write(Realm realm, Action`1 function) RealmExtensions.cs:line 14
-         * at osu.Game.Database.ModelManager`1.performFileOperation(TModel item, Action`1 operation) ModelManager.cs:line 47
-         * at osu.Game.Database.ModelManager`1.AddFile(TModel item, Stream contents, String filename) ModelManager.cs:line 37
-         * at osu.Game.Screens.Edit.Setup.ResourcesSection.ChangeAudioTrack(FileInfo source) ResourcesSection.cs:line 115
-         * at osu.Game.Tests.Visual.Editing.TestSceneEditorBeatmapCreation.<TestAddAudioTrack>b__11_0() TestSceneEditorBeatmapCreation.cs:line 101
-         */
         public void TestAddAudioTrack()
         {
             AddAssert("track is virtual", () => Beatmap.Value.Track is TrackVirtual);

From 6c4c76350fbecd1032709e2724b9abd9ee979e7f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Wed, 16 Aug 2023 07:36:56 +0200
Subject: [PATCH 28/61] Remove unused using directive

---
 osu.Game/Database/ModelManager.cs | 1 -
 1 file changed, 1 deletion(-)

diff --git a/osu.Game/Database/ModelManager.cs b/osu.Game/Database/ModelManager.cs
index 6b538e62ea..56aa0843a0 100644
--- a/osu.Game/Database/ModelManager.cs
+++ b/osu.Game/Database/ModelManager.cs
@@ -3,7 +3,6 @@
 
 using System;
 using System.Collections.Generic;
-using System.Diagnostics;
 using System.IO;
 using System.Linq;
 using osu.Framework.Platform;

From 88295a49aa4655fcf48c317c1efac5a19dbc7342 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Wed, 16 Aug 2023 07:38:31 +0200
Subject: [PATCH 29/61] Fix invalid reference in xmldoc

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

diff --git a/osu.Game/Database/RealmLive.cs b/osu.Game/Database/RealmLive.cs
index 1134636756..bfb755c42a 100644
--- a/osu.Game/Database/RealmLive.cs
+++ b/osu.Game/Database/RealmLive.cs
@@ -30,7 +30,7 @@ namespace osu.Game.Database
         /// <summary>
         /// Construct a new instance of live realm data.
         /// </summary>
-        /// <param name="data">The realm data. Must be managed (see <see cref="IRealmObject.IsManaged"/>).</param>
+        /// <param name="data">The realm data. Must be managed (see <see cref="IRealmObjectBase.IsManaged"/>).</param>
         /// <param name="realm">The realm factory the data was sourced from. May be null for an unmanaged object.</param>
         public RealmLive(T data, RealmAccess realm)
             : base(data.ID)

From 6e11162ab10734b86b88f13b4967ae0bf68b6abd Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 16 Aug 2023 15:36:31 +0900
Subject: [PATCH 30/61] Add helper method for safer realm `Find<T>`

---
 osu.Game/Database/ModelManager.cs    | 12 +++++---
 osu.Game/Database/RealmExtensions.cs | 25 +++++++++++++++++
 osu.Game/Database/RealmLive.cs       | 41 +++-------------------------
 3 files changed, 37 insertions(+), 41 deletions(-)

diff --git a/osu.Game/Database/ModelManager.cs b/osu.Game/Database/ModelManager.cs
index 56aa0843a0..39dae61d36 100644
--- a/osu.Game/Database/ModelManager.cs
+++ b/osu.Game/Database/ModelManager.cs
@@ -3,6 +3,7 @@
 
 using System;
 using System.Collections.Generic;
+using System.Diagnostics;
 using System.IO;
 using System.Linq;
 using osu.Framework.Platform;
@@ -47,13 +48,16 @@ namespace osu.Game.Database
             // This method should be removed as soon as all the surrounding pieces support non-detached operations.
             if (!item.IsManaged)
             {
-                // We use RealmLive here as it handled re-retrieval and refreshing of realm if required.
-                new RealmLive<TModel>(item.ID, Realm).PerformWrite(i =>
+                // Importantly, begin the realm write *before* re-fetching, else the update realm may not be in a consistent state
+                // (ie. if an async import finished very recently).
+                Realm.Realm.Write(realm =>
                 {
-                    operation(i);
+                    var managed = realm.FindWithRefresh<TModel>(item.ID);
+                    Debug.Assert(managed != null);
+                    operation(managed);
 
                     item.Files.Clear();
-                    item.Files.AddRange(i.Files.Detach());
+                    item.Files.AddRange(managed.Files.Detach());
                 });
             }
             else
diff --git a/osu.Game/Database/RealmExtensions.cs b/osu.Game/Database/RealmExtensions.cs
index 13c4defb83..ee76f1aa79 100644
--- a/osu.Game/Database/RealmExtensions.cs
+++ b/osu.Game/Database/RealmExtensions.cs
@@ -8,6 +8,31 @@ namespace osu.Game.Database
 {
     public static class RealmExtensions
     {
+        /// <summary>
+        /// Performs a <see cref="Realm.Find{T}(System.Nullable{long})"/>.
+        /// If a match was not found, a <see cref="Realm.Refresh"/> is performed before trying a second time.
+        /// This ensures that an instance is found even if the realm requested against was not in a consistent state.
+        /// </summary>
+        /// <param name="realm"></param>
+        /// <param name="id"></param>
+        /// <typeparam name="T"></typeparam>
+        /// <returns></returns>
+        public static T? FindWithRefresh<T>(this Realm realm, Guid id) where T : IRealmObject
+        {
+            var found = realm.Find<T>(id);
+
+            if (found == null)
+            {
+                // It may be that we access this from the update thread before a refresh has taken place.
+                // To ensure that behaviour matches what we'd expect (the object *is* available), force
+                // a refresh to bring in any off-thread changes immediately.
+                realm.Refresh();
+                found = realm.Find<T>(id);
+            }
+
+            return found;
+        }
+
         /// <summary>
         /// Perform a write operation against the provided realm instance.
         /// </summary>
diff --git a/osu.Game/Database/RealmLive.cs b/osu.Game/Database/RealmLive.cs
index bfb755c42a..9e99cba45c 100644
--- a/osu.Game/Database/RealmLive.cs
+++ b/osu.Game/Database/RealmLive.cs
@@ -41,24 +41,6 @@ namespace osu.Game.Database
             dataIsFromUpdateThread = ThreadSafety.IsUpdateThread;
         }
 
-        /// <summary>
-        /// Construct a new instance of live realm data from an ID.
-        /// </summary>
-        /// <param name="id">The ID of an already-persisting realm instance.</param>
-        /// <param name="realm">The realm factory the data was sourced from. May be null for an unmanaged object.</param>
-        public RealmLive(Guid id, RealmAccess realm)
-            : base(id)
-        {
-            data = retrieveFromID(realm.Realm);
-
-            if (data.IsNull())
-                throw new ArgumentException("Realm instance for provided ID could not be found.", nameof(id));
-
-            this.realm = realm;
-
-            dataIsFromUpdateThread = ThreadSafety.IsUpdateThread;
-        }
-
         /// <summary>
         /// Perform a read operation on this live object.
         /// </summary>
@@ -80,7 +62,7 @@ namespace osu.Game.Database
                     return;
                 }
 
-                perform(retrieveFromID(r));
+                perform(r.FindWithRefresh<T>(ID)!);
                 RealmLiveStatistics.USAGE_ASYNC.Value++;
             });
         }
@@ -102,7 +84,7 @@ namespace osu.Game.Database
 
             return realm.Run(r =>
             {
-                var returnData = perform(retrieveFromID(r));
+                var returnData = perform(r.FindWithRefresh<T>(ID)!);
                 RealmLiveStatistics.USAGE_ASYNC.Value++;
 
                 if (returnData is RealmObjectBase realmObject && realmObject.IsManaged)
@@ -159,25 +141,10 @@ namespace osu.Game.Database
             }
 
             dataIsFromUpdateThread = true;
-            data = retrieveFromID(realm.Realm);
+            data = realm.Realm.FindWithRefresh<T>(ID)!;
+
             RealmLiveStatistics.USAGE_UPDATE_REFETCH.Value++;
         }
-
-        private T retrieveFromID(Realm realm)
-        {
-            var found = realm.Find<T>(ID);
-
-            if (found == null)
-            {
-                // It may be that we access this from the update thread before a refresh has taken place.
-                // To ensure that behaviour matches what we'd expect (the object *is* available), force
-                // a refresh to bring in any off-thread changes immediately.
-                realm.Refresh();
-                found = realm.Find<T>(ID)!;
-            }
-
-            return found;
-        }
     }
 
     internal static class RealmLiveStatistics

From c885b71f3ad7a35438b3eba37c87cefff53c8a2b Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 16 Aug 2023 16:17:56 +0900
Subject: [PATCH 31/61] Add leaderboard toggle as tracked setting to give
 better use visibility

---
 osu.Game/Configuration/OsuConfigManager.cs | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs
index edcbb94368..921284ad4d 100644
--- a/osu.Game/Configuration/OsuConfigManager.cs
+++ b/osu.Game/Configuration/OsuConfigManager.cs
@@ -237,6 +237,12 @@ namespace osu.Game.Configuration
                     value: disabledState ? CommonStrings.Disabled.ToLower() : CommonStrings.Enabled.ToLower(),
                     shortcut: LookupKeyBindings(GlobalAction.ToggleGameplayMouseButtons))
                 ),
+                new TrackedSetting<bool>(OsuSetting.GameplayLeaderboard, state => new SettingDescription(
+                    rawValue: state,
+                    name: GlobalActionKeyBindingStrings.ToggleInGameLeaderboard,
+                    value: state ? CommonStrings.Enabled.ToLower() : CommonStrings.Disabled.ToLower(),
+                    shortcut: LookupKeyBindings(GlobalAction.ToggleInGameLeaderboard))
+                ),
                 new TrackedSetting<HUDVisibilityMode>(OsuSetting.HUDVisibilityMode, visibilityMode => new SettingDescription(
                     rawValue: visibilityMode,
                     name: GameplaySettingsStrings.HUDVisibilityMode,

From 68db112882b03ec4f361e0066245208ef789de60 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 16 Aug 2023 16:19:56 +0900
Subject: [PATCH 32/61] Re-date migration

---
 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 90e39da201..b729fdd8e8 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -82,7 +82,7 @@ namespace osu.Game.Database
         /// 30   2023-06-16    Run migration of old lazer scores again. This time with more correct rounding considerations.
         /// 31   2023-06-26    Add Version and LegacyTotalScore to ScoreInfo, set Version to 30000002 and copy TotalScore into LegacyTotalScore for legacy scores.
         /// 32   2023-07-09    Populate legacy scores with the ScoreV2 mod (and restore TotalScore to the legacy total for such scores) using replay files.
-        /// 33   2023-08-01    Reset default chat toggle keybind to avoid conflict with leaderboard toggle keybind.
+        /// 33   2023-08-16    Reset default chat toggle key binding to avoid conflict with newly added leaderboard toggle key binding.
         /// </summary>
         private const int schema_version = 33;
 

From caf0fd074296aa21fad8a5bd9dda96912ece8414 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 16 Aug 2023 16:21:42 +0900
Subject: [PATCH 33/61] Refactor migration to read the same as previous one

---
 osu.Game/Database/RealmAccess.cs | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index b729fdd8e8..917d662255 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -1022,10 +1022,10 @@ namespace osu.Game.Database
                 {
                     // Clear default bindings for the chat focus toggle,
                     // as they would conflict with the newly-added leaderboard toggle.
-                    var newKeyBindings = migration.NewRealm.All<RealmKeyBinding>().ToList();
-                    var toggleChatBind = newKeyBindings.FirstOrDefault(bind => bind.ActionInt == (int)GlobalAction.ToggleChatFocus);
+                    var keyBindings = migration.NewRealm.All<RealmKeyBinding>();
 
-                    if (toggleChatBind != default && toggleChatBind.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Tab }))
+                    var toggleChatBind = keyBindings.FirstOrDefault(bind => bind.ActionInt == (int)GlobalAction.ToggleChatFocus);
+                    if (toggleChatBind != null && toggleChatBind.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Tab }))
                         migration.NewRealm.Remove(toggleChatBind);
 
                     break;

From bb98f10ff67b87794a542f8f51f4cc1aeb1f1743 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 16 Aug 2023 16:38:49 +0900
Subject: [PATCH 34/61] Use `CurrentTime` instead of `StartTime` for hotfix
 seek (and update comment)

---
 osu.Game/Screens/Play/GameplayClockContainer.cs | 14 +++++++++++---
 1 file changed, 11 insertions(+), 3 deletions(-)

diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs
index 226108209a..a50c089d16 100644
--- a/osu.Game/Screens/Play/GameplayClockContainer.cs
+++ b/osu.Game/Screens/Play/GameplayClockContainer.cs
@@ -162,10 +162,18 @@ namespace osu.Game.Screens.Play
 
             // This is a workaround for the fact that DecoupleableInterpolatingFramedClock doesn't seek the source
             // if the source is not IsRunning. (see https://github.com/ppy/osu-framework/blob/2102638056dfcf85d21b4d85266d53b5dd018767/osu.Framework/Timing/DecoupleableInterpolatingFramedClock.cs#L209-L210)
-            //
-            // This breaks in multiplayer spectator.
             // I hope to remove this once we knock some sense into clocks in general.
-            (SourceClock as IAdjustableClock)?.Seek(StartTime);
+            //
+            // Without this seek, the multiplayer spectator start sequence breaks:
+            // - Individual clients' clocks are never updated to their expected time
+            // - The sync manager thinks they are running behind
+            // - Gameplay doesn't start when it should (until a timeout occurs because nothing is happening for 10+ seconds)
+            //
+            // In addition, we use `CurrentTime` for this seek instead of `StartTime` as the above seek may have applied inherent
+            // offsets which need to be accounted for (ie. FramedBeatmapClock.TotalApliedOffset).
+            //
+            // See https://github.com/ppy/osu/pull/24451/files/87fee001c786b29db34063ef3350e9a9f024d3ab#diff-28ca02979641e2d98a15fe5d5e806f56acf60ac100258a059fa72503b6cc54e8.
+            (SourceClock as IAdjustableClock)?.Seek(CurrentTime);
 
             if (!wasPaused || startClock)
                 Start();

From d70a9a5bc414c538ba67340c9a21d0666bd932d9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Wed, 16 Aug 2023 09:40:46 +0200
Subject: [PATCH 35/61] Fill out xmldoc and adjust inline commentary

---
 osu.Game/Database/RealmExtensions.cs | 13 ++++++++-----
 1 file changed, 8 insertions(+), 5 deletions(-)

diff --git a/osu.Game/Database/RealmExtensions.cs b/osu.Game/Database/RealmExtensions.cs
index ee76f1aa79..c84e1e35b8 100644
--- a/osu.Game/Database/RealmExtensions.cs
+++ b/osu.Game/Database/RealmExtensions.cs
@@ -13,10 +13,13 @@ namespace osu.Game.Database
         /// If a match was not found, a <see cref="Realm.Refresh"/> is performed before trying a second time.
         /// This ensures that an instance is found even if the realm requested against was not in a consistent state.
         /// </summary>
-        /// <param name="realm"></param>
-        /// <param name="id"></param>
-        /// <typeparam name="T"></typeparam>
-        /// <returns></returns>
+        /// <param name="realm">The realm to operate on.</param>
+        /// <param name="id">The ID of the entity to find in the realm.</param>
+        /// <typeparam name="T">The type of the entity to find in the realm.</typeparam>
+        /// <returns>
+        /// The retrieved entity of type <typeparamref name="T"/>.
+        /// Can be <see langword="null"/> if the entity is still not found by <paramref name="id"/> even after a refresh.
+        /// </returns>
         public static T? FindWithRefresh<T>(this Realm realm, Guid id) where T : IRealmObject
         {
             var found = realm.Find<T>(id);
@@ -24,7 +27,7 @@ namespace osu.Game.Database
             if (found == null)
             {
                 // It may be that we access this from the update thread before a refresh has taken place.
-                // To ensure that behaviour matches what we'd expect (the object *is* available), force
+                // To ensure that behaviour matches what we'd expect (the object generally *should be* available), force
                 // a refresh to bring in any off-thread changes immediately.
                 realm.Refresh();
                 found = realm.Find<T>(id);

From 56a4989f7158a071bf78b009e4524944f172493b Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 16 Aug 2023 16:55:48 +0900
Subject: [PATCH 36/61] Disable IPC binding in tournament client to allow
 running concurrently

---
 osu.Desktop/Program.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs
index 5a1373e040..a33e845f5b 100644
--- a/osu.Desktop/Program.cs
+++ b/osu.Desktop/Program.cs
@@ -85,7 +85,7 @@ namespace osu.Desktop
                 }
             }
 
-            using (DesktopGameHost host = Host.GetSuitableDesktopHost(gameName, new HostOptions { BindIPC = true }))
+            using (DesktopGameHost host = Host.GetSuitableDesktopHost(gameName, new HostOptions { BindIPC = !tournamentClient }))
             {
                 if (!host.IsPrimaryInstance)
                 {

From add1ef77d0b1ef4cb41ce083a937b9154eacc7b6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Wed, 16 Aug 2023 10:07:12 +0200
Subject: [PATCH 37/61] Fix typo in comment

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

diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs
index a50c089d16..20bf6c3829 100644
--- a/osu.Game/Screens/Play/GameplayClockContainer.cs
+++ b/osu.Game/Screens/Play/GameplayClockContainer.cs
@@ -170,7 +170,7 @@ namespace osu.Game.Screens.Play
             // - Gameplay doesn't start when it should (until a timeout occurs because nothing is happening for 10+ seconds)
             //
             // In addition, we use `CurrentTime` for this seek instead of `StartTime` as the above seek may have applied inherent
-            // offsets which need to be accounted for (ie. FramedBeatmapClock.TotalApliedOffset).
+            // offsets which need to be accounted for (ie. FramedBeatmapClock.TotalAppliedOffset).
             //
             // See https://github.com/ppy/osu/pull/24451/files/87fee001c786b29db34063ef3350e9a9f024d3ab#diff-28ca02979641e2d98a15fe5d5e806f56acf60ac100258a059fa72503b6cc54e8.
             (SourceClock as IAdjustableClock)?.Seek(CurrentTime);

From 965da343a409fbcf714c05226ddcd8bbffdb9fdd Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 1 Aug 2023 14:56:02 +0900
Subject: [PATCH 38/61] Fix tournament song bar tests not showing anything by
 default

---
 .../Components/TestSceneSongBar.cs                  | 13 +++++++++----
 1 file changed, 9 insertions(+), 4 deletions(-)

diff --git a/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs b/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs
index 762cfa2519..0f31192a9c 100644
--- a/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs
+++ b/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs
@@ -4,6 +4,7 @@
 using NUnit.Framework;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics;
+using osu.Framework.Testing;
 using osu.Game.Beatmaps.Legacy;
 using osu.Game.Tests.Visual;
 using osu.Game.Tournament.Components;
@@ -17,11 +18,11 @@ namespace osu.Game.Tournament.Tests.Components
         [Cached]
         private readonly LadderInfo ladder = new LadderInfo();
 
-        [Test]
-        public void TestSongBar()
-        {
-            SongBar songBar = null!;
+        private SongBar songBar = null!;
 
+        [SetUpSteps]
+        public void SetUpSteps()
+        {
             AddStep("create bar", () => Child = songBar = new SongBar
             {
                 RelativeSizeAxes = Axes.X,
@@ -29,7 +30,11 @@ namespace osu.Game.Tournament.Tests.Components
                 Origin = Anchor.Centre
             });
             AddUntilStep("wait for loaded", () => songBar.IsLoaded);
+        }
 
+        [Test]
+        public void TestSongBar()
+        {
             AddStep("set beatmap", () =>
             {
                 var beatmap = CreateAPIBeatmap(Ruleset.Value);

From 56eb44d15bd2e119dec201562f1db41d22534e0c Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 2 Aug 2023 16:07:03 +0900
Subject: [PATCH 39/61] Change `TestSpectatorClient` to provide some better
 fake data for score / acc

---
 .../Multiplayer/TestSceneMultiSpectatorLeaderboard.cs      | 2 ++
 osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs     | 7 ++++++-
 2 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs
index 049c02ffde..4bf2ebc1a4 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs
@@ -49,6 +49,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
 
                 LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(clocks.Keys.Select(id => new MultiplayerRoomUser(id)).ToArray())
                 {
+                    Anchor = Anchor.Centre,
+                    Origin = Anchor.Centre,
                     Expanded = { Value = true }
                 }, Add);
             });
diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs
index 305a615102..5db08810ca 100644
--- a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs
+++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs
@@ -124,7 +124,12 @@ namespace osu.Game.Tests.Visual.Spectator
                 if (frames.Count == 0)
                     return;
 
-                var bundle = new FrameDataBundle(new ScoreInfo { Combo = currentFrameIndex }, new ScoreProcessor(rulesetStore.GetRuleset(0)!.CreateInstance()), frames.ToArray());
+                var bundle = new FrameDataBundle(new ScoreInfo
+                {
+                    Combo = currentFrameIndex,
+                    TotalScore = (long)(currentFrameIndex * 123478 * RNG.NextDouble(0.99, 1.01)),
+                    Accuracy = RNG.NextDouble(0.98, 1),
+                }, new ScoreProcessor(rulesetStore.GetRuleset(0)!.CreateInstance()), frames.ToArray());
                 ((ISpectatorClient)this).UserSentFrames(userId, bundle);
 
                 frames.Clear();

From 26d05afadcbacaa9a8dacfb2c98d79a3c4483e81 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 1 Aug 2023 18:28:06 +0900
Subject: [PATCH 40/61] Add more test coverage to multi spectator screen

---
 .../TestSceneMultiSpectatorScreen.cs          | 19 +++++++++++++++++--
 1 file changed, 17 insertions(+), 2 deletions(-)

diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
index a61c3f1234..cebc75f90c 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
@@ -79,6 +79,19 @@ namespace osu.Game.Tests.Visual.Multiplayer
             AddWaitStep("wait a bit", 20);
         }
 
+        [TestCase(2)]
+        [TestCase(16)]
+        public void TestTeams(int count)
+        {
+            int[] userIds = getPlayerIds(count);
+
+            start(userIds, teams: true);
+            loadSpectateScreen();
+
+            sendFrames(userIds, 1000);
+            AddWaitStep("wait a bit", 20);
+        }
+
         [Test]
         public void TestMultipleStartRequests()
         {
@@ -450,16 +463,18 @@ namespace osu.Game.Tests.Visual.Multiplayer
 
         private void start(int userId, int? beatmapId = null) => start(new[] { userId }, beatmapId);
 
-        private void start(int[] userIds, int? beatmapId = null, APIMod[]? mods = null)
+        private void start(int[] userIds, int? beatmapId = null, APIMod[]? mods = null, bool teams = false)
         {
             AddStep("start play", () =>
             {
-                foreach (int id in userIds)
+                for (int i = 0; i < userIds.Length; i++)
                 {
+                    int id = userIds[i];
                     var user = new MultiplayerRoomUser(id)
                     {
                         User = new APIUser { Id = id },
                         Mods = mods ?? Array.Empty<APIMod>(),
+                        MatchState = teams ? new TeamVersusUserState { TeamID = i % 2 } : null,
                     };
 
                     OnlinePlayDependencies.MultiplayerClient.AddUser(user, true);

From f02416f877c758d4d8e7a3fbe78b413260c7b997 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 1 Aug 2023 14:56:48 +0900
Subject: [PATCH 41/61] Improve visuals of tournament song bar

This is a stop-gap until we add new versions (and share between game and tournament client).
---
 osu.Game.Tournament/Components/SongBar.cs     | 39 ++++++++++++++-----
 .../Components/TournamentBeatmapPanel.cs      |  6 +--
 2 files changed, 32 insertions(+), 13 deletions(-)

diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs
index fa0cbda16b..3d060600f7 100644
--- a/osu.Game.Tournament/Components/SongBar.cs
+++ b/osu.Game.Tournament/Components/SongBar.cs
@@ -14,7 +14,6 @@ using osu.Game.Extensions;
 using osu.Game.Graphics;
 using osu.Game.Rulesets;
 using osu.Game.Screens.Menu;
-using osu.Game.Tournament.Models;
 using osuTK;
 using osuTK.Graphics;
 
@@ -22,14 +21,14 @@ namespace osu.Game.Tournament.Components
 {
     public partial class SongBar : CompositeDrawable
     {
-        private TournamentBeatmap? beatmap;
+        private IBeatmapInfo? beatmap;
 
         public const float HEIGHT = 145 / 2f;
 
         [Resolved]
         private IBindable<RulesetInfo> ruleset { get; set; } = null!;
 
-        public TournamentBeatmap? Beatmap
+        public IBeatmapInfo? Beatmap
         {
             set
             {
@@ -37,7 +36,7 @@ namespace osu.Game.Tournament.Components
                     return;
 
                 beatmap = value;
-                update();
+                refreshContent();
             }
         }
 
@@ -49,7 +48,7 @@ namespace osu.Game.Tournament.Components
             set
             {
                 mods = value;
-                update();
+                refreshContent();
             }
         }
 
@@ -71,19 +70,25 @@ namespace osu.Game.Tournament.Components
         protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false;
 
         [BackgroundDependencyLoader]
-        private void load()
+        private void load(OsuColour colours)
         {
             RelativeSizeAxes = Axes.X;
             AutoSizeAxes = Axes.Y;
 
+            Masking = true;
+            CornerRadius = 5;
+
             InternalChildren = new Drawable[]
             {
+                new Box
+                {
+                    Colour = colours.Gray3,
+                    RelativeSizeAxes = Axes.Both,
+                },
                 flow = new FillFlowContainer
                 {
                     RelativeSizeAxes = Axes.X,
                     AutoSizeAxes = Axes.Y,
-                    LayoutDuration = 500,
-                    LayoutEasing = Easing.OutQuint,
                     Direction = FillDirection.Full,
                     Anchor = Anchor.BottomRight,
                     Origin = Anchor.BottomRight,
@@ -93,7 +98,7 @@ namespace osu.Game.Tournament.Components
             Expanded = true;
         }
 
-        private void update()
+        private void refreshContent()
         {
             if (beatmap == null)
             {
@@ -229,7 +234,7 @@ namespace osu.Game.Tournament.Components
                         }
                     }
                 },
-                new TournamentBeatmapPanel(beatmap)
+                new UnmaskedTournamentBeatmapPanel(beatmap)
                 {
                     RelativeSizeAxes = Axes.X,
                     Width = 0.5f,
@@ -272,4 +277,18 @@ namespace osu.Game.Tournament.Components
             }
         }
     }
+
+    internal partial class UnmaskedTournamentBeatmapPanel : TournamentBeatmapPanel
+    {
+        public UnmaskedTournamentBeatmapPanel(IBeatmapInfo? beatmap, string mod = "")
+            : base(beatmap, mod)
+        {
+        }
+
+        [BackgroundDependencyLoader]
+        private void load()
+        {
+            Masking = false;
+        }
+    }
 }
diff --git a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs
index ba922c7c7b..4e0adb30ac 100644
--- a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs
+++ b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs
@@ -20,7 +20,7 @@ namespace osu.Game.Tournament.Components
 {
     public partial class TournamentBeatmapPanel : CompositeDrawable
     {
-        public readonly TournamentBeatmap? Beatmap;
+        public readonly IBeatmapInfo? Beatmap;
 
         private readonly string mod;
 
@@ -30,7 +30,7 @@ namespace osu.Game.Tournament.Components
 
         private Box flash = null!;
 
-        public TournamentBeatmapPanel(TournamentBeatmap? beatmap, string mod = "")
+        public TournamentBeatmapPanel(IBeatmapInfo? beatmap, string mod = "")
         {
             Beatmap = beatmap;
             this.mod = mod;
@@ -58,7 +58,7 @@ namespace osu.Game.Tournament.Components
                 {
                     RelativeSizeAxes = Axes.Both,
                     Colour = OsuColour.Gray(0.5f),
-                    OnlineInfo = Beatmap,
+                    OnlineInfo = (Beatmap as IBeatmapSetOnlineInfo),
                 },
                 new FillFlowContainer
                 {

From ab826c35b7515619535fb2bc4959f57afab1fe97 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Tue, 1 Aug 2023 18:28:59 +0900
Subject: [PATCH 42/61] Add score diff display to multiplayer spectator

Basically pulling changes over from the tournament client implementation
---
 .../Screens/Play/HUD/MatchScoreDisplay.cs     | 25 +++++++++++++++++++
 1 file changed, 25 insertions(+)

diff --git a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs
index 58bf4eea4b..e1e388d872 100644
--- a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs
+++ b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs
@@ -30,6 +30,8 @@ namespace osu.Game.Screens.Play.HUD
         private Drawable score1Bar;
         private Drawable score2Bar;
 
+        private MatchScoreDiffCounter scoreDiffText;
+
         [BackgroundDependencyLoader]
         private void load(OsuColour colours)
         {
@@ -98,6 +100,16 @@ namespace osu.Game.Screens.Play.HUD
                         },
                     }
                 },
+                scoreDiffText = new MatchScoreDiffCounter
+                {
+                    Anchor = Anchor.TopCentre,
+                    Margin = new MarginPadding
+                    {
+                        Top = bar_height / 4,
+                        Horizontal = 8
+                    },
+                    Alpha = 0
+                }
             };
         }
 
@@ -139,6 +151,10 @@ namespace osu.Game.Screens.Play.HUD
 
             losingBar.ResizeWidthTo(0, 400, Easing.OutQuint);
             winningBar.ResizeWidthTo(Math.Min(0.4f, MathF.Pow(diff / 1500000f, 0.5f) / 2), 400, Easing.OutQuint);
+
+            scoreDiffText.Alpha = diff != 0 ? 1 : 0;
+            scoreDiffText.Current.Value = -diff;
+            scoreDiffText.Origin = Team1Score.Value > Team2Score.Value ? Anchor.TopLeft : Anchor.TopRight;
         }
 
         protected override void UpdateAfterChildren()
@@ -174,5 +190,14 @@ namespace osu.Game.Screens.Play.HUD
                     ? OsuFont.Torus.With(weight: FontWeight.Bold, size: font_size, fixedWidth: true)
                     : OsuFont.Torus.With(weight: FontWeight.Regular, size: font_size * 0.8f, fixedWidth: true);
         }
+
+        private partial class MatchScoreDiffCounter : CommaSeparatedScoreCounter
+        {
+            protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s =>
+            {
+                s.Spacing = new Vector2(-2);
+                s.Font = OsuFont.Torus.With(weight: FontWeight.Regular, size: bar_height, fixedWidth: true);
+            });
+        }
     }
 }

From 8b9759c569590687ee78591aa713bdbd99d3c4a4 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 16 Aug 2023 17:22:55 +0900
Subject: [PATCH 43/61] Apply nullability to `MatchScoreDisplay`

---
 osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs | 14 ++++++--------
 1 file changed, 6 insertions(+), 8 deletions(-)

diff --git a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs
index e1e388d872..4a61c7fd1b 100644
--- a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs
+++ b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs
@@ -1,8 +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.
 
-#nullable disable
-
 using System;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
@@ -24,13 +22,13 @@ namespace osu.Game.Screens.Play.HUD
         public BindableLong Team1Score = new BindableLong();
         public BindableLong Team2Score = new BindableLong();
 
-        protected MatchScoreCounter Score1Text;
-        protected MatchScoreCounter Score2Text;
+        protected MatchScoreCounter Score1Text = null!;
+        protected MatchScoreCounter Score2Text = null!;
 
-        private Drawable score1Bar;
-        private Drawable score2Bar;
+        private Drawable score1Bar = null!;
+        private Drawable score2Bar = null!;
 
-        private MatchScoreDiffCounter scoreDiffText;
+        private MatchScoreDiffCounter scoreDiffText = null!;
 
         [BackgroundDependencyLoader]
         private void load(OsuColour colours)
@@ -166,7 +164,7 @@ namespace osu.Game.Screens.Play.HUD
 
         protected partial class MatchScoreCounter : CommaSeparatedScoreCounter
         {
-            private OsuSpriteText displayedSpriteText;
+            private OsuSpriteText displayedSpriteText = null!;
 
             public MatchScoreCounter()
             {

From d309865b0daac418ad4f7de6a3f512ab4dd2b51b Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Wed, 16 Aug 2023 17:25:25 +0900
Subject: [PATCH 44/61] Update `TournamentMatchScoreDisplay` to share base
 implementation

---
 osu.Game.Tournament/IPC/MatchIPCInfo.cs       |   4 +-
 .../Components/TournamentMatchScoreDisplay.cs | 170 +-----------------
 2 files changed, 6 insertions(+), 168 deletions(-)

diff --git a/osu.Game.Tournament/IPC/MatchIPCInfo.cs b/osu.Game.Tournament/IPC/MatchIPCInfo.cs
index f57518971f..b4575144e7 100644
--- a/osu.Game.Tournament/IPC/MatchIPCInfo.cs
+++ b/osu.Game.Tournament/IPC/MatchIPCInfo.cs
@@ -14,7 +14,7 @@ namespace osu.Game.Tournament.IPC
         public Bindable<LegacyMods> Mods { get; } = new Bindable<LegacyMods>();
         public Bindable<TourneyState> State { get; } = new Bindable<TourneyState>();
         public Bindable<string> ChatChannel { get; } = new Bindable<string>();
-        public BindableInt Score1 { get; } = new BindableInt();
-        public BindableInt Score2 { get; } = new BindableInt();
+        public BindableLong Score1 { get; } = new BindableLong();
+        public BindableLong Score2 { get; } = new BindableLong();
     }
 }
diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs
index 7ae20acc77..f8de34a511 100644
--- a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs
+++ b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs
@@ -1,181 +1,19 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
-using System;
 using osu.Framework.Allocation;
-using osu.Framework.Bindables;
-using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
-using osu.Framework.Graphics.Shapes;
-using osu.Game.Graphics;
-using osu.Game.Graphics.Sprites;
-using osu.Game.Graphics.UserInterface;
+using osu.Game.Screens.Play.HUD;
 using osu.Game.Tournament.IPC;
-using osuTK;
 
 namespace osu.Game.Tournament.Screens.Gameplay.Components
 {
-    // TODO: Update to derive from osu-side class?
-    public partial class TournamentMatchScoreDisplay : CompositeDrawable
+    public partial class TournamentMatchScoreDisplay : MatchScoreDisplay
     {
-        private const float bar_height = 18;
-
-        private readonly BindableInt score1 = new BindableInt();
-        private readonly BindableInt score2 = new BindableInt();
-
-        private readonly MatchScoreCounter score1Text;
-        private readonly MatchScoreCounter score2Text;
-
-        private readonly MatchScoreDiffCounter scoreDiffText;
-
-        private readonly Drawable score1Bar;
-        private readonly Drawable score2Bar;
-
-        public TournamentMatchScoreDisplay()
-        {
-            RelativeSizeAxes = Axes.X;
-            AutoSizeAxes = Axes.Y;
-
-            InternalChildren = new[]
-            {
-                new Box
-                {
-                    Name = "top bar red (static)",
-                    RelativeSizeAxes = Axes.X,
-                    Height = bar_height / 4,
-                    Width = 0.5f,
-                    Colour = TournamentGame.COLOUR_RED,
-                    Anchor = Anchor.TopCentre,
-                    Origin = Anchor.TopRight
-                },
-                new Box
-                {
-                    Name = "top bar blue (static)",
-                    RelativeSizeAxes = Axes.X,
-                    Height = bar_height / 4,
-                    Width = 0.5f,
-                    Colour = TournamentGame.COLOUR_BLUE,
-                    Anchor = Anchor.TopCentre,
-                    Origin = Anchor.TopLeft
-                },
-                score1Bar = new Box
-                {
-                    Name = "top bar red",
-                    RelativeSizeAxes = Axes.X,
-                    Height = bar_height,
-                    Width = 0,
-                    Colour = TournamentGame.COLOUR_RED,
-                    Anchor = Anchor.TopCentre,
-                    Origin = Anchor.TopRight
-                },
-                score1Text = new MatchScoreCounter
-                {
-                    Anchor = Anchor.TopCentre,
-                    Origin = Anchor.TopCentre
-                },
-                score2Bar = new Box
-                {
-                    Name = "top bar blue",
-                    RelativeSizeAxes = Axes.X,
-                    Height = bar_height,
-                    Width = 0,
-                    Colour = TournamentGame.COLOUR_BLUE,
-                    Anchor = Anchor.TopCentre,
-                    Origin = Anchor.TopLeft
-                },
-                score2Text = new MatchScoreCounter
-                {
-                    Anchor = Anchor.TopCentre,
-                    Origin = Anchor.TopCentre
-                },
-                scoreDiffText = new MatchScoreDiffCounter
-                {
-                    Anchor = Anchor.TopCentre,
-                    Margin = new MarginPadding
-                    {
-                        Top = bar_height / 4,
-                        Horizontal = 8
-                    },
-                    Alpha = 0
-                }
-            };
-        }
-
         [BackgroundDependencyLoader]
         private void load(MatchIPCInfo ipc)
         {
-            score1.BindValueChanged(_ => updateScores());
-            score1.BindTo(ipc.Score1);
-
-            score2.BindValueChanged(_ => updateScores());
-            score2.BindTo(ipc.Score2);
-        }
-
-        private void updateScores()
-        {
-            score1Text.Current.Value = score1.Value;
-            score2Text.Current.Value = score2.Value;
-
-            var winningText = score1.Value > score2.Value ? score1Text : score2Text;
-            var losingText = score1.Value <= score2.Value ? score1Text : score2Text;
-
-            winningText.Winning = true;
-            losingText.Winning = false;
-
-            var winningBar = score1.Value > score2.Value ? score1Bar : score2Bar;
-            var losingBar = score1.Value <= score2.Value ? score1Bar : score2Bar;
-
-            int diff = Math.Max(score1.Value, score2.Value) - Math.Min(score1.Value, score2.Value);
-
-            losingBar.ResizeWidthTo(0, 400, Easing.OutQuint);
-            winningBar.ResizeWidthTo(Math.Min(0.4f, MathF.Pow(diff / 1500000f, 0.5f) / 2), 400, Easing.OutQuint);
-
-            scoreDiffText.Alpha = diff != 0 ? 1 : 0;
-            scoreDiffText.Current.Value = -diff;
-            scoreDiffText.Origin = score1.Value > score2.Value ? Anchor.TopLeft : Anchor.TopRight;
-        }
-
-        protected override void UpdateAfterChildren()
-        {
-            base.UpdateAfterChildren();
-            score1Text.X = -Math.Max(5 + score1Text.DrawWidth / 2, score1Bar.DrawWidth);
-            score2Text.X = Math.Max(5 + score2Text.DrawWidth / 2, score2Bar.DrawWidth);
-        }
-
-        private partial class MatchScoreCounter : CommaSeparatedScoreCounter
-        {
-            private OsuSpriteText displayedSpriteText = null!;
-
-            public MatchScoreCounter()
-            {
-                Margin = new MarginPadding { Top = bar_height, Horizontal = 10 };
-            }
-
-            public bool Winning
-            {
-                set => updateFont(value);
-            }
-
-            protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s =>
-            {
-                displayedSpriteText = s;
-                displayedSpriteText.Spacing = new Vector2(-6);
-                updateFont(false);
-            });
-
-            private void updateFont(bool winning)
-                => displayedSpriteText.Font = winning
-                    ? OsuFont.Torus.With(weight: FontWeight.Bold, size: 50, fixedWidth: true)
-                    : OsuFont.Torus.With(weight: FontWeight.Regular, size: 40, fixedWidth: true);
-        }
-
-        private partial class MatchScoreDiffCounter : CommaSeparatedScoreCounter
-        {
-            protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s =>
-            {
-                s.Spacing = new Vector2(-2);
-                s.Font = OsuFont.Torus.With(weight: FontWeight.Regular, size: bar_height, fixedWidth: true);
-            });
+            Team1Score.BindTo(ipc.Score1);
+            Team2Score.BindTo(ipc.Score2);
         }
     }
 }

From 19f892687a0607afbe4e0d010366dc2a66236073 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sun, 9 Jul 2023 21:00:35 +0200
Subject: [PATCH 45/61] Add precise rotation control to osu! editor

---
 .../Edit/OsuHitObjectComposer.cs              |   5 +
 .../Edit/PreciseRotationPopover.cs            | 107 ++++++++++++++++++
 .../Edit/TransformToolboxGroup.cs             |  80 +++++++++++++
 .../UserInterfaceV2/LabelledTextBox.cs        |   1 +
 .../UserInterfaceV2/SliderWithTextBoxInput.cs |   2 +
 .../Input/Bindings/GlobalActionContainer.cs   |   4 +
 .../GlobalActionKeyBindingStrings.cs          |   5 +
 .../Edit/Components/EditorToolButton.cs       | 107 ++++++++++++++++++
 .../Compose/Components/BlueprintContainer.cs  |   2 +-
 .../Compose/Components/SelectionHandler.cs    |   2 +-
 10 files changed, 313 insertions(+), 2 deletions(-)
 create mode 100644 osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
 create mode 100644 osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs
 create mode 100644 osu.Game/Screens/Edit/Components/EditorToolButton.cs

diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs
index 0b80750a02..cff2171cbd 100644
--- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs
+++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs
@@ -85,6 +85,11 @@ namespace osu.Game.Rulesets.Osu.Edit
 
             // we may be entering the screen with a selection already active
             updateDistanceSnapGrid();
+
+            RightToolbox.Add(new TransformToolboxGroup
+            {
+                RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler
+            });
         }
 
         protected override ComposeBlueprintContainer CreateBlueprintContainer()
diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
new file mode 100644
index 0000000000..0bc7e72751
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
@@ -0,0 +1,107 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Graphics.UserInterfaceV2;
+using osu.Game.Rulesets.Osu.UI;
+using osu.Game.Screens.Edit.Components.RadioButtons;
+using osu.Game.Screens.Edit.Compose.Components;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Edit
+{
+    public partial class PreciseRotationPopover : OsuPopover
+    {
+        private readonly SelectionRotationHandler rotationHandler;
+
+        private readonly Bindable<PreciseRotationInfo> rotationInfo = new Bindable<PreciseRotationInfo>(new PreciseRotationInfo(0, RotationOrigin.PlayfieldCentre));
+
+        private SliderWithTextBoxInput<float> angleInput = null!;
+        private EditorRadioButtonCollection rotationOrigin = null!;
+
+        public PreciseRotationPopover(SelectionRotationHandler rotationHandler)
+        {
+            this.rotationHandler = rotationHandler;
+
+            AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
+        }
+
+        [BackgroundDependencyLoader]
+        private void load()
+        {
+            Child = new FillFlowContainer
+            {
+                Width = 220,
+                AutoSizeAxes = Axes.Y,
+                Spacing = new Vector2(20),
+                Children = new Drawable[]
+                {
+                    angleInput = new SliderWithTextBoxInput<float>("Angle (degrees):")
+                    {
+                        Current = new BindableNumber<float>
+                        {
+                            MinValue = -180,
+                            MaxValue = 180,
+                            Precision = 1
+                        },
+                        Instantaneous = true
+                    },
+                    rotationOrigin = new EditorRadioButtonCollection
+                    {
+                        RelativeSizeAxes = Axes.X,
+                        Items = new[]
+                        {
+                            new RadioButton("Playfield centre",
+                                () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre },
+                                () => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
+                            new RadioButton("Selection centre",
+                                () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.SelectionCentre },
+                                () => new SpriteIcon { Icon = FontAwesome.Solid.ObjectGroup })
+                        }
+                    }
+                }
+            };
+        }
+
+        protected override void LoadComplete()
+        {
+            base.LoadComplete();
+
+            ScheduleAfterChildren(() => angleInput.TakeFocus());
+            angleInput.Current.BindValueChanged(angle => rotationInfo.Value = rotationInfo.Value with { Degrees = angle.NewValue });
+            rotationOrigin.Items.First().Select();
+
+            rotationInfo.BindValueChanged(rotation =>
+            {
+                rotationHandler.Update(rotation.NewValue.Degrees, rotation.NewValue.Origin == RotationOrigin.PlayfieldCentre ? OsuPlayfield.BASE_SIZE / 2 : null);
+            });
+        }
+
+        protected override void PopIn()
+        {
+            base.PopIn();
+            rotationHandler.Begin();
+        }
+
+        protected override void PopOut()
+        {
+            base.PopOut();
+
+            if (IsLoaded)
+                rotationHandler.Commit();
+        }
+    }
+
+    public enum RotationOrigin
+    {
+        PlayfieldCentre,
+        SelectionCentre
+    }
+
+    public record PreciseRotationInfo(float Degrees, RotationOrigin Origin);
+}
diff --git a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs
new file mode 100644
index 0000000000..3da9f5b69b
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs
@@ -0,0 +1,80 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Input.Bindings;
+using osu.Framework.Input.Events;
+using osu.Game.Input.Bindings;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Screens.Edit.Components;
+using osu.Game.Screens.Edit.Compose.Components;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Edit
+{
+    public partial class TransformToolboxGroup : EditorToolboxGroup, IKeyBindingHandler<GlobalAction>
+    {
+        private readonly Bindable<bool> canRotate = new BindableBool();
+
+        private EditorToolButton rotateButton = null!;
+
+        public SelectionRotationHandler RotationHandler { get; init; } = null!;
+
+        public TransformToolboxGroup()
+            : base("transform")
+        {
+        }
+
+        [BackgroundDependencyLoader]
+        private void load()
+        {
+            Child = new FillFlowContainer
+            {
+                RelativeSizeAxes = Axes.X,
+                AutoSizeAxes = Axes.Y,
+                Spacing = new Vector2(5),
+                Children = new Drawable[]
+                {
+                    rotateButton = new EditorToolButton("Rotate",
+                        () => new SpriteIcon { Icon = FontAwesome.Solid.Undo },
+                        () => new PreciseRotationPopover(RotationHandler)),
+                    // TODO: scale
+                }
+            };
+        }
+
+        protected override void LoadComplete()
+        {
+            base.LoadComplete();
+
+            // bindings to `Enabled` on the buttons are decoupled on purpose
+            // due to the weird `OsuButton` behaviour of resetting `Enabled` to `false` when `Action` is set.
+            canRotate.BindTo(RotationHandler.CanRotate);
+            canRotate.BindValueChanged(_ => rotateButton.Enabled.Value = canRotate.Value, true);
+        }
+
+        public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
+        {
+            if (e.Repeat) return false;
+
+            switch (e.Action)
+            {
+                case GlobalAction.EditorToggleRotateControl:
+                {
+                    rotateButton.TriggerClick();
+                    return true;
+                }
+            }
+
+            return false;
+        }
+
+        public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
+        {
+        }
+    }
+}
diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
index 454be02d0b..8b9d35e343 100644
--- a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
+++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
@@ -35,6 +35,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
 
         public string Text
         {
+            get => Component.Text;
             set => Component.Text = value;
         }
 
diff --git a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
index fc0e4d2083..37ea2a3f96 100644
--- a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
+++ b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
@@ -85,6 +85,8 @@ namespace osu.Game.Graphics.UserInterfaceV2
             Current.BindValueChanged(updateTextBoxFromSlider, true);
         }
 
+        public bool TakeFocus() => GetContainingInputManager().ChangeFocus(textBox);
+
         private bool updatingFromTextBox;
 
         private void textChanged(ValueChangedEvent<string> change)
diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs
index 1090eeb462..9a0a2d5c15 100644
--- a/osu.Game/Input/Bindings/GlobalActionContainer.cs
+++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs
@@ -105,6 +105,7 @@ namespace osu.Game.Input.Bindings
             // See https://github.com/ppy/osu-framework/blob/master/osu.Framework/Input/StateChanges/MouseScrollRelativeInput.cs#L37-L38.
             new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelRight }, GlobalAction.EditorCyclePreviousBeatSnapDivisor),
             new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelLeft }, GlobalAction.EditorCycleNextBeatSnapDivisor),
+            new KeyBinding(new[] { InputKey.Control, InputKey.R }, GlobalAction.EditorToggleRotateControl),
         };
 
         public IEnumerable<KeyBinding> InGameKeyBindings => new[]
@@ -378,5 +379,8 @@ namespace osu.Game.Input.Bindings
 
         [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleInGameLeaderboard))]
         ToggleInGameLeaderboard,
+
+        [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorToggleRotateControl))]
+        EditorToggleRotateControl,
     }
 }
diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs
index ceefc27968..8356c480dd 100644
--- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs
+++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs
@@ -344,6 +344,11 @@ namespace osu.Game.Localisation
         /// </summary>
         public static LocalisableString ExportReplay => new TranslatableString(getKey(@"export_replay"), @"Export replay");
 
+        /// <summary>
+        /// "Toggle rotate control"
+        /// </summary>
+        public static LocalisableString EditorToggleRotateControl => new TranslatableString(getKey(@"editor_toggle_rotate_control"), @"Toggle rotate control");
+
         private static string getKey(string key) => $@"{prefix}:{key}";
     }
 }
diff --git a/osu.Game/Screens/Edit/Components/EditorToolButton.cs b/osu.Game/Screens/Edit/Components/EditorToolButton.cs
new file mode 100644
index 0000000000..6550362687
--- /dev/null
+++ b/osu.Game/Screens/Edit/Components/EditorToolButton.cs
@@ -0,0 +1,107 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions;
+using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Localisation;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Overlays;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Screens.Edit.Components
+{
+    public partial class EditorToolButton : OsuButton, IHasPopover
+    {
+        public BindableBool Selected { get; } = new BindableBool();
+
+        private readonly Func<Drawable> createIcon;
+        private readonly Func<Popover?> createPopover;
+
+        private Color4 defaultBackgroundColour;
+        private Color4 defaultIconColour;
+        private Color4 selectedBackgroundColour;
+        private Color4 selectedIconColour;
+
+        private Drawable icon = null!;
+
+        public EditorToolButton(LocalisableString text, Func<Drawable> createIcon, Func<Popover?> createPopover)
+        {
+            Text = text;
+            this.createIcon = createIcon;
+            this.createPopover = createPopover;
+
+            RelativeSizeAxes = Axes.X;
+        }
+
+        [BackgroundDependencyLoader]
+        private void load(OverlayColourProvider colourProvider)
+        {
+            defaultBackgroundColour = colourProvider.Background3;
+            selectedBackgroundColour = colourProvider.Background1;
+
+            defaultIconColour = defaultBackgroundColour.Darken(0.5f);
+            selectedIconColour = selectedBackgroundColour.Lighten(0.5f);
+
+            Add(icon = createIcon().With(b =>
+            {
+                b.Blending = BlendingParameters.Additive;
+                b.Anchor = Anchor.CentreLeft;
+                b.Origin = Anchor.CentreLeft;
+                b.Size = new Vector2(20);
+                b.X = 10;
+            }));
+
+            Action = Selected.Toggle;
+        }
+
+        protected override void LoadComplete()
+        {
+            base.LoadComplete();
+
+            Selected.BindValueChanged(_ => updateSelectionState(), true);
+        }
+
+        private void updateSelectionState()
+        {
+            if (!IsLoaded)
+                return;
+
+            BackgroundColour = Selected.Value ? selectedBackgroundColour : defaultBackgroundColour;
+            icon.Colour = Selected.Value ? selectedIconColour : defaultIconColour;
+
+            if (Selected.Value)
+                this.ShowPopover();
+            else
+                this.HidePopover();
+        }
+
+        protected override SpriteText CreateText() => new OsuSpriteText
+        {
+            Depth = -1,
+            Origin = Anchor.CentreLeft,
+            Anchor = Anchor.CentreLeft,
+            X = 40f
+        };
+
+        public Popover? GetPopover() => Enabled.Value
+            ? createPopover()?.With(p =>
+            {
+                p.State.BindValueChanged(state =>
+                {
+                    if (state.NewValue == Visibility.Hidden)
+                        Selected.Value = false;
+                });
+            })
+            : null;
+    }
+}
diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs
index 1de6c8364c..110beb0fa6 100644
--- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs
@@ -34,7 +34,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
 
         public Container<SelectionBlueprint<T>> SelectionBlueprints { get; private set; }
 
-        protected SelectionHandler<T> SelectionHandler { get; private set; }
+        public SelectionHandler<T> SelectionHandler { get; private set; }
 
         private readonly Dictionary<T, SelectionBlueprint<T>> blueprintMap = new Dictionary<T, SelectionBlueprint<T>>();
 
diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
index 158b4066bc..3c859c65ff 100644
--- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
@@ -55,7 +55,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
         [Resolved(CanBeNull = true)]
         protected IEditorChangeHandler ChangeHandler { get; private set; }
 
-        protected SelectionRotationHandler RotationHandler { get; private set; }
+        public SelectionRotationHandler RotationHandler { get; private set; }
 
         protected SelectionHandler()
         {

From 5219b8a13b328341d1afad565f6638cceedbdbca Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Wed, 16 Aug 2023 11:35:09 +0200
Subject: [PATCH 46/61] Add test coverage for precise rotation control

---
 .../Editor/TestScenePreciseRotation.cs        | 95 +++++++++++++++++++
 1 file changed, 95 insertions(+)
 create mode 100644 osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs

diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs
new file mode 100644
index 0000000000..d7dd30d608
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs
@@ -0,0 +1,95 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Edit;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.UI;
+using osu.Game.Screens.Edit.Components.RadioButtons;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor
+{
+    public partial class TestScenePreciseRotation : TestSceneOsuEditor
+    {
+        protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset);
+
+        [Test]
+        public void TestHotkeyHandling()
+        {
+            AddStep("select single circle", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.OfType<HitCircle>().First()));
+            AddStep("press rotate hotkey", () =>
+            {
+                InputManager.PressKey(Key.ControlLeft);
+                InputManager.Key(Key.R);
+                InputManager.ReleaseKey(Key.ControlLeft);
+            });
+            AddUntilStep("no popover present", () => this.ChildrenOfType<PreciseRotationPopover>().Count(), () => Is.Zero);
+
+            AddStep("select first three objects", () =>
+            {
+                EditorBeatmap.SelectedHitObjects.Clear();
+                EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects.Take(3));
+            });
+            AddStep("press rotate hotkey", () =>
+            {
+                InputManager.PressKey(Key.ControlLeft);
+                InputManager.Key(Key.R);
+                InputManager.ReleaseKey(Key.ControlLeft);
+            });
+            AddUntilStep("popover present", () => this.ChildrenOfType<PreciseRotationPopover>().Count(), () => Is.EqualTo(1));
+            AddStep("press rotate hotkey", () =>
+            {
+                InputManager.PressKey(Key.ControlLeft);
+                InputManager.Key(Key.R);
+                InputManager.ReleaseKey(Key.ControlLeft);
+            });
+            AddUntilStep("no popover present", () => this.ChildrenOfType<PreciseRotationPopover>().Count(), () => Is.Zero);
+        }
+
+        [Test]
+        public void TestRotateCorrectness()
+        {
+            AddStep("replace objects", () =>
+            {
+                EditorBeatmap.Clear();
+                EditorBeatmap.AddRange(new HitObject[]
+                {
+                    new HitCircle { Position = new Vector2(100) },
+                    new HitCircle { Position = new Vector2(200) },
+                });
+            });
+            AddStep("select both circles", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects));
+            AddStep("press rotate hotkey", () =>
+            {
+                InputManager.PressKey(Key.ControlLeft);
+                InputManager.Key(Key.R);
+                InputManager.ReleaseKey(Key.ControlLeft);
+            });
+            AddUntilStep("popover present", getPopover, () => Is.Not.Null);
+
+            AddStep("rotate by 180deg", () => getPopover().ChildrenOfType<TextBox>().Single().Current.Value = "180");
+            AddAssert("first object rotated 180deg around playfield centre",
+                () => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(0).Position,
+                () => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(100)));
+            AddAssert("second object rotated 180deg around playfield centre",
+                () => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(1).Position,
+                () => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(200)));
+
+            AddStep("change rotation origin", () => getPopover().ChildrenOfType<EditorRadioButton>().ElementAt(1).TriggerClick());
+            AddAssert("first object rotated 90deg around selection centre",
+                () => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(0).Position, () => Is.EqualTo(new Vector2(200, 200)));
+            AddAssert("second object rotated 90deg around selection centre",
+                () => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(1).Position, () => Is.EqualTo(new Vector2(100, 100)));
+
+            PreciseRotationPopover? getPopover() => this.ChildrenOfType<PreciseRotationPopover>().SingleOrDefault();
+        }
+    }
+}

From 59abb59ee862cc83145e7af2efb6f2e17dfcb407 Mon Sep 17 00:00:00 2001
From: Krzysztof Gutkowski <krzysio.gutkowski@gmail.com>
Date: Thu, 17 Aug 2023 00:49:48 +0200
Subject: [PATCH 47/61] Set correct date added value when importing stable
 beatmapsets

---
 .../Database/LegacyBeatmapImporterTest.cs     | 31 ++++++++++++++-
 osu.Game/Beatmaps/BeatmapImporter.cs          | 38 ++++++++++++++++++-
 .../Archives/LegacyDirectoryArchiveReader.cs  |  4 +-
 3 files changed, 70 insertions(+), 3 deletions(-)

diff --git a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs
index b237556d11..638ad94472 100644
--- a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs
+++ b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs
@@ -1,19 +1,22 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
+using System;
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
 using NUnit.Framework;
 using osu.Framework.Platform;
 using osu.Framework.Testing;
+using osu.Game.Beatmaps;
 using osu.Game.Database;
 using osu.Game.IO;
+using osu.Game.Tests.Resources;
 
 namespace osu.Game.Tests.Database
 {
     [TestFixture]
-    public class LegacyBeatmapImporterTest
+    public class LegacyBeatmapImporterTest : RealmTest
     {
         private readonly TestLegacyBeatmapImporter importer = new TestLegacyBeatmapImporter();
 
@@ -60,6 +63,32 @@ namespace osu.Game.Tests.Database
             }
         }
 
+        [Test]
+        public void TestStableDateAddedApplied()
+        {
+            RunTestWithRealmAsync(async (realm, storage) =>
+            {
+                using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
+                using (var tmpStorage = new TemporaryNativeStorage("stable-songs-folder"))
+                {
+                    var stableStorage = new StableStorage(tmpStorage.GetFullPath(""), host);
+                    var songsStorage = stableStorage.GetStorageForDirectory(StableStorage.STABLE_DEFAULT_SONGS_PATH);
+
+                    System.IO.Compression.ZipFile.ExtractToDirectory(TestResources.GetQuickTestBeatmapForImport(), songsStorage.GetFullPath("renatus"));
+
+                    string[] beatmaps = Directory.GetFiles(songsStorage.GetFullPath("renatus"), "*.osu", SearchOption.TopDirectoryOnly);
+
+                    File.SetLastWriteTimeUtc(beatmaps[beatmaps.Length / 2], new DateTime(2000, 1, 1, 12, 0, 0));
+
+                    await new LegacyBeatmapImporter(new BeatmapImporter(storage, realm)).ImportFromStableAsync(stableStorage);
+
+                    var set = realm.Realm.All<BeatmapSetInfo>();
+
+                    Assert.AreEqual(new DateTimeOffset(new DateTime(2000, 1, 1, 12, 0, 0, DateTimeKind.Utc)), set.First().DateAdded);
+                }
+            });
+        }
+
         private class TestLegacyBeatmapImporter : LegacyBeatmapImporter
         {
             public TestLegacyBeatmapImporter()
diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs
index c840b4fa94..6c3f393069 100644
--- a/osu.Game/Beatmaps/BeatmapImporter.cs
+++ b/osu.Game/Beatmaps/BeatmapImporter.cs
@@ -302,14 +302,50 @@ namespace osu.Game.Beatmaps
                 beatmap = Decoder.GetDecoder<Beatmap>(stream).Decode(stream);
             }
 
+            var dateAdded = DateTimeOffset.UtcNow;
+
+            // Apply proper date added for a beatmapset when importing from stable.
+            // Stable tracks said date using the filesystem last modified date on the .osu file.
+            if (reader is LegacyDirectoryArchiveReader legacyReader)
+            {
+                dateAdded = determineDateAdded(legacyReader);
+            }
+
             return new BeatmapSetInfo
             {
                 OnlineID = beatmap.BeatmapInfo.BeatmapSet?.OnlineID ?? -1,
                 // Metadata = beatmap.Metadata,
-                DateAdded = DateTimeOffset.UtcNow
+                DateAdded = dateAdded
             };
         }
 
+        /// <summary>
+        /// Used for beatmapsets in legacy (stable) storage.
+        /// Determine the date a given beatmapset has been added to the game.
+        /// The specific date is determined based on the oldest `.osu` file existing
+        /// in the beatmapset directory.
+        /// </summary>
+        /// <param name="reader"></param>
+        /// <returns></returns>
+        private DateTimeOffset determineDateAdded(LegacyDirectoryArchiveReader reader)
+        {
+            var beatmaps = reader.Filenames.Where(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase));
+
+            var dateAdded = File.GetLastWriteTimeUtc(reader.GetPath(beatmaps.First()));
+
+            foreach (string beatmapName in beatmaps)
+            {
+                var currentDateAdded = File.GetLastWriteTimeUtc(reader.GetPath(beatmapName));
+
+                if (currentDateAdded < dateAdded)
+                {
+                    dateAdded = currentDateAdded;
+                }
+            }
+
+            return new DateTimeOffset(dateAdded);
+        }
+
         /// <summary>
         /// Create all required <see cref="BeatmapInfo"/>s for the provided archive.
         /// </summary>
diff --git a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs
index dfae58aed7..8a576e33d8 100644
--- a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs
+++ b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs
@@ -21,7 +21,9 @@ namespace osu.Game.IO.Archives
             this.path = Path.GetFullPath(path);
         }
 
-        public override Stream GetStream(string name) => File.OpenRead(Path.Combine(path, name));
+        public override Stream GetStream(string name) => File.OpenRead(GetPath(name));
+
+        public string GetPath(string name) => Path.Combine(path, name);
 
         public override void Dispose()
         {

From 046cc62db2b3c58096a3a9697c4f6a3ed76a232f Mon Sep 17 00:00:00 2001
From: Krzysztof Gutkowski <krzysio.gutkowski@gmail.com>
Date: Thu, 17 Aug 2023 01:11:09 +0200
Subject: [PATCH 48/61] Cleanup tests

---
 osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs
index 638ad94472..d6056fa71b 100644
--- a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs
+++ b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs
@@ -82,9 +82,10 @@ namespace osu.Game.Tests.Database
 
                     await new LegacyBeatmapImporter(new BeatmapImporter(storage, realm)).ImportFromStableAsync(stableStorage);
 
-                    var set = realm.Realm.All<BeatmapSetInfo>();
+                    var beatmapset = realm.Realm.All<BeatmapSetInfo>().First();
 
-                    Assert.AreEqual(new DateTimeOffset(new DateTime(2000, 1, 1, 12, 0, 0, DateTimeKind.Utc)), set.First().DateAdded);
+                    Assert.NotNull(beatmapset);
+                    Assert.AreEqual(new DateTimeOffset(new DateTime(2000, 1, 1, 12, 0, 0, DateTimeKind.Utc)), beatmapset.DateAdded);
                 }
             });
         }

From e2cb0d7afb1223eb8b0f85bdd585a8211a431fb3 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 17 Aug 2023 17:59:22 +0900
Subject: [PATCH 49/61] 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 d64855e5c1..8ebfde8047 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -10,7 +10,7 @@
     <EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
   </PropertyGroup>
   <ItemGroup>
-    <PackageReference Include="ppy.osu.Framework.Android" Version="2023.815.0" />
+    <PackageReference Include="ppy.osu.Framework.Android" Version="2023.817.0" />
   </ItemGroup>
   <PropertyGroup>
     <!-- Fody does not handle Android build well, and warns when unchanged.
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index a5a9387e36..7d4a721c91 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="11.1.2" />
-    <PackageReference Include="ppy.osu.Framework" Version="2023.815.0" />
+    <PackageReference Include="ppy.osu.Framework" Version="2023.817.0" />
     <PackageReference Include="ppy.osu.Game.Resources" Version="2023.719.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 d93dfaf67c..fd1ba0f6d1 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -23,6 +23,6 @@
     <RuntimeIdentifier>iossimulator-x64</RuntimeIdentifier>
   </PropertyGroup>
   <ItemGroup>
-    <PackageReference Include="ppy.osu.Framework.iOS" Version="2023.815.0" />
+    <PackageReference Include="ppy.osu.Framework.iOS" Version="2023.817.0" />
   </ItemGroup>
 </Project>

From 0858bef66e8adf93bbd4a220232a0c2505a0e948 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 17 Aug 2023 18:12:04 +0900
Subject: [PATCH 50/61] Update `SmokeSegment` in line with framework vertex
 changes

---
 osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs
index 9d64c354e2..d818c8baee 100644
--- a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs
@@ -257,7 +257,7 @@ namespace osu.Game.Rulesets.Osu.Skinning
                 texture.Bind();
 
                 for (int i = 0; i < points.Count; i++)
-                    drawPointQuad(points[i], textureRect, i + firstVisiblePointIndex);
+                    drawPointQuad(renderer, points[i], textureRect, i + firstVisiblePointIndex);
 
                 UnbindTextureShader(renderer);
                 renderer.PopLocalMatrix();
@@ -325,7 +325,7 @@ namespace osu.Game.Rulesets.Osu.Skinning
 
             private float getRotation(int index) => max_rotation * (StatelessRNG.NextSingle(rotationSeed, index) * 2 - 1);
 
-            private void drawPointQuad(SmokePoint point, RectangleF textureRect, int index)
+            private void drawPointQuad(IRenderer renderer, SmokePoint point, RectangleF textureRect, int index)
             {
                 Debug.Assert(quadBatch != null);
 
@@ -347,25 +347,25 @@ namespace osu.Game.Rulesets.Osu.Skinning
                 var localBotLeft = point.Position + ortho - dir;
                 var localBotRight = point.Position + ortho + dir;
 
-                quadBatch.Add(new TexturedVertex2D
+                quadBatch.Add(new TexturedVertex2D(renderer)
                 {
                     Position = localTopLeft,
                     TexturePosition = textureRect.TopLeft,
                     Colour = Color4Extensions.Multiply(ColourAtPosition(localTopLeft), colour),
                 });
-                quadBatch.Add(new TexturedVertex2D
+                quadBatch.Add(new TexturedVertex2D(renderer)
                 {
                     Position = localTopRight,
                     TexturePosition = textureRect.TopRight,
                     Colour = Color4Extensions.Multiply(ColourAtPosition(localTopRight), colour),
                 });
-                quadBatch.Add(new TexturedVertex2D
+                quadBatch.Add(new TexturedVertex2D(renderer)
                 {
                     Position = localBotRight,
                     TexturePosition = textureRect.BottomRight,
                     Colour = Color4Extensions.Multiply(ColourAtPosition(localBotRight), colour),
                 });
-                quadBatch.Add(new TexturedVertex2D
+                quadBatch.Add(new TexturedVertex2D(renderer)
                 {
                     Position = localBotLeft,
                     TexturePosition = textureRect.BottomLeft,

From 63080d049f254c2051f47de6c3e77b5f3170eec6 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 17 Aug 2023 19:08:18 +0900
Subject: [PATCH 51/61] Adjust icons slightly

---
 osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
index 0bc7e72751..86112d9751 100644
--- a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
+++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
@@ -61,7 +61,7 @@ namespace osu.Game.Rulesets.Osu.Edit
                                 () => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
                             new RadioButton("Selection centre",
                                 () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.SelectionCentre },
-                                () => new SpriteIcon { Icon = FontAwesome.Solid.ObjectGroup })
+                                () => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare })
                         }
                     }
                 }

From 360f9750e1c3311928c57198d77c82b02ae4cfde Mon Sep 17 00:00:00 2001
From: OliBomby <olivierschipper@gmail.com>
Date: Thu, 17 Aug 2023 16:30:18 +0200
Subject: [PATCH 52/61] Allow selecting empty control point groups

---
 osu.Game/Screens/Edit/Timing/ControlPointList.cs | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/osu.Game/Screens/Edit/Timing/ControlPointList.cs b/osu.Game/Screens/Edit/Timing/ControlPointList.cs
index 555c36aac0..64e2d5c1a8 100644
--- a/osu.Game/Screens/Edit/Timing/ControlPointList.cs
+++ b/osu.Game/Screens/Edit/Timing/ControlPointList.cs
@@ -147,6 +147,10 @@ namespace osu.Game.Screens.Edit.Timing
                 trackedType = null;
             else
             {
+                // If the selected group has no control points, clear the tracked type.
+                // Otherwise the user will be unable to select a group with no control points.
+                if (selectedGroup.Value.ControlPoints.Count == 0)
+                    trackedType = null;
                 // If the selected group only has one control point, update the tracking type.
                 if (selectedGroup.Value.ControlPoints.Count == 1)
                     trackedType = selectedGroup.Value?.ControlPoints.Single().GetType();

From 9023059bc0cbbbe7caaa6e7fe8dd7be913f478e8 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 18 Aug 2023 17:27:09 +0900
Subject: [PATCH 53/61] Convert to switch statement

---
 .../Screens/Edit/Timing/ControlPointList.cs   | 30 ++++++++++++-------
 1 file changed, 19 insertions(+), 11 deletions(-)

diff --git a/osu.Game/Screens/Edit/Timing/ControlPointList.cs b/osu.Game/Screens/Edit/Timing/ControlPointList.cs
index 64e2d5c1a8..22e37b9efb 100644
--- a/osu.Game/Screens/Edit/Timing/ControlPointList.cs
+++ b/osu.Game/Screens/Edit/Timing/ControlPointList.cs
@@ -147,17 +147,25 @@ namespace osu.Game.Screens.Edit.Timing
                 trackedType = null;
             else
             {
-                // If the selected group has no control points, clear the tracked type.
-                // Otherwise the user will be unable to select a group with no control points.
-                if (selectedGroup.Value.ControlPoints.Count == 0)
-                    trackedType = null;
-                // If the selected group only has one control point, update the tracking type.
-                if (selectedGroup.Value.ControlPoints.Count == 1)
-                    trackedType = selectedGroup.Value?.ControlPoints.Single().GetType();
-                // If the selected group has more than one control point, choose the first as the tracking type
-                // if we don't already have a singular tracked type.
-                else if (trackedType == null)
-                    trackedType = selectedGroup.Value?.ControlPoints.FirstOrDefault()?.GetType();
+                switch (selectedGroup.Value.ControlPoints.Count)
+                {
+                    // If the selected group has no control points, clear the tracked type.
+                    // Otherwise the user will be unable to select a group with no control points.
+                    case 0:
+                        trackedType = null;
+                        break;
+
+                    // If the selected group only has one control point, update the tracking type.
+                    case 1:
+                        trackedType = selectedGroup.Value?.ControlPoints.Single().GetType();
+                        break;
+
+                    // If the selected group has more than one control point, choose the first as the tracking type
+                    // if we don't already have a singular tracked type.
+                    default:
+                        trackedType ??= selectedGroup.Value?.ControlPoints.FirstOrDefault()?.GetType();
+                        break;
+                }
             }
 
             if (trackedType != null)

From eb2460d18014d3a14d0b2001be69b1bc778cfd5e Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 18 Aug 2023 17:40:00 +0900
Subject: [PATCH 54/61] Remove dead metadata transfer code

---
 osu.Game/Beatmaps/BeatmapImporter.cs | 1 -
 1 file changed, 1 deletion(-)

diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs
index 6c3f393069..21a694b3ba 100644
--- a/osu.Game/Beatmaps/BeatmapImporter.cs
+++ b/osu.Game/Beatmaps/BeatmapImporter.cs
@@ -314,7 +314,6 @@ namespace osu.Game.Beatmaps
             return new BeatmapSetInfo
             {
                 OnlineID = beatmap.BeatmapInfo.BeatmapSet?.OnlineID ?? -1,
-                // Metadata = beatmap.Metadata,
                 DateAdded = dateAdded
             };
         }

From 864f1bdb3e6781dd8c947f517d830610c5a376b4 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 18 Aug 2023 17:54:39 +0900
Subject: [PATCH 55/61] Move population of import time to the `Populate` method

Feels like a better place to be doing this. I think we want to keep any
kind of potentially expensive work in the `Populate` method.
---
 osu.Game/Beatmaps/BeatmapImporter.cs | 40 +++++++++++-----------------
 1 file changed, 16 insertions(+), 24 deletions(-)

diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs
index 21a694b3ba..7e82516bed 100644
--- a/osu.Game/Beatmaps/BeatmapImporter.cs
+++ b/osu.Game/Beatmaps/BeatmapImporter.cs
@@ -152,6 +152,8 @@ namespace osu.Game.Beatmaps
             if (archive != null)
                 beatmapSet.Beatmaps.AddRange(createBeatmapDifficulties(beatmapSet, realm));
 
+            beatmapSet.DateAdded = getDateAdded(archive);
+
             foreach (BeatmapInfo b in beatmapSet.Beatmaps)
             {
                 b.BeatmapSet = beatmapSet;
@@ -302,47 +304,37 @@ namespace osu.Game.Beatmaps
                 beatmap = Decoder.GetDecoder<Beatmap>(stream).Decode(stream);
             }
 
-            var dateAdded = DateTimeOffset.UtcNow;
-
-            // Apply proper date added for a beatmapset when importing from stable.
-            // Stable tracks said date using the filesystem last modified date on the .osu file.
-            if (reader is LegacyDirectoryArchiveReader legacyReader)
-            {
-                dateAdded = determineDateAdded(legacyReader);
-            }
-
             return new BeatmapSetInfo
             {
                 OnlineID = beatmap.BeatmapInfo.BeatmapSet?.OnlineID ?? -1,
-                DateAdded = dateAdded
             };
         }
 
         /// <summary>
-        /// Used for beatmapsets in legacy (stable) storage.
         /// Determine the date a given beatmapset has been added to the game.
-        /// The specific date is determined based on the oldest `.osu` file existing
-        /// in the beatmapset directory.
+        /// For legacy imports, we can use the oldest file write time for any `.osu` file in the directory.
+        /// For any other import types, use "now".
         /// </summary>
-        /// <param name="reader"></param>
-        /// <returns></returns>
-        private DateTimeOffset determineDateAdded(LegacyDirectoryArchiveReader reader)
+        private DateTimeOffset getDateAdded(ArchiveReader? reader)
         {
-            var beatmaps = reader.Filenames.Where(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase));
+            DateTimeOffset dateAdded = DateTimeOffset.UtcNow;
 
-            var dateAdded = File.GetLastWriteTimeUtc(reader.GetPath(beatmaps.First()));
-
-            foreach (string beatmapName in beatmaps)
+            if (reader is LegacyDirectoryArchiveReader legacyReader)
             {
-                var currentDateAdded = File.GetLastWriteTimeUtc(reader.GetPath(beatmapName));
+                var beatmaps = reader.Filenames.Where(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase));
 
-                if (currentDateAdded < dateAdded)
+                dateAdded = File.GetLastWriteTimeUtc(legacyReader.GetPath(beatmaps.First()));
+
+                foreach (string beatmapName in beatmaps)
                 {
-                    dateAdded = currentDateAdded;
+                    var currentDateAdded = File.GetLastWriteTimeUtc(legacyReader.GetPath(beatmapName));
+
+                    if (currentDateAdded < dateAdded)
+                        dateAdded = currentDateAdded;
                 }
             }
 
-            return new DateTimeOffset(dateAdded);
+            return dateAdded;
         }
 
         /// <summary>

From 5e0b89a1a862c20a938dc3b7d1e63be87e717ff9 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 18 Aug 2023 17:56:43 +0900
Subject: [PATCH 56/61] Rename `GetPath` to `GetFullPath` to better match
 expectations

---
 osu.Game/Beatmaps/BeatmapImporter.cs                 | 4 ++--
 osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs | 4 ++--
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs
index 7e82516bed..14719da1bc 100644
--- a/osu.Game/Beatmaps/BeatmapImporter.cs
+++ b/osu.Game/Beatmaps/BeatmapImporter.cs
@@ -323,11 +323,11 @@ namespace osu.Game.Beatmaps
             {
                 var beatmaps = reader.Filenames.Where(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase));
 
-                dateAdded = File.GetLastWriteTimeUtc(legacyReader.GetPath(beatmaps.First()));
+                dateAdded = File.GetLastWriteTimeUtc(legacyReader.GetFullPath(beatmaps.First()));
 
                 foreach (string beatmapName in beatmaps)
                 {
-                    var currentDateAdded = File.GetLastWriteTimeUtc(legacyReader.GetPath(beatmapName));
+                    var currentDateAdded = File.GetLastWriteTimeUtc(legacyReader.GetFullPath(beatmapName));
 
                     if (currentDateAdded < dateAdded)
                         dateAdded = currentDateAdded;
diff --git a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs
index 8a576e33d8..1503705022 100644
--- a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs
+++ b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs
@@ -21,9 +21,9 @@ namespace osu.Game.IO.Archives
             this.path = Path.GetFullPath(path);
         }
 
-        public override Stream GetStream(string name) => File.OpenRead(GetPath(name));
+        public override Stream GetStream(string name) => File.OpenRead(GetFullPath(name));
 
-        public string GetPath(string name) => Path.Combine(path, name);
+        public string GetFullPath(string filename) => Path.Combine(path, filename);
 
         public override void Dispose()
         {

From 80a143ae85cd2e2b53213c891c6521b8457cd7a9 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 18 Aug 2023 18:00:33 +0900
Subject: [PATCH 57/61] Tidy up test a bit more

---
 osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs | 9 +++++----
 1 file changed, 5 insertions(+), 4 deletions(-)

diff --git a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs
index d6056fa71b..0144c0bf97 100644
--- a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs
+++ b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs
@@ -4,6 +4,7 @@
 using System;
 using System.Collections.Generic;
 using System.IO;
+using System.IO.Compression;
 using System.Linq;
 using NUnit.Framework;
 using osu.Framework.Platform;
@@ -74,7 +75,7 @@ namespace osu.Game.Tests.Database
                     var stableStorage = new StableStorage(tmpStorage.GetFullPath(""), host);
                     var songsStorage = stableStorage.GetStorageForDirectory(StableStorage.STABLE_DEFAULT_SONGS_PATH);
 
-                    System.IO.Compression.ZipFile.ExtractToDirectory(TestResources.GetQuickTestBeatmapForImport(), songsStorage.GetFullPath("renatus"));
+                    ZipFile.ExtractToDirectory(TestResources.GetQuickTestBeatmapForImport(), songsStorage.GetFullPath("renatus"));
 
                     string[] beatmaps = Directory.GetFiles(songsStorage.GetFullPath("renatus"), "*.osu", SearchOption.TopDirectoryOnly);
 
@@ -82,10 +83,10 @@ namespace osu.Game.Tests.Database
 
                     await new LegacyBeatmapImporter(new BeatmapImporter(storage, realm)).ImportFromStableAsync(stableStorage);
 
-                    var beatmapset = realm.Realm.All<BeatmapSetInfo>().First();
+                    var importedSet = realm.Realm.All<BeatmapSetInfo>().Single();
 
-                    Assert.NotNull(beatmapset);
-                    Assert.AreEqual(new DateTimeOffset(new DateTime(2000, 1, 1, 12, 0, 0, DateTimeKind.Utc)), beatmapset.DateAdded);
+                    Assert.NotNull(importedSet);
+                    Assert.AreEqual(new DateTimeOffset(new DateTime(2000, 1, 1, 12, 0, 0, DateTimeKind.Utc)), importedSet.DateAdded);
                 }
             });
         }

From 3d52a1267e722596ec9438a55df9d5e49643b3e8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sat, 19 Aug 2023 15:08:30 +0200
Subject: [PATCH 58/61] Fix test failures via test-local popover containers

---
 .../Editor/TestSceneOsuDistanceSnapGrid.cs                   | 5 +++--
 .../Editing/TestSceneHitObjectComposerDistanceSnapping.cs    | 3 ++-
 osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs  | 4 ++--
 3 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs
index 0c064ecfa6..9338d5453d 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs
@@ -9,6 +9,7 @@ using NUnit.Framework;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
 using osu.Framework.Graphics.Shapes;
 using osu.Framework.Graphics.UserInterface;
 using osu.Framework.Input;
@@ -70,12 +71,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
             base.Content.Children = new Drawable[]
             {
                 editorClock = new EditorClock(editorBeatmap),
-                snapProvider,
+                new PopoverContainer { Child = snapProvider },
                 Content
             };
         }
 
-        protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
+        protected override Container<Drawable> Content { get; } = new PopoverContainer { RelativeSizeAxes = Axes.Both };
 
         [SetUp]
         public void Setup() => Schedule(() =>
diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs
index 6399507aa0..e30caac95e 100644
--- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs
+++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs
@@ -6,6 +6,7 @@ using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
 using osu.Framework.Testing;
 using osu.Framework.Utils;
 using osu.Game.Beatmaps.ControlPoints;
@@ -29,7 +30,7 @@ namespace osu.Game.Tests.Editing
         [Cached(typeof(IBeatSnapProvider))]
         private readonly EditorBeatmap editorBeatmap;
 
-        protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
+        protected override Container<Drawable> Content { get; } = new PopoverContainer { RelativeSizeAxes = Axes.Both };
 
         public TestSceneHitObjectComposerDistanceSnapping()
         {
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs
index a38c481003..ed3bffe5c2 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs
@@ -8,7 +8,7 @@ using System.Linq;
 using NUnit.Framework;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
 using osu.Framework.Testing;
 using osu.Game.Beatmaps;
 using osu.Game.Beatmaps.ControlPoints;
@@ -178,7 +178,7 @@ namespace osu.Game.Tests.Visual.Editing
             AddAssert("distance spacing increased by 0.5", () => editorBeatmap.BeatmapInfo.DistanceSpacing == originalSpacing + 0.5);
         }
 
-        public partial class EditorBeatmapContainer : Container
+        public partial class EditorBeatmapContainer : PopoverContainer
         {
             private readonly IWorkingBeatmap working;
 

From 3c191ff9ea5a35f507fb85d32b44b73c37b489ab Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sat, 19 Aug 2023 15:09:59 +0200
Subject: [PATCH 59/61] Expand allowable rotation range to [-360, 360]

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

diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
index 86112d9751..f09d6b78e6 100644
--- a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
+++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
@@ -45,8 +45,8 @@ namespace osu.Game.Rulesets.Osu.Edit
                     {
                         Current = new BindableNumber<float>
                         {
-                            MinValue = -180,
-                            MaxValue = 180,
+                            MinValue = -360,
+                            MaxValue = 360,
                             Precision = 1
                         },
                         Instantaneous = true

From 249dcc2ced2e14eca2938fbd48362518edefd691 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sun, 20 Aug 2023 23:36:07 +0200
Subject: [PATCH 60/61] Bump Android target SDK version to 33

---
 osu.Android/AndroidManifest.xml                           | 2 +-
 osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml | 2 +-
 osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml | 2 +-
 osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml   | 2 +-
 osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml | 2 +-
 osu.Game.Tests.Android/AndroidManifest.xml                | 2 +-
 6 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/osu.Android/AndroidManifest.xml b/osu.Android/AndroidManifest.xml
index bc2f49b1a9..fb54c8e151 100644
--- a/osu.Android/AndroidManifest.xml
+++ b/osu.Android/AndroidManifest.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="sh.ppy.osulazer" android:installLocation="auto">
-	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="31" />
+	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
 	<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!" android:icon="@drawable/lazer" />
 </manifest>
\ No newline at end of file
diff --git a/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml
index bf7c0bfeca..52b34959b9 100644
--- a/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml
+++ b/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- using a different name because package name cannot contain 'catch' -->
 <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" package="osu.Game.Rulesets.Catch_Tests.Android" android:installLocation="auto">
-	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="31" />
+	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
 	<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!catch Test" />
 </manifest>
\ No newline at end of file
diff --git a/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml
index 4a1545a423..f5a49210ea 100644
--- a/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml
+++ b/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" package="osu.Game.Rulesets.Mania.Tests.Android" android:installLocation="auto">
-	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="31" />
+	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
 	<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!mania Test" />
 </manifest>
\ No newline at end of file
diff --git a/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml
index 45d27dda70..ed4725dd94 100644
--- a/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml
+++ b/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" package="osu.Game.Rulesets.Osu.Tests.Android" android:installLocation="auto">
-	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="31" />
+	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
 	<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!standard Test" />
 </manifest>
\ No newline at end of file
diff --git a/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml
index 452b9683ec..cc88d3080a 100644
--- a/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml
+++ b/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" package="osu.Game.Rulesets.Taiko.Tests.Android" android:installLocation="auto">
-	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="31" />
+	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
 	<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!taiko Test" />
 </manifest>
\ No newline at end of file
diff --git a/osu.Game.Tests.Android/AndroidManifest.xml b/osu.Game.Tests.Android/AndroidManifest.xml
index f25b2e5328..6f91fb928c 100644
--- a/osu.Game.Tests.Android/AndroidManifest.xml
+++ b/osu.Game.Tests.Android/AndroidManifest.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" package="osu.Game.Tests.Android" android:installLocation="auto">
-	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="31" />
+	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
 	<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!visual Test" />
 </manifest>
\ No newline at end of file

From 912f31dabc7160fbd32ccb0a47fd733e427274e0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= <dach.bartlomiej@gmail.com>
Date: Sun, 20 Aug 2023 23:37:11 +0200
Subject: [PATCH 61/61] Declare media permissions in game project for editor
 usage

---
 osu.Android/AndroidManifest.xml | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/osu.Android/AndroidManifest.xml b/osu.Android/AndroidManifest.xml
index fb54c8e151..af102a1e4e 100644
--- a/osu.Android/AndroidManifest.xml
+++ b/osu.Android/AndroidManifest.xml
@@ -2,4 +2,7 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="sh.ppy.osulazer" android:installLocation="auto">
 	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
 	<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!" android:icon="@drawable/lazer" />
+	<!-- for editor usage -->
+	<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
+	<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
 </manifest>
\ No newline at end of file