diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000000..221e4746cb
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,8 @@
+Add any details pertaining to developers above the break.
+
+- [ ] Depends on #PR
+- Closes #ISSUE
+
+---
+
+Add a sentence or two describing this change in plain english. This will be displayed on the [changelog](https://osu.ppy.sh/home/changelog). A single screenshot or short gif is also welcomed.
\ No newline at end of file
diff --git a/.idea/.idea.osu/.idea/runConfigurations/VisualTests__netcoreapp2_1_.xml b/.idea/.idea.osu/.idea/runConfigurations/VisualTests.xml
similarity index 87%
rename from .idea/.idea.osu/.idea/runConfigurations/VisualTests__netcoreapp2_1_.xml
rename to .idea/.idea.osu/.idea/runConfigurations/VisualTests.xml
index 2d3a848922..bf5a1f64e0 100644
--- a/.idea/.idea.osu/.idea/runConfigurations/VisualTests__netcoreapp2_1_.xml
+++ b/.idea/.idea.osu/.idea/runConfigurations/VisualTests.xml
@@ -1,12 +1,11 @@
 <component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="VisualTests (netcoreapp2.1)" type="DotNetProject" factoryName=".NET Project">
+  <configuration default="false" name="VisualTests" type="DotNetProject" factoryName=".NET Project">
     <option name="EXE_PATH" value="$PROJECT_DIR$/osu.Game.Tests/bin/Debug/netcoreapp2.1/osu.Game.Tests.dll" />
     <option name="PROGRAM_PARAMETERS" value="" />
     <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/osu.Game.Tests" />
     <option name="PASS_PARENT_ENVS" value="1" />
-    <envs />
-    <option name="USE_MONO" value="0" />
     <option name="USE_EXTERNAL_CONSOLE" value="0" />
+    <option name="USE_MONO" value="0" />
     <option name="PROJECT_PATH" value="$PROJECT_DIR$/osu.Game.Tests/osu.Game.Tests.csproj" />
     <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
     <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
diff --git a/.idea/.idea.osu/.idea/runConfigurations/osu___netcoreapp2_1_.xml b/.idea/.idea.osu/.idea/runConfigurations/osu_.xml
similarity index 87%
rename from .idea/.idea.osu/.idea/runConfigurations/osu___netcoreapp2_1_.xml
rename to .idea/.idea.osu/.idea/runConfigurations/osu_.xml
index 36efe211c6..344301d4a7 100644
--- a/.idea/.idea.osu/.idea/runConfigurations/osu___netcoreapp2_1_.xml
+++ b/.idea/.idea.osu/.idea/runConfigurations/osu_.xml
@@ -1,12 +1,11 @@
 <component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="osu! (netcoreapp2.1)" type="DotNetProject" factoryName=".NET Project">
+  <configuration default="false" name="osu!" type="DotNetProject" factoryName=".NET Project">
     <option name="EXE_PATH" value="$PROJECT_DIR$/osu.Desktop/bin/Debug/netcoreapp2.1/osu!.dll" />
     <option name="PROGRAM_PARAMETERS" value="" />
     <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/osu.Desktop" />
     <option name="PASS_PARENT_ENVS" value="1" />
-    <envs />
-    <option name="USE_MONO" value="0" />
     <option name="USE_EXTERNAL_CONSOLE" value="0" />
+    <option name="USE_MONO" value="0" />
     <option name="PROJECT_PATH" value="$PROJECT_DIR$/osu.Desktop/osu.Desktop.csproj" />
     <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
     <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
diff --git a/COMPILING.md b/COMPILING.md
deleted file mode 100644
index bfcbf6bc2c..0000000000
--- a/COMPILING.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Linux
-### 1. Requirements:
-Mono >= 5.4.0 (>= 5.8.0 recommended)
-Please check [here](http://www.mono-project.com/download/) for stable or [here](http://www.mono-project.com/download/alpha/) for an alpha release.  
-NuGet >= 4.4.0  
-msbuild  
-git
-
-### 2. Cloning project
-Clone the entire repository with submodules using
-```
-git clone https://github.com/ppy/osu --recursive
-```
-Then restore NuGet packages from the repository
-```
-nuget restore
-```
-### 3. Compiling
-Simply run `msbuild` where `osu.sln` is located, this will create all binaries in `osu/osu.Desktop/bin/Debug`.
-### 4. Optimizing
-If you want additional performance you can change build type to Release with
-```
-msbuild -p:Configuration=Release
-```
-Additionally, mono provides an AOT utility which attempts to precompile binaries. You can utilize that by running
-```
-mono --aot ./osu\!.exe
-```
-### 5. Troubleshooting
-You may run into trouble with NuGet versioning, as the one in packaging system is almost always out of date. Simply run 
-```
-nuget
-sudo nuget update -self
-```
-**Warning** NuGet creates few config files when it's run for the first time.
-Do not run NuGet as root on the first run or you might run into very peculiar issues.
diff --git a/osu.Desktop.Deploy/.vscode/launch.json b/osu.Desktop.Deploy/.vscode/launch.json
deleted file mode 100644
index 8c35d211bd..0000000000
--- a/osu.Desktop.Deploy/.vscode/launch.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
-    // Use IntelliSense to learn about possible attributes.
-    // Hover to view descriptions of existing attributes.
-    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
-    "version": "0.2.0",
-    "configurations": [{
-            "name": "Deploy (Debug)",
-            "request": "launch",
-            "type": "mono",
-            "program": "${workspaceRoot}/bin/Debug/net471/osu.Desktop.Deploy.exe",
-            "cwd": "${workspaceRoot}",
-            "preLaunchTask": "Build (Debug)",
-            "runtimeExecutable": null,
-            "env": {},
-            "console": "internalConsole"
-        },
-        {
-            "name": "Deploy (Release)",
-            "request": "launch",
-            "type": "clr",
-            "program": "${workspaceRoot}/bin/Release/net471/osu.Desktop.Deploy.exe",
-            "cwd": "${workspaceRoot}",
-            "preLaunchTask": "Build (Release)",
-            "runtimeExecutable": null,
-            "env": {},
-            "console": "internalConsole"
-        }
-    ]
-}
\ No newline at end of file
diff --git a/osu.Desktop.Deploy/.vscode/tasks.json b/osu.Desktop.Deploy/.vscode/tasks.json
deleted file mode 100644
index 35bf9e7a0e..0000000000
--- a/osu.Desktop.Deploy/.vscode/tasks.json
+++ /dev/null
@@ -1,64 +0,0 @@
-{
-    // See https://go.microsoft.com/fwlink/?LinkId=733558
-    // for the documentation about the tasks.json format
-    "version": "2.0.0",
-    "command": "msbuild",
-    "type": "shell",
-    "suppressTaskName": true,
-    "args": [
-        "/property:GenerateFullPaths=true",
-        "/property:DebugType=portable",
-        "/verbosity:minimal",
-        "/m" //parallel compiling support.
-    ],
-    "tasks": [{
-            "taskName": "Build (Debug)",
-            "group": {
-                "kind": "build",
-                "isDefault": true
-            },
-            "problemMatcher": [
-                "$msCompile"
-            ]
-        },
-        {
-            "taskName": "Build (Release)",
-            "group": "build",
-            "args": [
-                "/property:Configuration=Release"
-            ],
-            "problemMatcher": [
-                "$msCompile"
-            ]
-        },
-        {
-            "taskName": "Clean (Debug)",
-            "args": [
-                "/target:Clean"
-            ],
-            "problemMatcher": [
-                "$msCompile"
-            ]
-        },
-        {
-            "taskName": "Clean (Release)",
-            "args": [
-                "/target:Clean",
-                "/property:Configuration=Release"
-            ],
-            "problemMatcher": [
-                "$msCompile"
-            ]
-        },
-        {
-            "taskName": "Clean All",
-            "dependsOn": [
-                "Clean (Debug)",
-                "Clean (Release)"
-            ],
-            "problemMatcher": [
-                "$msCompile"
-            ]
-        }
-    ]
-}
\ No newline at end of file
diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs
index 844db4a80f..64adcecba4 100644
--- a/osu.Desktop/OsuGameDesktop.cs
+++ b/osu.Desktop/OsuGameDesktop.cs
@@ -73,7 +73,7 @@ namespace osu.Desktop
             }
 
             public StableStorage()
-                : base(string.Empty)
+                : base(string.Empty, null)
             {
             }
         }
diff --git a/osu.Desktop/Overlays/VersionManager.cs b/osu.Desktop/Overlays/VersionManager.cs
index 26e80b3f48..bc1faec822 100644
--- a/osu.Desktop/Overlays/VersionManager.cs
+++ b/osu.Desktop/Overlays/VersionManager.cs
@@ -1,12 +1,13 @@
 // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
-using System.Diagnostics;
+using System;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Sprites;
 using osu.Framework.Graphics.Textures;
+using osu.Framework.Platform;
 using osu.Game;
 using osu.Game.Configuration;
 using osu.Game.Graphics;
@@ -24,16 +25,18 @@ namespace osu.Desktop.Overlays
         private OsuConfigManager config;
         private OsuGameBase game;
         private NotificationOverlay notificationOverlay;
+        private GameHost host;
 
         public override bool HandleKeyboardInput => false;
         public override bool HandleMouseInput => false;
 
         [BackgroundDependencyLoader]
-        private void load(NotificationOverlay notification, OsuColour colours, TextureStore textures, OsuGameBase game, OsuConfigManager config)
+        private void load(NotificationOverlay notification, OsuColour colours, TextureStore textures, OsuGameBase game, OsuConfigManager config, GameHost host)
         {
             notificationOverlay = notification;
             this.config = config;
             this.game = game;
+            this.host = host;
 
             AutoSizeAxes = Axes.Both;
             Anchor = Anchor.BottomCentre;
@@ -106,19 +109,19 @@ namespace osu.Desktop.Overlays
 
                 // 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));
+                    notificationOverlay.Post(new UpdateCompleteNotification(version, host.OpenUrlExternally));
             }
         }
 
         private class UpdateCompleteNotification : SimpleNotification
         {
-            public UpdateCompleteNotification(string version)
+            public UpdateCompleteNotification(string version, Action<string> openUrl = null)
             {
                 Text = $"You are now running osu!lazer {version}.\nClick to see what's new!";
                 Icon = FontAwesome.fa_check_square;
                 Activated = delegate
                 {
-                    Process.Start($"https://osu.ppy.sh/home/changelog/{version}");
+                    openUrl?.Invoke($"https://osu.ppy.sh/home/changelog/lazer/{version}");
                     return true;
                 };
             }
diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj
index 3cf95e9b3e..29d3b0e394 100644
--- a/osu.Desktop/osu.Desktop.csproj
+++ b/osu.Desktop/osu.Desktop.csproj
@@ -30,6 +30,7 @@
   </ItemGroup>
   <ItemGroup Label="Package References">
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.0" />
+    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="2.1.1" />
     <PackageReference Include="squirrel.windows" Version="1.8.0" Condition="'$(TargetFramework)' == 'net471'" />
   </ItemGroup>
   <ItemGroup Label="Resources">
diff --git a/osu.Game.Rulesets.Catch.Tests/TestCasePerformancePoints.cs b/osu.Game.Rulesets.Catch.Tests/TestCasePerformancePoints.cs
deleted file mode 100644
index 9512cf2061..0000000000
--- a/osu.Game.Rulesets.Catch.Tests/TestCasePerformancePoints.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
-// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
-
-using NUnit.Framework;
-
-namespace osu.Game.Rulesets.Catch.Tests
-{
-    [TestFixture]
-    public class TestCasePerformancePoints : Game.Tests.Visual.TestCasePerformancePoints
-    {
-        public TestCasePerformancePoints()
-            : base(new CatchRuleset())
-        {
-        }
-    }
-}
diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs
index d0180f1791..fc6e23c884 100644
--- a/osu.Game.Rulesets.Catch/CatchRuleset.cs
+++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs
@@ -112,7 +112,7 @@ namespace osu.Game.Rulesets.Catch
 
         public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.fa_osu_fruits_o };
 
-        public override DifficultyCalculator CreateDifficultyCalculator(IBeatmap beatmap, Mod[] mods = null) => new CatchDifficultyCalculator(beatmap);
+        public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new CatchDifficultyCalculator(this, beatmap);
 
         public override int? LegacyID => 2;
 
diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs
new file mode 100644
index 0000000000..f6535380c8
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs
@@ -0,0 +1,19 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using osu.Game.Rulesets.Difficulty;
+using osu.Game.Rulesets.Mods;
+
+namespace osu.Game.Rulesets.Catch.Difficulty
+{
+    public class CatchDifficultyAttributes : DifficultyAttributes
+    {
+        public double ApproachRate;
+        public int MaxCombo;
+
+        public CatchDifficultyAttributes(Mod[] mods, double starRating)
+            : base(mods, starRating)
+        {
+        }
+    }
+}
diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs
index f8351b7519..3d1013aad3 100644
--- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs
@@ -1,18 +1,146 @@
 // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
+using System;
 using System.Collections.Generic;
+using System.Linq;
 using osu.Game.Beatmaps;
 using osu.Game.Rulesets.Difficulty;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Catch.UI;
 
 namespace osu.Game.Rulesets.Catch.Difficulty
 {
     public class CatchDifficultyCalculator : DifficultyCalculator
     {
-        public CatchDifficultyCalculator(IBeatmap beatmap) : base(beatmap)
+
+        /// <summary>
+        /// In milliseconds. For difficulty calculation we will only look at the highest strain value in each time interval of size STRAIN_STEP.
+        /// This is to eliminate higher influence of stream over aim by simply having more HitObjects with high strain.
+        /// The higher this value, the less strains there will be, indirectly giving long beatmaps an advantage.
+        /// </summary>
+        private const double strain_step = 750;
+
+        /// <summary>
+        /// The weighting of each strain value decays to this number * it's previous value
+        /// </summary>
+        private const double decay_weight = 0.94;
+
+        private const double star_scaling_factor = 0.145;
+
+        public CatchDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap)
+            : base(ruleset, beatmap)
         {
         }
 
-        public override double Calculate(Dictionary<string, double> categoryDifficulty = null) => 0;
+        protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double timeRate)
+        {
+            if (!beatmap.HitObjects.Any())
+                return new CatchDifficultyAttributes(mods, 0);
+
+            var catcher = new CatcherArea.Catcher(beatmap.BeatmapInfo.BaseDifficulty);
+            float halfCatchWidth = catcher.CatchWidth * 0.5f;
+
+            var difficultyHitObjects = new List<CatchDifficultyHitObject>();
+
+            foreach (var hitObject in beatmap.HitObjects)
+            {
+                // We want to only consider fruits that contribute to the combo. Droplets are addressed as accuracy and spinners are not relevant for "skill" calculations.
+                if (hitObject is Fruit)
+                {
+                    difficultyHitObjects.Add(new CatchDifficultyHitObject((CatchHitObject)hitObject, halfCatchWidth));
+                }
+                if (hitObject is JuiceStream)
+                    difficultyHitObjects.AddRange(hitObject.NestedHitObjects.OfType<CatchHitObject>().Where(o => !(o is TinyDroplet)).Select(o => new CatchDifficultyHitObject(o, halfCatchWidth)));
+            }
+
+            difficultyHitObjects.Sort((a, b) => a.BaseHitObject.StartTime.CompareTo(b.BaseHitObject.StartTime));
+
+            if (!calculateStrainValues(difficultyHitObjects, timeRate))
+                return new CatchDifficultyAttributes(mods, 0);
+
+            // this is the same as osu!, so there's potential to share the implementation... maybe
+            double preEmpt = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.ApproachRate, 1800, 1200, 450) / timeRate;
+            double starRating = Math.Sqrt(calculateDifficulty(difficultyHitObjects, timeRate)) * star_scaling_factor;
+
+            return new CatchDifficultyAttributes(mods, starRating)
+            {
+                ApproachRate = preEmpt > 1200.0 ? -(preEmpt - 1800.0) / 120.0 : -(preEmpt - 1200.0) / 150.0 + 5.0,
+                MaxCombo = difficultyHitObjects.Count
+            };
+        }
+
+        private bool calculateStrainValues(List<CatchDifficultyHitObject> objects, double timeRate)
+        {
+            CatchDifficultyHitObject lastObject = null;
+
+            if (!objects.Any()) return false;
+
+            // Traverse hitObjects in pairs to calculate the strain value of NextHitObject from the strain value of CurrentHitObject and environment.
+            foreach (var currentObject in objects)
+            {
+                if (lastObject != null)
+                    currentObject.CalculateStrains(lastObject, timeRate);
+
+                lastObject = currentObject;
+            }
+
+            return true;
+        }
+
+        private double calculateDifficulty(List<CatchDifficultyHitObject> objects, double timeRate)
+        {
+            // The strain step needs to be adjusted for the algorithm to be considered equal with speed changing mods
+            double actualStrainStep = strain_step * timeRate;
+
+            // Find the highest strain value within each strain step
+            var highestStrains = new List<double>();
+            double intervalEndTime = actualStrainStep;
+            double maximumStrain = 0; // We need to keep track of the maximum strain in the current interval
+
+            CatchDifficultyHitObject previousHitObject = null;
+            foreach (CatchDifficultyHitObject hitObject in objects)
+            {
+                // While we are beyond the current interval push the currently available maximum to our strain list
+                while (hitObject.BaseHitObject.StartTime > intervalEndTime)
+                {
+                    highestStrains.Add(maximumStrain);
+
+                    // The maximum strain of the next interval is not zero by default! We need to take the last hitObject we encountered, take its strain and apply the decay
+                    // until the beginning of the next interval.
+                    if (previousHitObject == null)
+                    {
+                        maximumStrain = 0;
+                    }
+                    else
+                    {
+                        double decay = Math.Pow(CatchDifficultyHitObject.DECAY_BASE, (intervalEndTime - previousHitObject.BaseHitObject.StartTime) / 1000);
+                        maximumStrain = previousHitObject.Strain * decay;
+                    }
+
+                    // Go to the next time interval
+                    intervalEndTime += actualStrainStep;
+                }
+
+                // Obtain maximum strain
+                maximumStrain = Math.Max(hitObject.Strain, maximumStrain);
+
+                previousHitObject = hitObject;
+            }
+
+            // Build the weighted sum over the highest strains for each interval
+            double difficulty = 0;
+            double weight = 1;
+            highestStrains.Sort((a, b) => b.CompareTo(a)); // Sort from highest to lowest strain.
+
+            foreach (double strain in highestStrains)
+            {
+                difficulty += weight * strain;
+                weight *= decay_weight;
+            }
+
+            return difficulty;
+        }
     }
 }
diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyHitObject.cs
new file mode 100644
index 0000000000..720c1d8653
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyHitObject.cs
@@ -0,0 +1,130 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using System;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Catch.UI;
+using OpenTK;
+
+namespace osu.Game.Rulesets.Catch.Difficulty
+{
+    public class CatchDifficultyHitObject
+    {
+        internal static readonly double DECAY_BASE = 0.20;
+        private const float normalized_hitobject_radius = 41.0f;
+        private const float absolute_player_positioning_error = 16f;
+        private readonly float playerPositioningError;
+
+        internal CatchHitObject BaseHitObject;
+
+        /// <summary>
+        /// Measures jump difficulty. CtB doesn't have something like button pressing speed or accuracy
+        /// </summary>
+        internal double Strain = 1;
+
+        /// <summary>
+        /// This is required to keep track of lazy player movement (always moving only as far as necessary)
+        /// Without this quick repeat sliders / weirdly shaped streams might become ridiculously overrated
+        /// </summary>
+        internal float PlayerPositionOffset;
+        internal float LastMovement;
+
+        internal float NormalizedPosition;
+        internal float ActualNormalizedPosition => NormalizedPosition + PlayerPositionOffset;
+
+        internal CatchDifficultyHitObject(CatchHitObject baseHitObject, float catcherWidthHalf)
+        {
+            BaseHitObject = baseHitObject;
+
+            // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps.
+            float scalingFactor = normalized_hitobject_radius / catcherWidthHalf;
+
+            playerPositioningError = absolute_player_positioning_error; // * scalingFactor;
+            NormalizedPosition = baseHitObject.X * CatchPlayfield.BASE_WIDTH * scalingFactor;
+        }
+
+        private const double direction_change_bonus = 12.5;
+        internal void CalculateStrains(CatchDifficultyHitObject previousHitObject, double timeRate)
+        {
+            // Rather simple, but more specialized things are inherently inaccurate due to the big difference playstyles and opinions make.
+            // See Taiko feedback thread.
+            double timeElapsed = (BaseHitObject.StartTime - previousHitObject.BaseHitObject.StartTime) / timeRate;
+            double decay = Math.Pow(DECAY_BASE, timeElapsed / 1000);
+
+            // Update new position with lazy movement.
+            PlayerPositionOffset =
+                MathHelper.Clamp(
+                    previousHitObject.ActualNormalizedPosition,
+                    NormalizedPosition - (normalized_hitobject_radius - playerPositioningError),
+                    NormalizedPosition + (normalized_hitobject_radius - playerPositioningError)) // Obtain new lazy position, but be stricter by allowing for an error of a certain degree of the player.
+                - NormalizedPosition; // Subtract HitObject position to obtain offset
+
+            LastMovement = DistanceTo(previousHitObject);
+            double addition = spacingWeight(LastMovement);
+
+            if (NormalizedPosition < previousHitObject.NormalizedPosition)
+            {
+                LastMovement = -LastMovement;
+            }
+
+            CatchHitObject previousHitCircle = previousHitObject.BaseHitObject;
+
+            double additionBonus = 0;
+            double sqrtTime = Math.Sqrt(Math.Max(timeElapsed, 25));
+
+            // Direction changes give an extra point!
+            if (Math.Abs(LastMovement) > 0.1)
+            {
+                if (Math.Abs(previousHitObject.LastMovement) > 0.1 && Math.Sign(LastMovement) != Math.Sign(previousHitObject.LastMovement))
+                {
+                    double bonus = direction_change_bonus / sqrtTime;
+
+                    // Weight bonus by how
+                    double bonusFactor = Math.Min(playerPositioningError, Math.Abs(LastMovement)) / playerPositioningError;
+
+                    // We want time to play a role twice here!
+                    addition += bonus * bonusFactor;
+
+                    // Bonus for tougher direction switches and "almost" hyperdashes at this point
+                    if (previousHitCircle != null && previousHitCircle.DistanceToHyperDash <= 10.0f / CatchPlayfield.BASE_WIDTH)
+                    {
+                        additionBonus += 0.3 * bonusFactor;
+                    }
+                }
+
+                // Base bonus for every movement, giving some weight to streams.
+                addition += 7.5 * Math.Min(Math.Abs(LastMovement), normalized_hitobject_radius * 2) / (normalized_hitobject_radius * 6) / sqrtTime;
+            }
+
+            // Bonus for "almost" hyperdashes at corner points
+            if (previousHitCircle != null && previousHitCircle.DistanceToHyperDash <= 10.0f / CatchPlayfield.BASE_WIDTH)
+            {
+                if (!previousHitCircle.HyperDash)
+                {
+                    additionBonus += 1.0;
+                }
+                else
+                {
+                    // After a hyperdash we ARE in the correct position. Always!
+                    PlayerPositionOffset = 0;
+                }
+
+                addition *= 1.0 + additionBonus * ((10 - previousHitCircle.DistanceToHyperDash * CatchPlayfield.BASE_WIDTH) / 10);
+            }
+
+            addition *= 850.0 / Math.Max(timeElapsed, 25);
+
+            Strain = previousHitObject.Strain * decay + addition;
+        }
+
+        private static double spacingWeight(float distance)
+        {
+            return Math.Pow(distance, 1.3) / 500;
+        }
+
+        internal float DistanceTo(CatchDifficultyHitObject other)
+        {
+            return Math.Abs(ActualNormalizedPosition - other.ActualNormalizedPosition);
+        }
+    }
+}
diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
index 548813fbd2..d55cdac115 100644
--- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
+++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
@@ -24,6 +24,11 @@ namespace osu.Game.Rulesets.Catch.Objects
 
         public int ComboIndex { get; set; }
 
+        /// <summary>
+        /// The distance for a fruit to to next hyper if it's not a hyper.
+        /// </summary>
+        public float DistanceToHyperDash { get; set; }
+
         /// <summary>
         /// The next fruit starts a new combo. Used for explodey.
         /// </summary>
diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableCatchHitObject.cs
index 3dbda708e5..e3564b5967 100644
--- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableCatchHitObject.cs
+++ b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableCatchHitObject.cs
@@ -42,6 +42,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawable
     {
         public virtual bool CanBePlated => false;
 
+        public virtual bool StaysOnPlate => CanBePlated;
+
         protected DrawableCatchHitObject(CatchHitObject hitObject)
             : base(hitObject)
         {
diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableDroplet.cs
index a19d67ebbe..5c8a7c4a7c 100644
--- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableDroplet.cs
+++ b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableDroplet.cs
@@ -13,6 +13,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawable
     {
         private Pulp pulp;
 
+        public override bool StaysOnPlate => false;
+
         public DrawableDroplet(Droplet h)
             : base(h)
         {
diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs
index ae799875a9..b2d8e3f8a5 100644
--- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs
+++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs
@@ -124,6 +124,9 @@ namespace osu.Game.Rulesets.Catch.Objects
                     X = X + Curve.PositionAt(reversed ? 0 : 1).X / CatchPlayfield.BASE_WIDTH
                 });
             }
+
+            if (NestedHitObjects.LastOrDefault() is IHasComboInformation lastNested)
+                lastNested.LastInCombo = LastInCombo;
         }
 
         public double EndTime => StartTime + this.SpanCount() * Curve.Distance / Velocity;
diff --git a/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs b/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs
index 6a9d1bdbc7..8d3d898655 100644
--- a/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs
+++ b/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs
@@ -28,9 +28,9 @@ namespace osu.Game.Rulesets.Catch.Replays
             }
         }
 
