diff --git a/README.md b/README.md
index 7ace47a74f..f64240f67a 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,7 @@ Please make sure you have the following prerequisites:
 
 - A desktop platform with the [.NET 6.0 SDK](https://dotnet.microsoft.com/download) installed.
 - When developing with mobile, [Xamarin](https://docs.microsoft.com/en-us/xamarin/) is required, which is shipped together with Visual Studio or [Visual Studio for Mac](https://visualstudio.microsoft.com/vs/mac/).
-- When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as [Visual Studio 2019+](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/).
+- When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/).
 - When running on Linux, please have a system-wide FFmpeg installation available to support video decoding.
 
 ### Downloading the source code
@@ -72,7 +72,7 @@ git pull
 
 Build configurations for the recommended IDEs (listed above) are included. You should use the provided Build/Run functionality of your IDE to get things going. When testing or building new components, it's highly encouraged you use the `VisualTests` project/configuration. More information on this is provided [below](#contributing).
 
-- Visual Studio / Rider users should load the project via one of the platform-specific `.slnf` files, rather than the main `.sln.` This will allow access to template run configurations.
+- Visual Studio / Rider users should load the project via one of the platform-specific `.slnf` files, rather than the main `.sln`. This will allow access to template run configurations.
 
 You can also build and run *osu!* from the command-line with a single command:
 
diff --git a/osu.Android.props b/osu.Android.props
index 526ce959a6..5b26b8f36e 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -51,8 +51,8 @@
     <Reference Include="Java.Interop" />
   </ItemGroup>
   <ItemGroup>
-    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.211.0" />
-    <PackageReference Include="ppy.osu.Framework.Android" Version="2022.223.0" />
+    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.304.0" />
+    <PackageReference Include="ppy.osu.Framework.Android" Version="2022.304.0" />
   </ItemGroup>
   <ItemGroup Label="Transitive Dependencies">
     <!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
diff --git a/osu.Desktop/DiscordRichPresence.cs b/osu.Desktop/DiscordRichPresence.cs
index 3642f70a56..d87b25a4c7 100644
--- a/osu.Desktop/DiscordRichPresence.cs
+++ b/osu.Desktop/DiscordRichPresence.cs
@@ -10,6 +10,7 @@ using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Logging;
 using osu.Game.Configuration;
+using osu.Game.Extensions;
 using osu.Game.Online.API;
 using osu.Game.Online.API.Requests.Responses;
 using osu.Game.Rulesets;
@@ -108,10 +109,7 @@ namespace osu.Desktop
                 presence.Assets.LargeImageText = $"{user.Value.Username}" + (user.Value.Statistics?.GlobalRank > 0 ? $" (rank #{user.Value.Statistics.GlobalRank:N0})" : string.Empty);
 
             // update ruleset
-            int onlineID = ruleset.Value.OnlineID;
-            bool isLegacyRuleset = onlineID >= 0 && onlineID <= ILegacyRuleset.MAX_LEGACY_RULESET_ID;
-
-            presence.Assets.SmallImageKey = isLegacyRuleset ? $"mode_{onlineID}" : "mode_custom";
+            presence.Assets.SmallImageKey = ruleset.Value.IsLegacyRuleset() ? $"mode_{ruleset.Value.OnlineID}" : "mode_custom";
             presence.Assets.SmallImageText = ruleset.Value.Name;
 
             client.SetPresence(presence);
diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
index 36fa336d0c..bd3b8c3b10 100644
--- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
+++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
@@ -45,10 +45,5 @@ namespace osu.Game.Rulesets.Mania
                 }
             };
         }
-
-        private class TimeSlider : OsuSliderBar<double>
-        {
-            public override LocalisableString TooltipText => Current.Value.ToString(@"N0") + "ms";
-        }
     }
 }
diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs
index 88862ea28b..6457a23a1b 100644
--- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs
+++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs
@@ -1,29 +1,23 @@
 // 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 System.Threading;
 using System.Threading.Tasks;
 using NUnit.Framework;
 using osu.Framework.Allocation;
 using osu.Framework.Audio;
 using osu.Framework.Audio.Sample;
-using osu.Framework.Graphics.Audio;
 using osu.Framework.Graphics.Textures;
 using osu.Framework.IO.Stores;
 using osu.Framework.Testing;
-using osu.Framework.Utils;
 using osu.Game.Audio;
 using osu.Game.Configuration;
 using osu.Game.Database;
 using osu.Game.IO;
 using osu.Game.Rulesets;
-using osu.Game.Rulesets.Mods;
 using osu.Game.Rulesets.Osu;
-using osu.Game.Rulesets.Osu.Mods;
 using osu.Game.Rulesets.UI;
 using osu.Game.Screens.Play;
 using osu.Game.Skinning;
@@ -118,59 +112,6 @@ namespace osu.Game.Tests.Gameplay
             AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue);
         }
 
-        [TestCase(typeof(OsuModDoubleTime), 1.5)]
-        [TestCase(typeof(OsuModHalfTime), 0.75)]
-        [TestCase(typeof(ModWindUp), 1.5)]
-        [TestCase(typeof(ModWindDown), 0.75)]
-        [TestCase(typeof(OsuModDoubleTime), 2)]
-        [TestCase(typeof(OsuModHalfTime), 0.5)]
-        [TestCase(typeof(ModWindUp), 2)]
-        [TestCase(typeof(ModWindDown), 0.5)]
-        public void TestSamplePlaybackWithRateMods(Type expectedMod, double expectedRate)
-        {
-            GameplayClockContainer gameplayContainer = null;
-            StoryboardSampleInfo sampleInfo = null;
-            TestDrawableStoryboardSample sample = null;
-
-            Mod testedMod = Activator.CreateInstance(expectedMod) as Mod;
-
-            switch (testedMod)
-            {
-                case ModRateAdjust m:
-                    m.SpeedChange.Value = expectedRate;
-                    break;
-
-                case ModTimeRamp m:
-                    m.FinalRate.Value = m.InitialRate.Value = expectedRate;
-                    break;
-            }
-
-            AddStep("setup storyboard sample", () =>
-            {
-                Beatmap.Value = new TestCustomSkinWorkingBeatmap(new OsuRuleset().RulesetInfo, this);
-                SelectedMods.Value = new[] { testedMod };
-
-                var beatmapSkinSourceContainer = new BeatmapSkinProvidingContainer(Beatmap.Value.Skin);
-
-                Add(gameplayContainer = new MasterGameplayClockContainer(Beatmap.Value, 0)
-                {
-                    Child = beatmapSkinSourceContainer
-                });
-
-                beatmapSkinSourceContainer.Add(sample = new TestDrawableStoryboardSample(sampleInfo = new StoryboardSampleInfo("test-sample", 1, 1))
-                {
-                    Clock = gameplayContainer.GameplayClock
-                });
-            });
-
-            AddStep("start", () => gameplayContainer.Start());
-
-            AddAssert("sample playback rate matches mod rates", () =>
-                testedMod != null && Precision.AlmostEquals(
-                    sample.ChildrenOfType<DrawableSample>().First().AggregateFrequency.Value,
-                    ((IApplicableToRate)testedMod).ApplyToRate(sampleInfo.StartTime)));
-        }
-
         [Test]
         public void TestSamplePlaybackWithBeatmapHitsoundsOff()
         {
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs
index 67f5db548b..8ca49837da 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs
@@ -48,7 +48,7 @@ namespace osu.Game.Tests.Visual.Gameplay
         }
 
         [Test]
-        public void TestDisplay()
+        public void TestCalibrationFromZero()
         {
             const double average_error = -4.5;
 
@@ -62,11 +62,39 @@ namespace osu.Game.Tests.Visual.Gameplay
                 };
             });
 
-            AddAssert("Has calibration button", () => offsetControl.ChildrenOfType<SettingsButton>().Any());
+            AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType<SettingsButton>().Any());
             AddStep("Press button", () => offsetControl.ChildrenOfType<SettingsButton>().Single().TriggerClick());
             AddAssert("Offset is adjusted", () => offsetControl.Current.Value == -average_error);
 
-            AddAssert("Button is disabled", () => !offsetControl.ChildrenOfType<SettingsButton>().Single().Enabled.Value);
+            AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType<SettingsButton>().Single().Enabled.Value);
+            AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null);
+            AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
+        }
+
+        /// <summary>
+        /// When a beatmap offset was already set, the calibration should take it into account.
+        /// </summary>
+        [Test]
+        public void TestCalibrationFromNonZero()
+        {
+            const double average_error = -4.5;
+            const double initial_offset = -2;
+
+            AddStep("Set offset non-neutral", () => offsetControl.Current.Value = initial_offset);
+            AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
+            AddStep("Set reference score", () =>
+            {
+                offsetControl.ReferenceScore.Value = new ScoreInfo
+                {
+                    HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error)
+                };
+            });
+
+            AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType<SettingsButton>().Any());
+            AddStep("Press button", () => offsetControl.ChildrenOfType<SettingsButton>().Single().TriggerClick());
+            AddAssert("Offset is adjusted", () => offsetControl.Current.Value == initial_offset - average_error);
+
+            AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType<SettingsButton>().Single().Enabled.Value);
             AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null);
             AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
         }
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs
index 3b6d02c67c..014ccb1652 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs
@@ -94,7 +94,7 @@ namespace osu.Game.Tests.Visual.Gameplay
             var decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true };
             storyboardContainer.Clock = decoupledClock;
 
-            storyboard = working.Storyboard.CreateDrawable(Beatmap.Value);
+            storyboard = working.Storyboard.CreateDrawable(SelectedMods.Value);
             storyboard.Passing = false;
 
             storyboardContainer.Add(storyboard);
@@ -118,7 +118,7 @@ namespace osu.Game.Tests.Visual.Gameplay
                 sb = decoder.Decode(bfr);
             }
 
-            storyboard = sb.CreateDrawable(Beatmap.Value);
+            storyboard = sb.CreateDrawable(SelectedMods.Value);
 
             storyboardContainer.Add(storyboard);
             decoupledClock.ChangeSource(Beatmap.Value.Track);
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs
index 95603b5c04..909cab5e3d 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs
@@ -1,17 +1,23 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
+using System;
 using System.Collections.Generic;
 using System.Linq;
 using NUnit.Framework;
 using osu.Framework.Allocation;
+using osu.Framework.Extensions.ObjectExtensions;
+using osu.Framework.Graphics.Audio;
 using osu.Framework.Testing;
 using osu.Game.Beatmaps;
 using osu.Game.Configuration;
 using osu.Game.Rulesets;
+using osu.Game.Rulesets.Mods;
 using osu.Game.Rulesets.Osu;
+using osu.Game.Rulesets.Osu.Mods;
 using osu.Game.Storyboards;
 using osu.Game.Storyboards.Drawables;
