From b21c6457b1a1febd004d508e3597815b64a2a6d4 Mon Sep 17 00:00:00 2001
From: Givikap120 <89256026+Givikap120@users.noreply.github.com>
Date: Thu, 9 Jan 2025 15:27:54 +0200
Subject: [PATCH] Punish speed PP for scores with high deviation (#30907)
---
.../Difficulty/OsuDifficultyAttributes.cs | 31 ++++-
.../Difficulty/OsuDifficultyCalculator.cs | 5 +
.../Difficulty/OsuPerformanceAttributes.cs | 3 +
.../Difficulty/OsuPerformanceCalculator.cs | 119 +++++++++++++++++-
.../Difficulty/DifficultyAttributes.cs | 1 +
5 files changed, 149 insertions(+), 10 deletions(-)
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
index 3b9a23df23a3..395f581b6539 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
@@ -62,21 +62,33 @@ public class OsuDifficultyAttributes : DifficultyAttributes
///
/// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc).
///
- ///
- /// Rate-adjusting mods don't directly affect the approach rate difficulty value, but have a perceived effect as a result of adjusting audio timing.
- ///
[JsonProperty("approach_rate")]
public double ApproachRate { get; set; }
///
/// The perceived overall difficulty inclusive of rate-adjusting mods (DT/HT/etc).
///
- ///
- /// Rate-adjusting mods don't directly affect the overall difficulty value, but have a perceived effect as a result of adjusting audio timing.
- ///
[JsonProperty("overall_difficulty")]
public double OverallDifficulty { get; set; }
+ ///
+ /// The perceived hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc).
+ ///
+ [JsonProperty("great_hit_window")]
+ public double GreatHitWindow { get; set; }
+
+ ///
+ /// The perceived hit window for an OK hit inclusive of rate-adjusting mods (DT/HT/etc).
+ ///
+ [JsonProperty("ok_hit_window")]
+ public double OkHitWindow { get; set; }
+
+ ///
+ /// The perceived hit window for a MEH hit inclusive of rate-adjusting mods (DT/HT/etc).
+ ///
+ [JsonProperty("meh_hit_window")]
+ public double MehHitWindow { get; set; }
+
///
/// The beatmap's drain rate. This doesn't scale with rate-adjusting mods.
///
@@ -107,6 +119,7 @@ public class OsuDifficultyAttributes : DifficultyAttributes
yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty);
yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
+ yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);
if (ShouldSerializeFlashlightDifficulty())
yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty);
@@ -117,6 +130,9 @@ public class OsuDifficultyAttributes : DifficultyAttributes
yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount);
yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount);
yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount);
+
+ yield return (ATTRIB_ID_OK_HIT_WINDOW, OkHitWindow);
+ yield return (ATTRIB_ID_MEH_HIT_WINDOW, MehHitWindow);
}
public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo)
@@ -128,12 +144,15 @@ public override void FromDatabaseAttributes(IReadOnlyDictionary val
OverallDifficulty = values[ATTRIB_ID_OVERALL_DIFFICULTY];
ApproachRate = values[ATTRIB_ID_APPROACH_RATE];
StarRating = values[ATTRIB_ID_DIFFICULTY];
+ GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];
AimDifficultStrainCount = values[ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT];
SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT];
SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT];
AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT];
+ OkHitWindow = values[ATTRIB_ID_OK_HIT_WINDOW];
+ MehHitWindow = values[ATTRIB_ID_MEH_HIT_WINDOW];
DrainRate = onlineInfo.DrainRate;
HitCircleCount = onlineInfo.CircleCount;
SliderCount = onlineInfo.SliderCount;
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
index d0f23735c309..5a61ea586a29 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
@@ -99,6 +99,8 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate;
+ double hitWindowOk = hitWindows.WindowFor(HitResult.Ok) / clockRate;
+ double hitWindowMeh = hitWindows.WindowFor(HitResult.Meh) / clockRate;
OsuDifficultyAttributes attributes = new OsuDifficultyAttributes
{
@@ -114,6 +116,9 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat
SpeedDifficultStrainCount = speedDifficultyStrainCount,
ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5,
OverallDifficulty = (80 - hitWindowGreat) / 6,
+ GreatHitWindow = hitWindowGreat,
+ OkHitWindow = hitWindowOk,
+ MehHitWindow = hitWindowMeh,
DrainRate = drainRate,
MaxCombo = beatmap.GetMaxCombo(),
HitCircleCount = hitCirclesCount,
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs
index 0aeaf7669f68..de4491a31b5b 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs
@@ -24,6 +24,9 @@ public class OsuPerformanceAttributes : PerformanceAttributes
[JsonProperty("effective_miss_count")]
public double EffectiveMissCount { get; set; }
+ [JsonProperty("speed_deviation")]
+ public double? SpeedDeviation { get; set; }
+
public override IEnumerable GetAttributesForDisplay()
{
foreach (var attribute in base.GetAttributesForDisplay())
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
index 5cf7a56d8af3..91cd2709668b 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
@@ -9,6 +9,7 @@
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
+using osu.Game.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty
{
@@ -40,6 +41,8 @@ public class OsuPerformanceCalculator : PerformanceCalculator
///
private double effectiveMissCount;
+ private double? speedDeviation;
+
public OsuPerformanceCalculator()
: base(new OsuRuleset())
{
@@ -110,10 +113,13 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s
effectiveMissCount = Math.Min(effectiveMissCount + countOk * okMultiplier + countMeh * mehMultiplier, totalHits);
}
+ speedDeviation = calculateSpeedDeviation(osuAttributes);
+
double aimValue = computeAimValue(score, osuAttributes);
double speedValue = computeSpeedValue(score, osuAttributes);
double accuracyValue = computeAccuracyValue(score, osuAttributes);
double flashlightValue = computeFlashlightValue(score, osuAttributes);
+
double totalValue =
Math.Pow(
Math.Pow(aimValue, 1.1) +
@@ -129,6 +135,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s
Accuracy = accuracyValue,
Flashlight = flashlightValue,
EffectiveMissCount = effectiveMissCount,
+ SpeedDeviation = speedDeviation,
Total = totalValue
};
}
@@ -198,7 +205,7 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut
private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attributes)
{
- if (score.Mods.Any(h => h is OsuModRelax))
+ if (score.Mods.Any(h => h is OsuModRelax) || speedDeviation == null)
return 0.0;
double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
@@ -230,6 +237,9 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib
speedValue *= 1.0 + 0.04 * (12.0 - attributes.ApproachRate);
}
+ double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes);
+ speedValue *= speedHighDeviationMultiplier;
+
// Calculate accuracy assuming the worst case scenario
double relevantTotalDiff = totalHits - attributes.SpeedNoteCount;
double relevantCountGreat = Math.Max(0, countGreat - relevantTotalDiff);
@@ -240,9 +250,6 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib
// Scale the speed value with accuracy and OD.
speedValue *= (0.95 + Math.Pow(Math.Max(0, attributes.OverallDifficulty), 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - attributes.OverallDifficulty) / 2);
- // Scale the speed value with # of 50s to punish doubletapping.
- speedValue *= Math.Pow(0.99, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0);
-
return speedValue;
}
@@ -310,12 +317,116 @@ private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes a
return flashlightValue;
}
+ ///
+ /// Estimates player's deviation on speed notes using , assuming worst-case.
+ /// Treats all speed notes as hit circles.
+ ///
+ private double? calculateSpeedDeviation(OsuDifficultyAttributes attributes)
+ {
+ if (totalSuccessfulHits == 0)
+ return null;
+
+ // Calculate accuracy assuming the worst case scenario
+ double speedNoteCount = attributes.SpeedNoteCount;
+ speedNoteCount += (totalHits - attributes.SpeedNoteCount) * 0.1;
+
+ // Assume worst case: all mistakes were on speed notes
+ double relevantCountMiss = Math.Min(countMiss, speedNoteCount);
+ double relevantCountMeh = Math.Min(countMeh, speedNoteCount - relevantCountMiss);
+ double relevantCountOk = Math.Min(countOk, speedNoteCount - relevantCountMiss - relevantCountMeh);
+ double relevantCountGreat = Math.Max(0, speedNoteCount - relevantCountMiss - relevantCountMeh - relevantCountOk);
+
+ return calculateDeviation(attributes, relevantCountGreat, relevantCountOk, relevantCountMeh, relevantCountMiss);
+ }
+
+ ///
+ /// Estimates the player's tap deviation based on the OD, given number of greats, oks, mehs and misses,
+ /// assuming the player's mean hit error is 0. The estimation is consistent in that two SS scores on the same map with the same settings
+ /// will always return the same deviation. Misses are ignored because they are usually due to misaiming.
+ /// Greats and oks are assumed to follow a normal distribution, whereas mehs are assumed to follow a uniform distribution.
+ ///
+ private double? calculateDeviation(OsuDifficultyAttributes attributes, double relevantCountGreat, double relevantCountOk, double relevantCountMeh, double relevantCountMiss)
+ {
+ if (relevantCountGreat + relevantCountOk + relevantCountMeh <= 0)
+ return null;
+
+ double objectCount = relevantCountGreat + relevantCountOk + relevantCountMeh + relevantCountMiss;
+
+ double hitWindowGreat = attributes.GreatHitWindow;
+ double hitWindowOk = attributes.OkHitWindow;
+ double hitWindowMeh = attributes.MehHitWindow;
+
+ // The probability that a player hits a circle is unknown, but we can estimate it to be
+ // the number of greats on circles divided by the number of circles, and then add one
+ // to the number of circles as a bias correction.
+ double n = Math.Max(1, objectCount - relevantCountMiss - relevantCountMeh);
+ const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed).
+
+ // Proportion of greats hit on circles, ignoring misses and 50s.
+ double p = relevantCountGreat / n;
+
+ // We can be 99% confident that p is at least this value.
+ double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);
+
+ // Compute the deviation assuming greats and oks are normally distributed, and mehs are uniformly distributed.
+ // Begin with greats and oks first. Ignoring mehs, we can be 99% confident that the deviation is not higher than:
+ double deviation = hitWindowGreat / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound));
+
+ double randomValue = Math.Sqrt(2 / Math.PI) * hitWindowOk * Math.Exp(-0.5 * Math.Pow(hitWindowOk / deviation, 2))
+ / (deviation * SpecialFunctions.Erf(hitWindowOk / (Math.Sqrt(2) * deviation)));
+
+ deviation *= Math.Sqrt(1 - randomValue);
+
+ // Value deviation approach as greatCount approaches 0
+ double limitValue = hitWindowOk / Math.Sqrt(3);
+
+ // If precision is not enough to compute true deviation - use limit value
+ if (pLowerBound == 0 || randomValue >= 1 || deviation > limitValue)
+ deviation = limitValue;
+
+ // Then compute the variance for mehs.
+ double mehVariance = (hitWindowMeh * hitWindowMeh + hitWindowOk * hitWindowMeh + hitWindowOk * hitWindowOk) / 3;
+
+ // Find the total deviation.
+ deviation = Math.Sqrt(((relevantCountGreat + relevantCountOk) * Math.Pow(deviation, 2) + relevantCountMeh * mehVariance) / (relevantCountGreat + relevantCountOk + relevantCountMeh));
+
+ return deviation;
+ }
+
+ // Calculates multiplier for speed to account for improper tapping based on the deviation and speed difficulty
+ // https://www.desmos.com/calculator/dmogdhzofn
+ private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attributes)
+ {
+ if (speedDeviation == null)
+ return 0;
+
+ double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
+
+ // Decides a point where the PP value achieved compared to the speed deviation is assumed to be tapped improperly. Any PP above this point is considered "excess" speed difficulty.
+ // This is used to cause PP above the cutoff to scale logarithmically towards the original speed value thus nerfing the value.
+ double excessSpeedDifficultyCutoff = 100 + 220 * Math.Pow(22 / speedDeviation.Value, 6.5);
+
+ if (speedValue <= excessSpeedDifficultyCutoff)
+ return 1.0;
+
+ const double scale = 50;
+ double adjustedSpeedValue = scale * (Math.Log((speedValue - excessSpeedDifficultyCutoff) / scale + 1) + excessSpeedDifficultyCutoff / scale);
+
+ // 200 UR and less are considered tapped correctly to ensure that normal scores will be punished as little as possible
+ double lerp = 1 - Math.Clamp((speedDeviation.Value - 20) / (24 - 20), 0, 1);
+ adjustedSpeedValue = double.Lerp(adjustedSpeedValue, speedValue, lerp);
+
+ return adjustedSpeedValue / speedValue;
+ }
+
// Miss penalty assumes that a player will miss on the hardest parts of a map,
// so we use the amount of relatively difficult sections to adjust miss penalty
// to make it more punishing on maps with lower amount of hard sections.
private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1);
private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0);
+
private int totalHits => countGreat + countOk + countMeh + countMiss;
+ private int totalSuccessfulHits => countGreat + countOk + countMeh;
private int totalImperfectHits => countOk + countMeh + countMiss;
}
}
diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
index f5ed5a180b58..1d6cee043bfb 100644
--- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
+++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
@@ -31,6 +31,7 @@ public class DifficultyAttributes
protected const int ATTRIB_ID_OK_HIT_WINDOW = 27;
protected const int ATTRIB_ID_MONO_STAMINA_FACTOR = 29;
protected const int ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT = 31;
+ protected const int ATTRIB_ID_MEH_HIT_WINDOW = 33;
///
/// The mods which were applied to the beatmap.