-        public override List<InputState> GetPendingStates()
+        public override List<IInput> GetPendingInputs()
         {
-            if (!Position.HasValue) return new List<InputState>();
+            if (!Position.HasValue) return new List<IInput>();
 
             var actions = new List<CatchAction>();
 
@@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Catch.Replays
             else if (Position.Value < CurrentFrame.Position)
                 actions.Add(CatchAction.MoveLeft);
 
-            return new List<InputState>
+            return new List<IInput>
             {
                 new CatchReplayState
                 {
diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs
index d8c7b5130d..30f4979255 100644
--- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs
+++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs
@@ -16,6 +16,7 @@ using osu.Game.Rulesets.Catch.Objects.Drawable;
 using osu.Game.Rulesets.Catch.Replays;
 using osu.Game.Rulesets.Judgements;
 using osu.Game.Rulesets.Objects.Drawables;
+using osu.Game.Rulesets.UI;
 using OpenTK;
 using OpenTK.Graphics;
 
@@ -48,6 +49,16 @@ namespace osu.Game.Rulesets.Catch.UI
 
         public void OnJudgement(DrawableCatchHitObject fruit, Judgement judgement)
         {
+            void runAfterLoaded(Action action)
+            {
+                // this is required to make this run after the last caught fruit runs UpdateState at least once.
+                // TODO: find a better alternative
+                if (lastPlateableFruit.IsLoaded)
+                    action();
+                else
+                    lastPlateableFruit.OnLoadComplete = _ => action();
+            }
+
             if (judgement.IsHit && fruit.CanBePlated)
             {
                 var caughtFruit = (DrawableCatchHitObject)GetVisualRepresentation?.Invoke(fruit.HitObject);
@@ -63,21 +74,17 @@ namespace osu.Game.Rulesets.Catch.UI
                 caughtFruit.LifetimeEnd = double.MaxValue;
 
                 MovableCatcher.Add(caughtFruit);
-
                 lastPlateableFruit = caughtFruit;
+
+                if (!fruit.StaysOnPlate)
+                    runAfterLoaded(() => MovableCatcher.Explode(caughtFruit));
+
             }
 
             if (fruit.HitObject.LastInCombo)
             {
                 if (judgement.IsHit)
-                {
-                    // this is required to make this run after the last caught fruit runs UpdateState at least once.
-                    // TODO: find a better alternative
-                    if (lastPlateableFruit.IsLoaded)
-                        MovableCatcher.Explode();
-                    else
-                        lastPlateableFruit.OnLoadComplete = _ => { MovableCatcher.Explode(); };
-                }
+                    runAfterLoaded(() => MovableCatcher.Explode());
                 else
                     MovableCatcher.Drop();
             }
@@ -87,7 +94,7 @@ namespace osu.Game.Rulesets.Catch.UI
         {
             base.UpdateAfterChildren();
 
-            var state = GetContainingInputManager().CurrentState as CatchFramedReplayInputHandler.CatchReplayState;
+            var state = (GetContainingInputManager().CurrentState as RulesetInputManagerInputState<CatchAction>)?.LastReplayState as CatchFramedReplayInputHandler.CatchReplayState;
 
             if (state?.CatcherX != null)
                 MovableCatcher.X = state.CatcherX.Value;
@@ -99,6 +106,11 @@ namespace osu.Game.Rulesets.Catch.UI
 
         public class Catcher : Container, IKeyBindingHandler<CatchAction>
         {
+            /// <summary>
+            /// Width of the area that can be used to attempt catches during gameplay.
+            /// </summary>
+            internal float CatchWidth => CATCHER_SIZE * Math.Abs(Scale.X);
+
             private Container<DrawableHitObject> caughtFruit;
 
             public Container ExplodingFruitTarget;
@@ -226,15 +238,15 @@ namespace osu.Game.Rulesets.Catch.UI
             /// <returns>Whether the catch is possible.</returns>
             public bool AttemptCatch(CatchHitObject fruit)
             {
-                double halfCatcherWidth = CATCHER_SIZE * Math.Abs(Scale.X) * 0.5f;
+                float halfCatchWidth = CatchWidth * 0.5f;
 
                 // this stuff wil disappear once we move fruit to non-relative coordinate space in the future.
                 var catchObjectPosition = fruit.X * CatchPlayfield.BASE_WIDTH;
                 var catcherPosition = Position.X * CatchPlayfield.BASE_WIDTH;
 
                 var validCatch =
-                    catchObjectPosition >= catcherPosition - halfCatcherWidth &&
-                    catchObjectPosition <= catcherPosition + halfCatcherWidth;
+                    catchObjectPosition >= catcherPosition - halfCatchWidth &&
+                    catchObjectPosition <= catcherPosition + halfCatchWidth;
 
                 if (validCatch && fruit.HyperDash)
                 {
@@ -378,28 +390,31 @@ namespace osu.Game.Rulesets.Catch.UI
                 var fruit = caughtFruit.ToArray();
 
                 foreach (var f in fruit)
+                    Explode(f);
+            }
+
+            public void Explode(DrawableHitObject fruit)
+            {
+                var originalX = fruit.X * Scale.X;
+
+                if (ExplodingFruitTarget != null)
                 {
-                    var originalX = f.X * Scale.X;
+                    fruit.Anchor = Anchor.TopLeft;
+                    fruit.Position = caughtFruit.ToSpaceOfOtherDrawable(fruit.DrawPosition, ExplodingFruitTarget);
 
-                    if (ExplodingFruitTarget != null)
-                    {
-                        f.Anchor = Anchor.TopLeft;
-                        f.Position = caughtFruit.ToSpaceOfOtherDrawable(f.DrawPosition, ExplodingFruitTarget);
+                    caughtFruit.Remove(fruit);
 
-                        caughtFruit.Remove(f);
-
-                        ExplodingFruitTarget.Add(f);
-                    }
-
-                    f.MoveToY(f.Y - 50, 250, Easing.OutSine)
-                     .Then()
-                     .MoveToY(f.Y + 50, 500, Easing.InSine);
-
-                    f.MoveToX(f.X + originalX * 6, 1000);
-                    f.FadeOut(750);
-
-                    f.Expire();
+                    ExplodingFruitTarget.Add(fruit);
                 }
+
+                fruit.MoveToY(fruit.Y - 50, 250, Easing.OutSine)
+                 .Then()
+                 .MoveToY(fruit.Y + 50, 500, Easing.InSine);
+
+                fruit.MoveToX(fruit.X + originalX * 6, 1000);
+                fruit.FadeOut(750);
+
+                fruit.Expire();
             }
 
             private class CatcherSprite : Sprite
diff --git a/osu.Game.Rulesets.Mania.Tests/TestCasePerformancePoints.cs b/osu.Game.Rulesets.Mania.Tests/TestCasePerformancePoints.cs
deleted file mode 100644
index c15a6dd688..0000000000
--- a/osu.Game.Rulesets.Mania.Tests/TestCasePerformancePoints.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
-// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
-
-using NUnit.Framework;
-
-namespace osu.Game.Rulesets.Mania.Tests
-{
-    [TestFixture]
-    public class TestCasePerformancePoints : Game.Tests.Visual.TestCasePerformancePoints
-    {
-        public TestCasePerformancePoints()
-            : base(new ManiaRuleset())
-        {
-        }
-    }
-}
diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs
index afa9bdbbd7..c6fa465a0f 100644
--- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs
+++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs
@@ -58,6 +58,13 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
 
         public override Pattern Generate()
         {
+            if (TotalColumns == 1)
+            {
+                var pattern = new Pattern();
+                addToPattern(pattern, 0, HitObject.StartTime, endTime);
+                return pattern;
+            }
+
             if (spanCount > 1)
             {
                 if (segmentDuration <= 90)
@@ -322,7 +329,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
                     break;
             }
 
-            bool isDoubleSample(SampleInfo sample) => sample.Name == SampleInfo.HIT_CLAP && sample.Name == SampleInfo.HIT_FINISH;
+            bool isDoubleSample(SampleInfo sample) => sample.Name == SampleInfo.HIT_CLAP || sample.Name == SampleInfo.HIT_FINISH;
 
             bool canGenerateTwoNotes = (convertType & PatternType.LowProbability) == 0;
             canGenerateTwoNotes &= HitObject.Samples.Any(isDoubleSample) || sampleInfoListAt(HitObject.StartTime).Any(isDoubleSample);
diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs
index cec3e18ad6..b4160dc98b 100644
--- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs
+++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs
@@ -77,10 +77,25 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
             }
             else
                 convertType |= PatternType.LowProbability;
+
+            if ((convertType & PatternType.KeepSingle) == 0)
+            {
+                if (HitObject.Samples.Any(s => s.Name == SampleInfo.HIT_FINISH) && TotalColumns != 8)
+                    convertType |= PatternType.Mirror;
+                else
+                    convertType |= PatternType.Gathered;
+            }
         }
 
         public override Pattern Generate()
         {
+            if (TotalColumns == 1)
+            {
+                var pattern = new Pattern();
+                addToPattern(pattern, 0);
+                return pattern;
+            }
+
             int lastColumn = PreviousPattern.HitObjects.FirstOrDefault()?.Column ?? 0;
 
             if ((convertType & PatternType.Reverse) > 0 && PreviousPattern.HitObjects.Any())
@@ -346,7 +361,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
             addToCentre = false;
 
             if ((convertType & PatternType.ForceNotStack) > 0)
-                return getRandomNoteCount(p2 / 2, p2, (p2 + p3) / 2, p3);
+                return getRandomNoteCount(1 / 2f + p2 / 2, p2, (p2 + p3) / 2, p3);
 
             switch (TotalColumns)
             {
diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaConfigManager.cs
index ea5f590bd1..d9e360081d 100644
--- a/osu.Game.Rulesets.Mania/Configuration/ManiaConfigManager.cs
+++ b/osu.Game.Rulesets.Mania/Configuration/ManiaConfigManager.cs
@@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Mania.Configuration
 {
     public class ManiaConfigManager : RulesetConfigManager<ManiaSetting>
     {
-        public ManiaConfigManager(SettingsStore settings, RulesetInfo ruleset, int variant)
+        public ManiaConfigManager(SettingsStore settings, RulesetInfo ruleset, int? variant = null)
             : base(settings, ruleset, variant)
         {
         }
diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs
index ca2002b7c9..5fa113224d 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs
@@ -29,47 +29,36 @@ namespace osu.Game.Rulesets.Mania.Difficulty
         /// </summary>
         private const double decay_weight = 0.9;
 
-        /// <summary>
-        /// HitObjects are stored as a member variable.
-        /// </summary>
-        private readonly List<ManiaHitObjectDifficulty> difficultyHitObjects = new List<ManiaHitObjectDifficulty>();
+        private readonly bool isForCurrentRuleset;
 
-        public ManiaDifficultyCalculator(IBeatmap beatmap)
-            : base(beatmap)
+        public ManiaDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap)
+            : base(ruleset, beatmap)
         {
+            isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(ruleset.RulesetInfo);
         }
 
-        public ManiaDifficultyCalculator(IBeatmap beatmap, Mod[] mods)
-            : base(beatmap, mods)
+        protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double timeRate)
         {
-        }
+            var difficultyHitObjects = new List<ManiaHitObjectDifficulty>();
 
-        public override double Calculate(Dictionary<string, double> categoryDifficulty = null)
-        {
-            // Fill our custom DifficultyHitObject class, that carries additional information
-            difficultyHitObjects.Clear();
-
-            int columnCount = (Beatmap as ManiaBeatmap)?.TotalColumns ?? 7;
+            int columnCount = ((ManiaBeatmap)beatmap).TotalColumns;
 
             // Sort DifficultyHitObjects by StartTime of the HitObjects - just to make sure.
             // Note: Stable sort is done so that the ordering of hitobjects with equal start times doesn't change
-            difficultyHitObjects.AddRange(Beatmap.HitObjects.Select(h => new ManiaHitObjectDifficulty((ManiaHitObject)h, columnCount)).OrderBy(h => h.BaseHitObject.StartTime));
+            difficultyHitObjects.AddRange(beatmap.HitObjects.Select(h => new ManiaHitObjectDifficulty((ManiaHitObject)h, columnCount)).OrderBy(h => h.BaseHitObject.StartTime));
 
-            if (!calculateStrainValues())
-                return 0;
+            if (!calculateStrainValues(difficultyHitObjects, timeRate))
+                return new DifficultyAttributes(mods, 0);
 
-            double starRating = calculateDifficulty() * star_scaling_factor;
+            double starRating = calculateDifficulty(difficultyHitObjects, timeRate) * star_scaling_factor;
 
-            if (categoryDifficulty != null)
-                categoryDifficulty["Strain"] = starRating;
-
-            return starRating;
+            return new DifficultyAttributes(mods, starRating);
         }
 
-        private bool calculateStrainValues()
+        private bool calculateStrainValues(List<ManiaHitObjectDifficulty> objects, double timeRate)
         {
             // Traverse hitObjects in pairs to calculate the strain value of NextHitObject from the strain value of CurrentHitObject and environment.
-            using (List<ManiaHitObjectDifficulty>.Enumerator hitObjectsEnumerator = difficultyHitObjects.GetEnumerator())
+            using (var hitObjectsEnumerator = objects.GetEnumerator())
             {
                 if (!hitObjectsEnumerator.MoveNext())
                     return false;
@@ -80,7 +69,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
                 while (hitObjectsEnumerator.MoveNext())
                 {
                     var next = hitObjectsEnumerator.Current;
-                    next?.CalculateStrains(current, TimeRate);
+                    next?.CalculateStrains(current, timeRate);
                     current = next;
                 }
 
@@ -88,9 +77,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty
             }
         }
 
-        private double calculateDifficulty()
+        private double calculateDifficulty(List<ManiaHitObjectDifficulty> objects, double timeRate)
         {
-            double actualStrainStep = strain_step * TimeRate;
+            double actualStrainStep = strain_step * timeRate;
 
             // Find the highest strain value within each strain step
             List<double> highestStrains = new List<double>();
@@ -98,7 +87,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
             double maximumStrain = 0; // We need to keep track of the maximum strain in the current interval
 
             ManiaHitObjectDifficulty previousHitObject = null;
-            foreach (var hitObject in difficultyHitObjects)
+            foreach (var hitObject in objects)
             {
                 // While we are beyond the current interval push the currently available maximum to our strain list
                 while (hitObject.BaseHitObject.StartTime > intervalEndTime)
@@ -143,21 +132,35 @@ namespace osu.Game.Rulesets.Mania.Difficulty
             return difficulty;
         }
 
-        protected override Mod[] DifficultyAdjustmentMods => new Mod[]
+        protected override Mod[] DifficultyAdjustmentMods
         {
-            new ManiaModDoubleTime(),
-            new ManiaModHalfTime(),
-            new ManiaModEasy(),
-            new ManiaModHardRock(),
-            new ManiaModKey1(),
-            new ManiaModKey2(),
-            new ManiaModKey3(),
-            new ManiaModKey4(),
-            new ManiaModKey5(),
-            new ManiaModKey6(),
-            new ManiaModKey7(),
-            new ManiaModKey8(),
-            new ManiaModKey9(),
-        };
+            get
+            {
+                var mods = new Mod[]
+                {
+                    new ManiaModDoubleTime(),
+                    new ManiaModHalfTime(),
+                    new ManiaModEasy(),
+                    new ManiaModHardRock(),
+                };
+
+                if (isForCurrentRuleset)
+                    return mods;
+
+                // if we are a convert, we can be played in any key mod.
+                return mods.Concat(new Mod[]
+                {
+                    new ManiaModKey1(),
+                    new ManiaModKey2(),
+                    new ManiaModKey3(),
+                    new ManiaModKey4(),
+                    new ManiaModKey5(),
+                    new ManiaModKey6(),
+                    new ManiaModKey7(),
+                    new ManiaModKey8(),
+                    new ManiaModKey9(),
+                }).ToArray();
+            }
+        }
     }
 }
diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs
index 93652f7610..b6089b830b 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs
@@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
         private int countMeh;
         private int countMiss;
 
-        public ManiaPerformanceCalculator(Ruleset ruleset, IBeatmap beatmap, Score score)
+        public ManiaPerformanceCalculator(Ruleset ruleset, WorkingBeatmap beatmap, Score score)
             : base(ruleset, beatmap, score)
         {
         }
@@ -82,7 +82,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
         private double computeStrainValue()
         {
             // Obtain strain difficulty
-            double strainValue = Math.Pow(5 * Math.Max(1, Attributes["Strain"] / 0.2) - 4.0, 2.2) / 135.0;
+            double strainValue = Math.Pow(5 * Math.Max(1, Attributes.StarRating / 0.2) - 4.0, 2.2) / 135.0;
 
             // Longer maps are worth more
             strainValue *= 1.0 + 0.1 * Math.Min(1.0, totalHits / 1500.0);
diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
index 5f803e3406..ac5fbfdde0 100644
--- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
@@ -15,8 +15,11 @@ using osu.Game.Graphics;
 using osu.Game.Rulesets.Mania.Replays;
 using osu.Game.Rulesets.Replays.Types;
 using osu.Game.Beatmaps.Legacy;
+using osu.Game.Configuration;
+using osu.Game.Rulesets.Configuration;
 using osu.Game.Rulesets.Difficulty;
 using osu.Game.Rulesets.Mania.Beatmaps;
+using osu.Game.Rulesets.Mania.Configuration;
 using osu.Game.Rulesets.Mania.Difficulty;
 using osu.Game.Rulesets.Scoring;
 
@@ -26,7 +29,7 @@ namespace osu.Game.Rulesets.Mania
     {
         public override RulesetContainer CreateRulesetContainerWith(WorkingBeatmap beatmap) => new ManiaRulesetContainer(this, beatmap);
         public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new ManiaBeatmapConverter(beatmap);
-        public override PerformanceCalculator CreatePerformanceCalculator(IBeatmap beatmap, Score score) => new ManiaPerformanceCalculator(this, beatmap, score);
+        public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, Score score) => new ManiaPerformanceCalculator(this, beatmap, score);
 
         public override IEnumerable<Mod> ConvertLegacyMods(LegacyMods mods)
         {
@@ -144,12 +147,14 @@ namespace osu.Game.Rulesets.Mania
 
         public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.fa_osu_mania_o };
 
-        public override DifficultyCalculator CreateDifficultyCalculator(IBeatmap beatmap, Mod[] mods = null) => new ManiaDifficultyCalculator(beatmap, mods);
+        public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new ManiaDifficultyCalculator(this, beatmap);
 
         public override int? LegacyID => 3;
 
         public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new ManiaReplayFrame();
 
+        public override IRulesetConfigManager CreateConfig(SettingsStore settings) => new ManiaConfigManager(settings, RulesetInfo);
+
         public ManiaRuleset(RulesetInfo rulesetInfo = null)
             : base(rulesetInfo)
         {
diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaFramedReplayInputHandler.cs b/osu.Game.Rulesets.Mania/Replays/ManiaFramedReplayInputHandler.cs
index c71db745e0..29eeb1cab5 100644
--- a/osu.Game.Rulesets.Mania/Replays/ManiaFramedReplayInputHandler.cs
+++ b/osu.Game.Rulesets.Mania/Replays/ManiaFramedReplayInputHandler.cs
@@ -17,6 +17,6 @@ namespace osu.Game.Rulesets.Mania.Replays
 
         protected override bool IsImportant(ManiaReplayFrame frame) => frame.Actions.Any();
 
-        public override List<InputState> GetPendingStates() => new List<InputState> { new ReplayState<ManiaAction> { PressedActions = CurrentFrame.Actions } };
+        public override List<IInput> GetPendingInputs() => new List<IInput> { new ReplayState<ManiaAction> { PressedActions = CurrentFrame.Actions } };
     }
 }
diff --git a/osu.Game.Rulesets.Mania/UI/HitExplosion.cs b/osu.Game.Rulesets.Mania/UI/HitExplosion.cs
index f19c3a811b..bf8db63137 100644
--- a/osu.Game.Rulesets.Mania/UI/HitExplosion.cs
+++ b/osu.Game.Rulesets.Mania/UI/HitExplosion.cs
@@ -15,6 +15,8 @@ namespace osu.Game.Rulesets.Mania.UI
 {
     internal class HitExplosion : CompositeDrawable
     {
+        public override bool RemoveWhenNotAlive => true;
+
         private readonly CircularContainer circle;
 
         public HitExplosion(DrawableHitObject judgedObject)
diff --git a/osu.Game.Rulesets.Mania/UI/ManiaRulesetContainer.cs b/osu.Game.Rulesets.Mania/UI/ManiaRulesetContainer.cs
index 7123aab901..a3145d6035 100644
--- a/osu.Game.Rulesets.Mania/UI/ManiaRulesetContainer.cs
+++ b/osu.Game.Rulesets.Mania/UI/ManiaRulesetContainer.cs
@@ -10,12 +10,9 @@ using osu.Framework.Input;
 using osu.Framework.MathUtils;
 using osu.Game.Beatmaps;
 using osu.Game.Beatmaps.ControlPoints;
-using osu.Game.Configuration;
 using osu.Game.Input.Handlers;
-using osu.Game.Rulesets.Configuration;
 using osu.Game.Rulesets.Mania.Beatmaps;
 using osu.Game.Rulesets.Mania.Mods;
-using osu.Game.Rulesets.Mania.Configuration;
 using osu.Game.Rulesets.Mania.Objects;
 using osu.Game.Rulesets.Mania.Objects.Drawables;
 using osu.Game.Rulesets.Mania.Replays;
@@ -103,7 +100,5 @@ namespace osu.Game.Rulesets.Mania.UI
         protected override Vector2 PlayfieldArea => new Vector2(1, 0.8f);
 
         protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new ManiaFramedReplayInputHandler(replay);
-
-        protected override IRulesetConfigManager CreateConfig(Ruleset ruleset, SettingsStore settings) => new ManiaConfigManager(settings, Ruleset.RulesetInfo, Variant);
     }
 }
diff --git a/osu.Game.Rulesets.Osu.Tests/TestCasePerformancePoints.cs b/osu.Game.Rulesets.Osu.Tests/TestCasePerformancePoints.cs
deleted file mode 100644
index 63026fe316..0000000000
--- a/osu.Game.Rulesets.Osu.Tests/TestCasePerformancePoints.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
-// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
-
-using NUnit.Framework;
-
-namespace osu.Game.Rulesets.Osu.Tests
-{
-    [TestFixture]
-    public class TestCasePerformancePoints : Game.Tests.Visual.TestCasePerformancePoints
-    {
-        public TestCasePerformancePoints()
-            : base(new OsuRuleset())
-        {
-        }
-    }
-}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
new file mode 100644
index 0000000000..50a259ae55
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
@@ -0,0 +1,19 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using osu.Game.Rulesets.Difficulty;
+using osu.Game.Rulesets.Mods;
+
+namespace osu.Game.Rulesets.Osu.Difficulty
+{
+    public class OsuDifficultyAttributes : DifficultyAttributes
+    {
+        public double AimStrain;
+        public double SpeedStrain;
+
+        public OsuDifficultyAttributes(Mod[] mods, double starRating)
+            : base(mods, starRating)
+        {
+        }
+    }
+}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
index 94d2afbf45..400afbc043 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
@@ -2,7 +2,7 @@
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
 using System;
-using System.Collections.Generic;
+using System.Linq;
 using osu.Game.Beatmaps;
 using osu.Game.Rulesets.Difficulty;
 using osu.Game.Rulesets.Mods;
@@ -18,31 +18,26 @@ namespace osu.Game.Rulesets.Osu.Difficulty
         private const int section_length = 400;
         private const double difficulty_multiplier = 0.0675;
 
-        public OsuDifficultyCalculator(IBeatmap beatmap)
-            : base(beatmap)
+        public OsuDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap)
+            : base(ruleset, beatmap)
         {
         }
 
-        public OsuDifficultyCalculator(IBeatmap beatmap, Mod[] mods)
-            : base(beatmap, mods)
+        protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double timeRate)
         {
-        }
-
-        public override double Calculate(Dictionary<string, double> categoryDifficulty = null)
-        {
-            OsuDifficultyBeatmap beatmap = new OsuDifficultyBeatmap((List<OsuHitObject>)Beatmap.HitObjects, TimeRate);
+            OsuDifficultyBeatmap difficultyBeatmap = new OsuDifficultyBeatmap(beatmap.HitObjects.Cast<OsuHitObject>().ToList(), timeRate);
             Skill[] skills =
             {
                 new Aim(),
                 new Speed()
             };
 
-            double sectionLength = section_length * TimeRate;
+            double sectionLength = section_length * timeRate;
 
             // The first object doesn't generate a strain, so we begin with an incremented section end
             double currentSectionEnd = 2 * sectionLength;
 
-            foreach (OsuDifficultyHitObject h in beatmap)
+            foreach (OsuDifficultyHitObject h in difficultyBeatmap)
             {
                 while (h.BaseObject.StartTime > currentSectionEnd)
                 {
@@ -61,16 +56,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty
 
             double aimRating = Math.Sqrt(skills[0].DifficultyValue()) * difficulty_multiplier;
             double speedRating = Math.Sqrt(skills[1].DifficultyValue()) * difficulty_multiplier;
-
             double starRating = aimRating + speedRating + Math.Abs(aimRating - speedRating) / 2;
 
-            if (categoryDifficulty != null)
+            return new OsuDifficultyAttributes(mods, starRating)
             {
-                categoryDifficulty.Add("Aim", aimRating);
-                categoryDifficulty.Add("Speed", speedRating);
-            }
-
-            return starRating;
+                AimStrain = aimRating,
+                SpeedStrain = speedRating
+            };
         }
 
         protected override Mod[] DifficultyAdjustmentMods => new Mod[]
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
index 57cf962fa7..3ab3cc879a 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
@@ -15,6 +15,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
 {
     public class OsuPerformanceCalculator : PerformanceCalculator
     {
+        public new OsuDifficultyAttributes Attributes => (OsuDifficultyAttributes)base.Attributes;
+
         private readonly int countHitCircles;
         private readonly int beatmapMaxCombo;
 
@@ -37,7 +39,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
         private int countMeh;
         private int countMiss;
 
-        public OsuPerformanceCalculator(Ruleset ruleset, IBeatmap beatmap, Score score)
+        public OsuPerformanceCalculator(Ruleset ruleset, WorkingBeatmap beatmap, Score score)
             : base(ruleset, beatmap, score)
         {
             countHitCircles = Beatmap.HitObjects.Count(h => h is HitCircle);
@@ -102,7 +104,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
 
         private double computeAimValue()
         {
-            double aimValue = Math.Pow(5.0f * Math.Max(1.0f, Attributes["Aim"] / 0.0675f) - 4.0f, 3.0f) / 100000.0f;
+            double aimValue = Math.Pow(5.0f * Math.Max(1.0f, Attributes.AimStrain / 0.0675f) - 4.0f, 3.0f) / 100000.0f;
 
             // Longer maps are worth more
             double lengthBonus = 0.95f + 0.4f * Math.Min(1.0f, totalHits / 2000.0f) +
@@ -151,7 +153,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
 
         private double computeSpeedValue()
         {
-            double speedValue = Math.Pow(5.0f * Math.Max(1.0f, Attributes["Speed"] / 0.0675f) - 4.0f, 3.0f) / 100000.0f;
+            double speedValue = Math.Pow(5.0f * Math.Max(1.0f, Attributes.SpeedStrain / 0.0675f) - 4.0f, 3.0f) / 100000.0f;
 
             // Longer maps are worth more
             speedValue *= 0.95f + 0.4f * Math.Min(1.0f, totalHits / 2000.0f) +
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableRepeatPoint.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableRepeatPoint.cs
index b3bc2930d8..26f3ee6bb4 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableRepeatPoint.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableRepeatPoint.cs
@@ -4,6 +4,7 @@
 using System;
 using System.Collections.Generic;
 using osu.Framework.Graphics;
+using osu.Framework.MathUtils;
 using osu.Game.Rulesets.Objects.Drawables;
 using OpenTK;
 using osu.Game.Graphics;
@@ -89,7 +90,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
             // find the next vector2 in the curve which is not equal to our current position to infer a rotation.
             for (int i = searchStart; i >= 0 && i < curve.Count; i += direction)
             {
-                if (curve[i] == Position)
+                if (Precision.AlmostEquals(curve[i], Position))
                     continue;
 
                 Rotation = MathHelper.RadiansToDegrees((float)Math.Atan2(curve[i].Y - Position.Y, curve[i].X - Position.X));
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBody.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBody.cs
index 0a6b1b459a..94a61e7904 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBody.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBody.cs
@@ -139,8 +139,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces
             var texture = new Texture(textureWidth, 1);
 
             //initialise background
-            var upload = new TextureUpload(textureWidth * 4);
-            var bytes = upload.Data;
+            var raw = new RawTexture(textureWidth, 1);
+            var bytes = raw.Data;
 
             const float aa_portion = 0.02f;
             const float border_portion = 0.128f;
@@ -171,7 +171,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces
                 }
             }
 
-            texture.SetData(upload);
+            texture.SetData(new TextureUpload(raw));
             path.Texture = texture;
 
             container.ForceRedraw();
diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs
index b920e889ce..ce80537b93 100644
--- a/osu.Game.Rulesets.Osu/OsuRuleset.cs
+++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs
@@ -120,9 +120,9 @@ namespace osu.Game.Rulesets.Osu
 
         public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.fa_osu_osu_o };
 
-        public override DifficultyCalculator CreateDifficultyCalculator(IBeatmap beatmap, Mod[] mods = null) => new OsuDifficultyCalculator(beatmap, mods);
+        public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new OsuDifficultyCalculator(this, beatmap);
 
-        public override PerformanceCalculator CreatePerformanceCalculator(IBeatmap beatmap, Score score) => new OsuPerformanceCalculator(this, beatmap, score);
+        public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, Score score) => new OsuPerformanceCalculator(this, beatmap, score);
 
         public override HitObjectComposer CreateHitObjectComposer() => new OsuHitObjectComposer(this);
 
@@ -130,7 +130,7 @@ namespace osu.Game.Rulesets.Osu
 
         public override string ShortName => "osu";
 
-        public override SettingsSubsection CreateSettings() => new OsuSettings();
+        public override RulesetSettingsSubsection CreateSettings() => new OsuSettings(this);
 
         public override int? LegacyID => 0;
 
diff --git a/osu.Game.Rulesets.Osu/Replays/OsuReplayInputHandler.cs b/osu.Game.Rulesets.Osu/Replays/OsuReplayInputHandler.cs
index f9e5bfa89b..2eed41d13f 100644
--- a/osu.Game.Rulesets.Osu/Replays/OsuReplayInputHandler.cs
+++ b/osu.Game.Rulesets.Osu/Replays/OsuReplayInputHandler.cs
@@ -30,13 +30,16 @@ namespace osu.Game.Rulesets.Osu.Replays
             }
         }
 
-        public override List<InputState> GetPendingStates()
+        public override List<IInput> GetPendingInputs()
         {
-            return new List<InputState>
+            return new List<IInput>
             {
+                new MousePositionAbsoluteInput
+                {
+                    Position = GamefieldToScreenSpace(Position ?? Vector2.Zero)
+                },
                 new ReplayState<OsuAction>
                 {
-                    Mouse = new ReplayMouseState(GamefieldToScreenSpace(Position ?? Vector2.Zero)),
                     PressedActions = CurrentFrame.Actions
                 }
             };
diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/GameplayCursor.cs b/osu.Game.Rulesets.Osu/UI/Cursor/GameplayCursor.cs
index 240d8dc396..35146dfe29 100644
--- a/osu.Game.Rulesets.Osu/UI/Cursor/GameplayCursor.cs
+++ b/osu.Game.Rulesets.Osu/UI/Cursor/GameplayCursor.cs
@@ -161,7 +161,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
                 };
 
                 this.beatmap.BindTo(beatmap);
-                beatmap.ValueChanged += v => calculateScale();
+                this.beatmap.ValueChanged += v => calculateScale();
 
                 cursorScale = config.GetBindable<double>(OsuSetting.GameplayCursorSize);
                 cursorScale.ValueChanged += v => calculateScale();
diff --git a/osu.Game.Rulesets.Osu/UI/OsuRulesetContainer.cs b/osu.Game.Rulesets.Osu/UI/OsuRulesetContainer.cs
index ad1052f86a..33d4e16662 100644
--- a/osu.Game.Rulesets.Osu/UI/OsuRulesetContainer.cs
+++ b/osu.Game.Rulesets.Osu/UI/OsuRulesetContainer.cs
@@ -18,7 +18,7 @@ using osu.Game.Rulesets.Replays;
 
 namespace osu.Game.Rulesets.Osu.UI
 {
-    public class OsuRulesetContainer : RulesetContainer<OsuHitObject>
+    public class OsuRulesetContainer : RulesetContainer<OsuPlayfield, OsuHitObject>
     {
         public OsuRulesetContainer(Ruleset ruleset, WorkingBeatmap beatmap)
             : base(ruleset, beatmap)
diff --git a/osu.Game.Rulesets.Osu/UI/OsuSettings.cs b/osu.Game.Rulesets.Osu/UI/OsuSettings.cs
index 31ad6701fd..25c009b117 100644
--- a/osu.Game.Rulesets.Osu/UI/OsuSettings.cs
+++ b/osu.Game.Rulesets.Osu/UI/OsuSettings.cs
@@ -8,10 +8,15 @@ using osu.Game.Overlays.Settings;
 
 namespace osu.Game.Rulesets.Osu.UI
 {
-    public class OsuSettings : SettingsSubsection
+    public class OsuSettings : RulesetSettingsSubsection
     {
         protected override string Header => "osu!";
 
+        public OsuSettings(Ruleset ruleset)
+            : base(ruleset)
+        {
+        }
+
         [BackgroundDependencyLoader]
         private void load(OsuConfigManager config)
         {
diff --git a/osu.Game.Rulesets.Taiko.Tests/TestCasePerformancePoints.cs b/osu.Game.Rulesets.Taiko.Tests/TestCasePerformancePoints.cs
deleted file mode 100644
index 2fd9161d13..0000000000
--- a/osu.Game.Rulesets.Taiko.Tests/TestCasePerformancePoints.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
-// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
-
-using NUnit.Framework;
-
-namespace osu.Game.Rulesets.Taiko.Tests
-{
-    [TestFixture]
-    public class TestCasePerformancePoints : Game.Tests.Visual.TestCasePerformancePoints
-    {
-        public TestCasePerformancePoints()
-            : base(new TaikoRuleset())
-        {
-        }
-    }
-}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
index bb666eb528..473c205293 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
@@ -27,54 +27,33 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
         /// </summary>
         private const double decay_weight = 0.9;
 
-        /// <summary>
-        /// HitObjects are stored as a member variable.
-        /// </summary>
-        private readonly List<TaikoHitObjectDifficulty> difficultyHitObjects = new List<TaikoHitObjectDifficulty>();
-
-        public TaikoDifficultyCalculator(IBeatmap beatmap)
-            : base(beatmap)
+        public TaikoDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap)
+            : base(ruleset, beatmap)
         {
         }
 
-        public TaikoDifficultyCalculator(IBeatmap beatmap, Mod[] mods)
-            : base(beatmap, mods)
+        protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double timeRate)
         {
-        }
+            var difficultyHitObjects = new List<TaikoHitObjectDifficulty>();
 
-        public override double Calculate(Dictionary<string, double> categoryDifficulty = null)
-        {
-            // Fill our custom DifficultyHitObject class, that carries additional information
-            difficultyHitObjects.Clear();
-
-            foreach (var hitObject in Beatmap.HitObjects)
+            foreach (var hitObject in beatmap.HitObjects)
                 difficultyHitObjects.Add(new TaikoHitObjectDifficulty((TaikoHitObject)hitObject));
 
             // Sort DifficultyHitObjects by StartTime of the HitObjects - just to make sure.
             difficultyHitObjects.Sort((a, b) => a.BaseHitObject.StartTime.CompareTo(b.BaseHitObject.StartTime));
 
-            if (!calculateStrainValues()) return 0;
+            if (!calculateStrainValues(difficultyHitObjects, timeRate))
+                return new DifficultyAttributes(mods, 0);
 
-            double starRating = calculateDifficulty() * star_scaling_factor;
+            double starRating = calculateDifficulty(difficultyHitObjects, timeRate) * star_scaling_factor;
 
-            if (categoryDifficulty != null)
-                categoryDifficulty["Strain"] = starRating;
-
-            return starRating;
+            return new DifficultyAttributes(mods, starRating);
         }
 
-        protected override Mod[] DifficultyAdjustmentMods => new Mod[]
-        {
-            new TaikoModDoubleTime(),
-            new TaikoModHalfTime(),
-            new TaikoModEasy(),
-            new TaikoModHardRock(),
-        };
-
-        private bool calculateStrainValues()
+        private bool calculateStrainValues(List<TaikoHitObjectDifficulty> objects, double timeRate)
         {
             // Traverse hitObjects in pairs to calculate the strain value of NextHitObject from the strain value of CurrentHitObject and environment.
-            using (List<TaikoHitObjectDifficulty>.Enumerator hitObjectsEnumerator = difficultyHitObjects.GetEnumerator())
+            using (var hitObjectsEnumerator = objects.GetEnumerator())
             {
                 if (!hitObjectsEnumerator.MoveNext()) return false;
 
@@ -84,7 +63,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
                 while (hitObjectsEnumerator.MoveNext())
                 {
                     var next = hitObjectsEnumerator.Current;
-                    next?.CalculateStrains(current, TimeRate);
+                    next?.CalculateStrains(current, timeRate);
                     current = next;
                 }
 
@@ -92,9 +71,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
             }
         }
 
-        private double calculateDifficulty()
+        private double calculateDifficulty(List<TaikoHitObjectDifficulty> objects, double timeRate)
         {
-            double actualStrainStep = strain_step * TimeRate;
+            double actualStrainStep = strain_step * timeRate;
 
             // Find the highest strain value within each strain step
             List<double> highestStrains = new List<double>();
@@ -102,7 +81,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
             double maximumStrain = 0; // We need to keep track of the maximum strain in the current interval
 
             TaikoHitObjectDifficulty previousHitObject = null;
-            foreach (var hitObject in difficultyHitObjects)
+            foreach (var hitObject in objects)
             {
                 // While we are beyond the current interval push the currently available maximum to our strain list
                 while (hitObject.BaseHitObject.StartTime > intervalEndTime)
@@ -144,5 +123,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
 
             return difficulty;
         }
+
+        protected override Mod[] DifficultyAdjustmentMods => new Mod[]
+        {
+            new TaikoModDoubleTime(),
+            new TaikoModHalfTime(),
+            new TaikoModEasy(),
+            new TaikoModHardRock(),
+        };
     }
 }
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs
index 6b1a25d667..53cfb4fd0f 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs
@@ -22,10 +22,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
         private int countMeh;
         private int countMiss;
 
-        public TaikoPerformanceCalculator(Ruleset ruleset, IBeatmap beatmap, Score score)
+        public TaikoPerformanceCalculator(Ruleset ruleset, WorkingBeatmap beatmap, Score score)
             : base(ruleset, beatmap, score)
         {
-            beatmapMaxCombo = beatmap.HitObjects.Count(h => h is Hit);
+            beatmapMaxCombo = Beatmap.HitObjects.Count(h => h is Hit);
         }
 
         public override double Calculate(Dictionary<string, double> categoryDifficulty = null)
@@ -68,7 +68,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
 
         private double computeStrainValue()
         {
-            double strainValue = Math.Pow(5.0 * Math.Max(1.0, Attributes["Strain"] / 0.0075) - 4.0, 2.0) / 100000.0;
+            double strainValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.StarRating / 0.0075) - 4.0, 2.0) / 100000.0;
 
             // Longer maps are worth more
             double lengthBonus = 1 + 0.1f * Math.Min(1.0, totalHits / 1500.0);
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs
index ad361b66bc..12228cbc21 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs
@@ -89,6 +89,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
                         SecondHitAllowed = false;
                         validKeyPressed = false;
 
+                        UnproxyContent();
                         this.Delay(HitObject.HitWindows.HalfWindowFor(HitResult.Miss)).Expire();
                         break;
                     case ArmedState.Miss:
@@ -96,6 +97,10 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
                             .Expire();
                         break;
                     case ArmedState.Hit:
+                        // If we're far enough away from the left stage, we should bring outselves in front of it
+                        if (X >= -0.05f)
+                            ProxyContent();
+
                         var flash = circlePiece?.FlashBox;
                         if (flash != null)
                         {
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs
index 5c3f8601b4..b2f7de4ef2 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs
@@ -19,12 +19,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
 {
     public class DrawableSwell : DrawableTaikoHitObject<Swell>
     {
-        /// <summary>
-        /// Invoked when the swell has reached the hit target, i.e. when CurrentTime >= StartTime.
-        /// This is only ever invoked once.
-        /// </summary>
-        public event Action OnStart;
-
         /// <summary>
         /// A judgement is only displayed when the user has complete the swell (either a hit or miss).
         /// </summary>
@@ -40,6 +34,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
         private readonly CircularContainer expandingRing;
 
         private bool hasStarted;
+
         private readonly SwellSymbolPiece symbol;
 
         public DrawableSwell(Swell swell)
@@ -47,7 +42,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
         {
             FillMode = FillMode.Fit;
 
-            AddInternal(bodyContainer = new Container
+            Content.Add(bodyContainer = new Container
             {
                 RelativeSizeAxes = Axes.Both,
                 Depth = 1,
@@ -171,6 +166,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
             switch (state)
             {
                 case ArmedState.Idle:
+                    UnproxyContent();
                     expandingRing.FadeTo(0);
                     using (BeginAbsoluteSequence(HitObject.StartTime - preempt, true))
                         targetRing.ScaleTo(target_ring_scale, preempt * 4, Easing.OutQuint);
@@ -195,11 +191,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
             X = Math.Max(0, X);
 
             double t = Math.Min(HitObject.StartTime, Time.Current);
-            if (t == HitObject.StartTime && !hasStarted)
-            {
-                OnStart?.Invoke();
-                hasStarted = true;
-            }
+            if (t == HitObject.StartTime)
+                ProxyContent();
         }
 
         private bool? lastWasCentre;
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs
index 971fd8854d..a6d61f1a5a 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs
@@ -9,10 +9,75 @@ using OpenTK;
 using System.Linq;
 using osu.Game.Audio;
 using System.Collections.Generic;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Primitives;
 
 namespace osu.Game.Rulesets.Taiko.Objects.Drawables
 {
-    public abstract class DrawableTaikoHitObject<TaikoHitType> : DrawableHitObject<TaikoHitObject>, IKeyBindingHandler<TaikoAction>
+    public abstract class DrawableTaikoHitObject : DrawableHitObject<TaikoHitObject>, IKeyBindingHandler<TaikoAction>
+    {
+        protected readonly Container Content;
+        private readonly Container proxiedContent;
+
+        private readonly Container nonProxiedContent;
+
+        protected DrawableTaikoHitObject(TaikoHitObject hitObject)
+            : base(hitObject)
+        {
+            InternalChildren = new[]
+            {
+                nonProxiedContent = new Container
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Child = Content = new Container { RelativeSizeAxes = Axes.Both }
+                },
+                proxiedContent = new Container { RelativeSizeAxes = Axes.Both }
+            };
+        }
+
+        /// <summary>
+        /// <see cref="proxiedContent"/> is proxied into an upper layer. We don't want to get masked away otherwise <see cref="proxiedContent"/> would too.
+        /// </summary>
+        protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false;
+
+        private bool isProxied;
+
+        /// <summary>
+        /// Moves <see cref="Content"/> to a layer proxied above the playfield.
+        /// Does nothing is content is already proxied.
+        /// </summary>
+        protected void ProxyContent()
+        {
+            if (isProxied) return;
+            isProxied = true;
+
+            nonProxiedContent.Remove(Content);
+            proxiedContent.Add(Content);
+        }
+
+        /// <summary>
+        /// Moves <see cref="Content"/> to the normal hitobject layer.
+        /// Does nothing is content is not currently proxied.
+        /// </summary>
+        protected void UnproxyContent()
+        {
+            if (!isProxied) return;
+            isProxied = false;
+
+            proxiedContent.Remove(Content);
+            nonProxiedContent.Add(Content);
+        }
+
+        /// <summary>
+        /// Creates a proxy for the content of this <see cref="DrawableTaikoHitObject"/>.
+        /// </summary>
+        public Drawable CreateProxiedContent() => proxiedContent.CreateProxy();
+
+        public abstract bool OnPressed(TaikoAction action);
+        public virtual bool OnReleased(TaikoAction action) => false;
+    }
+
+    public abstract class DrawableTaikoHitObject<TaikoHitType> : DrawableTaikoHitObject
         where TaikoHitType : TaikoHitObject
     {
         public override Vector2 OriginPosition => new Vector2(DrawHeight / 2);
@@ -34,7 +99,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
             RelativeSizeAxes = Axes.Both;
             Size = BaseSize = new Vector2(HitObject.IsStrong ? TaikoHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE);
 
-            InternalChild = MainPiece = CreateMainPiece();
+            Content.Add(MainPiece = CreateMainPiece());
             MainPiece.KiaiMode = HitObject.Kiai;
         }
 
@@ -44,9 +109,5 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
         protected override string SampleNamespace => "Taiko";
 
         protected virtual TaikoPiece CreateMainPiece() => new CirclePiece();
-
-        public abstract bool OnPressed(TaikoAction action);
-
-        public virtual bool OnReleased(TaikoAction action) => false;
     }
 }
diff --git a/osu.Game.Rulesets.Taiko/Replays/TaikoFramedReplayInputHandler.cs b/osu.Game.Rulesets.Taiko/Replays/TaikoFramedReplayInputHandler.cs
index 6ccbd575e5..eae033401e 100644
--- a/osu.Game.Rulesets.Taiko/Replays/TaikoFramedReplayInputHandler.cs
+++ b/osu.Game.Rulesets.Taiko/Replays/TaikoFramedReplayInputHandler.cs
@@ -17,6 +17,6 @@ namespace osu.Game.Rulesets.Taiko.Replays
 
         protected override bool IsImportant(TaikoReplayFrame frame) => frame.Actions.Any();
 
-        public override List<InputState> GetPendingStates() => new List<InputState> { new ReplayState<TaikoAction> { PressedActions = CurrentFrame.Actions } };
+        public override List<IInput> GetPendingInputs() => new List<IInput> { new ReplayState<TaikoAction> { PressedActions = CurrentFrame.Actions } };
     }
 }
diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs
index ccf28a2f12..609fd27bb4 100644
--- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs
+++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs
@@ -27,14 +27,12 @@ namespace osu.Game.Rulesets.Taiko
 
         public override IEnumerable<KeyBinding> GetDefaultKeyBindings(int variant = 0) => new[]
         {
+            new KeyBinding(InputKey.MouseLeft, TaikoAction.LeftCentre),
+            new KeyBinding(InputKey.MouseRight, TaikoAction.LeftRim),
             new KeyBinding(InputKey.D, TaikoAction.LeftRim),
             new KeyBinding(InputKey.F, TaikoAction.LeftCentre),
             new KeyBinding(InputKey.J, TaikoAction.RightCentre),
             new KeyBinding(InputKey.K, TaikoAction.RightRim),
-            new KeyBinding(InputKey.MouseLeft, TaikoAction.LeftCentre),
-            new KeyBinding(InputKey.MouseLeft, TaikoAction.RightCentre),
-            new KeyBinding(InputKey.MouseRight, TaikoAction.LeftRim),
-            new KeyBinding(InputKey.MouseRight, TaikoAction.RightRim),
         };
 
         public override IEnumerable<Mod> ConvertLegacyMods(LegacyMods mods)
@@ -91,7 +89,7 @@ namespace osu.Game.Rulesets.Taiko
                     {
                         new TaikoModHardRock(),
                         new MultiMod(new TaikoModSuddenDeath(), new TaikoModPerfect()),
-                        new MultiMod(new TaikoModDoubleTime(), new TaikoModDaycore()),
+                        new MultiMod(new TaikoModDoubleTime(), new TaikoModNightcore()),
                         new TaikoModHidden(),
                         new TaikoModFlashlight(),
                     };
@@ -114,9 +112,9 @@ namespace osu.Game.Rulesets.Taiko
 
         public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.fa_osu_taiko_o };
 
-        public override DifficultyCalculator CreateDifficultyCalculator(IBeatmap beatmap, Mod[] mods = null) => new TaikoDifficultyCalculator(beatmap, mods);
+        public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new TaikoDifficultyCalculator(this, beatmap);
 
-        public override PerformanceCalculator CreatePerformanceCalculator(IBeatmap beatmap, Score score) => new TaikoPerformanceCalculator(this, beatmap, score);
+        public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, Score score) => new TaikoPerformanceCalculator(this, beatmap, score);
 
         public override int? LegacyID => 1;
 
diff --git a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs
index ee2c1d5ad5..4dd0ba4d3d 100644
--- a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs
+++ b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs
@@ -18,6 +18,8 @@ namespace osu.Game.Rulesets.Taiko.UI
     /// </summary>
     internal class HitExplosion : CircularContainer
     {
+        public override bool RemoveWhenNotAlive => true;
+
         public readonly DrawableHitObject JudgedObject;
 
         private readonly Box innerFill;
@@ -66,7 +68,7 @@ namespace osu.Game.Rulesets.Taiko.UI
             this.ScaleTo(3f, 1000, Easing.OutQuint);
             this.FadeOut(500);
 
-            Expire();
+            Expire(true);
         }
 
         /// <summary>
diff --git a/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs
index ac3cf8305a..287d59972a 100644
--- a/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs
+++ b/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs
@@ -14,6 +14,8 @@ namespace osu.Game.Rulesets.Taiko.UI
 {
     public class KiaiHitExplosion : CircularContainer
     {
+        public override bool RemoveWhenNotAlive => true;
+
         public readonly DrawableHitObject JudgedObject;
 
         private readonly bool isRim;
@@ -62,7 +64,7 @@ namespace osu.Game.Rulesets.Taiko.UI
             this.ScaleTo(new Vector2(1, 3f), 500, Easing.OutQuint);
             this.FadeOut(250);
 
-            Expire();
+            Expire(true);
         }
     }
 }
diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs
index 417a7c2581..7fdd3cd1e2 100644
--- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs
+++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs
@@ -216,10 +216,9 @@ namespace osu.Game.Rulesets.Taiko.UI
             if (barline != null)
                 barlineContainer.Add(barline.CreateProxy());
 
-            // Swells should be moved at the very top of the playfield when they reach the hit target
-            var swell = h as DrawableSwell;
-            if (swell != null)
-                swell.OnStart += () => topLevelHitContainer.Add(swell.CreateProxy());
+            var taikoObject = h as DrawableTaikoHitObject;
+            if (taikoObject != null)
+                topLevelHitContainer.Add(taikoObject.CreateProxiedContent());
         }
 
         internal void OnJudgement(DrawableHitObject judgedObject, Judgement judgement)
@@ -244,19 +243,6 @@ namespace osu.Game.Rulesets.Taiko.UI
                 hitExplosionContainer.Children.FirstOrDefault(e => e.JudgedObject == judgedObject)?.VisualiseSecondHit();
             else
             {
-                if (judgedObject.X >= -0.05f && judgedObject is DrawableHit)
-                {
-                    // If we're far enough away from the left stage, we should bring outselves in front of it
-                    // Todo: The following try-catch is temporary for replay rewinding support
-                    try
-                    {
-                        topLevelHitContainer.Add(judgedObject.CreateProxy());
-                    }
-                    catch
-                    {
-                    }
-                }
-
                 hitExplosionContainer.Add(new HitExplosion(judgedObject, isRim));
 
                 if (judgedObject.HitObject.Kiai)
diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs
index 4985aa9365..1628423fe8 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs
@@ -86,7 +86,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
                 Assert.AreEqual(string.Empty, metadata.Source);
                 Assert.AreEqual("MBC7 Unisphere 地球ヤバイEP Chikyu Yabai", metadata.Tags);
                 Assert.AreEqual(557821, beatmapInfo.OnlineBeatmapID);
-                Assert.AreEqual(241526, metadata.OnlineBeatmapSetID);
+                Assert.AreEqual(241526, beatmapInfo.BeatmapSet.OnlineBeatmapSetID);
             }
         }
 
diff --git a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs
index 489c38c420..b834be71f1 100644
--- a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs
@@ -28,7 +28,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
         {
             var beatmap = decodeAsJson(normal);
             var meta = beatmap.BeatmapInfo.Metadata;
-            Assert.AreEqual(241526, meta.OnlineBeatmapSetID);
+            Assert.AreEqual(241526, beatmap.BeatmapInfo.BeatmapSet.OnlineBeatmapSetID);
             Assert.AreEqual("Soleily", meta.Artist);
             Assert.AreEqual("Soleily", meta.ArtistUnicode);
             Assert.AreEqual("03. Renatus - Soleily 192kbps.mp3", meta.AudioFile);
diff --git a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs
index 1c9696901c..616ba132fd 100644
--- a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs
+++ b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs
@@ -19,7 +19,7 @@ namespace osu.Game.Tests.Beatmaps.IO
     [TestFixture]
     public class ImportBeatmapTest
     {
-        private const string osz_path = @"../../../../osu-resources/osu.Game.Resources/Beatmaps/241526 Soleily - Renatus.osz";
+        public const string TEST_OSZ_PATH = @"../../../../osu-resources/osu.Game.Resources/Beatmaps/241526 Soleily - Renatus.osz";
 
         [Test]
         public void TestImportWhenClosed()
@@ -265,7 +265,7 @@ namespace osu.Game.Tests.Beatmaps.IO
         private string createTemporaryBeatmap()
         {
             var temp = Path.GetTempFileName() + ".osz";
-            File.Copy(osz_path, temp, true);
+            File.Copy(TEST_OSZ_PATH, temp, true);
             Assert.IsTrue(File.Exists(temp));
             return temp;
         }
diff --git a/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs b/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs
index f9b055ed55..0039516c0c 100644
--- a/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs
+++ b/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs
@@ -48,11 +48,14 @@ namespace osu.Game.Tests.Beatmaps.IO
             {
                 var reader = new ZipArchiveReader(osz);
 
-                BeatmapMetadata meta;
-                using (var stream = new StreamReader(reader.GetStream("Soleily - Renatus (Deif) [Platter].osu")))
-                    meta = Decoder.GetDecoder<Beatmap>(stream).Decode(stream).Metadata;
+                Beatmap beatmap;
 
-                Assert.AreEqual(241526, meta.OnlineBeatmapSetID);
+                using (var stream = new StreamReader(reader.GetStream("Soleily - Renatus (Deif) [Platter].osu")))
+                    beatmap = Decoder.GetDecoder<Beatmap>(stream).Decode(stream);
+
+                var meta = beatmap.Metadata;
+
+                Assert.AreEqual(241526, beatmap.BeatmapInfo.BeatmapSet.OnlineBeatmapSetID);
                 Assert.AreEqual("Soleily", meta.Artist);
                 Assert.AreEqual("Soleily", meta.ArtistUnicode);
                 Assert.AreEqual("03. Renatus - Soleily 192kbps.mp3", meta.AudioFile);
diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs
index fd697ba3d3..49494b65b9 100644
--- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs
+++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs
@@ -2,8 +2,8 @@
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
 using System;
-using System.Collections.Generic;
 using NUnit.Framework;
+using osu.Game.Beatmaps;
 using osu.Game.Rulesets.Difficulty;
 using osu.Game.Rulesets.Mods;
 
@@ -139,14 +139,14 @@ namespace osu.Game.Tests.NonVisual
         private class TestDifficultyCalculator : DifficultyCalculator
         {
             public TestDifficultyCalculator(params Mod[] mods)
-                : base(null)
+                : base(null, null)
             {
                 DifficultyAdjustmentMods = mods;
             }
 
-            public override double Calculate(Dictionary<string, double> categoryDifficulty = null) => throw new NotImplementedException();
-
             protected override Mod[] DifficultyAdjustmentMods { get; }
+
+            protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double timeRate) => throw new NotImplementedException();
         }
     }
 }
diff --git a/osu.Game.Tests/Visual/TestCaseBeatmapCarousel.cs b/osu.Game.Tests/Visual/TestCaseBeatmapCarousel.cs
index 4679fca855..6d2b37d981 100644
--- a/osu.Game.Tests/Visual/TestCaseBeatmapCarousel.cs
+++ b/osu.Game.Tests/Visual/TestCaseBeatmapCarousel.cs
@@ -449,7 +449,6 @@ namespace osu.Game.Tests.Visual
                 Hash = new MemoryStream(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())).ComputeMD5Hash(),
                 Metadata = new BeatmapMetadata
                 {
-                    OnlineBeatmapSetID = id,
                     // Create random metadata, then we can check if sorting works based on these
                     Artist = $"peppy{id.ToString().PadLeft(6, '0')}",
                     Title = $"test set #{id}!",
@@ -503,7 +502,6 @@ namespace osu.Game.Tests.Visual
                 Hash = new MemoryStream(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())).ComputeMD5Hash(),
                 Metadata = new BeatmapMetadata
                 {
-                    OnlineBeatmapSetID = id,
                     // Create random metadata, then we can check if sorting works based on these
                     Artist = $"peppy{id.ToString().PadLeft(6, '0')}",
                     Title = $"test set #{id}!",
diff --git a/osu.Game.Tests/Visual/TestCaseBeatmapScoresContainer.cs b/osu.Game.Tests/Visual/TestCaseBeatmapScoresContainer.cs
index 3f63bacfa6..d3098864f4 100644
--- a/osu.Game.Tests/Visual/TestCaseBeatmapScoresContainer.cs
+++ b/osu.Game.Tests/Visual/TestCaseBeatmapScoresContainer.cs
@@ -52,7 +52,7 @@ namespace osu.Game.Tests.Visual
             AddStep("remove scores", () => scoresContainer.Scores = null);
             AddStep("resize to big", () => container.ResizeWidthTo(1, 300));
             AddStep("resize to normal", () => container.ResizeWidthTo(0.8f, 300));
-            AddStep("online scores", () => scoresContainer.Beatmap = new BeatmapInfo { OnlineBeatmapSetID = 1, OnlineBeatmapID = 75, Ruleset = new OsuRuleset().RulesetInfo });
+            AddStep("online scores", () => scoresContainer.Beatmap = new BeatmapInfo { OnlineBeatmapID = 75, Ruleset = new OsuRuleset().RulesetInfo });
 
 
             scores = new[]
diff --git a/osu.Game.Tests/Visual/TestCaseEditorComposeTimeline.cs b/osu.Game.Tests/Visual/TestCaseEditorComposeTimeline.cs
index 6c74876e81..9ad8bf7b92 100644
--- a/osu.Game.Tests/Visual/TestCaseEditorComposeTimeline.cs
+++ b/osu.Game.Tests/Visual/TestCaseEditorComposeTimeline.cs
@@ -4,28 +4,48 @@
 using System;
 using System.Collections.Generic;
 using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Configuration;
 using OpenTK;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
-using osu.Game.Overlays;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Timing;
+using osu.Game.Beatmaps;
 using osu.Game.Screens.Edit.Screens.Compose.Timeline;
+using OpenTK.Graphics;
 
 namespace osu.Game.Tests.Visual
 {
     [TestFixture]
-    public class TestCaseEditorComposeTimeline : OsuTestCase
+    public class TestCaseEditorComposeTimeline : EditorClockTestCase
     {
-        public override IReadOnlyList<Type> RequiredTypes => new[] { typeof(TimelineArea), typeof(Timeline), typeof(TimelineButton) };
-
-        public TestCaseEditorComposeTimeline()
+        public override IReadOnlyList<Type> RequiredTypes => new[]
         {
+            typeof(TimelineArea),
+            typeof(Timeline),
+            typeof(TimelineButton),
+            typeof(CentreMarker)
+        };
+
+        [BackgroundDependencyLoader]
+        private void load()
+        {
+            Beatmap.Value = new WaveformTestBeatmap();
+
             Children = new Drawable[]
             {
-                new MusicController
+                new FillFlowContainer
                 {
-                    Anchor = Anchor.TopCentre,
-                    Origin = Anchor.TopCentre,
-                    State = Visibility.Visible
+                    AutoSizeAxes = Axes.Both,
+                    Direction = FillDirection.Vertical,
+                    Spacing = new Vector2(0, 5),
+                    Children = new Drawable[]
+                    {
+                        new StartStopButton(),
+                        new AudioVisualiser(),
+                    }
                 },
                 new TimelineArea
                 {
@@ -36,5 +56,85 @@ namespace osu.Game.Tests.Visual
                 }
             };
         }
+
+        private class AudioVisualiser : CompositeDrawable
+        {
+            private readonly Drawable marker;
+
+            private readonly IBindable<WorkingBeatmap> beatmap = new Bindable<WorkingBeatmap>();
+            private IAdjustableClock adjustableClock;
+
+            public AudioVisualiser()
+            {
+                Size = new Vector2(250, 25);
+
+                InternalChildren = new[]
+                {
+                    new Box
+                    {
+                        RelativeSizeAxes = Axes.Both,
+                        Alpha = 0.25f,
+                    },
+                    marker = new Box
+                    {
+                        RelativePositionAxes = Axes.X,
+                        RelativeSizeAxes = Axes.Y,
+                        Width = 2,
+                    }
+                };
+            }
+
+            [BackgroundDependencyLoader]
+            private void load(IAdjustableClock adjustableClock, IBindableBeatmap beatmap)
+            {
+                this.adjustableClock = adjustableClock;
+                this.beatmap.BindTo(beatmap);
+            }
+
+            protected override void Update()
+            {
+                base.Update();
+
+                if (beatmap.Value.Track.IsLoaded)
+                    marker.X = (float)(adjustableClock.CurrentTime / beatmap.Value.Track.Length);
+            }
+        }
+
+        private class StartStopButton : Button
+        {
+            private IAdjustableClock adjustableClock;
+            private bool started;
+
+            public StartStopButton()
+            {
+                BackgroundColour = Color4.SlateGray;
+                Size = new Vector2(100, 50);
+                Text = "Start";
+
+                Action = onClick;
+            }
+
+            [BackgroundDependencyLoader]
+            private void load(IAdjustableClock adjustableClock)
+            {
+                this.adjustableClock = adjustableClock;
+            }
+
+            private void onClick()
+            {
+                if (started)
+                {
+                    adjustableClock.Stop();
+                    Text = "Start";
+                }
+                else
+                {
+                    adjustableClock.Start();
+                    Text = "Stop";
+                }
+
+                started = !started;
+            }
+        }
     }
 }
diff --git a/osu.Game.Tests/Visual/TestCaseLounge.cs b/osu.Game.Tests/Visual/TestCaseLounge.cs
index b96d705d5c..174873b011 100644
--- a/osu.Game.Tests/Visual/TestCaseLounge.cs
+++ b/osu.Game.Tests/Visual/TestCaseLounge.cs
@@ -8,6 +8,8 @@ using osu.Framework.Allocation;
 using osu.Game.Beatmaps;
 using osu.Game.Online.Multiplayer;
 using osu.Game.Rulesets;
+using osu.Game.Screens;
+using osu.Game.Screens.Backgrounds;
 using osu.Game.Screens.Multi.Components;
 using osu.Game.Screens.Multi.Screens.Lounge;
 using osu.Game.Users;
@@ -165,6 +167,7 @@ namespace osu.Game.Tests.Visual
             AddStep(@"set rooms", () => lounge.Rooms = rooms);
             selectAssert(1);
             AddStep(@"open room 1", () => clickRoom(1));
+            AddUntilStep(() => lounge.ChildScreen?.IsCurrentScreen == true, "wait until room current");
             AddStep(@"make lounge current", lounge.MakeCurrent);
             filterAssert(@"THE FINAL", LoungeTab.Public, 1);
             filterAssert(string.Empty, LoungeTab.Public, 2);
@@ -198,6 +201,8 @@ namespace osu.Game.Tests.Visual
 
         private class TestLounge : Lounge
         {
+            protected override BackgroundScreen CreateBackground() => new BackgroundScreenDefault();
+
             public IEnumerable<DrawableRoom> ChildRooms => RoomsContainer.Children.Where(r => r.MatchingFilter);
             public Room SelectedRoom => Inspector.Room;
 
diff --git a/osu.Game.Tests/Visual/TestCaseMatch.cs b/osu.Game.Tests/Visual/TestCaseMatch.cs
new file mode 100644
index 0000000000..bb22358425
--- /dev/null
+++ b/osu.Game.Tests/Visual/TestCaseMatch.cs
@@ -0,0 +1,142 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Game.Beatmaps;
+using osu.Game.Online.Multiplayer;
+using osu.Game.Rulesets;
+using osu.Game.Screens.Multi.Screens.Match;
+using osu.Game.Users;
+
+namespace osu.Game.Tests.Visual
+{
+    [TestFixture]
+    public class TestCaseMatch : OsuTestCase
+    {
+        [BackgroundDependencyLoader]
+        private void load(RulesetStore rulesets)
+        {
+            Room room = new Room
+            {
+                Name = { Value = @"One Awesome Room" },
+                Status = { Value = new RoomStatusOpen() },
+                Availability = { Value = RoomAvailability.Public },
+                Type = { Value = new GameTypeTeamVersus() },
+                Beatmap =
+                {
+                    Value = new BeatmapInfo
+                    {
+                        StarDifficulty = 5.02,
+                        Ruleset = rulesets.GetRuleset(1),
+                        Metadata = new BeatmapMetadata
+                        {
+                            Title = @"Paradigm Shift",
+                            Artist = @"Morimori Atsushi",
+                            AuthorString = @"eiri-",
+                        },
+                        BeatmapSet = new BeatmapSetInfo
+                        {
+                            OnlineInfo = new BeatmapSetOnlineInfo
+                            {
+                                Covers = new BeatmapSetOnlineCovers
+                                {
+                                    Cover = @"https://assets.ppy.sh/beatmaps/765055/covers/cover.jpg?1526955337",
+                                },
+                            },
+                        },
+                    },
+                },
+                MaxParticipants = { Value = 5 },
+                Participants =
+                {
+                    Value = new[]
+                    {
+                        new User
+                        {
+                            Username = @"eiri-",
+                            Id = 3388410,
+                            Country = new Country { FlagName = @"US" },
+                            CoverUrl = @"https://assets.ppy.sh/user-profile-covers/3388410/00a8486a247831e1cc4375db519f611ac970bda8bc0057d78b0f540ea38c3e58.jpeg",
+                            IsSupporter = true,
+                        },
+                        new User
+                        {
+                            Username = @"Nepuri",
+                            Id = 6637817,
+                            Country = new Country { FlagName = @"DE" },
+                            CoverUrl = @"https://assets.ppy.sh/user-profile-covers/6637817/9085fc60248b6b5327a72c1dcdecf2dbedba810ae0ab6bcf7224e46b1339632a.jpeg",
+                            IsSupporter = true,
+                        },
+                        new User
+                        {
+                            Username = @"goheegy",
+                            Id = 8057655,
+                            Country = new Country { FlagName = @"GB" },
+                            CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8057655/21cec27c25a11dc197a4ec6a74253dbabb495949b0e0697113352f12007018c5.jpeg",
+                        },
+                        new User
+                        {
+                            Username = @"Alumetri",
+                            Id = 5371497,
+                            Country = new Country { FlagName = @"RU" },
+                            CoverUrl = @"https://assets.ppy.sh/user-profile-covers/5371497/e023b8c7fbe3613e64bd4856703517ea50fbed8a5805dc9acda9efe9897c67e2.jpeg",
+                        },
+                    }
+                },
+            };
+
+            Match match = new Match(room);
+
+            AddStep(@"show", () => Add(match));
+            AddStep(@"null beatmap", () => room.Beatmap.Value = null);
+            AddStep(@"change name", () => room.Name.Value = @"Two Awesome Rooms");
+            AddStep(@"change status", () => room.Status.Value = new RoomStatusPlaying());
+            AddStep(@"change availability", () => room.Availability.Value = RoomAvailability.FriendsOnly);
+            AddStep(@"change type", () => room.Type.Value = new GameTypeTag());
+            AddStep(@"change beatmap", () => room.Beatmap.Value = new BeatmapInfo
+            {
+                StarDifficulty = 4.33,
+                Ruleset = rulesets.GetRuleset(2),
+                Metadata = new BeatmapMetadata
+                {
+                    Title = @"Yasashisa no Riyuu",
+                    Artist = @"ChouCho",
+                    AuthorString = @"celerih",
+                },
+                BeatmapSet = new BeatmapSetInfo
+                {
+                    OnlineInfo = new BeatmapSetOnlineInfo
+                    {
+                        Covers = new BeatmapSetOnlineCovers
+                        {
+                            Cover = @"https://assets.ppy.sh/beatmaps/685391/covers/cover.jpg?1524597970",
+                        },
+                    },
+                },
+            });
+
+            AddStep(@"null max participants", () => room.MaxParticipants.Value = null);
+            AddStep(@"change participants", () => room.Participants.Value = new[]
+            {
+                new User
+                {
+                    Username = @"Spectator",
+                    Id = 702598,
+                    Country = new Country { FlagName = @"KR" },
+                    CoverUrl = @"https://assets.ppy.sh/user-profile-covers/702598/3bbf4cb8b8d2cf8b03145000a975ff27e191ab99b0920832e7dd67386280e288.jpeg",
+                    IsSupporter = true,
+                },
+                new User
+                {
+                    Username = @"celerih",
+                    Id = 4696296,
+                    Country = new Country { FlagName = @"CA" },
+                    CoverUrl = @"https://assets.ppy.sh/user-profile-covers/4696296/7f8500731d0ac66d5472569d146a7be07d9460273361913f22c038867baddaef.jpeg",
+                },
+            });
+
+            AddStep(@"exit", match.Exit);
+        }
+    }
+}
diff --git a/osu.Game.Tests/Visual/TestCaseMatchHeader.cs b/osu.Game.Tests/Visual/TestCaseMatchHeader.cs
new file mode 100644
index 0000000000..34f98f97c2
--- /dev/null
+++ b/osu.Game.Tests/Visual/TestCaseMatchHeader.cs
@@ -0,0 +1,43 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Screens.Multi.Screens.Match;
+
+namespace osu.Game.Tests.Visual
+{
+    [TestFixture]
+    public class TestCaseMatchHeader : OsuTestCase
+    {
+        public TestCaseMatchHeader()
+        {
+            Header header = new Header();
+            Add(header);
+
+            AddStep(@"set beatmap set", () => header.BeatmapSet = new BeatmapSetInfo
+            {
+                OnlineInfo = new BeatmapSetOnlineInfo
+                {
+                    Covers = new BeatmapSetOnlineCovers
+                    {
+                        Cover = @"https://assets.ppy.sh/beatmaps/760757/covers/cover.jpg?1526944540",
+                    },
+                },
+            });
+
+            AddStep(@"change beatmap set", () => header.BeatmapSet = new BeatmapSetInfo
+            {
+                OnlineInfo = new BeatmapSetOnlineInfo
+                {
+                    Covers = new BeatmapSetOnlineCovers
+                    {
+                        Cover = @"https://assets.ppy.sh/beatmaps/761883/covers/cover.jpg?1525557400",
+                    },
+                },
+            });
+
+            AddStep(@"null beatmap set", () => header.BeatmapSet = null);
+        }
+    }
+}
diff --git a/osu.Game.Tests/Visual/TestCaseMatchInfo.cs b/osu.Game.Tests/Visual/TestCaseMatchInfo.cs
new file mode 100644
index 0000000000..205da6932f
--- /dev/null
+++ b/osu.Game.Tests/Visual/TestCaseMatchInfo.cs
@@ -0,0 +1,57 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Game.Beatmaps;
+using osu.Game.Online.Multiplayer;
+using osu.Game.Rulesets;
+using osu.Game.Screens.Multi.Screens.Match;
+
+namespace osu.Game.Tests.Visual
+{
+    [TestFixture]
+    public class TestCaseMatchInfo : OsuTestCase
+    {
+        [BackgroundDependencyLoader]
+        private void load(RulesetStore rulesets)
+        {
+            Info info = new Info();
+            Add(info);
+
+            AddStep(@"set name", () => info.Name = @"Room Name?");
+            AddStep(@"set availability", () => info.Availability = RoomAvailability.FriendsOnly);
+            AddStep(@"set status", () => info.Status = new RoomStatusPlaying());
+            AddStep(@"set beatmap", () => info.Beatmap = new BeatmapInfo
+            {
+                StarDifficulty = 2.4,
+                Ruleset = rulesets.GetRuleset(0),
+                Metadata = new BeatmapMetadata
+                {
+                    Title = @"My Song",
+                    Artist = @"VisualTests",
+                    AuthorString = @"osu!lazer",
+                },
+            });
+
+            AddStep(@"set type", () => info.Type = new GameTypeTagTeam());
+
+            AddStep(@"change name", () => info.Name = @"Room Name!");
+            AddStep(@"change availability", () => info.Availability = RoomAvailability.InviteOnly);
+            AddStep(@"change status", () => info.Status = new RoomStatusOpen());
+            AddStep(@"null beatmap", () => info.Beatmap = null);
+            AddStep(@"change type", () => info.Type = new GameTypeTeamVersus());
+            AddStep(@"change beatmap", () => info.Beatmap = new BeatmapInfo
+            {
+                StarDifficulty = 4.2,
+                Ruleset = rulesets.GetRuleset(3),
+                Metadata = new BeatmapMetadata
+                {
+                    Title = @"Your Song",
+                    Artist = @"Tester",
+                    AuthorString = @"Someone",
+                },
+            });
+        }
+    }
+}
diff --git a/osu.Game.Tests/Visual/TestCaseMatchParticipants.cs b/osu.Game.Tests/Visual/TestCaseMatchParticipants.cs
new file mode 100644
index 0000000000..d6ae07252b
--- /dev/null
+++ b/osu.Game.Tests/Visual/TestCaseMatchParticipants.cs
@@ -0,0 +1,56 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using NUnit.Framework;
+using osu.Framework.Graphics;
+using osu.Game.Screens.Multi.Screens.Match;
+using osu.Game.Users;
+
+namespace osu.Game.Tests.Visual
+{
+    [TestFixture]
+    public class TestCaseMatchParticipants : OsuTestCase
+    {
+        public TestCaseMatchParticipants()
+        {
+            Participants participants;
+            Add(participants = new Participants
+            {
+                RelativeSizeAxes = Axes.Both,
+            });
+
+            AddStep(@"set max to null", () => participants.Max = null);
+            AddStep(@"set users", () => participants.Users = new[]
+            {
+                new User
+                {
+                    Username = @"Feppla",
+                    Id = 4271601,
+                    Country = new Country { FlagName = @"SE" },
+                    CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg",
+                    IsSupporter = true,
+                },
+                new User
+                {
+                    Username = @"Xilver",
+                    Id = 3099689,
+                    Country = new Country { FlagName = @"IL" },
+                    CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg",
+                    IsSupporter = true,
+                },
+                new User
+                {
+                    Username = @"Wucki",
+                    Id = 5287410,
+                    Country = new Country { FlagName = @"FI" },
+                    CoverUrl = @"https://assets.ppy.sh/user-profile-covers/5287410/5cfeaa9dd41cbce038ecdc9d781396ed4b0108089170bf7f50492ef8eadeb368.jpeg",
+                    IsSupporter = true,
+                },
+            });
+
+            AddStep(@"set max", () => participants.Max = 10);
+            AddStep(@"clear users", () => participants.Users = new User[] { });
+            AddStep(@"set max to null", () => participants.Max = null);
+        }
+    }
+}
diff --git a/osu.Game.Tests/Visual/TestCaseParallaxContainer.cs b/osu.Game.Tests/Visual/TestCaseParallaxContainer.cs
new file mode 100644
index 0000000000..8c12589c6f
--- /dev/null
+++ b/osu.Game.Tests/Visual/TestCaseParallaxContainer.cs
@@ -0,0 +1,26 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using osu.Game.Graphics.Containers;
+using osu.Game.Screens.Backgrounds;
+
+namespace osu.Game.Tests.Visual
+{
+    public class TestCaseParallaxContainer : OsuTestCase
+    {
+        public TestCaseParallaxContainer()
+        {
+            ParallaxContainer parallax;
+
+            Add(parallax = new ParallaxContainer
+            {
+                Child = new BackgroundScreenDefault { Alpha = 0.8f }
+            });
+
+            AddStep("default parallax", () => parallax.ParallaxAmount = ParallaxContainer.DEFAULT_PARALLAX_AMOUNT);
+            AddStep("high parallax", () => parallax.ParallaxAmount = ParallaxContainer.DEFAULT_PARALLAX_AMOUNT * 10);
+            AddStep("no parallax", () => parallax.ParallaxAmount = 0);
+            AddStep("negative parallax", () => parallax.ParallaxAmount = -ParallaxContainer.DEFAULT_PARALLAX_AMOUNT);
+        }
+    }
+}
diff --git a/osu.Game.Tests/Visual/TestCasePlaySongSelect.cs b/osu.Game.Tests/Visual/TestCasePlaySongSelect.cs
index 10121738f1..dab7f7e037 100644
--- a/osu.Game.Tests/Visual/TestCasePlaySongSelect.cs
+++ b/osu.Game.Tests/Visual/TestCasePlaySongSelect.cs
@@ -122,7 +122,6 @@ namespace osu.Game.Tests.Visual
                 Hash = new MemoryStream(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())).ComputeMD5Hash(),
                 Metadata = new BeatmapMetadata
                 {
-                    OnlineBeatmapSetID = 1234 + i,
                     // Create random metadata, then we can check if sorting works based on these
                     Artist = "MONACA " + RNG.Next(0, 9),
                     Title = "Black Song " + RNG.Next(0, 9),
diff --git a/osu.Game.Tests/Visual/TestCasePreviewTrackManager.cs b/osu.Game.Tests/Visual/TestCasePreviewTrackManager.cs
new file mode 100644
index 0000000000..d711d501fe
--- /dev/null
+++ b/osu.Game.Tests/Visual/TestCasePreviewTrackManager.cs
@@ -0,0 +1,127 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Audio.Track;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Audio;
+using osu.Game.Beatmaps;
+
+namespace osu.Game.Tests.Visual
+{
+    public class TestCasePreviewTrackManager : OsuTestCase, IPreviewTrackOwner
+    {
+        private readonly PreviewTrackManager trackManager = new TestPreviewTrackManager();
+
+        protected override IReadOnlyDependencyContainer CreateLocalDependencies(IReadOnlyDependencyContainer parent)
+        {
+            var dependencies = new DependencyContainer(base.CreateLocalDependencies(parent));
+            dependencies.CacheAs(trackManager);
+            dependencies.CacheAs<IPreviewTrackOwner>(this);
+            return dependencies;
+        }
+
+        [BackgroundDependencyLoader]
+        private void load()
+        {
+            AddInternal(trackManager);
+        }
+
+        [Test]
+        public void TestStartStop()
+        {
+            PreviewTrack track = null;
+
+            AddStep("get track", () => track = getOwnedTrack());
+            AddStep("start", () => track.Start());
+            AddAssert("started", () => track.IsRunning);
+            AddStep("stop", () => track.Stop());
+            AddAssert("stopped", () => !track.IsRunning);
+        }
+
+        [Test]
+        public void TestStartMultipleTracks()
+        {
+            PreviewTrack track1 = null;
+            PreviewTrack track2 = null;
+
+            AddStep("get tracks", () =>
+            {
+                track1 = getOwnedTrack();
+                track2 = getOwnedTrack();
+            });
+
+            AddStep("start track 1", () => track1.Start());
+            AddStep("start track 2", () => track2.Start());
+            AddAssert("track 1 stopped", () => !track1.IsRunning);
+            AddAssert("track 2 started", () => track2.IsRunning);
+        }
+
+        [Test]
+        public void TestCancelFromOwner()
+        {
+            PreviewTrack track = null;
+
+            AddStep("get track", () => track = getOwnedTrack());
+            AddStep("start", () => track.Start());
+            AddStep("stop by owner", () => trackManager.StopAnyPlaying(this));
+            AddAssert("stopped", () => !track.IsRunning);
+        }
+
+        [Test]
+        public void TestCancelFromNonOwner()
+        {
+            TestTrackOwner owner = null;
+            PreviewTrack track = null;
+
+            AddStep("get track", () => AddInternal(owner = new TestTrackOwner(track = getTrack())));
+            AddStep("start", () => track.Start());
+            AddStep("attempt stop", () => trackManager.StopAnyPlaying(this));
+            AddAssert("not stopped", () => track.IsRunning);
+            AddStep("stop by true owner", () => trackManager.StopAnyPlaying(owner));
+            AddAssert("stopped", () => !track.IsRunning);
+        }
+
+        private PreviewTrack getTrack() => trackManager.Get(null);
+
+        private PreviewTrack getOwnedTrack()
+        {
+            var track = getTrack();
+
+            AddInternal(track);
+
+            return track;
+        }
+
+        private class TestTrackOwner : CompositeDrawable, IPreviewTrackOwner
+        {
+            public TestTrackOwner(PreviewTrack track)
+            {
+                AddInternal(track);
+            }
+
+            protected override IReadOnlyDependencyContainer CreateLocalDependencies(IReadOnlyDependencyContainer parent)
+            {
+                var dependencies = new DependencyContainer(base.CreateLocalDependencies(parent));
+                dependencies.CacheAs<IPreviewTrackOwner>(this);
+                return dependencies;
+            }
+        }
+
+        private class TestPreviewTrackManager : PreviewTrackManager
+        {
+            protected override TrackManagerPreviewTrack CreatePreviewTrack(BeatmapSetInfo beatmapSetInfo, TrackManager trackManager) => new TestPreviewTrack(beatmapSetInfo, trackManager);
+
+            protected class TestPreviewTrack : TrackManagerPreviewTrack
+            {
+                public TestPreviewTrack(BeatmapSetInfo beatmapSetInfo, TrackManager trackManager)
+                    : base(beatmapSetInfo, trackManager)
+                {
+                }
+
+                protected override Track GetTrack() => new TrackVirtual { Length = 100000 };
+            }
+        }
+    }
+}
diff --git a/osu.Game.Tests/Visual/TestCaseVolumePieces.cs b/osu.Game.Tests/Visual/TestCaseVolumePieces.cs
index 449f48b7d7..3c5b91ccd2 100644
--- a/osu.Game.Tests/Visual/TestCaseVolumePieces.cs
+++ b/osu.Game.Tests/Visual/TestCaseVolumePieces.cs
@@ -5,6 +5,7 @@ using System;
 using System.Collections.Generic;
 using osu.Framework.Graphics;
 using osu.Game.Overlays.Volume;
+using OpenTK;
 using OpenTK.Graphics;
 
 namespace osu.Game.Tests.Visual
@@ -17,13 +18,21 @@ namespace osu.Game.Tests.Visual
         {
             VolumeMeter meter;
             MuteButton mute;
-            Add(meter = new VolumeMeter("MASTER", 125, Color4.Blue));
+            Add(meter = new VolumeMeter("MASTER", 125, Color4.Blue) { Position = new Vector2(10) });
+            AddSliderStep("master volume", 0, 10, 0, i => meter.Bindable.Value = i * 0.1);
+
+            Add(new VolumeMeter("BIG", 250, Color4.Red)
+            {
+                Anchor = Anchor.Centre,
+                Origin = Anchor.Centre,
+                Position = new Vector2(10),
+            });
+
             Add(mute = new MuteButton
             {
                 Margin = new MarginPadding { Top = 200 }
             });
 
-            AddSliderStep("master volume", 0, 10, 0, i => meter.Bindable.Value = i * 0.1);
             AddToggleStep("mute", b => mute.Current.Value = b);
         }
     }
diff --git a/osu.Game.Tests/Visual/TestCaseWaveform.cs b/osu.Game.Tests/Visual/TestCaseWaveform.cs
index 983b98016e..46d46863ad 100644
--- a/osu.Game.Tests/Visual/TestCaseWaveform.cs
+++ b/osu.Game.Tests/Visual/TestCaseWaveform.cs
@@ -10,7 +10,6 @@ using osu.Framework.Graphics.Audio;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
 using osu.Game.Graphics.Sprites;
-using osu.Game.Overlays;
 
 namespace osu.Game.Tests.Visual
 {
@@ -20,22 +19,14 @@ namespace osu.Game.Tests.Visual
         [BackgroundDependencyLoader]
         private void load()
         {
+            Beatmap.Value = new WaveformTestBeatmap();
+
             FillFlowContainer flow;
             Child = flow = new FillFlowContainer
             {
                 RelativeSizeAxes = Axes.Both,
                 Direction = FillDirection.Vertical,
                 Spacing = new Vector2(0, 10),
-                Children = new Drawable[]
-                {
-                    new MusicController
-                    {
-                        Anchor = Anchor.TopCentre,
-                        Origin = Anchor.TopCentre,
-                        Y = 100,
-                        State = Visibility.Visible
-                    },
-                }
             };
 
             for (int i = 1; i <= 16; i *= 2)
@@ -44,10 +35,9 @@ namespace osu.Game.Tests.Visual
                 {
                     RelativeSizeAxes = Axes.Both,
                     Resolution = 1f / i,
+                    Waveform = Beatmap.Value.Waveform,
                 };
 
-                Beatmap.ValueChanged += b => newDisplay.Waveform = b.Waveform;
-
                 flow.Add(new Container
                 {
                     RelativeSizeAxes = Axes.X,
diff --git a/osu.Game.Tests/Visual/TestCaseZoomableScrollContainer.cs b/osu.Game.Tests/Visual/TestCaseZoomableScrollContainer.cs
index 70dd67cdbd..8bd1b79a84 100644
--- a/osu.Game.Tests/Visual/TestCaseZoomableScrollContainer.cs
+++ b/osu.Game.Tests/Visual/TestCaseZoomableScrollContainer.cs
@@ -13,7 +13,6 @@ using osu.Game.Graphics.Cursor;
 using osu.Game.Screens.Edit.Screens.Compose.Timeline;
 using OpenTK;
 using OpenTK.Graphics;
-using OpenTK.Input;
 
 namespace osu.Game.Tests.Visual
 {
@@ -66,7 +65,7 @@ namespace osu.Game.Tests.Visual
         {
             reset();
             AddStep("Set zoom = 10", () => scrollContainer.Zoom = 10);
-            AddAssert("Box at 1/2", () => Precision.AlmostEquals(boxQuad.Centre, scrollQuad.Centre));
+            AddAssert("Box at 1/2", () => Precision.AlmostEquals(boxQuad.Centre, scrollQuad.Centre, 1));
             AddAssert("Box width = 10x", () => Precision.AlmostEquals(boxQuad.Size.X, 10 * scrollQuad.Size.X));
         }
 
@@ -77,16 +76,12 @@ namespace osu.Game.Tests.Visual
 
             // Scroll in at 0.25
             AddStep("Move mouse to 0.25x", () => InputManager.MoveMouseTo(new Vector2(scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X, scrollQuad.Centre.Y)));
-            AddStep("Press ctrl", () => InputManager.PressKey(Key.LControl));
-            AddStep("Scroll by 3", () => InputManager.ScrollBy(new Vector2(3, 0)));
-            AddStep("Release ctrl", () => InputManager.ReleaseKey(Key.LControl));
+            AddStep("Scroll by 3", () => InputManager.ScrollBy(new Vector2(0, 3)));
             AddAssert("Box not at 0", () => !Precision.AlmostEquals(boxQuad.TopLeft, scrollQuad.TopLeft));
             AddAssert("Box 1/4 at 1/4", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.25f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X));
 
             // Scroll out at 0.25
-            AddStep("Press ctrl", () => InputManager.PressKey(Key.LControl));
-            AddStep("Scroll by -3", () => InputManager.ScrollBy(new Vector2(-3, 0)));
-            AddStep("Release ctrl", () => InputManager.ReleaseKey(Key.LControl));
+            AddStep("Scroll by -3", () => InputManager.ScrollBy(new Vector2(0, -3)));
             AddAssert("Box at 0", () => Precision.AlmostEquals(boxQuad.TopLeft, scrollQuad.TopLeft));
             AddAssert("Box 1/4 at 1/4", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.25f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X));
         }
@@ -98,15 +93,11 @@ namespace osu.Game.Tests.Visual
 
             // Scroll in at 0.25
             AddStep("Move mouse to 0.25x", () => InputManager.MoveMouseTo(new Vector2(scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X, scrollQuad.Centre.Y)));
-            AddStep("Press ctrl", () => InputManager.PressKey(Key.LControl));
-            AddStep("Scroll by 1", () => InputManager.ScrollBy(new Vector2(1, 0)));
-            AddStep("Release ctrl", () => InputManager.ReleaseKey(Key.LControl));
+            AddStep("Scroll by 1", () => InputManager.ScrollBy(new Vector2(0, 1)));
 
             // Scroll in at 0.6
             AddStep("Move mouse to 0.75x", () => InputManager.MoveMouseTo(new Vector2(scrollQuad.TopLeft.X + 0.75f * scrollQuad.Size.X, scrollQuad.Centre.Y)));
-            AddStep("Press ctrl", () => InputManager.PressKey(Key.LControl));
-            AddStep("Scroll by 1", () => InputManager.ScrollBy(new Vector2(1, 0)));
-            AddStep("Release ctrl", () => InputManager.ReleaseKey(Key.LControl));
+            AddStep("Scroll by 1", () => InputManager.ScrollBy(new Vector2(0, 1)));
             AddAssert("Box not at 0", () => !Precision.AlmostEquals(boxQuad.TopLeft, scrollQuad.TopLeft));
 
             // Very hard to determine actual position, so approximate
@@ -115,15 +106,11 @@ namespace osu.Game.Tests.Visual
             AddAssert("Box at correct position (3)", () => Precision.DefinitelyBigger(boxQuad.TopLeft.X + 0.6f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.6f * scrollQuad.Size.X));
 
             // Scroll out at 0.6
-            AddStep("Press ctrl", () => InputManager.PressKey(Key.LControl));
-            AddStep("Scroll by -1", () => InputManager.ScrollBy(new Vector2(-1, 0)));
-            AddStep("Release ctrl", () => InputManager.ReleaseKey(Key.LControl));
+            AddStep("Scroll by -1", () => InputManager.ScrollBy(new Vector2(0, -1)));
 
             // Scroll out at 0.25
             AddStep("Move mouse to 0.25x", () => InputManager.MoveMouseTo(new Vector2(scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X, scrollQuad.Centre.Y)));
-            AddStep("Press ctrl", () => InputManager.PressKey(Key.LControl));
-            AddStep("Scroll by -1", () => InputManager.ScrollBy(new Vector2(-1, 0)));
-            AddStep("Release ctrl", () => InputManager.ReleaseKey(Key.LControl));
+            AddStep("Scroll by -1", () => InputManager.ScrollBy(new Vector2(0, -1)));
             AddAssert("Box at 0", () => Precision.AlmostEquals(boxQuad.TopLeft, scrollQuad.TopLeft));
         }
 
diff --git a/osu.Game.Tests/WaveformTestBeatmap.cs b/osu.Game.Tests/WaveformTestBeatmap.cs
new file mode 100644
index 0000000000..17aa7db14d
--- /dev/null
+++ b/osu.Game.Tests/WaveformTestBeatmap.cs
@@ -0,0 +1,55 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using System.IO;
+using System.Linq;
+using osu.Framework.Audio.Track;
+using osu.Framework.Graphics.Textures;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.Formats;
+using osu.Game.IO.Archives;
+using osu.Game.Tests.Beatmaps.IO;
+
+namespace osu.Game.Tests
+{
+    /// <summary>
+    /// A <see cref="WorkingBeatmap"/> that is used for testcases that include waveforms.
+    /// </summary>
+    public class WaveformTestBeatmap : WorkingBeatmap
+    {
+        private readonly ZipArchiveReader reader;
+        private readonly FileStream stream;
+
+        public WaveformTestBeatmap()
+            : base(new BeatmapInfo())
+        {
+            stream = File.OpenRead(ImportBeatmapTest.TEST_OSZ_PATH);
+            reader = new ZipArchiveReader(stream);
+        }
+
+        public override void Dispose()
+        {
+            base.Dispose();
+            stream?.Dispose();
+            reader?.Dispose();
+        }
+
+        protected override IBeatmap GetBeatmap() => createTestBeatmap();
+
+        protected override Texture GetBackground() => null;
+
+        protected override Waveform GetWaveform() => new Waveform(getAudioStream());
+
+        protected override Track GetTrack() => new TrackBass(getAudioStream());
+
+        private Stream getAudioStream() => reader.GetStream(reader.Filenames.First(f => f.EndsWith(".mp3")));
+        private Stream getBeatmapStream() => reader.GetStream(reader.Filenames.First(f => f.EndsWith(".osu")));
+
+        private Beatmap createTestBeatmap()
+        {
+            using (var beatmapStream = getBeatmapStream())
+            using (var beatmapReader = new StreamReader(beatmapStream))
+                return Decoder.GetDecoder<Beatmap>(beatmapReader).Decode(beatmapReader);
+        }
+    }
+}
diff --git a/osu.Game/Audio/IPreviewTrackOwner.cs b/osu.Game/Audio/IPreviewTrackOwner.cs
new file mode 100644
index 0000000000..f166096601
--- /dev/null
+++ b/osu.Game/Audio/IPreviewTrackOwner.cs
@@ -0,0 +1,16 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+namespace osu.Game.Audio
+{
+    /// <summary>
+    /// Interface for objects that can own <see cref="IPreviewTrack"/>s.
+    /// </summary>
+    /// <remarks>
+    /// <see cref="IPreviewTrackOwner"/>s can cancel the currently playing <see cref="PreviewTrack"/> through the
+    /// global <see cref="PreviewTrackManager"/> if they're the owner of the playing <see cref="PreviewTrack"/>.
+    /// </remarks>
+    public interface IPreviewTrackOwner
+    {
+    }
+}
diff --git a/osu.Game/Audio/PreviewTrack.cs b/osu.Game/Audio/PreviewTrack.cs
new file mode 100644
index 0000000000..3c9122b941
--- /dev/null
+++ b/osu.Game/Audio/PreviewTrack.cs
@@ -0,0 +1,103 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using System;
+using osu.Framework.Allocation;
+using osu.Framework.Audio.Track;
+using osu.Framework.Graphics;
+using osu.Framework.Threading;
+
+namespace osu.Game.Audio
+{
+    public abstract class PreviewTrack : Component
+    {
+        /// <summary>
+        /// Invoked when this <see cref="PreviewTrack"/> has stopped playing.
+        /// </summary>
+        public event Action Stopped;
+
+        /// <summary>
+        /// Invoked when this <see cref="PreviewTrack"/> has started playing.
+        /// </summary>
+        public event Action Started;
+
+        private Track track;
+        private bool hasStarted;
+
+        [BackgroundDependencyLoader]
+        private void load()
+        {
+            track = GetTrack();
+        }
+
+        /// <summary>
+        /// Length of the track.
+        /// </summary>
+        public double Length => track?.Length ?? 0;
+
+        /// <summary>
+        /// The current track time.
+        /// </summary>
+        public double CurrentTime => track?.CurrentTime ?? 0;
+
+        /// <summary>
+        /// Whether the track is loaded.
+        /// </summary>
+        public bool TrackLoaded => track?.IsLoaded ?? false;
+
+        /// <summary>
+        /// Whether the track is playing.
+        /// </summary>
+        public bool IsRunning => track?.IsRunning ?? false;
+
+        protected override void Update()
+        {
+            base.Update();
+
+            // Todo: Track currently doesn't signal its completion, so we have to handle it manually
+            if (hasStarted && track.HasCompleted)
+                Stop();
+        }
+
+        private ScheduledDelegate startDelegate;
+
+        /// <summary>
+        /// Starts playing this <see cref="PreviewTrack"/>.
+        /// </summary>
+        public void Start() => startDelegate = Schedule(() =>
+        {
+            if (track == null)
+                return;
+
+            if (hasStarted)
+                return;
+            hasStarted = true;
+
+            track.Restart();
+            Started?.Invoke();
+        });
+
+        /// <summary>
+        /// Stops playing this <see cref="PreviewTrack"/>.
+        /// </summary>
+        public void Stop()
+        {
+            startDelegate?.Cancel();
+
+            if (track == null)
+                return;
+
+            if (!hasStarted)
+                return;
+            hasStarted = false;
+
+            track.Stop();
+            Stopped?.Invoke();
+        }
+
+        /// <summary>
+        /// Retrieves the audio track.
+        /// </summary>
+        protected abstract Track GetTrack();
+    }
+}
diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs
new file mode 100644
index 0000000000..07fbe86ff4
--- /dev/null
+++ b/osu.Game/Audio/PreviewTrackManager.cs
@@ -0,0 +1,107 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Audio.Track;
+using osu.Framework.Configuration;
+using osu.Framework.Graphics;
+using osu.Framework.IO.Stores;
+using osu.Game.Beatmaps;
+
+namespace osu.Game.Audio
+{
+    /// <summary>
+    /// A central store for the retrieval of <see cref="PreviewTrack"/>s.
+    /// </summary>
+    public class PreviewTrackManager : Component
+    {
+        private readonly BindableDouble muteBindable = new BindableDouble();
+
+        private AudioManager audio;
+        private TrackManager trackManager;
+
+        private TrackManagerPreviewTrack current;
+
+        [BackgroundDependencyLoader]
+        private void load(AudioManager audio, FrameworkConfigManager config)
+        {
+            trackManager = new TrackManager(new OnlineStore());
+
+            this.audio = audio;
+            audio.AddItem(trackManager);
+
+            config.BindWith(FrameworkSetting.VolumeMusic, trackManager.Volume);
+        }
+
+        /// <summary>
+        /// Retrieves a <see cref="PreviewTrack"/> for a <see cref="BeatmapSetInfo"/>.
+        /// </summary>
+        /// <param name="beatmapSetInfo">The <see cref="BeatmapSetInfo"/> to retrieve the preview track for.</param>
+        /// <returns>The playable <see cref="PreviewTrack"/>.</returns>
+        public PreviewTrack Get(BeatmapSetInfo beatmapSetInfo)
+        {
+            var track = CreatePreviewTrack(beatmapSetInfo, trackManager);
+
+            track.Started += () =>
+            {
+                current?.Stop();
+                current = track;
+                audio.Track.AddAdjustment(AdjustableProperty.Volume, muteBindable);
+            };
+
+            track.Stopped += () =>
+            {
+                current = null;
+                audio.Track.RemoveAdjustment(AdjustableProperty.Volume, muteBindable);
+            };
+
+            return track;
+        }
+
+        /// <summary>
+        /// Stops any currently playing <see cref="PreviewTrack"/>.
+        /// </summary>
+        /// <remarks>
+        /// Only the immediate owner (an object that implements <see cref="IPreviewTrackOwner"/>) of the playing <see cref="PreviewTrack"/>
+        /// can globally stop the currently playing <see cref="PreviewTrack"/>. The object holding a reference to the <see cref="PreviewTrack"/>
+        /// can always stop the <see cref="PreviewTrack"/> themselves through <see cref="PreviewTrack.Stop()"/>.
+        /// </remarks>
+        /// <param name="source">The <see cref="IPreviewTrackOwner"/> which may be the owner of the <see cref="PreviewTrack"/>.</param>
+        public void StopAnyPlaying(IPreviewTrackOwner source)
+        {
+            if (current == null || current.Owner != source)
+                return;
+
+            current.Stop();
+            current = null;
+        }
+
+        /// <summary>
+        /// Creates the <see cref="TrackManagerPreviewTrack"/>.
+        /// </summary>
+        protected virtual TrackManagerPreviewTrack CreatePreviewTrack(BeatmapSetInfo beatmapSetInfo, TrackManager trackManager) => new TrackManagerPreviewTrack(beatmapSetInfo, trackManager);
+
+        protected class TrackManagerPreviewTrack : PreviewTrack
+        {
+            public IPreviewTrackOwner Owner { get; private set; }
+
+            private readonly BeatmapSetInfo beatmapSetInfo;
+            private readonly TrackManager trackManager;
+
+            public TrackManagerPreviewTrack(BeatmapSetInfo beatmapSetInfo, TrackManager trackManager)
+            {
+                this.beatmapSetInfo = beatmapSetInfo;
+                this.trackManager = trackManager;
+            }
+
+            [BackgroundDependencyLoader]
+            private void load(IPreviewTrackOwner owner)
+            {
+                Owner = owner;
+            }
+
+            protected override Track GetTrack() => trackManager.Get($"https://b.ppy.sh/preview/{beatmapSetInfo?.OnlineBeatmapSetID}.mp3");
+        }
+    }
+}
diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs
index 40d62103a8..3afc3c4d32 100644
--- a/osu.Game/Beatmaps/BeatmapInfo.cs
+++ b/osu.Game/Beatmaps/BeatmapInfo.cs
@@ -23,7 +23,6 @@ namespace osu.Game.Beatmaps
         public int BeatmapVersion;
 
         private int? onlineBeatmapID;
-        private int? onlineBeatmapSetID;
 
         [JsonProperty("id")]
         public int? OnlineBeatmapID
@@ -32,19 +31,10 @@ namespace osu.Game.Beatmaps
             set { onlineBeatmapID = value > 0 ? value : null; }
         }
 
-        [JsonProperty("beatmapset_id")]
-        [NotMapped]
-        public int? OnlineBeatmapSetID
-        {
-            get { return onlineBeatmapSetID; }
-            set { onlineBeatmapSetID = value > 0 ? value : null; }
-        }
-
         [JsonIgnore]
         public int BeatmapSetInfoID { get; set; }
 
         [Required]
-        [JsonIgnore]
         public BeatmapSetInfo BeatmapSet { get; set; }
 
         public BeatmapMetadata Metadata { get; set; }
@@ -141,8 +131,8 @@ namespace osu.Game.Beatmaps
                                                       (Metadata ?? BeatmapSet.Metadata).AudioFile == (other.Metadata ?? other.BeatmapSet.Metadata).AudioFile;
 
         public bool BackgroundEquals(BeatmapInfo other) => other != null && BeatmapSet != null && other.BeatmapSet != null &&
-                                                      BeatmapSet.Hash == other.BeatmapSet.Hash &&
-                                                      (Metadata ?? BeatmapSet.Metadata).BackgroundFile == (other.Metadata ?? other.BeatmapSet.Metadata).BackgroundFile;
+                                                           BeatmapSet.Hash == other.BeatmapSet.Hash &&
+                                                           (Metadata ?? BeatmapSet.Metadata).BackgroundFile == (other.Metadata ?? other.BeatmapSet.Metadata).BackgroundFile;
 
         /// <summary>
         /// Returns a shallow-clone of this <see cref="BeatmapInfo"/>.
diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index 806bcc4132..50428cc5e6 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -81,12 +81,31 @@ namespace osu.Game.Beatmaps
 
         protected override void Populate(BeatmapSetInfo model, ArchiveReader archive)
         {
-            model.Beatmaps = createBeatmapDifficulties(model, archive);
+            model.Beatmaps = createBeatmapDifficulties(archive);
 
-            // remove metadata from difficulties where it matches the set
             foreach (BeatmapInfo b in model.Beatmaps)
+            {
+                // remove metadata from difficulties where it matches the set
                 if (model.Metadata.Equals(b.Metadata))
                     b.Metadata = null;
+
+                // by setting the model here, we can update the noline set id below.
+                b.BeatmapSet = model;
+
+                fetchAndPopulateOnlineIDs(b);
+            }
+
+            // check if a set already exists with the same online id, delete if it does.
+            if (model.OnlineBeatmapSetID != null)
+            {
+                var existingOnlineId = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID);
+                if (existingOnlineId != null)
+                {
+                    Delete(existingOnlineId);
+                    beatmaps.PurgeDeletable(s => s.ID == existingOnlineId.ID);
+                    Logger.Log($"Found existing beatmap set with same OnlineBeatmapSetID ({model.OnlineBeatmapSetID}). It has been purged.", LoggingTarget.Database);
+                }
+            }
         }
 
         protected override BeatmapSetInfo CheckForExisting(BeatmapSetInfo model)
@@ -99,18 +118,6 @@ namespace osu.Game.Beatmaps
                 return existingHashMatch;
             }
 
-            // check if a set already exists with the same online id
-            if (model.OnlineBeatmapSetID != null)
-            {
-                var existingOnlineId = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID);
-                if (existingOnlineId != null)
-                {
-                    Delete(existingOnlineId);
-                    beatmaps.PurgeDeletable(s => s.ID == existingOnlineId.ID);
-                    Logger.Log($"Found existing beatmap set with same OnlineBeatmapSetID ({model.OnlineBeatmapSetID}). It has been purged.", LoggingTarget.Database);
-                }
-            }
-
             return null;
         }
 