+using osuTK.Input;
 
 namespace osu.Game.Tests.Visual.Gameplay
 {
@@ -19,6 +25,10 @@ namespace osu.Game.Tests.Visual.Gameplay
     {
         private Storyboard storyboard;
 
+        private IReadOnlyList<Mod> storyboardMods;
+
+        protected override bool HasCustomSteps => true;
+
         [BackgroundDependencyLoader]
         private void load(OsuConfigManager config)
         {
@@ -31,42 +41,107 @@ namespace osu.Game.Tests.Visual.Gameplay
             backgroundLayer.Add(new StoryboardSampleInfo("Intro/welcome.mp3", time: 0, volume: 20));
         }
 
+        [SetUp]
+        public void SetUp() => storyboardMods = Array.Empty<Mod>();
+
         [Test]
         public void TestStoryboardSamplesStopDuringPause()
         {
-            checkForFirstSamplePlayback();
+            createPlayerTest();
 
             AddStep("player paused", () => Player.Pause());
             AddAssert("player is currently paused", () => Player.GameplayClockContainer.IsPaused.Value);
-            AddAssert("all storyboard samples stopped immediately", () => allStoryboardSamples.All(sound => !sound.IsPlaying));
+            allStoryboardSamplesStopped();
 
             AddStep("player resume", () => Player.Resume());
-            AddUntilStep("any storyboard samples playing after resume", () => allStoryboardSamples.Any(sound => sound.IsPlaying));
+            waitUntilStoryboardSamplesPlay();
         }
 
         [Test]
         public void TestStoryboardSamplesStopOnSkip()
         {
-            checkForFirstSamplePlayback();
+            createPlayerTest();
 
-            AddStep("skip intro", () => InputManager.Key(osuTK.Input.Key.Space));
-            AddAssert("all storyboard samples stopped immediately", () => allStoryboardSamples.All(sound => !sound.IsPlaying));
+            skipIntro();
+            allStoryboardSamplesStopped();
 
-            AddUntilStep("any storyboard samples playing after skip", () => allStoryboardSamples.Any(sound => sound.IsPlaying));
+            waitUntilStoryboardSamplesPlay();
         }
 
-        private void checkForFirstSamplePlayback()
+        [TestCase(typeof(OsuModDoubleTime), 1.5)]
+        [TestCase(typeof(OsuModDoubleTime), 2)]
+        [TestCase(typeof(OsuModHalfTime), 0.75)]
+        [TestCase(typeof(OsuModHalfTime), 0.5)]
+        public void TestStoryboardSamplesPlaybackWithRateAdjustMods(Type expectedMod, double expectedRate)
         {
-            AddAssert("storyboard loaded", () => Player.Beatmap.Value.Storyboard != null);
-            AddUntilStep("any storyboard samples playing", () => allStoryboardSamples.Any(sound => sound.IsPlaying));
+            AddStep("setup mod", () =>
+            {
+                ModRateAdjust testedMod = (ModRateAdjust)Activator.CreateInstance(expectedMod).AsNonNull();
+                testedMod.SpeedChange.Value = expectedRate;
+                storyboardMods = new[] { testedMod };
+            });
+
+            createPlayerTest();
+            skipIntro();
+
+            AddAssert("sample playback rate matches mod rates", () => allStoryboardSamples.All(sound =>
+                sound.ChildrenOfType<DrawableSample>().First().AggregateFrequency.Value == expectedRate));
         }
 
+        [TestCase(typeof(ModWindUp), 0.5, 2)]
+        [TestCase(typeof(ModWindUp), 1.51, 2)]
+        [TestCase(typeof(ModWindDown), 2, 0.5)]
+        [TestCase(typeof(ModWindDown), 0.99, 0.5)]
+        public void TestStoryboardSamplesPlaybackWithTimeRampMods(Type expectedMod, double initialRate, double finalRate)
+        {
+            AddStep("setup mod", () =>
+            {
+                ModTimeRamp testedMod = (ModTimeRamp)Activator.CreateInstance(expectedMod).AsNonNull();
+                testedMod.InitialRate.Value = initialRate;
+                testedMod.FinalRate.Value = finalRate;
+                storyboardMods = new[] { testedMod };
+            });
+
+            createPlayerTest();
+            skipIntro();
+
+            ModTimeRamp gameplayMod = null;
+
+            AddUntilStep("mod speed change updated", () =>
+            {
+                gameplayMod = Player.GameplayState.Mods.OfType<ModTimeRamp>().Single();
+                return gameplayMod.SpeedChange.Value != initialRate;
+            });
+
+            AddAssert("sample playback rate matches mod rates", () => allStoryboardSamples.All(sound =>
+                sound.ChildrenOfType<DrawableSample>().First().AggregateFrequency.Value == gameplayMod.SpeedChange.Value));
+        }
+
+        private void createPlayerTest()
+        {
+            CreateTest(null);
+
+            AddAssert("storyboard loaded", () => Player.Beatmap.Value.Storyboard != null);
+            waitUntilStoryboardSamplesPlay();
+        }
+
+        private void waitUntilStoryboardSamplesPlay() => AddUntilStep("any storyboard samples playing", () => allStoryboardSamples.Any(sound => sound.IsPlaying));
+
+        private void allStoryboardSamplesStopped() => AddAssert("all storyboard samples stopped immediately", () => allStoryboardSamples.All(sound => !sound.IsPlaying));
+
+        private void skipIntro() => AddStep("skip intro", () => InputManager.Key(Key.Space));
+
         private IEnumerable<DrawableStoryboardSample> allStoryboardSamples => Player.ChildrenOfType<DrawableStoryboardSample>();
 
         protected override bool AllowFail => false;
 
+        protected override TestPlayer CreatePlayer(Ruleset ruleset)
+        {
+            SelectedMods.Value = SelectedMods.Value.Concat(storyboardMods).ToArray();
+            return new TestPlayer(true, false);
+        }
+
         protected override Ruleset CreatePlayerRuleset() => new OsuRuleset();
-        protected override TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(true, false);
 
         protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) =>
             new ClockBackedTestWorkingBeatmap(beatmap, storyboard ?? this.storyboard, Clock, Audio);
diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs
index 779d72190d..a21647712d 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs
@@ -9,7 +9,6 @@ using System;
 using System.Linq;
 using NUnit.Framework;
 using osu.Framework.Graphics.Containers;
-using osu.Game.Graphics.Containers;
 using osu.Game.Online.API.Requests.Responses;
 using osu.Game.Overlays.Chat;
 using osuTK.Input;
@@ -207,7 +206,28 @@ namespace osu.Game.Tests.Visual.Online
         }
 
         [Test]
-        public void TestUserScrollOverride()
+        public void TestOverrideChatScrolling()
+        {
+            fillChat();
+
+            sendMessage();
+            checkScrolledToBottom();
+
+            AddStep("Scroll to start", () => chatDisplay.ScrollContainer.ScrollToStart());
+
+            checkNotScrolledToBottom();
+            sendMessage();
+            checkNotScrolledToBottom();
+
+            AddStep("Scroll to bottom", () => chatDisplay.ScrollContainer.ScrollToEnd());
+
+            checkScrolledToBottom();
+            sendMessage();
+            checkScrolledToBottom();
+        }
+
+        [Test]
+        public void TestOverrideChatScrollingByUser()
         {
             fillChat();
 
@@ -314,9 +334,9 @@ namespace osu.Game.Tests.Visual.Online
             {
             }
 
-            protected DrawableChannel DrawableChannel => InternalChildren.OfType<DrawableChannel>().First();
+            public DrawableChannel DrawableChannel => InternalChildren.OfType<DrawableChannel>().First();
 
-            protected UserTrackingScrollContainer ScrollContainer => (UserTrackingScrollContainer)((Container)DrawableChannel.Child).Child;
+            public ChannelScrollContainer ScrollContainer => (ChannelScrollContainer)((Container)DrawableChannel.Child).Child;
 
             public FillFlowContainer FillFlow => (FillFlowContainer)ScrollContainer.Child;
 
diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs
index 221001e40b..7471b6acf2 100644
--- a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs
+++ b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs
@@ -8,6 +8,7 @@ using NUnit.Framework;
 using osu.Framework.Extensions.Color4Extensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Shapes;
+using osu.Game.Rulesets.Objects;
 using osu.Game.Rulesets.Osu.Objects;
 using osu.Game.Rulesets.Scoring;
 using osu.Game.Screens.Ranking.Statistics;
@@ -17,22 +18,33 @@ namespace osu.Game.Tests.Visual.Ranking
 {
     public class TestSceneHitEventTimingDistributionGraph : OsuTestScene
     {
+        private HitEventTimingDistributionGraph graph;
+
+        private static readonly HitObject placeholder_object = new HitCircle();
+
         [Test]
         public void TestManyDistributedEvents()
         {
             createTest(CreateDistributedHitEvents());
+            AddStep("add adjustment", () => graph.UpdateOffset(10));
+        }
+
+        [Test]
+        public void TestManyDistributedEventsOffset()
+        {
+            createTest(CreateDistributedHitEvents(-3.5));
         }
 
         [Test]
         public void TestAroundCentre()
         {
-            createTest(Enumerable.Range(-150, 300).Select(i => new HitEvent(i / 50f, HitResult.Perfect, new HitCircle(), new HitCircle(), null)).ToList());
+            createTest(Enumerable.Range(-150, 300).Select(i => new HitEvent(i / 50f, HitResult.Perfect, placeholder_object, placeholder_object, null)).ToList());
         }
 
         [Test]
         public void TestZeroTimeOffset()
         {
-            createTest(Enumerable.Range(0, 100).Select(_ => new HitEvent(0, HitResult.Perfect, new HitCircle(), new HitCircle(), null)).ToList());
+            createTest(Enumerable.Range(0, 100).Select(_ => new HitEvent(0, HitResult.Perfect, placeholder_object, placeholder_object, null)).ToList());
         }
 
         [Test]
@@ -47,9 +59,9 @@ namespace osu.Game.Tests.Visual.Ranking
             createTest(Enumerable.Range(0, 100).Select(i =>
             {
                 if (i % 2 == 0)
-                    return new HitEvent(0, HitResult.Perfect, new HitCircle(), new HitCircle(), null);
+                    return new HitEvent(0, HitResult.Perfect, placeholder_object, placeholder_object, null);
 
-                return new HitEvent(30, HitResult.Miss, new HitCircle(), new HitCircle(), null);
+                return new HitEvent(30, HitResult.Miss, placeholder_object, placeholder_object, null);
             }).ToList());
         }
 
@@ -62,7 +74,7 @@ namespace osu.Game.Tests.Visual.Ranking
                     RelativeSizeAxes = Axes.Both,
                     Colour = Color4Extensions.FromHex("#333")
                 },
-                new HitEventTimingDistributionGraph(events)
+                graph = new HitEventTimingDistributionGraph(events)
                 {
                     Anchor = Anchor.Centre,
                     Origin = Anchor.Centre,
@@ -77,10 +89,10 @@ namespace osu.Game.Tests.Visual.Ranking
 
             for (int i = 0; i < range * 2; i++)
             {
-                int count = (int)(Math.Pow(range - Math.Abs(i - range), 2));
+                int count = (int)(Math.Pow(range - Math.Abs(i - range), 2)) / 10;
 
                 for (int j = 0; j < count; j++)
-                    hitEvents.Add(new HitEvent(centre + i - range, HitResult.Perfect, new HitCircle(), new HitCircle(), null));
+                    hitEvents.Add(new HitEvent(centre + i - range, HitResult.Perfect, placeholder_object, placeholder_object, null));
             }
 
             return hitEvents;
diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs
index 31bd3a203c..1ed6648131 100644
--- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs
+++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs
@@ -119,7 +119,8 @@ namespace osu.Game.Tests.Visual.SongSelect
             AddStep(@"Network failure", () => leaderboard.SetErrorState(LeaderboardState.NetworkFailure));
             AddStep(@"No supporter", () => leaderboard.SetErrorState(LeaderboardState.NotSupporter));
             AddStep(@"Not logged in", () => leaderboard.SetErrorState(LeaderboardState.NotLoggedIn));
-            AddStep(@"Unavailable", () => leaderboard.SetErrorState(LeaderboardState.Unavailable));
+            AddStep(@"Ruleset unavailable", () => leaderboard.SetErrorState(LeaderboardState.RulesetUnavailable));
+            AddStep(@"Beatmap unavailable", () => leaderboard.SetErrorState(LeaderboardState.BeatmapUnavailable));
             AddStep(@"None selected", () => leaderboard.SetErrorState(LeaderboardState.NoneSelected));
         }
 
diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePopupScreenTitle.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePopupScreenTitle.cs
new file mode 100644
index 0000000000..22a8fa8a46
--- /dev/null
+++ b/osu.Game.Tests/Visual/UserInterface/TestScenePopupScreenTitle.cs
@@ -0,0 +1,45 @@
+// 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.Allocation;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Overlays;
+
+namespace osu.Game.Tests.Visual.UserInterface
+{
+    [TestFixture]
+    public class TestScenePopupScreenTitle : OsuTestScene
+    {
+        [Cached]
+        private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
+
+        [Test]
+        public void TestPopupScreenTitle()
+        {
+            AddStep("create content", () =>
+            {
+                Child = new PopupScreenTitle
+                {
+                    Title = "Popup Screen Title",
+                    Description = string.Join(" ", Enumerable.Repeat("This is a description.", 20)),
+                    Close = () => { }
+                };
+            });
+        }
+
+        [Test]
+        public void TestDisabledExit()
+        {
+            AddStep("create content", () =>
+            {
+                Child = new PopupScreenTitle
+                {
+                    Title = "Popup Screen Title",
+                    Description = "This is a description."
+                };
+            });
+        }
+    }
+}
diff --git a/osu.Game/Beatmaps/DifficultyRecommender.cs b/osu.Game/Beatmaps/DifficultyRecommender.cs
index 3949e84f4a..93c2fccbc7 100644
--- a/osu.Game/Beatmaps/DifficultyRecommender.cs
+++ b/osu.Game/Beatmaps/DifficultyRecommender.cs
@@ -9,6 +9,7 @@ using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Extensions.IEnumerableExtensions;
 using osu.Framework.Graphics;
+using osu.Game.Extensions;
 using osu.Game.Online.API;
 using osu.Game.Online.API.Requests;
 using osu.Game.Rulesets;
@@ -83,7 +84,7 @@ namespace osu.Game.Beatmaps
             requestedUserId = api.LocalUser.Value.Id;
 
             // only query API for built-in rulesets
-            rulesets.AvailableRulesets.Where(ruleset => ruleset.OnlineID >= 0 && ruleset.OnlineID <= ILegacyRuleset.MAX_LEGACY_RULESET_ID).ForEach(rulesetInfo =>
+            rulesets.AvailableRulesets.Where(ruleset => ruleset.IsLegacyRuleset()).ForEach(rulesetInfo =>
             {
                 var req = new GetUserRequest(api.LocalUser.Value.Id, rulesetInfo);
 
diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs
index 07d2026c65..c279ce1220 100644
--- a/osu.Game/Configuration/OsuConfigManager.cs
+++ b/osu.Game/Configuration/OsuConfigManager.cs
@@ -140,7 +140,7 @@ namespace osu.Game.Configuration
 
             SetDefault(OsuSetting.UIScale, 1f, 0.8f, 1.6f, 0.01f);
 
-            SetDefault(OsuSetting.UIHoldActivationDelay, 200f, 0f, 500f, 50f);
+            SetDefault(OsuSetting.UIHoldActivationDelay, 200.0, 0.0, 500.0, 50.0);
 
             SetDefault(OsuSetting.IntroSequence, IntroSequence.Triangles);
 
@@ -240,9 +240,9 @@ namespace osu.Game.Configuration
             };
         }
 
-        public Func<Guid, string> LookupSkinName { private get; set; }
+        public Func<Guid, string> LookupSkinName { private get; set; } = _ => @"unknown";
 
-        public Func<GlobalAction, LocalisableString> LookupKeyBindings { get; set; }
+        public Func<GlobalAction, LocalisableString> LookupKeyBindings { get; set; } = _ => @"unknown";
     }
 
     // IMPORTANT: These are used in user configuration files.
@@ -270,7 +270,13 @@ namespace osu.Game.Configuration
         MouseDisableButtons,
         MouseDisableWheel,
         ConfineMouseMode,
+
+        /// <summary>
+        /// Globally applied audio offset.
+        /// This is added to the audio track's current time. Higher values will cause gameplay to occur earlier, relative to the audio track.
+        /// </summary>
         AudioOffset,
+
         VolumeInactive,
         MenuMusic,
         MenuVoice,
diff --git a/osu.Game/Extensions/ModelExtensions.cs b/osu.Game/Extensions/ModelExtensions.cs
index f178a5c97b..13c25e45c8 100644
--- a/osu.Game/Extensions/ModelExtensions.cs
+++ b/osu.Game/Extensions/ModelExtensions.cs
@@ -72,6 +72,11 @@ namespace osu.Game.Extensions
             return result;
         }
 
