diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs
index 161a59c5fdf5..12a4182bf1af 100644
--- a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs
+++ b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs
@@ -89,7 +89,7 @@ protected override double GetComboScoreChange(JudgementResult result)
             return baseIncrease * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(combo_cap, combo_base));
         }
 
-        public override ScoreRank RankFromAccuracy(double accuracy)
+        public override ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary<HitResult, int> results)
         {
             if (accuracy == accuracy_cutoff_x)
                 return ScoreRank.X;
diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs
index 97980c6d180c..4d8381cf4237 100644
--- a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs
+++ b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs
@@ -1,9 +1,11 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
+using System.Collections.Generic;
 using osu.Game.Rulesets.Judgements;
 using osu.Game.Rulesets.Osu.Judgements;
 using osu.Game.Rulesets.Scoring;
+using osu.Game.Scoring;
 
 namespace osu.Game.Rulesets.Osu.Scoring
 {
@@ -14,6 +16,22 @@ public OsuScoreProcessor()
         {
         }
 
+        public override ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary<HitResult, int> results)
+        {
+            ScoreRank rank = base.RankFromScore(accuracy, results);
+
+            switch (rank)
+            {
+                case ScoreRank.S:
+                case ScoreRank.X:
+                    if (results.GetValueOrDefault(HitResult.Miss) > 0)
+                        rank = ScoreRank.A;
+                    break;
+            }
+
+            return rank;
+        }
+
         protected override HitEvent CreateHitEvent(JudgementResult result)
             => base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit);
     }
diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs
index 2fd9f070ec90..7e40d575bc90 100644
--- a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs
+++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs
@@ -2,9 +2,11 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System;
+using System.Collections.Generic;
 using osu.Game.Rulesets.Judgements;
 using osu.Game.Rulesets.Scoring;
 using osu.Game.Rulesets.Taiko.Objects;
+using osu.Game.Scoring;
 
 namespace osu.Game.Rulesets.Taiko.Scoring
 {
@@ -33,6 +35,22 @@ protected override double GetComboScoreChange(JudgementResult result)
                    * strongScaleValue(result);
         }
 
+        public override ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary<HitResult, int> results)
+        {
+            ScoreRank rank = base.RankFromScore(accuracy, results);
+
+            switch (rank)
+            {
+                case ScoreRank.S:
+                case ScoreRank.X:
+                    if (results.GetValueOrDefault(HitResult.Miss) > 0)
+                        rank = ScoreRank.A;
+                    break;
+            }
+
+            return rank;
+        }
+
         public override int GetBaseScoreForResult(HitResult result)
         {
             switch (result)
diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs
index 6e7c8c3631f2..7e3967dc95d4 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs
@@ -10,7 +10,6 @@
 using System.Linq;
 using NUnit.Framework;
 using osu.Framework.Extensions;
-using osu.Framework.Utils;
 using osu.Game.Beatmaps;
 using osu.Game.Beatmaps.Formats;
 using osu.Game.Beatmaps.Legacy;
@@ -23,6 +22,7 @@
 using osu.Game.Rulesets.Mods;
 using osu.Game.Rulesets.Osu;
 using osu.Game.Rulesets.Osu.Mods;
+using osu.Game.Rulesets.Osu.Objects;
 using osu.Game.Rulesets.Osu.Replays;
 using osu.Game.Rulesets.Osu.UI;
 using osu.Game.Rulesets.Replays;
@@ -59,14 +59,14 @@ public void TestDecodeManiaReplay()
                 Assert.AreEqual(2, score.ScoreInfo.Statistics[HitResult.Great]);
                 Assert.AreEqual(1, score.ScoreInfo.Statistics[HitResult.Good]);
 
-                Assert.AreEqual(829_931, score.ScoreInfo.TotalScore);
+                Assert.AreEqual(829_931, score.ScoreInfo.LegacyTotalScore);
                 Assert.AreEqual(3, score.ScoreInfo.MaxCombo);
 
                 Assert.IsTrue(score.ScoreInfo.Mods.Any(m => m is ManiaModClassic));
                 Assert.IsTrue(score.ScoreInfo.APIMods.Any(m => m.Acronym == "CL"));
                 Assert.IsTrue(score.ScoreInfo.ModsJson.Contains("CL"));
 
-                Assert.IsTrue(Precision.AlmostEquals(0.8889, score.ScoreInfo.Accuracy, 0.0001));
+                Assert.That((2 * 300d + 1 * 200) / (3 * 305d), Is.EqualTo(score.ScoreInfo.Accuracy).Within(0.0001));
                 Assert.AreEqual(ScoreRank.B, score.ScoreInfo.Rank);
 
                 Assert.That(score.Replay.Frames, Is.Not.Empty);
@@ -252,7 +252,49 @@ public void TestSoloScoreData()
         }
 
         [Test]