@@ -306,29 +313,29 @@ namespace osu.Game.Beatmaps
             return hashable.ComputeSHA2Hash();
         }
 
-       protected override BeatmapSetInfo CreateModel(ArchiveReader reader)
+        protected override BeatmapSetInfo CreateModel(ArchiveReader reader)
         {
             // let's make sure there are actually .osu files to import.
             string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu"));
             if (string.IsNullOrEmpty(mapName)) throw new InvalidOperationException("No beatmap files found in this beatmap archive.");
 
-            BeatmapMetadata metadata;
+            Beatmap beatmap;
             using (var stream = new StreamReader(reader.GetStream(mapName)))
-                metadata = Decoder.GetDecoder<Beatmap>(stream).Decode(stream).Metadata;
+                beatmap = Decoder.GetDecoder<Beatmap>(stream).Decode(stream);
 
             return new BeatmapSetInfo
             {
-                OnlineBeatmapSetID = metadata.OnlineBeatmapSetID,
+                OnlineBeatmapSetID = beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID,
                 Beatmaps = new List<BeatmapInfo>(),
                 Hash = computeBeatmapSetHash(reader),
-                Metadata = metadata
+                Metadata = beatmap.Metadata
             };
         }
 
         /// <summary>
         /// Create all required <see cref="BeatmapInfo"/>s for the provided archive.
         /// </summary>