+        /// <summary>
+        /// Check whether this <see cref="IRulesetInfo"/>'s online ID is within the range that defines it as a legacy ruleset (ie. either osu!, osu!taiko, osu!catch or osu!mania).
+        /// </summary>
+        public static bool IsLegacyRuleset(this IRulesetInfo ruleset) => ruleset.OnlineID >= 0 && ruleset.OnlineID <= ILegacyRuleset.MAX_LEGACY_RULESET_ID;
+
         /// <summary>
         /// Check whether the online ID of two <see cref="IBeatmapSetInfo"/>s match.
         /// </summary>
diff --git a/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs b/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs
index 56ef87c1f4..7aed442800 100644
--- a/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs
+++ b/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs
@@ -3,12 +3,15 @@
 
 #nullable enable
 
+using System.Collections.Generic;
 using osu.Framework.Allocation;
+using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Timing;
 using osu.Game.Beatmaps;
 using osu.Game.Overlays;
+using osu.Game.Rulesets.Mods;
 using osu.Game.Storyboards.Drawables;
 
 namespace osu.Game.Graphics.Backgrounds
@@ -20,6 +23,9 @@ namespace osu.Game.Graphics.Backgrounds
         [Resolved(CanBeNull = true)]
         private MusicController? musicController { get; set; }
 
+        [Resolved]
+        private IBindable<IReadOnlyList<Mod>> mods { get; set; } = null!;
+
         public BeatmapBackgroundWithStoryboard(WorkingBeatmap beatmap, string fallbackTextureName = "Backgrounds/bg1")
             : base(beatmap, fallbackTextureName)
         {
@@ -39,7 +45,7 @@ namespace osu.Game.Graphics.Backgrounds
             {
                 RelativeSizeAxes = Axes.Both,
                 Volume = { Value = 0 },
-                Child = new DrawableStoryboard(Beatmap.Storyboard) { Clock = storyboardClock }
+                Child = new DrawableStoryboard(Beatmap.Storyboard, mods.Value) { Clock = storyboardClock }
             }, AddInternal);
         }
 
diff --git a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs
index fcf445a878..999dd183aa 100644
--- a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs
+++ b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs
@@ -30,12 +30,12 @@ namespace osu.Game.Graphics.Containers
 
         public Bindable<double> Progress = new BindableDouble();
 
-        private Bindable<float> holdActivationDelay;
+        private Bindable<double> holdActivationDelay;
 
         [BackgroundDependencyLoader]
         private void load(OsuConfigManager config)
         {
-            holdActivationDelay = config.GetBindable<float>(OsuSetting.UIHoldActivationDelay);
+            holdActivationDelay = config.GetBindable<double>(OsuSetting.UIHoldActivationDelay);
         }
 
         protected void BeginConfirm()
diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs
index 0d543bdbc8..d331b818a1 100644
--- a/osu.Game/Graphics/Containers/ScalingContainer.cs
+++ b/osu.Game/Graphics/Containers/ScalingContainer.cs
@@ -5,6 +5,7 @@ using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Primitives;
 using osu.Framework.Screens;
 using osu.Game.Configuration;
 using osu.Game.Screens;
@@ -38,24 +39,24 @@ namespace osu.Game.Graphics.Containers
 
         private BackgroundScreenStack backgroundStack;
 
-        private bool allowScaling = true;
+        private RectangleF? customRect;
+        private bool customRectIsRelativePosition;
 
         /// <summary>
-        /// Whether user scaling preferences should be applied. Enabled by default.
+        /// Set a custom position and scale which overrides any user specification.
         /// </summary>
-        public bool AllowScaling
+        /// <param name="rect">A rectangle with positional and sizing information for this container to conform to. <c>null</c> will clear the custom rect and revert to user settings.</param>
+        /// <param name="relativePosition">Whether the position portion of the provided rect is in relative coordinate space or not.</param>
+        public void SetCustomRect(RectangleF? rect, bool relativePosition = false)
         {
-            get => allowScaling;
-            set
-            {
-                if (value == allowScaling)
-                    return;
+            customRect = rect;
+            customRectIsRelativePosition = relativePosition;
 
-                allowScaling = value;
-                if (IsLoaded) Scheduler.AddOnce(updateSize);
-            }
+            if (IsLoaded) Scheduler.AddOnce(updateSize);
         }
 
+        private const float corner_radius = 10;
+
         /// <summary>
         /// Create a new instance.
         /// </summary>
@@ -69,7 +70,7 @@ namespace osu.Game.Graphics.Containers
             {
                 RelativeSizeAxes = Axes.Both,
                 RelativePositionAxes = Axes.Both,
-                CornerRadius = 10,
+                CornerRadius = corner_radius,
                 Child = content = new ScalingDrawSizePreservingFillContainer(targetMode != ScalingMode.Gameplay)
             };
         }
@@ -137,7 +138,7 @@ namespace osu.Game.Graphics.Containers
 
         private void updateSize()
         {
-            const float fade_time = 500;
+            const float duration = 500;
 
             if (targetMode == ScalingMode.Everything)
             {
@@ -156,17 +157,31 @@ namespace osu.Game.Graphics.Containers
                         backgroundStack.Push(new ScalingBackgroundScreen());
                     }
 
-                    backgroundStack.FadeIn(fade_time);
+                    backgroundStack.FadeIn(duration);
                 }
                 else
-                    backgroundStack?.FadeOut(fade_time);
+                    backgroundStack?.FadeOut(duration);
             }
 
-            bool scaling = AllowScaling && (targetMode == null || scalingMode.Value == targetMode);
+            RectangleF targetRect = new RectangleF(Vector2.Zero, Vector2.One);
 
-            var targetSize = scaling ? new Vector2(sizeX.Value, sizeY.Value) : Vector2.One;
-            var targetPosition = scaling ? new Vector2(posX.Value, posY.Value) * (Vector2.One - targetSize) : Vector2.Zero;
-            bool requiresMasking = (scaling && targetSize != Vector2.One)
+            if (customRect != null)
+            {
+                sizableContainer.RelativePositionAxes = customRectIsRelativePosition ? Axes.Both : Axes.None;
+
+                targetRect = customRect.Value;
+            }
+            else if (targetMode == null || scalingMode.Value == targetMode)
+            {
+                sizableContainer.RelativePositionAxes = Axes.Both;
+
+                Vector2 scale = new Vector2(sizeX.Value, sizeY.Value);
+                Vector2 pos = new Vector2(posX.Value, posY.Value) * (Vector2.One - scale);
+
+                targetRect = new RectangleF(pos, scale);
+            }
+
+            bool requiresMasking = targetRect.Size != Vector2.One
                                    // For the top level scaling container, for now we apply masking if safe areas are in use.
                                    // In the future this can likely be removed as more of the actual UI supports overflowing into the safe areas.
                                    || (targetMode == ScalingMode.Everything && safeAreaPadding.Value.Total != Vector2.Zero);
@@ -174,8 +189,14 @@ namespace osu.Game.Graphics.Containers
             if (requiresMasking)
                 sizableContainer.Masking = true;
 
-            sizableContainer.MoveTo(targetPosition, 500, Easing.OutQuart);
-            sizableContainer.ResizeTo(targetSize, 500, Easing.OutQuart).OnComplete(_ => { sizableContainer.Masking = requiresMasking; });
+            sizableContainer.MoveTo(targetRect.Location, duration, Easing.OutQuart);
+            sizableContainer.ResizeTo(targetRect.Size, duration, Easing.OutQuart);
+
+            // Of note, this will not work great in the case of nested ScalingContainers where multiple are applying corner radius.
+            // Masking and corner radius should likely only be applied at one point in the full game stack to fix this.
+            // An example of how this can occur is when the skin editor is visible and the game screen scaling is set to "Everything".
+            sizableContainer.TransformTo(nameof(CornerRadius), requiresMasking ? corner_radius : 0, duration, requiresMasking ? Easing.OutQuart : Easing.None)
+                            .OnComplete(_ => { sizableContainer.Masking = requiresMasking; });
         }
 
         private class ScalingBackgroundScreen : BackgroundScreenDefault
diff --git a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs
index 0561051e35..44afaf77ea 100644
--- a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs
+++ b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs
@@ -25,8 +25,6 @@ namespace osu.Game.Graphics.Containers
         /// </summary>
         public bool UserScrolling { get; private set; }
 
-        public void CancelUserScroll() => UserScrolling = false;
-
         public UserTrackingScrollContainer()
         {
         }
diff --git a/osu.Game/Graphics/Cursor/MenuCursor.cs b/osu.Game/Graphics/Cursor/MenuCursor.cs
index 0cc751ea21..03fad00e41 100644
--- a/osu.Game/Graphics/Cursor/MenuCursor.cs
+++ b/osu.Game/Graphics/Cursor/MenuCursor.cs
@@ -140,6 +140,7 @@ namespace osu.Game.Graphics.Cursor
             // Scale to [-0.75, 0.75] so that the sample isn't fully panned left or right (sounds weird)
             channel.Balance.Value = ((activeCursor.X / DrawWidth) * 2 - 1) * 0.75;
             channel.Frequency.Value = baseFrequency - (random_range / 2f) + RNG.NextDouble(random_range);
+            channel.Volume.Value = baseFrequency;
 
             channel.Play();
         }
diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs
index 333ae4f832..21c8dfcfa4 100644
--- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs
+++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs
@@ -148,7 +148,7 @@ namespace osu.Game.Graphics.UserInterface
         protected override void LoadComplete()
         {
             base.LoadComplete();
-            CurrentNumber.BindValueChanged(current => updateTooltipText(current.NewValue), true);
+            CurrentNumber.BindValueChanged(current => TooltipText = getTooltipText(current.NewValue), true);
         }
 
         protected override bool OnHover(HoverEvent e)
@@ -178,7 +178,7 @@ namespace osu.Game.Graphics.UserInterface
         {
             base.OnUserChange(value);
             playSample(value);
-            updateTooltipText(value);
+            TooltipText = getTooltipText(value);
         }
 
         private void playSample(T value)
@@ -203,28 +203,22 @@ namespace osu.Game.Graphics.UserInterface
             channel.Play();
         }
 
-        private void updateTooltipText(T value)
+        private LocalisableString getTooltipText(T value)
         {
             if (CurrentNumber.IsInteger)
-                TooltipText = value.ToInt32(NumberFormatInfo.InvariantInfo).ToString("N0");
-            else
-            {
-                double floatValue = value.ToDouble(NumberFormatInfo.InvariantInfo);
+                return value.ToInt32(NumberFormatInfo.InvariantInfo).ToString("N0");
 
-                if (DisplayAsPercentage)
-                {
-                    TooltipText = floatValue.ToString("0%");
-                }
-                else
-                {
-                    decimal decimalPrecision = normalise(CurrentNumber.Precision.ToDecimal(NumberFormatInfo.InvariantInfo), max_decimal_digits);
+            double floatValue = value.ToDouble(NumberFormatInfo.InvariantInfo);
 
-                    // Find the number of significant digits (we could have less than 5 after normalize())
-                    int significantDigits = FormatUtils.FindPrecision(decimalPrecision);
+            if (DisplayAsPercentage)
+                return floatValue.ToString("0%");
 
-                    TooltipText = floatValue.ToString($"N{significantDigits}");
-                }
-            }
+            decimal decimalPrecision = normalise(CurrentNumber.Precision.ToDecimal(NumberFormatInfo.InvariantInfo), max_decimal_digits);
+
+            // Find the number of significant digits (we could have less than 5 after normalize())
+            int significantDigits = FormatUtils.FindPrecision(decimalPrecision);
+
+            return floatValue.ToString($"N{significantDigits}");
         }
 
         protected override void UpdateAfterChildren()
