diff --git a/osu.Android.props b/osu.Android.props
index c57fc342ba..46fd5424df 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -62,6 +62,6 @@
   </ItemGroup>
   <ItemGroup>
     <PackageReference Include="ppy.osu.Game.Resources" Version="2019.913.0" />
-    <PackageReference Include="ppy.osu.Framework.Android" Version="2019.921.0" />
+    <PackageReference Include="ppy.osu.Framework.Android" Version="2019.924.0" />
   </ItemGroup>
 </Project>
diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs
index 761f52f961..7725ee6451 100644
--- a/osu.Desktop/OsuGameDesktop.cs
+++ b/osu.Desktop/OsuGameDesktop.cs
@@ -17,6 +17,7 @@ using osu.Framework.Logging;
 using osu.Framework.Platform.Windows;
 using osu.Framework.Screens;
 using osu.Game.Screens.Menu;
+using osu.Game.Updater;
 
 namespace osu.Desktop
 {
diff --git a/osu.Desktop/Overlays/VersionManager.cs b/osu.Desktop/Overlays/VersionManager.cs
index 51e801c185..6eed46867a 100644
--- a/osu.Desktop/Overlays/VersionManager.cs
+++ b/osu.Desktop/Overlays/VersionManager.cs
@@ -8,11 +8,8 @@ using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Sprites;
 using osu.Framework.Graphics.Textures;
 using osu.Game;
-using osu.Game.Configuration;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Sprites;
-using osu.Game.Overlays;
-using osu.Game.Overlays.Notifications;
 using osuTK;
 using osuTK.Graphics;
 
@@ -20,17 +17,9 @@ namespace osu.Desktop.Overlays
 {
     public class VersionManager : OverlayContainer
     {
-        private OsuConfigManager config;
-        private OsuGameBase game;
-        private NotificationOverlay notificationOverlay;
-
         [BackgroundDependencyLoader]
-        private void load(NotificationOverlay notification, OsuColour colours, TextureStore textures, OsuGameBase game, OsuConfigManager config)
+        private void load(OsuColour colours, TextureStore textures, OsuGameBase game)
         {
-            notificationOverlay = notification;
-            this.config = config;
-            this.game = game;
-
             AutoSizeAxes = Axes.Both;
             Anchor = Anchor.BottomCentre;
             Origin = Anchor.BottomCentre;
@@ -85,48 +74,6 @@ namespace osu.Desktop.Overlays
             };
         }
 
-        protected override void LoadComplete()
-        {
-            base.LoadComplete();
-
-            var version = game.Version;
-            var lastVersion = config.Get<string>(OsuSetting.Version);
-
-            if (game.IsDeployedBuild && version != lastVersion)
-            {
-                config.Set(OsuSetting.Version, version);
-
-                // only show a notification if we've previously saved a version to the config file (ie. not the first run).
-                if (!string.IsNullOrEmpty(lastVersion))
-                    notificationOverlay.Post(new UpdateCompleteNotification(version));
-            }
-        }
-
-        private class UpdateCompleteNotification : SimpleNotification
-        {
-            private readonly string version;
-
-            public UpdateCompleteNotification(string version)
-            {
-                this.version = version;
-                Text = $"You are now running osu!lazer {version}.\nClick to see what's new!";
-            }
-
-            [BackgroundDependencyLoader]
-            private void load(OsuColour colours, ChangelogOverlay changelog, NotificationOverlay notificationOverlay)
-            {
-                Icon = FontAwesome.Solid.CheckSquare;
-                IconBackgound.Colour = colours.BlueDark;
-
-                Activated = delegate
-                {
-                    notificationOverlay.Hide();
-                    changelog.ShowBuild(OsuGameBase.CLIENT_STREAM_NAME, version);
-                    return true;
-                };
-            }
-        }
-
         protected override void PopIn()
         {
             this.FadeIn(1400, Easing.OutQuint);
diff --git a/osu.Desktop/Updater/SquirrelUpdateManager.cs b/osu.Desktop/Updater/SquirrelUpdateManager.cs
index fa41c061b5..60b47a8b3a 100644
--- a/osu.Desktop/Updater/SquirrelUpdateManager.cs
+++ b/osu.Desktop/Updater/SquirrelUpdateManager.cs
@@ -20,7 +20,7 @@ using LogLevel = Splat.LogLevel;
 
 namespace osu.Desktop.Updater
 {
-    public class SquirrelUpdateManager : Component
+    public class SquirrelUpdateManager : osu.Game.Updater.UpdateManager
     {
         private UpdateManager updateManager;
         private NotificationOverlay notificationOverlay;
diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj
index 538aaf2d7a..2461351110 100644
--- a/osu.Desktop/osu.Desktop.csproj
+++ b/osu.Desktop/osu.Desktop.csproj
@@ -23,10 +23,10 @@
     <ProjectReference Include="..\osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj" />
     <ProjectReference Include="..\osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj" />
     <ProjectReference Include="..\osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj" />
-    <PackageReference Include="Microsoft.Win32.Registry" Version="4.5.0" />
+    <PackageReference Include="Microsoft.Win32.Registry" Version="4.6.0" />
   </ItemGroup>
   <ItemGroup Label="Package References">
-    <PackageReference Include="System.IO.Packaging" Version="4.5.0" />
+    <PackageReference Include="System.IO.Packaging" Version="4.6.0" />
     <PackageReference Include="ppy.squirrel.windows" Version="1.9.0.4" />
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
     <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="2.2.6" />
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs
index e7fd601abe..d5fd2808b8 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs
+++ b/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs
@@ -15,7 +15,6 @@ using osu.Game.Rulesets.Mania.Objects;
 using osu.Game.Rulesets.Mania.Objects.Drawables;
 using osu.Game.Rulesets.Mania.UI;
 using osu.Game.Rulesets.Mods;
-using osu.Game.Rulesets.Objects;
 using osu.Game.Rulesets.UI.Scrolling;
 using osu.Game.Tests.Visual;
 using osuTK;
@@ -67,6 +66,8 @@ namespace osu.Game.Rulesets.Mania.Tests
 
             AddAssert("check note anchors", () => notesInStageAreAnchored(stages[0], Anchor.TopCentre));
             AddAssert("check note anchors", () => notesInStageAreAnchored(stages[1], Anchor.BottomCentre));
+            AddAssert("check bar anchors", () => barsInStageAreAnchored(stages[0], Anchor.TopCentre));
+            AddAssert("check bar anchors", () => barsInStageAreAnchored(stages[1], Anchor.BottomCentre));
 
             AddStep("flip direction", () =>
             {
@@ -76,10 +77,14 @@ namespace osu.Game.Rulesets.Mania.Tests
 
             AddAssert("check note anchors", () => notesInStageAreAnchored(stages[0], Anchor.BottomCentre));
             AddAssert("check note anchors", () => notesInStageAreAnchored(stages[1], Anchor.TopCentre));
+            AddAssert("check bar anchors", () => barsInStageAreAnchored(stages[0], Anchor.BottomCentre));
+            AddAssert("check bar anchors", () => barsInStageAreAnchored(stages[1], Anchor.TopCentre));
         }
 
         private bool notesInStageAreAnchored(ManiaStage stage, Anchor anchor) => stage.Columns.SelectMany(c => c.AllHitObjects).All(o => o.Anchor == anchor);
 
+        private bool barsInStageAreAnchored(ManiaStage stage, Anchor anchor) => stage.AllHitObjects.Where(obj => obj is DrawableBarLine).All(o => o.Anchor == anchor);
+
         private void createNote()
         {
             foreach (var stage in stages)
diff --git a/osu.Game.Rulesets.Mania/Objects/BarLine.cs b/osu.Game.Rulesets.Mania/Objects/BarLine.cs
new file mode 100644
index 0000000000..0981b028b2
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/Objects/BarLine.cs
@@ -0,0 +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 osu.Game.Rulesets.Objects;
+
+namespace osu.Game.Rulesets.Mania.Objects
+{
+    public class BarLine : ManiaHitObject, IBarLine
+    {
+        public bool Major { get; set; }
+    }
+}
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs
index be21610525..56bc797c7f 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs
@@ -4,7 +4,6 @@
 using osuTK;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Shapes;
-using osu.Game.Rulesets.Objects;
 using osu.Game.Rulesets.Objects.Drawables;
 using osuTK.Graphics;
 
@@ -14,7 +13,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
     /// Visualises a <see cref="BarLine"/>. Although this derives DrawableManiaHitObject,
     /// this does not handle input/sound like a normal hit object.
     /// </summary>
-    public class DrawableBarLine : DrawableHitObject<BarLine>
+    public class DrawableBarLine : DrawableManiaHitObject<BarLine>
     {
         /// <summary>
         /// Height of major bar line triangles.
diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
index 29863fba2e..d371c1f7a8 100644
--- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
@@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Mania.UI
         public DrawableManiaRuleset(Ruleset ruleset, IWorkingBeatmap beatmap, IReadOnlyList<Mod> mods)
             : base(ruleset, beatmap, mods)
         {
-            BarLines = new BarLineGenerator(Beatmap).BarLines;
+            BarLines = new BarLineGenerator<BarLine>(Beatmap).BarLines;
         }
 
         [BackgroundDependencyLoader]
diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
index 12faa499ad..5ab07416a6 100644
--- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
+++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
@@ -8,7 +8,6 @@ using System.Collections.Generic;
 using System.Linq;
 using osu.Game.Rulesets.Mania.Beatmaps;
 using osu.Game.Rulesets.Mania.Objects;
-using osu.Game.Rulesets.Objects;
 using osu.Game.Rulesets.Objects.Drawables;
 using osu.Game.Rulesets.UI.Scrolling;
 using osuTK;
diff --git a/osu.Game.Rulesets.Mania/UI/ManiaStage.cs b/osu.Game.Rulesets.Mania/UI/ManiaStage.cs
index 98a4b7d0b6..a28de7ea58 100644
--- a/osu.Game.Rulesets.Mania/UI/ManiaStage.cs
+++ b/osu.Game.Rulesets.Mania/UI/ManiaStage.cs
@@ -12,7 +12,6 @@ using osu.Game.Rulesets.Judgements;
 using osu.Game.Rulesets.Mania.Beatmaps;
 using osu.Game.Rulesets.Mania.Objects;
 using osu.Game.Rulesets.Mania.Objects.Drawables;
-using osu.Game.Rulesets.Objects;
 using osu.Game.Rulesets.Objects.Drawables;
 using osu.Game.Rulesets.UI;
 using osu.Game.Rulesets.UI.Scrolling;
diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs
index b32dfd483f..80291c002e 100644
--- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs
+++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs
@@ -40,9 +40,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
 
             for (int i = 0; i < max_sprites; i++)
             {
-                // InvalidationID 1 forces an update of each part of the cursor trail the first time ApplyState is run on the draw node
-                // This is to prevent garbage data from being sent to the vertex shader, resulting in visual issues on some platforms
-                parts[i].InvalidationID = 1;
+                // -1 signals that the part is unusable, and should not be drawn
+                parts[i].InvalidationID = -1;
             }
         }
 
@@ -112,7 +111,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
             for (int i = 0; i < parts.Length; ++i)
             {
                 parts[i].Time -= time;
-                ++parts[i].InvalidationID;
+
+                if (parts[i].InvalidationID != -1)
+                    ++parts[i].InvalidationID;
             }
 
             time = 0;
@@ -205,8 +206,6 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
             public TrailDrawNode(CursorTrail source)
                 : base(source)
             {
-                for (int i = 0; i < max_sprites; i++)
-                    parts[i].InvalidationID = 0;
             }
 
             public override void ApplyState()
@@ -218,11 +217,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
                 size = Source.partSize;
                 time = Source.time;
 
-                for (int i = 0; i < Source.parts.Length; ++i)
-                {
-                    if (Source.parts[i].InvalidationID > parts[i].InvalidationID)
-                        parts[i] = Source.parts[i];
-                }
+                Source.parts.CopyTo(parts, 0);
             }
 
             public override void Draw(Action<TexturedVertex2D> vertexAction)
@@ -234,6 +229,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
 
                 for (int i = 0; i < parts.Length; ++i)
                 {
+                    if (parts[i].InvalidationID == -1)
+                        continue;
+
                     vertexBatch.DrawTime = parts[i].Time;
 
                     Vector2 pos = parts[i].Position;
diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs
index 3aa461e779..cbbf5b0c09 100644
--- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs
@@ -53,6 +53,11 @@ namespace osu.Game.Rulesets.Taiko.Tests
             AddStep("Strong Rim", () => addRimHit(true));
             AddStep("Add bar line", () => addBarLine(false));
             AddStep("Add major bar line", () => addBarLine(true));
+            AddStep("Add centre w/ bar line", () =>
+            {
+                addCentreHit(false);
+                addBarLine(true);
+            });
             AddStep("Height test 1", () => changePlayfieldSize(1));
             AddStep("Height test 2", () => changePlayfieldSize(2));
             AddStep("Height test 3", () => changePlayfieldSize(3));
diff --git a/osu.Game.Rulesets.Taiko/Objects/BarLine.cs b/osu.Game.Rulesets.Taiko/Objects/BarLine.cs
new file mode 100644
index 0000000000..2afbbc737c
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Objects/BarLine.cs
@@ -0,0 +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 osu.Game.Rulesets.Objects;
+
+namespace osu.Game.Rulesets.Taiko.Objects
+{
+    public class BarLine : TaikoHitObject, IBarLine
+    {
+        public bool Major { get; set; }
+    }
+}
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLineMajor.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLineMajor.cs
index f5b75a781b..4d3a1a3f8a 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLineMajor.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLineMajor.cs
@@ -5,7 +5,6 @@ using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osuTK;
 using osu.Framework.Graphics.Shapes;
-using osu.Game.Rulesets.Objects;
 
 namespace osu.Game.Rulesets.Taiko.Objects.Drawables
 {
diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs
index 5caa9e4626..fc109bf6a6 100644
--- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs
+++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Taiko.UI
         [BackgroundDependencyLoader]
         private void load()
         {
-            new BarLineGenerator(Beatmap).BarLines.ForEach(bar => Playfield.Add(bar.Major ? new DrawableBarLineMajor(bar) : new DrawableBarLine(bar)));
+            new BarLineGenerator<BarLine>(Beatmap).BarLines.ForEach(bar => Playfield.Add(bar.Major ? new DrawableBarLineMajor(bar) : new DrawableBarLine(bar)));
         }
 
         public override ScoreProcessor CreateScoreProcessor() => new TaikoScoreProcessor(this);
diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs
new file mode 100644
index 0000000000..30686cb947
--- /dev/null
+++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs
@@ -0,0 +1,201 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Screens.Select;
+using osu.Game.Screens.Select.Carousel;
+
+namespace osu.Game.Tests.NonVisual.Filtering
+{
+    [TestFixture]
+    public class FilterMatchingTest
+    {
+        private BeatmapInfo getExampleBeatmap() => new BeatmapInfo
+        {
+            Ruleset = new RulesetInfo { ID = 5 },
+            StarDifficulty = 4.0d,
+            BaseDifficulty = new BeatmapDifficulty
+            {
+                ApproachRate = 5.0f,
+                DrainRate = 3.0f,
+                CircleSize = 2.0f,
+            },
+            Metadata = new BeatmapMetadata
+            {
+                Artist = "The Artist",
+                ArtistUnicode = "check unicode too",
+                Title = "Title goes here",
+                TitleUnicode = "Title goes here",
+                AuthorString = "The Author",
+                Source = "unit tests",
+                Tags = "look for tags too",
+            },
+            Version = "version as well",
+            Length = 2500,
+            BPM = 160,
+            BeatDivisor = 12,
+            Status = BeatmapSetOnlineStatus.Loved
+        };
+
+        [Test]
+        public void TestCriteriaMatchingNoRuleset()
+        {
+            var exampleBeatmapInfo = getExampleBeatmap();
+            var criteria = new FilterCriteria();
+            var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+            carouselItem.Filter(criteria);
+            Assert.IsFalse(carouselItem.Filtered.Value);
+        }
+
+        [Test]
+        public void TestCriteriaMatchingSpecificRuleset()
+        {
+            var exampleBeatmapInfo = getExampleBeatmap();
+            var criteria = new FilterCriteria
+            {
+                Ruleset = new RulesetInfo { ID = 6 }
+            };
+            var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+            carouselItem.Filter(criteria);
+            Assert.IsTrue(carouselItem.Filtered.Value);
+        }
+
+        [Test]
+        public void TestCriteriaMatchingConvertedBeatmaps()
+        {
+            var exampleBeatmapInfo = getExampleBeatmap();
+            var criteria = new FilterCriteria
+            {
+                Ruleset = new RulesetInfo { ID = 6 },
+                AllowConvertedBeatmaps = true
+            };
+            var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+            carouselItem.Filter(criteria);
+            Assert.IsFalse(carouselItem.Filtered.Value);
+        }
+
+        [Test]
+        [TestCase(true)]
+        [TestCase(false)]
+        public void TestCriteriaMatchingRangeMin(bool inclusive)
+        {
+            var exampleBeatmapInfo = getExampleBeatmap();
+            var criteria = new FilterCriteria
+            {
+                Ruleset = new RulesetInfo { ID = 6 },
+                AllowConvertedBeatmaps = true,
+                ApproachRate = new FilterCriteria.OptionalRange<float>
+                {
+                    IsLowerInclusive = inclusive,
+                    Min = 5.0f
+                }
+            };
+            var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+            carouselItem.Filter(criteria);
+            Assert.AreEqual(!inclusive, carouselItem.Filtered.Value);
+        }
+
+        [Test]
+        [TestCase(true)]
+        [TestCase(false)]
+        public void TestCriteriaMatchingRangeMax(bool inclusive)
+        {
+            var exampleBeatmapInfo = getExampleBeatmap();
+            var criteria = new FilterCriteria
+            {
+                Ruleset = new RulesetInfo { ID = 6 },
+                AllowConvertedBeatmaps = true,
+                BPM = new FilterCriteria.OptionalRange<double>
+                {
+                    IsUpperInclusive = inclusive,
+                    Max = 160d
+                }
+            };
+            var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+            carouselItem.Filter(criteria);
+            Assert.AreEqual(!inclusive, carouselItem.Filtered.Value);
+        }
+
+        [Test]
+        [TestCase("artist", false)]
+        [TestCase("artist title author", false)]
+        [TestCase("an artist", true)]
+        [TestCase("tags too", false)]
+        [TestCase("version", false)]
+        [TestCase("an auteur", true)]
+        public void TestCriteriaMatchingTerms(string terms, bool filtered)
+        {
+            var exampleBeatmapInfo = getExampleBeatmap();
+            var criteria = new FilterCriteria
+            {
+                Ruleset = new RulesetInfo { ID = 6 },
+                AllowConvertedBeatmaps = true,
+                SearchText = terms
+            };
+            var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+            carouselItem.Filter(criteria);
+            Assert.AreEqual(filtered, carouselItem.Filtered.Value);
+        }
+
+        [Test]
+        [TestCase("", false)]
+        [TestCase("The", false)]
+        [TestCase("THE", false)]
+        [TestCase("author", false)]
+        [TestCase("the author", false)]
+        [TestCase("the author AND then something else", true)]
+        [TestCase("unknown", true)]
+        public void TestCriteriaMatchingCreator(string creatorName, bool filtered)
+        {
+            var exampleBeatmapInfo = getExampleBeatmap();
+            var criteria = new FilterCriteria
+            {
+                Creator = new FilterCriteria.OptionalTextFilter { SearchTerm = creatorName }
+            };
+            var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+            carouselItem.Filter(criteria);
+            Assert.AreEqual(filtered, carouselItem.Filtered.Value);
+        }
+
+        [Test]
+        [TestCase("", false)]
+        [TestCase("The", false)]
+        [TestCase("THE", false)]
+        [TestCase("artist", false)]
+        [TestCase("the artist", false)]
+        [TestCase("the artist AND then something else", true)]
+        [TestCase("unicode too", false)]
+        [TestCase("unknown", true)]
+        public void TestCriteriaMatchingArtist(string artistName, bool filtered)
+        {
+            var exampleBeatmapInfo = getExampleBeatmap();
+            var criteria = new FilterCriteria
+            {
+                Artist = new FilterCriteria.OptionalTextFilter { SearchTerm = artistName }
+            };
+            var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+            carouselItem.Filter(criteria);
+            Assert.AreEqual(filtered, carouselItem.Filtered.Value);
+        }
+
+        [Test]
+        [TestCase("", false)]
+        [TestCase("artist", false)]
+        [TestCase("unknown", true)]
+        public void TestCriteriaMatchingArtistWithNullUnicodeName(string artistName, bool filtered)
+        {
+            var exampleBeatmapInfo = getExampleBeatmap();
+            exampleBeatmapInfo.Metadata.ArtistUnicode = null;
+
+            var criteria = new FilterCriteria
+            {
+                Artist = new FilterCriteria.OptionalTextFilter { SearchTerm = artistName }
+            };
+            var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+            carouselItem.Filter(criteria);
+            Assert.AreEqual(filtered, carouselItem.Filtered.Value);
+        }
+    }
+}
diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
new file mode 100644
index 0000000000..9869ddde41
--- /dev/null
+++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
@@ -0,0 +1,184 @@
+// 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 NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Screens.Select;
+
+namespace osu.Game.Tests.NonVisual.Filtering
+{
+    [TestFixture]
+    public class FilterQueryParserTest
+    {
+        [Test]
+        public void TestApplyQueriesBareWords()
+        {
+            const string query = "looking for a beatmap";
+            var filterCriteria = new FilterCriteria();
+            FilterQueryParser.ApplyQueries(filterCriteria, query);
+            Assert.AreEqual("looking for a beatmap", filterCriteria.SearchText);
+            Assert.AreEqual(4, filterCriteria.SearchTerms.Length);
+        }
+
+        /*
+         * The following tests have been written a bit strangely (they don't check exact
+         * bound equality with what the filter says).
+         * This is to account for floating-point arithmetic issues.
+         * For example, specifying a bpm<140 filter would previously match beatmaps with BPM
+         * of 139.99999, which would be displayed in the UI as 140.
+         * Due to this the tests check the last tick inside the range and the first tick
+         * outside of the range.
+         */
+
+        [Test]
+        public void TestApplyStarQueries()
+        {
+            const string query = "stars<4 easy";
+            var filterCriteria = new FilterCriteria();
+            FilterQueryParser.ApplyQueries(filterCriteria, query);
+            Assert.AreEqual("easy", filterCriteria.SearchText.Trim());
+            Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
+            Assert.IsNotNull(filterCriteria.StarDifficulty.Max);
+            Assert.Greater(filterCriteria.StarDifficulty.Max, 3.99d);
+            Assert.Less(filterCriteria.StarDifficulty.Max, 4.00d);
+            Assert.IsNull(filterCriteria.StarDifficulty.Min);
+        }
+
+        [Test]
+        public void TestApplyApproachRateQueries()
+        {
+            const string query = "ar>=9 difficult";
+            var filterCriteria = new FilterCriteria();
+            FilterQueryParser.ApplyQueries(filterCriteria, query);
+            Assert.AreEqual("difficult", filterCriteria.SearchText.Trim());
+            Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
+            Assert.IsNotNull(filterCriteria.ApproachRate.Min);
+            Assert.Greater(filterCriteria.ApproachRate.Min, 8.9f);
+            Assert.Less(filterCriteria.ApproachRate.Min, 9.0f);
+            Assert.IsNull(filterCriteria.ApproachRate.Max);
+        }
+
+        [Test]
+        public void TestApplyDrainRateQueries()
+        {
+            const string query = "dr>2 quite specific dr<:6";
+            var filterCriteria = new FilterCriteria();
+            FilterQueryParser.ApplyQueries(filterCriteria, query);
+            Assert.AreEqual("quite specific", filterCriteria.SearchText.Trim());
+            Assert.AreEqual(2, filterCriteria.SearchTerms.Length);
+            Assert.Greater(filterCriteria.DrainRate.Min, 2.0f);
+            Assert.Less(filterCriteria.DrainRate.Min, 2.1f);
+            Assert.Greater(filterCriteria.DrainRate.Max, 6.0f);
+            Assert.Less(filterCriteria.DrainRate.Min, 6.1f);
+        }
+
+        [Test]
+        public void TestApplyBPMQueries()
+        {
+            const string query = "bpm>:200 gotta go fast";
+            var filterCriteria = new FilterCriteria();
+            FilterQueryParser.ApplyQueries(filterCriteria, query);
+            Assert.AreEqual("gotta go fast", filterCriteria.SearchText.Trim());
+            Assert.AreEqual(3, filterCriteria.SearchTerms.Length);
+            Assert.IsNotNull(filterCriteria.BPM.Min);
+            Assert.Greater(filterCriteria.BPM.Min, 199.99d);
+            Assert.Less(filterCriteria.BPM.Min, 200.00d);
+            Assert.IsNull(filterCriteria.BPM.Max);
+        }
+
+        private static object[] lengthQueryExamples =
+        {
+            new object[] { "6ms", TimeSpan.FromMilliseconds(6), TimeSpan.FromMilliseconds(1) },
+            new object[] { "23s", TimeSpan.FromSeconds(23), TimeSpan.FromSeconds(1) },
+            new object[] { "9m", TimeSpan.FromMinutes(9), TimeSpan.FromMinutes(1) },
+            new object[] { "0.25h", TimeSpan.FromHours(0.25), TimeSpan.FromHours(1) },
+            new object[] { "70", TimeSpan.FromSeconds(70), TimeSpan.FromSeconds(1) },
+        };
+
+        [Test]
+        [TestCaseSource(nameof(lengthQueryExamples))]
+        public void TestApplyLengthQueries(string lengthQuery, TimeSpan expectedLength, TimeSpan scale)
+        {
+            string query = $"length={lengthQuery} time";
+            var filterCriteria = new FilterCriteria();
+            FilterQueryParser.ApplyQueries(filterCriteria, query);
+            Assert.AreEqual("time", filterCriteria.SearchText.Trim());
+            Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
+            Assert.AreEqual(expectedLength.TotalMilliseconds - scale.TotalMilliseconds / 2.0, filterCriteria.Length.Min);
+            Assert.AreEqual(expectedLength.TotalMilliseconds + scale.TotalMilliseconds / 2.0, filterCriteria.Length.Max);
+        }
+
+        [Test]
+        public void TestApplyDivisorQueries()
+        {
+            const string query = "that's a time signature alright! divisor:12";
+            var filterCriteria = new FilterCriteria();
+            FilterQueryParser.ApplyQueries(filterCriteria, query);
+            Assert.AreEqual("that's a time signature alright!", filterCriteria.SearchText.Trim());
+            Assert.AreEqual(5, filterCriteria.SearchTerms.Length);
+            Assert.AreEqual(12, filterCriteria.BeatDivisor.Min);
+            Assert.IsTrue(filterCriteria.BeatDivisor.IsLowerInclusive);
+            Assert.AreEqual(12, filterCriteria.BeatDivisor.Max);
+            Assert.IsTrue(filterCriteria.BeatDivisor.IsUpperInclusive);
+        }
+
+        [Test]
+        public void TestApplyStatusQueries()
+        {
+            const string query = "I want the pp status=ranked";
+            var filterCriteria = new FilterCriteria();
+            FilterQueryParser.ApplyQueries(filterCriteria, query);
+            Assert.AreEqual("I want the pp", filterCriteria.SearchText.Trim());
+            Assert.AreEqual(4, filterCriteria.SearchTerms.Length);
+            Assert.AreEqual(BeatmapSetOnlineStatus.Ranked, filterCriteria.OnlineStatus.Min);
+            Assert.IsTrue(filterCriteria.OnlineStatus.IsLowerInclusive);
+            Assert.AreEqual(BeatmapSetOnlineStatus.Ranked, filterCriteria.OnlineStatus.Max);
+            Assert.IsTrue(filterCriteria.OnlineStatus.IsUpperInclusive);
+        }
+
+        [Test]
+        public void TestApplyCreatorQueries()
+        {
+            const string query = "beatmap specifically by creator=my_fav";
+            var filterCriteria = new FilterCriteria();
+            FilterQueryParser.ApplyQueries(filterCriteria, query);
+            Assert.AreEqual("beatmap specifically by", filterCriteria.SearchText.Trim());
+            Assert.AreEqual(3, filterCriteria.SearchTerms.Length);
+            Assert.AreEqual("my_fav", filterCriteria.Creator.SearchTerm);
+        }
+
+        [Test]
+        public void TestApplyArtistQueries()
+        {
+            const string query = "find me songs by artist=singer please";
+            var filterCriteria = new FilterCriteria();
+            FilterQueryParser.ApplyQueries(filterCriteria, query);
+            Assert.AreEqual("find me songs by  please", filterCriteria.SearchText.Trim());
+            Assert.AreEqual(5, filterCriteria.SearchTerms.Length);
+            Assert.AreEqual("singer", filterCriteria.Artist.SearchTerm);
+        }
+
+        [Test]
+        public void TestApplyArtistQueriesWithSpaces()
+        {
+            const string query = "really like artist=\"name with space\" yes";
+            var filterCriteria = new FilterCriteria();
+            FilterQueryParser.ApplyQueries(filterCriteria, query);
+            Assert.AreEqual("really like  yes", filterCriteria.SearchText.Trim());
+            Assert.AreEqual(3, filterCriteria.SearchTerms.Length);
+            Assert.AreEqual("name with space", filterCriteria.Artist.SearchTerm);
+        }
+
+        [Test]
+        public void TestApplyArtistQueriesOneDoubleQuote()
+        {
+            const string query = "weird artist=double\"quote";
+            var filterCriteria = new FilterCriteria();
+            FilterQueryParser.ApplyQueries(filterCriteria, query);
+            Assert.AreEqual("weird", filterCriteria.SearchText.Trim());
+            Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
+            Assert.AreEqual("double\"quote", filterCriteria.Artist.SearchTerm);
+        }
+    }
+}
diff --git a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs
index bbcc4140a9..578030748b 100644
--- a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs
+++ b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs
@@ -9,6 +9,7 @@ using osu.Framework.Bindables;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Textures;
+using osu.Framework.Testing;
 using osu.Game.Audio;
 using osu.Game.Skinning;
 using osu.Game.Tests.Visual;
@@ -17,6 +18,7 @@ using osuTK.Graphics;
 namespace osu.Game.Tests.Skins
 {
     [TestFixture]
+    [HeadlessTest]
     public class TestSceneSkinConfigurationLookup : OsuTestScene
     {
         private LegacySkin source1;
diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs
index 6669ec7da3..f12a613bf1 100644
--- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs
+++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs
@@ -43,7 +43,7 @@ namespace osu.Game.Tests.Visual.SongSelect
         private readonly Stack<BeatmapSetInfo> selectedSets = new Stack<BeatmapSetInfo>();
         private readonly HashSet<int> eagerSelectedIDs = new HashSet<int>();
 
-        private BeatmapInfo currentSelection;
+        private BeatmapInfo currentSelection => carousel.SelectedBeatmap;
 
         private const int set_count = 5;
 
@@ -56,37 +56,26 @@ namespace osu.Game.Tests.Visual.SongSelect
             {
                 RelativeSizeAxes = Axes.Both,
             });
-
-            List<BeatmapSetInfo> beatmapSets = new List<BeatmapSetInfo>();
-
-            for (int i = 1; i <= set_count; i++)
-                beatmapSets.Add(createTestBeatmapSet(i));
-
-            carousel.SelectionChanged = s => currentSelection = s;
-
-            loadBeatmaps(beatmapSets);
-
-            testTraversal();
-            testFiltering();
-            testRandom();
-            testAddRemove();
-            testSorting();
-
-            testRemoveAll();
-            testEmptyTraversal();
-            testHiding();
-            testSelectingFilteredRuleset();
-            testCarouselRootIsRandom();
         }
 
-        private void loadBeatmaps(List<BeatmapSetInfo> beatmapSets)
+        private void loadBeatmaps(List<BeatmapSetInfo> beatmapSets = null)
         {
+            if (beatmapSets == null)
+            {
+                beatmapSets = new List<BeatmapSetInfo>();
+
+                for (int i = 1; i <= set_count; i++)
+                    beatmapSets.Add(createTestBeatmapSet(i));
+            }
+
             bool changed = false;
             AddStep($"Load {beatmapSets.Count} Beatmaps", () =>
             {
+                carousel.Filter(new FilterCriteria());
                 carousel.BeatmapSetsChanged = () => changed = true;
                 carousel.BeatmapSets = beatmapSets;
             });
+
             AddUntilStep("Wait for load", () => changed);
         }
 
@@ -173,8 +162,11 @@ namespace osu.Game.Tests.Visual.SongSelect
         /// <summary>
         /// Test keyboard traversal
         /// </summary>
-        private void testTraversal()
+        [Test]
+        public void TestTraversal()
         {
+            loadBeatmaps();
+
             advanceSelection(direction: 1, diff: false);
             checkSelected(1, 1);
 
@@ -199,8 +191,11 @@ namespace osu.Game.Tests.Visual.SongSelect
         /// <summary>
         /// Test filtering
         /// </summary>
-        private void testFiltering()
+        [Test]
+        public void TestFiltering()
         {
+            loadBeatmaps();
+
             // basic filtering
 
             setSelected(1, 1);
@@ -242,13 +237,31 @@ namespace osu.Game.Tests.Visual.SongSelect
             AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false));
 
             AddAssert("Selection is non-null", () => currentSelection != null);
+
+            setSelected(1, 3);
+            AddStep("Apply a range filter", () => carousel.Filter(new FilterCriteria
+            {
+                SearchText = "#3",
+                StarDifficulty = new FilterCriteria.OptionalRange<double>
+                {
+                    Min = 2,
+                    Max = 5.5,
+                    IsLowerInclusive = true
+                }
+            }, false));
+            checkSelected(3, 2);
+
+            AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false));
         }
 
         /// <summary>
         /// Test random non-repeating algorithm
         /// </summary>
-        private void testRandom()
+        [Test]
+        public void TestRandom()
         {
+            loadBeatmaps();
+
             setSelected(1, 1);
 
             nextRandom();
@@ -284,8 +297,11 @@ namespace osu.Game.Tests.Visual.SongSelect
         /// <summary>
         /// Test adding and removing beatmap sets
         /// </summary>
-        private void testAddRemove()
+        [Test]
+        public void TestAddRemove()
         {
+            loadBeatmaps();
+
             AddStep("Add new set", () => carousel.UpdateBeatmapSet(createTestBeatmapSet(set_count + 1)));
             AddStep("Add new set", () => carousel.UpdateBeatmapSet(createTestBeatmapSet(set_count + 2)));
 
@@ -307,16 +323,22 @@ namespace osu.Game.Tests.Visual.SongSelect
         /// <summary>
         /// Test sorting
         /// </summary>
-        private void testSorting()
+        [Test]
+        public void TestSorting()
         {
+            loadBeatmaps();
+
             AddStep("Sort by author", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Author }, false));
             AddAssert("Check zzzzz is at bottom", () => carousel.BeatmapSets.Last().Metadata.AuthorString == "zzzzz");
             AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false));
             AddAssert($"Check #{set_count} is at bottom", () => carousel.BeatmapSets.Last().Metadata.Title.EndsWith($"#{set_count}!"));
         }
 
-        private void testRemoveAll()
+        [Test]
+        public void TestRemoveAll()
         {
+            loadBeatmaps();
+
             setSelected(2, 1);
             AddAssert("Selection is non-null", () => currentSelection != null);
 
@@ -338,8 +360,11 @@ namespace osu.Game.Tests.Visual.SongSelect
             checkNoSelection();
         }
 
-        private void testEmptyTraversal()
+        [Test]
+        public void TestEmptyTraversal()
         {
+            loadBeatmaps(new List<BeatmapSetInfo>());
+
             advanceSelection(direction: 1, diff: false);
             checkNoSelection();
 
@@ -353,11 +378,14 @@ namespace osu.Game.Tests.Visual.SongSelect
             checkNoSelection();
         }
 
-        private void testHiding()
+        [Test]
+        public void TestHiding()
         {
-            var hidingSet = createTestBeatmapSet(1);
+            BeatmapSetInfo hidingSet = createTestBeatmapSet(1);
             hidingSet.Beatmaps[1].Hidden = true;
-            AddStep("Add set with diff 2 hidden", () => carousel.UpdateBeatmapSet(hidingSet));
+
+            loadBeatmaps(new List<BeatmapSetInfo> { hidingSet });
+
             setSelected(1, 1);
 
             checkVisibleItemCount(true, 2);
@@ -387,7 +415,8 @@ namespace osu.Game.Tests.Visual.SongSelect
             }
         }
 
-        private void testSelectingFilteredRuleset()
+        [Test]
+        public void TestSelectingFilteredRuleset()
         {
             var testMixed = createTestBeatmapSet(set_count + 1);
             AddStep("add mixed ruleset beatmapset", () =>
@@ -422,14 +451,16 @@ namespace osu.Game.Tests.Visual.SongSelect
             AddStep("remove single ruleset set", () => carousel.RemoveBeatmapSet(testSingle));
         }
 
-        private void testCarouselRootIsRandom()
+        [Test]
+        public void TestCarouselRootIsRandom()
         {
-            List<BeatmapSetInfo> beatmapSets = new List<BeatmapSetInfo>();
+            List<BeatmapSetInfo> manySets = new List<BeatmapSetInfo>();
 
             for (int i = 1; i <= 50; i++)
-                beatmapSets.Add(createTestBeatmapSet(i));
+                manySets.Add(createTestBeatmapSet(i));
+
+            loadBeatmaps(manySets);
 
-            loadBeatmaps(beatmapSets);
             advanceSelection(direction: 1, diff: false);
             checkNonmatchingFilter();
             checkNonmatchingFilter();
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledComponent.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledComponent.cs
index 73e0191adb..700adad9cb 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledComponent.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledComponent.cs
@@ -6,7 +6,7 @@ using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
 using osu.Game.Graphics.Sprites;
-using osu.Game.Screens.Edit.Setup.Components.LabelledComponents;
+using osu.Game.Graphics.UserInterfaceV2;
 using osuTK.Graphics;
 
 namespace osu.Game.Tests.Visual.UserInterface
@@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.UserInterface
         {
             AddStep("create component", () =>
             {
-                LabelledComponent component;
+                LabelledComponent<Drawable> component;
 
                 Child = new Container
                 {
@@ -33,7 +33,7 @@ namespace osu.Game.Tests.Visual.UserInterface
                     Origin = Anchor.Centre,
                     Width = 500,
                     AutoSizeAxes = Axes.Y,
-                    Child = component = padded ? (LabelledComponent)new PaddedLabelledComponent() : new NonPaddedLabelledComponent(),
+                    Child = component = padded ? (LabelledComponent<Drawable>)new PaddedLabelledComponent() : new NonPaddedLabelledComponent(),
                 };
 
                 component.Label = "a sample component";
@@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual.UserInterface
             });
         }
 
-        private class PaddedLabelledComponent : LabelledComponent
+        private class PaddedLabelledComponent : LabelledComponent<Drawable>
         {
             public PaddedLabelledComponent()
                 : base(true)
@@ -57,7 +57,7 @@ namespace osu.Game.Tests.Visual.UserInterface
             };
         }
 
-        private class NonPaddedLabelledComponent : LabelledComponent
+        private class NonPaddedLabelledComponent : LabelledComponent<Drawable>
         {
             public NonPaddedLabelledComponent()
                 : base(false)
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs
index 395905a30d..53a2bfabbc 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs
@@ -7,7 +7,8 @@ using NUnit.Framework;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
-using osu.Game.Screens.Edit.Setup.Components.LabelledComponents;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Graphics.UserInterfaceV2;
 
 namespace osu.Game.Tests.Visual.UserInterface
 {
@@ -19,6 +20,36 @@ namespace osu.Game.Tests.Visual.UserInterface
             typeof(LabelledTextBox),
         };
 
+        [TestCase(false)]
+        [TestCase(true)]
+        public void TestTextBox(bool hasDescription) => createTextBox(hasDescription);
+
+        private void createTextBox(bool hasDescription = false)
+        {
+            AddStep("create component", () =>
+            {
+                LabelledComponent<OsuTextBox> component;
+
+                Child = new Container
+                {
+                    Anchor = Anchor.Centre,
+                    Origin = Anchor.Centre,
+                    Width = 500,
+                    AutoSizeAxes = Axes.Y,
+                    Child = component = new LabelledTextBox
+                    {
+                        Anchor = Anchor.Centre,
+                        Origin = Anchor.Centre,
+                        Label = "Testing text",
+                        PlaceholderText = "This is definitely working as intended",
+                    }
+                };
+
+                component.Label = "a sample component";
+                component.Description = hasDescription ? "this text describes the component" : string.Empty;
+            });
+        }
+
         [BackgroundDependencyLoader]
         private void load()
         {
@@ -32,7 +63,7 @@ namespace osu.Game.Tests.Visual.UserInterface
                 {
                     Anchor = Anchor.Centre,
                     Origin = Anchor.Centre,
-                    LabelText = "Testing text",
+                    Label = "Testing text",
                     PlaceholderText = "This is definitely working as intended",
                 }
             };
diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSetupScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSetupScreen.cs
new file mode 100644
index 0000000000..650b4c5412
--- /dev/null
+++ b/osu.Game.Tournament.Tests/Screens/TestSceneSetupScreen.cs
@@ -0,0 +1,17 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Game.Tournament.Screens;
+
+namespace osu.Game.Tournament.Tests.Screens
+{
+    public class TestSceneSetupScreen : TournamentTestScene
+    {
+        [BackgroundDependencyLoader]
+        private void load()
+        {
+            Add(new SetupScreen());
+        }
+    }
+}
diff --git a/osu.Game.Tournament/IPC/FileBasedIPC.cs b/osu.Game.Tournament/IPC/FileBasedIPC.cs
index 4fd858bd12..e05d96e098 100644
--- a/osu.Game.Tournament/IPC/FileBasedIPC.cs
+++ b/osu.Game.Tournament/IPC/FileBasedIPC.cs
@@ -9,6 +9,7 @@ using osu.Framework.Allocation;
 using osu.Framework.Logging;
 using osu.Framework.Platform;
 using osu.Framework.Platform.Windows;
+using osu.Framework.Threading;
 using osu.Game.Beatmaps;
 using osu.Game.Beatmaps.Legacy;
 using osu.Game.Online.API;
@@ -26,103 +27,120 @@ namespace osu.Game.Tournament.IPC
         [Resolved]
         protected RulesetStore Rulesets { get; private set; }
 
+        [Resolved]
+        private GameHost host { get; set; }
+
+        [Resolved]
+        private LadderInfo ladder { get; set; }
+
         private int lastBeatmapId;
+        private ScheduledDelegate scheduled;
+
+        public Storage Storage { get; private set; }
 
         [BackgroundDependencyLoader]
-        private void load(LadderInfo ladder, GameHost host)
+        private void load()
         {
-            StableStorage stable;
+            LocateStableStorage();
+        }
+
+        public Storage LocateStableStorage()
+        {
+            scheduled?.Cancel();
+
+            Storage = null;
 
             try
             {
-                stable = new StableStorage(host as DesktopGameHost);
+                Storage = new StableStorage(host as DesktopGameHost);
+
+                const string file_ipc_filename = "ipc.txt";
+                const string file_ipc_state_filename = "ipc-state.txt";
+                const string file_ipc_scores_filename = "ipc-scores.txt";
+                const string file_ipc_channel_filename = "ipc-channel.txt";
+
+                if (Storage.Exists(file_ipc_filename))
+                    scheduled = Scheduler.AddDelayed(delegate
+                    {
+                        try
+                        {
+                            using (var stream = Storage.GetStream(file_ipc_filename))
+                            using (var sr = new StreamReader(stream))
+                            {
+                                var beatmapId = int.Parse(sr.ReadLine());
+                                var mods = int.Parse(sr.ReadLine());
+
+                                if (lastBeatmapId != beatmapId)
+                                {
+                                    lastBeatmapId = beatmapId;
+
+                                    var existing = ladder.CurrentMatch.Value?.Round.Value?.Beatmaps.FirstOrDefault(b => b.ID == beatmapId && b.BeatmapInfo != null);
+
+                                    if (existing != null)
+                                        Beatmap.Value = existing.BeatmapInfo;
+                                    else
+                                    {
+                                        var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = beatmapId });
+                                        req.Success += b => Beatmap.Value = b.ToBeatmap(Rulesets);
+                                        API.Queue(req);
+                                    }
+                                }
+
+                                Mods.Value = (LegacyMods)mods;
+                            }
+                        }
+                        catch
+                        {
+                            // file might be in use.
+                        }
+
+                        try
+                        {
+                            using (var stream = Storage.GetStream(file_ipc_channel_filename))
+                            using (var sr = new StreamReader(stream))
+                            {
+                                ChatChannel.Value = sr.ReadLine();
+                            }
+                        }
+                        catch (Exception)
+                        {
+                            // file might be in use.
+                        }
+
+                        try
+                        {
+                            using (var stream = Storage.GetStream(file_ipc_state_filename))
+                            using (var sr = new StreamReader(stream))
+                            {
+                                State.Value = (TourneyState)Enum.Parse(typeof(TourneyState), sr.ReadLine());
+                            }
+                        }
+                        catch (Exception)
+                        {
+                            // file might be in use.
+                        }
+
+                        try
+                        {
+                            using (var stream = Storage.GetStream(file_ipc_scores_filename))
+                            using (var sr = new StreamReader(stream))
+                            {
+                                Score1.Value = int.Parse(sr.ReadLine());
+                                Score2.Value = int.Parse(sr.ReadLine());
+                            }
+                        }
+                        catch (Exception)
+                        {
+                            // file might be in use.
+                        }
+                    }, 250, true);
             }
             catch (Exception e)
             {
                 Logger.Error(e, "Stable installation could not be found; disabling file based IPC");
-                return;
             }
 
-            const string file_ipc_filename = "ipc.txt";
-            const string file_ipc_state_filename = "ipc-state.txt";
-            const string file_ipc_scores_filename = "ipc-scores.txt";
-            const string file_ipc_channel_filename = "ipc-channel.txt";
-
-            if (stable.Exists(file_ipc_filename))
-                Scheduler.AddDelayed(delegate
-                {
-                    try
-                    {
-                        using (var stream = stable.GetStream(file_ipc_filename))
-                        using (var sr = new StreamReader(stream))
-                        {
-                            var beatmapId = int.Parse(sr.ReadLine());
-                            var mods = int.Parse(sr.ReadLine());
-
-                            if (lastBeatmapId != beatmapId)
-                            {
-                                lastBeatmapId = beatmapId;
-
-                                var existing = ladder.CurrentMatch.Value?.Round.Value?.Beatmaps.FirstOrDefault(b => b.ID == beatmapId && b.BeatmapInfo != null);
-
-                                if (existing != null)
-                                    Beatmap.Value = existing.BeatmapInfo;
-                                else
-                                {
-                                    var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = beatmapId });
-                                    req.Success += b => Beatmap.Value = b.ToBeatmap(Rulesets);
-                                    API.Queue(req);
-                                }
-                            }
-
-                            Mods.Value = (LegacyMods)mods;
-                        }
-                    }
-                    catch
-                    {
-                        // file might be in use.
-                    }
-
-                    try
-                    {
-                        using (var stream = stable.GetStream(file_ipc_channel_filename))
-                        using (var sr = new StreamReader(stream))
-                        {
-                            ChatChannel.Value = sr.ReadLine();
-                        }
-                    }
-                    catch (Exception)
-                    {
-                        // file might be in use.
-                    }
-
-                    try
-                    {
-                        using (var stream = stable.GetStream(file_ipc_state_filename))
-                        using (var sr = new StreamReader(stream))
-                        {
-                            State.Value = (TourneyState)Enum.Parse(typeof(TourneyState), sr.ReadLine());
-                        }
-                    }
-                    catch (Exception)
-                    {
-                        // file might be in use.
-                    }
-
-                    try
-                    {
-                        using (var stream = stable.GetStream(file_ipc_scores_filename))
-                        using (var sr = new StreamReader(stream))
-                        {
-                            Score1.Value = int.Parse(sr.ReadLine());
-                            Score2.Value = int.Parse(sr.ReadLine());
-                        }
-                    }
-                    catch (Exception)
-                    {
-                        // file might be in use.
-                    }
-                }, 250, true);
+            return Storage;
         }
 
         /// <summary>