-        private List<BeatmapInfo> createBeatmapDifficulties(BeatmapSetInfo model, ArchiveReader reader)
+        private List<BeatmapInfo> createBeatmapDifficulties(ArchiveReader reader)
         {
             var beatmapInfos = new List<BeatmapInfo>();
 
@@ -348,10 +355,6 @@ namespace osu.Game.Beatmaps
                     beatmap.BeatmapInfo.Hash = ms.ComputeSHA2Hash();
                     beatmap.BeatmapInfo.MD5Hash = ms.ComputeMD5Hash();
 
-                    // ensure we have the same online set ID as the set itself.
-                    beatmap.BeatmapInfo.OnlineBeatmapSetID = model.OnlineBeatmapSetID;
-                    beatmap.BeatmapInfo.Metadata.OnlineBeatmapSetID = model.OnlineBeatmapSetID;
-
                     // check that no existing beatmap exists that is imported with the same online beatmap ID. if so, give it precedence.
                     if (beatmap.BeatmapInfo.OnlineBeatmapID.HasValue && QueryBeatmap(b => b.OnlineBeatmapID.Value == beatmap.BeatmapInfo.OnlineBeatmapID.Value) != null)
                         beatmap.BeatmapInfo.OnlineBeatmapID = null;
@@ -363,8 +366,7 @@ namespace osu.Game.Beatmaps
                     if (ruleset != null)
                     {
                         // TODO: this should be done in a better place once we actually need to dynamically update it.
-                        var converted = new DummyConversionBeatmap(beatmap).GetPlayableBeatmap(ruleset);
-                        beatmap.BeatmapInfo.StarDifficulty = ruleset.CreateInstance().CreateDifficultyCalculator(converted).Calculate();
+                        beatmap.BeatmapInfo.StarDifficulty = ruleset.CreateInstance().CreateDifficultyCalculator(new DummyConversionBeatmap(beatmap)).Calculate().StarRating;
                     }
                     else
                         beatmap.BeatmapInfo.StarDifficulty = 0;
@@ -376,6 +378,43 @@ namespace osu.Game.Beatmaps
             return beatmapInfos;
         }
 
+        /// <summary>
+        /// Query the API to populate mising OnlineBeatmapID / OnlineBeatmapSetID properties.
+        /// </summary>
+        /// <param name="beatmap">The beatmap to populate.</param>
+        /// <param name="force">Whether to re-query if the provided beatmap already has populated values.</param>
+        /// <returns>True if population was successful.</returns>
+        private bool fetchAndPopulateOnlineIDs(BeatmapInfo beatmap, bool force = false)
+        {
+            if (!force && beatmap.OnlineBeatmapID != null && beatmap.BeatmapSet.OnlineBeatmapSetID != null)
+                return true;
+
+            if (api.State != APIState.Online)
+                return false;
+
+            Logger.Log("Attempting online lookup for IDs...", LoggingTarget.Database);
+
+            try
+            {
+                var req = new GetBeatmapRequest(beatmap);
+
+                req.Perform(api);
+
+                var res = req.Result;
+
+                Logger.Log($"Successfully mapped to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}.", LoggingTarget.Database);
+
+                beatmap.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID;
+                beatmap.OnlineBeatmapID = res.OnlineBeatmapID;
+                return true;
+            }
+            catch (Exception e)
+            {
+                Logger.Log($"Failed ({e})", LoggingTarget.Database);
+                return false;
+            }
+        }
+
         /// <summary>
         /// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation.
         /// </summary>
diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs
index 34147c18d2..6c1bcd0531 100644
--- a/osu.Game/Beatmaps/BeatmapMetadata.cs
+++ b/osu.Game/Beatmaps/BeatmapMetadata.cs
@@ -17,16 +17,6 @@ namespace osu.Game.Beatmaps
         [JsonIgnore]
         public int ID { get; set; }
 
-        private int? onlineBeatmapSetID;
-
-        [NotMapped]
-        [JsonProperty(@"id")]
-        public int? OnlineBeatmapSetID
-        {
-            get { return onlineBeatmapSetID; }
-            set { onlineBeatmapSetID = value > 0 ? value : null; }
-        }
-
         public string Title { get; set; }
         public string TitleUnicode { get; set; }
         public string Artist { get; set; }
@@ -82,8 +72,7 @@ namespace osu.Game.Beatmaps
             if (other == null)
                 return false;
 
-            return onlineBeatmapSetID == other.onlineBeatmapSetID
-                && Title == other.Title
+            return Title == other.Title
                 && TitleUnicode == other.TitleUnicode
                 && Artist == other.Artist
                 && ArtistUnicode == other.ArtistUnicode
diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs
index fa08c6cb68..ed8fbdbb26 100644
--- a/osu.Game/Beatmaps/BeatmapSetInfo.cs
+++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs
@@ -22,18 +22,18 @@ namespace osu.Game.Beatmaps
         [NotMapped]
         public BeatmapSetOnlineInfo OnlineInfo { get; set; }
 
-        public double MaxStarDifficulty => Beatmaps.Max(b => b.StarDifficulty);
+        public double MaxStarDifficulty => Beatmaps?.Max(b => b.StarDifficulty) ?? 0;
 
         [NotMapped]
         public bool DeletePending { get; set; }
 
         public string Hash { get; set; }
 
-        public string StoryboardFile => Files.FirstOrDefault(f => f.Filename.EndsWith(".osb"))?.Filename;
+        public string StoryboardFile => Files?.FirstOrDefault(f => f.Filename.EndsWith(".osb"))?.Filename;
 
         public List<BeatmapSetFileInfo> Files { get; set; }
 
-        public override string ToString() => Metadata.ToString();
+        public override string ToString() => Metadata?.ToString() ?? base.ToString();
 
         public bool Protected { get; set; }
     }
diff --git a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs
index ee1fc6aec3..265c6832b2 100644
--- a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs
+++ b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs
@@ -62,7 +62,7 @@ namespace osu.Game.Beatmaps
 
                 public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new DummyBeatmapConverter { Beatmap = beatmap };
 
-                public override DifficultyCalculator CreateDifficultyCalculator(IBeatmap beatmap, Mod[] mods = null) => null;
+                public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => null;
 
                 public override string Description => "dummy";
 
diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs
index a83ac26fb3..581207607a 100644
--- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs
+++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs
@@ -34,7 +34,8 @@ namespace osu.Game.Beatmaps.Formats
 
         private readonly int offset;
 
-        public LegacyBeatmapDecoder(int version = LATEST_VERSION) : base(version)
+        public LegacyBeatmapDecoder(int version = LATEST_VERSION)
+            : base(version)
         {
             // BeatmapVersion 4 and lower had an incorrect offset (stable has this set as 24ms off)
             offset = FormatVersion < 5 ? 24 : 0;
@@ -135,6 +136,7 @@ namespace osu.Game.Beatmaps.Formats
                             parser = new Rulesets.Objects.Legacy.Mania.ConvertHitObjectParser();
                             break;
                     }
+
                     break;
                 case @"LetterboxInBreaks":
                     beatmap.BeatmapInfo.LetterboxInBreaks = int.Parse(pair.Value) == 1;
@@ -207,8 +209,7 @@ namespace osu.Game.Beatmaps.Formats
                     beatmap.BeatmapInfo.OnlineBeatmapID = int.Parse(pair.Value);
                     break;
                 case @"BeatmapSetID":
-                    beatmap.BeatmapInfo.OnlineBeatmapSetID = int.Parse(pair.Value);
-                    metadata.OnlineBeatmapSetID = int.Parse(pair.Value);
+                    beatmap.BeatmapInfo.BeatmapSet = new BeatmapSetInfo { OnlineBeatmapSetID = int.Parse(pair.Value) };
                     break;
             }
         }
diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs
index 66a6206c16..4310d9b7df 100644
--- a/osu.Game/Beatmaps/WorkingBeatmap.cs
+++ b/osu.Game/Beatmaps/WorkingBeatmap.cs
@@ -13,7 +13,6 @@ using osu.Game.Storyboards;
 using osu.Framework.IO.File;
 using System.IO;
 using osu.Game.IO.Serialization;
-using System.Diagnostics;
 using osu.Game.Rulesets;
 using osu.Game.Rulesets.UI;
 using osu.Game.Skinning;
@@ -49,12 +48,13 @@ namespace osu.Game.Beatmaps
         /// <summary>
         /// Saves the <see cref="Beatmaps.Beatmap"/>.
         /// </summary>
-        public void Save()
+        /// <returns>The absolute path of the output file.</returns>
+        public string Save()
         {
             var path = FileSafety.GetTempPath(Guid.NewGuid().ToString().Replace("-", string.Empty) + ".json");
             using (var sw = new StreamWriter(path))
                 sw.WriteLine(Beatmap.Serialize());
-            Process.Start(path);
+            return path;
         }
 
         protected abstract IBeatmap GetBeatmap();
diff --git a/osu.Game/Configuration/DatabasedConfigManager.cs b/osu.Game/Configuration/DatabasedConfigManager.cs
index 0ef0589dff..0ede6de0f2 100644
--- a/osu.Game/Configuration/DatabasedConfigManager.cs
+++ b/osu.Game/Configuration/DatabasedConfigManager.cs
@@ -13,13 +13,13 @@ namespace osu.Game.Configuration
     {
         private readonly SettingsStore settings;
 
-        private readonly int variant;
+        private readonly int? variant;
 
         private readonly List<DatabasedSetting> databasedSettings;
 
         private readonly RulesetInfo ruleset;
 
-        protected DatabasedConfigManager(SettingsStore settings, RulesetInfo ruleset = null, int variant = 0)
+        protected DatabasedConfigManager(SettingsStore settings, RulesetInfo ruleset = null, int? variant = null)
         {
             this.settings = settings;
             this.ruleset = ruleset;
diff --git a/osu.Game/Database/OsuDbContext.cs b/osu.Game/Database/OsuDbContext.cs
index 4b0de57c4c..bf57644caf 100644
--- a/osu.Game/Database/OsuDbContext.cs
+++ b/osu.Game/Database/OsuDbContext.cs
@@ -181,24 +181,6 @@ namespace osu.Game.Database
             }
         }
 
-        public void Migrate()
-        {
-            try
-            {
-                Database.Migrate();
-            }
-            catch (Exception e)
-            {
-                throw new MigrationFailedException(e);
-            }
-        }
-    }
-
-    public class MigrationFailedException : Exception
-    {
-        public MigrationFailedException(Exception exception)
-            : base("sqlite-net migration failed", exception)
-        {
-        }
+        public void Migrate() => Database.Migrate();
     }
 }
diff --git a/osu.Game/Graphics/Containers/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs
index 157c814f55..9c5da71aff 100644
--- a/osu.Game/Graphics/Containers/LinkFlowContainer.cs
+++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs
@@ -3,11 +3,11 @@
 
 using osu.Game.Online.Chat;
 using System;
-using System.Diagnostics;
 using System.Linq;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics.Sprites;
 using System.Collections.Generic;
+using osu.Framework.Platform;
 using osu.Game.Overlays;
 using osu.Game.Overlays.Notifications;
 
@@ -25,12 +25,14 @@ namespace osu.Game.Graphics.Containers
         private OsuGame game;
 
         private Action showNotImplementedError;
+        private GameHost host;
 
         [BackgroundDependencyLoader(true)]