diff --git a/osu.Game/Graphics/UserInterface/PopupScreenTitle.cs b/osu.Game/Graphics/UserInterface/PopupScreenTitle.cs
new file mode 100644
index 0000000000..5b7db09e77
--- /dev/null
+++ b/osu.Game/Graphics/UserInterface/PopupScreenTitle.cs
@@ -0,0 +1,154 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable enable
+
+using System;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Colour;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Effects;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Localisation;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Overlays;
+using osuTK;
+
+namespace osu.Game.Graphics.UserInterface
+{
+    public class PopupScreenTitle : CompositeDrawable
+    {
+        public LocalisableString Title
+        {
+            set => titleSpriteText.Text = value;
+        }
+
+        public LocalisableString Description
+        {
+            set => descriptionText.Text = value;
+        }
+
+        public Action? Close
+        {
+            get => closeButton.Action;
+            set => closeButton.Action = value;
+        }
+
+        private const float corner_radius = 14;
+        private const float main_area_height = 70;
+
+        private readonly Container underlayContainer;
+        private readonly Box underlayBackground;
+        private readonly Container contentContainer;
+        private readonly Box contentBackground;
+        private readonly OsuSpriteText titleSpriteText;
+        private readonly OsuTextFlowContainer descriptionText;
+        private readonly IconButton closeButton;
+
+        public PopupScreenTitle()
+        {
+            RelativeSizeAxes = Axes.X;
+            AutoSizeAxes = Axes.Y;
+
+            InternalChild = new Container
+            {
+                RelativeSizeAxes = Axes.X,
+                AutoSizeAxes = Axes.Y,
+                Padding = new MarginPadding
+                {
+                    Horizontal = 70,
+                    Top = -corner_radius
+                },
+                Children = new Drawable[]
+                {
+                    underlayContainer = new Container
+                    {
+                        RelativeSizeAxes = Axes.X,
+                        Height = main_area_height + 2 * corner_radius,
+                        CornerRadius = corner_radius,
+                        Masking = true,
+                        BorderThickness = 2,
+                        Child = underlayBackground = new Box
+                        {
+                            RelativeSizeAxes = Axes.Both
+                        }
+                    },
+                    contentContainer = new Container
+                    {
+                        RelativeSizeAxes = Axes.X,
+                        Height = main_area_height + corner_radius,
+                        CornerRadius = corner_radius,
+                        Masking = true,
+                        BorderThickness = 2,
+                        EdgeEffect = new EdgeEffectParameters
+                        {
+                            Type = EdgeEffectType.Shadow,
+                            Colour = Colour4.Black.Opacity(0.1f),
+                            Offset = new Vector2(0, 1),
+                            Radius = 3
+                        },
+                        Children = new Drawable[]
+                        {
+                            contentBackground = new Box
+                            {
+                                RelativeSizeAxes = Axes.Both
+                            },
+                            new FillFlowContainer
+                            {
+                                RelativeSizeAxes = Axes.X,
+                                AutoSizeAxes = Axes.Y,
+                                Direction = FillDirection.Vertical,
+                                Anchor = Anchor.CentreLeft,
+                                Origin = Anchor.CentreLeft,
+                                Margin = new MarginPadding { Top = corner_radius },
+                                Padding = new MarginPadding { Horizontal = 100 },
+                                Children = new Drawable[]
+                                {
+                                    titleSpriteText = new OsuSpriteText
+                                    {
+                                        Font = OsuFont.TorusAlternate.With(size: 20)
+                                    },
+                                    descriptionText = new OsuTextFlowContainer(t =>
+                                    {
+                                        t.Font = OsuFont.Default.With(size: 12);
+                                    })
+                                    {
+                                        RelativeSizeAxes = Axes.X,
+                                        AutoSizeAxes = Axes.Y
+                                    }
+                                }
+                            },
+                            closeButton = new IconButton
+                            {
+                                Icon = FontAwesome.Solid.Times,
+                                Scale = new Vector2(0.6f),
+                                Anchor = Anchor.CentreRight,
+                                Origin = Anchor.CentreRight,
+                                Margin = new MarginPadding
+                                {
+                                    Right = 21,
+                                    Top = corner_radius
+                                }
+                            }
+                        }
+                    }
+                }
+            };
+        }
+
+        [BackgroundDependencyLoader]
+        private void load(OverlayColourProvider colourProvider)
+        {
+            underlayContainer.BorderColour = ColourInfo.GradientVertical(Colour4.Black, colourProvider.Dark4);
+            underlayBackground.Colour = colourProvider.Dark4;
+
+            contentContainer.BorderColour = ColourInfo.GradientVertical(colourProvider.Dark3, colourProvider.Dark1);
+            contentBackground.Colour = colourProvider.Dark3;
+
+            closeButton.IconHoverColour = colourProvider.Highlight1;
+        }
+    }
+}
diff --git a/osu.Game/Graphics/UserInterface/TimeSlider.cs b/osu.Game/Graphics/UserInterface/TimeSlider.cs
new file mode 100644
index 0000000000..82b02f1b48
--- /dev/null
+++ b/osu.Game/Graphics/UserInterface/TimeSlider.cs
@@ -0,0 +1,15 @@
+// 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.Localisation;
+
+namespace osu.Game.Graphics.UserInterface
+{
+    /// <summary>
+    /// A slider bar which displays a millisecond time value.
+    /// </summary>
+    public class TimeSlider : OsuSliderBar<double>
+    {
+        public override LocalisableString TooltipText => $"{Current.Value:N0} ms";
+    }
+}
diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
index 4da8d6a554..fd64cc2056 100644
--- a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
+++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
@@ -35,7 +35,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
             set => Component.Text = value;
         }
 
-        public Container TabbableContentContainer
+        public CompositeDrawable TabbableContentContainer
         {
             set => Component.TabbableContentContainer = value;
         }
diff --git a/osu.Game/Localisation/BeatmapOffsetControlStrings.cs b/osu.Game/Localisation/BeatmapOffsetControlStrings.cs
index 7b2a9e50b2..632a1ad0ea 100644
--- a/osu.Game/Localisation/BeatmapOffsetControlStrings.cs
+++ b/osu.Game/Localisation/BeatmapOffsetControlStrings.cs
@@ -29,6 +29,16 @@ namespace osu.Game.Localisation
         /// </summary>
         public static LocalisableString CalibrateUsingLastPlay => new TranslatableString(getKey(@"calibrate_using_last_play"), @"Calibrate using last play");
 
+        /// <summary>
+        /// "(hit objects appear later)"
+        /// </summary>
+        public static LocalisableString HitObjectsAppearLater => new TranslatableString(getKey(@"hit_objects_appear_later"), @"(hit objects appear later)");
+
+        /// <summary>
+        /// "(hit objects appear earlier)"
+        /// </summary>
+        public static LocalisableString HitObjectsAppearEarlier => new TranslatableString(getKey(@"hit_objects_appear_earlier"), @"(hit objects appear earlier)");
+
         private static string getKey(string key) => $@"{prefix}:{key}";
     }
-}
\ No newline at end of file
+}
diff --git a/osu.Game/Localisation/GraphicsSettingsStrings.cs b/osu.Game/Localisation/GraphicsSettingsStrings.cs
index 996a1350eb..1c9aa64df5 100644
--- a/osu.Game/Localisation/GraphicsSettingsStrings.cs
+++ b/osu.Game/Localisation/GraphicsSettingsStrings.cs
@@ -54,6 +54,11 @@ namespace osu.Game.Localisation
         /// </summary>
         public static LocalisableString Resolution => new TranslatableString(getKey(@"resolution"), @"Resolution");
 
+        /// <summary>
+        /// "Display"
+        /// </summary>
+        public static LocalisableString Display => new TranslatableString(getKey(@"display"), @"Display");
+
         /// <summary>
         /// "UI scaling"
         /// </summary>
diff --git a/osu.Game/Localisation/LeaderboardStrings.cs b/osu.Game/Localisation/LeaderboardStrings.cs
new file mode 100644
index 0000000000..8e53f8e88c
--- /dev/null
+++ b/osu.Game/Localisation/LeaderboardStrings.cs
@@ -0,0 +1,49 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Localisation;
+
+namespace osu.Game.Localisation
+{
+    public static class LeaderboardStrings
+    {
+        private const string prefix = @"osu.Game.Resources.Localisation.Leaderboard";
+
+        /// <summary>
+        /// "Couldn't fetch scores!"
+        /// </summary>
+        public static LocalisableString CouldntFetchScores => new TranslatableString(getKey(@"couldnt_fetch_scores"), @"Couldn't fetch scores!");
+
+        /// <summary>
+        /// "Please select a beatmap!"
+        /// </summary>
+        public static LocalisableString PleaseSelectABeatmap => new TranslatableString(getKey(@"please_select_a_beatmap"), @"Please select a beatmap!");
+
+        /// <summary>
+        /// "Leaderboards are not available for this ruleset!"
+        /// </summary>
+        public static LocalisableString LeaderboardsAreNotAvailableForThisRuleset => new TranslatableString(getKey(@"leaderboards_are_not_available_for_this_ruleset"), @"Leaderboards are not available for this ruleset!");
+
+        /// <summary>
+        /// "Leaderboards are not available for this beatmap!"
+        /// </summary>
+        public static LocalisableString LeaderboardsAreNotAvailableForThisBeatmap => new TranslatableString(getKey(@"leaderboards_are_not_available_for_this_beatmap"), @"Leaderboards are not available for this beatmap!");
+
+        /// <summary>
+        /// "No records yet!"
+        /// </summary>
+        public static LocalisableString NoRecordsYet => new TranslatableString(getKey(@"no_records_yet"), @"No records yet!");
+
+        /// <summary>
+        /// "Please sign in to view online leaderboards!"
+        /// </summary>
+        public static LocalisableString PleaseSignInToViewOnlineLeaderboards => new TranslatableString(getKey(@"please_sign_in_to_view_online_leaderboards"), @"Please sign in to view online leaderboards!");
+
+        /// <summary>
+        /// "Please invest in an osu!supporter tag to view this leaderboard!"
+        /// </summary>
+        public static LocalisableString PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard => new TranslatableString(getKey(@"please_invest_in_an_osu_supporter_tag_to_view_this_leaderboard"), @"Please invest in an osu!supporter tag to view this leaderboard!");
+
+        private static string getKey(string key) => $@"{prefix}:{key}";
+    }
+}
diff --git a/osu.Game/Online/API/Requests/GetWikiRequest.cs b/osu.Game/Online/API/Requests/GetWikiRequest.cs
index 248fcc03e3..09571ab0a8 100644
--- a/osu.Game/Online/API/Requests/GetWikiRequest.cs
+++ b/osu.Game/Online/API/Requests/GetWikiRequest.cs
@@ -1,6 +1,8 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
+using osu.Game.Extensions;
+using osu.Game.Localisation;
 using osu.Game.Online.API.Requests.Responses;
 
 namespace osu.Game.Online.API.Requests
@@ -8,14 +10,14 @@ namespace osu.Game.Online.API.Requests
     public class GetWikiRequest : APIRequest<APIWikiPage>
     {
         private readonly string path;
-        private readonly string locale;
+        private readonly Language language;
 
-        public GetWikiRequest(string path, string locale = "en")
+        public GetWikiRequest(string path, Language language = Language.en)
         {
             this.path = path;
-            this.locale = locale;
+            this.language = language;
         }
 
-        protected override string Target => $"wiki/{locale}/{path}";
+        protected override string Target => $"wiki/{language.ToCultureCode()}/{path}";
     }
 }
diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs
index 5dd3e46b4a..c94a6d3361 100644
--- a/osu.Game/Online/Leaderboards/Leaderboard.cs
+++ b/osu.Game/Online/Leaderboards/Leaderboard.cs
@@ -22,6 +22,7 @@ using osu.Game.Online.API;
 using osu.Game.Online.Placeholders;
 using osuTK;
 using osuTK.Graphics;
+using osu.Game.Localisation;
 
 namespace osu.Game.Online.Leaderboards
 {
@@ -311,25 +312,28 @@ namespace osu.Game.Online.Leaderboards
             switch (state)
             {
                 case LeaderboardState.NetworkFailure:
-                    return new ClickablePlaceholder(@"Couldn't fetch scores!", FontAwesome.Solid.Sync)
+                    return new ClickablePlaceholder(LeaderboardStrings.CouldntFetchScores, FontAwesome.Solid.Sync)
                     {
                         Action = RefetchScores
                     };
 
                 case LeaderboardState.NoneSelected:
-                    return new MessagePlaceholder(@"Please select a beatmap!");
+                    return new MessagePlaceholder(LeaderboardStrings.PleaseSelectABeatmap);
 
-                case LeaderboardState.Unavailable:
-                    return new MessagePlaceholder(@"Leaderboards are not available for this beatmap!");
+                case LeaderboardState.RulesetUnavailable:
+                    return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisRuleset);
+
+                case LeaderboardState.BeatmapUnavailable:
+                    return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisBeatmap);
 
                 case LeaderboardState.NoScores:
-                    return new MessagePlaceholder(@"No records yet!");
+                    return new MessagePlaceholder(LeaderboardStrings.NoRecordsYet);
 
                 case LeaderboardState.NotLoggedIn:
-                    return new LoginPlaceholder(@"Please sign in to view online leaderboards!");
+                    return new LoginPlaceholder(LeaderboardStrings.PleaseSignInToViewOnlineLeaderboards);
 
                 case LeaderboardState.NotSupporter:
-                    return new MessagePlaceholder(@"Please invest in an osu!supporter tag to view this leaderboard!");
+                    return new MessagePlaceholder(LeaderboardStrings.PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard);
 
                 case LeaderboardState.Retrieving:
                     return null;