diff --git a/osu.Game.Tournament/Screens/SetupScreen.cs b/osu.Game.Tournament/Screens/SetupScreen.cs
new file mode 100644
index 0000000000..091a837745
--- /dev/null
+++ b/osu.Game.Tournament/Screens/SetupScreen.cs
@@ -0,0 +1,142 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Graphics.UserInterfaceV2;
+using osu.Game.Online.API;
+using osu.Game.Overlays;
+using osu.Game.Tournament.IPC;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Tournament.Screens
+{
+    public class SetupScreen : TournamentScreen, IProvideVideo
+    {
+        private FillFlowContainer fillFlow;
+
+        private LoginOverlay loginOverlay;
+
+        [Resolved]
+        private MatchIPCInfo ipc { get; set; }
+
+        [Resolved]
+        private IAPIProvider api { get; set; }
+
+        [BackgroundDependencyLoader]
+        private void load()
+        {
+            InternalChild = fillFlow = new FillFlowContainer
+            {
+                RelativeSizeAxes = Axes.X,
+                AutoSizeAxes = Axes.Y,
+                Direction = FillDirection.Vertical,
+                Padding = new MarginPadding(10),
+                Spacing = new Vector2(10),
+            };
+
+            api.LocalUser.BindValueChanged(_ => Schedule(reload));
+            reload();
+        }
+
+        private void reload()
+        {
+            var fileBasedIpc = ipc as FileBasedIPC;
+
+            fillFlow.Children = new Drawable[]
+            {
+                new ActionableInfo
+                {
+                    Label = "Current IPC source",
+                    ButtonText = "Refresh",
+                    Action = () =>
+                    {
+                        fileBasedIpc?.LocateStableStorage();
+                        reload();
+                    },
+                    Value = fileBasedIpc?.Storage?.GetFullPath(string.Empty) ?? "Not found",
+                    Failing = fileBasedIpc?.Storage == null,
+                    Description = "The osu!stable installation which is currently being used as a data source. If a source is not found, make sure you have created an empty ipc.txt in your stable cutting-edge installation, and that it is registered as the default osu! install."
+                },
+                new ActionableInfo
+                {
+                    Label = "Current User",
+                    ButtonText = "Change Login",
+                    Action = () =>
+                    {
+                        api.Logout();
+
+                        if (loginOverlay == null)
+                        {
+                            AddInternal(loginOverlay = new LoginOverlay
+                            {
+                                Anchor = Anchor.TopRight,
+                                Origin = Anchor.TopRight,
+                            });
+                        }
+
+                        loginOverlay.State.Value = Visibility.Visible;
+                    },
+                    Value = api?.LocalUser.Value.Username,
+                    Failing = api?.IsLoggedIn != true,
+                    Description = "In order to access the API and display metadata, a login is required."
+                }
+            };
+        }
+
+        private class ActionableInfo : LabelledComponent<Drawable>
+        {
+            private OsuButton button;
+
+            public ActionableInfo()
+                : base(true)
+            {
+            }
+
+            public string ButtonText
+            {
+                set => button.Text = value;
+            }
+
+            public string Value
+            {
+                set => valueText.Text = value;
+            }
+
+            public bool Failing
+            {
+                set => valueText.Colour = value ? Color4.Red : Color4.White;
+            }
+
+            public Action Action;
+
+            private OsuSpriteText valueText;
+
+            protected override Drawable CreateComponent() => new Container
+            {
+                AutoSizeAxes = Axes.Y,
+                RelativeSizeAxes = Axes.X,
+                Children = new Drawable[]
+                {
+                    valueText = new OsuSpriteText
+                    {
+                        Anchor = Anchor.CentreLeft,
+                        Origin = Anchor.CentreLeft,
+                    },
+                    button = new TriangleButton
+                    {
+                        Anchor = Anchor.CentreRight,
+                        Origin = Anchor.CentreRight,
+                        Size = new Vector2(100, 30),
+                        Action = () => Action?.Invoke()
+                    },
+                }
+            };
+        }
+    }
+}
diff --git a/osu.Game.Tournament/TournamentSceneManager.cs b/osu.Game.Tournament/TournamentSceneManager.cs
index 4c255be463..02ee1c8603 100644
--- a/osu.Game.Tournament/TournamentSceneManager.cs
+++ b/osu.Game.Tournament/TournamentSceneManager.cs
@@ -69,6 +69,7 @@ namespace osu.Game.Tournament
                             RelativeSizeAxes = Axes.Both,
                             Children = new Drawable[]
                             {
+                                new SetupScreen(),
                                 new ScheduleScreen(),
                                 new LadderScreen(),
                                 new LadderEditorScreen(),
@@ -106,6 +107,8 @@ namespace osu.Game.Tournament
                             Direction = FillDirection.Vertical,
                             Children = new Drawable[]
                             {
+                                new OsuButton { RelativeSizeAxes = Axes.X, Text = "Setup", Action = () => SetScreen(typeof(SetupScreen)) },
+                                new Container { RelativeSizeAxes = Axes.X, Height = 50 },
                                 new OsuButton { RelativeSizeAxes = Axes.X, Text = "Team Editor", Action = () => SetScreen(typeof(TeamEditorScreen)) },
                                 new OsuButton { RelativeSizeAxes = Axes.X, Text = "Rounds Editor", Action = () => SetScreen(typeof(RoundEditorScreen)) },
                                 new OsuButton { RelativeSizeAxes = Axes.X, Text = "Bracket Editor", Action = () => SetScreen(typeof(LadderEditorScreen)) },
@@ -127,7 +130,7 @@ namespace osu.Game.Tournament
                 },
             };
 