-        private void load(OsuGame game, NotificationOverlay notifications)
+        private void load(OsuGame game, NotificationOverlay notifications, GameHost host)
         {
             // will be null in tests
             this.game = game;
+            this.host = host;
 
             showNotImplementedError = () => notifications?.Post(new SimpleNotification
             {
@@ -88,7 +90,7 @@ namespace osu.Game.Graphics.Containers
                             showNotImplementedError?.Invoke();
                             break;
                         case LinkAction.External:
-                            Process.Start(url);
+                            host.OpenUrlExternally(url);
                             break;
                         case LinkAction.OpenUserProfile:
                             if (long.TryParse(linkArgument, out long userId))
diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs
index 0186a170c9..d6b6595b69 100644
--- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs
+++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs
@@ -8,20 +8,32 @@ using osu.Framework.Graphics.Containers;
 using osu.Framework.Input;
 using OpenTK;
 using osu.Framework.Configuration;
+using osu.Game.Audio;
 using osu.Game.Overlays;
 
 namespace osu.Game.Graphics.Containers
 {
-    public class OsuFocusedOverlayContainer : FocusedOverlayContainer
+    public class OsuFocusedOverlayContainer : FocusedOverlayContainer, IPreviewTrackOwner
     {
         private SampleChannel samplePopIn;
         private SampleChannel samplePopOut;
 
+        private PreviewTrackManager previewTrackManager;
+
         protected readonly Bindable<OverlayActivation> OverlayActivationMode = new Bindable<OverlayActivation>(OverlayActivation.All);
 
-        [BackgroundDependencyLoader(true)]
-        private void load(OsuGame osuGame, AudioManager audio)
+        protected override IReadOnlyDependencyContainer CreateLocalDependencies(IReadOnlyDependencyContainer parent)
         {
+            var dependencies = new DependencyContainer(base.CreateLocalDependencies(parent));
+            dependencies.CacheAs<IPreviewTrackOwner>(this);
+            return dependencies;
+        }
+
+        [BackgroundDependencyLoader(true)]
+        private void load(OsuGame osuGame, AudioManager audio, PreviewTrackManager previewTrackManager)
+        {
+            this.previewTrackManager = previewTrackManager;
+
             if (osuGame != null)
                 OverlayActivationMode.BindTo(osuGame.OverlayActivationMode);
 
@@ -33,7 +45,7 @@ namespace osu.Game.Graphics.Containers
 
         /// <summary>
         /// Whether mouse input should be blocked screen-wide while this overlay is visible.
-        /// Performing mouse actions outside of the valid extents will hide the overlay but pass the events through.
+        /// Performing mouse actions outside of the valid extents will hide the overlay.
         /// </summary>
         public virtual bool BlockScreenWideMouse => BlockPassThroughMouse;
 
@@ -66,5 +78,11 @@ namespace osu.Game.Graphics.Containers
                     break;
             }
         }
+
+        protected override void PopOut()
+        {
+            base.PopOut();
+            previewTrackManager.StopAnyPlaying(this);
+        }
     }
 }
diff --git a/osu.Game/Graphics/Containers/ParallaxContainer.cs b/osu.Game/Graphics/Containers/ParallaxContainer.cs
index dc635ce7e7..8e1e5d54fa 100644
--- a/osu.Game/Graphics/Containers/ParallaxContainer.cs
+++ b/osu.Game/Graphics/Containers/ParallaxContainer.cs
@@ -16,6 +16,9 @@ namespace osu.Game.Graphics.Containers
     {
         public const float DEFAULT_PARALLAX_AMOUNT = 0.02f;
 
+        /// <summary>
+        /// The amount of parallax movement. Negative values will reverse the direction of parallax relative to user input.
+        /// </summary>
         public float ParallaxAmount = DEFAULT_PARALLAX_AMOUNT;
 
         private Bindable<bool> parallaxEnabled;
@@ -45,7 +48,7 @@ namespace osu.Game.Graphics.Containers
                 if (!parallaxEnabled)
                 {
                     content.MoveTo(Vector2.Zero, firstUpdate ? 0 : 1000, Easing.OutQuint);
-                    content.Scale = new Vector2(1 + ParallaxAmount);
+                    content.Scale = new Vector2(1 + System.Math.Abs(ParallaxAmount));
                 }
             };
         }
@@ -69,7 +72,7 @@ namespace osu.Game.Graphics.Containers
                 double elapsed = MathHelper.Clamp(Clock.ElapsedFrameTime, 0, 1000);
 
                 content.Position = Interpolation.ValueAt(elapsed, content.Position, offset, 0, 1000, Easing.OutQuint);
-                content.Scale = Interpolation.ValueAt(elapsed, content.Scale, new Vector2(1 + ParallaxAmount), 0, 1000, Easing.OutQuint);
+                content.Scale = Interpolation.ValueAt(elapsed, content.Scale, new Vector2(1 + System.Math.Abs(ParallaxAmount)), 0, 1000, Easing.OutQuint);
             }
 
             firstUpdate = false;
diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs
index 77079894cc..be2412ccad 100644
--- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs
+++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs
@@ -1,12 +1,12 @@
 // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
-using System.Diagnostics;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Cursor;
 using osu.Framework.Input;
+using osu.Framework.Platform;
 using OpenTK;
 using OpenTK.Graphics;
 
@@ -17,6 +17,7 @@ namespace osu.Game.Graphics.UserInterface
         public string Link { get; set; }
 
         private Color4 hoverColour;
+        private GameHost host;
 
         public ExternalLinkButton(string link = null)
         {
@@ -30,9 +31,10 @@ namespace osu.Game.Graphics.UserInterface
         }
 
         [BackgroundDependencyLoader]
-        private void load(OsuColour colours)
+        private void load(OsuColour colours, GameHost host)
         {
             hoverColour = colours.Yellow;
+            this.host = host;
         }
 
         protected override bool OnHover(InputState state)
@@ -50,11 +52,7 @@ namespace osu.Game.Graphics.UserInterface
         protected override bool OnClick(InputState state)
         {
             if(Link != null)
-                Process.Start(new ProcessStartInfo
-                {
-                    FileName = Link,
-                    UseShellExecute = true //see https://github.com/dotnet/corefx/issues/10361
-                });
+                host.OpenUrlExternally(Link);
             return true;
         }
 
diff --git a/osu.Game/Input/Handlers/ReplayInputHandler.cs b/osu.Game/Input/Handlers/ReplayInputHandler.cs
index 5454dd0c9f..57a2e5df6d 100644
--- a/osu.Game/Input/Handlers/ReplayInputHandler.cs
+++ b/osu.Game/Input/Handlers/ReplayInputHandler.cs
@@ -32,16 +32,14 @@ namespace osu.Game.Input.Handlers
 
         public override int Priority => 0;
 
-        public class ReplayState<T> : InputState
+        public class ReplayState<T> : IInput
             where T : struct
         {
             public List<T> PressedActions;
 
-            public override InputState Clone()
+            public void Apply(InputState state, IInputStateChangeHandler handler)
             {
-                var clone = (ReplayState<T>)base.Clone();
-                clone.PressedActions = new List<T>(PressedActions);
-                return clone;
+                handler.HandleCustomInput(state, this);
             }
         }
     }
diff --git a/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.Designer.cs b/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.Designer.cs
new file mode 100644
index 0000000000..aaa11e88b6
--- /dev/null
+++ b/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.Designer.cs
@@ -0,0 +1,376 @@
+// <auto-generated />
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using osu.Game.Database;
+
+namespace osu.Game.Migrations
+{
+    [DbContext(typeof(OsuDbContext))]
+    [Migration("20180621044111_UpdateTaikoDefaultBindings")]
+    partial class UpdateTaikoDefaultBindings
+    {
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder
+                .HasAnnotation("ProductVersion", "2.1.1-rtm-30846");
+
+            modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b =>
+                {
+                    b.Property<int>("ID")
+                        .ValueGeneratedOnAdd();
+
+                    b.Property<float>("ApproachRate");
+
+                    b.Property<float>("CircleSize");
+
+                    b.Property<float>("DrainRate");
+
+                    b.Property<float>("OverallDifficulty");
+
+                    b.Property<double>("SliderMultiplier");
+
+                    b.Property<double>("SliderTickRate");
+
+                    b.HasKey("ID");
+
+                    b.ToTable("BeatmapDifficulty");
+                });
+
+            modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b =>
+                {
+                    b.Property<int>("ID")
+                        .ValueGeneratedOnAdd();
+
+                    b.Property<int>("AudioLeadIn");
+
+                    b.Property<int>("BaseDifficultyID");
+
+                    b.Property<int>("BeatDivisor");
+
+                    b.Property<int>("BeatmapSetInfoID");
+
+                    b.Property<bool>("Countdown");
+
+                    b.Property<double>("DistanceSpacing");
+
+                    b.Property<int>("GridSize");
+
+                    b.Property<string>("Hash");
+
+                    b.Property<bool>("Hidden");
+
+                    b.Property<bool>("LetterboxInBreaks");
+
+                    b.Property<string>("MD5Hash");
+
+                    b.Property<int?>("MetadataID");
+
+                    b.Property<int?>("OnlineBeatmapID");
+
+                    b.Property<string>("Path");
+
+                    b.Property<int>("RulesetID");
+
+                    b.Property<bool>("SpecialStyle");
+
+                    b.Property<float>("StackLeniency");
+
+                    b.Property<double>("StarDifficulty");
+
+                    b.Property<string>("StoredBookmarks");
+
+                    b.Property<double>("TimelineZoom");
+
+                    b.Property<string>("Version");
+
+                    b.Property<bool>("WidescreenStoryboard");
+
+                    b.HasKey("ID");
+
+                    b.HasIndex("BaseDifficultyID");
+
+                    b.HasIndex("BeatmapSetInfoID");
+
+                    b.HasIndex("Hash");
+
+                    b.HasIndex("MD5Hash");
+
+                    b.HasIndex("MetadataID");
+
+                    b.HasIndex("OnlineBeatmapID")
+                        .IsUnique();
+
+                    b.HasIndex("RulesetID");
+
+                    b.ToTable("BeatmapInfo");
+                });
+
+            modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b =>
+                {
+                    b.Property<int>("ID")
+                        .ValueGeneratedOnAdd();
+
+                    b.Property<string>("Artist");
+
+                    b.Property<string>("ArtistUnicode");
+
+                    b.Property<string>("AudioFile");
+
+                    b.Property<string>("AuthorString")
+                        .HasColumnName("Author");
+
+                    b.Property<string>("BackgroundFile");
+
+                    b.Property<int>("PreviewTime");
+
+                    b.Property<string>("Source");
+
+                    b.Property<string>("Tags");
+
+                    b.Property<string>("Title");
+
+                    b.Property<string>("TitleUnicode");
+
+                    b.HasKey("ID");
+
+                    b.ToTable("BeatmapMetadata");
+                });
+
+            modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b =>
+                {
+                    b.Property<int>("ID")
+                        .ValueGeneratedOnAdd();
+
+                    b.Property<int>("BeatmapSetInfoID");
+
+                    b.Property<int>("FileInfoID");
+
+                    b.Property<string>("Filename")
+                        .IsRequired();
+
+                    b.HasKey("ID");
+
+                    b.HasIndex("BeatmapSetInfoID");
+
+                    b.HasIndex("FileInfoID");
+
+                    b.ToTable("BeatmapSetFileInfo");
+                });
+
+            modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b =>
+                {
+                    b.Property<int>("ID")
+                        .ValueGeneratedOnAdd();
+
+                    b.Property<bool>("DeletePending");
+
+                    b.Property<string>("Hash");
+
+                    b.Property<int?>("MetadataID");
+
+                    b.Property<int?>("OnlineBeatmapSetID");
+
+                    b.Property<bool>("Protected");
+
+                    b.HasKey("ID");
+
+                    b.HasIndex("DeletePending");
+
+                    b.HasIndex("Hash")
+                        .IsUnique();
+
+                    b.HasIndex("MetadataID");
+
+                    b.HasIndex("OnlineBeatmapSetID")
+                        .IsUnique();
+
+                    b.ToTable("BeatmapSetInfo");
+                });
+
+            modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b =>
+                {
+                    b.Property<int>("ID")
+                        .ValueGeneratedOnAdd();
+
+                    b.Property<int>("IntKey")
+                        .HasColumnName("Key");
+
+                    b.Property<int?>("RulesetID");
+
+                    b.Property<string>("StringValue")
+                        .HasColumnName("Value");
+
+                    b.Property<int?>("Variant");
+
+                    b.HasKey("ID");
+
+                    b.HasIndex("RulesetID", "Variant");
+
+                    b.ToTable("Settings");
+                });
+
+            modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b =>
+                {
+                    b.Property<int>("ID")
+                        .ValueGeneratedOnAdd();
+
+                    b.Property<int>("IntAction")
+                        .HasColumnName("Action");
+
+                    b.Property<string>("KeysString")
+                        .HasColumnName("Keys");
+
+                    b.Property<int?>("RulesetID");
+
+                    b.Property<int?>("Variant");
+
+                    b.HasKey("ID");
+
+                    b.HasIndex("IntAction");
+
+                    b.HasIndex("RulesetID", "Variant");
+
+                    b.ToTable("KeyBinding");
+                });
+
+            modelBuilder.Entity("osu.Game.IO.FileInfo", b =>
+                {
+                    b.Property<int>("ID")
+                        .ValueGeneratedOnAdd();
+
+                    b.Property<string>("Hash");
+
+                    b.Property<int>("ReferenceCount");
+
+                    b.HasKey("ID");
+
+                    b.HasIndex("Hash")
+                        .IsUnique();
+
+                    b.HasIndex("ReferenceCount");
+
+                    b.ToTable("FileInfo");
+                });
+
+            modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b =>
+                {
+                    b.Property<int?>("ID")
+                        .ValueGeneratedOnAdd();
+
+                    b.Property<bool>("Available");
+
+                    b.Property<string>("InstantiationInfo");
+
+                    b.Property<string>("Name");
+
+                    b.Property<string>("ShortName");
+
+                    b.HasKey("ID");
+
+                    b.HasIndex("Available");
+
+                    b.HasIndex("ShortName")
+                        .IsUnique();
+
+                    b.ToTable("RulesetInfo");
+                });
+
+            modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b =>
+                {
+                    b.Property<int>("ID")
+                        .ValueGeneratedOnAdd();
+
+                    b.Property<int>("FileInfoID");
+
+                    b.Property<string>("Filename")
+                        .IsRequired();
+
+                    b.Property<int>("SkinInfoID");
+
+                    b.HasKey("ID");
+
+                    b.HasIndex("FileInfoID");
+
+                    b.HasIndex("SkinInfoID");
+
+                    b.ToTable("SkinFileInfo");
+                });
+
+            modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b =>
+                {
+                    b.Property<int>("ID")
+                        .ValueGeneratedOnAdd();
+
+                    b.Property<string>("Creator");
+
+                    b.Property<bool>("DeletePending");
+
+                    b.Property<string>("Name");
+
+                    b.HasKey("ID");
+
+                    b.ToTable("SkinInfo");
+                });
+
+            modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b =>
+                {
+                    b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty")
+                        .WithMany()
+                        .HasForeignKey("BaseDifficultyID")
+                        .OnDelete(DeleteBehavior.Cascade);
+
+                    b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet")
+                        .WithMany("Beatmaps")
+                        .HasForeignKey("BeatmapSetInfoID")
+                        .OnDelete(DeleteBehavior.Cascade);
+
+                    b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata")
+                        .WithMany("Beatmaps")
+                        .HasForeignKey("MetadataID");
+
+                    b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset")
+                        .WithMany()
+                        .HasForeignKey("RulesetID")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b =>
+                {
+                    b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo")
+                        .WithMany("Files")
+                        .HasForeignKey("BeatmapSetInfoID")
+                        .OnDelete(DeleteBehavior.Cascade);
+
+                    b.HasOne("osu.Game.IO.FileInfo", "FileInfo")
+                        .WithMany()
+                        .HasForeignKey("FileInfoID")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b =>
+                {
+                    b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata")
+                        .WithMany("BeatmapSets")
+                        .HasForeignKey("MetadataID");
+                });
+
+            modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b =>
+                {
+                    b.HasOne("osu.Game.IO.FileInfo", "FileInfo")
+                        .WithMany()
+                        .HasForeignKey("FileInfoID")
+                        .OnDelete(DeleteBehavior.Cascade);
+
+                    b.HasOne("osu.Game.Skinning.SkinInfo")
+                        .WithMany("Files")
+                        .HasForeignKey("SkinInfoID")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}
diff --git a/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.cs b/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.cs
new file mode 100644
index 0000000000..98ce5def08
--- /dev/null
+++ b/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.cs
@@ -0,0 +1,19 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+using osu.Framework.Logging;
+
+namespace osu.Game.Migrations
+{
+    public partial class UpdateTaikoDefaultBindings : Migration
+    {
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.Sql("DELETE FROM KeyBinding WHERE RulesetID = 1");
+            Logger.Log("osu!taiko bindings have been reset due to new defaults", LoggingTarget.Runtime, LogLevel.Important);
+        }
+
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            // we can't really tell if these should be restored or not, so let's just not do so.
+        }
+    }
+}
diff --git a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs
index d750d50b5b..bd80cb743b 100644
--- a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs
+++ b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs
@@ -1,11 +1,9 @@
 // <auto-generated />
+using System;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.EntityFrameworkCore.Infrastructure;
-using Microsoft.EntityFrameworkCore.Metadata;
-using Microsoft.EntityFrameworkCore.Migrations;
-using Microsoft.EntityFrameworkCore.Storage;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
 using osu.Game.Database;
-using System;
 
 namespace osu.Game.Migrations
 {
@@ -16,7 +14,7 @@ namespace osu.Game.Migrations
         {
 #pragma warning disable 612, 618
             modelBuilder
-                .HasAnnotation("ProductVersion", "2.0.3-rtm-10026");
+                .HasAnnotation("ProductVersion", "2.1.1-rtm-30846");
 
             modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b =>
                 {
diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs
index dfd181b98a..adbedb2aac 100644
--- a/osu.Game/Online/API/APIRequest.cs
+++ b/osu.Game/Online/API/APIRequest.cs
@@ -14,16 +14,19 @@ namespace osu.Game.Online.API
     {
         protected override WebRequest CreateWebRequest() => new JsonWebRequest<T>(Uri);
 
+        public T Result => ((JsonWebRequest<T>)WebRequest).ResponseObject;
+
         protected APIRequest()
         {
             base.Success += onSuccess;
         }
 
-        private void onSuccess()
-        {
-            Success?.Invoke(((JsonWebRequest<T>)WebRequest).ResponseObject);
-        }
+        private void onSuccess() => Success?.Invoke(Result);
 
+        /// <summary>
+        /// Invoked on successful completion of an API request.
+        /// This will be scheduled to the API's internal scheduler (run on update thread automatically).
+        /// </summary>
         public new event APISuccessHandler<T> Success;
     }
 
@@ -52,7 +55,16 @@ namespace osu.Game.Online.API
         protected APIAccess API;
         protected WebRequest WebRequest;
 
+        /// <summary>
+        /// Invoked on successful completion of an API request.
+        /// This will be scheduled to the API's internal scheduler (run on update thread automatically).
+        /// </summary>
         public event APISuccessHandler Success;
+
+        /// <summary>
+        /// Invoked on failure to complete an API request.
+        /// This will be scheduled to the API's internal scheduler (run on update thread automatically).
+        /// </summary>
         public event APIFailureHandler Failure;
 
         private bool cancelled;
diff --git a/osu.Game/Online/API/Requests/GetBeatmapDetailsRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapDetailsRequest.cs
index ab840d054f..e3865be5fb 100644
--- a/osu.Game/Online/API/Requests/GetBeatmapDetailsRequest.cs
+++ b/osu.Game/Online/API/Requests/GetBeatmapDetailsRequest.cs
@@ -10,13 +10,11 @@ namespace osu.Game.Online.API.Requests
     {
         private readonly BeatmapInfo beatmap;
 
-        private string lookupString => beatmap.OnlineBeatmapID > 0 ? beatmap.OnlineBeatmapID.ToString() : $@"lookup?checksum={beatmap.Hash}&filename={System.Uri.EscapeUriString(beatmap.Path)}";
-
         public GetBeatmapDetailsRequest(BeatmapInfo beatmap)
         {
             this.beatmap = beatmap;
         }
 
-        protected override string Target => $@"beatmaps/{lookupString}";
+        protected override string Target => $@"beatmaps/{beatmap.OnlineBeatmapID}";
     }
 }
diff --git a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs
new file mode 100644
index 0000000000..9d254ce29d
--- /dev/null
+++ b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs
@@ -0,0 +1,22 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using osu.Game.Beatmaps;
+using osu.Game.Online.API.Requests.Responses;
+
+namespace osu.Game.Online.API.Requests
+{
+    public class GetBeatmapRequest : APIRequest<APIBeatmap>
+    {
+        private readonly BeatmapInfo beatmap;
+
+        private string lookupString => beatmap.OnlineBeatmapID > 0 ? beatmap.OnlineBeatmapID.ToString() : $@"lookup?checksum={beatmap.MD5Hash}&filename={System.Uri.EscapeUriString(beatmap.Path)}";
+
+        public GetBeatmapRequest(BeatmapInfo beatmap)
+        {
+            this.beatmap = beatmap;
+        }
+
+        protected override string Target => $@"beatmaps/{lookupString}";
+    }
+}
diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs
index f75d320a46..99e4392374 100644
--- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs
+++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs
@@ -10,7 +10,10 @@ namespace osu.Game.Online.API.Requests.Responses
     public class APIBeatmap : BeatmapMetadata
     {
         [JsonProperty(@"id")]
-        private int onlineBeatmapID { get; set; }
+        public int OnlineBeatmapID { get; set; }
+
+        [JsonProperty(@"beatmapset_id")]
+        public int OnlineBeatmapSetID { get; set; }
 
         [JsonProperty(@"playcount")]
         private int playCount { get; set; }
@@ -55,7 +58,11 @@ namespace osu.Game.Online.API.Requests.Responses
                 Metadata = this,
                 Ruleset = rulesets.GetRuleset(ruleset),
                 StarDifficulty = starDifficulty,
-                OnlineBeatmapID = onlineBeatmapID,
+                OnlineBeatmapID = OnlineBeatmapID,
+                BeatmapSet = new BeatmapSetInfo
+                {
+                    OnlineBeatmapSetID = OnlineBeatmapSetID,
+                },
                 Version = version,
                 BaseDifficulty = new BeatmapDifficulty
                 {
diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs
index f57de016a2..3b6bb565b0 100644
--- a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs
+++ b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs
@@ -15,6 +15,15 @@ namespace osu.Game.Online.API.Requests.Responses
         [JsonProperty(@"covers")]
         private BeatmapSetOnlineCovers covers { get; set; }
 
+        private int? onlineBeatmapSetID;
+
+        [JsonProperty(@"id")]
+        public int? OnlineBeatmapSetID
+        {
+            get { return onlineBeatmapSetID; }
+            set { onlineBeatmapSetID = value > 0 ? value : null; }
+        }
+
         [JsonProperty(@"preview_url")]
         private string preview { get; set; }
 
diff --git a/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs
index d49613eab7..8a5aea9e97 100644
--- a/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs
+++ b/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs
@@ -25,7 +25,6 @@ namespace osu.Game.Online.API.Requests.Responses
         {
             BeatmapSetInfo setInfo = beatmapSet.ToBeatmapSet(rulesets);
             beatmap.BeatmapSet = setInfo;
-            beatmap.OnlineBeatmapSetID = setInfo.OnlineBeatmapSetID;
             beatmap.Metadata = setInfo.Metadata;
             return beatmap;
         }
diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index 36c76851c6..ba8685b5b2 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -101,6 +101,8 @@ namespace osu.Game
         public OsuGame(string[] args = null)
         {
             this.args = args;
+
+            forwardLoggedErrorsToNotifications();
         }
 
         public void ToggleSettings() => settings.ToggleVisibility();
@@ -305,8 +307,6 @@ namespace osu.Game
                 Depth = -6,
             }, overlayContent.Add);
 
-            forwardLoggedErrorsToNotifications();
-
             dependencies.Cache(settings);
             dependencies.Cache(onscreenDisplay);
             dependencies.Cache(social);
@@ -394,31 +394,40 @@ namespace osu.Game
 
         private void forwardLoggedErrorsToNotifications()
         {
-            int recentErrorCount = 0;
+            int recentLogCount = 0;
 
             const double debounce = 5000;
 
             Logger.NewEntry += entry =>
             {
-                if (entry.Level < LogLevel.Error || entry.Target == null) return;
+                if (entry.Level < LogLevel.Important || entry.Target == null) return;
 
-                if (recentErrorCount < 2)
+                const int short_term_display_limit = 3;
+
+                if (recentLogCount < short_term_display_limit)
                 {
-                    notifications.Post(new SimpleNotification
+                    Schedule(() => notifications.Post(new SimpleNotification
                     {
-                        Icon = FontAwesome.fa_bomb,
-                        Text = (recentErrorCount == 0 ? entry.Message : "Subsequent errors occurred and have been logged.") + "\nClick to view log files.",
+                        Icon = entry.Level == LogLevel.Important ? FontAwesome.fa_exclamation_circle : FontAwesome.fa_bomb,
+                        Text = entry.Message,
+                    }));
+                }
+                else if (recentLogCount == short_term_display_limit)
+                {
+                    Schedule(() => notifications.Post(new SimpleNotification
+                    {
+                        Icon = FontAwesome.fa_ellipsis_h,
+                        Text = "Subsequent messages have been logged. Click to view log files.",
                         Activated = () =>
                         {
                             Host.Storage.GetStorageForDirectory("logs").OpenInNativeExplorer();
                             return true;
                         }
-                    });
+                    }));
                 }
 
-                Interlocked.Increment(ref recentErrorCount);
-
-                Scheduler.AddDelayed(() => Interlocked.Decrement(ref recentErrorCount), debounce);
+                Interlocked.Increment(ref recentLogCount);
+                Scheduler.AddDelayed(() => Interlocked.Decrement(ref recentLogCount), debounce);
             };
         }
 
diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs
index b968d7d4d0..246229a794 100644
--- a/osu.Game/OsuGameBase.cs
+++ b/osu.Game/OsuGameBase.cs
@@ -21,6 +21,7 @@ using osu.Game.Online.API;
 using osu.Framework.Graphics.Performance;
 using osu.Framework.Graphics.Textures;
 using osu.Framework.Logging;
+using osu.Game.Audio;
 using osu.Game.Database;
 using osu.Game.Graphics.Textures;
 using osu.Game.Input;
@@ -56,6 +57,8 @@ namespace osu.Game
 
         protected SettingsStore SettingsStore;
 
+        protected RulesetConfigCache RulesetConfigCache;
+
         protected MenuCursorContainer MenuCursorContainer;
 
         private Container content;
@@ -123,6 +126,7 @@ namespace osu.Game
             dependencies.Cache(ScoreStore = new ScoreStore(Host.Storage, contextFactory, Host, BeatmapManager, RulesetStore));
             dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory, RulesetStore));
             dependencies.Cache(SettingsStore = new SettingsStore(contextFactory));
+            dependencies.Cache(RulesetConfigCache = new RulesetConfigCache(SettingsStore));
             dependencies.Cache(new OsuColour());
 
             fileImporters.Add(BeatmapManager);
@@ -184,6 +188,10 @@ namespace osu.Game
 
             KeyBindingStore.Register(globalBinding);
             dependencies.Cache(globalBinding);
+
+            PreviewTrackManager previewTrackManager;
+            dependencies.Cache(previewTrackManager = new PreviewTrackManager());
+            Add(previewTrackManager);
         }
 
         protected override void LoadComplete()
diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs
index 08a99f1aea..505b7a7540 100644
--- a/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs
+++ b/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs
@@ -2,13 +2,13 @@
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
 using osu.Framework.Allocation;
-using osu.Framework.Audio.Track;
 using osu.Framework.Configuration;
 using osu.Framework.Extensions.Color4Extensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
 using osu.Framework.Input;
+using osu.Game.Audio;
 using osu.Game.Beatmaps;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Containers;
@@ -25,7 +25,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons
         private readonly Box bg, progress;
         private readonly PlayButton playButton;
 
-        private Track preview => playButton.Preview;
+        private PreviewTrack preview => playButton.Preview;
         public Bindable<bool> Playing => playButton.Playing;
 
         public BeatmapSetInfo BeatmapSet
@@ -66,7 +66,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons
                 },
             };
 
-            Action = () => Playing.Value = !Playing.Value;
+            Action = () => playButton.TriggerOnClick();
             Playing.ValueChanged += newValue => progress.FadeTo(newValue ? 1 : 0, 100);
         }
 
@@ -89,12 +89,6 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons
                 progress.Width = 0;
         }
 
-        protected override void Dispose(bool isDisposing)
-        {
-            Playing.Value = false;
-            base.Dispose(isDisposing);
-        }
-
         protected override bool OnHover(InputState state)
         {
             bg.FadeColour(Color4.Black.Opacity(0.5f), 100);
diff --git a/osu.Game/Overlays/BeatmapSet/Details.cs b/osu.Game/Overlays/BeatmapSet/Details.cs
index 5264caf936..ccd0fa04ab 100644
--- a/osu.Game/Overlays/BeatmapSet/Details.cs
+++ b/osu.Game/Overlays/BeatmapSet/Details.cs
@@ -102,8 +102,6 @@ namespace osu.Game.Overlays.BeatmapSet
             updateDisplay();
         }
 
-        public void StopPreview() => preview.Playing.Value = false;
-
         private class DetailBox : Container
         {
             private readonly Container content;
diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs
index 096f7bb63c..88f0a72ddf 100644
--- a/osu.Game/Overlays/BeatmapSetOverlay.cs
+++ b/osu.Game/Overlays/BeatmapSetOverlay.cs
@@ -1,9 +1,8 @@
 // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
+using System.Linq;
 using osu.Framework.Allocation;
-using OpenTK;
-using OpenTK.Graphics;
 using osu.Framework.Extensions.Color4Extensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
@@ -15,9 +14,10 @@ using osu.Game.Graphics.Containers;
 using osu.Game.Online.API;
 using osu.Game.Online.API.Requests;
 using osu.Game.Overlays.BeatmapSet;
-using osu.Game.Rulesets;
 using osu.Game.Overlays.BeatmapSet.Scores;
-using System.Linq;
+using osu.Game.Rulesets;
+using OpenTK;
+using OpenTK.Graphics;
 
 namespace osu.Game.Overlays
 {
@@ -124,8 +124,6 @@ namespace osu.Game.Overlays
         protected override void PopOut()
         {
             base.PopOut();
-            header.Details.StopPreview();
-
             FadeEdgeEffectTo(0, WaveContainer.DISAPPEAR_DURATION, Easing.Out).OnComplete(_ => BeatmapSet = null);
         }
 
diff --git a/osu.Game/Overlays/Direct/DirectGridPanel.cs b/osu.Game/Overlays/Direct/DirectGridPanel.cs
index 723e9e8b35..e286837746 100644
--- a/osu.Game/Overlays/Direct/DirectGridPanel.cs
+++ b/osu.Game/Overlays/Direct/DirectGridPanel.cs
@@ -149,7 +149,7 @@ namespace osu.Game.Overlays.Direct
                                             {
                                                 new OsuSpriteText
                                                 {
-                                                    Text = $"{SetInfo.Metadata.Source}",
+                                                    Text = SetInfo.Metadata.Source,
                                                     TextSize = 14,
                                                     Shadow = false,
                                                     Colour = colours.Gray5,
diff --git a/osu.Game/Overlays/Direct/DirectListPanel.cs b/osu.Game/Overlays/Direct/DirectListPanel.cs
index 6e3483604b..812a0e2073 100644
--- a/osu.Game/Overlays/Direct/DirectListPanel.cs
+++ b/osu.Game/Overlays/Direct/DirectListPanel.cs
@@ -160,7 +160,7 @@ namespace osu.Game.Overlays.Direct
                                         },
                                         new OsuSpriteText
                                         {
-                                            Text = $"from {SetInfo.Metadata.Source}",
+                                            Text = SetInfo.Metadata.Source,
                                             Anchor = Anchor.TopRight,
                                             Origin = Anchor.TopRight,
                                             TextSize = 14,
diff --git a/osu.Game/Overlays/Direct/DirectPanel.cs b/osu.Game/Overlays/Direct/DirectPanel.cs
index e767f6ec83..e63c290ce5 100644
--- a/osu.Game/Overlays/Direct/DirectPanel.cs
+++ b/osu.Game/Overlays/Direct/DirectPanel.cs
@@ -4,22 +4,22 @@
 using System.Collections.Generic;
 using System.Linq;
 using osu.Framework.Allocation;
+using osu.Framework.Configuration;
 using osu.Framework.Extensions.Color4Extensions;
-using OpenTK;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
 using osu.Framework.Graphics.Sprites;
+using osu.Framework.Input;
+using osu.Game.Audio;
 using osu.Game.Beatmaps;
 using osu.Game.Beatmaps.Drawables;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Sprites;
-using OpenTK.Graphics;
-using osu.Framework.Input;
 using osu.Game.Graphics.UserInterface;
 using osu.Game.Online.API.Requests;
-using osu.Framework.Configuration;
-using osu.Framework.Audio.Track;
+using OpenTK;
+using OpenTK.Graphics;
 
 namespace osu.Game.Overlays.Direct
 {
@@ -35,7 +35,7 @@ namespace osu.Game.Overlays.Direct
         private BeatmapManager beatmaps;
         private BeatmapSetOverlay beatmapSetOverlay;
 
-        public Track Preview => PlayButton.Preview;
+        public PreviewTrack Preview => PlayButton.Preview;
         public Bindable<bool> PreviewPlaying => PlayButton.Playing;
         protected abstract PlayButton PlayButton { get; }
         protected abstract Box PreviewBar { get; }
@@ -113,7 +113,7 @@ namespace osu.Game.Overlays.Direct
         {
             base.Update();
 
-            if (PreviewPlaying && Preview != null && Preview.IsLoaded)
+            if (PreviewPlaying && Preview != null && Preview.TrackLoaded)
             {
                 PreviewBar.Width = (float)(Preview.CurrentTime / Preview.Length);
             }
@@ -141,7 +141,6 @@ namespace osu.Game.Overlays.Direct
         protected override bool OnClick(InputState state)
         {
             ShowInformation();
-            PreviewPlaying.Value = false;
             return true;
         }
 
diff --git a/osu.Game/Overlays/Direct/PlayButton.cs b/osu.Game/Overlays/Direct/PlayButton.cs
index 131083c6ff..4b91a3d700 100644
--- a/osu.Game/Overlays/Direct/PlayButton.cs
+++ b/osu.Game/Overlays/Direct/PlayButton.cs
@@ -1,25 +1,23 @@
 // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
-using OpenTK.Graphics;
 using osu.Framework.Allocation;
-using osu.Framework.Audio;
-using osu.Framework.Audio.Track;
 using osu.Framework.Configuration;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Input;
-using osu.Framework.IO.Stores;
+using osu.Game.Audio;
 using osu.Game.Beatmaps;
 using osu.Game.Graphics;
 using osu.Game.Graphics.UserInterface;
+using OpenTK.Graphics;
 
 namespace osu.Game.Overlays.Direct
 {
     public class PlayButton : Container
     {
-        public readonly Bindable<bool> Playing = new Bindable<bool>();
-        public Track Preview { get; private set; }
+        public readonly BindableBool Playing = new BindableBool();
+        public PreviewTrack Preview { get; private set; }
 
         private BeatmapSetInfo beatmapSet;
 
@@ -31,9 +29,11 @@ namespace osu.Game.Overlays.Direct
                 if (value == beatmapSet) return;
                 beatmapSet = value;
 
-                Playing.Value = false;
-                trackLoader = null;
+                Preview?.Stop();
+                Preview?.Expire();
                 Preview = null;
+
+                Playing.Value = false;
             }
         }
 
@@ -41,8 +41,6 @@ namespace osu.Game.Overlays.Direct
         private readonly SpriteIcon icon;
         private readonly LoadingAnimation loadingAnimation;
 
-        private readonly BindableDouble muteBindable = new BindableDouble();
-
         private const float transition_duration = 500;
 
         private bool loading
@@ -50,15 +48,9 @@ namespace osu.Game.Overlays.Direct
             set
             {
                 if (value)
-                {
                     loadingAnimation.Show();
-                    icon.FadeOut(transition_duration * 5, Easing.OutQuint);
-                }
                 else
-                {
                     loadingAnimation.Hide();
-                    icon.FadeIn(transition_duration, Easing.OutQuint);
-                }
             }
         }
 
@@ -78,19 +70,22 @@ namespace osu.Game.Overlays.Direct
                 loadingAnimation = new LoadingAnimation(),
             });
 
-            Playing.ValueChanged += updatePreviewTrack;
+            Playing.ValueChanged += playingStateChanged;
         }
 
+        private PreviewTrackManager previewTrackManager;
+
         [BackgroundDependencyLoader]
-        private void load(OsuColour colour, AudioManager audio)
+        private void load(OsuColour colour, PreviewTrackManager previewTrackManager)
         {
+            this.previewTrackManager = previewTrackManager;
+
             hoverColour = colour.Yellow;
-            this.audio = audio;
         }
 
         protected override bool OnClick(InputState state)
         {
-            Playing.Value = !Playing.Value;
+            Playing.Toggle();
             return true;
         }
 
@@ -107,44 +102,44 @@ namespace osu.Game.Overlays.Direct
             base.OnHoverLost(state);
         }
 
-        protected override void Update()
+        private void playingStateChanged(bool playing)
         {
-            base.Update();
-
-            if (Preview?.HasCompleted ?? false)
-            {
-                Playing.Value = false;
-                Preview = null;
-            }
-        }
-
-        private void updatePreviewTrack(bool playing)
-        {
-            if (playing && BeatmapSet == null)
-            {
-                Playing.Value = false;
-                return;
-            }
-
             icon.Icon = playing ? FontAwesome.fa_stop : FontAwesome.fa_play;
             icon.FadeColour(playing || IsHovered ? hoverColour : Color4.White, 120, Easing.InOutQuint);
 
             if (playing)
             {
-                if (Preview == null)
+                if (BeatmapSet == null)
                 {
-                    beginAudioLoad();
+                    Playing.Value = false;
                     return;
                 }
 
-                Preview.Restart();
+                if (Preview != null)
+                {
+                    Preview.Start();
+                    return;
+                }
 
-                audio.Track.AddAdjustment(AdjustableProperty.Volume, muteBindable);
+                loading = true;
+
+                LoadComponentAsync(Preview = previewTrackManager.Get(beatmapSet), preview =>
+                {
+                    // beatmapset may have changed.
+                    if (Preview != preview)
+                        return;
+
+                    AddInternal(preview);
+                    loading = false;
+                    preview.Stopped += () => Playing.Value = false;
+
+                    // user may have changed their mind.
+                    if (Playing)
+                        preview.Start();
+                });
             }
             else
             {
-                audio.Track.RemoveAdjustment(AdjustableProperty.Volume, muteBindable);
-
                 Preview?.Stop();
                 loading = false;
             }
@@ -155,64 +150,5 @@ namespace osu.Game.Overlays.Direct
             base.Dispose(isDisposing);
             Playing.Value = false;
         }
-
-        private TrackLoader trackLoader;
-        private AudioManager audio;
-
-        private void beginAudioLoad()
-        {
-            if (trackLoader != null)
-            {
-                Preview = trackLoader.Preview;
-                Playing.TriggerChange();
-                return;
-            }
-
-            loading = true;
-
-            LoadComponentAsync(trackLoader = new TrackLoader($"https://b.ppy.sh/preview/{BeatmapSet.OnlineBeatmapSetID}.mp3"),
-                d =>
-                {
-                    // We may have been replaced by another loader
-                    if (trackLoader != d) return;
-
-                    Preview = d?.Preview;
-                    updatePreviewTrack(Playing);
-                    loading = false;
-
-                    Add(trackLoader);
-                });
-        }
-
-        private class TrackLoader : Drawable
-        {
-            private readonly string preview;
-
-            public Track Preview;
-            private TrackManager trackManager;
-
-            public TrackLoader(string preview)
-            {
-                this.preview = preview;
-            }
-
-            [BackgroundDependencyLoader]
-            private void load(AudioManager audio, FrameworkConfigManager config)
-            {
-                // create a local trackManager to bypass the mute we are applying above.
-                audio.AddItem(trackManager = new TrackManager(new OnlineStore()));
-
-                // add back the user's music volume setting (since we are no longer in the global TrackManager's hierarchy).
-                config.BindWith(FrameworkSetting.VolumeMusic, trackManager.Volume);
-
-                Preview = trackManager.Get(preview);
-            }
-
-            protected override void Dispose(bool isDisposing)
-            {
-                base.Dispose(isDisposing);
-                trackManager?.Dispose();
-            }
-        }
     }
 }