diff --git a/osu.Game/Online/Leaderboards/LeaderboardState.cs b/osu.Game/Online/Leaderboards/LeaderboardState.cs
index 75e2c6e6db..6b07500a98 100644
--- a/osu.Game/Online/Leaderboards/LeaderboardState.cs
+++ b/osu.Game/Online/Leaderboards/LeaderboardState.cs
@@ -8,7 +8,8 @@ namespace osu.Game.Online.Leaderboards
         Success,
         Retrieving,
         NetworkFailure,
-        Unavailable,
+        BeatmapUnavailable,
+        RulesetUnavailable,
         NoneSelected,
         NoScores,
         NotLoggedIn,
diff --git a/osu.Game/Online/Placeholders/LoginPlaceholder.cs b/osu.Game/Online/Placeholders/LoginPlaceholder.cs
index f8a326a52e..d03b3d8ffc 100644
--- a/osu.Game/Online/Placeholders/LoginPlaceholder.cs
+++ b/osu.Game/Online/Placeholders/LoginPlaceholder.cs
@@ -3,6 +3,7 @@
 
 using osu.Framework.Allocation;
 using osu.Framework.Graphics.Sprites;
+using osu.Framework.Localisation;
 using osu.Game.Overlays;
 
 namespace osu.Game.Online.Placeholders
@@ -12,7 +13,7 @@ namespace osu.Game.Online.Placeholders
         [Resolved(CanBeNull = true)]
         private LoginOverlay login { get; set; }
 
-        public LoginPlaceholder(string actionMessage)
+        public LoginPlaceholder(LocalisableString actionMessage)
             : base(actionMessage, FontAwesome.Solid.UserLock)
         {
             Action = () => login?.Show();
diff --git a/osu.Game/Overlays/Chat/ChannelScrollContainer.cs b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs
new file mode 100644
index 0000000000..58b2b9a075
--- /dev/null
+++ b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs
@@ -0,0 +1,70 @@
+// 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.Graphics;
+using osu.Game.Graphics.Containers;
+
+namespace osu.Game.Overlays.Chat
+{
+    /// <summary>
+    /// An <see cref="OsuScrollContainer"/> with functionality to automatically scroll whenever the maximum scrollable distance increases.
+    /// </summary>
+    public class ChannelScrollContainer : OsuScrollContainer
+    {
+        /// <summary>
+        /// The chat will be automatically scrolled to end if and only if
+        /// the distance between the current scroll position and the end of the scroll
+        /// is less than this value.
+        /// </summary>
+        private const float auto_scroll_leniency = 10f;
+
+        /// <summary>
+        /// Whether to keep this container scrolled to end on new content.
+        /// </summary>
+        /// <remarks>
+        /// This is specifically controlled by whether the latest scroll operation made the container scrolled to end.
+        /// </remarks>
+        private bool trackNewContent = true;
+
+        protected override void UpdateAfterChildren()
+        {
+            base.UpdateAfterChildren();
+
+            if (trackNewContent && !IsScrolledToEnd())
+                ScrollToEnd();
+        }
+
+        private void updateTrackState() => trackNewContent = IsScrolledToEnd(auto_scroll_leniency);
+
+        // todo: we may eventually want this encapsulated in a "OnScrollChange" event handler method provided by ScrollContainer.
+        // important to note that this intentionally doesn't consider OffsetScrollPosition, but could make it do so with side changes.
+
+        #region Scroll handling
+
+        protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = null)
+        {
+            base.OnUserScroll(value, animated, distanceDecay);
+            updateTrackState();
+        }
+
+        public new void ScrollIntoView(Drawable d, bool animated = true)
+        {
+            base.ScrollIntoView(d, animated);
+            updateTrackState();
+        }
+
+        public new void ScrollToStart(bool animated = true, bool allowDuringDrag = false)
+        {
+            base.ScrollToStart(animated, allowDuringDrag);
+            updateTrackState();
+        }
+
+        public new void ScrollToEnd(bool animated = true, bool allowDuringDrag = false)
+        {
+            base.ScrollToEnd(animated, allowDuringDrag);
+            updateTrackState();
+        }
+
+        #endregion
+    }
+}
diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs
index 41e70bbfae..6220beeb82 100644
--- a/osu.Game/Overlays/Chat/DrawableChannel.cs
+++ b/osu.Game/Overlays/Chat/DrawableChannel.cs
@@ -11,9 +11,7 @@ using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
 using osu.Framework.Graphics.Sprites;
-using osu.Framework.Utils;
 using osu.Game.Graphics;
-using osu.Game.Graphics.Containers;
 using osu.Game.Graphics.Cursor;
 using osu.Game.Graphics.Sprites;
 using osu.Game.Online.Chat;
@@ -236,52 +234,5 @@ namespace osu.Game.Overlays.Chat
                 };
             }
         }
-
-        /// <summary>
-        /// An <see cref="OsuScrollContainer"/> with functionality to automatically scroll whenever the maximum scrollable distance increases.
-        /// </summary>
-        private class ChannelScrollContainer : UserTrackingScrollContainer
-        {
-            /// <summary>
-            /// The chat will be automatically scrolled to end if and only if
-            /// the distance between the current scroll position and the end of the scroll
-            /// is less than this value.
-            /// </summary>
-            private const float auto_scroll_leniency = 10f;
-
-            private float? lastExtent;
-
-            protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default)
-            {
-                base.OnUserScroll(value, animated, distanceDecay);
-                lastExtent = null;
-            }
-
-            protected override void Update()
-            {
-                base.Update();
-
-                // If the user has scrolled to the bottom of the container, we should resume tracking new content.
-                if (UserScrolling && IsScrolledToEnd(auto_scroll_leniency))
-                    CancelUserScroll();
-
-                // If the user hasn't overridden our behaviour and there has been new content added to the container, we should update our scroll position to track it.
-                bool requiresScrollUpdate = !UserScrolling && (lastExtent == null || Precision.AlmostBigger(ScrollableExtent, lastExtent.Value));
-
-                if (requiresScrollUpdate)
-                {
-                    // Schedule required to allow FillFlow to be the correct size.
-                    Schedule(() =>
-                    {
-                        if (!UserScrolling)
-                        {
-                            if (Current < ScrollableExtent)
-                                ScrollToEnd();
-                            lastExtent = ScrollableExtent;
-                        }
-                    });
-                }
-            }
-        }
     }
 }
diff --git a/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs
index 5f513582e5..922f3832e4 100644
--- a/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs
+++ b/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs
@@ -1,6 +1,7 @@
 // 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.Threading;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Extensions.Color4Extensions;
@@ -63,11 +64,17 @@ namespace osu.Game.Overlays.Profile.Header
             };
         }
 
+        private CancellationTokenSource cancellationTokenSource;
+
         private void updateDisplay(APIUser user)
         {
-            var badges = user.Badges;
+            cancellationTokenSource?.Cancel();
+            cancellationTokenSource = new CancellationTokenSource();
+
             badgeFlowContainer.Clear();
 
+            var badges = user.Badges;
+
             if (badges?.Length > 0)
             {
                 Show();
@@ -79,7 +86,7 @@ namespace osu.Game.Overlays.Profile.Header
                     {
                         // load in stable order regardless of async load order.
                         badgeFlowContainer.Insert(displayIndex, asyncBadge);
-                    });
+                    }, cancellationTokenSource.Token);
                 }
             }
             else
@@ -87,5 +94,11 @@ namespace osu.Game.Overlays.Profile.Header
                 Hide();
             }
         }
+
+        protected override void Dispose(bool isDisposing)
+        {
+            cancellationTokenSource?.Cancel();
+            base.Dispose(isDisposing);
+        }
     }
 }
diff --git a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs
index 9345d3fcc7..673252a99e 100644
--- a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs
@@ -23,7 +23,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio
         {
             Children = new Drawable[]
             {
-                new SettingsSlider<double, OffsetSlider>
+                new SettingsSlider<double, TimeSlider>
                 {
                     LabelText = AudioSettingsStrings.AudioOffset,
                     Current = config.GetBindable<double>(OsuSetting.AudioOffset),
@@ -35,10 +35,5 @@ namespace osu.Game.Overlays.Settings.Sections.Audio
                 }
             };
         }
-
-        private class OffsetSlider : OsuSliderBar<double>
-        {
-            public override LocalisableString TooltipText => Current.Value.ToString(@"0ms");
-        }
     }
 }
diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs
index adf1453d1a..602ace6dea 100644
--- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs
@@ -27,7 +27,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
 
         private FillFlowContainer<SettingsSlider<float>> scalingSettings;
 
-        private readonly IBindable<Display> currentDisplay = new Bindable<Display>();
+        private readonly Bindable<Display> currentDisplay = new Bindable<Display>();
         private readonly IBindableList<WindowMode> windowModes = new BindableList<WindowMode>();
 
         private Bindable<ScalingMode> scalingMode;
@@ -39,6 +39,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
         private OsuGameBase game { get; set; }
 
         private SettingsDropdown<Size> resolutionDropdown;
+        private SettingsDropdown<Display> displayDropdown;
         private SettingsDropdown<WindowMode> windowModeDropdown;
 
         private Bindable<float> scalingPositionX;
@@ -72,6 +73,12 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
                     ItemSource = windowModes,
                     Current = config.GetBindable<WindowMode>(FrameworkSetting.WindowMode),
                 },
+                displayDropdown = new DisplaySettingsDropdown
+                {
+                    LabelText = GraphicsSettingsStrings.Display,
+                    Items = host.Window?.Displays,
+                    Current = currentDisplay,
+                },
                 resolutionDropdown = new ResolutionSettingsDropdown
                 {
                     LabelText = GraphicsSettingsStrings.Resolution,
@@ -142,7 +149,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
 
             windowModeDropdown.Current.BindValueChanged(mode =>
             {
-                updateResolutionDropdown();
+                updateDisplayModeDropdowns();
 
                 windowModeDropdown.WarningText = mode.NewValue != WindowMode.Fullscreen ? GraphicsSettingsStrings.NotFullscreenNote : default;
             }, true);
@@ -168,7 +175,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
                                                 .Distinct());
                 }
 
-                updateResolutionDropdown();
+                updateDisplayModeDropdowns();
             }), true);
 
             scalingMode.BindValueChanged(mode =>
@@ -183,12 +190,17 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
             // initial update bypasses transforms
             updateScalingModeVisibility();
 
-            void updateResolutionDropdown()
+            void updateDisplayModeDropdowns()
             {
                 if (resolutions.Count > 1 && windowModeDropdown.Current.Value == WindowMode.Fullscreen)
                     resolutionDropdown.Show();
                 else
                     resolutionDropdown.Hide();
+
+                if (displayDropdown.Items.Count() > 1)
+                    displayDropdown.Show();
+                else
+                    displayDropdown.Hide();
             }
 
             void updateScalingModeVisibility()
@@ -243,6 +255,19 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
             public override LocalisableString TooltipText => base.TooltipText + "x";
         }
 
+        private class DisplaySettingsDropdown : SettingsDropdown<Display>
+        {
+            protected override OsuDropdown<Display> CreateDropdown() => new DisplaySettingsDropdownControl();
+
+            private class DisplaySettingsDropdownControl : DropdownControl
+            {
+                protected override LocalisableString GenerateItemText(Display item)
+                {
+                    return $"{item.Index}: {item.Name} ({item.Bounds.Width}x{item.Bounds.Height})";
+                }
+            }
+        }
+
         private class ResolutionSettingsDropdown : SettingsDropdown<Size>
         {
             protected override OsuDropdown<Size> CreateDropdown() => new ResolutionDropdownControl();
diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs
index 0afbed5df5..59894cbcae 100644
--- a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs
@@ -35,18 +35,13 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface
                     LabelText = UserInterfaceStrings.Parallax,
                     Current = config.GetBindable<bool>(OsuSetting.MenuParallax)
                 },
-                new SettingsSlider<float, TimeSlider>
+                new SettingsSlider<double, TimeSlider>
                 {
                     LabelText = UserInterfaceStrings.HoldToConfirmActivationTime,
-                    Current = config.GetBindable<float>(OsuSetting.UIHoldActivationDelay),
+                    Current = config.GetBindable<double>(OsuSetting.UIHoldActivationDelay),
                     KeyboardStep = 50
                 },
             };
         }
-
-        private class TimeSlider : OsuSliderBar<float>
-        {
-            public override LocalisableString TooltipText => Current.Value.ToString(@"N0") + "ms";
-        }
     }
 }
diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs
index 08321f68fe..b4178359a4 100644
--- a/osu.Game/Overlays/SettingsToolboxGroup.cs
+++ b/osu.Game/Overlays/SettingsToolboxGroup.cs
@@ -22,8 +22,9 @@ namespace osu.Game.Overlays
 {
     public class SettingsToolboxGroup : Container, IExpandable
     {
+        public const int CONTAINER_WIDTH = 270;
+
         private const float transition_duration = 250;
-        private const int container_width = 270;
         private const int border_thickness = 2;
         private const int header_height = 30;
         private const int corner_radius = 5;
@@ -49,7 +50,7 @@ namespace osu.Game.Overlays
         public SettingsToolboxGroup(string title)
         {
             AutoSizeAxes = Axes.Y;
-            Width = container_width;
+            Width = CONTAINER_WIDTH;
             Masking = true;
             CornerRadius = corner_radius;
             BorderColour = Color4.Black;
@@ -201,7 +202,5 @@ namespace osu.Game.Overlays
         }
 
         protected override Container<Drawable> Content => content;
-
-        protected override bool OnMouseDown(MouseDownEvent e) => true;
     }
 }
diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs
index 44713d637d..4015d8e196 100644
--- a/osu.Game/Overlays/WikiOverlay.cs
+++ b/osu.Game/Overlays/WikiOverlay.cs
@@ -7,6 +7,7 @@ using System.Threading;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
+using osu.Game.Extensions;
 using osu.Game.Online.API;
 using osu.Game.Online.API.Requests;
 using osu.Game.Online.API.Requests.Responses;
@@ -100,7 +101,12 @@ namespace osu.Game.Overlays
             cancellationToken?.Cancel();
             request?.Cancel();
 
-            request = new GetWikiRequest(e.NewValue);
+            string[] values = e.NewValue.Split('/', 2);
+
+            if (values.Length > 1 && LanguageExtensions.TryParseCultureCode(values[0], out var language))
+                request = new GetWikiRequest(values[1], language);
+            else
+                request = new GetWikiRequest(e.NewValue);
 
             Loading.Show();
 
diff --git a/osu.Game/Rulesets/Mods/IUpdatableByPlayfield.cs b/osu.Game/Rulesets/Mods/IUpdatableByPlayfield.cs
index 9baa252caf..7cf480a11b 100644
--- a/osu.Game/Rulesets/Mods/IUpdatableByPlayfield.cs
+++ b/osu.Game/Rulesets/Mods/IUpdatableByPlayfield.cs
@@ -5,8 +5,19 @@ using osu.Game.Rulesets.UI;
 
 namespace osu.Game.Rulesets.Mods
 {
+    /// <summary>
+    /// An interface for <see cref="Mod"/>s that are updated every frame by a <see cref="Playfield"/>.
+    /// </summary>
     public interface IUpdatableByPlayfield : IApplicableMod
     {
+        /// <summary>
+        /// Update this <see cref="Mod"/>.
+        /// </summary>
+        /// <param name="playfield">The main <see cref="Playfield"/></param>
+        /// <remarks>
+        /// This method is called once per frame during gameplay by the main <see cref="Playfield"/> only.
+        /// To access nested <see cref="Playfield"/>s, use <see cref="Playfield.NestedPlayfields"/>.
+        /// </remarks>
         void Update(Playfield playfield);
     }
 }
diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs
index d0bbf859af..30e71dde1c 100644
--- a/osu.Game/Rulesets/UI/Playfield.cs
+++ b/osu.Game/Rulesets/UI/Playfield.cs
@@ -79,6 +79,11 @@ namespace osu.Game.Rulesets.UI
 
         private readonly List<Playfield> nestedPlayfields = new List<Playfield>();
 
+        /// <summary>
+        /// Whether this <see cref="Playfield"/> is nested in another <see cref="Playfield"/>.
+        /// </summary>
+        public bool IsNested { get; private set; }
+
         /// <summary>
         /// Whether judgements should be displayed by this and and all nested <see cref="Playfield"/>s.
         /// </summary>