-            SetScreen(typeof(ScheduleScreen));
+            SetScreen(typeof(SetupScreen));
         }
 
         public void SetScreen(Type screenType)
diff --git a/osu.Game.Tournament/osu.Game.Tournament.csproj b/osu.Game.Tournament/osu.Game.Tournament.csproj
index 4790fcbcde..bddaff0a80 100644
--- a/osu.Game.Tournament/osu.Game.Tournament.csproj
+++ b/osu.Game.Tournament/osu.Game.Tournament.csproj
@@ -11,6 +11,6 @@
     <ProjectReference Include="..\osu.Game\osu.Game.csproj" />
   </ItemGroup>
   <ItemGroup Label="Package References">
-    <PackageReference Include="Microsoft.Win32.Registry" Version="4.5.0" />
+    <PackageReference Include="Microsoft.Win32.Registry" Version="4.6.0" />
   </ItemGroup>
 </Project>
\ No newline at end of file
diff --git a/osu.Game/Screens/Edit/Setup/Components/LabelledComponents/LabelledComponent.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledComponent.cs
similarity index 93%
rename from osu.Game/Screens/Edit/Setup/Components/LabelledComponents/LabelledComponent.cs
rename to osu.Game/Graphics/UserInterfaceV2/LabelledComponent.cs
index 19e9c329d6..2e659825b7 100644
--- a/osu.Game/Screens/Edit/Setup/Components/LabelledComponents/LabelledComponent.cs
+++ b/osu.Game/Graphics/UserInterfaceV2/LabelledComponent.cs
@@ -5,13 +5,13 @@ using osu.Framework.Allocation;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
-using osu.Game.Graphics;
 using osu.Game.Graphics.Containers;
 using osuTK;
 