diff --git a/osu.Game/Overlays/DirectOverlay.cs b/osu.Game/Overlays/DirectOverlay.cs
index b33f271986..423211659d 100644
--- a/osu.Game/Overlays/DirectOverlay.cs
+++ b/osu.Game/Overlays/DirectOverlay.cs
@@ -4,12 +4,12 @@
 using System.Collections.Generic;
 using System.Linq;
 using System.Threading.Tasks;
-using OpenTK;
 using osu.Framework.Allocation;
 using osu.Framework.Configuration;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Threading;
+using osu.Game.Audio;
 using osu.Game.Beatmaps;
 using osu.Game.Graphics;
 using osu.Game.Graphics.Sprites;
@@ -18,6 +18,7 @@ using osu.Game.Online.API.Requests;
 using osu.Game.Overlays.Direct;
 using osu.Game.Overlays.SearchableList;
 using osu.Game.Rulesets;
+using OpenTK;
 using OpenTK.Graphics;
 
 namespace osu.Game.Overlays
@@ -32,7 +33,6 @@ namespace osu.Game.Overlays
         private readonly FillFlowContainer resultCountsContainer;
         private readonly OsuSpriteText resultCountsText;
         private FillFlowContainer<DirectPanel> panels;
-        private DirectPanel playing;
 
         protected override Color4 BackgroundColour => OsuColour.FromHex(@"485e74");
         protected override Color4 TrianglesColourLight => OsuColour.FromHex(@"465b71");
@@ -176,10 +176,11 @@ namespace osu.Game.Overlays
         }
 
         [BackgroundDependencyLoader]
-        private void load(OsuColour colours, APIAccess api, RulesetStore rulesets)
+        private void load(OsuColour colours, APIAccess api, RulesetStore rulesets, PreviewTrackManager previewTrackManager)
         {
             this.api = api;
             this.rulesets = rulesets;
+            this.previewTrackManager = previewTrackManager;
 
             resultCountsContainer.Colour = colours.Yellow;
         }
@@ -206,12 +207,6 @@ namespace osu.Game.Overlays
                 panels.FadeOut(200);
                 panels.Expire();
                 panels = null;
-
-                if (playing != null)
-                {
-                    playing.PreviewPlaying.Value = false;
-                    playing = null;
-                }
             }
 
             if (BeatmapSets == null) return;
@@ -242,17 +237,6 @@ namespace osu.Game.Overlays
             {
                 if (panels != null) ScrollFlow.Remove(panels);
                 ScrollFlow.Add(panels = newPanels);
-
-                foreach (DirectPanel panel in p.Children)
-                    panel.PreviewPlaying.ValueChanged += newValue =>
-                    {
-                        if (newValue)
-                        {
-                            if (playing != null && playing != panel)
-                                playing.PreviewPlaying.Value = false;
-                            playing = panel;
-                        }
-                    };
             });
         }
 
@@ -261,6 +245,7 @@ namespace osu.Game.Overlays
         private readonly Bindable<string> currentQuery = new Bindable<string>();
 
         private ScheduledDelegate queryChangedDebounce;
+        private PreviewTrackManager previewTrackManager;
 
         private void updateSearch()
         {
@@ -277,6 +262,8 @@ namespace osu.Game.Overlays
 
             if (Header.Tabs.Current.Value == DirectTab.Search && (Filter.Search.Text == string.Empty || currentQuery == string.Empty)) return;
 
+            previewTrackManager.StopAnyPlaying(this);
+
             getSetsRequest = new SearchBeatmapSetsRequest(currentQuery.Value ?? string.Empty,
                 ((FilterControl)Filter).Ruleset.Value,
                 Filter.DisplayStyleControl.Dropdown.Current.Value,
@@ -300,14 +287,6 @@ namespace osu.Game.Overlays
             api.Queue(getSetsRequest);
         }
 
-        protected override void PopOut()
-        {
-            base.PopOut();
-
-            if (playing != null)
-                playing.PreviewPlaying.Value = false;
-        }
-
         private int distinctCount(List<string> list) => list.Distinct().ToArray().Length;
 
         public class ResultCounts
diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs
index d96bb40165..a57d5fd183 100644
--- a/osu.Game/Overlays/MusicController.cs
+++ b/osu.Game/Overlays/MusicController.cs
@@ -66,34 +66,6 @@ namespace osu.Game.Overlays
             AlwaysPresent = true;
         }
 
-        private Vector2 dragStart;
-
-        protected override bool OnDragStart(InputState state)
-        {
-            base.OnDragStart(state);
-            dragStart = state.Mouse.Position;
-            return true;
-        }
-
-        protected override bool OnDrag(InputState state)
-        {
-            if (base.OnDrag(state)) return true;
-
-            Vector2 change = state.Mouse.Position - dragStart;
-
-            // Diminish the drag distance as we go further to simulate "rubber band" feeling.
-            change *= change.Length <= 0 ? 0 : (float)Math.Pow(change.Length, 0.7f) / change.Length;
-
-            dragContainer.MoveTo(change);
-            return true;
-        }
-
-        protected override bool OnDragEnd(InputState state)
-        {
-            dragContainer.MoveTo(Vector2.Zero, 800, Easing.OutElastic);
-            return base.OnDragEnd(state);
-        }
-
         [BackgroundDependencyLoader]
         private void load(BindableBeatmap beatmap, BeatmapManager beatmaps, OsuColour colours, LocalisationEngine localisation)
         {
@@ -103,7 +75,7 @@ namespace osu.Game.Overlays
 
             Children = new Drawable[]
             {
-                dragContainer = new Container
+                dragContainer = new DragContainer
                 {
                     Anchor = Anchor.Centre,
                     Origin = Anchor.Centre,
@@ -470,5 +442,36 @@ namespace osu.Game.Overlays
                 sprite.Texture = beatmap?.Background ?? textures.Get(@"Backgrounds/bg4");
             }
         }
+
+        private class DragContainer : Container
+        {
+            private Vector2 dragStart;
+
+            protected override bool OnDragStart(InputState state)
+            {
+                base.OnDragStart(state);
+                dragStart = state.Mouse.Position;
+                return true;
+            }
+
+            protected override bool OnDrag(InputState state)
+            {
+                if (base.OnDrag(state)) return true;
+
+                Vector2 change = state.Mouse.Position - dragStart;
+
+                // Diminish the drag distance as we go further to simulate "rubber band" feeling.
+                change *= change.Length <= 0 ? 0 : (float)Math.Pow(change.Length, 0.7f) / change.Length;
+
+                this.MoveTo(change);
+                return true;
+            }
+
+            protected override bool OnDragEnd(InputState state)
+            {
+                this.MoveTo(Vector2.Zero, 800, Easing.OutElastic);
+                return base.OnDragEnd(state);
+            }
+        }
     }
 }
diff --git a/osu.Game/Overlays/Notifications/SimpleNotification.cs b/osu.Game/Overlays/Notifications/SimpleNotification.cs
index a78bc8da81..25a832941e 100644
--- a/osu.Game/Overlays/Notifications/SimpleNotification.cs
+++ b/osu.Game/Overlays/Notifications/SimpleNotification.cs
@@ -59,7 +59,7 @@ namespace osu.Game.Overlays.Notifications
                 }
             });
 
-            Content.Add(textDrawable = new OsuTextFlowContainer(t => t.TextSize = 16)
+            Content.Add(textDrawable = new OsuTextFlowContainer(t => t.TextSize = 14)
             {
                 Colour = OsuColour.Gray(128),
                 AutoSizeAxes = Axes.Y,
diff --git a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs
index 97079c77f3..359bfc7564 100644
--- a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs
+++ b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs
@@ -32,7 +32,7 @@ namespace osu.Game.Overlays.Profile.Sections
         {
             Action = () =>
             {
-                if (beatmap.OnlineBeatmapSetID.HasValue) beatmapSetOverlay?.FetchAndShowBeatmapSet(beatmap.OnlineBeatmapSetID.Value);
+                if (beatmap.BeatmapSet?.OnlineBeatmapSetID != null) beatmapSetOverlay?.FetchAndShowBeatmapSet(beatmap.BeatmapSet.OnlineBeatmapSetID.Value);
             };
 
             Child = new FillFlowContainer
diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs
index 3fec9d8697..0b06acd426 100644
--- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs
+++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs
@@ -1,14 +1,13 @@
 // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
-using System;
-using OpenTK;
+using System.Linq;
 using osu.Framework.Configuration;
 using osu.Framework.Graphics;
 using osu.Game.Online.API.Requests;
 using osu.Game.Overlays.Direct;
 using osu.Game.Users;
-using System.Linq;
+using OpenTK;
 
 namespace osu.Game.Overlays.Profile.Sections.Beatmaps
 {
@@ -18,10 +17,6 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps
 
         private readonly BeatmapSetType type;
 
-        private DirectPanel currentlyPlaying;
-
-        public event Action<PaginatedBeatmapContainer> BeganPlayingPreview;
-
         public PaginatedBeatmapContainer(BeatmapSetType type, Bindable<User> user, string header, string missing = "None... yet.")
             : base(user, header, missing)
         {
@@ -56,28 +51,10 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps
 
                     var panel = new DirectGridPanel(s.ToBeatmapSet(Rulesets));
                     ItemsContainer.Add(panel);
-
-                    panel.PreviewPlaying.ValueChanged += isPlaying =>
-                    {
-                        StopPlayingPreview();
-
-                        if (isPlaying)
-                        {
-                            BeganPlayingPreview?.Invoke(this);
-                            currentlyPlaying = panel;
-                        }
-                    };
                 }
             };
 
             Api.Queue(req);
         }
-
-        public void StopPlayingPreview()
-        {
-            if (currentlyPlaying == null) return;
-            currentlyPlaying.PreviewPlaying.Value = false;
-            currentlyPlaying = null;
-        }
     }
 }
diff --git a/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs b/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs
index 92abd20f93..367d096c16 100644
--- a/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs
+++ b/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs
@@ -1,7 +1,6 @@
 // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
-using System.Linq;
 using osu.Game.Online.API.Requests;
 using osu.Game.Overlays.Profile.Sections.Beatmaps;
 
@@ -22,15 +21,6 @@ namespace osu.Game.Overlays.Profile.Sections
                 new PaginatedBeatmapContainer(BeatmapSetType.Unranked, User, "Pending Beatmaps"),
                 new PaginatedBeatmapContainer(BeatmapSetType.Graveyard, User, "Graveyarded Beatmaps"),
             };
-
-            foreach (var paginatedBeatmapContainer in Children.OfType<PaginatedBeatmapContainer>())
-            {
-                paginatedBeatmapContainer.BeganPlayingPreview += _ =>
-                {
-                    foreach (var bc in Children.OfType<PaginatedBeatmapContainer>())
-                        bc.StopPlayingPreview();
-                };
-            }
         }
     }
 }
diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs b/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs
index 1e0406c125..6dbb9b9ba3 100644
--- a/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs
+++ b/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs
@@ -64,6 +64,7 @@ namespace osu.Game.Overlays.Profile.Sections
                     {
                         TextSize = 14,
                         Text = "show more",
+                        Padding = new MarginPadding {Vertical = 10, Horizontal = 15 },
                     }
                 },
                 ShowMoreLoading = new LoadingAnimation
diff --git a/osu.Game/Overlays/Settings/RulesetSettingsSubsection.cs b/osu.Game/Overlays/Settings/RulesetSettingsSubsection.cs
new file mode 100644
index 0000000000..05104018cd
--- /dev/null
+++ b/osu.Game/Overlays/Settings/RulesetSettingsSubsection.cs
@@ -0,0 +1,35 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using osu.Framework.Allocation;
+using osu.Game.Rulesets;
+
+namespace osu.Game.Overlays.Settings
+{
+    /// <summary>
+    /// A <see cref="SettingsSubsection"/> which provides subclasses with the <see cref="IRulesetConfigManager"/>
+    /// from the <see cref="Ruleset"/>'s <see cref="Ruleset.CreateConfig()"/>.
+    /// </summary>
+    public abstract class RulesetSettingsSubsection : SettingsSubsection
+    {
+        private readonly Ruleset ruleset;
+
+        protected RulesetSettingsSubsection(Ruleset ruleset)
+        {
+            this.ruleset = ruleset;
+        }
+
+        private DependencyContainer dependencies;
+
+        protected override IReadOnlyDependencyContainer CreateLocalDependencies(IReadOnlyDependencyContainer parent)
+        {
+            dependencies = new DependencyContainer(base.CreateLocalDependencies(parent));
+
+            var config = dependencies.Get<RulesetConfigCache>().GetConfigFor(ruleset);
+            if (config != null)
+                dependencies.Cache(config);
+
+            return dependencies;
+        }
+    }
+}
diff --git a/osu.Game/Overlays/UserProfileOverlay.cs b/osu.Game/Overlays/UserProfileOverlay.cs
index a4dd0c9ec3..745f2f3def 100644
--- a/osu.Game/Overlays/UserProfileOverlay.cs
+++ b/osu.Game/Overlays/UserProfileOverlay.cs
@@ -2,8 +2,6 @@
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
 using System.Linq;
-using OpenTK;
-using OpenTK.Graphics;
 using osu.Framework.Allocation;
 using osu.Framework.Extensions.Color4Extensions;
 using osu.Framework.Graphics;
@@ -18,6 +16,8 @@ using osu.Game.Online.API.Requests;
 using osu.Game.Overlays.Profile;
 using osu.Game.Overlays.Profile.Sections;
 using osu.Game.Users;
+using OpenTK;
+using OpenTK.Graphics;
 
 namespace osu.Game.Overlays
 {
diff --git a/osu.Game/Overlays/Volume/MuteButton.cs b/osu.Game/Overlays/Volume/MuteButton.cs
index b62c639ee3..d0aa58e668 100644
--- a/osu.Game/Overlays/Volume/MuteButton.cs
+++ b/osu.Game/Overlays/Volume/MuteButton.cs
@@ -66,7 +66,7 @@ namespace osu.Game.Overlays.Volume
         protected override bool OnHover(InputState state)
         {
             this.TransformTo<MuteButton, SRGBColour>("BorderColour", hoveredColour, 500, Easing.OutQuint);
-            return true;
+            return false;
         }
 
         protected override void OnHoverLost(InputState state)
diff --git a/osu.Game/Overlays/Volume/VolumeMeter.cs b/osu.Game/Overlays/Volume/VolumeMeter.cs
index b2cf43704b..1d392e6ee8 100644
--- a/osu.Game/Overlays/Volume/VolumeMeter.cs
+++ b/osu.Game/Overlays/Volume/VolumeMeter.cs
@@ -11,6 +11,7 @@ using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Effects;
 using osu.Framework.Graphics.Shapes;
 using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Input;
 using osu.Framework.Input.Bindings;
 using osu.Framework.MathUtils;
 using osu.Game.Graphics;
@@ -24,6 +25,8 @@ namespace osu.Game.Overlays.Volume
     public class VolumeMeter : Container, IKeyBindingHandler<GlobalAction>
     {
         private CircularProgress volumeCircle;
+        private CircularProgress volumeCircleGlow;
+
         public BindableDouble Bindable { get; } = new BindableDouble { MinValue = 0, MaxValue = 1 };
         private readonly float circleSize;
         private readonly Color4 meterColour;
@@ -44,90 +47,143 @@ namespace osu.Game.Overlays.Volume
         [BackgroundDependencyLoader]
         private void load(OsuColour colours)
         {
-            Add(new Container
-            {
-                Size = new Vector2(120, 20),
-                CornerRadius = 10,
-                Masking = true,
-                Margin = new MarginPadding { Left = circleSize + 10 },
-                Origin = Anchor.CentreLeft,
-                Anchor = Anchor.CentreLeft,
-                Children = new Drawable[]
-                {
-                    new Box
-                    {
-                        RelativeSizeAxes = Axes.Both,
-                        Colour = colours.Gray1,
-                        Alpha = 0.9f,
-                    },
-                    new OsuSpriteText
-                    {
-                        Anchor = Anchor.Centre,
-                        Origin = Anchor.Centre,
-                        Font = "Exo2.0-Bold",
-                        Text = name
-                    }
-                }
-            });
+            Color4 backgroundColour = colours.Gray1;
 
             CircularProgress bgProgress;
 
-            Add(new CircularContainer
+            const float progress_start_radius = 0.75f;
+            const float progress_size = 0.03f;
+            const float progress_end_radius = progress_start_radius + progress_size;
+
+            const float blur_amount = 5;
+
+            Children = new Drawable[]
             {
-                Masking = true,
-                Size = new Vector2(circleSize),
-                Children = new Drawable[]
+                new Container
                 {
-                    new Box
+                    Size = new Vector2(circleSize),
+                    Children = new Drawable[]
                     {
-                        RelativeSizeAxes = Axes.Both,
-                        Colour = colours.Gray1,
-                        Alpha = 0.9f,
-                    },
-                    bgProgress = new CircularProgress
-                    {
-                        RelativeSizeAxes = Axes.Both,
-                        InnerRadius = 0.05f,
-                        Rotation = 180,
-                        Anchor = Anchor.Centre,
-                        Origin = Anchor.Centre,
-                        Colour = colours.Gray2,
-                        Size = new Vector2(0.8f)
-                    },
-                    new Container
-                    {
-                        Anchor = Anchor.Centre,
-                        Origin = Anchor.Centre,
-                        RelativeSizeAxes = Axes.Both,
-                        Size = new Vector2(0.8f),
-                        Padding = new MarginPadding(-Blur.KernelSize(5)),
-                        Rotation = 180,
-                        Child = (volumeCircle = new CircularProgress
+                        new BufferedContainer
                         {
+                            Alpha = 0.9f,
                             RelativeSizeAxes = Axes.Both,
-                            InnerRadius = 0.05f,
+                            Children = new Drawable[]
+                            {
+                                new Circle
+                                {
+                                    RelativeSizeAxes = Axes.Both,
+                                    Colour = backgroundColour,
+                                },
+                                new CircularContainer
+                                {
+                                    Masking = true,
+                                    Anchor = Anchor.Centre,
+                                    Origin = Anchor.Centre,
+                                    RelativeSizeAxes = Axes.Both,
+                                    Size = new Vector2(progress_end_radius),
+                                    Children = new Drawable[]
+                                    {
+                                        bgProgress = new CircularProgress
+                                        {
+                                            Anchor = Anchor.Centre,
+                                            Origin = Anchor.Centre,
+                                            RelativeSizeAxes = Axes.Both,
+                                            Rotation = 180,
+                                            Colour = backgroundColour,
+                                        },
+                                        new Container
+                                        {
+                                            Anchor = Anchor.Centre,
+                                            Origin = Anchor.Centre,
+                                            Name = "Progress under covers for smoothing",
+                                            RelativeSizeAxes = Axes.Both,
+                                            Rotation = 180,
+                                            Child = volumeCircle = new CircularProgress
+                                            {
+                                                RelativeSizeAxes = Axes.Both,
+                                            }
+                                        },
+                                    }
+                                },
+                                new Circle
+                                {
+                                    Name = "Inner Cover",
+                                    Anchor = Anchor.Centre,
+                                    Origin = Anchor.Centre,
+                                    RelativeSizeAxes = Axes.Both,
+                                    Colour = backgroundColour,
+                                    Size = new Vector2(progress_start_radius),
+                                },
+                                new Container
+                                {
+                                    Name = "Progress overlay for glow",
+                                    Anchor = Anchor.Centre,
+                                    Origin = Anchor.Centre,
+                                    RelativeSizeAxes = Axes.Both,
+                                    Size = new Vector2(progress_start_radius + progress_size / 1.5f),
+                                    Rotation = 180,
+                                    Padding = new MarginPadding(-Blur.KernelSize(blur_amount)),
+                                    Child = (volumeCircleGlow = new CircularProgress
+                                    {
+                                        RelativeSizeAxes = Axes.Both,
+                                        InnerRadius = progress_size * 0.8f,
+                                    }).WithEffect(new GlowEffect
+                                    {
+                                        Colour = meterColour,
+                                        BlurSigma = new Vector2(blur_amount),
+                                        Strength = 5,
+                                        PadExtent = true
+                                    }),
+                                },
+                            },
+                        },
+                        maxGlow = (text = new OsuSpriteText
+                        {
+                            Anchor = Anchor.Centre,
+                            Origin = Anchor.Centre,
+                            Font = "Venera",
+                            TextSize = 0.16f * circleSize
                         }).WithEffect(new GlowEffect
                         {
-                            Colour = meterColour,
-                            Strength = 2,
-                            PadExtent = true
-                        }),
-                    },
-                    maxGlow = (text = new OsuSpriteText
+                            Colour = Color4.Transparent,
+                            PadExtent = true,
+                        })
+                    }
+                },
+                new Container
+                {
+                    Size = new Vector2(120, 20),
+                    CornerRadius = 10,
+                    Masking = true,
+                    Margin = new MarginPadding { Left = circleSize + 10 },
+                    Origin = Anchor.CentreLeft,
+                    Anchor = Anchor.CentreLeft,
+                    Children = new Drawable[]
                     {
-                        Anchor = Anchor.Centre,
-                        Origin = Anchor.Centre,
-                        Font = "Venera",
-                        TextSize = 0.16f * circleSize
-                    }).WithEffect(new GlowEffect
-                    {
-                        Colour = Color4.Transparent,
-                        PadExtent = true,
-                    })
+                        new Box
+                        {
+                            Alpha = 0.9f,
+                            RelativeSizeAxes = Axes.Both,
+                            Colour = backgroundColour,
+                        },
+                        new OsuSpriteText
+                        {
+                            Anchor = Anchor.Centre,
+                            Origin = Anchor.Centre,
+                            Font = "Exo2.0-Bold",
+                            Text = name
+                        }
+                    }
                 }
-            });
-
-            Bindable.ValueChanged += newVolume => { this.TransformTo("DisplayVolume", newVolume, 400, Easing.OutQuint); };
+            };
+            Bindable.ValueChanged += newVolume =>
+            {
+                this.TransformTo("DisplayVolume",
+                    newVolume,
+                    400,
+                    Easing.OutQuint);
+            };
             bgProgress.Current.Value = 0.75f;
         }
 
@@ -158,6 +214,7 @@ namespace osu.Game.Overlays.Volume
                 }
 
                 volumeCircle.Current.Value = displayVolume * 0.75f;
+                volumeCircleGlow.Current.Value = displayVolume * 0.75f;
             }
         }
 
@@ -176,12 +233,13 @@ namespace osu.Game.Overlays.Volume
         {
             float amount = adjust_step * direction;
 
-            var mouse = GetContainingInputManager().CurrentState.Mouse;
-            if (mouse.HasPreciseScroll)
+            // handle the case where the OnPressed action was actually a mouse wheel.
+            // this allows for precise wheel handling.
+            var state = GetContainingInputManager().CurrentState;
+            if (state.Mouse?.ScrollDelta.Y != 0)
             {
-                float scrollDelta = mouse.ScrollDelta.Y;
-                if (scrollDelta != 0)
-                    amount *= Math.Abs(scrollDelta / 10);
+                OnScroll(state);
+                return;
             }
 
             Volume += amount;
@@ -204,6 +262,34 @@ namespace osu.Game.Overlays.Volume
             return false;
         }
 
+        // because volume precision is set to 0.01, this local is required to keep track of more precise adjustments and only apply when possible.
+        private double scrollAmount;
+
+        protected override bool OnScroll(InputState state)
+        {
+            scrollAmount += adjust_step * state.Mouse.ScrollDelta.Y * (state.Mouse.HasPreciseScroll ? 0.1f : 1);
+
+            if (Math.Abs(scrollAmount) < Bindable.Precision)
+                return true;
+
+            Volume += scrollAmount;
+            scrollAmount = 0;
+            return true;
+        }
+
         public bool OnReleased(GlobalAction action) => false;
+
+        private const float transition_length = 500;
+
+        protected override bool OnHover(InputState state)
+        {
+            this.ScaleTo(1.04f, transition_length, Easing.OutExpo);
+            return false;
+        }
+
+        protected override void OnHoverLost(InputState state)
+        {
+            this.ScaleTo(1f, transition_length, Easing.OutExpo);
+        }
     }
 }
diff --git a/osu.Game/Overlays/VolumeOverlay.cs b/osu.Game/Overlays/VolumeOverlay.cs
index f922c507f7..1c9c615bbb 100644
--- a/osu.Game/Overlays/VolumeOverlay.cs
+++ b/osu.Game/Overlays/VolumeOverlay.cs
@@ -9,6 +9,7 @@ using osu.Framework.Graphics;
 using osu.Framework.Graphics.Colour;
 using osu.Framework.Graphics.Containers;
 using osu.Framework.Graphics.Shapes;
+using osu.Framework.Input;
 using osu.Framework.Threading;
 using osu.Game.Graphics;
 using osu.Game.Input.Bindings;
@@ -86,16 +87,10 @@ namespace osu.Game.Overlays
         {
             base.LoadComplete();
 
-            volumeMeterMaster.Bindable.ValueChanged += _ => settingChanged();
-            volumeMeterEffect.Bindable.ValueChanged += _ => settingChanged();
-            volumeMeterMusic.Bindable.ValueChanged += _ => settingChanged();
-            muteButton.Current.ValueChanged += _ => settingChanged();
-        }
-
-        private void settingChanged()
-        {
-            Show();
-            schedulePopOut();
+            volumeMeterMaster.Bindable.ValueChanged += _ => Show();
+            volumeMeterEffect.Bindable.ValueChanged += _ => Show();
+            volumeMeterMusic.Bindable.ValueChanged += _ => Show();
+            muteButton.Current.ValueChanged += _ => Show();
         }
 
         public bool Adjust(GlobalAction action)
@@ -127,6 +122,14 @@ namespace osu.Game.Overlays
 
         private ScheduledDelegate popOutDelegate;
 
+        public override void Show()
+        {
+            if (State == Visibility.Visible)
+                schedulePopOut();
+
+            base.Show();
+        }
+
         protected override void PopIn()
         {
             ClearTransforms();
@@ -140,10 +143,33 @@ namespace osu.Game.Overlays
             this.FadeOut(100);
         }
 
+        protected override bool OnMouseMove(InputState state)
+        {
+            // keep the scheduled event correctly timed as long as we have movement.
+            schedulePopOut();
+            return base.OnMouseMove(state);
+        }
+
+        protected override bool OnHover(InputState state)
+        {
+            schedulePopOut();
+            return true;
+        }
+
+        protected override void OnHoverLost(InputState state)
+        {
+            schedulePopOut();
+            base.OnHoverLost(state);
+        }
+
         private void schedulePopOut()
         {
             popOutDelegate?.Cancel();
-            this.Delay(1000).Schedule(Hide, out popOutDelegate);
+            this.Delay(1000).Schedule(() =>
+            {
+                if (!IsHovered)
+                    Hide();
+            }, out popOutDelegate);
         }
     }
 }
diff --git a/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs
index 4ecf1eefb2..74cece5154 100644
--- a/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs
+++ b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs
@@ -8,7 +8,8 @@ namespace osu.Game.Rulesets.Configuration
     public abstract class RulesetConfigManager<T> : DatabasedConfigManager<T>, IRulesetConfigManager
         where T : struct
     {
-        protected RulesetConfigManager(SettingsStore settings, RulesetInfo ruleset, int variant) : base(settings, ruleset, variant)
+        protected RulesetConfigManager(SettingsStore settings, RulesetInfo ruleset, int? variant = null)
+            : base(settings, ruleset, variant)
         {
         }
     }
diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
new file mode 100644
index 0000000000..1fdebd586f
--- /dev/null
+++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
@@ -0,0 +1,19 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using osu.Game.Rulesets.Mods;
+
+namespace osu.Game.Rulesets.Difficulty
+{
+    public class DifficultyAttributes
+    {
+        public readonly Mod[] Mods;
+        public readonly double StarRating;
+
+        public DifficultyAttributes(Mod[] mods, double starRating)
+        {
+            Mods = mods;
+            StarRating = starRating;
+        }
+    }
+}
diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
index 31cd9dc6f5..8f9651ab09 100644
--- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
+++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
@@ -13,28 +13,44 @@ namespace osu.Game.Rulesets.Difficulty
 {
     public abstract class DifficultyCalculator
     {
-        protected readonly IBeatmap Beatmap;
-        protected readonly Mod[] Mods;
+        private readonly Ruleset ruleset;
+        private readonly WorkingBeatmap beatmap;
 
-        protected double TimeRate { get; private set; } = 1;
-
-        protected DifficultyCalculator(IBeatmap beatmap, Mod[] mods = null)
+        protected DifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap)
         {
-            Beatmap = beatmap;
-            Mods = mods ?? new Mod[0];
-
-            ApplyMods(Mods);
+            this.ruleset = ruleset;
+            this.beatmap = beatmap;
         }
 
-        protected virtual void ApplyMods(Mod[] mods)
+        /// <summary>
+        /// Calculates the difficulty of the beatmap using a specific mod combination.
+        /// </summary>
+        /// <param name="mods">The mods that should be applied to the beatmap.</param>
+        /// <returns>A structure describing the difficulty of the beatmap.</returns>
+        public DifficultyAttributes Calculate(params Mod[] mods)
         {
+            beatmap.Mods.Value = mods;
+            IBeatmap playableBeatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo);
+
             var clock = new StopwatchClock();
             mods.OfType<IApplicableToClock>().ForEach(m => m.ApplyToClock(clock));
-            TimeRate = clock.Rate;
+
+            return Calculate(playableBeatmap, mods, clock.Rate);
         }
 
-        protected virtual void PreprocessHitObjects()
+        /// <summary>
+        /// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap.
+        /// </summary>
+        /// <returns>A collection of structures describing the difficulty of the beatmap for each mod combination.</returns>
+        public IEnumerable<DifficultyAttributes> CalculateAll()
         {
+            foreach (var combination in CreateDifficultyAdjustmentModCombinations())
+            {
+                if (combination is MultiMod multi)
+                    yield return Calculate(multi.Mods);
+                else
+                    yield return Calculate(combination);
+            }
         }
 
         /// <summary>
@@ -75,6 +91,13 @@ namespace osu.Game.Rulesets.Difficulty
         /// </summary>
         protected virtual Mod[] DifficultyAdjustmentMods => Array.Empty<Mod>();
 
-        public abstract double Calculate(Dictionary<string, double> categoryDifficulty = null);
+        /// <summary>
+        /// Calculates the difficulty of a <see cref="Beatmap"/> using a specific <see cref="Mod"/> combination.
+        /// </summary>
+        /// <param name="beatmap">The <see cref="IBeatmap"/> to compute the difficulty for.</param>
+        /// <param name="mods">The <see cref="Mod"/>s that should be applied.</param>
+        /// <param name="timeRate">The rate of time in <paramref name="beatmap"/>.</param>
+        /// <returns>A structure containing the difficulty attributes.</returns>
+        protected abstract DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double timeRate);
     }
 }
diff --git a/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs
index 07d9c80061..ba783ee87b 100644
--- a/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs
+++ b/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs
@@ -13,8 +13,7 @@ namespace osu.Game.Rulesets.Difficulty
 {
     public abstract class PerformanceCalculator
     {
-        private readonly Dictionary<string, double> attributes = new Dictionary<string, double>();
-        protected IDictionary<string, double> Attributes => attributes;
+        protected readonly DifficultyAttributes Attributes;
 
         protected readonly Ruleset Ruleset;
         protected readonly IBeatmap Beatmap;
@@ -22,14 +21,15 @@ namespace osu.Game.Rulesets.Difficulty
 
         protected double TimeRate { get; private set; } = 1;
 
-        protected PerformanceCalculator(Ruleset ruleset, IBeatmap beatmap, Score score)
+        protected PerformanceCalculator(Ruleset ruleset, WorkingBeatmap beatmap, Score score)
         {
             Ruleset = ruleset;
-            Beatmap = beatmap;
             Score = score;
 
-            var diffCalc = ruleset.CreateDifficultyCalculator(beatmap, score.Mods);
-            diffCalc.Calculate(attributes);
+            beatmap.Mods.Value = score.Mods;
+            Beatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo);
+
+            Attributes = ruleset.CreateDifficultyCalculator(beatmap).Calculate(score.Mods);
 
             ApplyMods(score.Mods);
         }
diff --git a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs
index 0f5490a182..f13d96b35e 100644
--- a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs
+++ b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs
@@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Replays
             return true;
         }
 
-        public override List<InputState> GetPendingStates() => new List<InputState>();
+        public override List<IInput> GetPendingInputs() => new List<IInput>();
 
         public bool AtLastFrame => currentFrameIndex == Frames.Count - 1;
         public bool AtFirstFrame => currentFrameIndex == 0;