-        public void AccuracyAndRankOfStableScorePreserved()
+        public void AccuracyOfStableScoreRecomputed()
+        {
+            var memoryStream = new MemoryStream();
+
+            // local partial implementation of legacy score encoder
+            // this is done half for readability, half because `LegacyScoreEncoder` forces `LATEST_VERSION`
+            // and we want to emulate a stable score here
+            using (var sw = new SerializationWriter(memoryStream, true))
+            {
+                sw.Write((byte)3); // ruleset id (mania).
+                                   // mania is used intentionally as it is the only ruleset wherein default accuracy calculation is changed in lazer
+                sw.Write(20240116); // version (anything below `LegacyScoreEncoder.FIRST_LAZER_VERSION` is stable)
+                sw.Write(string.Empty.ComputeMD5Hash()); // beatmap hash, irrelevant to this test
+                sw.Write("username"); // irrelevant to this test
+                sw.Write(string.Empty.ComputeMD5Hash()); // score hash, irrelevant to this test
+                sw.Write((ushort)1); // count300
+                sw.Write((ushort)0); // count100
+                sw.Write((ushort)0); // count50
+                sw.Write((ushort)198); // countGeki (perfects / "rainbow 300s" in mania)
+                sw.Write((ushort)0); // countKatu
+                sw.Write((ushort)1); // countMiss
+                sw.Write(12345678); // total score, irrelevant to this test
+                sw.Write((ushort)1000); // max combo, irrelevant to this test
+                sw.Write(false); // full combo, irrelevant to this test
+                sw.Write((int)LegacyMods.Hidden); // mods
+                sw.Write(string.Empty); // hp graph, irrelevant
+                sw.Write(DateTime.Now); // date, irrelevant
+                sw.Write(Array.Empty<byte>()); // replay data, irrelevant
+                sw.Write((long)1234); // legacy online ID, irrelevant
+            }
+
+            memoryStream.Seek(0, SeekOrigin.Begin);
+            var decoded = new TestLegacyScoreDecoder().Parse(memoryStream);
+
+            Assert.Multiple(() =>
+            {
+                Assert.That(decoded.ScoreInfo.Accuracy, Is.EqualTo((double)(198 * 305 + 300) / (200 * 305)));
+                Assert.That(decoded.ScoreInfo.Rank, Is.EqualTo(ScoreRank.SH));
+            });
+        }
+
+        [Test]
+        public void RankOfStableScoreUsesLazerDefinitions()
         {
             var memoryStream = new MemoryStream();
 
@@ -266,12 +308,12 @@ public void AccuracyAndRankOfStableScorePreserved()
                 sw.Write(string.Empty.ComputeMD5Hash()); // beatmap hash, irrelevant to this test
                 sw.Write("username"); // irrelevant to this test
                 sw.Write(string.Empty.ComputeMD5Hash()); // score hash, irrelevant to this test
-                sw.Write((ushort)198); // count300
+                sw.Write((ushort)195); // count300
                 sw.Write((ushort)1); // count100
-                sw.Write((ushort)0); // count50
+                sw.Write((ushort)4); // count50
                 sw.Write((ushort)0); // countGeki
                 sw.Write((ushort)0); // countKatu
-                sw.Write((ushort)1); // countMiss
+                sw.Write((ushort)0); // countMiss
                 sw.Write(12345678); // total score, irrelevant to this test
                 sw.Write((ushort)1000); // max combo, irrelevant to this test
                 sw.Write(false); // full combo, irrelevant to this test
@@ -287,13 +329,13 @@ public void AccuracyAndRankOfStableScorePreserved()
 
             Assert.Multiple(() =>
             {
-                Assert.That(decoded.ScoreInfo.Accuracy, Is.EqualTo((double)(198 * 300 + 100) / (200 * 300)));
-                Assert.That(decoded.ScoreInfo.Rank, Is.EqualTo(ScoreRank.A));
+                // In stable this would be an A because there are over 1% 50s. But that's not a thing in lazer.
+                Assert.That(decoded.ScoreInfo.Rank, Is.EqualTo(ScoreRank.SH));
             });
         }
 
         [Test]
-        public void AccuracyAndRankOfLazerScorePreserved()
+        public void AccuracyRankAndTotalScoreOfLazerScorePreserved()
         {
             var ruleset = new OsuRuleset().RulesetInfo;
 
@@ -321,8 +363,10 @@ public void AccuracyAndRankOfLazerScorePreserved()
 
             Assert.Multiple(() =>
             {
+                Assert.That(decodedAfterEncode.ScoreInfo.TotalScore, Is.EqualTo(284_537));
+                Assert.That(decodedAfterEncode.ScoreInfo.LegacyTotalScore, Is.Null);
                 Assert.That(decodedAfterEncode.ScoreInfo.Accuracy, Is.EqualTo((double)(199 * 300 + 30) / (200 * 300 + 30)));
-                Assert.That(decodedAfterEncode.ScoreInfo.Rank, Is.EqualTo(ScoreRank.SH));
+                Assert.That(decodedAfterEncode.ScoreInfo.Rank, Is.EqualTo(ScoreRank.A));
             });
         }
 
@@ -415,6 +459,12 @@ public TestLegacyScoreDecoder(int beatmapVersion = LegacyBeatmapDecoder.LATEST_V
                     Ruleset = new OsuRuleset().RulesetInfo,
                     Difficulty = new BeatmapDifficulty(),
                     BeatmapVersion = beatmapVersion,
+                },
+                // needs to have at least one objects so that `StandardisedScoreMigrationTools` doesn't die
+                // when trying to recompute total score.
+                HitObjects =
+                {
+                    new HitCircle()
                 }
             });
         }
diff --git a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs
index 8b066f860fde..e960995c4585 100644
--- a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs
+++ b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs
@@ -9,6 +9,7 @@
 using osu.Framework.Extensions;
 using osu.Framework.Testing;
 using osu.Game.Beatmaps;
+using osu.Game.Database;
 using osu.Game.Rulesets;
 using osu.Game.Scoring;
 using osu.Game.Scoring.Legacy;
@@ -210,31 +211,6 @@ public void TestCustomRulesetScoreNotSubjectToUpgrades([Values] bool available)
             AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find<ScoreInfo>(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(30000001));
         }
 
-        [Test]
-        public void TestNonLegacyScoreNotSubjectToUpgrades()
-        {
-            ScoreInfo scoreInfo = null!;
-            TestBackgroundDataStoreProcessor processor = null!;
-
-            AddStep("Add score which requires upgrade (and has beatmap)", () =>
-            {
-                Realm.Write(r =>
-                {
-                    r.Add(scoreInfo = new ScoreInfo(ruleset: r.All<RulesetInfo>().First(), beatmap: r.All<BeatmapInfo>().First())
-                    {
-                        TotalScoreVersion = 30000005,
-                        LegacyTotalScore = 123456,
-                    });
-                });
-            });
-
-            AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor()));
-            AddUntilStep("Wait for completion", () => processor.Completed);
-
-            AddAssert("Score not marked as failed", () => Realm.Run(r => r.Find<ScoreInfo>(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.False);
-            AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find<ScoreInfo>(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(30000005));
-        }
-
         public partial class TestBackgroundDataStoreProcessor : BackgroundDataStoreProcessor
         {
             protected override int TimeToSleepDuringGameplay => 10;
diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs
index 435dd7712087..7aa36429a702 100644
--- a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs
+++ b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs
@@ -2,6 +2,7 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System;
+using System.Collections.Generic;
 using NUnit.Framework;
 using osu.Framework.Extensions.Color4Extensions;
 using osu.Framework.Graphics;
@@ -96,6 +97,14 @@ private ScoreInfo createScore(double accuracy, Ruleset ruleset)
         {
             var scoreProcessor = ruleset.CreateScoreProcessor();
 
+            var statistics = new Dictionary<HitResult, int>
+            {
+                { HitResult.Miss, 1 },
+                { HitResult.Meh, 50 },
+                { HitResult.Good, 100 },
+                { HitResult.Great, 300 },
+            };
+
             return new ScoreInfo
             {
                 User = new APIUser
@@ -109,15 +118,9 @@ private ScoreInfo createScore(double accuracy, Ruleset ruleset)
                 TotalScore = 2845370,
                 Accuracy = accuracy,
                 MaxCombo = 999,
-                Rank = scoreProcessor.RankFromAccuracy(accuracy),
+                Rank = scoreProcessor.RankFromScore(accuracy, statistics),
                 Date = DateTimeOffset.Now,
-                Statistics =
-                {
-                    { HitResult.Miss, 1 },
-                    { HitResult.Meh, 50 },
-                    { HitResult.Good, 100 },
-                    { HitResult.Great, 300 },
-                }
+                Statistics = statistics,
             };
         }
     }
diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs
index ab2e86725511..866e20d06366 100644
--- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs
+++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs
@@ -21,6 +21,7 @@
 using osu.Game.Rulesets;
 using osu.Game.Rulesets.Difficulty;
 using osu.Game.Rulesets.Osu;
+using osu.Game.Rulesets.Scoring;
 using osu.Game.Scoring;
 using osu.Game.Screens;
 using osu.Game.Screens.Play;
@@ -71,15 +72,16 @@ public void TestScaling()
 
         private int onlineScoreID = 1;
 
-        [TestCase(1, ScoreRank.X)]
-        [TestCase(0.9999, ScoreRank.S)]
-        [TestCase(0.975, ScoreRank.S)]
-        [TestCase(0.925, ScoreRank.A)]
-        [TestCase(0.85, ScoreRank.B)]
-        [TestCase(0.75, ScoreRank.C)]
-        [TestCase(0.5, ScoreRank.D)]
-        [TestCase(0.2, ScoreRank.D)]
-        public void TestResultsWithPlayer(double accuracy, ScoreRank rank)
+        [TestCase(1, ScoreRank.X, 0)]
+        [TestCase(0.9999, ScoreRank.S, 0)]
+        [TestCase(0.975, ScoreRank.S, 0)]
+        [TestCase(0.975, ScoreRank.A, 1)]
+        [TestCase(0.925, ScoreRank.A, 5)]
+        [TestCase(0.85, ScoreRank.B, 9)]
+        [TestCase(0.75, ScoreRank.C, 11)]
+        [TestCase(0.5, ScoreRank.D, 21)]
+        [TestCase(0.2, ScoreRank.D, 51)]
+        public void TestResultsWithPlayer(double accuracy, ScoreRank rank, int missCount)
         {
             TestResultsScreen screen = null;
 
@@ -91,6 +93,7 @@ public void TestResultsWithPlayer(double accuracy, ScoreRank rank)
                 score.HitEvents = TestSceneStatisticsPanel.CreatePositionDistributedHitEvents();
                 score.Accuracy = accuracy;
                 score.Rank = rank;
+                score.Statistics[HitResult.Miss] = missCount;
 
                 return screen = createResultsScreen(score);
             });
diff --git a/osu.Game/BackgroundDataStoreProcessor.cs b/osu.Game/Database/BackgroundDataStoreProcessor.cs
similarity index 87%
rename from osu.Game/BackgroundDataStoreProcessor.cs
rename to osu.Game/Database/BackgroundDataStoreProcessor.cs
index fc7db13d4105..be0c83bdb3f0 100644
--- a/osu.Game/BackgroundDataStoreProcessor.cs
+++ b/osu.Game/Database/BackgroundDataStoreProcessor.cs
@@ -12,7 +12,6 @@
 using osu.Framework.Graphics;
 using osu.Framework.Logging;
 using osu.Game.Beatmaps;
-using osu.Game.Database;
 using osu.Game.Extensions;
 using osu.Game.Online.API;
 using osu.Game.Overlays;
@@ -22,7 +21,7 @@
 using osu.Game.Scoring.Legacy;
 using osu.Game.Screens.Play;
 
-namespace osu.Game
+namespace osu.Game.Database
 {
     /// <summary>
     /// Performs background updating of data stores at startup.
@@ -74,6 +73,7 @@ protected override void LoadComplete()
                 processBeatmapsWithMissingObjectCounts();
                 processScoresWithMissingStatistics();
                 convertLegacyTotalScoreToStandardised();
+                upgradeScoreRanks();
             }, TaskCreationOptions.LongRunning).ContinueWith(t =>
             {
                 if (t.Exception?.InnerException is ObjectDisposedException)
@@ -355,7 +355,7 @@ private void convertLegacyTotalScoreToStandardised()
                     realmAccess.Write(r =>
                     {
                         ScoreInfo s = r.Find<ScoreInfo>(id)!;
-                        StandardisedScoreMigrationTools.UpdateFromLegacy(s, beatmapManager);
+                        StandardisedScoreMigrationTools.UpdateFromLegacy(s, beatmapManager.GetWorkingBeatmap(s.BeatmapInfo));
                         s.TotalScoreVersion = LegacyScoreEncoder.LATEST_VERSION;
                     });
 
@@ -376,6 +376,66 @@ private void convertLegacyTotalScoreToStandardised()
             completeNotification(notification, processedCount, scoreIds.Count, failedCount);
         }
 
+        private void upgradeScoreRanks()
+        {
+            Logger.Log("Querying for scores that need rank upgrades...");
+
+            HashSet<Guid> scoreIds = realmAccess.Run(r => new HashSet<Guid>(
+                r.All<ScoreInfo>()
+                 .Where(s => s.TotalScoreVersion < LegacyScoreEncoder.LATEST_VERSION)
+                 .AsEnumerable()
+                 // must be done after materialisation, as realm doesn't support
+                 // filtering on nested property predicates or projection via `.Select()`
+                 .Where(s => s.Ruleset.IsLegacyRuleset())
+                 .Select(s => s.ID)));
+
+            Logger.Log($"Found {scoreIds.Count} scores which require rank upgrades.");
+
+            if (scoreIds.Count == 0)
+                return;
+
+            var notification = showProgressNotification(scoreIds.Count, "Adjusting ranks of scores", "scores now have more correct ranks");
+
+            int processedCount = 0;
+            int failedCount = 0;
+
+            foreach (var id in scoreIds)
+            {
+                if (notification?.State == ProgressNotificationState.Cancelled)
+                    break;
+
+                updateNotificationProgress(notification, processedCount, scoreIds.Count);
+
+                sleepIfRequired();
+
+                try
+                {
+                    // Can't use async overload because we're not on the update thread.
+                    // ReSharper disable once MethodHasAsyncOverload
+                    realmAccess.Write(r =>
+                    {
+                        ScoreInfo s = r.Find<ScoreInfo>(id)!;
+                        s.Rank = StandardisedScoreMigrationTools.ComputeRank(s);
+                        s.TotalScoreVersion = LegacyScoreEncoder.LATEST_VERSION;
+                    });
+
+                    ++processedCount;
+                }
+                catch (ObjectDisposedException)
+                {
+                    throw;
+                }
+                catch (Exception e)
+                {
+                    Logger.Log($"Failed to update rank score {id}: {e}");
+                    realmAccess.Write(r => r.Find<ScoreInfo>(id)!.BackgroundReprocessingFailed = true);
+                    ++failedCount;
+                }
+            }
+
+            completeNotification(notification, processedCount, scoreIds.Count, failedCount);
+        }
+
         private void updateNotificationProgress(ProgressNotification? notification, int processedCount, int totalCount)
         {
             if (notification == null)
diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs
index 8c73806cb595..403e73ab77ae 100644
--- a/osu.Game/Database/StandardisedScoreMigrationTools.cs
+++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs
@@ -232,42 +232,60 @@ static int numericScoreFor(HitResult result)
         }
 
         /// <summary>
-        /// Updates a legacy <see cref="ScoreInfo"/> to standardised scoring.
+        /// Updates a <see cref="ScoreInfo"/> to standardised scoring.
+        /// This will recompite the score's <see cref="ScoreInfo.Accuracy"/> (always), <see cref="ScoreInfo.Rank"/> (always),
+        /// and <see cref="ScoreInfo.TotalScore"/> (if the score comes from stable).
+        /// The total score from stable - if any applicable - will be stored to <see cref="ScoreInfo.LegacyTotalScore"/>.
         /// </summary>
         /// <param name="score">The score to update.</param>
-        /// <param name="beatmaps">A <see cref="BeatmapManager"/> used for <see cref="WorkingBeatmap"/> lookups.</param>
-        public static void UpdateFromLegacy(ScoreInfo score, BeatmapManager beatmaps)
+        /// <param name="beatmap">The <see cref="WorkingBeatmap"/> applicable for this score.</param>
+        public static void UpdateFromLegacy(ScoreInfo score, WorkingBeatmap beatmap)
         {
-            score.TotalScore = convertFromLegacyTotalScore(score, beatmaps);
-            score.Accuracy = ComputeAccuracy(score);
+            var ruleset = score.Ruleset.CreateInstance();
+            var scoreProcessor = ruleset.CreateScoreProcessor();
+
+            // warning: ordering is important here - both total score and ranks are dependent on accuracy!
+            score.Accuracy = computeAccuracy(score, scoreProcessor);
+            score.Rank = computeRank(score, scoreProcessor);
+            score.TotalScore = convertFromLegacyTotalScore(score, ruleset, beatmap);
         }
 
         /// <summary>
-        /// Updates a legacy <see cref="ScoreInfo"/> to standardised scoring.
+        /// Updates a <see cref="ScoreInfo"/> to standardised scoring.
+        /// This will recompute the score's <see cref="ScoreInfo.Accuracy"/> (always), <see cref="ScoreInfo.Rank"/> (always),
+        /// and <see cref="ScoreInfo.TotalScore"/> (if the score comes from stable).
+        /// The total score from stable - if any applicable - will be stored to <see cref="ScoreInfo.LegacyTotalScore"/>.
         /// </summary>
+        /// <remarks>
+        /// This overload is intended for server-side flows.
+        /// See: https://github.com/ppy/osu-queue-score-statistics/blob/3681e92ac91c6c61922094bdbc7e92e6217dd0fc/osu.Server.Queues.ScoreStatisticsProcessor/Commands/Queue/BatchInserter.cs
+        /// </remarks>
         /// <param name="score">The score to update.</param>
+        /// <param name="ruleset">The <see cref="Ruleset"/> in which the score was set.</param>
         /// <param name="difficulty">The beatmap difficulty.</param>
         /// <param name="attributes">The legacy scoring attributes for the beatmap which the score was set on.</param>
-        public static void UpdateFromLegacy(ScoreInfo score, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes)
+        public static void UpdateFromLegacy(ScoreInfo score, Ruleset ruleset, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes)
         {
-            score.TotalScore = convertFromLegacyTotalScore(score, difficulty, attributes);
-            score.Accuracy = ComputeAccuracy(score);
+            var scoreProcessor = ruleset.CreateScoreProcessor();
+
+            // warning: ordering is important here - both total score and ranks are dependent on accuracy!
+            score.Accuracy = computeAccuracy(score, scoreProcessor);
+            score.Rank = computeRank(score, scoreProcessor);
+            score.TotalScore = convertFromLegacyTotalScore(score, ruleset, difficulty, attributes);
         }
 
         /// <summary>
         /// Converts from <see cref="ScoreInfo.LegacyTotalScore"/> to the new standardised scoring of <see cref="ScoreProcessor"/>.
         /// </summary>
         /// <param name="score">The score to convert the total score of.</param>
-        /// <param name="beatmaps">A <see cref="BeatmapManager"/> used for <see cref="WorkingBeatmap"/> lookups.</param>
+        /// <param name="ruleset">The <see cref="Ruleset"/> in which the score was set.</param>
+        /// <param name="beatmap">The <see cref="WorkingBeatmap"/> applicable for this score.</param>
         /// <returns>The standardised total score.</returns>
-        private static long convertFromLegacyTotalScore(ScoreInfo score, BeatmapManager beatmaps)
+        private static long convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, WorkingBeatmap beatmap)
         {
             if (!score.IsLegacyScore)
                 return score.TotalScore;
 
-            WorkingBeatmap beatmap = beatmaps.GetWorkingBeatmap(score.BeatmapInfo);
-            Ruleset ruleset = score.Ruleset.CreateInstance();
-
             if (ruleset is not ILegacyRuleset legacyRuleset)
                 return score.TotalScore;
 
@@ -283,24 +301,24 @@ private static long convertFromLegacyTotalScore(ScoreInfo score, BeatmapManager
             ILegacyScoreSimulator sv1Simulator = legacyRuleset.CreateLegacyScoreSimulator();
             LegacyScoreAttributes attributes = sv1Simulator.Simulate(beatmap, playableBeatmap);
 
-            return convertFromLegacyTotalScore(score, LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap.Beatmap), attributes);
+            return convertFromLegacyTotalScore(score, ruleset, LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap.Beatmap), attributes);
         }
 
         /// <summary>
         /// Converts from <see cref="ScoreInfo.LegacyTotalScore"/> to the new standardised scoring of <see cref="ScoreProcessor"/>.
         /// </summary>
         /// <param name="score">The score to convert the total score of.</param>
+        /// <param name="ruleset">The <see cref="Ruleset"/> in which the score was set.</param>
         /// <param name="difficulty">The beatmap difficulty.</param>
         /// <param name="attributes">The legacy scoring attributes for the beatmap which the score was set on.</param>
         /// <returns>The standardised total score.</returns>
-        private static long convertFromLegacyTotalScore(ScoreInfo score, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes)
+        private static long convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes)
         {
             if (!score.IsLegacyScore)
                 return score.TotalScore;
 
             Debug.Assert(score.LegacyTotalScore != null);
 
-            Ruleset ruleset = score.Ruleset.CreateInstance();
             if (ruleset is not ILegacyRuleset legacyRuleset)
                 return score.TotalScore;
 
@@ -474,14 +492,9 @@ double lowerEstimateOfComboPortionInStandardisedScore
                     break;
 
                 case 3:
-                    // in the mania case accuracy actually changes between score V1 and score V2 / standardised
-                    // (PERFECT weighting changes from 300 to 305),
-                    // so for better accuracy recompute accuracy locally based on hit statistics and use that instead,
-                    double scoreV2Accuracy = ComputeAccuracy(score);
-
                     convertedTotalScore = (long)Math.Round((
                         850000 * comboProportion
-                        + 150000 * Math.Pow(scoreV2Accuracy, 2 + 2 * scoreV2Accuracy)
+                        + 150000 * Math.Pow(score.Accuracy, 2 + 2 * score.Accuracy)
                         + bonusProportion) * modMultiplier);
                     break;
 
@@ -584,11 +597,8 @@ double estimateDroppedComboScoreAfterMiss(int lengthOfComboAfterMiss)
             }
         }
 
-        public static double ComputeAccuracy(ScoreInfo scoreInfo)
+        private static double computeAccuracy(ScoreInfo scoreInfo, ScoreProcessor scoreProcessor)
         {
-            Ruleset ruleset = scoreInfo.Ruleset.CreateInstance();
-            ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor();
-
             int baseScore = scoreInfo.Statistics.Where(kvp => kvp.Key.AffectsAccuracy())
                                      .Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key));
             int maxBaseScore = scoreInfo.MaximumStatistics.Where(kvp => kvp.Key.AffectsAccuracy())
@@ -597,6 +607,18 @@ public static double ComputeAccuracy(ScoreInfo scoreInfo)
             return maxBaseScore == 0 ? 1 : baseScore / (double)maxBaseScore;
         }
 
+        public static ScoreRank ComputeRank(ScoreInfo scoreInfo) => computeRank(scoreInfo, scoreInfo.Ruleset.CreateInstance().CreateScoreProcessor());
+
+        private static ScoreRank computeRank(ScoreInfo scoreInfo, ScoreProcessor scoreProcessor)
+        {
+            var rank = scoreProcessor.RankFromScore(scoreInfo.Accuracy, scoreInfo.Statistics);
+
+            foreach (var mod in scoreInfo.Mods.OfType<IApplicableToScoreProcessor>())
+                rank = mod.AdjustRank(rank, scoreInfo.Accuracy);
+
+            return rank;
+        }
+
         /// <summary>
         /// Used to populate the <paramref name="score"/> model using data parsed from its corresponding replay file.
         /// </summary>
diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs
index 80e751422edb..a09282931746 100644
--- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs
+++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs
@@ -370,7 +370,7 @@ private void updateRank()
             if (rank.Value == ScoreRank.F)
                 return;
 
-            rank.Value = RankFromAccuracy(Accuracy.Value);
+            rank.Value = RankFromScore(Accuracy.Value, ScoreResultCounts);
             foreach (var mod in Mods.Value.OfType<IApplicableToScoreProcessor>())
                 rank.Value = mod.AdjustRank(Rank.Value, Accuracy.Value);
         }
@@ -505,7 +505,7 @@ public void SetScoreProcessorStatistics(ScoreProcessorStatistics statistics)
         /// <summary>
         /// Given an accuracy (0..1), return the correct <see cref="ScoreRank"/>.
         /// </summary>
-        public virtual ScoreRank RankFromAccuracy(double accuracy)
+        public virtual ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary<HitResult, int> results)
         {
             if (accuracy == accuracy_cutoff_x)
                 return ScoreRank.X;
diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs
index b30fc7aee1e6..e51a95798b3b 100644
--- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs
+++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs
@@ -4,6 +4,7 @@
 #nullable disable
 
 using System;
+using System.Collections.Generic;
 using System.Diagnostics;
 using System.IO;
 using System.Linq;
@@ -19,6 +20,7 @@
 using osu.Game.Rulesets;
 using osu.Game.Rulesets.Mods;
 using osu.Game.Rulesets.Replays;
+using osu.Game.Rulesets.Scoring;
 using SharpCompress.Compressors.LZMA;
 
 namespace osu.Game.Scoring.Legacy
@@ -38,7 +40,6 @@ public Score Parse(Stream stream)
             };
 
             WorkingBeatmap workingBeatmap;
-            byte[] compressedScoreInfo = null;
 
             using (SerializationReader sr = new SerializationReader(stream))
             {
@@ -107,6 +108,8 @@ public Score Parse(Stream stream)
                 else if (version >= 20121008)
                     scoreInfo.LegacyOnlineID = sr.ReadInt32();
 
+                byte[] compressedScoreInfo = null;
+
                 if (version >= 30000001)
                     compressedScoreInfo = sr.ReadByteArray();
 
@@ -130,10 +133,12 @@ public Score Parse(Stream stream)
                 }
             }
 
-            if (score.ScoreInfo.IsLegacyScore || compressedScoreInfo == null)
-                PopulateLegacyAccuracyAndRank(score.ScoreInfo);
-            else
-                populateLazerAccuracyAndRank(score.ScoreInfo);
+            PopulateMaximumStatistics(score.ScoreInfo, workingBeatmap);
+
+            if (score.ScoreInfo.IsLegacyScore)
+                score.ScoreInfo.LegacyTotalScore = score.ScoreInfo.TotalScore;
+
+            StandardisedScoreMigrationTools.UpdateFromLegacy(score.ScoreInfo, workingBeatmap);
 
             // before returning for database import, we must restore the database-sourced BeatmapInfo.
             // if not, the clone operation in GetPlayableBeatmap will cause a dereference and subsequent database exception.
@@ -171,121 +176,65 @@ private void readCompressedData(byte[] data, Action<StreamReader> readFunc)
         }
 
         /// <summary>
-        /// Populates the accuracy of a given <see cref="ScoreInfo"/> from its contained statistics.
+        /// Populates the <see cref="ScoreInfo.MaximumStatistics"/> for a given <see cref="ScoreInfo"/>.
         /// </summary>
-        /// <remarks>
-        /// Legacy use only.
-        /// </remarks>
-        /// <param name="score">The <see cref="ScoreInfo"/> to populate.</param>
-        public static void PopulateLegacyAccuracyAndRank(ScoreInfo score)
+        /// <param name="score">The score to populate the statistics of.</param>
+        /// <param name="workingBeatmap">The corresponding <see cref="WorkingBeatmap"/>.</param>
+        internal static void PopulateMaximumStatistics(ScoreInfo score, WorkingBeatmap workingBeatmap)
         {
-            int countMiss = score.GetCountMiss() ?? 0;
-            int count50 = score.GetCount50() ?? 0;
-            int count100 = score.GetCount100() ?? 0;
-            int count300 = score.GetCount300() ?? 0;
-            int countGeki = score.GetCountGeki() ?? 0;
-            int countKatu = score.GetCountKatu() ?? 0;
-
-            switch (score.Ruleset.OnlineID)
-            {
-                case 0:
-                {
-                    int totalHits = count50 + count100 + count300 + countMiss;
-                    score.Accuracy = totalHits > 0 ? (double)(count50 * 50 + count100 * 100 + count300 * 300) / (totalHits * 300) : 1;
-
-                    float ratio300 = (float)count300 / totalHits;
-                    float ratio50 = (float)count50 / totalHits;
-
-                    if (ratio300 == 1)
-                        score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.XH : ScoreRank.X;
-                    else if (ratio300 > 0.9 && ratio50 <= 0.01 && countMiss == 0)
-                        score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.SH : ScoreRank.S;
-                    else if ((ratio300 > 0.8 && countMiss == 0) || ratio300 > 0.9)
-                        score.Rank = ScoreRank.A;
-                    else if ((ratio300 > 0.7 && countMiss == 0) || ratio300 > 0.8)
-                        score.Rank = ScoreRank.B;
-                    else if (ratio300 > 0.6)
-                        score.Rank = ScoreRank.C;
-                    else
-                        score.Rank = ScoreRank.D;
-                    break;
-                }
+            Debug.Assert(score.BeatmapInfo != null);
 
-                case 1:
-                {
-                    int totalHits = count50 + count100 + count300 + countMiss;
-                    score.Accuracy = totalHits > 0 ? (double)(count100 * 150 + count300 * 300) / (totalHits * 300) : 1;
-
-                    float ratio300 = (float)count300 / totalHits;
-                    float ratio50 = (float)count50 / totalHits;
-
-                    if (ratio300 == 1)
-                        score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.XH : ScoreRank.X;
-                    else if (ratio300 > 0.9 && ratio50 <= 0.01 && countMiss == 0)
-                        score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.SH : ScoreRank.S;
-                    else if ((ratio300 > 0.8 && countMiss == 0) || ratio300 > 0.9)
-                        score.Rank = ScoreRank.A;
-                    else if ((ratio300 > 0.7 && countMiss == 0) || ratio300 > 0.8)
-                        score.Rank = ScoreRank.B;
-                    else if (ratio300 > 0.6)
-                        score.Rank = ScoreRank.C;
-                    else
-                        score.Rank = ScoreRank.D;
-                    break;
-                }
+            if (score.MaximumStatistics.Select(kvp => kvp.Value).Sum() > 0)
+                return;
 
-                case 2:
-                {
-                    int totalHits = count50 + count100 + count300 + countMiss + countKatu;
-                    score.Accuracy = totalHits > 0 ? (double)(count50 + count100 + count300) / totalHits : 1;
-
-                    if (score.Accuracy == 1)
-                        score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.XH : ScoreRank.X;
-                    else if (score.Accuracy > 0.98)
-                        score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.SH : ScoreRank.S;
-                    else if (score.Accuracy > 0.94)
-                        score.Rank = ScoreRank.A;
-                    else if (score.Accuracy > 0.9)
-                        score.Rank = ScoreRank.B;
-                    else if (score.Accuracy > 0.85)
-                        score.Rank = ScoreRank.C;
-                    else
-                        score.Rank = ScoreRank.D;
-                    break;
-                }
+            var ruleset = score.Ruleset.Detach();
+            var rulesetInstance = ruleset.CreateInstance();
+            var scoreProcessor = rulesetInstance.CreateScoreProcessor();
 
-                case 3:
+            // Populate the maximum statistics.
+            HitResult maxBasicResult = rulesetInstance.GetHitResults()
+                                                      .Select(h => h.result)
+                                                      .Where(h => h.IsBasic()).MaxBy(scoreProcessor.GetBaseScoreForResult);
+
+            foreach ((HitResult result, int count) in score.Statistics)
+            {
+                switch (result)
                 {
-                    int totalHits = count50 + count100 + count300 + countMiss + countGeki + countKatu;
-                    score.Accuracy = totalHits > 0 ? (double)(count50 * 50 + count100 * 100 + countKatu * 200 + (count300 + countGeki) * 300) / (totalHits * 300) : 1;
-
-                    if (score.Accuracy == 1)
-                        score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.XH : ScoreRank.X;
-                    else if (score.Accuracy > 0.95)
-                        score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.SH : ScoreRank.S;
-                    else if (score.Accuracy > 0.9)
-                        score.Rank = ScoreRank.A;
-                    else if (score.Accuracy > 0.8)
-                        score.Rank = ScoreRank.B;
-                    else if (score.Accuracy > 0.7)
-                        score.Rank = ScoreRank.C;
-                    else
-                        score.Rank = ScoreRank.D;
-                    break;
+                    case HitResult.LargeTickHit:
+                    case HitResult.LargeTickMiss:
+                        score.MaximumStatistics[HitResult.LargeTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.LargeTickHit) + count;
+                        break;
+
+                    case HitResult.SmallTickHit:
+                    case HitResult.SmallTickMiss:
+                        score.MaximumStatistics[HitResult.SmallTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + count;
+                        break;
+
+                    case HitResult.IgnoreHit:
+                    case HitResult.IgnoreMiss:
+                    case HitResult.SmallBonus:
+                    case HitResult.LargeBonus:
+                        break;
+
+                    default:
+                        score.MaximumStatistics[maxBasicResult] = score.MaximumStatistics.GetValueOrDefault(maxBasicResult) + count;
+                        break;
                 }
             }
-        }
-
-        private void populateLazerAccuracyAndRank(ScoreInfo scoreInfo)
-        {
-            scoreInfo.Accuracy = StandardisedScoreMigrationTools.ComputeAccuracy(scoreInfo);
 
-            var rank = currentRuleset.CreateScoreProcessor().RankFromAccuracy(scoreInfo.Accuracy);
+            if (!score.IsLegacyScore)
+                return;
 
-            foreach (var mod in scoreInfo.Mods.OfType<IApplicableToScoreProcessor>())
-                rank = mod.AdjustRank(rank, scoreInfo.Accuracy);
+#pragma warning disable CS0618
+            // In osu! and osu!mania, some judgements affect combo but aren't stored to scores.
+            // A special hit result is used to pad out the combo value to match, based on the max combo from the difficulty attributes.
+            var calculator = rulesetInstance.CreateDifficultyCalculator(workingBeatmap);
+            var attributes = calculator.Calculate(score.Mods);
 
-            scoreInfo.Rank = rank;
+            int maxComboFromStatistics = score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Select(kvp => kvp.Value).DefaultIfEmpty(0).Sum();
+            if (attributes.MaxCombo > maxComboFromStatistics)
+                score.MaximumStatistics[HitResult.LegacyComboIncrease] = attributes.MaxCombo - maxComboFromStatistics;
+#pragma warning restore CS0618
         }
 
         private void readLegacyReplay(Replay replay, StreamReader reader)
diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs
index 389b20b5c8a5..c74980abb640 100644
--- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs
+++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs
@@ -43,9 +43,10 @@ public class LegacyScoreEncoder
         /// 30000012: Fix incorrect total score conversion on selected beatmaps after implementing the more correct
         /// <see cref="LegacyRulesetExtensions.CalculateDifficultyPeppyStars"/> method. Reconvert all scores.
         /// </description></item>
+        /// <item><description>30000013: All local scores will use lazer definitions of ranks for consistency. Recalculates the rank of all scores.</description></item>
         /// </list>
         /// </remarks>
-        public const int LATEST_VERSION = 30000012;
+        public const int LATEST_VERSION = 30000013;
 
         /// <summary>
         /// The first stable-compatible YYYYMMDD format version given to lazer usage of replays.
diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs
index 8e28707107ec..768c28cc38b6 100644
--- a/osu.Game/Scoring/ScoreImporter.cs
+++ b/osu.Game/Scoring/ScoreImporter.cs
@@ -17,7 +17,6 @@
 using osu.Game.Online.API;
 using osu.Game.Online.API.Requests;
 using osu.Game.Online.API.Requests.Responses;
-using osu.Game.Rulesets.Scoring;
 using Realms;
 
 namespace osu.Game.Scoring
@@ -91,8 +90,6 @@ protected override void Populate(ScoreInfo model, ArchiveReader? archive, Realm
             ArgumentNullException.ThrowIfNull(model.BeatmapInfo);
             ArgumentNullException.ThrowIfNull(model.Ruleset);
 
-            PopulateMaximumStatistics(model);
-
             if (string.IsNullOrEmpty(model.StatisticsJson))
                 model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics);
 
@@ -103,75 +100,6 @@ protected override void Populate(ScoreInfo model, ArchiveReader? archive, Realm
             // this requires: max combo, statistics, max statistics (where available), and mods to already be populated on the score.
             if (StandardisedScoreMigrationTools.ShouldMigrateToNewStandardised(model))
                 model.TotalScore = StandardisedScoreMigrationTools.GetNewStandardised(model);
-            else if (model.IsLegacyScore)
-            {
-                model.LegacyTotalScore = model.TotalScore;
-                StandardisedScoreMigrationTools.UpdateFromLegacy(model, beatmaps());
-            }
-        }
-
-        /// <summary>
-        /// Populates the <see cref="ScoreInfo.MaximumStatistics"/> for a given <see cref="ScoreInfo"/>.
-        /// </summary>
-        /// <param name="score">The score to populate the statistics of.</param>
-        public void PopulateMaximumStatistics(ScoreInfo score)
-        {
-            Debug.Assert(score.BeatmapInfo != null);
-
-            if (score.MaximumStatistics.Select(kvp => kvp.Value).Sum() > 0)
-                return;
-
-            var beatmap = score.BeatmapInfo!.Detach();
-            var ruleset = score.Ruleset.Detach();
-            var rulesetInstance = ruleset.CreateInstance();
-            var scoreProcessor = rulesetInstance.CreateScoreProcessor();
-
-            Debug.Assert(rulesetInstance != null);
-
-            // Populate the maximum statistics.
-            HitResult maxBasicResult = rulesetInstance.GetHitResults()
-                                                      .Select(h => h.result)
-                                                      .Where(h => h.IsBasic()).MaxBy(scoreProcessor.GetBaseScoreForResult);
-
-            foreach ((HitResult result, int count) in score.Statistics)
-            {
-                switch (result)
-                {
-                    case HitResult.LargeTickHit:
-                    case HitResult.LargeTickMiss:
-                        score.MaximumStatistics[HitResult.LargeTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.LargeTickHit) + count;
-                        break;
-
-                    case HitResult.SmallTickHit:
-                    case HitResult.SmallTickMiss:
-                        score.MaximumStatistics[HitResult.SmallTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + count;
-                        break;
-
-                    case HitResult.IgnoreHit:
-                    case HitResult.IgnoreMiss:
-                    case HitResult.SmallBonus:
-                    case HitResult.LargeBonus:
-                        break;
-
-                    default:
-                        score.MaximumStatistics[maxBasicResult] = score.MaximumStatistics.GetValueOrDefault(maxBasicResult) + count;
-                        break;
-                }
-            }
-
-            if (!score.IsLegacyScore)
-                return;
-
-#pragma warning disable CS0618
-            // In osu! and osu!mania, some judgements affect combo but aren't stored to scores.
-            // A special hit result is used to pad out the combo value to match, based on the max combo from the difficulty attributes.
-            var calculator = rulesetInstance.CreateDifficultyCalculator(beatmaps().GetWorkingBeatmap(beatmap));
-            var attributes = calculator.Calculate(score.Mods);
-
-            int maxComboFromStatistics = score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Select(kvp => kvp.Value).DefaultIfEmpty(0).Sum();
-            if (attributes.MaxCombo > maxComboFromStatistics)
-                score.MaximumStatistics[HitResult.LegacyComboIncrease] = attributes.MaxCombo - maxComboFromStatistics;
-#pragma warning restore CS0618
         }
 
         // Very naive local caching to improve performance of large score imports (where the username is usually the same for most or all scores).
diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs
index 02d9e0a2800e..1ee99e9e939e 100644
--- a/osu.Game/Scoring/ScoreManager.cs
+++ b/osu.Game/Scoring/ScoreManager.cs
@@ -5,6 +5,7 @@
 
 using System;
 using System.Collections.Generic;
+using System.Diagnostics;
 using System.Linq;
 using System.Linq.Expressions;
 using System.Threading;
@@ -26,6 +27,7 @@ namespace osu.Game.Scoring
 {
     public class ScoreManager : ModelManager<ScoreInfo>, IModelImporter<ScoreInfo>
     {
+        private readonly Func<BeatmapManager> beatmaps;
         private readonly OsuConfigManager configManager;
         private readonly ScoreImporter scoreImporter;
         private readonly LegacyScoreExporter scoreExporter;
@@ -44,6 +46,7 @@ public ScoreManager(RulesetStore rulesets, Func<BeatmapManager> beatmaps, Storag
                             OsuConfigManager configManager = null)
             : base(storage, realm)
         {
+            this.beatmaps = beatmaps;
             this.configManager = configManager;
 
             scoreImporter = new ScoreImporter(rulesets, beatmaps, storage, realm, api)
@@ -171,7 +174,11 @@ public Live<ScoreInfo> Import(ScoreInfo item, ArchiveReader archive = null, Impo
         /// Populates the <see cref="ScoreInfo.MaximumStatistics"/> for a given <see cref="ScoreInfo"/>.
         /// </summary>
         /// <param name="score">The score to populate the statistics of.</param>
-        public void PopulateMaximumStatistics(ScoreInfo score) => scoreImporter.PopulateMaximumStatistics(score);
+        public void PopulateMaximumStatistics(ScoreInfo score)
+        {
+            Debug.Assert(score.BeatmapInfo != null);
+            LegacyScoreDecoder.PopulateMaximumStatistics(score, beatmaps().GetWorkingBeatmap(score.BeatmapInfo.Detach()));
+        }
 
         #region Implementation of IPresentImports<ScoreInfo>
 
diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs
index 8cbca74466ad..e7e54d0fae0d 100644
--- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs
+++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs
@@ -21,6 +21,7 @@
 using osu.Game.Scoring;
 using osu.Game.Skinning;
 using osuTK;
+using osuTK.Graphics;
 
 namespace osu.Game.Screens.Ranking.Expanded.Accuracy
 {
@@ -111,6 +112,9 @@ public partial class AccuracyCircle : CompositeDrawable
         private readonly double accuracyD;
         private readonly bool withFlair;
 
+        private readonly bool isFailedSDueToMisses;
+        private RankText failedSRankText;
+
         public AccuracyCircle(ScoreInfo score, bool withFlair = false)
         {
             this.score = score;
@@ -119,10 +123,13 @@ public AccuracyCircle(ScoreInfo score, bool withFlair = false)
             ScoreProcessor scoreProcessor = score.Ruleset.CreateInstance().CreateScoreProcessor();
             accuracyX = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.X);
             accuracyS = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.S);
+
             accuracyA = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.A);
             accuracyB = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.B);
             accuracyC = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.C);
             accuracyD = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.D);
+
+            isFailedSDueToMisses = score.Accuracy >= accuracyS && score.Rank == ScoreRank.A;
         }
 
         [BackgroundDependencyLoader]
@@ -249,6 +256,9 @@ private void load()
 
             if (withFlair)
             {
+                if (isFailedSDueToMisses)
+                    AddInternal(failedSRankText = new RankText(ScoreRank.S));
+
                 AddRangeInternal(new Drawable[]
                 {
                     rankImpactSound = new PoolableSkinnableSample(new SampleInfo(impactSampleName)),
@@ -387,6 +397,31 @@ protected override void LoadComplete()
                         });
                     }
                 }
+
+                if (isFailedSDueToMisses)
+                {
+                    const double adjust_duration = 200;
+
+                    using (BeginDelayedSequence(TEXT_APPEAR_DELAY - adjust_duration))
+                    {
+                        failedSRankText.FadeIn(adjust_duration);
+
+                        using (BeginDelayedSequence(adjust_duration))
+                        {
+                            failedSRankText
+                                .FadeColour(Color4.Red, 800, Easing.Out)
+                                .RotateTo(10, 1000, Easing.Out)
+                                .MoveToY(100, 1000, Easing.In)
+                                .FadeOut(800, Easing.Out);
+
+                            accuracyCircle
+                                .FillTo(accuracyS - NOTCH_WIDTH_PERCENTAGE / 2 - visual_alignment_offset, 70, Easing.OutQuint);
+
+                            badges.Single(b => b.Rank == ScoreRank.S)
+                                  .FadeOut(70, Easing.OutQuint);
+                        }
+                    }
+                }
             }
         }
 
diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs
index 7af327828ed6..8aea6045ebb4 100644
--- a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs
+++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs
@@ -32,7 +32,7 @@ public partial class RankBadge : CompositeDrawable
         /// </summary>
         private readonly double displayPosition;
 
-        private readonly ScoreRank rank;
+        public readonly ScoreRank Rank;
 
         private Drawable rankContainer;
         private Drawable overlay;
@@ -47,7 +47,7 @@ public RankBadge(double accuracy, double position, ScoreRank rank)
         {
             Accuracy = accuracy;
             displayPosition = position;
-            this.rank = rank;
+            Rank = rank;
 
             RelativeSizeAxes = Axes.Both;
             Alpha = 0;
@@ -62,7 +62,7 @@ private void load()
                 Size = new Vector2(28, 14),
                 Children = new[]
                 {
-                    new DrawableRank(rank),
+                    new DrawableRank(Rank),
                     overlay = new CircularContainer
                     {
                         RelativeSizeAxes = Axes.Both,
@@ -71,7 +71,7 @@ private void load()
                         EdgeEffect = new EdgeEffectParameters
                         {
                             Type = EdgeEffectType.Glow,
-                            Colour = OsuColour.ForRank(rank).Opacity(0.2f),
+                            Colour = OsuColour.ForRank(Rank).Opacity(0.2f),
                             Radius = 10,
                         },
                         Child = new Box