-namespace osu.Game.Screens.Edit.Setup.Components.LabelledComponents
+namespace osu.Game.Graphics.UserInterfaceV2
 {
-    public abstract class LabelledComponent : CompositeDrawable
+    public abstract class LabelledComponent<T> : CompositeDrawable
+        where T : Drawable
     {
         protected const float CONTENT_PADDING_VERTICAL = 10;
         protected const float CONTENT_PADDING_HORIZONTAL = 15;
@@ -20,15 +20,15 @@ namespace osu.Game.Screens.Edit.Setup.Components.LabelledComponents
         /// <summary>
         /// The component that is being displayed.
         /// </summary>
-        protected readonly Drawable Component;
+        protected readonly T Component;
 
         private readonly OsuTextFlowContainer labelText;
         private readonly OsuTextFlowContainer descriptionText;
 
         /// <summary>
-        /// Creates a new <see cref="LabelledComponent"/>.
+        /// Creates a new <see cref="LabelledComponent{T}"/>.
         /// </summary>
-        /// <param name="padded">Whether the component should be padded or should be expanded to the bounds of this <see cref="LabelledComponent"/>.</param>
+        /// <param name="padded">Whether the component should be padded or should be expanded to the bounds of this <see cref="LabelledComponent{T}"/>.</param>
         protected LabelledComponent(bool padded)
         {
             RelativeSizeAxes = Axes.X;
@@ -127,6 +127,6 @@ namespace osu.Game.Screens.Edit.Setup.Components.LabelledComponents
         /// Creates the component that should be displayed.
         /// </summary>
         /// <returns>The component.</returns>
-        protected abstract Drawable CreateComponent();
+        protected abstract T CreateComponent();
     }
 }
diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
new file mode 100644
index 0000000000..50d2a14482
--- /dev/null
+++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.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.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.UserInterface;
+using osu.Game.Graphics.UserInterface;
+
+namespace osu.Game.Graphics.UserInterfaceV2
+{
+    public class LabelledTextBox : LabelledComponent<OsuTextBox>
+    {
+        public event TextBox.OnCommitHandler OnCommit;
+
+        public LabelledTextBox()
+            : base(false)
+        {
+        }
+
+        public bool ReadOnly
+        {
+            set => Component.ReadOnly = value;
+        }
+
+        public string PlaceholderText
+        {
+            set => Component.PlaceholderText = value;
+        }
+
+        public string Text
+        {
+            set => Component.Text = value;
+        }
+
+        [BackgroundDependencyLoader]
+        private void load(OsuColour colours)
+        {
+            Component.BorderColour = colours.Blue;
+        }
+
+        protected override OsuTextBox CreateComponent() => new OsuTextBox
+        {
+            Anchor = Anchor.Centre,
+            Origin = Anchor.Centre,
+            RelativeSizeAxes = Axes.X,
+            CornerRadius = CORNER_RADIUS,
+        }.With(t => t.OnCommit += (sender, newText) => OnCommit?.Invoke(sender, newText));
+    }
+}
diff --git a/osu.Game/Overlays/Changelog/ChangelogBuild.cs b/osu.Game/Overlays/Changelog/ChangelogBuild.cs
index 11dc2049fd..bce1be5941 100644
--- a/osu.Game/Overlays/Changelog/ChangelogBuild.cs
+++ b/osu.Game/Overlays/Changelog/ChangelogBuild.cs
@@ -15,6 +15,7 @@ using osu.Game.Users;
 using osuTK.Graphics;
 using osu.Framework.Allocation;
 using System.Net;
+using osuTK;
 
 namespace osu.Game.Overlays.Changelog
 {
@@ -67,22 +68,34 @@ namespace osu.Game.Overlays.Changelog
 
                 foreach (APIChangelogEntry entry in categoryEntries)
                 {
-                    LinkFlowContainer title = new LinkFlowContainer
-                    {
-                        Direction = FillDirection.Full,
-                        RelativeSizeAxes = Axes.X,
-                        AutoSizeAxes = Axes.Y,
-                        Margin = new MarginPadding { Vertical = 5 },
-                    };
-
                     var entryColour = entry.Major ? colours.YellowLight : Color4.White;
 
-                    title.AddIcon(entry.Type == ChangelogEntryType.Fix ? FontAwesome.Solid.Check : FontAwesome.Solid.Plus, t =>
+                    LinkFlowContainer title;
+
+                    Container titleContainer = new Container
                     {
-                        t.Font = fontSmall;
-                        t.Colour = entryColour;
-                        t.Padding = new MarginPadding { Left = -17, Right = 5 };
-                    });
+                        AutoSizeAxes = Axes.Y,
+                        RelativeSizeAxes = Axes.X,
+                        Margin = new MarginPadding { Vertical = 5 },
+                        Children = new Drawable[]
+                        {
+                            new SpriteIcon
+                            {
+                                Anchor = Anchor.CentreLeft,
+                                Origin = Anchor.CentreRight,
+                                Size = new Vector2(fontSmall.Size),
+                                Icon = entry.Type == ChangelogEntryType.Fix ? FontAwesome.Solid.Check : FontAwesome.Solid.Plus,
+                                Colour = entryColour,
+                                Margin = new MarginPadding { Right = 5 },
+                            },
+                            title = new LinkFlowContainer
+                            {
+                                Direction = FillDirection.Full,
+                                RelativeSizeAxes = Axes.X,
+                                AutoSizeAxes = Axes.Y,
+                            }
+                        }
+                    };
 
                     title.AddText(entry.Title, t =>
                     {
@@ -139,7 +152,7 @@ namespace osu.Game.Overlays.Changelog
                             t.Colour = entryColour;
                         });
 
-                    ChangelogEntries.Add(title);
+                    ChangelogEntries.Add(titleContainer);
 
                     if (!string.IsNullOrEmpty(entry.MessageHtml))
                     {
diff --git a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs
index 66fec1ecf9..b02b1a5489 100644
--- a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs
@@ -67,7 +67,7 @@ namespace osu.Game.Overlays.Settings.Sections.General
             api?.Register(this);
         }
 
-        public void APIStateChanged(IAPIProvider api, APIState state)
+        public void APIStateChanged(IAPIProvider api, APIState state) => Schedule(() =>
         {
             form = null;
 
@@ -184,7 +184,7 @@ namespace osu.Game.Overlays.Settings.Sections.General
             }
 
             if (form != null) GetContainingInputManager()?.ChangeFocus(form);
-        }
+        });
 
         public override bool AcceptsFocus => true;
 