@@ -206,6 +211,8 @@ namespace osu.Game.Rulesets.UI
         /// <param name="otherPlayfield">The <see cref="Playfield"/> to add.</param>
         protected void AddNested(Playfield otherPlayfield)
         {
+            otherPlayfield.IsNested = true;
+
             otherPlayfield.DisplayJudgements.BindTo(DisplayJudgements);
 
             otherPlayfield.NewResult += (d, r) => NewResult?.Invoke(d, r);
@@ -229,7 +236,7 @@ namespace osu.Game.Rulesets.UI
         {
             base.Update();
 
-            if (mods != null)
+            if (!IsNested && mods != null)
             {
                 foreach (var mod in mods)
                 {
diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs
index 1326395695..f0ead05280 100644
--- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs
+++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs
@@ -7,9 +7,9 @@ using System.Linq;
 using System.Text;
 using osu.Framework.Extensions;
 using osu.Game.Beatmaps;
+using osu.Game.Extensions;
 using osu.Game.IO.Legacy;
 using osu.Game.Replays.Legacy;
-using osu.Game.Rulesets;
 using osu.Game.Rulesets.Replays;
 using osu.Game.Rulesets.Replays.Types;
 using SharpCompress.Compressors.LZMA;
@@ -48,7 +48,7 @@ namespace osu.Game.Scoring.Legacy
             if (beatmap == null && !score.Replay.Frames.All(f => f is LegacyReplayFrame))
                 throw new ArgumentException(@"Beatmap must be provided if frames are not already legacy frames.", nameof(beatmap));
 
-            if (score.ScoreInfo.Ruleset.OnlineID < 0 || score.ScoreInfo.Ruleset.OnlineID > ILegacyRuleset.MAX_LEGACY_RULESET_ID)
+            if (!score.ScoreInfo.Ruleset.IsLegacyRuleset())
                 throw new ArgumentException(@"Only scores in the osu, taiko, catch, or mania rulesets can be encoded to the legacy score format.", nameof(score));
         }
 
diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs
index 7d52645aa1..fc0952d4f0 100644
--- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs
@@ -75,9 +75,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
             [BackgroundDependencyLoader]
             private void load()
             {
+                FillFlowContainer flow;
+
                 Children = new Drawable[]
                 {
-                    new FillFlowContainer
+                    flow = new FillFlowContainer
                     {
                         Width = 200,
                         Direction = FillDirection.Vertical,
@@ -94,6 +96,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
                     }
                 };
 
+                bank.TabbableContentContainer = flow;
+                volume.TabbableContentContainer = flow;
+
                 // if the piece belongs to a currently selected object, assume that the user wants to change all selected objects.
                 // if the piece belongs to an unselected object, operate on that object alone, independently of the selection.
                 var relevantObjects = (beatmap.SelectedHitObjects.Contains(hitObject) ? beatmap.SelectedHitObjects : hitObject.Yield()).ToArray();
diff --git a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs
index e25d83cfb0..0cf2cf6c54 100644
--- a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs
+++ b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs
@@ -32,6 +32,11 @@ namespace osu.Game.Screens.Edit.Timing
             set => slider.KeyboardStep = value;
         }
 
+        public CompositeDrawable TabbableContentContainer
+        {
+            set => textBox.TabbableContentContainer = value;
+        }
+
         private readonly BindableWithCurrent<T?> current = new BindableWithCurrent<T?>();
 
         public Bindable<T?> Current
diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs
index b0208a0ae8..e2d79b4015 100644
--- a/osu.Game/Screens/Menu/MainMenu.cs
+++ b/osu.Game/Screens/Menu/MainMenu.cs
@@ -66,7 +66,7 @@ namespace osu.Game.Screens.Menu
 
         protected override BackgroundScreen CreateBackground() => background;
 
-        private Bindable<float> holdDelay;
+        private Bindable<double> holdDelay;
         private Bindable<bool> loginDisplayed;
 
         private ExitConfirmOverlay exitConfirmOverlay;
@@ -77,7 +77,7 @@ namespace osu.Game.Screens.Menu
         [BackgroundDependencyLoader(true)]
         private void load(BeatmapListingOverlay beatmapListing, SettingsOverlay settings, OsuConfigManager config, SessionStatics statics)
         {
-            holdDelay = config.GetBindable<float>(OsuSetting.UIHoldActivationDelay);
+            holdDelay = config.GetBindable<double>(OsuSetting.UIHoldActivationDelay);
             loginDisplayed = statics.GetBindable<bool>(Static.LoginOverlayDisplayed);
 
             if (host.CanExit)
diff --git a/osu.Game/Screens/Play/DimmableStoryboard.cs b/osu.Game/Screens/Play/DimmableStoryboard.cs
index f8cedddfbe..5a3ef1e9d3 100644
--- a/osu.Game/Screens/Play/DimmableStoryboard.cs
+++ b/osu.Game/Screens/Play/DimmableStoryboard.cs
@@ -1,10 +1,12 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
+using System.Collections.Generic;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics.Containers;
 using osu.Game.Graphics.Containers;
+using osu.Game.Rulesets.Mods;
 using osu.Game.Storyboards;
 using osu.Game.Storyboards.Drawables;
 
@@ -18,6 +20,8 @@ namespace osu.Game.Screens.Play
         public Container OverlayLayerContainer { get; private set; }
 
         private readonly Storyboard storyboard;
+        private readonly IReadOnlyList<Mod> mods;
+
         private DrawableStoryboard drawableStoryboard;
 
         /// <summary>
@@ -28,9 +32,10 @@ namespace osu.Game.Screens.Play
         /// </remarks>
         public IBindable<bool> HasStoryboardEnded = new BindableBool(true);
 
-        public DimmableStoryboard(Storyboard storyboard)
+        public DimmableStoryboard(Storyboard storyboard, IReadOnlyList<Mod> mods)
         {
             this.storyboard = storyboard;
+            this.mods = mods;
         }
 
         [BackgroundDependencyLoader]
@@ -57,7 +62,7 @@ namespace osu.Game.Screens.Play
             if (!ShowStoryboard.Value && !IgnoreUserSettings.Value)
                 return;
 
-            drawableStoryboard = storyboard.CreateDrawable();
+            drawableStoryboard = storyboard.CreateDrawable(mods);
             HasStoryboardEnded.BindTo(drawableStoryboard.HasStoryboardEnded);
 
             if (async)
diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs
index 430f001427..4087011933 100644
--- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs
+++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs
@@ -63,11 +63,11 @@ namespace osu.Game.Screens.Play.HUD
         [Resolved]
         private OsuConfigManager config { get; set; }
 
-        private Bindable<float> activationDelay;
+        private Bindable<double> activationDelay;
 
         protected override void LoadComplete()
         {
-            activationDelay = config.GetBindable<float>(OsuSetting.UIHoldActivationDelay);
+            activationDelay = config.GetBindable<double>(OsuSetting.UIHoldActivationDelay);
             activationDelay.BindValueChanged(v =>
             {
                 text.Text = v.NewValue > 0
diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs
index 86ea412488..b6f576ff2b 100644
--- a/osu.Game/Screens/Play/Player.cs
+++ b/osu.Game/Screens/Play/Player.cs
@@ -359,7 +359,7 @@ namespace osu.Game.Screens.Play
         protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart);
 
         private Drawable createUnderlayComponents() =>
-            DimmableStoryboard = new DimmableStoryboard(Beatmap.Value.Storyboard) { RelativeSizeAxes = Axes.Both };
+            DimmableStoryboard = new DimmableStoryboard(Beatmap.Value.Storyboard, GameplayState.Mods) { RelativeSizeAxes = Axes.Both };
 
         private Drawable createGameplayComponents(IWorkingBeatmap working) => new ScalingContainer(ScalingMode.Gameplay)
         {
diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs
index f6d63a8ec5..41eb822e39 100644
--- a/osu.Game/Screens/Play/PlayerLoader.cs
+++ b/osu.Game/Screens/Play/PlayerLoader.cs
@@ -143,6 +143,8 @@ namespace osu.Game.Screens.Play
             muteWarningShownOnce = sessionStatics.GetBindable<bool>(Static.MutedAudioNotificationShownOnce);
             batteryWarningShownOnce = sessionStatics.GetBindable<bool>(Static.LowBatteryNotificationShownOnce);
 
+            const float padding = 25;
+
             InternalChildren = new Drawable[]
             {
                 (content = new LogoTrackingContainer
@@ -158,20 +160,27 @@ namespace osu.Game.Screens.Play
                         Anchor = Anchor.Centre,
                         Origin = Anchor.Centre,
                     },
-                    PlayerSettings = new FillFlowContainer<PlayerSettingsGroup>
+                    new OsuScrollContainer
                     {
                         Anchor = Anchor.TopRight,
                         Origin = Anchor.TopRight,
-                        AutoSizeAxes = Axes.Both,
-                        Direction = FillDirection.Vertical,
-                        Spacing = new Vector2(0, 20),
-                        Margin = new MarginPadding(25),
-                        Children = new PlayerSettingsGroup[]
+                        RelativeSizeAxes = Axes.Y,
+                        Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2,
+                        Padding = new MarginPadding { Vertical = padding },
+                        Masking = false,
+                        Child = PlayerSettings = new FillFlowContainer<PlayerSettingsGroup>
                         {
-                            VisualSettings = new VisualSettings(),
-                            AudioSettings = new AudioSettings(),
-                            new InputSettings()
-                        }
+                            AutoSizeAxes = Axes.Both,
+                            Direction = FillDirection.Vertical,
+                            Spacing = new Vector2(0, 20),
+                            Padding = new MarginPadding { Horizontal = padding },
+                            Children = new PlayerSettingsGroup[]
+                            {
+                                VisualSettings = new VisualSettings(),
+                                AudioSettings = new AudioSettings(),
+                                new InputSettings()
+                            }
+                        },
                     },
                     idleTracker = new IdleTracker(750),
                 }),
diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs
index c05c5af10d..c00b2f56dc 100644
--- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs
+++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs
@@ -2,12 +2,14 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System;
+using System.Diagnostics;
 using System.Linq;
 using System.Threading.Tasks;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
+using osu.Framework.Localisation;
 using osu.Framework.Utils;
 using osu.Game.Beatmaps;
 using osu.Game.Database;
@@ -50,6 +52,8 @@ namespace osu.Game.Screens.Play.PlayerSettings
         private OsuColour colours { get; set; } = null!;
 
         private double lastPlayAverage;
+        private double lastPlayBeatmapOffset;
+        private HitEventTimingDistributionGraph? lastPlayGraph;
 
         private SettingsButton? useAverageButton;
 
@@ -70,7 +74,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
                 Spacing = new Vector2(10),
                 Children = new Drawable[]
                 {
-                    new PlayerSliderBar<double>
+                    new OffsetSliderBar
                     {
                         KeyboardStep = 5,
                         LabelText = BeatmapOffsetControlStrings.BeatmapOffset,
@@ -87,6 +91,28 @@ namespace osu.Game.Screens.Play.PlayerSettings
             };
         }
 
+        public class OffsetSliderBar : PlayerSliderBar<double>
+        {
+            protected override Drawable CreateControl() => new CustomSliderBar();
+
+            protected class CustomSliderBar : SliderBar
+            {
+                public override LocalisableString TooltipText =>
+                    Current.Value == 0
+                        ? new TranslatableString("_", @"{0} ms", base.TooltipText)
+                        : new TranslatableString("_", @"{0} ms {1}", base.TooltipText, getEarlyLateText(Current.Value));
+
+                private LocalisableString getEarlyLateText(double value)
+                {
+                    Debug.Assert(value != 0);
+
+                    return value > 0
+                        ? BeatmapOffsetControlStrings.HitObjectsAppearEarlier
+                        : BeatmapOffsetControlStrings.HitObjectsAppearLater;
+                }
+            }
+        }
+
         protected override void LoadComplete()
         {
             base.LoadComplete();
@@ -107,6 +133,12 @@ namespace osu.Game.Screens.Play.PlayerSettings
 
             void updateOffset()
             {
+                // the last play graph is relative to the offset at the point of the last play, so we need to factor that out.
+                double adjustmentSinceLastPlay = lastPlayBeatmapOffset - Current.Value;
+
+                // Negative is applied here because the play graph is considering a hit offset, not track (as we currently use for clocks).
+                lastPlayGraph?.UpdateOffset(-adjustmentSinceLastPlay);
+
                 // ensure the previous write has completed. ignoring performance concerns, if we don't do this, the async writes could be out of sequence.
                 if (realmWriteTask?.IsCompleted == false)
                 {
@@ -115,7 +147,9 @@ namespace osu.Game.Screens.Play.PlayerSettings
                 }
 
                 if (useAverageButton != null)
-                    useAverageButton.Enabled.Value = !Precision.AlmostEquals(lastPlayAverage, -Current.Value, Current.Precision / 2);
+                {
+                    useAverageButton.Enabled.Value = !Precision.AlmostEquals(lastPlayAverage, adjustmentSinceLastPlay, Current.Precision / 2);
+                }
 
                 realmWriteTask = realm.WriteAsync(r =>
                 {
@@ -172,10 +206,11 @@ namespace osu.Game.Screens.Play.PlayerSettings
             }
 
             lastPlayAverage = average;
+            lastPlayBeatmapOffset = Current.Value;
 
             referenceScoreContainer.AddRange(new Drawable[]
             {
-                new HitEventTimingDistributionGraph(hitEvents)
+                lastPlayGraph = new HitEventTimingDistributionGraph(hitEvents)
                 {
                     RelativeSizeAxes = Axes.X,
                     Height = 50,
@@ -184,7 +219,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
                 useAverageButton = new SettingsButton
                 {
                     Text = BeatmapOffsetControlStrings.CalibrateUsingLastPlay,
-                    Action = () => Current.Value = -lastPlayAverage
+                    Action = () => Current.Value = lastPlayBeatmapOffset - lastPlayAverage
                 },
             });
         }
diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs
index 57ffe16f76..3f1a5bc0ac 100644
--- a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs
+++ b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs
@@ -15,13 +15,15 @@ namespace osu.Game.Screens.Play.PlayerSettings
     {
         public OsuSliderBar<T> Bar => (OsuSliderBar<T>)Control;
 
-        protected override Drawable CreateControl() => new SliderBar
-        {
-            RelativeSizeAxes = Axes.X
-        };
+        protected override Drawable CreateControl() => new SliderBar();
 
-        private class SliderBar : OsuSliderBar<T>
+        protected class SliderBar : OsuSliderBar<T>
         {
+            public SliderBar()
+            {
+                RelativeSizeAxes = Axes.X;
+            }
+
             [BackgroundDependencyLoader]
             private void load(OsuColour colours)
             {
diff --git a/osu.Game/Screens/Play/SoloPlayer.cs b/osu.Game/Screens/Play/SoloPlayer.cs
index 824c0072e3..a935ce49eb 100644
--- a/osu.Game/Screens/Play/SoloPlayer.cs
+++ b/osu.Game/Screens/Play/SoloPlayer.cs
@@ -4,10 +4,10 @@
 using System;
 using System.Diagnostics;
 using osu.Game.Beatmaps;
+using osu.Game.Extensions;
 using osu.Game.Online.API;
 using osu.Game.Online.Rooms;
 using osu.Game.Online.Solo;
-using osu.Game.Rulesets;
 using osu.Game.Scoring;
 
 namespace osu.Game.Screens.Play
@@ -32,7 +32,7 @@ namespace osu.Game.Screens.Play
             if (beatmapId <= 0)
                 return null;
 
-            if (rulesetId < 0 || rulesetId > ILegacyRuleset.MAX_LEGACY_RULESET_ID)
+            if (!Ruleset.Value.IsLegacyRuleset())
                 return null;
 
             return new CreateSoloScoreRequest(beatmapId, rulesetId, Game.VersionHash);
diff --git a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs
index 93885b6e02..f7c9d36cc4 100644
--- a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs
+++ b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs
@@ -12,6 +12,7 @@ using osu.Framework.Graphics.Shapes;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Sprites;
 using osu.Game.Rulesets.Scoring;
+using osuTK.Graphics;
 
 namespace osu.Game.Screens.Ranking.Statistics
 {
@@ -40,6 +41,9 @@ namespace osu.Game.Screens.Ranking.Statistics
         /// </summary>
         private const float axis_points = 5;
 
+        /// <summary>
+        /// The currently displayed hit events.
+        /// </summary>
         private readonly IReadOnlyList<HitEvent> hitEvents;
 
         /// <summary>
@@ -51,123 +55,223 @@ namespace osu.Game.Screens.Ranking.Statistics
             this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit()).ToList();
         }
 
+        private int[] bins;
+        private double binSize;
+        private double hitOffset;
+
+        private Bar[] barDrawables;
+
         [BackgroundDependencyLoader]
         private void load()
         {
             if (hitEvents == null || hitEvents.Count == 0)
                 return;
 
-            int[] bins = new int[total_timing_distribution_bins];
+            bins = new int[total_timing_distribution_bins];
 
-            double binSize = Math.Ceiling(hitEvents.Max(e => Math.Abs(e.TimeOffset)) / timing_distribution_bins);
+            binSize = Math.Ceiling(hitEvents.Max(e => Math.Abs(e.TimeOffset)) / timing_distribution_bins);
 
             // Prevent div-by-0 by enforcing a minimum bin size
             binSize = Math.Max(1, binSize);
 
+            Scheduler.AddOnce(updateDisplay);
+        }
+
+        public void UpdateOffset(double hitOffset)
+        {
+            this.hitOffset = hitOffset;
+            Scheduler.AddOnce(updateDisplay);
+        }
+
+        private void updateDisplay()
+        {
+            bool roundUp = true;
+
+            Array.Clear(bins, 0, bins.Length);
+
             foreach (var e in hitEvents)
             {
-                int binOffset = (int)Math.Round(e.TimeOffset / binSize, MidpointRounding.AwayFromZero);
-                bins[timing_distribution_centre_bin_index + binOffset]++;
+                double time = e.TimeOffset + hitOffset;
+
+                double binOffset = time / binSize;
+
+                // .NET's round midpoint handling doesn't provide a behaviour that works amazingly for display
+                // purposes here. We want midpoint rounding to roughly distribute evenly to each adjacent bucket
+                // so the easiest way is to cycle between downwards and upwards rounding as we process events.
+                if (Math.Abs(binOffset - (int)binOffset) == 0.5)
+                {
+                    binOffset = (int)binOffset + Math.Sign(binOffset) * (roundUp ? 1 : 0);
+                    roundUp = !roundUp;
+                }
+
+                int index = timing_distribution_centre_bin_index + (int)Math.Round(binOffset, MidpointRounding.AwayFromZero);
+
+                // may be out of range when applying an offset. for such cases we can just drop the results.
+                if (index >= 0 && index < bins.Length)
+                    bins[index]++;
             }
 
-            int maxCount = bins.Max();
-            var bars = new Drawable[total_timing_distribution_bins];
-            for (int i = 0; i < bars.Length; i++)
-                bars[i] = new Bar { Height = Math.Max(0.05f, (float)bins[i] / maxCount) };
-
-            Container axisFlow;
-
-            InternalChild = new GridContainer
+            if (barDrawables != null)
             {
-                Anchor = Anchor.Centre,
-                Origin = Anchor.Centre,
-                RelativeSizeAxes = Axes.Both,
-                Width = 0.8f,
-                Content = new[]
+                for (int i = 0; i < barDrawables.Length; i++)
                 {
-                    new Drawable[]
-                    {
-                        new GridContainer
-                        {
-                            RelativeSizeAxes = Axes.Both,
-                            Content = new[] { bars }
-                        }
-                    },
-                    new Drawable[]
-                    {
-                        axisFlow = new Container
-                        {
-                            RelativeSizeAxes = Axes.X,
-                            AutoSizeAxes = Axes.Y
-                        }
-                    },
-                },
-                RowDimensions = new[]
-                {
-                    new Dimension(),
-                    new Dimension(GridSizeMode.AutoSize),
+                    barDrawables[i].UpdateOffset(bins[i]);
                 }
-            };
-
-            // Our axis will contain one centre element + 5 points on each side, each with a value depending on the number of bins * bin size.
-            double maxValue = timing_distribution_bins * binSize;
-            double axisValueStep = maxValue / axis_points;
-
-            axisFlow.Add(new OsuSpriteText
+            }
+            else
             {
-                Anchor = Anchor.Centre,
-                Origin = Anchor.Centre,
-                Text = "0",
-                Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)
-            });
+                int maxCount = bins.Max();
+                barDrawables = new Bar[total_timing_distribution_bins];
 
-            for (int i = 1; i <= axis_points; i++)
-            {
-                double axisValue = i * axisValueStep;
-                float position = (float)(axisValue / maxValue);
-                float alpha = 1f - position * 0.8f;
+                for (int i = 0; i < barDrawables.Length; i++)
+                    barDrawables[i] = new Bar(bins[i], maxCount, i == timing_distribution_centre_bin_index);
+
+                Container axisFlow;
+
+                const float axis_font_size = 12;
+
+                InternalChild = new GridContainer
+                {
+                    Anchor = Anchor.Centre,
+                    Origin = Anchor.Centre,
+                    RelativeSizeAxes = Axes.Both,
+                    Width = 0.8f,
+                    Content = new[]
+                    {
+                        new Drawable[]
+                        {
+                            new GridContainer
+                            {
+                                RelativeSizeAxes = Axes.Both,
+                                Content = new[] { barDrawables }
+                            }
+                        },
+                        new Drawable[]
+                        {
+                            axisFlow = new Container
+                            {
+                                RelativeSizeAxes = Axes.X,
+                                Height = axis_font_size,
+                            }
+                        },
+                    },
+                    RowDimensions = new[]
+                    {
+                        new Dimension(),
+                        new Dimension(GridSizeMode.AutoSize),
+                    }
+                };
+
+                // Our axis will contain one centre element + 5 points on each side, each with a value depending on the number of bins * bin size.
+                double maxValue = timing_distribution_bins * binSize;
+                double axisValueStep = maxValue / axis_points;
 
                 axisFlow.Add(new OsuSpriteText
                 {
                     Anchor = Anchor.Centre,
                     Origin = Anchor.Centre,
-                    RelativePositionAxes = Axes.X,
-                    X = -position / 2,
-                    Alpha = alpha,
-                    Text = axisValue.ToString("-0"),
-                    Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)
+                    Text = "0",
+                    Font = OsuFont.GetFont(size: axis_font_size, weight: FontWeight.SemiBold)
                 });
 
-                axisFlow.Add(new OsuSpriteText
+                for (int i = 1; i <= axis_points; i++)
                 {
-                    Anchor = Anchor.Centre,
-                    Origin = Anchor.Centre,
-                    RelativePositionAxes = Axes.X,
-                    X = position / 2,
-                    Alpha = alpha,
-                    Text = axisValue.ToString("+0"),
-                    Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)
-                });
+                    double axisValue = i * axisValueStep;
+                    float position = (float)(axisValue / maxValue);
+                    float alpha = 1f - position * 0.8f;
+
+                    axisFlow.Add(new OsuSpriteText
+                    {
+                        Anchor = Anchor.Centre,
+                        Origin = Anchor.Centre,
+                        RelativePositionAxes = Axes.X,
+                        X = -position / 2,
+                        Alpha = alpha,
+                        Text = axisValue.ToString("-0"),
+                        Font = OsuFont.GetFont(size: axis_font_size, weight: FontWeight.SemiBold)
+                    });
+
+                    axisFlow.Add(new OsuSpriteText
+                    {
+                        Anchor = Anchor.Centre,
+                        Origin = Anchor.Centre,
+                        RelativePositionAxes = Axes.X,
+                        X = position / 2,
+                        Alpha = alpha,
+                        Text = axisValue.ToString("+0"),
+                        Font = OsuFont.GetFont(size: axis_font_size, weight: FontWeight.SemiBold)
+                    });
+                }
             }
         }
 
         private class Bar : CompositeDrawable
         {
-            public Bar()
+            private readonly float value;
+            private readonly float maxValue;
+
+            private readonly Circle boxOriginal;
+            private Circle boxAdjustment;
+
+            private const float minimum_height = 0.05f;
+
+            public Bar(float value, float maxValue, bool isCentre)
             {
-                Anchor = Anchor.BottomCentre;
-                Origin = Anchor.BottomCentre;
+                this.value = value;
+                this.maxValue = maxValue;
 
                 RelativeSizeAxes = Axes.Both;
+                Masking = true;
 
-                Padding = new MarginPadding { Horizontal = 1 };
-
-                InternalChild = new Circle
+                InternalChildren = new Drawable[]
                 {
-                    RelativeSizeAxes = Axes.Both,
-                    Colour = Color4Extensions.FromHex("#66FFCC")
+                    boxOriginal = new Circle
+                    {
+                        RelativeSizeAxes = Axes.Both,
+                        Anchor = Anchor.BottomCentre,
+                        Origin = Anchor.BottomCentre,
+                        Colour = isCentre ? Color4.White : Color4Extensions.FromHex("#66FFCC"),
+                        Height = minimum_height,
+                    },
                 };
             }
+
+            private const double duration = 300;
+
+            protected override void LoadComplete()
+            {
+                base.LoadComplete();
+
+                float height = Math.Clamp(value / maxValue, minimum_height, 1);
+
+                if (height > minimum_height)
+                    boxOriginal.ResizeHeightTo(height, duration, Easing.OutQuint);
+            }
+
+            public void UpdateOffset(float adjustment)
+            {
+                bool hasAdjustment = adjustment != value && adjustment / maxValue >= minimum_height;
+
+                if (boxAdjustment == null)
+                {
+                    if (!hasAdjustment)
+                        return;
+
+                    AddInternal(boxAdjustment = new Circle
+                    {
+                        RelativeSizeAxes = Axes.Both,
+                        Anchor = Anchor.BottomCentre,
+                        Origin = Anchor.BottomCentre,
+                        Colour = Color4.Yellow,
+                        Blending = BlendingParameters.Additive,
+                        Alpha = 0.6f,
+                        Height = 0,
+                    });
+                }
+
+                boxAdjustment.ResizeHeightTo(Math.Clamp(adjustment / maxValue, minimum_height, 1), duration, Easing.OutQuint);
+                boxAdjustment.FadeTo(!hasAdjustment ? 0 : 1, duration, Easing.OutQuint);
+            }
         }
     }
 }
diff --git a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs
index 760915b528..a000cfd5fc 100644
--- a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs
+++ b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs
@@ -16,6 +16,9 @@ namespace osu.Game.Screens.Select.Carousel
 {
     public class SetPanelContent : CompositeDrawable
     {
+        // Disallow interacting with difficulty icons on a panel until the panel has been selected.
+        public override bool PropagatePositionalInputSubTree => carouselSet.State.Value == CarouselItemState.Selected;
+
         private readonly CarouselBeatmapSet carouselSet;
 
         public SetPanelContent(CarouselBeatmapSet carouselSet)
diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs
index 907a2c9bda..eb0addd377 100644
--- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs
+++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs
@@ -11,6 +11,7 @@ using osu.Framework.Bindables;
 using osu.Framework.Extensions;
 using osu.Game.Beatmaps;
 using osu.Game.Database;
+using osu.Game.Extensions;
 using osu.Game.Online.API;
 using osu.Game.Online.API.Requests;
 using osu.Game.Online.Leaderboards;
@@ -98,6 +99,7 @@ namespace osu.Game.Screens.Select.Leaderboards
         protected override APIRequest FetchScores(CancellationToken cancellationToken)
         {
             var fetchBeatmapInfo = BeatmapInfo;
+            var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset;
 
             if (fetchBeatmapInfo == null)
             {
@@ -117,9 +119,15 @@ namespace osu.Game.Screens.Select.Leaderboards
                 return null;
             }
 
+            if (!fetchRuleset.IsLegacyRuleset())
+            {
+                SetErrorState(LeaderboardState.RulesetUnavailable);
+                return null;
+            }
+
             if (fetchBeatmapInfo.OnlineID <= 0 || fetchBeatmapInfo.Status <= BeatmapOnlineStatus.Pending)
             {
-                SetErrorState(LeaderboardState.Unavailable);
+                SetErrorState(LeaderboardState.BeatmapUnavailable);
                 return null;
             }
 
@@ -137,7 +145,7 @@ namespace osu.Game.Screens.Select.Leaderboards
             else if (filterMods)
                 requestMods = mods.Value;
 
-            var req = new GetScoresRequest(fetchBeatmapInfo, ruleset.Value ?? fetchBeatmapInfo.Ruleset, Scope, requestMods);
+            var req = new GetScoresRequest(fetchBeatmapInfo, fetchRuleset, Scope, requestMods);
 
             req.Success += r =>
             {
diff --git a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs
index 935d2756fb..ce9afd650a 100644
--- a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs
+++ b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs
@@ -23,6 +23,8 @@ namespace osu.Game.Skinning.Editor
 {
     public class SkinComponentToolbox : ScrollingToolboxGroup
     {
+        public const float WIDTH = 200;
+
         public Action<Type> RequestPlacement;
 
         private const float component_display_scale = 0.8f;
@@ -41,7 +43,7 @@ namespace osu.Game.Skinning.Editor
             : base("Components", height)
         {
             RelativeSizeAxes = Axes.None;
-            Width = 200;
+            Width = WIDTH;
         }
 
         [BackgroundDependencyLoader]
diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs
index 86854ab6ff..61c363b019 100644
--- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs
+++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs
@@ -5,6 +5,7 @@ using JetBrains.Annotations;
 using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Primitives;
 using osu.Framework.Input.Bindings;
 using osu.Framework.Input.Events;
 using osu.Game.Graphics.Containers;
@@ -100,30 +101,14 @@ namespace osu.Game.Skinning.Editor
         {
             if (visibility.NewValue == Visibility.Visible)
             {
-                updateMasking();
-                target.AllowScaling = false;
-                target.RelativePositionAxes = Axes.Both;
-
-                target.ScaleTo(VISIBLE_TARGET_SCALE, SkinEditor.TRANSITION_DURATION, Easing.OutQuint);
-                target.MoveToX(0.095f, SkinEditor.TRANSITION_DURATION, Easing.OutQuint);
+                target.SetCustomRect(new RectangleF(0.18f, 0.1f, VISIBLE_TARGET_SCALE, VISIBLE_TARGET_SCALE), true);
             }
             else
             {
-                target.AllowScaling = true;
-
-                target.ScaleTo(1, SkinEditor.TRANSITION_DURATION, Easing.OutQuint).OnComplete(_ => updateMasking());
-                target.MoveToX(0f, SkinEditor.TRANSITION_DURATION, Easing.OutQuint);
+                target.SetCustomRect(null);
             }
         }
 
-        private void updateMasking()
-        {
-            if (skinEditor == null)
-                return;
-
-            target.Masking = skinEditor.State.Value == Visibility.Visible;
-        }
-
         public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
         {
         }
diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
index e6528a83bd..a0fb7b0b4a 100644
--- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
+++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
@@ -1,6 +1,8 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
+using System;
+using System.Collections.Generic;
 using System.Linq;
 using System.Threading;
 using osuTK;
@@ -11,6 +13,7 @@ using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Textures;
 using osu.Framework.Platform;
 using osu.Game.Database;
+using osu.Game.Rulesets.Mods;
 using osu.Game.Screens.Play;
 using osu.Game.Stores;
 
@@ -50,14 +53,18 @@ namespace osu.Game.Storyboards.Drawables
 
         private double? lastEventEndTime;
 
+        [Cached(typeof(IReadOnlyList<Mod>))]
+        public IReadOnlyList<Mod> Mods { get; }
+
         private DependencyContainer dependencies;
 
         protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) =>
             dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
 
-        public DrawableStoryboard(Storyboard storyboard)
+        public DrawableStoryboard(Storyboard storyboard, IReadOnlyList<Mod> mods = null)
         {
             Storyboard = storyboard;
+            Mods = mods ?? Array.Empty<Mod>();
 
             Size = new Vector2(640, 480);
 
diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs
index 672274a2ad..4e3f72512c 100644
--- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs
+++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs
@@ -28,17 +28,20 @@ namespace osu.Game.Storyboards.Drawables
             LifetimeStart = sampleInfo.StartTime;
         }
 
-        [Resolved]
-        private IBindable<IReadOnlyList<Mod>> mods { get; set; }
+        [Resolved(CanBeNull = true)]
+        private IReadOnlyList<Mod> mods { get; set; }
 
         protected override void SkinChanged(ISkinSource skin)
         {
             base.SkinChanged(skin);
 
-            foreach (var mod in mods.Value.OfType<IApplicableToSample>())
+            if (mods != null)
             {
-                foreach (var sample in DrawableSamples)
-                    mod.ApplyToSample(sample);
+                foreach (var mod in mods.OfType<IApplicableToSample>())
+                {
+                    foreach (var sample in DrawableSamples)
+                        mod.ApplyToSample(sample);
+                }
             }
         }
 
diff --git a/osu.Game/Storyboards/Storyboard.cs b/osu.Game/Storyboards/Storyboard.cs
index c4864c0334..2faed98ae0 100644
--- a/osu.Game/Storyboards/Storyboard.cs
+++ b/osu.Game/Storyboards/Storyboard.cs
@@ -9,6 +9,7 @@ using osu.Framework.Graphics.Sprites;
 using osu.Framework.Graphics.Textures;
 using osu.Game.Beatmaps;
 using osu.Game.Extensions;
+using osu.Game.Rulesets.Mods;
 using osu.Game.Skinning;
 using osu.Game.Storyboards.Drawables;
 
@@ -90,8 +91,8 @@ namespace osu.Game.Storyboards
             }
         }
 
-        public DrawableStoryboard CreateDrawable(IWorkingBeatmap working = null) =>
-            new DrawableStoryboard(this);
+        public DrawableStoryboard CreateDrawable(IReadOnlyList<Mod> mods = null) =>
+            new DrawableStoryboard(this, mods);
 
         public Drawable CreateSpriteFromResourcePath(string path, TextureStore textureStore)
         {
diff --git a/osu.Game/Tests/Visual/TestPlayer.cs b/osu.Game/Tests/Visual/TestPlayer.cs
index 368f792e28..d463905cf4 100644
--- a/osu.Game/Tests/Visual/TestPlayer.cs
+++ b/osu.Game/Tests/Visual/TestPlayer.cs
@@ -26,9 +26,6 @@ namespace osu.Game.Tests.Visual
 
         public new DrawableRuleset DrawableRuleset => base.DrawableRuleset;
 
-        /// <summary>
-        /// Mods from *player* (not OsuScreen).
-        /// </summary>
         public new Bindable<IReadOnlyList<Mod>> Mods => base.Mods;
 
         public new HUDOverlay HUDOverlay => base.HUDOverlay;
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 7dfd099df1..d86fbc693e 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -36,8 +36,8 @@
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
     </PackageReference>
     <PackageReference Include="Realm" Version="10.9.0" />
-    <PackageReference Include="ppy.osu.Framework" Version="2022.223.0" />
-    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.211.0" />
+    <PackageReference Include="ppy.osu.Framework" Version="2022.304.0" />
+    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.304.0" />
     <PackageReference Include="Sentry" Version="3.14.0" />
     <PackageReference Include="SharpCompress" Version="0.30.1" />
     <PackageReference Include="NUnit" Version="3.13.2" />
diff --git a/osu.iOS.props b/osu.iOS.props
index 80600655aa..c37692f0d8 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -61,8 +61,8 @@
     <Reference Include="System.Net.Http" />
   </ItemGroup>
   <ItemGroup Label="Package References">
-    <PackageReference Include="ppy.osu.Framework.iOS" Version="2022.223.0" />
-    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.211.0" />
+    <PackageReference Include="ppy.osu.Framework.iOS" Version="2022.304.0" />
+    <PackageReference Include="ppy.osu.Game.Resources" Version="2022.304.0" />
   </ItemGroup>
   <!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) -->
   <PropertyGroup>
@@ -84,7 +84,7 @@
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" />
     <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
-    <PackageReference Include="ppy.osu.Framework" Version="2022.223.0" />
+    <PackageReference Include="ppy.osu.Framework" Version="2022.304.0" />
     <PackageReference Include="SharpCompress" Version="0.30.1" />
     <PackageReference Include="NUnit" Version="3.13.2" />
     <PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />