diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs
index 0594c80390..6f2f8d64fa 100644
--- a/osu.Game/Database/StandardisedScoreMigrationTools.cs
+++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs
@@ -415,7 +415,7 @@ namespace osu.Game.Database
// Calculate how many times the longest combo the user has achieved in the play can repeat
// without exceeding the combo portion in score V1 as achieved by the player.
- // This is a pessimistic estimate; it intentionally does not operate on object count and uses only score instead.
+ // This intentionally does not operate on object count and uses only score instead.
double maximumOccurrencesOfLongestCombo = Math.Floor(comboPortionInScoreV1 / comboPortionFromLongestComboInScoreV1);
double comboPortionFromRepeatedLongestCombosInScoreV1 = maximumOccurrencesOfLongestCombo * comboPortionFromLongestComboInScoreV1;
@@ -426,13 +426,12 @@ namespace osu.Game.Database
// ...and then based on that raw combo length, we calculate how much this last combo is worth in standardised score.
double remainingComboPortionInStandardisedScore = Math.Pow(remainingCombo, 1 + ScoreProcessor.COMBO_EXPONENT);
- double lowerEstimateOfComboPortionInStandardisedScore
+ double scoreBasedEstimateOfComboPortionInStandardisedScore
= maximumOccurrencesOfLongestCombo * comboPortionFromLongestComboInStandardisedScore
+ remainingComboPortionInStandardisedScore;
// Compute approximate upper estimate new score for that play.
// This time, divide the remaining combo among remaining objects equally to achieve longest possible combo lengths.
- // There is no rigorous proof that doing this will yield a correct upper bound, but it seems to work out in practice.
remainingComboPortionInScoreV1 = comboPortionInScoreV1 - comboPortionFromLongestComboInScoreV1;
double remainingCountOfObjectsGivingCombo = maximumLegacyCombo - score.MaxCombo - score.Statistics.GetValueOrDefault(HitResult.Miss);
// Because we assumed all combos were equal, `remainingComboPortionInScoreV1`
@@ -449,7 +448,17 @@ namespace osu.Game.Database
// we can skip adding the 1 and just multiply by x ^ 0.5.
remainingComboPortionInStandardisedScore = remainingCountOfObjectsGivingCombo * Math.Pow(lengthOfRemainingCombos, ScoreProcessor.COMBO_EXPONENT);
- double upperEstimateOfComboPortionInStandardisedScore = comboPortionFromLongestComboInStandardisedScore + remainingComboPortionInStandardisedScore;
+ double objectCountBasedEstimateOfComboPortionInStandardisedScore = comboPortionFromLongestComboInStandardisedScore + remainingComboPortionInStandardisedScore;
+
+ // Enforce some invariants on both of the estimates.
+ // In rare cases they can produce invalid results.
+ scoreBasedEstimateOfComboPortionInStandardisedScore =
+ Math.Clamp(scoreBasedEstimateOfComboPortionInStandardisedScore, 0, maximumAchievableComboPortionInStandardisedScore);
+ objectCountBasedEstimateOfComboPortionInStandardisedScore =
+ Math.Clamp(objectCountBasedEstimateOfComboPortionInStandardisedScore, 0, maximumAchievableComboPortionInStandardisedScore);
+
+ double lowerEstimateOfComboPortionInStandardisedScore = Math.Min(scoreBasedEstimateOfComboPortionInStandardisedScore, objectCountBasedEstimateOfComboPortionInStandardisedScore);
+ double upperEstimateOfComboPortionInStandardisedScore = Math.Max(scoreBasedEstimateOfComboPortionInStandardisedScore, objectCountBasedEstimateOfComboPortionInStandardisedScore);
// Approximate by combining lower and upper estimates.
// As the lower-estimate is very pessimistic, we use a 30/70 ratio
diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs
index 775d87f3f2..4ee4231925 100644
--- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs
+++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs
@@ -45,9 +45,10 @@ namespace osu.Game.Scoring.Legacy
///
/// - 30000013: All local scores will use lazer definitions of ranks for consistency. Recalculates the rank of all scores.
/// - 30000014: Fix edge cases in conversion for osu! scores on selected beatmaps. Reconvert all scores.
+ /// - 30000015: Fix osu! standardised score estimation algorithm violating basic invariants. Reconvert all scores.
///
///
- public const int LATEST_VERSION = 30000014;
+ public const int LATEST_VERSION = 30000015;
///
/// The first stable-compatible YYYYMMDD format version given to lazer usage of replays.