diff --git a/osu.Game/Rulesets/Objects/BarLine.cs b/osu.Game/Rulesets/Objects/BarLine.cs
deleted file mode 100644
index a5c716e127..0000000000
--- a/osu.Game/Rulesets/Objects/BarLine.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-namespace osu.Game.Rulesets.Objects
-{
-    /// <summary>
-    /// A hit object representing the end of a bar.
-    /// </summary>
-    public class BarLine : HitObject
-    {
-        /// <summary>
-        /// Whether this barline is a prominent beat (based on time signature of beatmap).
-        /// </summary>
-        public bool Major;
-    }
-}
diff --git a/osu.Game/Rulesets/Objects/BarLineGenerator.cs b/osu.Game/Rulesets/Objects/BarLineGenerator.cs
index ce571d7b17..4f9395435e 100644
--- a/osu.Game/Rulesets/Objects/BarLineGenerator.cs
+++ b/osu.Game/Rulesets/Objects/BarLineGenerator.cs
@@ -10,12 +10,13 @@ using osu.Game.Rulesets.Objects.Types;
 
 namespace osu.Game.Rulesets.Objects
 {
-    public class BarLineGenerator
+    public class BarLineGenerator<TBarLine>
+        where TBarLine : class, IBarLine, new()
     {
         /// <summary>
         /// The generated bar lines.
         /// </summary>
-        public readonly List<BarLine> BarLines = new List<BarLine>();
+        public readonly List<TBarLine> BarLines = new List<TBarLine>();
 
         /// <summary>
         /// Constructs and generates bar lines for provided beatmap.
@@ -46,7 +47,7 @@ namespace osu.Game.Rulesets.Objects
 
                 for (double t = currentTimingPoint.Time; Precision.DefinitelyBigger(endTime, t); t += barLength, currentBeat++)
                 {
-                    BarLines.Add(new BarLine
+                    BarLines.Add(new TBarLine
                     {
                         StartTime = t,
                         Major = currentBeat % (int)currentTimingPoint.TimeSignature == 0
diff --git a/osu.Game/Rulesets/Objects/IBarLine.cs b/osu.Game/Rulesets/Objects/IBarLine.cs
new file mode 100644
index 0000000000..14df80e3b9
--- /dev/null
+++ b/osu.Game/Rulesets/Objects/IBarLine.cs
@@ -0,0 +1,22 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+namespace osu.Game.Rulesets.Objects
+{
+    /// <summary>
+    /// Interface for bar line hitobjects.
+    /// Used to decouple bar line generation from ruleset-specific rendering/drawing hierarchies.
+    /// </summary>
+    public interface IBarLine
+    {
+        /// <summary>
+        /// The time position of the bar.
+        /// </summary>
+        double StartTime { set; }
+
+        /// <summary>
+        /// Whether this bar line is a prominent beat (based on time signature of beatmap).
+        /// </summary>
+        bool Major { set; }
+    }
+}
diff --git a/osu.Game/Screens/Edit/Setup/Components/LabelledComponents/LabelledTextBox.cs b/osu.Game/Screens/Edit/Setup/Components/LabelledComponents/LabelledTextBox.cs
deleted file mode 100644
index 1c53fc7088..0000000000
--- a/osu.Game/Screens/Edit/Setup/Components/LabelledComponents/LabelledTextBox.cs
+++ /dev/null
@@ -1,129 +0,0 @@
-// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using osu.Framework.Allocation;
-using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
-using osu.Framework.Graphics.Shapes;
-using osu.Framework.Graphics.UserInterface;
-using osu.Game.Graphics;
-using osu.Game.Graphics.Sprites;
-using osu.Game.Graphics.UserInterface;
-using osuTK.Graphics;
-
-namespace osu.Game.Screens.Edit.Setup.Components.LabelledComponents
-{
-    public class LabelledTextBox : CompositeDrawable
-    {
-        private const float label_container_width = 150;
-        private const float corner_radius = 15;
-        private const float default_height = 40;
-        private const float default_label_left_padding = 15;
-        private const float default_label_top_padding = 12;
-        private const float default_label_text_size = 16;
-
-        public event TextBox.OnCommitHandler OnCommit;
-
-        public bool ReadOnly
-        {
-            get => textBox.ReadOnly;
-            set => textBox.ReadOnly = value;
-        }
-
-        public string LabelText
-        {
-            get => label.Text;
-            set => label.Text = value;
-        }
-
-        public float LabelTextSize
-        {
-            get => label.Font.Size;
-            set => label.Font = label.Font.With(size: value);
-        }
-
-        public string PlaceholderText
-        {
-            get => textBox.PlaceholderText;
-            set => textBox.PlaceholderText = value;
-        }
-
-        public string Text
-        {
-            get => textBox.Text;
-            set => textBox.Text = value;
-        }
-
-        public Color4 LabelTextColour
-        {
-            get => label.Colour;
-            set => label.Colour = value;
-        }
-
-        private readonly OsuTextBox textBox;
-        private readonly OsuSpriteText label;
-
-        public LabelledTextBox()
-        {
-            RelativeSizeAxes = Axes.X;
-            Height = default_height;
-            CornerRadius = corner_radius;
-            Masking = true;
-
-            InternalChild = new Container
-            {
-                RelativeSizeAxes = Axes.Both,
-                CornerRadius = corner_radius,
-                Masking = true,
-                Children = new Drawable[]
-                {
-                    new Box
-                    {
-                        RelativeSizeAxes = Axes.Both,
-                        Colour = OsuColour.FromHex("1c2125"),
-                    },
-                    new GridContainer
-                    {
-                        RelativeSizeAxes = Axes.X,
-                        Height = default_height,
-                        Content = new[]
-                        {
-                            new Drawable[]
-                            {
-                                label = new OsuSpriteText
-                                {
-                                    Anchor = Anchor.TopLeft,
-                                    Origin = Anchor.TopLeft,
-                                    Padding = new MarginPadding { Left = default_label_left_padding, Top = default_label_top_padding },
-                                    Colour = Color4.White,
-                                    Font = OsuFont.GetFont(size: default_label_text_size, weight: FontWeight.Bold),
-                                },
-                                textBox = new OsuTextBox
-                                {
-                                    Anchor = Anchor.TopLeft,
-                                    Origin = Anchor.TopLeft,
-                                    RelativeSizeAxes = Axes.Both,
-                                    Height = 1,
-                                    CornerRadius = corner_radius,
-                                },
-                            },
-                        },
-                        ColumnDimensions = new[]
-                        {
-                            new Dimension(GridSizeMode.Absolute, label_container_width),
-                            new Dimension()
-                        }
-                    }
-                }
-            };
-
-            textBox.OnCommit += OnCommit;
-        }
-
-        [BackgroundDependencyLoader]
-        private void load(OsuColour colours)
-        {
-            textBox.BorderColour = colours.Blue;
-        }
-    }
-}
diff --git a/osu.Game/Screens/Menu/Button.cs b/osu.Game/Screens/Menu/Button.cs
index 1bf25a2504..ffeadb96c7 100644
--- a/osu.Game/Screens/Menu/Button.cs
+++ b/osu.Game/Screens/Menu/Button.cs
@@ -31,6 +31,8 @@ namespace osu.Game.Screens.Menu
     {
         public event Action<ButtonState> StateChanged;
 
+        public readonly Key TriggerKey;
+
         private readonly Container iconText;
         private readonly Container box;
         private readonly Box boxHoverLayer;
@@ -43,7 +45,6 @@ namespace osu.Game.Screens.Menu
         public ButtonSystemState VisibleState = ButtonSystemState.TopLevel;
 
         private readonly Action clickAction;
-        private readonly Key triggerKey;
         private SampleChannel sampleClick;
         private SampleChannel sampleHover;
 
@@ -53,7 +54,7 @@ namespace osu.Game.Screens.Menu
         {
             this.sampleName = sampleName;
             this.clickAction = clickAction;
-            this.triggerKey = triggerKey;
+            TriggerKey = triggerKey;
 
             AutoSizeAxes = Axes.Both;
             Alpha = 0;
@@ -210,7 +211,7 @@ namespace osu.Game.Screens.Menu
             if (e.Repeat || e.ControlPressed || e.ShiftPressed || e.AltPressed)
                 return false;
 
-            if (triggerKey == e.Key && triggerKey != Key.Unknown)
+            if (TriggerKey == e.Key && TriggerKey != Key.Unknown)
             {
                 trigger();
                 return true;
diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs
index 1a3e1213b4..ed8e4c70f9 100644
--- a/osu.Game/Screens/Menu/ButtonSystem.cs
+++ b/osu.Game/Screens/Menu/ButtonSystem.cs
@@ -14,6 +14,7 @@ using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Sprites;
 using osu.Framework.Input.Bindings;
+using osu.Framework.Input.Events;
 using osu.Framework.Logging;
 using osu.Framework.Platform;
 using osu.Framework.Threading;
@@ -180,6 +181,20 @@ namespace osu.Game.Screens.Menu
                 State = ButtonSystemState.Initial;
         }
 
+        protected override bool OnKeyDown(KeyDownEvent e)
+        {
+            if (State == ButtonSystemState.Initial)
+            {
+                if (buttonsTopLevel.Any(b => e.Key == b.TriggerKey))
+                {
+                    logo?.Click();
+                    return true;
+                }
+            }
+
+            return base.OnKeyDown(e);
+        }
+
         public bool OnPressed(GlobalAction action)
         {
             switch (action)
diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs
index 23c581c6f9..c3436ffd45 100644
--- a/osu.Game/Screens/Select/BeatmapCarousel.cs
+++ b/osu.Game/Screens/Select/BeatmapCarousel.cs
@@ -82,6 +82,9 @@ namespace osu.Game.Screens.Select
             var _ = newRoot.Drawables;
 
             root = newRoot;
+            if (selectedBeatmapSet != null && !beatmapSets.Contains(selectedBeatmapSet.BeatmapSet))
+                selectedBeatmapSet = null;
+
             scrollableContent.Clear(false);
             itemsCache.Invalidate();
             scrollPositionCache.Invalidate();
diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs
index 9cc84c8bdd..6c3c9d20f3 100644
--- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs
+++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs
@@ -39,6 +39,10 @@ namespace osu.Game.Screens.Select.Carousel
             match &= criteria.BeatDivisor.IsInRange(Beatmap.BeatDivisor);
             match &= criteria.OnlineStatus.IsInRange(Beatmap.Status);
 
+            match &= criteria.Creator.Matches(Beatmap.Metadata.AuthorString);
+            match &= criteria.Artist.Matches(Beatmap.Metadata.Artist) ||
+                     criteria.Artist.Matches(Beatmap.Metadata.ArtistUnicode);
+
             if (match)
                 foreach (var criteriaTerm in criteria.SearchTerms)
                     match &=
diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs
index e3c23f7e22..91f1ca0307 100644
--- a/osu.Game/Screens/Select/FilterControl.cs
+++ b/osu.Game/Screens/Select/FilterControl.cs
@@ -16,8 +16,6 @@ using Container = osu.Framework.Graphics.Containers.Container;
 using osu.Framework.Graphics.Shapes;
 using osu.Game.Configuration;
 using osu.Game.Rulesets;
-using System.Text.RegularExpressions;
-using osu.Game.Beatmaps;
 
 namespace osu.Game.Screens.Select
 {
@@ -47,10 +45,7 @@ namespace osu.Game.Screens.Select
                 Ruleset = ruleset.Value
             };
 
-            applyQueries(criteria, ref query);
-
-            criteria.SearchText = query;
-
+            FilterQueryParser.ApplyQueries(criteria, query);
             return criteria;
         }
 
@@ -181,129 +176,5 @@ namespace osu.Game.Screens.Select
         }
 
         private void updateCriteria() => FilterChanged?.Invoke(CreateCriteria());
-
-        private static readonly Regex query_syntax_regex = new Regex(
-            @"\b(?<key>stars|ar|dr|cs|divisor|length|objects|bpm|status)(?<op>[=:><]+)(?<value>\S*)",
-            RegexOptions.Compiled | RegexOptions.IgnoreCase);
-
-        private void applyQueries(FilterCriteria criteria, ref string query)
-        {
-            foreach (Match match in query_syntax_regex.Matches(query))
-            {
-                var key = match.Groups["key"].Value.ToLower();
-                var op = match.Groups["op"].Value;
-                var value = match.Groups["value"].Value;
-
-                switch (key)
-                {
-                    case "stars" when float.TryParse(value, out var stars):
-                        updateCriteriaRange(ref criteria.StarDifficulty, op, stars);
-                        break;
-
-                    case "ar" when float.TryParse(value, out var ar):
-                        updateCriteriaRange(ref criteria.ApproachRate, op, ar);
-                        break;
-
-                    case "dr" when float.TryParse(value, out var dr):
-                        updateCriteriaRange(ref criteria.DrainRate, op, dr);
-                        break;
-
-                    case "cs" when float.TryParse(value, out var cs):
-                        updateCriteriaRange(ref criteria.CircleSize, op, cs);
-                        break;
-
-                    case "bpm" when double.TryParse(value, out var bpm):
-                        updateCriteriaRange(ref criteria.BPM, op, bpm);
-                        break;
-
-                    case "length" when double.TryParse(value.TrimEnd('m', 's', 'h'), out var length):
-                        var scale =
-                            value.EndsWith("ms") ? 1 :
-                            value.EndsWith("s") ? 1000 :
-                            value.EndsWith("m") ? 60000 :
-                            value.EndsWith("h") ? 3600000 : 1000;
-
-                        updateCriteriaRange(ref criteria.Length, op, length * scale, scale / 2.0);
-                        break;
-
-                    case "divisor" when int.TryParse(value, out var divisor):
-                        updateCriteriaRange(ref criteria.BeatDivisor, op, divisor);
-                        break;
-
-                    case "status" when Enum.TryParse<BeatmapSetOnlineStatus>(value, true, out var statusValue):
-                        updateCriteriaRange(ref criteria.OnlineStatus, op, statusValue);
-                        break;
-                }
-
-                query = query.Replace(match.ToString(), "");
-            }
-        }
-
-        private void updateCriteriaRange(ref FilterCriteria.OptionalRange<float> range, string op, float value, float tolerance = 0.05f)
-        {
-            updateCriteriaRange(ref range, op, value);
-
-            switch (op)
-            {
-                case "=":
-                case ":":
-                    range.Min = value - tolerance;
-                    range.Max = value + tolerance;
-                    break;
-            }
-        }
-
-        private void updateCriteriaRange(ref FilterCriteria.OptionalRange<double> range, string op, double value, double tolerance = 0.05)
-        {
-            updateCriteriaRange(ref range, op, value);
-
-            switch (op)
-            {
-                case "=":
-                case ":":
-                    range.Min = value - tolerance;
-                    range.Max = value + tolerance;
-                    break;
-            }
-        }
-
-        private void updateCriteriaRange<T>(ref FilterCriteria.OptionalRange<T> range, string op, T value)
-            where T : struct, IComparable
-        {
-            switch (op)
-            {
-                default:
-                    return;
-
-                case "=":
-                case ":":
-                    range.IsInclusive = true;
-                    range.Min = value;
-                    range.Max = value;
-                    break;
-
-                case ">":
-                    range.IsInclusive = false;
-                    range.Min = value;
-                    break;
-
-                case ">=":
-                case ">:":
-                    range.IsInclusive = true;
-                    range.Min = value;
-                    break;
-
-                case "<":
-                    range.IsInclusive = false;
-                    range.Max = value;
-                    break;
-
-                case "<=":
-                case "<:":
-                    range.IsInclusive = true;
-                    range.Max = value;
-                    break;
-            }
-        }
     }
 }
diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs
index a3fa1b10ca..c2cbac905e 100644
--- a/osu.Game/Screens/Select/FilterCriteria.cs
+++ b/osu.Game/Screens/Select/FilterCriteria.cs
@@ -23,6 +23,8 @@ namespace osu.Game.Screens.Select
         public OptionalRange<double> BPM;
         public OptionalRange<int> BeatDivisor;
         public OptionalRange<BeatmapSetOnlineStatus> OnlineStatus;
+        public OptionalTextFilter Creator;
+        public OptionalTextFilter Artist;
 
         public string[] SearchTerms = Array.Empty<string>();
 
@@ -53,7 +55,7 @@ namespace osu.Game.Screens.Select
                     if (comparison < 0)
                         return false;
 
-                    if (comparison == 0 && !IsInclusive)
+                    if (comparison == 0 && !IsLowerInclusive)
                         return false;
                 }
 
@@ -64,7 +66,7 @@ namespace osu.Game.Screens.Select
                     if (comparison > 0)
                         return false;
 
-                    if (comparison == 0 && !IsInclusive)
+                    if (comparison == 0 && !IsUpperInclusive)
                         return false;
                 }
 
@@ -73,12 +75,33 @@ namespace osu.Game.Screens.Select
 
             public T? Min;
             public T? Max;
-            public bool IsInclusive;
+            public bool IsLowerInclusive;
+            public bool IsUpperInclusive;
 
             public bool Equals(OptionalRange<T> other)
                 => Min.Equals(other.Min)
                    && Max.Equals(other.Max)
-                   && IsInclusive.Equals(other.IsInclusive);
+                   && IsLowerInclusive.Equals(other.IsLowerInclusive)
+                   && IsUpperInclusive.Equals(other.IsUpperInclusive);
+        }
+
+        public struct OptionalTextFilter : IEquatable<OptionalTextFilter>
+        {
+            public bool Matches(string value)
+            {
+                if (string.IsNullOrEmpty(SearchTerm))
+                    return true;
+
+                // search term is guaranteed to be non-empty, so if the string we're comparing is empty, it's not matching
+                if (string.IsNullOrEmpty(value))
+                    return false;
+
+                return value.IndexOf(SearchTerm, StringComparison.InvariantCultureIgnoreCase) >= 0;
+            }
+
+            public string SearchTerm;
+
+            public bool Equals(OptionalTextFilter other) => SearchTerm?.Equals(other.SearchTerm) ?? true;
         }
     }
 }
diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs
new file mode 100644
index 0000000000..ffe1258168
--- /dev/null
+++ b/osu.Game/Screens/Select/FilterQueryParser.cs
@@ -0,0 +1,211 @@
+// 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.Globalization;
+using System.Text.RegularExpressions;
+using osu.Game.Beatmaps;
+
+namespace osu.Game.Screens.Select
+{
+    internal static class FilterQueryParser
+    {
+        private static readonly Regex query_syntax_regex = new Regex(
+            @"\b(?<key>stars|ar|dr|cs|divisor|length|objects|bpm|status|creator|artist)(?<op>[=:><]+)(?<value>("".*"")|(\S*))",
+            RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+        internal static void ApplyQueries(FilterCriteria criteria, string query)
+        {
+            foreach (Match match in query_syntax_regex.Matches(query))
+            {
+                var key = match.Groups["key"].Value.ToLower();
+                var op = match.Groups["op"].Value;
+                var value = match.Groups["value"].Value;
+
+                parseKeywordCriteria(criteria, key, value, op);
+
+                query = query.Replace(match.ToString(), "");
+            }
+
+            criteria.SearchText = query;
+        }
+
+        private static void parseKeywordCriteria(FilterCriteria criteria, string key, string value, string op)
+        {
+            switch (key)
+            {
+                case "stars" when parseFloatWithPoint(value, out var stars):
+                    updateCriteriaRange(ref criteria.StarDifficulty, op, stars, 0.01f / 2);
+                    break;
+
+                case "ar" when parseFloatWithPoint(value, out var ar):
+                    updateCriteriaRange(ref criteria.ApproachRate, op, ar, 0.1f / 2);
+                    break;
+
+                case "dr" when parseFloatWithPoint(value, out var dr):
+                    updateCriteriaRange(ref criteria.DrainRate, op, dr, 0.1f / 2);
+                    break;
+
+                case "cs" when parseFloatWithPoint(value, out var cs):
+                    updateCriteriaRange(ref criteria.CircleSize, op, cs, 0.1f / 2);
+                    break;
+
+                case "bpm" when parseDoubleWithPoint(value, out var bpm):
+                    updateCriteriaRange(ref criteria.BPM, op, bpm, 0.01d / 2);
+                    break;
+
+                case "length" when parseDoubleWithPoint(value.TrimEnd('m', 's', 'h'), out var length):
+                    var scale = getLengthScale(value);
+                    updateCriteriaRange(ref criteria.Length, op, length * scale, scale / 2.0);
+                    break;
+
+                case "divisor" when parseInt(value, out var divisor):
+                    updateCriteriaRange(ref criteria.BeatDivisor, op, divisor);
+                    break;
+
+                case "status" when Enum.TryParse<BeatmapSetOnlineStatus>(value, true, out var statusValue):
+                    updateCriteriaRange(ref criteria.OnlineStatus, op, statusValue);
+                    break;
+
+                case "creator":
+                    updateCriteriaText(ref criteria.Creator, op, value);
+                    break;
+
+                case "artist":
+                    updateCriteriaText(ref criteria.Artist, op, value);
+                    break;
+            }
+        }
+
+        private static int getLengthScale(string value) =>
+            value.EndsWith("ms") ? 1 :
+            value.EndsWith("s") ? 1000 :
+            value.EndsWith("m") ? 60000 :
+            value.EndsWith("h") ? 3600000 : 1000;
+
+        private static bool parseFloatWithPoint(string value, out float result) =>
+            float.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result);
+
+        private static bool parseDoubleWithPoint(string value, out double result) =>
+            double.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result);
+
+        private static bool parseInt(string value, out int result) =>
+            int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out result);
+
+        private static void updateCriteriaText(ref FilterCriteria.OptionalTextFilter textFilter, string op, string value)
+        {
+            switch (op)
+            {
+                case "=":
+                case ":":
+                    textFilter.SearchTerm = value.Trim('"');
+                    break;
+            }
+        }
+
+        private static void updateCriteriaRange(ref FilterCriteria.OptionalRange<float> range, string op, float value, float tolerance = 0.05f)
+        {
+            switch (op)
+            {
+                default:
+                    return;
+
+                case "=":
+                case ":":
+                    range.Min = value - tolerance;
+                    range.Max = value + tolerance;
+                    break;
+
+                case ">":
+                    range.Min = value + tolerance;
+                    break;
+
+                case ">=":
+                case ">:":
+                    range.Min = value - tolerance;
+                    break;
+
+                case "<":
+                    range.Max = value - tolerance;
+                    break;
+
+                case "<=":
+                case "<:":
+                    range.Max = value + tolerance;
+                    break;
+            }
+        }
+
+        private static void updateCriteriaRange(ref FilterCriteria.OptionalRange<double> range, string op, double value, double tolerance = 0.05)
+        {
+            switch (op)
+            {
+                default:
+                    return;
+
+                case "=":
+                case ":":
+                    range.Min = value - tolerance;
+                    range.Max = value + tolerance;
+                    break;
+
+                case ">":
+                    range.Min = value + tolerance;
+                    break;
+
+                case ">=":
+                case ">:":
+                    range.Min = value - tolerance;
+                    break;
+
+                case "<":
+                    range.Max = value - tolerance;
+                    break;
+
+                case "<=":
+                case "<:":
+                    range.Max = value + tolerance;
+                    break;
+            }
+        }
+
+        private static void updateCriteriaRange<T>(ref FilterCriteria.OptionalRange<T> range, string op, T value)
+            where T : struct, IComparable
+        {
+            switch (op)
+            {
+                default:
+                    return;
+
+                case "=":
+                case ":":
+                    range.IsLowerInclusive = range.IsUpperInclusive = true;
+                    range.Min = value;
+                    range.Max = value;
+                    break;
+
+                case ">":
+                    range.IsLowerInclusive = false;
+                    range.Min = value;
+                    break;
+
+                case ">=":
+                case ">:":
+                    range.IsLowerInclusive = true;
+                    range.Min = value;
+                    break;
+
+                case "<":
+                    range.IsUpperInclusive = false;
+                    range.Max = value;
+                    break;
+
+                case "<=":
+                case "<:":
+                    range.IsUpperInclusive = true;
+                    range.Max = value;
+                    break;
+            }
+        }
+    }
+}
diff --git a/osu.Desktop/Updater/SimpleUpdateManager.cs b/osu.Game/Updater/SimpleUpdateManager.cs
similarity index 87%
rename from osu.Desktop/Updater/SimpleUpdateManager.cs
rename to osu.Game/Updater/SimpleUpdateManager.cs
index 5184791de1..eec27d3325 100644
--- a/osu.Desktop/Updater/SimpleUpdateManager.cs
+++ b/osu.Game/Updater/SimpleUpdateManager.cs
@@ -6,31 +6,25 @@ using System.Threading.Tasks;
 using Newtonsoft.Json;
 using osu.Framework;
 using osu.Framework.Allocation;