@@ -119,7 +119,8 @@ namespace osu.Game.Rulesets.Replays
         {
             public ReplayKeyboardState(List<Key> keys)
             {
-                Keys = keys;
+                foreach (var key in keys)
+                    Keys.Add(key);
             }
         }
     }
diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs
index 395eeab419..f818523a3d 100644
--- a/osu.Game/Rulesets/Ruleset.cs
+++ b/osu.Game/Rulesets/Ruleset.cs
@@ -15,6 +15,8 @@ using osu.Game.Rulesets.Replays.Types;
 using osu.Game.Rulesets.Scoring;
 using osu.Game.Rulesets.UI;
 using osu.Game.Beatmaps.Legacy;
+using osu.Game.Configuration;
+using osu.Game.Rulesets.Configuration;
 using osu.Game.Rulesets.Difficulty;
 
 namespace osu.Game.Rulesets
@@ -59,9 +61,9 @@ namespace osu.Game.Rulesets
 
         public virtual IBeatmapProcessor CreateBeatmapProcessor(IBeatmap beatmap) => null;
 
-        public abstract DifficultyCalculator CreateDifficultyCalculator(IBeatmap beatmap, Mod[] mods = null);
+        public abstract DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap);
 
-        public virtual PerformanceCalculator CreatePerformanceCalculator(IBeatmap beatmap, Score score) => null;
+        public virtual PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, Score score) => null;
 
         public virtual HitObjectComposer CreateHitObjectComposer() => null;
 
@@ -69,7 +71,13 @@ namespace osu.Game.Rulesets
 
         public abstract string Description { get; }
 
-        public virtual SettingsSubsection CreateSettings() => null;
+        public virtual RulesetSettingsSubsection CreateSettings() => null;
+
+        /// <summary>
+        /// Creates the <see cref="IRulesetConfigManager"/> for this <see cref="Ruleset"/>.
+        /// </summary>
+        /// <param name="settings">The <see cref="SettingsStore"/> to store the settings.</param>
+        public virtual IRulesetConfigManager CreateConfig(SettingsStore settings) => null;
 
         /// <summary>
         /// Do not override this unless you are a legacy mode.
diff --git a/osu.Game/Rulesets/RulesetConfigCache.cs b/osu.Game/Rulesets/RulesetConfigCache.cs
new file mode 100644
index 0000000000..7e83ba0961
--- /dev/null
+++ b/osu.Game/Rulesets/RulesetConfigCache.cs
@@ -0,0 +1,43 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using System;
+using System.Collections.Generic;
+using osu.Framework.Graphics;
+using osu.Game.Configuration;
+using osu.Game.Rulesets.Configuration;
+
+namespace osu.Game.Rulesets
+{
+    /// <summary>
+    /// A cache that provides a single <see cref="IRulesetConfigManager"/> per-ruleset.
+    /// This is done to support referring to and updating ruleset configs from multiple locations in the absence of inter-config bindings.
+    /// </summary>
+    public class RulesetConfigCache : Component
+    {
+        private readonly Dictionary<int, IRulesetConfigManager> configCache = new Dictionary<int, IRulesetConfigManager>();
+        private readonly SettingsStore settingsStore;
+
+        public RulesetConfigCache(SettingsStore settingsStore)
+        {
+            this.settingsStore = settingsStore;
+        }
+
+        /// <summary>
+        /// Retrieves the <see cref="IRulesetConfigManager"/> for a <see cref="Ruleset"/>.
+        /// </summary>
+        /// <param name="ruleset">The <see cref="Ruleset"/> to retrieve the <see cref="IRulesetConfigManager"/> for.</param>
+        /// <returns>The <see cref="IRulesetConfigManager"/> defined by <paramref name="ruleset"/>, null if <paramref name="ruleset"/> doesn't define one.</returns>
+        /// <exception cref="InvalidOperationException">If <paramref name="ruleset"/> doesn't have a valid <see cref="RulesetInfo.ID"/>.</exception>
+        public IRulesetConfigManager GetConfigFor(Ruleset ruleset)
+        {
+            if (ruleset.RulesetInfo.ID == null)
+                throw new InvalidOperationException("The provided ruleset doesn't have a valid id.");
+
+            if (configCache.TryGetValue(ruleset.RulesetInfo.ID.Value, out var existing))
+                return existing;
+
+            return configCache[ruleset.RulesetInfo.ID.Value] = ruleset.CreateConfig(settingsStore);
+        }
+    }
+}
diff --git a/osu.Game/Rulesets/UI/RulesetContainer.cs b/osu.Game/Rulesets/UI/RulesetContainer.cs
index 384b71cccc..fedb6abed3 100644
--- a/osu.Game/Rulesets/UI/RulesetContainer.cs
+++ b/osu.Game/Rulesets/UI/RulesetContainer.cs
@@ -73,11 +73,6 @@ namespace osu.Game.Rulesets.UI
         private IRulesetConfigManager rulesetConfig;
         private OnScreenDisplay onScreenDisplay;
 
-        private DependencyContainer dependencies;
-
-        protected override IReadOnlyDependencyContainer CreateLocalDependencies(IReadOnlyDependencyContainer parent)
-            => dependencies = new DependencyContainer(base.CreateLocalDependencies(parent));
-
         /// <summary>
         /// A visual representation of a <see cref="Rulesets.Ruleset"/>.
         /// </summary>
@@ -90,18 +85,20 @@ namespace osu.Game.Rulesets.UI
             Cursor = CreateCursor();
         }
 
-        [BackgroundDependencyLoader(true)]
-        private void load(OnScreenDisplay onScreenDisplay, SettingsStore settings)
+        protected override IReadOnlyDependencyContainer CreateLocalDependencies(IReadOnlyDependencyContainer parent)
         {
-            this.onScreenDisplay = onScreenDisplay;
+            var dependencies = new DependencyContainer(base.CreateLocalDependencies(parent));
 
-            rulesetConfig = CreateConfig(Ruleset, settings);
+            onScreenDisplay = dependencies.Get<OnScreenDisplay>();
 
+            rulesetConfig = dependencies.Get<RulesetConfigCache>().GetConfigFor(Ruleset);
             if (rulesetConfig != null)
             {
                 dependencies.Cache(rulesetConfig);
                 onScreenDisplay?.BeginTracking(this, rulesetConfig);
             }
+
+            return dependencies;
         }
 
         public abstract ScoreProcessor CreateScoreProcessor();
@@ -136,8 +133,6 @@ namespace osu.Game.Rulesets.UI
         /// </summary>
         protected virtual CursorContainer CreateCursor() => null;
 
-        protected virtual IRulesetConfigManager CreateConfig(Ruleset ruleset, SettingsStore settings) => null;
-
         /// <summary>
         /// Creates a Playfield.
         /// </summary>
@@ -160,7 +155,7 @@ namespace osu.Game.Rulesets.UI
     /// RulesetContainer that applies conversion to Beatmaps. Does not contain a Playfield
     /// and does not load drawable hit objects.
     /// <para>
-    /// Should not be derived - derive <see cref="RulesetContainer{TObject}"/> instead.
+    /// Should not be derived - derive <see cref="RulesetContainer{TPlayfield, TObject}"/> instead.
     /// </para>
     /// </summary>
     /// <typeparam name="TObject">The type of HitObject contained by this RulesetContainer.</typeparam>
diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs
index 58a66a5224..f8c4fff5b8 100644
--- a/osu.Game/Rulesets/UI/RulesetInputManager.cs
+++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs
@@ -15,6 +15,7 @@ using osu.Game.Input.Bindings;
 using osu.Game.Input.Handlers;
 using osu.Game.Screens.Play;
 using OpenTK.Input;
+using static osu.Game.Input.Handlers.ReplayInputHandler;
 
 namespace osu.Game.Rulesets.UI
 {
@@ -29,6 +30,18 @@ namespace osu.Game.Rulesets.UI
             }
         }
 
+        protected override InputState CreateInitialState()
+        {
+            var state = base.CreateInitialState();
+            return new RulesetInputManagerInputState<T>
+            {
+                Mouse = state.Mouse,
+                Keyboard = state.Keyboard,
+                Joystick = state.Joystick,
+                LastReplayState = null
+            };
+        }
+
         protected readonly KeyBindingContainer<T> KeyBindingContainer;
 
         protected override Container<Drawable> Content => KeyBindingContainer;
@@ -42,13 +55,18 @@ namespace osu.Game.Rulesets.UI
 
         private List<T> lastPressedActions = new List<T>();
 
-        protected override void HandleNewState(InputState state)
+        public override void HandleCustomInput(InputState state, IInput input)
         {
-            base.HandleNewState(state);
+            if (!(input is ReplayState<T> replayState))
+            {
+                base.HandleCustomInput(state, input);
+                return;
+            }
 
-            var replayState = state as ReplayInputHandler.ReplayState<T>;
-
-            if (replayState == null) return;
+            if (state is RulesetInputManagerInputState<T> inputState)
+            {
+                inputState.LastReplayState = replayState;
+            }
 
             // Here we handle states specifically coming from a replay source.
             // These have extra action information rather than keyboard keys or mouse buttons.
@@ -80,7 +98,7 @@ namespace osu.Game.Rulesets.UI
                 if (replayInputHandler != null) RemoveHandler(replayInputHandler);
 
                 replayInputHandler = value;
-                UseParentState = replayInputHandler == null;
+                UseParentInput = replayInputHandler == null;
 
                 if (replayInputHandler != null)
                     AddHandler(replayInputHandler);
@@ -123,7 +141,7 @@ namespace osu.Game.Rulesets.UI
 
         protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate && validState;
 
-        private bool isAttached => replayInputHandler != null && !UseParentState;
+        private bool isAttached => replayInputHandler != null && !UseParentInput;
 
         private const int max_catch_up_updates_per_frame = 50;
 
@@ -267,4 +285,10 @@ namespace osu.Game.Rulesets.UI
     {
         void Attach(KeyCounterCollection keyCounter);
     }
+
+    public class RulesetInputManagerInputState<T> : InputState
+    where T : struct
+    {
+        public ReplayState<T> LastReplayState;
+    }
 }
diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs
index 6f86d20295..830214803c 100644
--- a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs
+++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs
@@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.UI.Scrolling
         /// <summary>
         /// The step increase/decrease of the span of time visible by the length of the scrolling axes.
         /// </summary>
-        private const double time_span_step = 50;
+        private const double time_span_step = 200;
 
         /// <summary>
         /// The span of time that is visible by the length of the scrolling axes.
@@ -88,10 +88,10 @@ namespace osu.Game.Rulesets.UI.Scrolling
                 switch (args.Key)
                 {
                     case Key.Minus:
-                        this.TransformBindableTo(VisibleTimeRange, VisibleTimeRange + time_span_step, 200, Easing.OutQuint);
+                        this.TransformBindableTo(VisibleTimeRange, VisibleTimeRange + time_span_step, 600, Easing.OutQuint);
                         break;
                     case Key.Plus:
-                        this.TransformBindableTo(VisibleTimeRange, VisibleTimeRange - time_span_step, 200, Easing.OutQuint);
+                        this.TransformBindableTo(VisibleTimeRange, VisibleTimeRange - time_span_step, 600, Easing.OutQuint);
                         break;
                 }
             }
diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs
index d4f66c2f09..7eeabd3e5e 100644
--- a/osu.Game/Screens/Edit/Editor.cs
+++ b/osu.Game/Screens/Edit/Editor.cs
@@ -13,6 +13,7 @@ using osu.Game.Screens.Edit.Components.Timelines.Summary;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics.UserInterface;
 using osu.Framework.Input;
+using osu.Framework.Platform;
 using osu.Framework.Timing;
 using osu.Game.Graphics.UserInterface;
 using osu.Game.Screens.Edit.Screens;
@@ -39,13 +40,16 @@ namespace osu.Game.Screens.Edit
         private EditorClock clock;
 
         private DependencyContainer dependencies;
+        private GameHost host;
 
         protected override IReadOnlyDependencyContainer CreateLocalDependencies(IReadOnlyDependencyContainer parent)
             => dependencies = new DependencyContainer(base.CreateLocalDependencies(parent));
 
         [BackgroundDependencyLoader]
-        private void load(OsuColour colours)
+        private void load(OsuColour colours, GameHost host)
         {
+            this.host = host;
+
             // TODO: should probably be done at a RulesetContainer level to share logic with Player.
             var sourceClock = (IAdjustableClock)Beatmap.Value.Track ?? new StopwatchClock();
             clock = new EditorClock(Beatmap.Value, beatDivisor) { IsCoupled = false };
@@ -155,7 +159,7 @@ namespace osu.Game.Screens.Edit
 
         private void exportBeatmap()
         {
-            Beatmap.Value.Save();
+            host.OpenFileExternally(Beatmap.Value.Save());
         }
 
         private void onModeChanged(EditorScreenMode mode)
@@ -181,9 +185,9 @@ namespace osu.Game.Screens.Edit
         protected override bool OnScroll(InputState state)
         {
             if (state.Mouse.ScrollDelta.X + state.Mouse.ScrollDelta.Y > 0)
-                clock.SeekBackward(true);
+                clock.SeekBackward(!clock.IsRunning);
             else
-                clock.SeekForward(true);
+                clock.SeekForward(!clock.IsRunning);
             return true;
         }
 
diff --git a/osu.Game/Screens/Edit/Screens/Compose/Timeline/CentreMarker.cs b/osu.Game/Screens/Edit/Screens/Compose/Timeline/CentreMarker.cs
new file mode 100644
index 0000000000..8e932f307d
--- /dev/null
+++ b/osu.Game/Screens/Edit/Screens/Compose/Timeline/CentreMarker.cs
@@ -0,0 +1,52 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Game.Graphics;
+using OpenTK;
+
+namespace osu.Game.Screens.Edit.Screens.Compose.Timeline
+{
+    public class CentreMarker : CompositeDrawable
+    {
+        private const float triangle_width = 20;
+        private const float triangle_height = 10;
+        private const float bar_width = 2;
+
+        public CentreMarker()
+        {
+            RelativeSizeAxes = Axes.Y;
+            Size = new Vector2(20, 1);
+
+            Anchor = Anchor.Centre;
+            Origin = Anchor.Centre;
+
+            InternalChildren = new Drawable[]
+            {
+                new Box
+                {
+                    Anchor = Anchor.Centre,
+                    Origin = Anchor.Centre,
+                    RelativeSizeAxes = Axes.Y,
+                    Width = bar_width,
+                },
+                new Triangle
+                {
+                    Anchor = Anchor.TopCentre,
+                    Origin = Anchor.BottomCentre,
+                    Size = new Vector2(triangle_width, triangle_height),
+                    Scale = new Vector2(1, -1)
+                }
+            };
+        }
+
+        [BackgroundDependencyLoader]
+        private void load(OsuColour colours)
+        {
+            Colour = colours.Red;
+        }
+    }
+}
diff --git a/osu.Game/Screens/Edit/Screens/Compose/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Screens/Compose/Timeline/Timeline.cs
index 3649b24cd0..e993d36551 100644
--- a/osu.Game/Screens/Edit/Screens/Compose/Timeline/Timeline.cs
+++ b/osu.Game/Screens/Edit/Screens/Compose/Timeline/Timeline.cs
@@ -2,9 +2,13 @@
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
 using osu.Framework.Allocation;
+using osu.Framework.Audio.Track;
 using osu.Framework.Configuration;
+using osu.Framework.Extensions.Color4Extensions;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Audio;
+using osu.Framework.Input;
+using osu.Framework.Timing;
 using osu.Game.Beatmaps;
 using osu.Game.Graphics;
 
@@ -15,25 +19,36 @@ namespace osu.Game.Screens.Edit.Screens.Compose.Timeline
         public readonly Bindable<bool> WaveformVisible = new Bindable<bool>();
         public readonly IBindable<WorkingBeatmap> Beatmap = new Bindable<WorkingBeatmap>();
 
+        private IAdjustableClock adjustableClock;
+
         public Timeline()
         {
             ZoomDuration = 200;
             ZoomEasing = Easing.OutQuint;
             Zoom = 10;
+            ScrollbarVisible = false;
         }
 
         private WaveformGraph waveform;
 
         [BackgroundDependencyLoader]
-        private void load(IBindableBeatmap beatmap)
+        private void load(IBindableBeatmap beatmap, IAdjustableClock adjustableClock, OsuColour colours)
         {
+            this.adjustableClock = adjustableClock;
+
             Child = waveform = new WaveformGraph
             {
                 RelativeSizeAxes = Axes.Both,
-                Colour = OsuColour.FromHex("222"),
+                Colour = colours.Blue.Opacity(0.2f),
+                LowColour = colours.BlueLighter,
+                MidColour = colours.BlueDark,
+                HighColour = colours.BlueDarker,
                 Depth = float.MaxValue
             };
 
+            // We don't want the centre marker to scroll
+            AddInternal(new CentreMarker());
+
             WaveformVisible.ValueChanged += visible => waveform.FadeTo(visible ? 1 : 0, 200, Easing.OutQuint);
 
             Beatmap.BindTo(beatmap);
@@ -46,12 +61,102 @@ namespace osu.Game.Screens.Edit.Screens.Compose.Timeline
             waveform.Waveform = Beatmap.Value.Waveform;
         }
 
+        /// <summary>
+        /// The track's time in the previous frame.
+        /// </summary>
+        private double lastTrackTime;
+
+        /// <summary>
+        /// Whether the user is currently dragging the timeline.
+        /// </summary>
+        private bool handlingDragInput;
+
+        /// <summary>
+        /// Whether the track was playing before a user drag event.
+        /// </summary>
+        private bool trackWasPlaying;
+
         protected override void Update()
         {
             base.Update();
 
-            // We want time = 0 to be at the centre of the container when scrolled to the start
+            // The extrema of track time should be positioned at the centre of the container when scrolled to the start or end
             Content.Margin = new MarginPadding { Horizontal = DrawWidth / 2 };
+
+            if (handlingDragInput)
+            {
+                // The user is dragging - the track should always follow the timeline
+                seekTrackToCurrent();
+            }
+            else if (adjustableClock.IsRunning)
+            {
+                // If the user hasn't provided mouse input but the track is running, always follow the track
+                scrollToTrackTime();
+            }
+            else
+            {
+                // The track isn't playing, so we want to smooth-scroll once more, and re-enable wheel scrolling
+                // There are two cases we have to be wary of:
+                // 1) The user scrolls on this timeline: We want the track to follow us
+                // 2) The user changes the track time through some other means (scrolling in the editor or overview timeline): We want to follow the track time
+
+                // The simplest way to cover both cases is by checking that inter-frame track times are identical
+                if (adjustableClock.CurrentTime == lastTrackTime)
+                {
+                    // The track hasn't been seeked externally
+                    seekTrackToCurrent();
+                }
+                else
+                {
+                    // The track has been seeked externally
+                    scrollToTrackTime();
+                }
+            }
+
+            lastTrackTime = adjustableClock.CurrentTime;
+
+            void seekTrackToCurrent()
+            {
+                if (!(Beatmap.Value.Track is TrackVirtual))
+                    adjustableClock.Seek(Current / Content.DrawWidth * Beatmap.Value.Track.Length);
+            }
+
+            void scrollToTrackTime()
+            {
+                if (!(Beatmap.Value.Track is TrackVirtual))
+                    ScrollTo((float)(adjustableClock.CurrentTime / Beatmap.Value.Track.Length) * Content.DrawWidth, false);
+            }
+        }
+
+        protected override bool OnMouseDown(InputState state, MouseDownEventArgs args)
+        {
+            if (base.OnMouseDown(state, args))
+            {
+                beginUserDrag();
+                return true;
+            }
+
+            return false;
+        }
+
+        protected override bool OnMouseUp(InputState state, MouseUpEventArgs args)
+        {
+            endUserDrag();
+            return base.OnMouseUp(state, args);
+        }
+
+        private void beginUserDrag()
+        {
+            handlingDragInput = true;
+            trackWasPlaying = adjustableClock.IsRunning;
+            adjustableClock.Stop();
+        }
+
+        private void endUserDrag()
+        {
+            handlingDragInput = false;
+            if (trackWasPlaying)
+                adjustableClock.Start();
         }
     }
 }
diff --git a/osu.Game/Screens/Edit/Screens/Compose/Timeline/ZoomableScrollContainer.cs b/osu.Game/Screens/Edit/Screens/Compose/Timeline/ZoomableScrollContainer.cs
index 035e6a0804..0bfea68e50 100644
--- a/osu.Game/Screens/Edit/Screens/Compose/Timeline/ZoomableScrollContainer.cs
+++ b/osu.Game/Screens/Edit/Screens/Compose/Timeline/ZoomableScrollContainer.cs
@@ -99,10 +99,11 @@ namespace osu.Game.Screens.Edit.Screens.Compose.Timeline
 
         protected override bool OnScroll(InputState state)
         {
-            if (!state.Keyboard.ControlPressed)
+            if (state.Mouse.HasPreciseScroll)
+                // for now, we don't support zoom when using a precision scroll device. this needs gesture support.
                 return base.OnScroll(state);
 
-            setZoomTarget(zoomTarget + state.Mouse.ScrollDelta.X, zoomedContent.ToLocalSpace(state.Mouse.NativeState.Position).X);
+            setZoomTarget(zoomTarget + state.Mouse.ScrollDelta.Y, zoomedContent.ToLocalSpace(state.Mouse.NativeState.Position).X);
             return true;
         }
 
diff --git a/osu.Game/Screens/Multi/Screens/Lounge/Lounge.cs b/osu.Game/Screens/Multi/Screens/Lounge/Lounge.cs
index 016babcaa5..51dea355bf 100644
--- a/osu.Game/Screens/Multi/Screens/Lounge/Lounge.cs
+++ b/osu.Game/Screens/Multi/Screens/Lounge/Lounge.cs
@@ -164,7 +164,7 @@ namespace osu.Game.Screens.Multi.Screens.Lounge
 
             // open the room if its selected and is clicked again
             if (room.State == SelectionState.Selected)
-                Push(new Match());
+                Push(new Match.Match(room.Room));
         }
 
         private class RoomsFilterContainer : FillFlowContainer<DrawableRoom>, IHasFilterableChildren
diff --git a/osu.Game/Screens/Multi/Screens/Match.cs b/osu.Game/Screens/Multi/Screens/Match.cs
deleted file mode 100644
index 4ba7fe9f6a..0000000000
--- a/osu.Game/Screens/Multi/Screens/Match.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
-// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
-
-using System;
-using System.Collections.Generic;
-using osu.Framework.Graphics;
-using osu.Framework.Screens;
-using osu.Game.Screens.Backgrounds;
-using osu.Game.Screens.Play;
-using osu.Game.Screens.Select;
-using OpenTK.Graphics;
-
-namespace osu.Game.Screens.Multi.Screens
-{
-    public class Match : ScreenWhiteBox
-    {
-        protected override IEnumerable<Type> PossibleChildren => new[] {
-            typeof(MatchSongSelect),
-            typeof(Player),
-        };
-
-        protected override BackgroundScreen CreateBackground() => new BackgroundScreenCustom(@"Backgrounds/bg4");
-
-        protected override void OnEntering(Screen last)
-        {
-            base.OnEntering(last);
-
-            Background.FadeColour(Color4.DarkGray, 500);
-        }
-
-        protected override bool OnExiting(Screen next)
-        {
-            Background.FadeColour(Color4.White, 500);
-            return base.OnExiting(next);
-        }
-    }
-}
diff --git a/osu.Game/Screens/Multi/Screens/Match/Header.cs b/osu.Game/Screens/Multi/Screens/Match/Header.cs
new file mode 100644
index 0000000000..02e717d4be
--- /dev/null
+++ b/osu.Game/Screens/Multi/Screens/Match/Header.cs
@@ -0,0 +1,184 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using System;
+using osu.Framework.Allocation;
+using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Colour;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Input;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.Drawables;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Overlays.SearchableList;
+using OpenTK.Graphics;
+
+namespace osu.Game.Screens.Multi.Screens.Match
+{
+    public class Header : Container
+    {
+        public const float HEIGHT = 200;
+
+        private readonly Box tabStrip;
+        private readonly UpdateableBeatmapSetCover cover;
+
+        public readonly PageTabControl<MatchHeaderPage> Tabs;
+
+        public BeatmapSetInfo BeatmapSet
+        {
+            set => cover.BeatmapSet = value;
+        }
+
+        public Action OnRequestSelectBeatmap;
+
+        public Header()
+        {
+            RelativeSizeAxes = Axes.X;
+            Height = HEIGHT;
+
+            BeatmapSelectButton beatmapButton;
+            Children = new Drawable[]
+            {
+                cover = new UpdateableBeatmapSetCover
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Masking = true,
+                },
+                new Box
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0), Color4.Black.Opacity(0.5f)),
+                },
+                tabStrip = new Box
+                {
+                    Anchor = Anchor.BottomLeft,
+                    Origin = Anchor.BottomLeft,
+                    RelativeSizeAxes = Axes.X,
+                    Height = 1,
+                },
+                new Container
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING },
+                    Children = new Drawable[]
+                    {
+                        new Container
+                        {
+                            Anchor = Anchor.TopRight,
+                            Origin = Anchor.TopRight,
+                            RelativeSizeAxes = Axes.Y,
+                            Width = 200,
+                            Padding = new MarginPadding { Vertical = 5 },
+                            Child = beatmapButton = new BeatmapSelectButton
+                            {
+                                RelativeSizeAxes = Axes.Both,
+                            },
+                        },
+                        Tabs = new PageTabControl<MatchHeaderPage>
+                        {
+                            Anchor = Anchor.BottomLeft,
+                            Origin = Anchor.BottomLeft,
+                            RelativeSizeAxes = Axes.X,
+                        },
+                    },
+                },
+            };
+
+            beatmapButton.Action = () => OnRequestSelectBeatmap?.Invoke();
+        }
+
+        [BackgroundDependencyLoader]
+        private void load(OsuColour colours)
+        {
+            tabStrip.Colour = colours.Yellow;
+        }
+
+        private class BeatmapSelectButton : OsuClickableContainer
+        {
+            private const float corner_radius = 5;
+            private const float bg_opacity = 0.5f;
+            private const float transition_duration = 100;
+
+            private readonly Box bg;
+            private readonly Container border;
+
+            public BeatmapSelectButton()
+            {
+                Masking = true;
+                CornerRadius = corner_radius;
+
+                Children = new Drawable[]
+                {
+                    bg = new Box
+                    {
+                        RelativeSizeAxes = Axes.Both,
+                        Colour = Color4.Black,
+                        Alpha = bg_opacity,
+                    },
+                    new OsuSpriteText
+                    {
+                        Anchor = Anchor.Centre,
+                        Origin = Anchor.Centre,
+                        Font = @"Exo2.0-Bold",
+                        Text = "Select Beatmap",
+                    },
+                    border = new Container
+                    {
+                        RelativeSizeAxes = Axes.Both,
+                        Masking = true,
+                        CornerRadius = corner_radius,
+                        BorderThickness = 4,
+                        Alpha = 0,
+                        Child = new Box // needs a child to show the border
+                        {
+                            RelativeSizeAxes = Axes.Both,
+                            Alpha = 0,
+                            AlwaysPresent = true
+                        },
+                    },
+                };
+            }
+
+            [BackgroundDependencyLoader]
+            private void load(OsuColour colours)
+            {
+                border.BorderColour = colours.Yellow;
+            }
+
+            protected override bool OnHover(InputState state)
+            {
+                border.FadeIn(transition_duration);
+                return base.OnHover(state);
+            }
+
+            protected override void OnHoverLost(InputState state)
+            {
+                base.OnHoverLost(state);
+                border.FadeOut(transition_duration);
+            }
+
+            protected override bool OnMouseDown(InputState state, MouseDownEventArgs args)
+            {
+                bg.FadeTo(0.75f, 1000, Easing.Out);
+                return base.OnMouseDown(state, args);
+            }
+
+            protected override bool OnMouseUp(InputState state, MouseUpEventArgs args)
+            {
+                bg.FadeTo(bg_opacity, transition_duration);
+                return base.OnMouseUp(state, args);
+            }
+        }
+    }
+
+    public enum MatchHeaderPage
+    {
+        Settings,
+        Room,
+    }
+}
diff --git a/osu.Game/Screens/Multi/Screens/Match/Info.cs b/osu.Game/Screens/Multi/Screens/Match/Info.cs
new file mode 100644
index 0000000000..ec93eb90b1
--- /dev/null
+++ b/osu.Game/Screens/Multi/Screens/Match/Info.cs
@@ -0,0 +1,210 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using osu.Framework.Allocation;
+using osu.Framework.Configuration;
+using osu.Framework.Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Beatmaps;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Online.Multiplayer;
+using osu.Game.Overlays.SearchableList;
+using osu.Game.Screens.Multi.Components;
+using OpenTK;
+
+namespace osu.Game.Screens.Multi.Screens.Match
+{
+    public class Info : Container
+    {
+        public const float HEIGHT = 128;
+
+        private readonly OsuSpriteText name, availabilityStatus;
+        private readonly BeatmapTypeInfo beatmapTypeInfo;
+        private readonly ReadyButton readyButton;
+
+        private OsuColour colours;
+
+        public Bindable<bool> Ready => readyButton.Ready;
+
+        public string Name
+        {
+            set { name.Text = value; }
+        }
+
+        private RoomAvailability availability;
+        public RoomAvailability Availability
+        {
+            set
+            {
+                if (value == availability) return;
+                availability = value;
+
+                if (IsLoaded)
+                    updateAvailabilityStatus();
+            }
+        }
+
+        private RoomStatus status;
+        public RoomStatus Status
+        {
+            set
+            {
+                if (value == status) return;
+                status = value;
+
+                if (IsLoaded)
+                    updateAvailabilityStatus();
+            }
+        }
+
+        public BeatmapInfo Beatmap
+        {
+            set { beatmapTypeInfo.Beatmap = value; }
+        }
+
+        public GameType Type
+        {
+            set { beatmapTypeInfo.Type = value; }
+        }
+
+        public Info()
+        {
+            RelativeSizeAxes = Axes.X;
+            Height = HEIGHT;
+
+            Children = new Drawable[]
+            {
+                new Box
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Colour = OsuColour.FromHex(@"28242d"),
+                },
+                new Container
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING },
+                    Children = new Drawable[]
+                    {
+                        new Container
+                        {
+                            RelativeSizeAxes = Axes.Y,
+                            AutoSizeAxes = Axes.X,
+                            Padding = new MarginPadding { Vertical = 20 },
+                            Children = new Drawable[]
+                            {
+                                new FillFlowContainer
+                                {
+                                    AutoSizeAxes = Axes.Both,
+                                    Direction = FillDirection.Vertical,
+                                    Children = new Drawable[]
+                                    {
+                                        name = new OsuSpriteText
+                                        {
+                                            TextSize = 30,
+                                        },
+                                        availabilityStatus = new OsuSpriteText
+                                        {
+                                            TextSize = 14,
+                                        },
+                                    },
+                                },
+                                beatmapTypeInfo = new BeatmapTypeInfo
+                                {
+                                    Anchor = Anchor.BottomLeft,
+                                    Origin = Anchor.BottomLeft,
+                                },
+                            },
+                        },
+                        readyButton = new ReadyButton
+                        {
+                            Anchor = Anchor.TopRight,
+                            Origin = Anchor.TopRight,
+                            RelativeSizeAxes = Axes.Y,
+                            Size = new Vector2(200, 1),
+                            Padding = new MarginPadding { Vertical = 10 },
+                        },
+                    },
+                },
+            };
+        }
+
+        [BackgroundDependencyLoader]
+        private void load(OsuColour colours)
+        {
+            this.colours = colours;
+        }
+
+        protected override void LoadComplete()
+        {
+            base.LoadComplete();
+
+            updateAvailabilityStatus();
+        }
+
+        private void updateAvailabilityStatus()
+        {
+            if (status != null)
+            {
+                availabilityStatus.FadeColour(status.GetAppropriateColour(colours), 100);
+                availabilityStatus.Text = $"{availability.GetDescription()}, {status.Message}";
+            }
+        }
+
+        private class ReadyButton : TriangleButton
+        {
+            public readonly Bindable<bool> Ready = new Bindable<bool>();
+
+            protected override SpriteText CreateText() => new OsuSpriteText
+            {
+                Depth = -1,
+                Origin = Anchor.Centre,
+                Anchor = Anchor.Centre,
+                Font = @"Exo2.0-Light",
+                TextSize = 30,
+            };
+
+            [BackgroundDependencyLoader]
+            private void load()
+            {
+                BackgroundColour = OsuColour.FromHex(@"1187aa");
+                Triangles.ColourLight = OsuColour.FromHex(@"277b9c");
+                Triangles.ColourDark = OsuColour.FromHex(@"1f6682");
+                Triangles.TriangleScale = 1.5f;
+
+                Container active;
+                Add(active = new Container
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Alpha = 0f,
+                    Child = new Box
+                    {
+                        RelativeSizeAxes = Axes.Both,
+                        Alpha = 0.15f,
+                        Blending = BlendingMode.Additive,
+                    },
+                });
+
+                Action = () => Ready.Value = !Ready.Value;
+
+                Ready.BindValueChanged(value =>
+                {
+                    if (value)
+                    {
+                        Text = "Not Ready";
+                        active.FadeIn(200);
+                    }
+                    else
+                    {
+                        Text = "Ready";
+                        active.FadeOut(200);
+                    }
+                }, true);
+            }
+        }
+    }
+}
diff --git a/osu.Game/Screens/Multi/Screens/Match/Match.cs b/osu.Game/Screens/Multi/Screens/Match/Match.cs
new file mode 100644
index 0000000000..ce3f7825a4
--- /dev/null
+++ b/osu.Game/Screens/Multi/Screens/Match/Match.cs
@@ -0,0 +1,81 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using System.Collections.Generic;
+using osu.Framework.Configuration;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Beatmaps;
+using osu.Game.Online.Multiplayer;
+using osu.Game.Screens.Select;
+using osu.Game.Users;
+
+namespace osu.Game.Screens.Multi.Screens.Match
+{
+    public class Match : MultiplayerScreen
+    {
+        private readonly Room room;
+        private readonly Participants participants;
+
+        private readonly Bindable<string> nameBind = new Bindable<string>();
+        private readonly Bindable<RoomStatus> statusBind = new Bindable<RoomStatus>();
+        private readonly Bindable<RoomAvailability> availabilityBind = new Bindable<RoomAvailability>();
+        private readonly Bindable<GameType> typeBind = new Bindable<GameType>();
+        private readonly Bindable<BeatmapInfo> beatmapBind = new Bindable<BeatmapInfo>();
+        private readonly Bindable<int?> maxParticipantsBind = new Bindable<int?>();
+        private readonly Bindable<IEnumerable<User>> participantsBind = new Bindable<IEnumerable<User>>();
+
+        protected override Container<Drawable> TransitionContent => participants;
+
+        public override string Type => "room";
+        public override string Title => room.Name.Value;
+
+        public Match(Room room)
+        {
+            this.room = room;
+            Header header;
+            Info info;
+
+            Children = new Drawable[]
+            {
+                header = new Header(),
+                info = new Info
+                {
+                    Margin = new MarginPadding { Top = Header.HEIGHT },
+                },
+                participants = new Participants
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Padding = new MarginPadding { Top = Header.HEIGHT + Info.HEIGHT },
+                },
+            };
+
+            header.OnRequestSelectBeatmap = () => Push(new MatchSongSelect());
+
+            beatmapBind.BindTo(room.Beatmap);
+            beatmapBind.BindValueChanged(b =>
+            {
+                header.BeatmapSet = b?.BeatmapSet;
+                info.Beatmap = b;
+            }, true);
+
+            nameBind.BindTo(room.Name);
+            nameBind.BindValueChanged(n => info.Name = n, true);
+
+            statusBind.BindTo(room.Status);
+            statusBind.BindValueChanged(s => info.Status = s, true);
+
+            availabilityBind.BindTo(room.Availability);
+            availabilityBind.BindValueChanged(a => info.Availability = a, true);
+
+            typeBind.BindTo(room.Type);
+            typeBind.BindValueChanged(t => info.Type = t, true);
+
+            maxParticipantsBind.BindTo(room.MaxParticipants);
+            maxParticipantsBind.BindValueChanged(m => { participants.Max = m; }, true);
+
+            participantsBind.BindTo(room.Participants);
+            participantsBind.BindValueChanged(p => participants.Users = p, true);
+        }
+    }
+}
diff --git a/osu.Game/Screens/Multi/Screens/Match/Participants.cs b/osu.Game/Screens/Multi/Screens/Match/Participants.cs
new file mode 100644
index 0000000000..9fa90f8752
--- /dev/null
+++ b/osu.Game/Screens/Multi/Screens/Match/Participants.cs
@@ -0,0 +1,74 @@
+// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
+// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
+
+using System.Collections.Generic;
+using System.Linq;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Overlays.SearchableList;
+using osu.Game.Screens.Multi.Components;
+using osu.Game.Users;
+using OpenTK;
+
+namespace osu.Game.Screens.Multi.Screens.Match
+{
+    public class Participants : Container
+    {
+        private readonly ParticipantCount count;
+        private readonly FillFlowContainer<UserPanel> usersFlow;
+
+        public IEnumerable<User> Users
+        {
+            set {
+                usersFlow.Children = value.Select(u => new UserPanel(u)
+                {
+                    Anchor = Anchor.TopCentre,
+                    Origin = Anchor.TopCentre,
+                    Width = 300,
+                    OnLoadComplete = d => d.FadeInFromZero(60),
+                }).ToList();
+
+                count.Count = value.Count();
+            }
+        }
+
+        public int? Max
+        {
+            set => count.Max = value;
+        }
+
+        public Participants()
+        {
+            Child = new Container
+            {
+                RelativeSizeAxes = Axes.Both,
+                Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING },
+                Children = new Drawable[]
+                {
+                    new ScrollContainer
+                    {
+                        RelativeSizeAxes = Axes.Both,
+                        Padding = new MarginPadding { Top = 10 },
+                        Children = new Drawable[]
+                        {
+                            count = new ParticipantCount
+                            {
+                                Anchor = Anchor.TopRight,
+                                Origin = Anchor.TopRight,
+                            },
+                            usersFlow = new FillFlowContainer<UserPanel>
+                            {
+                                RelativeSizeAxes = Axes.X,
+                                AutoSizeAxes = Axes.Y,
+                                Spacing = new Vector2(5),
+                                Padding = new MarginPadding { Top = 40 },
+                                LayoutDuration = 200,
+                                LayoutEasing = Easing.OutQuint,
+                            },
+                        },
+                    },
+                },
+            };
+        }
+    }
+}
diff --git a/osu.Game/Screens/Multi/Screens/MatchCreate.cs b/osu.Game/Screens/Multi/Screens/MatchCreate.cs
deleted file mode 100644
index 6b4e26d5e5..0000000000
--- a/osu.Game/Screens/Multi/Screens/MatchCreate.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
-// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
-
-using System;
-using System.Collections.Generic;
-
-namespace osu.Game.Screens.Multi.Screens
-{
-    public class MatchCreate : ScreenWhiteBox
-    {
-        protected override IEnumerable<Type> PossibleChildren => new[] {
-                typeof(Match)
-        };
-
-        public MatchCreate()
-        {
-            ValidForResume = false;
-        }
-    }
-}
diff --git a/osu.Game/Screens/Multi/Screens/MultiplayerScreen.cs b/osu.Game/Screens/Multi/Screens/MultiplayerScreen.cs
index fa9b40684c..00c2613d54 100644
--- a/osu.Game/Screens/Multi/Screens/MultiplayerScreen.cs
+++ b/osu.Game/Screens/Multi/Screens/MultiplayerScreen.cs
@@ -10,9 +10,6 @@ namespace osu.Game.Screens.Multi.Screens
 {
     public abstract class MultiplayerScreen : OsuScreen
     {
-        private const Easing in_easing = Easing.OutQuint;
-        private const Easing out_easing = Easing.InSine;
-
         protected virtual Container<Drawable> TransitionContent => Content;
 
         /// <summary>
@@ -24,16 +21,15 @@ namespace osu.Game.Screens.Multi.Screens
         {
             base.OnEntering(last);
 
-            TransitionContent.MoveToX(200);
-
-            TransitionContent.FadeInFromZero(WaveContainer.APPEAR_DURATION, in_easing);
-            TransitionContent.MoveToX(0, WaveContainer.APPEAR_DURATION, in_easing);
+            Content.FadeInFromZero(WaveContainer.APPEAR_DURATION, Easing.OutQuint);
+            TransitionContent.FadeInFromZero(WaveContainer.APPEAR_DURATION, Easing.OutQuint);
+            TransitionContent.MoveToX(200).MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint);
         }
 
         protected override bool OnExiting(Screen next)
         {
-            Content.FadeOut(WaveContainer.DISAPPEAR_DURATION, out_easing);
-            TransitionContent.MoveToX(200, WaveContainer.DISAPPEAR_DURATION, out_easing);
+            Content.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.OutQuint);
+            TransitionContent.MoveToX(200, WaveContainer.DISAPPEAR_DURATION, Easing.OutQuint);
 
             return base.OnExiting(next);
         }
@@ -42,16 +38,16 @@ namespace osu.Game.Screens.Multi.Screens
         {
             base.OnResuming(last);
 
-            Content.FadeIn(WaveContainer.APPEAR_DURATION, in_easing);
-            TransitionContent.MoveToX(0, WaveContainer.APPEAR_DURATION, in_easing);
+            Content.FadeIn(WaveContainer.APPEAR_DURATION, Easing.OutQuint);
+            TransitionContent.MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint);
         }
 
         protected override void OnSuspending(Screen next)
         {
             base.OnSuspending(next);
 
-            Content.FadeOut(WaveContainer.DISAPPEAR_DURATION, out_easing);
-            TransitionContent.MoveToX(-200, WaveContainer.DISAPPEAR_DURATION, out_easing);
+            Content.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.OutQuint);
+            TransitionContent.MoveToX(-200, WaveContainer.DISAPPEAR_DURATION, Easing.OutQuint);
         }
     }
 }
diff --git a/osu.Game/Screens/Play/HUD/QuitButton.cs b/osu.Game/Screens/Play/HUD/QuitButton.cs
index d0aa0dad92..29382c25f3 100644
--- a/osu.Game/Screens/Play/HUD/QuitButton.cs
+++ b/osu.Game/Screens/Play/HUD/QuitButton.cs
@@ -2,6 +2,7 @@
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
 using System;
+using System.Linq;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics;
 using osu.Framework.Graphics.Containers;
@@ -182,14 +183,14 @@ namespace osu.Game.Screens.Play.HUD
 
             protected override bool OnMouseDown(InputState state, MouseDownEventArgs args)
             {
-                if (!pendingAnimation && state.Mouse.Buttons.Count == 1)
+                if (!pendingAnimation && state.Mouse.Buttons.Count() == 1)
                     BeginConfirm();
                 return true;
             }
 
             protected override bool OnMouseUp(InputState state, MouseUpEventArgs args)
             {
-                if (state.Mouse.Buttons.Count == 0)
+                if (!state.Mouse.Buttons.Any())
                     AbortConfirm();
                 return true;
             }
diff --git a/osu.Game/Screens/Play/KeyCounterCollection.cs b/osu.Game/Screens/Play/KeyCounterCollection.cs
index 8cbb9986e5..114ea83ba6 100644
--- a/osu.Game/Screens/Play/KeyCounterCollection.cs
+++ b/osu.Game/Screens/Play/KeyCounterCollection.cs
@@ -18,7 +18,8 @@ namespace osu.Game.Screens.Play
     {
         private const int duration = 100;
 
-        private Bindable<bool> showKeyCounter;
+        public readonly Bindable<bool> Visible = new Bindable<bool>(true);
+        private readonly Bindable<bool> configVisibility = new Bindable<bool>();
 
         public KeyCounterCollection()
         {
@@ -46,9 +47,10 @@ namespace osu.Game.Screens.Play
         [BackgroundDependencyLoader]
         private void load(OsuConfigManager config)
         {
-            showKeyCounter = config.GetBindable<bool>(OsuSetting.KeyOverlay);
-            showKeyCounter.ValueChanged += keyCounterVisibility => this.FadeTo(keyCounterVisibility ? 1 : 0, duration);
-            showKeyCounter.TriggerChange();
+            config.BindWith(OsuSetting.KeyOverlay, configVisibility);
+
+            Visible.BindValueChanged(_ => updateVisibility());
+            configVisibility.BindValueChanged(_ => updateVisibility(), true);
         }
 
         //further: change default values here and in KeyCounter if needed, instead of passing them in every constructor
@@ -111,6 +113,8 @@ namespace osu.Game.Screens.Play
             }
         }
 
+        private void updateVisibility() => this.FadeTo(Visible.Value || configVisibility.Value ? 1 : 0, duration);
+
         public override bool HandleKeyboardInput => receptor == null;
         public override bool HandleMouseInput => receptor == null;
 
diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs
index 04148cd558..a2ed01f5a7 100644
--- a/osu.Game/Screens/Play/Player.cs
+++ b/osu.Game/Screens/Play/Player.cs
@@ -229,6 +229,7 @@ namespace osu.Game.Screens.Play
             };
 
             hudOverlay.HoldToQuit.Action = Exit;
+            hudOverlay.KeyCounter.Visible.BindTo(RulesetContainer.HasReplayLoaded);
 
             if (ShowStoryboard)
                 initializeStoryboard(false);
diff --git a/osu.Game/Screens/Select/MatchSongSelect.cs b/osu.Game/Screens/Select/MatchSongSelect.cs
index a0c96d0cee..339392d5cf 100644
--- a/osu.Game/Screens/Select/MatchSongSelect.cs
+++ b/osu.Game/Screens/Select/MatchSongSelect.cs
@@ -7,12 +7,7 @@ namespace osu.Game.Screens.Select
     {
         protected override bool OnStart()
         {
-            Schedule(() =>
-            {
-                // needs to be scheduled else we enter an infinite feedback loop.
-                if (IsCurrentScreen) Exit();
-            });
-
+            if (IsCurrentScreen) Exit();
             return true;
         }
     }
diff --git a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs
index 7470f6ebed..cf4dda52a8 100644
--- a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs
+++ b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs
@@ -16,7 +16,8 @@ using osu.Game.Rulesets.Objects;
 namespace osu.Game.Tests.Beatmaps
 {
     [TestFixture]
-    public abstract class BeatmapConversionTest<TConvertValue>
+    public abstract class BeatmapConversionTest<TConvertMapping, TConvertValue>
+        where TConvertMapping : ConvertMapping<TConvertValue>, IEquatable<TConvertMapping>, new()
         where TConvertValue : IEquatable<TConvertValue>
     {
         private const string resource_namespace = "Testing.Beatmaps";
@@ -59,9 +60,13 @@ namespace osu.Game.Tests.Beatmaps
                                 else if (objectCounter >= expectedMapping.Objects.Count)
                                     Assert.Fail($"The conversion generated a hitobject, but should not have, for hitobject at time: {ourMapping.StartTime}:\n"
                                                 + $"Received: {JsonConvert.SerializeObject(ourMapping.Objects[objectCounter])}\n");
-                                else if (!EqualityComparer<TConvertValue>.Default.Equals(expectedMapping.Objects[objectCounter], ourMapping.Objects[objectCounter]))
+                                else if (!expectedMapping.Equals(ourMapping))
+                                    Assert.Fail($"The conversion mapping differed for object at time {expectedMapping.StartTime}:\n"
+                                                + $"Expected {JsonConvert.SerializeObject(expectedMapping)}\n"
+                                                + $"Received: {JsonConvert.SerializeObject(ourMapping)}\n");
+                                else if (!expectedMapping.Objects[objectCounter].Equals(ourMapping.Objects[objectCounter]))
                                 {
-                                    Assert.Fail($"The conversion generated differing hitobjects for object at time: {expectedMapping.StartTime}\n"
+                                    Assert.Fail($"The conversion generated differing hitobjects for object at time: {expectedMapping.StartTime}:\n"
                                                 + $"Expected: {JsonConvert.SerializeObject(expectedMapping.Objects[objectCounter])}\n"
                                                 + $"Received: {JsonConvert.SerializeObject(ourMapping.Objects[objectCounter])}\n");
                                 }
@@ -84,19 +89,22 @@ namespace osu.Game.Tests.Beatmaps
             beatmap.BeatmapInfo.Ruleset = beatmap.BeatmapInfo.RulesetID == rulesetInstance.RulesetInfo.ID ? rulesetInstance.RulesetInfo : new RulesetInfo();
 
             var result = new ConvertResult();
-
             var converter = rulesetInstance.CreateBeatmapConverter(beatmap);
+
             converter.ObjectConverted += (orig, converted) =>
             {
                 converted.ForEach(h => h.ApplyDefaults(beatmap.ControlPointInfo, beatmap.BeatmapInfo.BaseDifficulty));
 
-                var mapping = new ConvertMapping { StartTime = orig.StartTime };
+                var mapping = CreateConvertMapping();
+                mapping.StartTime = orig.StartTime;
+
                 foreach (var obj in converted)
                     mapping.Objects.AddRange(CreateConvertValue(obj));
                 result.Mappings.Add(mapping);
             };
 
-            converter.Convert();
+            IBeatmap convertedBeatmap = converter.Convert();
+            rulesetInstance.CreateBeatmapProcessor(convertedBeatmap)?.PostProcess();
 
             return result;
         }
@@ -128,21 +136,54 @@ namespace osu.Game.Tests.Beatmaps
             return Assembly.LoadFrom(Path.Combine(localPath, $"{ResourceAssembly}.dll")).GetManifestResourceStream($@"{ResourceAssembly}.Resources.{name}");
         }
 
-        protected abstract IEnumerable<TConvertValue> CreateConvertValue(HitObject hitObject);
-        protected abstract Ruleset CreateRuleset();
+        /// <summary>
+        /// Creates the conversion mapping for a <see cref="HitObject"/>. A conversion mapping stores important information about the conversion process.
+        /// This is generated _after_ the <see cref="HitObject"/> has been converted.
+        /// <para>
+        /// This should be used to validate the integrity of the conversion process after a conversion has occurred.
+        /// </para>
+        /// </summary>
+        protected virtual TConvertMapping CreateConvertMapping() => new TConvertMapping();
 
-        private class ConvertMapping
-        {
-            [JsonProperty]
-            public double StartTime;
-            [JsonProperty]
-            public List<TConvertValue> Objects = new List<TConvertValue>();
-        }
+        /// <summary>
+        /// Creates the conversion value for a <see cref="HitObject"/>. A conversion value stores information about the converted <see cref="HitObject"/>.
+        /// <para>
+        /// This should be used to validate the integrity of the converted <see cref="HitObject"/>.
+        /// </para>
+        /// </summary>
+        /// <param name="hitObject">The converted <see cref="HitObject"/>.</param>
+        protected abstract IEnumerable<TConvertValue> CreateConvertValue(HitObject hitObject);
+
+        /// <summary>
+        /// Creates the <see cref="Ruleset"/> applicable to this <see cref="BeatmapConversionTest{TConvertMapping,TConvertValue}"/>.
+        /// </summary>
+        /// <returns></returns>
+        protected abstract Ruleset CreateRuleset();
 
         private class ConvertResult
         {
             [JsonProperty]
-            public List<ConvertMapping> Mappings = new List<ConvertMapping>();
+            public List<TConvertMapping> Mappings = new List<TConvertMapping>();
         }
     }
+
+    public abstract class BeatmapConversionTest<TConvertValue> : BeatmapConversionTest<ConvertMapping<TConvertValue>, TConvertValue>
+        where TConvertValue : IEquatable<TConvertValue>
+    {
+    }
+
+    public class ConvertMapping<TConvertValue> : IEquatable<ConvertMapping<TConvertValue>>
+        where TConvertValue : IEquatable<TConvertValue>
+    {
+        [JsonProperty]
+        public double StartTime;
+
+        [JsonIgnore]
+        public List<TConvertValue> Objects = new List<TConvertValue>();
+
+        [JsonProperty("Objects")]
+        private List<TConvertValue> setObjects { set => Objects = value; }
+
+        public virtual bool Equals(ConvertMapping<TConvertValue> other) => StartTime.Equals(other?.StartTime);
+    }
 }
diff --git a/osu.Game/Tests/OsuTestBrowser.cs b/osu.Game/Tests/OsuTestBrowser.cs
index 738bb2d642..217af8eb77 100644
--- a/osu.Game/Tests/OsuTestBrowser.cs
+++ b/osu.Game/Tests/OsuTestBrowser.cs
@@ -3,6 +3,7 @@
 
 using osu.Framework.Platform;
 using osu.Framework.Testing;
+using osu.Game.Graphics;
 using osu.Game.Screens.Backgrounds;
 
 namespace osu.Game.Tests
@@ -13,7 +14,11 @@ namespace osu.Game.Tests
         {
             base.LoadComplete();
 
-            LoadComponentAsync(new BackgroundScreenDefault { Depth = 10 }, AddInternal);
+            LoadComponentAsync(new BackgroundScreenDefault
+            {
+                Colour = OsuColour.Gray(0.5f),
+                Depth = 10
+            }, AddInternal);
 
             // Have to construct this here, rather than in the constructor, because
             // we depend on some dependencies to be loaded within OsuGameBase.load().
diff --git a/osu.Game/Tests/Platform/TestStorage.cs b/osu.Game/Tests/Platform/TestStorage.cs
index 883aae2184..5b31c7b4d0 100644
--- a/osu.Game/Tests/Platform/TestStorage.cs
+++ b/osu.Game/Tests/Platform/TestStorage.cs
@@ -7,7 +7,7 @@ namespace osu.Game.Tests.Platform
 {
     public class TestStorage : DesktopStorage
     {
-        public TestStorage(string baseName) : base(baseName)
+        public TestStorage(string baseName) : base(baseName, null)
         {
         }
 
diff --git a/osu.Game/Tests/Visual/EditorClockTestCase.cs b/osu.Game/Tests/Visual/EditorClockTestCase.cs
index 08dc6a3bbd..521b51529e 100644
--- a/osu.Game/Tests/Visual/EditorClockTestCase.cs
+++ b/osu.Game/Tests/Visual/EditorClockTestCase.cs
@@ -25,13 +25,20 @@ namespace osu.Game.Tests.Visual
             Clock = new EditorClock(new ControlPointInfo(), 5000, BeatDivisor) { IsCoupled = false };
         }
 
+        protected override IReadOnlyDependencyContainer CreateLocalDependencies(IReadOnlyDependencyContainer parent)
+        {
+            var dependencies = new DependencyContainer(base.CreateLocalDependencies(parent));
+
+            dependencies.Cache(BeatDivisor);
+            dependencies.CacheAs<IFrameBasedClock>(Clock);
+            dependencies.CacheAs<IAdjustableClock>(Clock);
+
+            return dependencies;
+        }
+
         [BackgroundDependencyLoader]
         private void load()
         {
-            Dependencies.Cache(BeatDivisor);
-            Dependencies.CacheAs<IFrameBasedClock>(Clock);
-            Dependencies.CacheAs<IAdjustableClock>(Clock);
-
             Beatmap.BindValueChanged(beatmapChanged, true);
         }
 
diff --git a/osu.Game/Tests/Visual/ManualInputManagerTestCase.cs b/osu.Game/Tests/Visual/ManualInputManagerTestCase.cs
index 132a655c15..01676ad56e 100644
--- a/osu.Game/Tests/Visual/ManualInputManagerTestCase.cs
+++ b/osu.Game/Tests/Visual/ManualInputManagerTestCase.cs
@@ -23,7 +23,7 @@ namespace osu.Game.Tests.Visual
         /// </summary>
         protected void ReturnUserInput()
         {
-            AddStep("Return user input", () => InputManager.UseParentState = true);
+            AddStep("Return user input", () => InputManager.UseParentInput = true);
         }
     }
 }
diff --git a/osu.Game/Tests/Visual/TestCasePerformancePoints.cs b/osu.Game/Tests/Visual/TestCasePerformancePoints.cs
deleted file mode 100644
index dfae8fbc1d..0000000000
--- a/osu.Game/Tests/Visual/TestCasePerformancePoints.cs
+++ /dev/null
@@ -1,403 +0,0 @@
-// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
-// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
-
-using System.Collections.Generic;
-using System.Linq;
-using OpenTK;
-using OpenTK.Graphics;
-using osu.Framework.Allocation;
-using osu.Framework.Caching;
-using osu.Framework.Configuration;
-using osu.Framework.Extensions.IEnumerableExtensions;
-using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
-using osu.Framework.Graphics.Cursor;
-using osu.Framework.Graphics.Shapes;
-using osu.Framework.Input;
-using osu.Game.Beatmaps;
-using osu.Game.Graphics.Sprites;
-using osu.Game.Graphics.UserInterface;
-using osu.Game.Online.API;
-using osu.Game.Online.API.Requests;
-using osu.Game.Rulesets;
-using osu.Game.Rulesets.Mods;
-using osu.Game.Rulesets.Scoring;
-
-namespace osu.Game.Tests.Visual
-{
-    public abstract class TestCasePerformancePoints : OsuTestCase
-    {
-        protected TestCasePerformancePoints(Ruleset ruleset)
-        {
-            Child = new GridContainer
-            {
-                RelativeSizeAxes = Axes.Both,
-                Content = new[]
-                {
-                    new Drawable[]
-                    {
-                        new Container
-                        {
-                            RelativeSizeAxes = Axes.Both,
-                            Children = new Drawable[]
-                            {
-                                new Box
-                                {
-                                    RelativeSizeAxes = Axes.Both,
-                                    Colour = Color4.Black,
-                                    Alpha = 0.5f,
-                                },
-                                new ScrollContainer
-                                {
-                                    RelativeSizeAxes = Axes.Both,
-                                    Child = new BeatmapList(ruleset, Beatmap)
-                                }
-                            }
-                        },
-                        null,
-                        new Container
-                        {
-                            RelativeSizeAxes = Axes.Both,
-                            Children = new Drawable[]
-                            {
-                                new Box
-                                {
-                                    RelativeSizeAxes = Axes.Both,
-                                    Colour = Color4.Black,
-                                    Alpha = 0.5f,
-                                },
-                                new ScrollContainer
-                                {
-                                    RelativeSizeAxes = Axes.Both,
-                                    Child = new StarRatingGrid()
-                                }
-                            }
-                        },
-                        null,
-                        new Container
-                        {
-                            RelativeSizeAxes = Axes.Both,
-                            Children = new Drawable[]
-                            {
-                                new Box
-                                {
-                                    RelativeSizeAxes = Axes.Both,
-                                    Colour = Color4.Black,
-                                    Alpha = 0.5f,
-                                },
-                                new ScrollContainer
-                                {
-                                    RelativeSizeAxes = Axes.Both,
-                                    Child = new PerformanceList()
-                                }
-                            }
-                        },
-                    }
-                },
-                ColumnDimensions = new[]
-                {
-                    new Dimension(),
-                    new Dimension(GridSizeMode.Absolute, 20),
-                    new Dimension(),
-                    new Dimension(GridSizeMode.Absolute, 20)
-                }
-            };
-        }
-
-        private class BeatmapList : CompositeDrawable
-        {
-            private readonly Container<BeatmapDisplay> beatmapDisplays;
-            private readonly Ruleset ruleset;
-            private readonly BindableBeatmap beatmapBindable;
-
-            public BeatmapList(Ruleset ruleset, BindableBeatmap beatmapBindable)
-            {
-                this.ruleset = ruleset;
-                this.beatmapBindable = beatmapBindable;
-
-                RelativeSizeAxes = Axes.X;
-                AutoSizeAxes = Axes.Y;
-                InternalChild = beatmapDisplays = new FillFlowContainer<BeatmapDisplay>
-                {
-                    RelativeSizeAxes = Axes.X,
-                    AutoSizeAxes = Axes.Y,
-                    Direction = FillDirection.Vertical,
-                    Spacing = new Vector2(0, 4)
-                };
-            }
-
-            [BackgroundDependencyLoader]
-            private void load(BeatmapManager beatmaps)
-            {
-                var sets = beatmaps.GetAllUsableBeatmapSets();
-                var allBeatmaps = sets.SelectMany(s => s.Beatmaps).Where(b => ruleset.LegacyID == null || b.RulesetID == ruleset.LegacyID);
-
-                allBeatmaps.ForEach(b => beatmapDisplays.Add(new BeatmapDisplay(b, beatmapBindable)));
-            }
-
-            private class BeatmapDisplay : CompositeDrawable, IHasTooltip
-            {
-                private readonly OsuSpriteText text;
-                private readonly BeatmapInfo beatmap;
-
-                private readonly BindableBeatmap beatmapBindable;
-
-                private BeatmapManager beatmaps;
-
-                private bool isSelected;
-
-                public string TooltipText => text.Text;
-
-                public BeatmapDisplay(BeatmapInfo beatmap, BindableBeatmap beatmapBindable)
-                {
-                    this.beatmap = beatmap;
-                    this.beatmapBindable = beatmapBindable;
-
-                    AutoSizeAxes = Axes.Both;
-                    InternalChild = text = new OsuSpriteText();
-
-                    this.beatmapBindable.ValueChanged += beatmapChanged;
-                }
-
-                [BackgroundDependencyLoader]
-                private void load(BeatmapManager beatmaps)
-                {
-                    this.beatmaps = beatmaps;
-
-                    var working = beatmaps.GetWorkingBeatmap(beatmap);
-                    text.Text = $"{working.Metadata.Artist} - {working.Metadata.Title} ({working.Metadata.AuthorString}) [{working.BeatmapInfo.Version}]";
-                }
-
-                private void beatmapChanged(WorkingBeatmap newBeatmap)
-                {
-                    if (isSelected)
-                        this.FadeColour(Color4.White, 100);
-                    isSelected = false;
-                }
-
-                protected override bool OnClick(InputState state)
-                {
-                    if (beatmapBindable.Value.BeatmapInfo.ID == beatmap.ID)
-                        return false;
-
-                    beatmapBindable.Value = beatmaps.GetWorkingBeatmap(beatmap);
-                    isSelected = true;
-                    return true;
-                }
-
-                protected override bool OnHover(InputState state)
-                {
-                    if (isSelected)
-                        return false;
-                    this.FadeColour(Color4.Yellow, 100);
-                    return true;
-                }
-
-                protected override void OnHoverLost(InputState state)
-                {
-                    if (isSelected)
-                        return;
-                    this.FadeColour(Color4.White, 100);
-                }
-            }
-        }
-
-        private class PerformanceList : CompositeDrawable
-        {
-            private readonly FillFlowContainer<PerformanceDisplay> scores;
-            private APIAccess api;
-
-            private readonly IBindable<WorkingBeatmap> currentBeatmap = new Bindable<WorkingBeatmap>();
-
-            public PerformanceList()
-            {
-                RelativeSizeAxes = Axes.X;
-                AutoSizeAxes = Axes.Y;
-                InternalChild = scores = new FillFlowContainer<PerformanceDisplay>
-                {
-                    RelativeSizeAxes = Axes.X,
-                    AutoSizeAxes = Axes.Y,
-                    Direction = FillDirection.Vertical,
-                    Spacing = new Vector2(0, 4)
-                };
-            }
-
-            [BackgroundDependencyLoader]
-            private void load(IBindableBeatmap beatmap, APIAccess api)
-            {
-                this.api = api;
-
-                if (!api.IsLoggedIn)
-                {
-                    InternalChild = new OsuSpriteText
-                    {
-                        Anchor = Anchor.TopCentre,
-                        Origin = Anchor.TopCentre,
-                        Text = "Please sign in to see online scores",
-                    };
-                }
-
-                currentBeatmap.ValueChanged += beatmapChanged;
-                currentBeatmap.BindTo(beatmap);
-            }
-
-            private GetScoresRequest lastRequest;
-            private void beatmapChanged(WorkingBeatmap newBeatmap)
-            {
-                if (!IsAlive) return;
-
-                lastRequest?.Cancel();
-                scores.Clear();
-
-                if (!api.IsLoggedIn)
-                    return;
-
-                lastRequest = new GetScoresRequest(newBeatmap.BeatmapInfo, newBeatmap.BeatmapInfo.Ruleset);
-                lastRequest.Success += res => res.Scores.ForEach(s => scores.Add(new PerformanceDisplay(s, newBeatmap.Beatmap)));
-                api.Queue(lastRequest);
-            }
-
-            private class PerformanceDisplay : CompositeDrawable
-            {
-                private readonly OsuSpriteText text;
-
-                private readonly Score score;
-                private readonly IBeatmap beatmap;
-
-                public PerformanceDisplay(Score score, IBeatmap beatmap)
-                {
-                    this.score = score;
-                    this.beatmap = beatmap;
-
-                    RelativeSizeAxes = Axes.X;
-                    AutoSizeAxes = Axes.Y;
-                    InternalChild = text = new OsuSpriteText();
-                }
-
-                [BackgroundDependencyLoader]
-                private void load()
-                {
-                    var ruleset = beatmap.BeatmapInfo.Ruleset.CreateInstance();
-                    var calculator = ruleset.CreatePerformanceCalculator(beatmap, score);
-                    if (calculator == null)
-                        return;
-
-                    var attributes = new Dictionary<string, double>();
-                    double performance = calculator.Calculate(attributes);
-
-                    text.Text = $"{score.User.Username} -> online: {score.PP:n2}pp | local: {performance:n2}pp";
-                }
-            }
-        }
-
-        private class StarRatingGrid : CompositeDrawable
-        {
-            private readonly FillFlowContainer<OsuCheckbox> modFlow;
-            private readonly OsuSpriteText totalText;
-            private readonly FillFlowContainer categoryTexts;
-
-            public StarRatingGrid()
-            {
-                RelativeSizeAxes = Axes.X;
-                AutoSizeAxes = Axes.Y;
-                InternalChild = new FillFlowContainer
-                {
-                    RelativeSizeAxes = Axes.X,
-                    AutoSizeAxes = Axes.Y,
-                    Children = new Drawable[]
-                    {
-                        modFlow = new FillFlowContainer<OsuCheckbox>
-                        {
-                            Name = "Checkbox flow",
-                            RelativeSizeAxes = Axes.X,
-                            AutoSizeAxes = Axes.Y,
-                            Spacing = new Vector2(4, 4)
-                        },
-                        new FillFlowContainer
-                        {
-                            Name = "Information display",
-                            RelativeSizeAxes = Axes.X,
-                            AutoSizeAxes = Axes.Y,
-                            Spacing = new Vector2(0, 4),
-                            Direction = FillDirection.Vertical,
-                            Children = new Drawable[]
-                            {
-                                totalText = new OsuSpriteText { TextSize = 24 },
-                                categoryTexts = new FillFlowContainer
-                                {
-                                    RelativeSizeAxes = Axes.X,
-                                    AutoSizeAxes = Axes.Y,
-                                    Direction = FillDirection.Vertical
-                                }
-                            }
-                        }
-                    }
-                };
-            }
-
-            [BackgroundDependencyLoader]
-            private void load(IBindableBeatmap beatmap)
-            {
-                beatmap.ValueChanged += beatmapChanged;
-            }
-
-            private Cached informationCache = new Cached();
-
-            private Ruleset ruleset;
-            private WorkingBeatmap beatmap;
-
-            private void beatmapChanged(WorkingBeatmap newBeatmap)
-            {
-                beatmap = newBeatmap;
-
-                modFlow.Clear();
-
-                ruleset = newBeatmap.BeatmapInfo.Ruleset.CreateInstance();
-                foreach (var mod in ruleset.GetAllMods())
-                {
-                    var checkBox = new OsuCheckbox
-                    {
-                        RelativeSizeAxes = Axes.None,
-                        Width = 50,
-                        LabelText = mod.ShortenedName
-                    };
-
-                    checkBox.Current.ValueChanged += v => informationCache.Invalidate();
-                    modFlow.Add(checkBox);
-                }
-
-                informationCache.Invalidate();
-            }
-
-            protected override void Update()
-            {
-                base.Update();
-
-                if (ruleset == null)
-                    return;
-
-                if (!informationCache.IsValid)
-                {
-                    totalText.Text = string.Empty;
-                    categoryTexts.Clear();
-
-                    var allMods = ruleset.GetAllMods().ToList();
-                    Mod[] activeMods = modFlow.Where(c => c.Current.Value).Select(c => allMods.First(m => m.ShortenedName == c.LabelText)).ToArray();
-
-                    var diffCalc = ruleset.CreateDifficultyCalculator(beatmap.Beatmap, activeMods);
-                    if (diffCalc != null)
-                    {
-                        var categories = new Dictionary<string, double>();
-                        double totalSr = diffCalc.Calculate(categories);
-
-                        totalText.Text = $"Star rating: {totalSr:n2}";
-                        foreach (var kvp in categories)
-                            categoryTexts.Add(new OsuSpriteText { Text = $"{kvp.Key}: {kvp.Value:n2}" });
-                    }
-
-                    informationCache.Validate();
-                }
-            }
-        }
-    }
-}
diff --git a/osu.Game/Tests/Visual/TestCasePlayer.cs b/osu.Game/Tests/Visual/TestCasePlayer.cs
index 3cdc496ee1..20c9646aa3 100644
--- a/osu.Game/Tests/Visual/TestCasePlayer.cs
+++ b/osu.Game/Tests/Visual/TestCasePlayer.cs
@@ -1,9 +1,11 @@
 // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
 // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
 
+using System;
 using System.Linq;
 using osu.Framework.Allocation;
 using osu.Framework.Graphics.Shapes;
+using osu.Framework.Lists;
 using osu.Game.Beatmaps;
 using osu.Game.Rulesets;
 using osu.Game.Rulesets.Mods;
@@ -51,6 +53,28 @@ namespace osu.Game.Tests.Visual
                     Player p = null;
                     AddStep(r.Name, () => p = loadPlayerFor(r));
                     AddUntilStep(() => ContinueCondition(p));
+
+                    AddAssert("no leaked beatmaps", () =>
+                    {
+                        p = null;
+
+                        GC.Collect();
+                        GC.WaitForPendingFinalizers();
+                        int count = 0;
+
+                        workingWeakReferences.ForEachAlive(_ => count++);
+                        return count == 1;
+                    });
+
+                    AddAssert("no leaked players", () =>
+                    {
+                        GC.Collect();
+                        GC.WaitForPendingFinalizers();
+                        int count = 0;
+
+                        playerWeakReferences.ForEachAlive(_ => count++);
+                        return count == 1;
+                    });
                 }
             }
         }
@@ -59,21 +83,32 @@ namespace osu.Game.Tests.Visual
 
         protected virtual IBeatmap CreateBeatmap(Ruleset ruleset) => new TestBeatmap(ruleset.RulesetInfo);
 
+        private readonly WeakList<WorkingBeatmap> workingWeakReferences = new WeakList<WorkingBeatmap>();
+        private readonly WeakList<Player> playerWeakReferences = new WeakList<Player>();
+
         private Player loadPlayerFor(RulesetInfo ri) => loadPlayerFor(ri.CreateInstance());
 
         private Player loadPlayerFor(Ruleset r)
         {
             var beatmap = CreateBeatmap(r);
+            var working = new TestWorkingBeatmap(beatmap);
 
-            Beatmap.Value = new TestWorkingBeatmap(beatmap);
+            workingWeakReferences.Add(working);
+
+            Beatmap.Value = working;
             Beatmap.Value.Mods.Value = new[] { r.GetAllMods().First(m => m is ModNoFail) };
 
-            if (Player != null)
-                Remove(Player);
+            Player?.Exit();
 
             var player = CreatePlayer(r);
 
-            LoadComponentAsync(player, LoadScreen);
+            playerWeakReferences.Add(player);
+
+            LoadComponentAsync(player, p =>
+            {
+                Player = p;
+                LoadScreen(p);
+            });
 
             return player;
         }
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index d87e190352..9cc538f70f 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -18,7 +18,7 @@
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.0" />
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.1.0" />
     <PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
-    <PackageReference Include="ppy.osu.Framework" Version="2018.611.1" />
+    <PackageReference Include="ppy.osu.Framework" Version="2018.622.0" />
     <PackageReference Include="SharpCompress" Version="0.18.1" />
     <PackageReference Include="NUnit" Version="3.10.1" />
     <PackageReference Include="System.ComponentModel.Annotations" Version="4.5.0" />