-using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Sprites;
 using osu.Framework.IO.Network;
 using osu.Framework.Platform;
-using osu.Game;
-using osu.Game.Overlays;
 using osu.Game.Overlays.Notifications;
 
-namespace osu.Desktop.Updater
+namespace osu.Game.Updater
 {
     /// <summary>
     /// An update manager that shows notifications if a newer release is detected.
     /// Installation is left up to the user.
     /// </summary>
-    internal class SimpleUpdateManager : CompositeDrawable
+    public class SimpleUpdateManager : UpdateManager
     {
-        private NotificationOverlay notificationOverlay;
         private string version;
         private GameHost host;
 
         [BackgroundDependencyLoader]
-        private void load(NotificationOverlay notification, OsuGameBase game, GameHost host)
+        private void load(OsuGameBase game, GameHost host)
         {
-            notificationOverlay = notification;
-
             this.host = host;
             version = game.Version;
 
@@ -50,7 +44,7 @@ namespace osu.Desktop.Updater
 
                 if (latest.TagName != version)
                 {
-                    notificationOverlay.Post(new SimpleNotification
+                    Notifications.Post(new SimpleNotification
                     {
                         Text = $"A newer release of osu! has been found ({version} → {latest.TagName}).\n\n"
                                + "Click here to download the new version, which can be installed over the top of your existing installation",
@@ -82,6 +76,10 @@ namespace osu.Desktop.Updater
                 case RuntimeInfo.Platform.MacOsx:
                     bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".app.zip"));
                     break;
+
+                case RuntimeInfo.Platform.Android:
+                    bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".apk"));
+                    break;
             }
 
             return bestAsset?.BrowserDownloadUrl ?? release.HtmlUrl;
diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs
new file mode 100644
index 0000000000..e256cdbe45
--- /dev/null
+++ b/osu.Game/Updater/UpdateManager.cs
@@ -0,0 +1,67 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Configuration;
+using osu.Game.Graphics;
+using osu.Game.Overlays;
+using osu.Game.Overlays.Notifications;
+
+namespace osu.Game.Updater
+{
+    public abstract class UpdateManager : CompositeDrawable
+    {
+        [Resolved]
+        private OsuConfigManager config { get; set; }
+
+        [Resolved]
+        private OsuGameBase game { get; set; }
+
+        [Resolved]
+        protected NotificationOverlay Notifications { get; private set; }
+
+        protected override void LoadComplete()
+        {
+            base.LoadComplete();
+
+            var version = game.Version;
+            var lastVersion = config.Get<string>(OsuSetting.Version);
+
+            if (game.IsDeployedBuild && version != lastVersion)
+            {
+                config.Set(OsuSetting.Version, version);
+
+                // only show a notification if we've previously saved a version to the config file (ie. not the first run).
+                if (!string.IsNullOrEmpty(lastVersion))
+                    Notifications.Post(new UpdateCompleteNotification(version));
+            }
+        }
+
+        private class UpdateCompleteNotification : SimpleNotification
+        {
+            private readonly string version;
+
+            public UpdateCompleteNotification(string version)
+            {
+                this.version = version;
+                Text = $"You are now running osu!lazer {version}.\nClick to see what's new!";
+            }
+
+            [BackgroundDependencyLoader]
+            private void load(OsuColour colours, ChangelogOverlay changelog, NotificationOverlay notificationOverlay)
+            {
+                Icon = FontAwesome.Solid.CheckSquare;
+                IconBackgound.Colour = colours.BlueDark;
+
+                Activated = delegate
+                {
+                    notificationOverlay.Hide();
+                    changelog.ShowBuild(OsuGameBase.CLIENT_STREAM_NAME, version);
+                    return true;
+                };
+            }
+        }
+    }
+}
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index a27a94b8f9..83632f3d41 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -26,10 +26,10 @@
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
     <PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
     <PackageReference Include="ppy.osu.Game.Resources" Version="2019.913.0" />
-    <PackageReference Include="ppy.osu.Framework" Version="2019.921.0" />
+    <PackageReference Include="ppy.osu.Framework" Version="2019.924.0" />
     <PackageReference Include="SharpCompress" Version="0.24.0" />
     <PackageReference Include="NUnit" Version="3.12.0" />
     <PackageReference Include="SharpRaven" Version="2.4.0" />
-    <PackageReference Include="System.ComponentModel.Annotations" Version="4.5.0" />
+    <PackageReference Include="System.ComponentModel.Annotations" Version="4.6.0" />
   </ItemGroup>
 </Project>
diff --git a/osu.iOS.props b/osu.iOS.props
index a6516e6d1b..30f1da362d 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -118,12 +118,12 @@
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.1" />
     <PackageReference Include="Newtonsoft.Json" Version="12.0.1" />
     <PackageReference Include="ppy.osu.Game.Resources" Version="2019.913.0" />
-    <PackageReference Include="ppy.osu.Framework" Version="2019.921.0" />
-    <PackageReference Include="ppy.osu.Framework.iOS" Version="2019.921.0" />
+    <PackageReference Include="ppy.osu.Framework" Version="2019.924.0" />
+    <PackageReference Include="ppy.osu.Framework.iOS" Version="2019.924.0" />
     <PackageReference Include="SharpCompress" Version="0.24.0" />
     <PackageReference Include="NUnit" Version="3.11.0" />
     <PackageReference Include="SharpRaven" Version="2.4.0" />
-    <PackageReference Include="System.ComponentModel.Annotations" Version="4.5.0" />
+    <PackageReference Include="System.ComponentModel.Annotations" Version="4.6.0" />
     <PackageReference Include="ppy.osu.Framework.NativeLibs" Version="2019.813.0" ExcludeAssets="all" />
   </ItemGroup>
 </Project>