From 1b230b3fdd0eb984768618c2e79956f094c797ab Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Sat, 29 Nov 2025 21:01:15 +0200 Subject: [PATCH 001/121] Remove drain rate attribute (#35817) --- osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs | 6 ------ osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs | 3 --- .../Difficulty/OsuPerformanceCalculator.cs | 5 ++++- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index 9cab45414266..be5e0385ee1f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -84,11 +84,6 @@ public class OsuDifficultyAttributes : DifficultyAttributes [JsonProperty("maximum_legacy_combo_score")] public double MaximumLegacyComboScore { get; set; } - /// - /// The beatmap's drain rate. This doesn't scale with rate-adjusting mods. - /// - public double DrainRate { get; set; } - /// /// The number of hitcircles in the beatmap. /// @@ -147,7 +142,6 @@ public override void FromDatabaseAttributes(IReadOnlyDictionary val NestedScorePerObject = values[ATTRIB_ID_NESTED_SCORE_PER_OBJECT]; LegacyScoreBaseMultiplier = values[ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER]; MaximumLegacyComboScore = values[ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE]; - DrainRate = onlineInfo.DrainRate; HitCircleCount = onlineInfo.CircleCount; SliderCount = onlineInfo.SliderCount; SpinnerCount = onlineInfo.SpinnerCount; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index d7fa159d10cc..8c78008bcb02 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -80,8 +80,6 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat int totalHits = beatmap.HitObjects.Count; - double drainRate = beatmap.Difficulty.DrainRate; - double aimDifficultyValue = aim.DifficultyValue(); double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue(); double speedDifficultyValue = speed.DifficultyValue(); @@ -132,7 +130,6 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat SpeedDifficultStrainCount = speedDifficultStrainCount, AimTopWeightedSliderFactor = aimTopWeightedSliderFactor, SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor, - DrainRate = drainRate, MaxCombo = beatmap.GetMaxCombo(), HitCircleCount = hitCircleCount, SliderCount = sliderCount, diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 741ddb3d4fdd..b2ffc5aa0eab 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -50,8 +50,10 @@ public class OsuPerformanceCalculator : PerformanceCalculator private double greatHitWindow; private double okHitWindow; private double mehHitWindow; + private double overallDifficulty; private double approachRate; + private double drainRate; private double? speedDeviation; @@ -95,6 +97,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s approachRate = OsuDifficultyCalculator.CalculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate); overallDifficulty = OsuDifficultyCalculator.CalculateRateAdjustedOverallDifficulty(difficulty.OverallDifficulty, clockRate); + drainRate = difficulty.DrainRate; double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes); double? scoreBasedEstimatedMissCount = null; @@ -211,7 +214,7 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. if (score.Mods.Any(m => m is OsuModBlinds)) - aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate); + aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * drainRate * drainRate); else if (score.Mods.Any(m => m is OsuModTraceable)) { aimValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate, sliderFactor: attributes.SliderFactor); From 346461ffdc24f9ae0e05c824271eba347062bb79 Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Sat, 13 Dec 2025 14:29:20 +0200 Subject: [PATCH 002/121] Skip score-based miss count estimation for ScoreV2 (#35962) * Add basic scorev2 support * Revert unnecessary score statistic changes * Fix CI * Revert changes * Disable score-based misscount for scoreV2 --- osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index b2ffc5aa0eab..3bc486d7e663 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -102,7 +102,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes); double? scoreBasedEstimatedMissCount = null; - if (usingClassicSliderAccuracy && score.LegacyTotalScore != null) + if (usingClassicSliderAccuracy && !usingScoreV2 && score.LegacyTotalScore != null) { var legacyScoreMissCalculator = new OsuLegacyScoreMissCalculator(score, osuAttributes); scoreBasedEstimatedMissCount = legacyScoreMissCalculator.Calculate(); From 16c1adf0f73b1063828c7daf5b66da61cf9228bc Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:19:16 +0500 Subject: [PATCH 003/121] Use `DifficultyCalculationUtils.Norm` (#36064) --- .../Difficulty/OsuDifficultyCalculator.cs | 10 +++------- .../Difficulty/OsuPerformanceCalculator.cs | 9 ++------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 8c78008bcb02..509619df4c6c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -8,6 +8,7 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Skills; @@ -107,12 +108,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); - double basePerformance = - Math.Pow( - Math.Pow(baseAimPerformance, 1.1) + - Math.Pow(baseSpeedPerformance, 1.1) + - Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 - ); + double basePerformance = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseFlashlightPerformance); double starRating = calculateStarRating(basePerformance); @@ -147,7 +143,7 @@ private double calculateMechanicalDifficultyRating(double aimDifficultyValue, do double aimValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue)); double speedValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(speedDifficultyValue)); - double totalValue = Math.Pow(Math.Pow(aimValue, 1.1) + Math.Pow(speedValue, 1.1), 1 / 1.1); + double totalValue = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, aimValue, speedValue); return calculateStarRating(totalValue); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 3bc486d7e663..66d6cc32cd94 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -20,6 +20,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty public class OsuPerformanceCalculator : PerformanceCalculator { public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + public const double PERFORMANCE_NORM_EXPONENT = 1.1; private bool usingClassicSliderAccuracy; private bool usingScoreV2; @@ -145,13 +146,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s double accuracyValue = computeAccuracyValue(score, osuAttributes); double flashlightValue = computeFlashlightValue(score, osuAttributes); - double totalValue = - Math.Pow( - Math.Pow(aimValue, 1.1) + - Math.Pow(speedValue, 1.1) + - Math.Pow(accuracyValue, 1.1) + - Math.Pow(flashlightValue, 1.1), 1.0 / 1.1 - ) * multiplier; + double totalValue = DifficultyCalculationUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, flashlightValue) * multiplier; return new OsuPerformanceAttributes { From 99daec3835045477546effe3b622c2e57bb254c7 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Fri, 19 Dec 2025 17:59:22 +0500 Subject: [PATCH 004/121] Simplify osu! star rating calculations (#36063) * Simplify star rating calculations * Refactor --- .../Difficulty/OsuDifficultyCalculator.cs | 7 +------ .../Difficulty/OsuPerformanceCalculator.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs | 2 +- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 509619df4c6c..32e3829f7444 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -22,8 +22,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuDifficultyCalculator : DifficultyCalculator { - private const double star_rating_multiplier = 0.0265; - public override int Version => 20250306; public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) @@ -150,10 +148,7 @@ private double calculateMechanicalDifficultyRating(double aimDifficultyValue, do private double calculateStarRating(double basePerformance) { - if (basePerformance <= 0.00001) - return 0; - - return Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4); + return Math.Cbrt(basePerformance * OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER); } protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 66d6cc32cd94..9dde9dfdea7c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuPerformanceCalculator : PerformanceCalculator { - public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + public const double PERFORMANCE_BASE_MULTIPLIER = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. public const double PERFORMANCE_NORM_EXPONENT = 1.1; private bool usingClassicSliderAccuracy; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs index 6823512cef12..916b6e7b2d51 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs @@ -57,6 +57,6 @@ public override double DifficultyValue() return difficulty; } - public static double DifficultyToPerformance(double difficulty) => Math.Pow(5.0 * Math.Max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100000.0; + public static double DifficultyToPerformance(double difficulty) => 4.0 * Math.Pow(difficulty, 3.0); } } From 90e820b76ad2624ac5c3a8e0b962aab6499d36a8 Mon Sep 17 00:00:00 2001 From: Nathan Corbett <75299710+Finadoggie@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:58:31 -0800 Subject: [PATCH 005/121] Uses real distance in speed instead of mishandling variables (#35387) Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index a58c1d36853e..e6197b177bdc 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -51,8 +51,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnly if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus) speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2); - double travelDistance = osuPrevObj?.TravelDistance ?? 0; - double distance = travelDistance + osuCurrObj.MinimumJumpDistance; + double travelDistance = osuPrevObj?.LazyTravelDistance ?? 0; + double distance = travelDistance + osuCurrObj.LazyJumpDistance; // Cap distance at single_spacing_threshold distance = Math.Min(distance, single_spacing_threshold); From 906df15cbe81898ecc6aca9115b4f1fa69332214 Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Tue, 23 Dec 2025 01:50:35 +0200 Subject: [PATCH 006/121] change FL calc depending on HD settings (#33019) Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- .../Difficulty/Evaluators/FlashlightEvaluator.cs | 10 +++++++--- osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs | 6 +----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs index 55192df7af93..e828eba1cc61 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs @@ -2,8 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators @@ -28,7 +32,7 @@ public static class FlashlightEvaluator /// and whether the hidden mod is enabled. /// /// - public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden) + public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList mods) { if (current.BaseObject is Spinner) return 0; @@ -66,7 +70,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd double stackNerf = Math.Min(1.0, (currentObj.LazyJumpDistance / scalingFactor) / 25.0); // Bonus based on how visible the object is. - double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, hidden)); + double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, mods.OfType().Any(m => !m.OnlyFadeApproachCircles.Value))); result += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime; @@ -84,7 +88,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd result = Math.Pow(smallDistNerf * result, 2.0); // Additional bonus for Hidden due to there being no approach circles. - if (hidden) + if (mods.OfType().Any()) result *= 1.0 + hidden_bonus; // Nerf patterns with repeated angles. diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index 6c839eac3fef..70d61250bbf1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -7,7 +7,6 @@ using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; -using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { @@ -16,12 +15,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Flashlight : StrainSkill { - private readonly bool hasHiddenMod; - public Flashlight(Mod[] mods) : base(mods) { - hasHiddenMod = mods.Any(m => m is OsuModHidden); } private double skillMultiplier => 0.05512; @@ -36,7 +32,7 @@ public Flashlight(Mod[] mods) protected override double StrainValueAt(DifficultyHitObject current) { currentStrain *= strainDecay(current.DeltaTime); - currentStrain += FlashlightEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * skillMultiplier; + currentStrain += FlashlightEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier; return currentStrain; } From 6356d50f13942787c8c29d1bcec052d4f80cc4ac Mon Sep 17 00:00:00 2001 From: Nathan Corbett <75299710+Finadoggie@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:16:01 -0800 Subject: [PATCH 007/121] Use number calculated at runtime from DecayWeight (#33733) Replaces a constant that assumes DecayWeight == 0.9 Co-authored-by: James Wilson Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index b6272bf56b85..63e639cb8774 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -73,7 +73,7 @@ public virtual double CountTopWeightedStrains() if (ObjectStrains.Count == 0) return 0.0; - double consistentTopStrain = DifficultyValue() / 10; // What would the top strain be if all strain values were identical + double consistentTopStrain = DifficultyValue() * (1 - DecayWeight); // What would the top strain be if all strain values were identical if (consistentTopStrain == 0) return ObjectStrains.Count; From d150e273818b1e8e78743f0c4f48970478d06212 Mon Sep 17 00:00:00 2001 From: James Wilson Date: Tue, 23 Dec 2025 00:41:24 +0000 Subject: [PATCH 008/121] Reduce calls to `DifficultyValue` where possible (#36112) --- .../Difficulty/OsuDifficultyCalculator.cs | 18 +++++++++--------- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 3 ++- .../Difficulty/Skills/Speed.cs | 3 ++- .../Difficulty/TaikoDifficultyCalculator.cs | 6 ++++-- .../Rulesets/Difficulty/Skills/StrainSkill.cs | 4 ++-- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 32e3829f7444..edaef0aa75cd 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -55,17 +55,21 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat var speed = skills.OfType().Single(); var flashlight = skills.OfType().SingleOrDefault(); + double aimDifficultyValue = aim.DifficultyValue(); + double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue(); + double speedDifficultyValue = speed.DifficultyValue(); + double speedNotes = speed.RelevantNoteCount(); - double aimDifficultStrainCount = aim.CountTopWeightedStrains(); - double speedDifficultStrainCount = speed.CountTopWeightedStrains(); + double aimDifficultStrainCount = aim.CountTopWeightedStrains(aimDifficultyValue); + double speedDifficultStrainCount = speed.CountTopWeightedStrains(speedDifficultyValue); - double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders(); - double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains(); + double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders(aimNoSlidersDifficultyValue); + double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains(aimNoSlidersDifficultyValue); double aimTopWeightedSliderFactor = aimNoSlidersTopWeightedSliderCount / Math.Max(1, aimNoSlidersDifficultStrainCount - aimNoSlidersTopWeightedSliderCount); - double speedTopWeightedSliderCount = speed.CountTopWeightedSliders(); + double speedTopWeightedSliderCount = speed.CountTopWeightedSliders(speedDifficultyValue); double speedTopWeightedSliderFactor = speedTopWeightedSliderCount / Math.Max(1, speedDifficultStrainCount - speedTopWeightedSliderCount); double difficultSliders = aim.GetDifficultSliders(); @@ -79,10 +83,6 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat int totalHits = beatmap.HitObjects.Count; - double aimDifficultyValue = aim.DifficultyValue(); - double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue(); - double speedDifficultyValue = speed.DifficultyValue(); - double mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue); double sliderFactor = aimDifficultyValue > 0 ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) : 1; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 5816d27a5e81..dbdfef4199ed 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -60,6 +60,7 @@ public double GetDifficultSliders() return sliderStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0)))); } - public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue()); + public double CountTopWeightedSliders(double difficultyValue) + => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, difficultyValue); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 8fe3df43470e..105d3956ac6b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -64,6 +64,7 @@ public double RelevantNoteCount() return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); } - public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue()); + public double CountTopWeightedSliders(double difficultyValue) + => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, difficultyValue); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index cdb5a36f659e..4905dd327594 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -108,14 +108,16 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat var stamina = skills.OfType().Single(s => !s.SingleColourStamina); var singleColourStamina = skills.OfType().Single(s => s.SingleColourStamina); + double staminaDifficultyValue = stamina.DifficultyValue(); + double rhythmSkill = rhythm.DifficultyValue() * rhythm_skill_multiplier; double readingSkill = reading.DifficultyValue() * reading_skill_multiplier; double colourSkill = colour.DifficultyValue() * colour_skill_multiplier; - double staminaSkill = stamina.DifficultyValue() * stamina_skill_multiplier; + double staminaSkill = staminaDifficultyValue * stamina_skill_multiplier; double monoStaminaSkill = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; double monoStaminaFactor = staminaSkill == 0 ? 1 : Math.Pow(monoStaminaSkill / staminaSkill, 5); - double staminaDifficultStrains = stamina.CountTopWeightedStrains(); + double staminaDifficultStrains = stamina.CountTopWeightedStrains(staminaDifficultyValue); // As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm. patternMultiplier = Math.Pow(staminaSkill * colourSkill, 0.10); diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index 63e639cb8774..1fccf32242b2 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -68,12 +68,12 @@ public sealed override void Process(DifficultyHitObject current) /// Calculates the number of strains weighted against the top strain. /// The result is scaled by clock rate as it affects the total number of strains. /// - public virtual double CountTopWeightedStrains() + public virtual double CountTopWeightedStrains(double difficultyValue) { if (ObjectStrains.Count == 0) return 0.0; - double consistentTopStrain = DifficultyValue() * (1 - DecayWeight); // What would the top strain be if all strain values were identical + double consistentTopStrain = difficultyValue * (1 - DecayWeight); // What would the top strain be if all strain values were identical if (consistentTopStrain == 0) return ObjectStrains.Count; From a07d7ad4cbfb73a8a823790aae18ce90a6e2428f Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Sun, 28 Dec 2025 18:56:55 +0500 Subject: [PATCH 009/121] Extend `Skill` to include `ObjectDifficulties` (#36152) * Extend `Skill` to include `ObjectDifficulties` * Remove generic * Change `ObjectDifficulties` to be modifiable by children skills * Fix tests --- osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs | 6 +++--- .../Difficulty/TaikoDifficultyCalculator.cs | 10 +++++----- .../TestSceneTimedDifficultyCalculation.cs | 3 ++- osu.Game/Rulesets/Difficulty/Skills/Skill.cs | 15 ++++++++++++++- .../Rulesets/Difficulty/Skills/StrainSkill.cs | 14 +++++--------- 5 files changed, 29 insertions(+), 19 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 105d3956ac6b..33dc23b7d8cf 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -54,14 +54,14 @@ protected override double StrainValueAt(DifficultyHitObject current) public double RelevantNoteCount() { - if (ObjectStrains.Count == 0) + if (ObjectDifficulties.Count == 0) return 0; - double maxStrain = ObjectStrains.Max(); + double maxStrain = ObjectDifficulties.Max(); if (maxStrain == 0) return 0; - return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); + return ObjectDifficulties.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); } public double CountTopWeightedSliders(double difficultyValue) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 4905dd327594..42a5dad31520 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -186,10 +186,10 @@ private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour co } List hitObjectStrainPeaks = combinePeaks( - rhythm.GetObjectStrains().ToList(), - reading.GetObjectStrains().ToList(), - colour.GetObjectStrains().ToList(), - stamina.GetObjectStrains().ToList() + rhythm.GetObjectDifficulties(), + reading.GetObjectDifficulties(), + colour.GetObjectDifficulties(), + stamina.GetObjectDifficulties() ); if (hitObjectStrainPeaks.Count == 0) @@ -211,7 +211,7 @@ private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour co /// /// Combines lists of peak strains from multiple skills into a list of single peak strains for each section. /// - private List combinePeaks(List rhythmPeaks, List readingPeaks, List colourPeaks, List staminaPeaks) + private List combinePeaks(IReadOnlyList rhythmPeaks, IReadOnlyList readingPeaks, IReadOnlyList colourPeaks, IReadOnlyList staminaPeaks) { var combinedPeaks = new List(); diff --git a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs index f860cd097a72..daf0bec44c0c 100644 --- a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs +++ b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs @@ -200,8 +200,9 @@ public PassThroughSkill(Mod[] mods) { } - public override void Process(DifficultyHitObject current) + protected override double ProcessInternal(DifficultyHitObject current) { + return 0; } public override double DifficultyValue() => 1; diff --git a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs index 8b8892113b8a..cf45104c942c 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs @@ -20,6 +20,11 @@ public abstract class Skill /// protected IReadOnlyList Mods => mods; + /// + /// List of calculated per-object difficulties, populated by Process + /// + protected readonly List ObjectDifficulties = new List(); + private readonly Mod[] mods; protected Skill(Mod[] mods) @@ -31,11 +36,19 @@ protected Skill(Mod[] mods) /// Process a . /// /// The to process. - public abstract void Process(DifficultyHitObject current); + public void Process(DifficultyHitObject current) + { + double difficultyValue = ProcessInternal(current); + ObjectDifficulties.Add(difficultyValue); + } + + protected abstract double ProcessInternal(DifficultyHitObject current); /// /// Returns the calculated difficulty value representing all s that have been processed up to this point. /// public abstract double DifficultyValue(); + + public IReadOnlyList GetObjectDifficulties() => ObjectDifficulties; } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index 1fccf32242b2..f066be0ec750 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -29,7 +29,6 @@ public abstract class StrainSkill : Skill private double currentSectionEnd; private readonly List strainPeaks = new List(); - protected readonly List ObjectStrains = new List(); // Store individual strains protected StrainSkill(Mod[] mods) : base(mods) @@ -44,7 +43,7 @@ protected StrainSkill(Mod[] mods) /// /// Process a and update current strain values accordingly. /// - public sealed override void Process(DifficultyHitObject current) + protected sealed override double ProcessInternal(DifficultyHitObject current) { // The first object doesn't generate a strain, so we begin with an incremented section end if (current.Index == 0) @@ -60,8 +59,7 @@ public sealed override void Process(DifficultyHitObject current) double strain = StrainValueAt(current); currentSectionPeak = Math.Max(strain, currentSectionPeak); - // Store the strain value for the object - ObjectStrains.Add(strain); + return strain; } /// @@ -70,16 +68,16 @@ public sealed override void Process(DifficultyHitObject current) /// public virtual double CountTopWeightedStrains(double difficultyValue) { - if (ObjectStrains.Count == 0) + if (ObjectDifficulties.Count == 0) return 0.0; double consistentTopStrain = difficultyValue * (1 - DecayWeight); // What would the top strain be if all strain values were identical if (consistentTopStrain == 0) - return ObjectStrains.Count; + return ObjectDifficulties.Count; // Use a weighted sum of all strains. Constants are arbitrary and give nice values - return ObjectStrains.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); + return ObjectDifficulties.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); } /// @@ -116,8 +114,6 @@ private void startNewSectionFrom(double time, DifficultyHitObject current) /// public IEnumerable GetCurrentStrainPeaks() => strainPeaks.Append(currentSectionPeak); - public IEnumerable GetObjectStrains() => ObjectStrains; - /// /// Returns the calculated difficulty value representing all s that have been processed up to this point. /// From c6441723b316f1f03b091f8aa4103204a2321e11 Mon Sep 17 00:00:00 2001 From: James Wilson Date: Sun, 28 Dec 2025 17:42:26 +0000 Subject: [PATCH 010/121] Add `HarmonicSkill` (#36153) * Add `OsuHarmonicSkill` * Make `ProcessInternal` a sealed override * Move to main game project --- .../Difficulty/Skills/HarmonicSkill.cs | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs diff --git a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs new file mode 100644 index 000000000000..b89b53d8cbac --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs @@ -0,0 +1,102 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Difficulty.Skills +{ + public abstract class HarmonicSkill : Skill + { + /// + /// The sum of note weights, calculated during summation. + /// Required for any calculations which need to normalise difficulty value. + /// + protected double NoteWeightSum; + + /// + /// Scaling factor applied as HarmonicScale / (1 + index) during weight calculations. + /// A higher value will increase the influence of the hardest object difficulties during summation. + /// + protected virtual double HarmonicScale => 1.0; + + /// + /// Exponent that controls the rate of which decay increases as the index increases. + /// Values closer to 1 decay faster whilst lower values give more weight to lower object difficulties. + /// + protected virtual double DecayExponent => 0.9; + + protected HarmonicSkill(Mod[] mods) + : base(mods) + { + } + + /// + /// Returns the difficulty value of the current . This value is calculated with or without respect to previous objects. + /// + protected abstract double ObjectDifficultyOf(DifficultyHitObject current); + + protected sealed override double ProcessInternal(DifficultyHitObject current) + => ObjectDifficultyOf(current); + + /// + /// Transforms the object difficulties specifically for final difficulty summation. + /// This can be used to decrease weight of certain notes based on a skill-specific criteria. + /// + protected virtual void ApplyDifficultyTransformation(double[] difficulties) + { + } + + public override double DifficultyValue() + { + if (ObjectDifficulties.Count == 0) + return 0; + + // Notes with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These notes will not contribute to the difficulty. + double[] difficulties = ObjectDifficulties.Where(p => p > 0).ToArray(); + + ApplyDifficultyTransformation(difficulties); + + double difficulty = 0; + int index = 0; + + foreach (double note in difficulties.OrderDescending()) + { + // Use a harmonic sum that considers each note of the map according to a predefined weight. + double weight = (1 + (HarmonicScale / (1 + index))) / (Math.Pow(index, DecayExponent) + 1 + (HarmonicScale / (1 + index))); + + NoteWeightSum += weight; + + difficulty += note * weight; + index += 1; + } + + return difficulty; + } + + /// + /// Calculates the number of object difficulties weighted against the top object difficulty. + /// + public virtual double CountTopWeightedObjectDifficulties(double difficultyValue) + { + if (ObjectDifficulties.Count == 0) + return 0.0; + + if (NoteWeightSum == 0) + return 0.0; + + double consistentTopNote = difficultyValue / NoteWeightSum; // What would the top difficulty be if all object difficulties were identical + + if (consistentTopNote == 0) + return 0; + + return ObjectDifficulties.Sum(d => DifficultyCalculationUtils.Logistic(d / consistentTopNote, 0.88, 10, 1.1)); + } + + public static double DifficultyToPerformance(double difficulty) => 4.0 * Math.Pow(difficulty, 3.0); + } +} From dd261623cde58dd7b1c402ec4a65c9ec60745478 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:11:25 +0500 Subject: [PATCH 011/121] Change the doubletapness scaling to be more punishing on the low end of the hitwindow (#36148) --- .../Difficulty/Evaluators/RhythmEvaluator.cs | 7 +------ .../Difficulty/Preprocessing/OsuDifficultyHitObject.cs | 5 ++++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index 9349083951e1..3bb35ccf1553 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -26,8 +26,6 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (current.BaseObject is Spinner) return 0; - var currentOsuObject = (OsuDifficultyHitObject)current; - double rhythmComplexitySum = 0; double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; @@ -176,10 +174,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) prevObj = currObj; } - double rhythmDifficulty = Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) - rhythmDifficulty *= 1 - currentOsuObject.GetDoubletapness((OsuDifficultyHitObject)current.Next(0)); - - return rhythmDifficulty; + return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though); } private class Island : IEquatable diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 5e9fc10ef877..2641475810d9 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -175,9 +175,12 @@ public double GetDoubletapness(OsuDifficultyHitObject? osuNextObj) { double currDeltaTime = Math.Max(1, DeltaTime); double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime); + double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); + double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); - double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 2); + double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 5); + return 1.0 - Math.Pow(speedRatio, 1 - windowRatio); } From bcaf35dae71f07b3f7c3928bce85e98567049613 Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Sat, 3 Jan 2026 21:51:45 +0200 Subject: [PATCH 012/121] Improve fullcombo threshold formula (#35724) * Implement new portion formula * Change the formula * Use base value of 5 sliders * Make the threshold harsher --------- Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- .../Difficulty/OsuLegacyScoreMissCalculator.cs | 8 ++++++-- .../Difficulty/OsuPerformanceCalculator.cs | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs index 0d406ea72a60..8bde33d292c1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs @@ -115,9 +115,13 @@ private double calculateMaximumComboBasedMissCount() double missCount = 0; + // If sliders in the map are hard - it's likely for player to drop sliderends + // If map has easy sliders - it's more likely for player to sliderbreak + double likelyMissedSliderendPortion = 0.04 + 0.06 * Math.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2); + // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it - // In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map - double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; + // In classic scores we can't know the amount of dropped sliders so we estimate it + double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount); if (score.MaxCombo < fullComboThreshold) missCount = Math.Pow(fullComboThreshold / Math.Max(1.0, score.MaxCombo), 2.5); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 9dde9dfdea7c..dc4ac6c37320 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -337,9 +337,13 @@ private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes att if (usingClassicSliderAccuracy) { + // If sliders in the map are hard - it's likely for player to drop sliderends + // If map has easy sliders - it's more likely for player to sliderbreak + double likelyMissedSliderendPortion = 0.04 + 0.06 * Math.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2); + // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it - // In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map - double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; + // In classic scores we can't know the amount of dropped sliders so we estimate it + double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount); if (scoreMaxCombo < fullComboThreshold) missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); From 7fc24c6566f96dc0859fd55a535518b2837a8b44 Mon Sep 17 00:00:00 2001 From: James Wilson Date: Sat, 17 Jan 2026 09:30:37 +0000 Subject: [PATCH 013/121] Fix legacy score base multiplier being calculated using modded difficulty values (#36209) Fun correctness bug. ScoreV1 multiplier uses the **nomod** peppy stars (i.e the beatmap's "base" HP, OD and CS) in all cases, which was generally the intention with this code but it passes the wrong beatmap resulting in it using modded values for this. Cross referenced with stable and this now results in the expected multipliers. Results in some very minor (<3pp from my testing) changes for HR and EZ scores with combo breaks. --- osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index edaef0aa75cd..47be0ebfa4fc 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -97,7 +97,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat flashlightRating = osuRatingCalculator.ComputeFlashlightRating(flashlight.DifficultyValue()); double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits); - double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap); + double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(WorkingBeatmap.Beatmap); var simulator = new OsuLegacyScoreSimulator(); var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap); From cda52cdae80cfac6a96088a2e6eb01ba9ad1998a Mon Sep 17 00:00:00 2001 From: kwotaq <80002984+kwotaq@users.noreply.github.com> Date: Sun, 18 Jan 2026 15:56:23 +0200 Subject: [PATCH 014/121] Add comment for wiggle bonus multiplier (#36373) It's a common mistake by newer pp dev contributors to increase the wiggle multiplier not knowing its at its maximum, so I added a comment to try to avoid more people doing it. --------- Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index dcf8ac0fedbd..d311bcf93dde 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -15,7 +15,7 @@ public static class AimEvaluator private const double acute_angle_multiplier = 2.55; private const double slider_multiplier = 1.35; private const double velocity_change_multiplier = 0.75; - private const double wiggle_multiplier = 1.02; + private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation /// /// Evaluates the difficulty of aiming the current object, based on: From 2876cebdd7d9979ace86eadff56bdc659c9806cd Mon Sep 17 00:00:00 2001 From: Natelytle <92956514+Natelytle@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:05:21 -0500 Subject: [PATCH 015/121] Move strain's influence in the difficulty of a note based on deltaTime to the evaluator level (#36417) This PR moves the influence of d/t^2 from the skill (through strain) directly into the evaluator level as a bonus applied at the end. This makes it more clear what d/t^2 is, and the fact that it applies to *all* bonuses in the evaluator. As well, the peak strain value of a note is now equal to the evaluator value. This comes with a couple benefits: 1. The BPM weight is now subject to balance, and can be flattened easily for a more "d/t" like system (previously this required hacky solutions) 2. StrainDecayBase becomes a much more useful variable now that it does not affect the difficulty of the note itself. When adjusting this in live, your star rating would double upon changing from 0.15 in aim to 0.3, and now it is intuitive what it does (makes strain take longer to accumulate). This means that future balancing efforts can use evaluators to dynamically adjust strainDecayBase (potentially letting large spikes provide more strain for the same difficulty if they're wide angle, for example). 3. In the object inspector, you get the actual maximum difficulty value of a note as seen in the ObjectDifficulties list. This makes it easier to tell what notes are deemed as harder by the system. For context, previously, a note of difficulty 1 at 200bpm would cap out at 6 as a result of strain, and a note of the same difficulty but at 300bpm would cap out at 8. The actual implementation is really really simple. I'm willing to move this to flashlight if wanted (I don't think it's necessary), or even abstract this away so that (1 - decay) doesn't look like a balancing constant. Side note: this is equivalent to live, except for notes with a deltaTime of less than 25ms (since I am using AdjustedDeltaTime in the aim evaluator for the bonus to avoid divisions by zero). --- .../Difficulty/Evaluators/AimEvaluator.cs | 4 ++++ .../Difficulty/Evaluators/SpeedEvaluator.cs | 4 ++++ osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 7 +++++-- osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs | 6 ++++-- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index d311bcf93dde..257087d69e60 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -162,9 +162,13 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with if (withSliderTravelDistance) aimStrain += sliderBonus * slider_multiplier; + aimStrain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); + return aimStrain; } + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.15, ms / 1000)); + private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); private static double calcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index e6197b177bdc..a35d24d7a8b2 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -69,8 +69,12 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnly // Base difficulty with all bonuses double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime; + difficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); + // Apply penalty if there's doubletappable doubles return difficulty * doubletapness; } + + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, ms / 1000)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index dbdfef4199ed..a97bad877dfd 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -7,6 +7,7 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Utils; using osu.Game.Rulesets.Osu.Objects; @@ -38,8 +39,10 @@ public Aim(Mod[] mods, bool includeSliders) protected override double StrainValueAt(DifficultyHitObject current) { - currentStrain *= strainDecay(current.DeltaTime); - currentStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplier; + double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); + + currentStrain *= decay; + currentStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * (1 - decay) * skillMultiplier; if (current.BaseObject is Slider) sliderStrains.Add(currentStrain); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 33dc23b7d8cf..cbba1de1523d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -39,8 +39,10 @@ public Speed(Mod[] mods) protected override double StrainValueAt(DifficultyHitObject current) { - currentStrain *= strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); - currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier; + double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); + + currentStrain *= decay; + currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * (1 - decay) * skillMultiplier; currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); From 520173e37dfa1f1f09114fde78546b9bf8458f6e Mon Sep 17 00:00:00 2001 From: kwotaq <80002984+kwotaq@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:40:01 +0200 Subject: [PATCH 016/121] Replace Speed skill strain summation with a per-note harmonic sum (#34696) The purpose of this change is to remove the arbitrary note based length bonus for speed and replace it with a more concrete per note difficulty aware calculation. As a result of the summation needing to be per-note, chunking and in consequence peak strain reduction have both been removed from the skill. ~~The various strain counting functions also heavily relied on chunking and the way summation worked, so they have also been changed to match values held before the change as much as possible (given the different summation some changes in values are bound to happen).~~ Above was a bug. Huis page: [https://pp.huismetbenen.nl/rankings/players/length-bonus](https://pp.huismetbenen.nl/rankings/players/length-bonus) --------- Co-authored-by: James Wilson --- .../Difficulty/Evaluators/AimEvaluator.cs | 2 +- .../Difficulty/Evaluators/RhythmEvaluator.cs | 2 +- .../Difficulty/OsuDifficultyCalculator.cs | 12 +++-- .../Difficulty/OsuPerformanceCalculator.cs | 7 +-- .../Difficulty/Skills/Aim.cs | 2 +- .../Difficulty/Skills/Speed.cs | 53 ++++++++++++------- 6 files changed, 46 insertions(+), 32 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 257087d69e60..c8c7fb605f24 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators public static class AimEvaluator { private const double wide_angle_multiplier = 1.5; - private const double acute_angle_multiplier = 2.55; + private const double acute_angle_multiplier = 2.3; private const double slider_multiplier = 1.35; private const double velocity_change_multiplier = 0.75; private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index 3bb35ccf1553..906ccc1b46dd 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -16,7 +16,7 @@ public static class RhythmEvaluator private const int history_time_max = 5 * 1000; // 5 seconds private const int history_objects_max = 32; private const double rhythm_overall_multiplier = 1.0; - private const double rhythm_ratio_multiplier = 15.0; + private const double rhythm_ratio_multiplier = 17.0; /// /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 47be0ebfa4fc..8230ed060b36 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -59,10 +59,10 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue(); double speedDifficultyValue = speed.DifficultyValue(); - double speedNotes = speed.RelevantNoteCount(); - double aimDifficultStrainCount = aim.CountTopWeightedStrains(aimDifficultyValue); - double speedDifficultStrainCount = speed.CountTopWeightedStrains(speedDifficultyValue); + double speedDifficultStrainCount = speed.CountTopWeightedObjectDifficulties(speedDifficultyValue); + + double speedNotes = speed.RelevantNoteCount(); double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders(aimNoSlidersDifficultyValue); double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains(aimNoSlidersDifficultyValue); @@ -84,7 +84,9 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat int totalHits = beatmap.HitObjects.Count; double mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue); - double sliderFactor = aimDifficultyValue > 0 ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) : 1; + double sliderFactor = aimDifficultyValue > 0 + ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) + : 1; var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating, sliderFactor); @@ -103,7 +105,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap); double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); - double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); + double baseSpeedPerformance = HarmonicSkill.DifficultyToPerformance(speedRating); double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); double basePerformance = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseFlashlightPerformance); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index dc4ac6c37320..9d11f07a7da3 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.Mods; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Scoring; @@ -225,11 +226,7 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib if (score.Mods.Any(h => h is OsuModRelax) || speedDeviation == null) return 0.0; - double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty); - - double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + - (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); - speedValue *= lengthBonus; + double speedValue = HarmonicSkill.DifficultyToPerformance(attributes.SpeedDifficulty); if (effectiveMissCount > 0) { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index a97bad877dfd..ed106ccfe741 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -28,7 +28,7 @@ public Aim(Mod[] mods, bool includeSliders) private double currentStrain; - private double skillMultiplier => 26; + private double skillMultiplier => 26.7; private double strainDecayBase => 0.15; private readonly List sliderStrains = new List(); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index cbba1de1523d..8911b32f42e9 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -6,27 +6,29 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; -using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; using System.Linq; -using osu.Game.Rulesets.Osu.Difficulty.Utils; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { /// /// Represents the skill required to press keys with regards to keeping up with the speed at which objects need to be hit. /// - public class Speed : OsuStrainSkill + public class Speed : HarmonicSkill { - private double skillMultiplier => 1.47; - private double strainDecayBase => 0.3; - - private double currentStrain; - private double currentRhythm; + private double skillMultiplier => 0.93; private readonly List sliderStrains = new List(); - protected override int ReducedSectionCount => 5; + private double currentDifficulty; + + private double strainDecayBase => 0.3; + + protected override double HarmonicScale => 20; + protected override double DecayExponent => 0.85; public Speed(Mod[] mods) : base(mods) @@ -35,23 +37,21 @@ public Speed(Mod[] mods) private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); - protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => (currentStrain * currentRhythm) * strainDecay(time - current.Previous(0).StartTime); - - protected override double StrainValueAt(DifficultyHitObject current) + protected override double ObjectDifficultyOf(DifficultyHitObject current) { double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); - currentStrain *= decay; - currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * (1 - decay) * skillMultiplier; + currentDifficulty *= decay; + currentDifficulty += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * (1 - decay) * skillMultiplier; - currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); + double currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); - double totalStrain = currentStrain * currentRhythm; + double totalDifficulty = currentDifficulty * currentRhythm; if (current.BaseObject is Slider) - sliderStrains.Add(totalStrain); + sliderStrains.Add(totalDifficulty); - return totalStrain; + return totalDifficulty; } public double RelevantNoteCount() @@ -60,6 +60,7 @@ public double RelevantNoteCount() return 0; double maxStrain = ObjectDifficulties.Max(); + if (maxStrain == 0) return 0; @@ -67,6 +68,20 @@ public double RelevantNoteCount() } public double CountTopWeightedSliders(double difficultyValue) - => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, difficultyValue); + { + if (sliderStrains.Count == 0) + return 0; + + if (NoteWeightSum == 0) + return 0.0; + + double consistentTopNote = difficultyValue / NoteWeightSum; // What would the top note be if all note values were identical + + if (consistentTopNote == 0) + return 0; + + // Use a weighted sum of all notes. Constants are arbitrary and give nice values + return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopNote, 0.88, 10, 1.1)); + } } } From fb8d198a640b72bfa84333d72843cbd5a14291db Mon Sep 17 00:00:00 2001 From: Dark98 <71676472+TheDark98@users.noreply.github.com> Date: Sat, 24 Jan 2026 23:05:59 +0100 Subject: [PATCH 017/121] Improve angle calculation to better represent the path taken on sliders (#35555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This pr resolves the issue with the angle calculation. The old system calculated the angle between three objects. If the last object was a long slider that the player needed to follow, the system calculated the angle using only the slider’s end point and the previous object, which caused long sliders to have incorrect angle values. --------- Co-authored-by: StanR Co-authored-by: James Wilson --- .../Difficulty/Evaluators/AimEvaluator.cs | 2 +- .../Preprocessing/OsuDifficultyHitObject.cs | 36 +++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index c8c7fb605f24..730bd6bc58fa 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -13,7 +13,7 @@ public static class AimEvaluator { private const double wide_angle_multiplier = 1.5; private const double acute_angle_multiplier = 2.3; - private const double slider_multiplier = 1.35; + private const double slider_multiplier = 1.5; private const double velocity_change_multiplier = 0.75; private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 2641475810d9..3c926fe18e2c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -242,15 +242,15 @@ private void setDistances(double clockRate) if (lastLastDifficultyObject != null && lastLastDifficultyObject.BaseObject is not Spinner) { - Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastDifficultyObject); + if (lastDifficultyObject!.BaseObject is Slider prevSlider && lastDifficultyObject.TravelDistance > 0) + lastCursorPosition = prevSlider.HeadCircle.StackedPosition; - Vector2 v1 = lastLastCursorPosition - LastObject.StackedPosition; - Vector2 v2 = BaseObject.StackedPosition - lastCursorPosition; + Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastDifficultyObject); - float dot = Vector2.Dot(v1, v2); - float det = v1.X * v2.Y - v1.Y * v2.X; + double angle = calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition); + double sliderAngle = calculateSliderAngle(lastDifficultyObject!, lastLastCursorPosition); - Angle = Math.Abs(Math.Atan2(det, dot)); + Angle = Math.Min(angle, sliderAngle); } } @@ -362,6 +362,30 @@ private void computeSliderCursorPosition() } } + private double calculateSliderAngle(OsuDifficultyHitObject lastDifficultyObject, Vector2 lastLastCursorPosition) + { + Vector2 lastCursorPosition = getEndCursorPosition(lastDifficultyObject); + + if (lastDifficultyObject.BaseObject is Slider prevSlider && lastDifficultyObject.TravelDistance > 0) + { + OsuHitObject secondLastNestedObject = (OsuHitObject)prevSlider.NestedHitObjects[^2]; + lastLastCursorPosition = secondLastNestedObject.StackedPosition; + } + + return calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition); + } + + private double calculateAngle(Vector2 currentPosition, Vector2 lastPosition, Vector2 lastLastPosition) + { + Vector2 v1 = lastLastPosition - lastPosition; + Vector2 v2 = currentPosition - lastPosition; + + float dot = Vector2.Dot(v1, v2); + float det = v1.X * v2.Y - v1.Y * v2.X; + + return Math.Abs(Math.Atan2(det, dot)); + } + private Vector2 getEndCursorPosition(OsuDifficultyHitObject difficultyHitObject) { return difficultyHitObject.LazyEndPosition ?? difficultyHitObject.BaseObject.StackedPosition; From c25820aa571a16424f6cc3d6403e52bc4ead845e Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Sun, 25 Jan 2026 03:49:23 +0500 Subject: [PATCH 018/121] Use correct object for the aim slider bonus (#36216) Currently the slider bonus works on the assumption that the travel velocity of the previous slider is a part of the current object's difficulty because it is part of the movement from prev to curr. However, this is contradicted by the fact that `currVelocity` is a combination of prev->curr + curr slider velocity and is actually breaking the assumption that slider difficulty is contained in the slider itself that we take when calculating difficult slider strains. This makes it so that the slider bonus difficulty is contained in the slider itself instead of buffing the next object, which makes both the calculation overall more consistent and the slider factor calculation actually work as expected. Aim multiplier got slightly lowered because this change makes most of the sliders gain a little bit --------- Co-authored-by: James Wilson --- osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs | 4 ++-- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 730bd6bc58fa..4aa45f45293c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -143,10 +143,10 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2); } - if (osuLastObj.BaseObject is Slider) + if (osuCurrObj.BaseObject is Slider) { // Reward sliders based on velocity. - sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime; + sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; } aimStrain += wiggleBonus * wiggle_multiplier; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index ed106ccfe741..11b84f2d342a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -28,7 +28,7 @@ public Aim(Mod[] mods, bool includeSliders) private double currentStrain; - private double skillMultiplier => 26.7; + private double skillMultiplier => 26.6; private double strainDecayBase => 0.15; private readonly List sliderStrains = new List(); From 562f1f80046aff46a283cc9a4575301de8af2cc4 Mon Sep 17 00:00:00 2001 From: kwotaq <80002984+kwotaq@users.noreply.github.com> Date: Sun, 25 Jan 2026 22:49:51 +0200 Subject: [PATCH 019/121] Replace current AR and HD bonuses with a new Reading skill on the osu! ruleset (#33196) This PR aims to replace the current bonuses used to award high approach rates and scores made using the Hidden mod with a Reading skill that takes into account each note's reading difficulty separately, Important to note is the fact that as reading difficulty is now a skill the bonuses are now additive instead of multiplicative, meaning there are vast changes in deltas on the high and low end of scores. Due to the nature of adding a new skill new difficulty and performance attributes need to be added. Huis page: [https://pp.huismetbenen.nl/rankings/admin/kwotaq-reading](url) --------- Co-authored-by: apollo-dw <83023433+apollo-dw@users.noreply.github.com> Co-authored-by: js1086 Co-authored-by: tsunyoku Co-authored-by: StanR --- .../Difficulty/Evaluators/ReadingEvaluator.cs | 244 ++++++++++++++++++ .../Difficulty/OsuDifficultyAttributes.cs | 13 + .../Difficulty/OsuDifficultyCalculator.cs | 26 +- .../Difficulty/OsuPerformanceAttributes.cs | 4 + .../Difficulty/OsuPerformanceCalculator.cs | 45 +++- .../Difficulty/OsuRatingCalculator.cs | 111 ++------ .../Preprocessing/OsuDifficultyHitObject.cs | 29 ++- .../Difficulty/Skills/Reading.cs | 107 ++++++++ .../Difficulty/DifficultyAttributes.cs | 2 + 9 files changed, 471 insertions(+), 110 deletions(-) create mode 100644 osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs create mode 100644 osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs new file mode 100644 index 000000000000..0a45ee5eb7e6 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -0,0 +1,244 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +{ + public static class ReadingEvaluator + { + private const double reading_window_size = 3000; // 3 seconds + private const double distance_influence_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.5; // 1.5 circles distance between centers + private const double hidden_multiplier = 0.28; + private const double density_multiplier = 2.4; + private const double density_difficulty_base = 2.5; + private const double preempt_balancing_factor = 140000; + private const double preempt_starting_point = 500; // AR 9.66 in milliseconds + private const double minimum_angle_relevancy_time = 2000; // 2 seconds + private const double maximum_angle_relevancy_time = 200; + + public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden) + { + if (current.BaseObject is Spinner || current.Index == 0) + return 0; + + var currObj = (OsuDifficultyHitObject)current; + var nextObj = (OsuDifficultyHitObject)current.Next(0); + + double velocity = Math.Max(1, currObj.LazyJumpDistance / currObj.AdjustedDeltaTime); // Only allow velocity to buff + + double currentVisibleObjectDensity = retrieveCurrentVisibleObjectDensity(currObj); + double pastObjectDifficultyInfluence = getPastObjectDifficultyInfluence(currObj); + + double constantAngleNerfFactor = getConstantAngleNerfFactor(currObj); + + double noteDensityDifficulty = calculateDensityDifficulty(nextObj, velocity, constantAngleNerfFactor, pastObjectDifficultyInfluence, currentVisibleObjectDensity); + + double hiddenDifficulty = hidden + ? calculateHiddenDifficulty(currObj, pastObjectDifficultyInfluence, currentVisibleObjectDensity, velocity, constantAngleNerfFactor) + : 0; + + double preemptDifficulty = calculatePreemptDifficulty(velocity, constantAngleNerfFactor, currObj.Preempt); + + double difficulty = DifficultyCalculationUtils.Norm(1.5, preemptDifficulty, hiddenDifficulty, noteDensityDifficulty); + + return difficulty; + } + + /// + /// Calculates the density difficulty of the current object and how hard it is to aim it because of it based on: + /// + /// cursor velocity to the current object, + /// how many times the current object's angle was repeated, + /// density of objects visible when the current object appears, + /// density of objects visible when the current object needs to be clicked, + /// /// + /// + private static double calculateDensityDifficulty(OsuDifficultyHitObject? nextObj, double velocity, double constantAngleNerfFactor, + double pastObjectDifficultyInfluence, double currentVisibleObjectDensity) + { + // Consider future densities too because it can make the path the cursor takes less clear + double futureObjectDifficultyInfluence = Math.Sqrt(currentVisibleObjectDensity); + + if (nextObj != null) + { + // Reduce difficulty if movement to next object is small + futureObjectDifficultyInfluence *= DifficultyCalculationUtils.Smootherstep(nextObj.LazyJumpDistance, 15, distance_influence_threshold); + } + + // Value higher note densities exponentially + double noteDensityDifficulty = Math.Pow(pastObjectDifficultyInfluence + futureObjectDifficultyInfluence, 1.7) * 0.4 * constantAngleNerfFactor * velocity; + + // Award only denser than average maps. + noteDensityDifficulty = Math.Max(0, noteDensityDifficulty - density_difficulty_base); + + // Apply a soft cap to general density reading to account for partial memorization + noteDensityDifficulty = Math.Pow(noteDensityDifficulty, 0.45) * density_multiplier; + + return noteDensityDifficulty; + } + + /// + /// Calculates the difficulty of aiming the current object when the approach rate is very high based on: + /// + /// cursor velocity to the current object, + /// how many times the current object's angle was repeated, + /// how many milliseconds elapse between the approach circle appearing and touching the inner circle + /// + /// + private static double calculatePreemptDifficulty(double velocity, double constantAngleNerfFactor, double preempt) + { + // Arbitrary curve for the base value preempt difficulty should have as approach rate increases. + // https://www.desmos.com/calculator/c175335a71 + double preemptDifficulty = Math.Pow((preempt_starting_point - preempt + Math.Abs(preempt - preempt_starting_point)) / 2, 2.5) / preempt_balancing_factor; + + preemptDifficulty *= constantAngleNerfFactor * velocity; + + return preemptDifficulty; + } + + /// + /// Calculates the difficulty of aiming the current object when the hidden mod is active based on: + /// + /// cursor velocity to the current object, + /// time the current object spends invisible, + /// density of objects visible when the current object appears, + /// density of objects visible when the current object needs to be clicked, + /// how many times the current object's angle was repeated, + /// if the current object is perfectly stacked to the previous one + /// + /// + private static double calculateHiddenDifficulty(OsuDifficultyHitObject currObj, double pastObjectDifficultyInfluence, double currentVisibleObjectDensity, double velocity, + double constantAngleNerfFactor) + { + double timeSpentInvisible = currObj.DurationSpentInvisible() / currObj.ClockRate; + + // Value time spent invisible exponentially + double timeSpentInvisibleFactor = Math.Pow(timeSpentInvisible, 2.2) * 0.022; + + // Account for both past and current densities + double densityFactor = Math.Pow(currentVisibleObjectDensity + pastObjectDifficultyInfluence, 3.3) * 3; + + double hiddenDifficulty = (timeSpentInvisibleFactor + densityFactor) * constantAngleNerfFactor * velocity * 0.01; + + // Apply a soft cap to general HD reading to account for partial memorization + hiddenDifficulty = Math.Pow(hiddenDifficulty, 0.4) * hidden_multiplier; + + var previousObj = (OsuDifficultyHitObject)currObj.Previous(0); + + // Buff perfect stacks only if current note is completely invisible at the time you click the previous note. + if (currObj.LazyJumpDistance == 0 && currObj.OpacityAt(previousObj.BaseObject.StartTime + previousObj.Preempt, true) == 0 && previousObj.StartTime + previousObj.Preempt > currObj.StartTime) + hiddenDifficulty += hidden_multiplier * 7500 / Math.Pow(currObj.AdjustedDeltaTime, 1.5); // Perfect stacks are harder the less time between notes + + return hiddenDifficulty; + } + + private static double getPastObjectDifficultyInfluence(OsuDifficultyHitObject currObj) + { + double pastObjectDifficultyInfluence = 0; + + foreach (var loopObj in retrievePastVisibleObjects(currObj)) + { + double loopDifficulty = currObj.OpacityAt(loopObj.BaseObject.StartTime, false); + + // When aiming an object small distances mean previous objects may be cheesed, so it doesn't matter whether they were arranged confusingly. + loopDifficulty *= DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 15, distance_influence_threshold); + + // Account less for objects close to the max reading window + double timeBetweenCurrAndLoopObj = currObj.StartTime - loopObj.StartTime; + double timeNerfFactor = getTimeNerfFactor(timeBetweenCurrAndLoopObj); + + loopDifficulty *= timeNerfFactor; + pastObjectDifficultyInfluence += loopDifficulty; + } + + return pastObjectDifficultyInfluence; + } + + // Returns a list of objects that are visible on screen at the point in time the current object becomes visible. + private static IEnumerable retrievePastVisibleObjects(OsuDifficultyHitObject current) + { + for (int i = 0; i < current.Index; i++) + { + OsuDifficultyHitObject hitObject = (OsuDifficultyHitObject)current.Previous(i); + + if (hitObject.IsNull() || + current.StartTime - hitObject.StartTime > reading_window_size || + hitObject.StartTime + hitObject.Preempt < current.StartTime) // Current object not visible at the time object needs to be clicked + break; + + yield return hitObject; + } + } + + // Returns the density of objects visible at the point in time the current object needs to be clicked capped by the reading window. + private static double retrieveCurrentVisibleObjectDensity(OsuDifficultyHitObject current) + { + double visibleObjectCount = 0; + + OsuDifficultyHitObject? hitObject = (OsuDifficultyHitObject)current.Next(0); + + while (hitObject != null) + { + if (hitObject.StartTime - current.StartTime > reading_window_size || + current.StartTime + hitObject.Preempt < hitObject.StartTime) // Object not visible at the time current object needs to be clicked. + break; + + double timeBetweenCurrAndLoopObj = hitObject.StartTime - current.StartTime; + double timeNerfFactor = getTimeNerfFactor(timeBetweenCurrAndLoopObj); + + visibleObjectCount += hitObject.OpacityAt(current.BaseObject.StartTime, false) * timeNerfFactor; + + hitObject = (OsuDifficultyHitObject?)hitObject.Next(0); + } + + return visibleObjectCount; + } + + // Returns a factor of how often the current object's angle has been repeated in a certain time frame. + // It does this by checking the difference in angle between current and past objects and sums them based on a range of similarity. + // https://www.desmos.com/calculator/eb057a4822 + private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current) + { + double constantAngleCount = 0; + int index = 0; + double currentTimeGap = 0; + + while (currentTimeGap < minimum_angle_relevancy_time) + { + var loopObj = (OsuDifficultyHitObject)current.Previous(index); + + if (loopObj.IsNull()) + break; + + // Account less for objects that are close to the time limit. + double longIntervalFactor = 1 - DifficultyCalculationUtils.ReverseLerp(loopObj.AdjustedDeltaTime, maximum_angle_relevancy_time, minimum_angle_relevancy_time); + + if (loopObj.Angle.IsNotNull() && current.Angle.IsNotNull()) + { + double angleDifference = Math.Abs(current.Angle.Value - loopObj.Angle.Value); + double stackFactor = DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); + + constantAngleCount += Math.Cos(3 * Math.Min(double.DegreesToRadians(30), angleDifference * stackFactor)) * longIntervalFactor; + } + + currentTimeGap = current.StartTime - loopObj.StartTime; + index++; + } + + return Math.Clamp(2 / constantAngleCount, 0.2, 1); + } + + // Returns a nerfing factor for when objects are very distant in time, affecting reading less. + private static double getTimeNerfFactor(double deltaTime) + { + return Math.Clamp(2 - deltaTime / (reading_window_size / 2), 0, 1); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index be5e0385ee1f..bfbacd0a864b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -45,6 +45,12 @@ public class OsuDifficultyAttributes : DifficultyAttributes [JsonProperty("flashlight_difficulty")] public double FlashlightDifficulty { get; set; } + /// + /// The difficulty corresponding to the reading skill. + /// + [JsonProperty("reading_difficulty")] + public double ReadingDifficulty { get; set; } + /// /// Describes how much of is contributed to by hitcircles or sliders. /// A value closer to 1.0 indicates most of is contributed by hitcircles. @@ -75,6 +81,9 @@ public class OsuDifficultyAttributes : DifficultyAttributes [JsonProperty("speed_difficult_strain_count")] public double SpeedDifficultStrainCount { get; set; } + [JsonProperty("reading_difficult_note_count")] + public double ReadingDifficultNoteCount { get; set; } + [JsonProperty("nested_score_per_object")] public double NestedScorePerObject { get; set; } @@ -106,6 +115,7 @@ public class OsuDifficultyAttributes : DifficultyAttributes yield return (ATTRIB_ID_AIM, AimDifficulty); yield return (ATTRIB_ID_SPEED, SpeedDifficulty); + yield return (ATTRIB_ID_READING, ReadingDifficulty); yield return (ATTRIB_ID_DIFFICULTY, StarRating); if (ShouldSerializeFlashlightDifficulty()) @@ -122,6 +132,7 @@ public class OsuDifficultyAttributes : DifficultyAttributes yield return (ATTRIB_ID_NESTED_SCORE_PER_OBJECT, NestedScorePerObject); yield return (ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER, LegacyScoreBaseMultiplier); yield return (ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE, MaximumLegacyComboScore); + yield return (ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT, ReadingDifficultNoteCount); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -130,6 +141,7 @@ public override void FromDatabaseAttributes(IReadOnlyDictionary val AimDifficulty = values[ATTRIB_ID_AIM]; SpeedDifficulty = values[ATTRIB_ID_SPEED]; + ReadingDifficulty = values[ATTRIB_ID_READING]; StarRating = values[ATTRIB_ID_DIFFICULTY]; FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT); SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR]; @@ -142,6 +154,7 @@ public override void FromDatabaseAttributes(IReadOnlyDictionary val NestedScorePerObject = values[ATTRIB_ID_NESTED_SCORE_PER_OBJECT]; LegacyScoreBaseMultiplier = values[ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER]; MaximumLegacyComboScore = values[ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE]; + ReadingDifficultNoteCount = values[ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT]; HitCircleCount = onlineInfo.CircleCount; SliderCount = onlineInfo.SliderCount; SpinnerCount = onlineInfo.SpinnerCount; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 8230ed060b36..245c4107e958 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -54,13 +54,16 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat var aimWithoutSliders = skills.OfType().Single(a => !a.IncludeSliders); var speed = skills.OfType().Single(); var flashlight = skills.OfType().SingleOrDefault(); + var reading = skills.OfType().Single(); double aimDifficultyValue = aim.DifficultyValue(); double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue(); double speedDifficultyValue = speed.DifficultyValue(); + double readingDifficultyValue = reading.DifficultyValue(); double aimDifficultStrainCount = aim.CountTopWeightedStrains(aimDifficultyValue); double speedDifficultStrainCount = speed.CountTopWeightedObjectDifficulties(speedDifficultyValue); + double readingDifficultNoteCount = reading.CountTopWeightedObjectDifficulties(readingDifficultyValue); double speedNotes = speed.RelevantNoteCount(); @@ -74,7 +77,6 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double difficultSliders = aim.GetDifficultSliders(); - double approachRate = CalculateRateAdjustedApproachRate(beatmap.Difficulty.ApproachRate, clockRate); double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, clockRate); int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle); @@ -83,15 +85,15 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat int totalHits = beatmap.HitObjects.Count; - double mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue); double sliderFactor = aimDifficultyValue > 0 ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) : 1; - var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating, sliderFactor); + var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, overallDifficulty); double aimRating = osuRatingCalculator.ComputeAimRating(aimDifficultyValue); double speedRating = osuRatingCalculator.ComputeSpeedRating(speedDifficultyValue); + double readingRating = osuRatingCalculator.ComputeReadingRating(readingDifficultyValue); double flashlightRating = 0.0; @@ -106,9 +108,10 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); double baseSpeedPerformance = HarmonicSkill.DifficultyToPerformance(speedRating); + double baseReadingPerformance = HarmonicSkill.DifficultyToPerformance(readingRating); double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); - double basePerformance = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseFlashlightPerformance); + double basePerformance = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseReadingPerformance, baseFlashlightPerformance); double starRating = calculateStarRating(basePerformance); @@ -121,9 +124,11 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat SpeedDifficulty = speedRating, SpeedNoteCount = speedNotes, FlashlightDifficulty = flashlightRating, + ReadingDifficulty = readingRating, SliderFactor = sliderFactor, AimDifficultStrainCount = aimDifficultStrainCount, SpeedDifficultStrainCount = speedDifficultStrainCount, + ReadingDifficultNoteCount = readingDifficultNoteCount, AimTopWeightedSliderFactor = aimTopWeightedSliderFactor, SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor, MaxCombo = beatmap.GetMaxCombo(), @@ -138,16 +143,6 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat return attributes; } - private double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue) - { - double aimValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue)); - double speedValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(speedDifficultyValue)); - - double totalValue = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, aimValue, speedValue); - - return calculateStarRating(totalValue); - } - private double calculateStarRating(double basePerformance) { return Math.Cbrt(basePerformance * OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER); @@ -173,7 +168,8 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clo { new Aim(mods, true), new Aim(mods, false), - new Speed(mods) + new Speed(mods), + new Reading(beatmap, mods, clockRate) }; if (mods.Any(h => h is OsuModFlashlight)) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs index 8577eff11ff5..e4a64cd81d6b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs @@ -21,6 +21,9 @@ public class OsuPerformanceAttributes : PerformanceAttributes [JsonProperty("flashlight")] public double Flashlight { get; set; } + [JsonProperty("reading")] + public double Reading { get; set; } + [JsonProperty("effective_miss_count")] public double EffectiveMissCount { get; set; } @@ -48,6 +51,7 @@ public override IEnumerable GetAttributesForDisplay yield return new PerformanceDisplayAttribute(nameof(Speed), "Speed", Speed); yield return new PerformanceDisplayAttribute(nameof(Accuracy), "Accuracy", Accuracy); yield return new PerformanceDisplayAttribute(nameof(Flashlight), "Flashlight Bonus", Flashlight); + yield return new PerformanceDisplayAttribute(nameof(Reading), "Reading", Reading); } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 9d11f07a7da3..87982dfaaa7f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -146,8 +146,9 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s double speedValue = computeSpeedValue(score, osuAttributes); double accuracyValue = computeAccuracyValue(score, osuAttributes); double flashlightValue = computeFlashlightValue(score, osuAttributes); + double readingValue = computeReadingValue(osuAttributes); - double totalValue = DifficultyCalculationUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, flashlightValue) * multiplier; + double totalValue = DifficultyCalculationUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, readingValue, flashlightValue) * multiplier; return new OsuPerformanceAttributes { @@ -155,6 +156,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s Speed = speedValue, Accuracy = accuracyValue, Flashlight = flashlightValue, + Reading = readingValue, EffectiveMissCount = effectiveMissCount, ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount, ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount, @@ -213,7 +215,7 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * drainRate * drainRate); else if (score.Mods.Any(m => m is OsuModTraceable)) { - aimValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate, sliderFactor: attributes.SliderFactor); + aimValue *= 1.0 + calculateTraceableBonus(attributes.SliderFactor); } aimValue *= accuracy; @@ -245,7 +247,7 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib } else if (score.Mods.Any(m => m is OsuModTraceable)) { - speedValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate); + speedValue *= 1.0 + calculateTraceableBonus(); } double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes); @@ -294,7 +296,7 @@ private double computeAccuracyValue(ScoreInfo score, OsuDifficultyAttributes att // Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given. if (score.Mods.Any(m => m is OsuModBlinds)) accuracyValue *= 1.14; - else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) + else if (score.Mods.Any(m => m is OsuModTraceable)) { // Decrease bonus for AR > 10 accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10); @@ -325,6 +327,19 @@ private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes a return flashlightValue; } + private double computeReadingValue(OsuDifficultyAttributes attributes) + { + double readingValue = HarmonicSkill.DifficultyToPerformance(attributes.ReadingDifficulty); + + if (effectiveMissCount > 0) + readingValue *= calculateMissPenalty(effectiveMissCount + aimEstimatedSliderBreaks, attributes.ReadingDifficultNoteCount); + + // Scale the reading value with accuracy _harshly_. + readingValue *= Math.Pow(accuracy, 3); + + return readingValue; + } + private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes attributes) { if (attributes.SliderCount <= 0) @@ -488,6 +503,28 @@ private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attribute return adjustedSpeedValue / speedValue; } + /// + /// Calculates a visibility bonus that is applicable to Traceable. + /// + private double calculateTraceableBonus(double sliderFactor = 1) + { + // Start from normal curve, rewarding lower AR up to AR7 + double traceableBonus = 0.025 * (12.0 - Math.Max(approachRate, 7)); + + // We want to reward slider aim on low AR less + double sliderVisibilityFactor = Math.Pow(sliderFactor, 3); + + // For AR up to 0 - reduce reward for very low ARs when object is visible + if (approachRate < 7) + traceableBonus += 0.02 * (7.0 - Math.Max(approachRate, 0)) * sliderVisibilityFactor; + + // Starting from AR0 - cap values so they won't grow to infinity + if (approachRate < 0) + traceableBonus += 0.01 * (1 - Math.Pow(1.5, approachRate)) * sliderVisibilityFactor; + + return traceableBonus; + } + // 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. diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index 2a050c0920cf..42a6d65cd51f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -15,19 +15,13 @@ public class OsuRatingCalculator private readonly Mod[] mods; private readonly int totalHits; - private readonly double approachRate; private readonly double overallDifficulty; - private readonly double mechanicalDifficultyRating; - private readonly double sliderFactor; - public OsuRatingCalculator(Mod[] mods, int totalHits, double approachRate, double overallDifficulty, double mechanicalDifficultyRating, double sliderFactor) + public OsuRatingCalculator(Mod[] mods, int totalHits, double overallDifficulty) { this.mods = mods; this.totalHits = totalHits; - this.approachRate = approachRate; this.overallDifficulty = overallDifficulty; - this.mechanicalDifficultyRating = mechanicalDifficultyRating; - this.sliderFactor = sliderFactor; } public double ComputeAimRating(double aimDifficultyValue) @@ -51,26 +45,6 @@ public double ComputeAimRating(double aimDifficultyValue) double ratingMultiplier = 1.0; - double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + - (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); - - double approachRateFactor = 0.0; - if (approachRate > 10.33) - approachRateFactor = 0.3 * (approachRate - 10.33); - else if (approachRate < 8.0) - approachRateFactor = 0.05 * (8.0 - approachRate); - - if (mods.Any(h => h is OsuModRelax)) - approachRateFactor = 0.0; - - ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. - - if (mods.Any(m => m is OsuModHidden)) - { - double visibilityFactor = calculateAimVisibilityFactor(approachRate); - ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor, sliderFactor); - } - // It is important to consider accuracy difficulty when scaling with accuracy. ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500; @@ -96,27 +70,34 @@ public double ComputeSpeedRating(double speedDifficultyValue) double ratingMultiplier = 1.0; - double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + - (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); + ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750; - double approachRateFactor = 0.0; - if (approachRate > 10.33) - approachRateFactor = 0.3 * (approachRate - 10.33); + return speedRating * Math.Cbrt(ratingMultiplier); + } - if (mods.Any(m => m is OsuModAutopilot)) - approachRateFactor = 0.0; + public double ComputeReadingRating(double readingDifficultyValue) + { + double readingRating = CalculateDifficultyRating(readingDifficultyValue); + + if (mods.Any(m => m is OsuModTouchDevice)) + readingRating = Math.Pow(readingRating, 0.8); - ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. + if (mods.Any(m => m is OsuModRelax)) + readingRating *= 0.7; + else if (mods.Any(m => m is OsuModAutopilot)) + readingRating *= 0.4; - if (mods.Any(m => m is OsuModHidden)) + if (mods.Any(m => m is OsuModMagnetised)) { - double visibilityFactor = calculateSpeedVisibilityFactor(approachRate); - ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor); + float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; + readingRating *= 1.0 - magnetisedStrength; } - ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750; + double ratingMultiplier = 1.0; - return speedRating * Math.Cbrt(ratingMultiplier); + ratingMultiplier *= 0.75 + Math.Pow(Math.Max(0, overallDifficulty), 2.2) / 800; + + return readingRating * Math.Cbrt(ratingMultiplier); } public double ComputeFlashlightRating(double flashlightDifficultyValue) @@ -158,56 +139,6 @@ public double ComputeFlashlightRating(double flashlightDifficultyValue) return flashlightRating * Math.Sqrt(ratingMultiplier); } - private double calculateAimVisibilityFactor(double approachRate) - { - const double ar_factor_end_point = 11.5; - - double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10); - double arFactorStartingPoint = double.Lerp(9, 10.33, mechanicalDifficultyFactor); - - return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint); - } - - private double calculateSpeedVisibilityFactor(double approachRate) - { - const double ar_factor_end_point = 11.5; - - double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10); - double arFactorStartingPoint = double.Lerp(10, 10.33, mechanicalDifficultyFactor); - - return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint); - } - - /// - /// Calculates a visibility bonus that is applicable to Hidden and Traceable. - /// - public static double CalculateVisibilityBonus(Mod[] mods, double approachRate, double visibilityFactor = 1, double sliderFactor = 1) - { - // NOTE: TC's effect is only noticeable in performance calculations until lazer mods are accounted for server-side. - bool isAlwaysPartiallyVisible = mods.OfType().Any(m => m.OnlyFadeApproachCircles.Value) || mods.OfType().Any(); - - // Start from normal curve, rewarding lower AR up to AR7 - // TC forcefully requires a lower reading bonus for now as it's post-applied in PP which makes it multiplicative with the regular AR bonuses - // This means it has an advantage over HD, so we decrease the multiplier to compensate - // This should be removed once we're able to apply TC bonuses in SR (depends on real-time difficulty calculations being possible) - double readingBonus = (isAlwaysPartiallyVisible ? 0.025 : 0.04) * (12.0 - Math.Max(approachRate, 7)); - - readingBonus *= visibilityFactor; - - // We want to reward slideraim on low AR less - double sliderVisibilityFactor = Math.Pow(sliderFactor, 3); - - // For AR up to 0 - reduce reward for very low ARs when object is visible - if (approachRate < 7) - readingBonus += (isAlwaysPartiallyVisible ? 0.02 : 0.045) * (7.0 - Math.Max(approachRate, 0)) * sliderVisibilityFactor; - - // Starting from AR0 - cap values so they won't grow to infinity - if (approachRate < 0) - readingBonus += (isAlwaysPartiallyVisible ? 0.01 : 0.1) * (1 - Math.Pow(1.5, approachRate)) * sliderVisibilityFactor; - - return readingBonus; - } - public static double CalculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * difficulty_multiplier; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 3c926fe18e2c..00bb4908c6de 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -35,6 +35,17 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// public readonly double AdjustedDeltaTime; + /// + /// Time (in ms) between the object first appearing and the time it needs to be clicked. + /// adjusted by clock rate. + /// + public readonly double Preempt; + + /// + /// Beatmap playback rate. + /// + public readonly double ClockRate; + /// /// Normalised distance from the "lazy" end position of the previous to the start position of this . /// @@ -124,6 +135,9 @@ public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40); + ClockRate = clockRate; + Preempt = BaseObject.TimePreempt / clockRate; + if (BaseObject is Slider sliderObject) { HitWindowGreat = 2 * sliderObject.HeadCircle.HitWindows.WindowFor(HitResult.Great) / clockRate; @@ -148,7 +162,9 @@ public double OpacityAt(double time, bool hidden) } double fadeInStartTime = BaseObject.StartTime - BaseObject.TimePreempt; - double fadeInDuration = BaseObject.TimeFadeIn; + + // Equal to `OsuHitObject.TimeFadeIn` minus any adjustments from the HD mod. + double fadeInDuration = 400 * Math.Min(1, BaseObject.TimePreempt / OsuHitObject.PREEMPT_MIN); if (hidden) { @@ -166,6 +182,17 @@ public double OpacityAt(double time, bool hidden) return Math.Clamp((time - fadeInStartTime) / fadeInDuration, 0.0, 1.0); } + /// + /// Returns the amount of time a note spends invisible with the hidden mod at the current approach rate. + /// + public double DurationSpentInvisible() + { + double fadeOutStartTime = BaseObject.StartTime - BaseObject.TimePreempt + BaseObject.TimeFadeIn; + double fadeOutDuration = BaseObject.TimePreempt * OsuModHidden.FADE_OUT_DURATION_MULTIPLIER; + + return (fadeOutStartTime + fadeOutDuration) - (BaseObject.StartTime - BaseObject.TimePreempt); + } + /// /// Returns how possible is it to doubletap this object together with the next one and get perfect judgement in range from 0 to 1 /// diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs new file mode 100644 index 000000000000..c475b2cfd817 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -0,0 +1,107 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Difficulty.Evaluators; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Difficulty.Skills +{ + public class Reading : HarmonicSkill + { + private readonly IReadOnlyList objectList; + + private readonly double clockRate; + private readonly bool hasHiddenMod; + + public Reading(IBeatmap beatmap, Mod[] mods, double clockRate) + : base(mods) + { + this.clockRate = clockRate; + hasHiddenMod = mods.OfType().Any(m => !m.OnlyFadeApproachCircles.Value); + objectList = beatmap.HitObjects; + } + + private double currentDifficulty; + + private double skillMultiplier => 2.5; + private double strainDecayBase => 0.8; + + private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + + protected override double ObjectDifficultyOf(DifficultyHitObject current) + { + currentDifficulty *= strainDecay(current.DeltaTime); + + currentDifficulty += ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * skillMultiplier; + + return currentDifficulty; + } + + protected override void ApplyDifficultyTransformation(double[] difficulties) + { + const double reduced_difficulty_base_line = 0.0; // Assume the first seconds are completely memorised + + if (difficulties.Length == 0) + return; + + int reducedNoteCount = calculateReducedNoteCount(); + + for (int i = 0; i < Math.Min(difficulties.Length, reducedNoteCount); i++) + { + double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((double)i / reducedNoteCount, 0, 1))); + difficulties[i] *= Interpolation.Lerp(reduced_difficulty_base_line, 1.0, scale); + } + } + + private int calculateReducedNoteCount() + { + const double reduced_difficulty_duration = 60 * 1000; + + if (objectList.Count < 2) + return 0; + + // We take the 2nd note to match `CreateDifficultyHitObjects` + HitObject firstDifficultyObject = objectList[1]; + + double reducedDuration = (firstDifficultyObject.StartTime / clockRate) + reduced_difficulty_duration; + + int reducedNoteCount = 0; + + foreach (var hitObject in objectList) + { + if (hitObject.StartTime / clockRate > reducedDuration) + break; + + reducedNoteCount++; + } + + return reducedNoteCount; + } + + public override double CountTopWeightedObjectDifficulties(double difficultyValue) + { + if (ObjectDifficulties.Count == 0) + return 0.0; + + if (NoteWeightSum == 0) + return 0.0; + + double consistentTopNote = difficultyValue / NoteWeightSum; // What would the top difficulty be if all object difficulties were identical + + if (consistentTopNote == 0) + return 0; + + return ObjectDifficulties.Sum(d => DifficultyCalculationUtils.Logistic(d / consistentTopNote, 1.15, 5, 1.1)); + } + } +} diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index 5e431dc35728..c98e2137ac10 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -33,6 +33,8 @@ public class DifficultyAttributes protected const int ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE = 41; protected const int ATTRIB_ID_RHYTHM_DIFFICULTY = 43; protected const int ATTRIB_ID_CONSISTENCY_FACTOR = 45; + protected const int ATTRIB_ID_READING = 47; + protected const int ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT = 49; /// /// The mods which were applied to the beatmap. From 0afcebe8bfbe8ec17386197fac3ed90d46d2edf8 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Mon, 26 Jan 2026 02:21:20 +0500 Subject: [PATCH 020/121] Move speed distance bonus to aim (#36168) This doesn't solve _flow_ aim in any way, but makes it so that `Speed` doesn't have distance scaling (read as "aim") anymore which fixes some issues related to that like the length bonus behaving incorrectly, and in general `Speed` being an aim+tap skill instead of just a tapping skill --------- Co-authored-by: James Wilson --- .../Difficulty/Evaluators/RhythmEvaluator.cs | 2 +- .../Evaluators/SpeedAimEvaluator.cs | 50 +++++++++++++++++++ .../Difficulty/Evaluators/SpeedEvaluator.cs | 28 +---------- .../Difficulty/OsuPerformanceCalculator.cs | 2 +- .../Difficulty/Skills/Aim.cs | 34 +++++++++---- .../Difficulty/Skills/Speed.cs | 4 +- 6 files changed, 80 insertions(+), 40 deletions(-) create mode 100644 osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index 906ccc1b46dd..1f379901969a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -16,7 +16,7 @@ public static class RhythmEvaluator private const int history_time_max = 5 * 1000; // 5 seconds private const int history_objects_max = 32; private const double rhythm_overall_multiplier = 1.0; - private const double rhythm_ratio_multiplier = 17.0; + private const double rhythm_ratio_multiplier = 30.0; /// /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs new file mode 100644 index 000000000000..c34fdb3236a3 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +{ + public static class SpeedAimEvaluator + { + private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers + + /// + /// Evaluates the difficulty of aiming the current object, based on: + /// + /// distance between the previous and current object + /// + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current) + { + if (current.BaseObject is Spinner) + return 0; + + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; + + double travelDistance = osuPrevObj?.LazyTravelDistance ?? 0; + double distance = travelDistance + osuCurrObj.LazyJumpDistance; + + // Cap distance at single_spacing_threshold + distance = Math.Min(distance, single_spacing_threshold); + + // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold + double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95); + + // Apply reduced small circle bonus because flow aim difficulty on small circles doesn't scale as hard as jumps + distanceBonus *= Math.Sqrt(osuCurrObj.SmallCircleBonus); + + double strain = distanceBonus * 1000 / osuCurrObj.AdjustedDeltaTime; + + strain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); + + return strain; + } + + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, ms / 1000)); + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index a35d24d7a8b2..32bf36b0eb96 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -2,40 +2,31 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; -using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; -using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { public static class SpeedEvaluator { - private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers private const double min_speed_bonus = 200; // 200 BPM 1/4th private const double speed_balancing_factor = 40; - private const double distance_multiplier = 0.8; /// /// Evaluates the difficulty of tapping the current object, based on: /// /// time between pressing the previous and current object, - /// distance between those objects, /// and how easily they can be cheesed. /// /// - public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList mods) + public static double EvaluateDifficultyOf(DifficultyHitObject current) { if (current.BaseObject is Spinner) return 0; - // derive strainTime for calculation var osuCurrObj = (OsuDifficultyHitObject)current; - var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; double strainTime = osuCurrObj.AdjustedDeltaTime; double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0)); @@ -51,23 +42,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnly if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus) speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2); - double travelDistance = osuPrevObj?.LazyTravelDistance ?? 0; - double distance = travelDistance + osuCurrObj.LazyJumpDistance; - - // Cap distance at single_spacing_threshold - distance = Math.Min(distance, single_spacing_threshold); - - // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold - double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier; - - // Apply reduced small circle bonus because flow aim difficulty on small circles doesn't scale as hard as jumps - distanceBonus *= Math.Sqrt(osuCurrObj.SmallCircleBonus); - - if (mods.OfType().Any()) - distanceBonus = 0; - // Base difficulty with all bonuses - double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime; + double difficulty = (1 + speedBonus) * 1000 / strainTime; difficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 87982dfaaa7f..95bbbd9310f2 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -197,7 +197,7 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut double aimValue = OsuStrainSkill.DifficultyToPerformance(aimDifficulty); - double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + + double lengthBonus = 0.95 + 0.3 * Math.Min(1.0, totalHits / 2000.0) + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); aimValue *= lengthBonus; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 11b84f2d342a..6a131511ef0a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; @@ -26,28 +27,41 @@ public Aim(Mod[] mods, bool includeSliders) IncludeSliders = includeSliders; } - private double currentStrain; + private double currentAimStrain; + private double currentSpeedStrain; - private double skillMultiplier => 26.6; - private double strainDecayBase => 0.15; + private double skillMultiplierAim => 26.0; + private double skillMultiplierSpeed => 1.3; + private double skillMultiplierTotal => 1.02; + private double meanExponent => 1.2; private readonly List sliderStrains = new List(); - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + private double strainDecayAim(double ms) => Math.Pow(0.15, ms / 1000); + private double strainDecaySpeed(double ms) => Math.Pow(0.3, ms / 1000); - protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime); + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => + DifficultyCalculationUtils.Norm(meanExponent, + currentAimStrain * strainDecayAim(time - current.Previous(0).StartTime), + currentSpeedStrain * strainDecaySpeed(time - current.Previous(0).StartTime)); protected override double StrainValueAt(DifficultyHitObject current) { - double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); + double decayAim = strainDecayAim(((OsuDifficultyHitObject)current).AdjustedDeltaTime); + double decaySpeed = strainDecaySpeed(((OsuDifficultyHitObject)current).AdjustedDeltaTime); - currentStrain *= decay; - currentStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * (1 - decay) * skillMultiplier; + currentAimStrain *= decayAim; + currentAimStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * (1 - decayAim) * skillMultiplierAim; + + currentSpeedStrain *= decaySpeed; + currentSpeedStrain += SpeedAimEvaluator.EvaluateDifficultyOf(current) * (1 - decaySpeed) * skillMultiplierSpeed; + + double totalStrain = DifficultyCalculationUtils.Norm(meanExponent, currentAimStrain, currentSpeedStrain); if (current.BaseObject is Slider) - sliderStrains.Add(currentStrain); + sliderStrains.Add(totalStrain); - return currentStrain; + return totalStrain * skillMultiplierTotal; } public double GetDifficultSliders() diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 8911b32f42e9..474091045db9 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : HarmonicSkill { - private double skillMultiplier => 0.93; + private double skillMultiplier => 0.95; private readonly List sliderStrains = new List(); @@ -42,7 +42,7 @@ protected override double ObjectDifficultyOf(DifficultyHitObject current) double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); currentDifficulty *= decay; - currentDifficulty += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * (1 - decay) * skillMultiplier; + currentDifficulty += SpeedEvaluator.EvaluateDifficultyOf(current) * (1 - decay) * skillMultiplier; double currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); From 02b76db0941e336e10090408ab76e497069ecb78 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:23:44 +0500 Subject: [PATCH 021/121] Move TD difficulty reduction to Aim (#36476) --- .../Difficulty/OsuRatingCalculator.cs | 3 --- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 14 ++++++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index 42a6d65cd51f..9ddd658d1757 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -31,9 +31,6 @@ public double ComputeAimRating(double aimDifficultyValue) double aimRating = CalculateDifficultyRating(aimDifficultyValue); - if (mods.Any(m => m is OsuModTouchDevice)) - aimRating = Math.Pow(aimRating, 0.8); - if (mods.Any(m => m is OsuModRelax)) aimRating *= 0.9; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 6a131511ef0a..17e5990895f0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -10,6 +10,7 @@ using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Skills @@ -50,11 +51,20 @@ protected override double StrainValueAt(DifficultyHitObject current) double decayAim = strainDecayAim(((OsuDifficultyHitObject)current).AdjustedDeltaTime); double decaySpeed = strainDecaySpeed(((OsuDifficultyHitObject)current).AdjustedDeltaTime); + double aimDifficulty = AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders); + double speedDifficulty = SpeedAimEvaluator.EvaluateDifficultyOf(current); + + if (Mods.Any(m => m is OsuModTouchDevice)) + { + aimDifficulty = Math.Pow(aimDifficulty, 0.8); + speedDifficulty = Math.Pow(speedDifficulty, 0.95); + } + currentAimStrain *= decayAim; - currentAimStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * (1 - decayAim) * skillMultiplierAim; + currentAimStrain += aimDifficulty * (1 - decayAim) * skillMultiplierAim; currentSpeedStrain *= decaySpeed; - currentSpeedStrain += SpeedAimEvaluator.EvaluateDifficultyOf(current) * (1 - decaySpeed) * skillMultiplierSpeed; + currentSpeedStrain += speedDifficulty * (1 - decaySpeed) * skillMultiplierSpeed; double totalStrain = DifficultyCalculationUtils.Norm(meanExponent, currentAimStrain, currentSpeedStrain); From ae43d3e3b1adf89e2dc59d9be591757d82912b58 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:23:04 +0500 Subject: [PATCH 022/121] Use deviation-based Speed accuracy scaling (#36475) https://pp.huismetbenen.nl/rankings/players/tap-statacc --------- Co-authored-by: James Wilson --- .../Difficulty/OsuPerformanceCalculator.cs | 18 +++++++++--------- .../Difficulty/OsuRatingCalculator.cs | 6 +----- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 2 +- .../Difficulty/Skills/Speed.cs | 2 +- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 95bbbd9310f2..a8a024023d30 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -253,15 +253,15 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes); speedValue *= speedHighDeviationMultiplier; - // Calculate accuracy assuming the worst case scenario - double relevantTotalDiff = Math.Max(0, totalHits - attributes.SpeedNoteCount); - double relevantCountGreat = Math.Max(0, countGreat - relevantTotalDiff); - double relevantCountOk = Math.Max(0, countOk - Math.Max(0, relevantTotalDiff - countGreat)); - double relevantCountMeh = Math.Max(0, countMeh - Math.Max(0, relevantTotalDiff - countGreat - countOk)); - double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0); - - // Scale the speed value with accuracy and OD. - speedValue *= Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2); + // An effective hit window is created based on the speed SR. The higher the speed difficulty, the shorter the hit window. + // For example, a speed SR of 3.0 leads to an effective hit window of 20ms, which is OD 10. + double effectiveHitWindow = Math.Sqrt(30 * 60 / attributes.SpeedDifficulty); + + // Find the proportion of 300s on speed notes assuming the hit window was the effective hit window. + double effectiveAccuracy = DifficultyCalculationUtils.Erf(effectiveHitWindow / (double)speedDeviation); + + // Scale speed value by normalized accuracy. + speedValue *= Math.Pow(effectiveAccuracy, 2); return speedValue; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index 9ddd658d1757..43107c8dce1f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -65,11 +65,7 @@ public double ComputeSpeedRating(double speedDifficultyValue) speedRating *= 1.0 - magnetisedStrength * 0.3; } - double ratingMultiplier = 1.0; - - ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750; - - return speedRating * Math.Cbrt(ratingMultiplier); + return speedRating; } public double ComputeReadingRating(double readingDifficultyValue) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 17e5990895f0..146ef8167dd9 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -33,7 +33,7 @@ public Aim(Mod[] mods, bool includeSliders) private double skillMultiplierAim => 26.0; private double skillMultiplierSpeed => 1.3; - private double skillMultiplierTotal => 1.02; + private double skillMultiplierTotal => 1.01; private double meanExponent => 1.2; private readonly List sliderStrains = new List(); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 474091045db9..b84c85d38aed 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : HarmonicSkill { - private double skillMultiplier => 0.95; + private double skillMultiplier => 1.03; private readonly List sliderStrains = new List(); From ea87e82a919f82a08fbfd8928ef89f4c880417be Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:47:08 +0200 Subject: [PATCH 023/121] Make reading and flashlight pp to be LP summed (#36464) Right now they're summed normally what opens a problem that FL rewards too much pp when combined with reading map, since you're memorizing FL anyway. Co-authored-by: James Wilson --- osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs | 3 ++- .../Difficulty/OsuPerformanceCalculator.cs | 6 ++++-- osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 245c4107e958..ee6e0dba8514 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -110,8 +110,9 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double baseSpeedPerformance = HarmonicSkill.DifficultyToPerformance(speedRating); double baseReadingPerformance = HarmonicSkill.DifficultyToPerformance(readingRating); double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); + double baseCognitionPerformance = DifficultyCalculationUtils.Norm(2, baseReadingPerformance, baseFlashlightPerformance); - double basePerformance = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseReadingPerformance, baseFlashlightPerformance); + double basePerformance = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseCognitionPerformance); double starRating = calculateStarRating(basePerformance); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index a8a024023d30..8ee76814c37d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -145,10 +145,12 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s double aimValue = computeAimValue(score, osuAttributes); double speedValue = computeSpeedValue(score, osuAttributes); double accuracyValue = computeAccuracyValue(score, osuAttributes); - double flashlightValue = computeFlashlightValue(score, osuAttributes); + double readingValue = computeReadingValue(osuAttributes); + double flashlightValue = computeFlashlightValue(score, osuAttributes); + double cognitionValue = DifficultyCalculationUtils.Norm(2, readingValue, flashlightValue); - double totalValue = DifficultyCalculationUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, readingValue, flashlightValue) * multiplier; + double totalValue = DifficultyCalculationUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, cognitionValue) * multiplier; return new OsuPerformanceAttributes { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index 70d61250bbf1..44c39cbb9cd0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -20,7 +20,7 @@ public Flashlight(Mod[] mods) { } - private double skillMultiplier => 0.05512; + private double skillMultiplier => 0.056; private double strainDecayBase => 0.15; private double currentStrain; From aefe31d315ce7bd5dce11cf4df6fdb0a03ecc2bd Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Tue, 27 Jan 2026 22:23:22 +0200 Subject: [PATCH 024/121] Make sliderless aim in fact sliderless (#29993) Part of this PR - https://github.com/ppy/osu/pull/27303 Current aim calculation have a flaw of sliderless aim still accounting for sliders. This happens because of usage of `LazyJumpDistance` as a main distance metric. This PR is fixing this by adding `JumpDistance` as true sliderless metric, using it instead of `LazyJumpDistance`. This can introduce very rare cases where sliderless aim is worth more than normal aim (because of velocity change bonus). The effect of this is minimal on most of the maps. It can be seen the best on this map - https://osu.ppy.sh/beatmapsets/594751#osu/1257904 Before: ![image](https://github.com/user-attachments/assets/e96773e6-3274-4ed6-8293-ed9bdfa213d0) After: ![image](https://github.com/user-attachments/assets/126e861b-c28d-4ed4-a0db-f5305225a749) --------- Co-authored-by: James Wilson Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- .../Difficulty/Evaluators/AimEvaluator.cs | 27 +++++++++++-------- .../Preprocessing/OsuDifficultyHitObject.cs | 8 +++++- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 4aa45f45293c..24db5ef525e4 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -40,7 +40,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER; // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. - double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.AdjustedDeltaTime; + double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance; + double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; // But if the last object is a slider, then we extend the travel velocity through the slider into the current object. if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) @@ -52,7 +53,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with } // As above, do the same for the previous hitobject. - double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.AdjustedDeltaTime; + double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance; + double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance) { @@ -88,7 +90,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter acuteAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * - DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2); + DifficultyCalculationUtils.Smootherstep(currDistance, diameter, diameter * 2); } wideAngleBonus = calcWideAngleBonus(currAngle); @@ -97,16 +99,16 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3)); // Apply full wide angle bonus for distance more than one diameter - wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter); + wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(currDistance, 0, diameter); // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle // https://www.desmos.com/calculator/dp0v0nvowc wiggleBonus = angleBonus - * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, radius, diameter) - * Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuCurrObj.LazyJumpDistance, diameter * 3, diameter), 1.8) + * DifficultyCalculationUtils.Smootherstep(currDistance, radius, diameter) + * Math.Pow(DifficultyCalculationUtils.ReverseLerp(currDistance, diameter * 3, diameter), 1.8) * DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) - * DifficultyCalculationUtils.Smootherstep(osuLastObj.LazyJumpDistance, radius, diameter) - * Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuLastObj.LazyJumpDistance, diameter * 3, diameter), 1.8) + * DifficultyCalculationUtils.Smootherstep(prevDistance, radius, diameter) + * Math.Pow(DifficultyCalculationUtils.ReverseLerp(prevDistance, diameter * 3, diameter), 1.8) * DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); if (osuLast2Obj != null) @@ -127,9 +129,12 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with if (Math.Max(prevVelocity, currVelocity) != 0) { - // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities. - prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.AdjustedDeltaTime; - currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.AdjustedDeltaTime; + if (withSliderTravelDistance) + { + // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities. + prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.AdjustedDeltaTime; + currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.AdjustedDeltaTime; + } // Scale with ratio of difference compared to 0.5 * max dist. double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 00bb4908c6de..727b07c4af5a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -46,6 +46,11 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// public readonly double ClockRate; + /// + /// Normalised distance from the start position of the previous to the start position of this . + /// + public double JumpDistance { get; private set; } + /// /// Normalised distance from the "lazy" end position of the previous to the start position of this . /// @@ -232,7 +237,8 @@ private void setDistances(double clockRate) Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition; - LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; + JumpDistance = (LastObject.StackedPosition - BaseObject.StackedPosition).Length * scalingFactor; + LazyJumpDistance = (BaseObject.StackedPosition - lastCursorPosition).Length * scalingFactor; MinimumJumpTime = AdjustedDeltaTime; MinimumJumpDistance = LazyJumpDistance; From f57a271450b76825f936e506c8b36b5edfc302c1 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:55:18 +0500 Subject: [PATCH 025/121] Undo slider high cs bonus hotfix (#36513) It existed for one map and now that that map is fine there's no need to do it anymore. --- osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 24db5ef525e4..664e0a636b96 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -160,13 +160,13 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Add in acute angle bonus or wide angle bonus, whichever is larger. aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier); - // Apply high circle size bonus - aimStrain *= osuCurrObj.SmallCircleBonus; - // Add in additional slider velocity bonus. if (withSliderTravelDistance) aimStrain += sliderBonus * slider_multiplier; + // Apply high circle size bonus + aimStrain *= osuCurrObj.SmallCircleBonus; + aimStrain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); return aimStrain; From 8be93174750368f90993043b6d6aff7bd057d1de Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:31:41 +0200 Subject: [PATCH 026/121] Fix flashlight having near-zero bonus when reading difficulty is much higher (#36487) There was a recent PR (https://github.com/ppy/osu/pull/36464) that was aimed on accounting for the fact that getting your map partially memorized with FL makes reading easier, so those bonuses shouldn't reward full pp to each other. But in cases where FL pp is significantly lower than reading it have lead to cases where FL adds practically 0 additional pp. This PR is adjusting a formula to account for cases like this. FL reward on this map - https://osu.ppy.sh/beatmapsets/1487999#osu/3259719 with EZHDHT(FL): Full bonus: 408pp -> 486pp (+78pp) Current: 408pp -> 424pp (+16pp) This PR: 408pp -> 478pp (+70pp) Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- .../Difficulty/OsuDifficultyCalculator.cs | 11 ++++++++++- .../Difficulty/OsuPerformanceCalculator.cs | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index ee6e0dba8514..fe430915f40d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -110,7 +110,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double baseSpeedPerformance = HarmonicSkill.DifficultyToPerformance(speedRating); double baseReadingPerformance = HarmonicSkill.DifficultyToPerformance(readingRating); double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); - double baseCognitionPerformance = DifficultyCalculationUtils.Norm(2, baseReadingPerformance, baseFlashlightPerformance); + double baseCognitionPerformance = SumCognitionDifficulty(baseReadingPerformance, baseFlashlightPerformance); double basePerformance = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseCognitionPerformance); @@ -144,6 +144,15 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat return attributes; } + public static double SumCognitionDifficulty(double reading, double flashlight) + { + // Base LP summed value, accounting for map being partially memorized with FL + double cognition = DifficultyCalculationUtils.Norm(2, reading, flashlight); + + // Inrease FL bonus when it's lower than reading to avoid situations where high reading difficulty makes FL give practically 0 bonus + return flashlight >= reading ? cognition : double.Lerp(reading + flashlight, cognition, flashlight / reading); + } + private double calculateStarRating(double basePerformance) { return Math.Cbrt(basePerformance * OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 8ee76814c37d..c833defdd7b2 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -148,7 +148,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s double readingValue = computeReadingValue(osuAttributes); double flashlightValue = computeFlashlightValue(score, osuAttributes); - double cognitionValue = DifficultyCalculationUtils.Norm(2, readingValue, flashlightValue); + double cognitionValue = OsuDifficultyCalculator.SumCognitionDifficulty(readingValue, flashlightValue); double totalValue = DifficultyCalculationUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, cognitionValue) * multiplier; From e9974ba43da440c46d7b69b79fa92b82c3eca735 Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Fri, 30 Jan 2026 20:18:07 +0200 Subject: [PATCH 027/121] Adjust speed deviation scaling to be more harsh on low end (#36554) This should buff raw speed plays like Ivaxa Violation, at the same time undoing part of the buff on the lower end scores like Save Me NM Moved part of the multiplier out of the Pow to be more intuitive (it multiplies the 20 by Pow(difficulty/4), so it's more clear that it would be equal to 1 on difficulty = 4) The scaling itself was adjusted to be more similar to live (so buffs/nerfs on 98% acc remains +- the same through the difficulty curve) --- osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index c833defdd7b2..96281297d70c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -256,8 +256,8 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib speedValue *= speedHighDeviationMultiplier; // An effective hit window is created based on the speed SR. The higher the speed difficulty, the shorter the hit window. - // For example, a speed SR of 3.0 leads to an effective hit window of 20ms, which is OD 10. - double effectiveHitWindow = Math.Sqrt(30 * 60 / attributes.SpeedDifficulty); + // For example, a speed SR of 4.0 leads to an effective hit window of 20ms, which is OD 10. + double effectiveHitWindow = 20 * Math.Pow(4 / attributes.SpeedDifficulty, 0.35); // Find the proportion of 300s on speed notes assuming the hit window was the effective hit window. double effectiveAccuracy = DifficultyCalculationUtils.Erf(effectiveHitWindow / (double)speedDeviation); From 355addc6ee765175324fc283023ffeb40ee690c1 Mon Sep 17 00:00:00 2001 From: James Wilson Date: Sat, 31 Jan 2026 20:55:11 +0000 Subject: [PATCH 028/121] Nerf aim strain for objects 1 / (1 - Math.Pow(0.15, ms / 1000)); + // We decrease strain for distances 1 / (1 - Math.Pow(0.15, ms / 1000)) + * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); From ee0ac3eab4b1ed4f85795e8b619e35bdd738ef41 Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Sun, 1 Feb 2026 23:59:19 +0200 Subject: [PATCH 029/121] Simplify hidden difficulty calculation (#36465) It removes unnecessary function `DurationSpentInvisible` function that just rescaled preempt. Now it's just using preempt directly. I've made multiplier to be very close to the current one, so pp deltas should be minimal. --- .../Difficulty/Evaluators/ReadingEvaluator.cs | 8 +++----- .../Preprocessing/OsuDifficultyHitObject.cs | 11 ----------- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs index 0a45ee5eb7e6..647278c520cc 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -117,15 +117,13 @@ private static double calculatePreemptDifficulty(double velocity, double constan private static double calculateHiddenDifficulty(OsuDifficultyHitObject currObj, double pastObjectDifficultyInfluence, double currentVisibleObjectDensity, double velocity, double constantAngleNerfFactor) { - double timeSpentInvisible = currObj.DurationSpentInvisible() / currObj.ClockRate; - - // Value time spent invisible exponentially - double timeSpentInvisibleFactor = Math.Pow(timeSpentInvisible, 2.2) * 0.022; + // Higher preempt means that time spent invisible is higher too, we want to reward that + double preemptFactor = Math.Pow(currObj.Preempt, 2.2) * 0.01; // Account for both past and current densities double densityFactor = Math.Pow(currentVisibleObjectDensity + pastObjectDifficultyInfluence, 3.3) * 3; - double hiddenDifficulty = (timeSpentInvisibleFactor + densityFactor) * constantAngleNerfFactor * velocity * 0.01; + double hiddenDifficulty = (preemptFactor + densityFactor) * constantAngleNerfFactor * velocity * 0.01; // Apply a soft cap to general HD reading to account for partial memorization hiddenDifficulty = Math.Pow(hiddenDifficulty, 0.4) * hidden_multiplier; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 727b07c4af5a..935ee553ffd9 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -187,17 +187,6 @@ public double OpacityAt(double time, bool hidden) return Math.Clamp((time - fadeInStartTime) / fadeInDuration, 0.0, 1.0); } - /// - /// Returns the amount of time a note spends invisible with the hidden mod at the current approach rate. - /// - public double DurationSpentInvisible() - { - double fadeOutStartTime = BaseObject.StartTime - BaseObject.TimePreempt + BaseObject.TimeFadeIn; - double fadeOutDuration = BaseObject.TimePreempt * OsuModHidden.FADE_OUT_DURATION_MULTIPLIER; - - return (fadeOutStartTime + fadeOutDuration) - (BaseObject.StartTime - BaseObject.TimePreempt); - } - /// /// Returns how possible is it to doubletap this object together with the next one and get perfect judgement in range from 0 to 1 /// From 8c180b00bebdfe7fca70c1e6c9165cba7e0ae080 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Thu, 5 Feb 2026 00:53:42 +0500 Subject: [PATCH 030/121] Change wide bonus distance reduction to be consistent with `SpeedAim.SINGLE_SPACING_THRESHOLD` (#36573) Slightly reducing aim/speedaim doubledipping, but mostly just done for consistency and ease of understanding of existing relation between both --- osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs | 4 ++-- .../Difficulty/Evaluators/SpeedAimEvaluator.cs | 6 +++--- osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index c7e96d757262..8573d36c70a8 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -98,8 +98,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Penalize angle repetition. wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3)); - // Apply full wide angle bonus for distance more than one diameter - wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(currDistance, 0, diameter); + // Apply full wide angle bonus for distance more than SINGLE_SPACING_THRESHOLD + wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(currDistance, 0, SpeedAimEvaluator.SINGLE_SPACING_THRESHOLD); // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle // https://www.desmos.com/calculator/dp0v0nvowc diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs index c34fdb3236a3..d56ffbfe14bf 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs @@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { public static class SpeedAimEvaluator { - private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers + public const double SINGLE_SPACING_THRESHOLD = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers /// /// Evaluates the difficulty of aiming the current object, based on: @@ -30,10 +30,10 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double distance = travelDistance + osuCurrObj.LazyJumpDistance; // Cap distance at single_spacing_threshold - distance = Math.Min(distance, single_spacing_threshold); + distance = Math.Min(distance, SINGLE_SPACING_THRESHOLD); // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold - double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95); + double distanceBonus = Math.Pow(distance / SINGLE_SPACING_THRESHOLD, 3.95); // Apply reduced small circle bonus because flow aim difficulty on small circles doesn't scale as hard as jumps distanceBonus *= Math.Sqrt(osuCurrObj.SmallCircleBonus); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index b84c85d38aed..715197b4b513 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : HarmonicSkill { - private double skillMultiplier => 1.03; + private double skillMultiplier => 1.035; private readonly List sliderStrains = new List(); From 52be3111b44ddf1ba6c39ce0bf5840b073063d07 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Thu, 5 Feb 2026 01:19:42 +0500 Subject: [PATCH 031/121] Add a bunch of rhythm improvements (#36569) This does a couple of things: - Adds slider->circle effective ratio adjustment - Removes same polarity nerf for single delta count islands (which are pretty much THE most unpredictable) - Makes polarity nerf check actual deltas and not just the count - Excludes spinners from ratio calculation https://pp.huismetbenen.nl/rankings/players/minor-rhythm-improvements --------- Co-authored-by: James Wilson --- .../Difficulty/Evaluators/RhythmEvaluator.cs | 43 +++++++++++++------ .../Preprocessing/OsuDifficultyHitObject.cs | 3 +- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index 1f379901969a..985898c6af44 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -15,7 +15,7 @@ public static class RhythmEvaluator { private const int history_time_max = 5 * 1000; // 5 seconds private const int history_objects_max = 32; - private const double rhythm_overall_multiplier = 1.0; + private const double rhythm_overall_multiplier = 0.9; private const double rhythm_ratio_multiplier = 30.0; /// @@ -55,6 +55,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) for (int i = rhythmStart; i > 0; i--) { OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1); + if (currObj.BaseObject is Spinner) + continue; // scales note 0 to 1 from history to now double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; @@ -62,7 +64,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count. - // Use custom cap value to ensure that that at this point delta time is actually zero + // Use custom cap value to ensure that at this point delta time is actually zero double currDelta = Math.Max(currObj.DeltaTime, 1e-7); double prevDelta = Math.Max(prevObj.DeltaTime, 1e-7); double lastDelta = Math.Max(lastObj.DeltaTime, 1e-7); @@ -71,17 +73,23 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) double deltaDifference = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta); - // Take only the fractional part of the value since we're only interested in punishing multiples - double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference); - - double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction)); - // reduce ratio bonus if delta difference is too big double differenceMultiplier = Math.Clamp(2.0 - deltaDifference / 8.0, 0.0, 1.0); double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon); - double effectiveRatio = windowPenalty * currRatio * differenceMultiplier; + double effectiveRatio = getEffectiveRatio(deltaDifference) * windowPenalty * differenceMultiplier; + + // if previous object is a slider it might be easier to tap since you don't have to do a whole tapping motion + // while a full deltatime might end up some weird ratio the "unpress->tap" motion might be simple + // for example a slider-circle-circle pattern should be evaluated as a regular triple and not as a single->double + if (prevObj.BaseObject is Slider) + { + double sliderEndDelta = currObj.MinimumJumpTime; + double sliderDeltaDifference = Math.Max(sliderEndDelta, currDelta) / Math.Min(sliderEndDelta, currDelta); + double sliderEffectiveRatio = getEffectiveRatio(sliderDeltaDifference); + effectiveRatio = Math.Min(sliderEffectiveRatio, effectiveRatio); + } if (firstDeltaSwitch) { @@ -99,7 +107,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // bpm change was from a slider, this is easier typically than circle -> circle // unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders if (prevObj.BaseObject is Slider) - effectiveRatio *= 0.3; + effectiveRatio *= 0.5; // repeated island polarity (2 -> 4, 3 -> 5) if (island.IsSimilarPolarity(previousIsland)) @@ -177,6 +185,14 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though); } + private static double getEffectiveRatio(double deltaDifference) + { + // Take only the fractional part of the value since we're only interested in punishing multiples + double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference); + + return 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction)); + } + private class Island : IEquatable { private readonly double deltaDifferenceEpsilon; @@ -206,9 +222,12 @@ public void AddDelta(int delta) public bool IsSimilarPolarity(Island other) { - // TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple) - // naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation - return DeltaCount % 2 == other.DeltaCount % 2; + // single delta islands shouldn't be compared + if (DeltaCount <= 1 || other.DeltaCount <= 1) + return false; + + return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon && + DeltaCount % 2 == other.DeltaCount % 2; } public bool Equals(Island? other) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 935ee553ffd9..7c21895181c5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -217,6 +217,8 @@ private void setDistances(double clockRate) TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME); } + MinimumJumpTime = AdjustedDeltaTime; + // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner if (BaseObject is Spinner || LastObject is Spinner) return; @@ -228,7 +230,6 @@ private void setDistances(double clockRate) JumpDistance = (LastObject.StackedPosition - BaseObject.StackedPosition).Length * scalingFactor; LazyJumpDistance = (BaseObject.StackedPosition - lastCursorPosition).Length * scalingFactor; - MinimumJumpTime = AdjustedDeltaTime; MinimumJumpDistance = LazyJumpDistance; if (LastObject is Slider lastSlider && lastDifficultyObject != null) From 4eec06c1c35b9a021240e3df70113f27b08a1869 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:38:59 +0500 Subject: [PATCH 032/121] Add real sliderend check in rhythm (#36593) Classic blunder. See https://discord.com/channels/546120878908506119/619291828054655016/1468738477297504489 --- .../Difficulty/Evaluators/RhythmEvaluator.cs | 15 +++++++-------- .../Preprocessing/OsuDifficultyHitObject.cs | 6 ++++++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index 985898c6af44..eca4619e4aa9 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -85,9 +85,13 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // for example a slider-circle-circle pattern should be evaluated as a regular triple and not as a single->double if (prevObj.BaseObject is Slider) { - double sliderEndDelta = currObj.MinimumJumpTime; - double sliderDeltaDifference = Math.Max(sliderEndDelta, currDelta) / Math.Min(sliderEndDelta, currDelta); - double sliderEffectiveRatio = getEffectiveRatio(sliderDeltaDifference); + double sliderLazyEndDelta = currObj.MinimumJumpTime; + double sliderLazyDeltaDifference = Math.Max(sliderLazyEndDelta, currDelta) / Math.Min(sliderLazyEndDelta, currDelta); + + double sliderRealEndDelta = currObj.LastObjectEndDeltaTime; + double sliderRealDeltaDifference = Math.Max(sliderRealEndDelta, currDelta) / Math.Min(sliderRealEndDelta, currDelta); + + double sliderEffectiveRatio = Math.Min(getEffectiveRatio(sliderLazyDeltaDifference), getEffectiveRatio(sliderRealDeltaDifference)); effectiveRatio = Math.Min(sliderEffectiveRatio, effectiveRatio); } @@ -102,11 +106,6 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) { // bpm change is into slider, this is easy acc window if (currObj.BaseObject is Slider) - effectiveRatio *= 0.125; - - // bpm change was from a slider, this is easier typically than circle -> circle - // unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders - if (prevObj.BaseObject is Slider) effectiveRatio *= 0.5; // repeated island polarity (2 -> 4, 3 -> 5) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 7c21895181c5..8b81788c73ad 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -35,6 +35,11 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// public readonly double AdjustedDeltaTime; + /// + /// Amount of time elapsed between lastDifficultyObject's and capped to a minimum of ms. + /// + public double LastObjectEndDeltaTime { get; private set; } + /// /// Time (in ms) between the object first appearing and the time it needs to be clicked. /// adjusted by clock rate. @@ -137,6 +142,7 @@ public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME); + LastObjectEndDeltaTime = lastDifficultyObject != null ? Math.Max(StartTime - lastDifficultyObject.EndTime, MIN_DELTA_TIME) : AdjustedDeltaTime; SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40); From 0f709035cdf6860048c4a5ad46b69e7f4e514cef Mon Sep 17 00:00:00 2001 From: molneya <62799417+molneya@users.noreply.github.com> Date: Mon, 9 Feb 2026 01:11:17 +0800 Subject: [PATCH 033/121] only nerf total cognition sum when reading > flashlight (#36599) Alternative to #36464 and #36487. Reading and flashlight should be treated much more separately, since the flashlight skill only accounts for object visibility and repetition, but reading accounts for overlaps, high/low AR, etc. However, for certain scores with very high reading (to the point of memorisation), the skill indeed overlaps with flashlight. This PR uses the normal strain sum exponent for reading and flashlight (for player expectations), with an extra factor on flashlight to account for the ease of adding flashlight to a partially memorised map when reading is the dominant skill. --- .../Difficulty/OsuDifficultyCalculator.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index fe430915f40d..d30f556c77f1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -146,11 +146,8 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat public static double SumCognitionDifficulty(double reading, double flashlight) { - // Base LP summed value, accounting for map being partially memorized with FL - double cognition = DifficultyCalculationUtils.Norm(2, reading, flashlight); - - // Inrease FL bonus when it's lower than reading to avoid situations where high reading difficulty makes FL give practically 0 bonus - return flashlight >= reading ? cognition : double.Lerp(reading + flashlight, cognition, flashlight / reading); + // Nerf flashlight value in cognition sum when reading is greater than flashlight + return DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, reading, flashlight * Math.Clamp(flashlight / reading, 0.25, 1.0)); } private double calculateStarRating(double basePerformance) From 0d42b77da7a854c994d32f0c383f9d71d5f412b4 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Mon, 9 Feb 2026 04:30:04 +0500 Subject: [PATCH 034/121] Return zero rhythm difficulty for `Swell` and `DrumRoll` (#36628) Found in the middle of hitwindow usage refactor --- .../Difficulty/Evaluators/RhythmEvaluator.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs index 3b3aea07f314..c0acd5935a62 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs @@ -8,6 +8,7 @@ using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data; +using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators { @@ -18,6 +19,9 @@ public class RhythmEvaluator /// public static double EvaluateDifficultyOf(DifficultyHitObject hitObject, double hitWindow) { + if (hitObject.BaseObject is not Hit) + return 0; + TaikoRhythmData rhythmData = ((TaikoDifficultyHitObject)hitObject).RhythmData; double difficulty = 0.0d; From 25d2c04787d78db177178538f3e40ad4a335cc9a Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:10:34 +0500 Subject: [PATCH 035/121] Add early returns in `HarmonicSkill` and `OsuDifficultyCalculator` (#36644) --- osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs | 6 ++++++ osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs | 3 --- osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index d30f556c77f1..790580ea8e18 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -146,6 +146,12 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat public static double SumCognitionDifficulty(double reading, double flashlight) { + if (reading <= 0) + return flashlight; + + if (flashlight <= 0) + return reading; + // Nerf flashlight value in cognition sum when reading is greater than flashlight return DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, reading, flashlight * Math.Clamp(flashlight / reading, 0.25, 1.0)); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index c475b2cfd817..07dc99e0f2c3 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -51,9 +51,6 @@ protected override void ApplyDifficultyTransformation(double[] difficulties) { const double reduced_difficulty_base_line = 0.0; // Assume the first seconds are completely memorised - if (difficulties.Length == 0) - return; - int reducedNoteCount = calculateReducedNoteCount(); for (int i = 0; i < Math.Min(difficulties.Length, reducedNoteCount); i++) diff --git a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs index b89b53d8cbac..cdd6048b8610 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs @@ -59,6 +59,9 @@ public override double DifficultyValue() // These notes will not contribute to the difficulty. double[] difficulties = ObjectDifficulties.Where(p => p > 0).ToArray(); + if (difficulties.Length == 0) + return 0; + ApplyDifficultyTransformation(difficulties); double difficulty = 0; From 3a4631954fc5e2005fd11449941f503667e7a600 Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:18:16 +0200 Subject: [PATCH 036/121] Nerf HD perfect stack reward (#36623) Safety measure to prevent potential major abuse maps. The alternative is reducing nerf on multiplier (for example 5000 instead of 2500), but increase nerf if rhythm is the same. Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs index 647278c520cc..326e98609c31 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -132,7 +132,7 @@ private static double calculateHiddenDifficulty(OsuDifficultyHitObject currObj, // Buff perfect stacks only if current note is completely invisible at the time you click the previous note. if (currObj.LazyJumpDistance == 0 && currObj.OpacityAt(previousObj.BaseObject.StartTime + previousObj.Preempt, true) == 0 && previousObj.StartTime + previousObj.Preempt > currObj.StartTime) - hiddenDifficulty += hidden_multiplier * 7500 / Math.Pow(currObj.AdjustedDeltaTime, 1.5); // Perfect stacks are harder the less time between notes + hiddenDifficulty += hidden_multiplier * 2500 / Math.Pow(currObj.AdjustedDeltaTime, 1.5); // Perfect stacks are harder the less time between notes return hiddenDifficulty; } From c8318d6edda3c169927277272c23e3a2db672695 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:01:51 +0500 Subject: [PATCH 037/121] Refactor `DifficultyHitObject` to include `ClockRate` and `HitWindows` (#36630) This changes difficulty calculations to use `ClockRate` and `HitWindows` from `DifficultyHitObjects` instead of providing them manually. It's main purpose is unifying all the calculations to use the same source of truth, and also allowing future work on supporting variable clockrates. --------- Co-authored-by: James Wilson --- .../Difficulty/CatchDifficultyCalculator.cs | 14 ++++------ .../Evaluators/MovementEvaluator.cs | 6 +++- .../Difficulty/Skills/Movement.cs | 18 ++---------- .../Difficulty/Evaluators/RhythmEvaluator.cs | 3 +- .../Difficulty/Evaluators/SpeedEvaluator.cs | 3 +- .../Difficulty/OsuDifficultyCalculator.cs | 2 +- .../Preprocessing/OsuDifficultyHitObject.cs | 22 +-------------- .../Difficulty/Skills/Reading.cs | 20 +++++-------- .../Difficulty/Evaluators/RhythmEvaluator.cs | 5 +++- .../Preprocessing/TaikoDifficultyHitObject.cs | 2 +- .../Difficulty/Skills/Rhythm.cs | 7 ++--- .../Difficulty/TaikoDifficultyCalculator.cs | 7 +---- .../Preprocessing/DifficultyHitObject.cs | 28 +++++++++++++++++++ 13 files changed, 62 insertions(+), 75 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index 14a8ff31c56f..4e30be689cb9 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -22,8 +22,6 @@ public class CatchDifficultyCalculator : DifficultyCalculator { private const double difficulty_multiplier = 4.59; - private float halfCatcherWidth; - public override int Version => 20250306; public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) @@ -52,6 +50,11 @@ protected override IEnumerable CreateDifficultyHitObjects(I List objects = new List(); + float halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f; + + // For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay. + halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f); + // In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream. foreach (var hitObject in CatchBeatmap.GetPalpableObjects(beatmap.HitObjects)) { @@ -70,14 +73,9 @@ protected override IEnumerable CreateDifficultyHitObjects(I protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { - halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f; - - // For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay. - halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f); - return new Skill[] { - new Movement(mods, halfCatcherWidth, clockRate), + new Movement(mods), }; } diff --git a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs index 618b18394341..8c44cd35693c 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs @@ -11,12 +11,16 @@ public static class MovementEvaluator { private const double direction_change_bonus = 21.0; - public static double EvaluateDifficultyOf(DifficultyHitObject current, double catcherSpeedMultiplier) + public static double EvaluateDifficultyOf(DifficultyHitObject current) { var catchCurrent = (CatchDifficultyHitObject)current; var catchLast = (CatchDifficultyHitObject)current.Previous(0); var catchLastLast = (CatchDifficultyHitObject)current.Previous(1); + // In catch, clockrate adjustments do not only affect the timings of hitobjects, + // but also the speed of the player's catcher, which has an impact on difficulty + double catcherSpeedMultiplier = current.ClockRate; + double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier); double distanceAddition = (Math.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510); diff --git a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs index 90055b9aa384..332ef7e17bf9 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -17,28 +17,14 @@ public class Movement : StrainDecaySkill protected override int SectionLength => 750; - protected readonly float HalfCatcherWidth; - - /// - /// The speed multiplier applied to the player's catcher. - /// - private readonly double catcherSpeedMultiplier; - - public Movement(Mod[] mods, float halfCatcherWidth, double clockRate) + public Movement(Mod[] mods) : base(mods) { - HalfCatcherWidth = halfCatcherWidth; - - // In catch, clockrate adjustments do not only affect the timings of hitobjects, - // but also the speed of the player's catcher, which has an impact on difficulty - // TODO: Support variable clockrates caused by mods such as ModTimeRamp - // (perhaps by using IApplicableToRate within the CatchDifficultyHitObject constructor to set a catcher speed for each object before processing) - catcherSpeedMultiplier = clockRate; } protected override double StrainValueOf(DifficultyHitObject current) { - return MovementEvaluator.EvaluateDifficultyOf(current, catcherSpeedMultiplier); + return MovementEvaluator.EvaluateDifficultyOf(current); } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index eca4619e4aa9..f97bb85b562a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -8,6 +8,7 @@ using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { @@ -28,7 +29,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double rhythmComplexitySum = 0; - double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; + double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindow(HitResult.Great) * 0.3; var island = new Island(deltaDifferenceEpsilon); var previousIsland = new Island(deltaDifferenceEpsilon); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index 32bf36b0eb96..5b724af2a025 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -6,6 +6,7 @@ using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { @@ -33,7 +34,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // Cap deltatime to the OD 300 hitwindow. // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. - strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1); + strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindow(HitResult.Great)) / 0.93, 0.92, 1); // speedBonus will be 0.0 for BPM < 200 double speedBonus = 0.0; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 790580ea8e18..3744a55a5292 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -182,7 +182,7 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clo new Aim(mods, true), new Aim(mods, false), new Speed(mods), - new Reading(beatmap, mods, clockRate) + new Reading(mods) }; if (mods.Any(h => h is OsuModFlashlight)) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 8b81788c73ad..b9b985fdc7c5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -46,11 +46,6 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// public readonly double Preempt; - /// - /// Beatmap playback rate. - /// - public readonly double ClockRate; - /// /// Normalised distance from the start position of the previous to the start position of this . /// @@ -121,11 +116,6 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// public double? Angle { get; private set; } - /// - /// Retrieves the full hit window for a Great . - /// - public double HitWindowGreat { get; private set; } - /// /// Selective bonus for maps with higher circle size. /// @@ -146,18 +136,8 @@ public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40); - ClockRate = clockRate; Preempt = BaseObject.TimePreempt / clockRate; - if (BaseObject is Slider sliderObject) - { - HitWindowGreat = 2 * sliderObject.HeadCircle.HitWindows.WindowFor(HitResult.Great) / clockRate; - } - else - { - HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate; - } - computeSliderCursorPosition(); setDistances(clockRate); } @@ -206,7 +186,7 @@ public double GetDoubletapness(OsuDifficultyHitObject? osuNextObj) double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); - double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 5); + double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindow(HitResult.Great)), 5); return 1.0 - Math.Pow(speedRatio, 1 - windowRatio); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index 07dc99e0f2c3..c548da5b8d76 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -5,12 +5,10 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Utils; -using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Mods; @@ -18,17 +16,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills { public class Reading : HarmonicSkill { - private readonly IReadOnlyList objectList; + private readonly List objectList = new List(); - private readonly double clockRate; private readonly bool hasHiddenMod; - public Reading(IBeatmap beatmap, Mod[] mods, double clockRate) + public Reading(Mod[] mods) : base(mods) { - this.clockRate = clockRate; hasHiddenMod = mods.OfType().Any(m => !m.OnlyFadeApproachCircles.Value); - objectList = beatmap.HitObjects; } private double currentDifficulty; @@ -40,6 +35,8 @@ public Reading(IBeatmap beatmap, Mod[] mods, double clockRate) protected override double ObjectDifficultyOf(DifficultyHitObject current) { + objectList.Add(current); + currentDifficulty *= strainDecay(current.DeltaTime); currentDifficulty += ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * skillMultiplier; @@ -64,19 +61,16 @@ private int calculateReducedNoteCount() { const double reduced_difficulty_duration = 60 * 1000; - if (objectList.Count < 2) + if (objectList.Count == 0) return 0; - // We take the 2nd note to match `CreateDifficultyHitObjects` - HitObject firstDifficultyObject = objectList[1]; - - double reducedDuration = (firstDifficultyObject.StartTime / clockRate) + reduced_difficulty_duration; + double reducedDuration = objectList.First().StartTime + reduced_difficulty_duration; int reducedNoteCount = 0; foreach (var hitObject in objectList) { - if (hitObject.StartTime / clockRate > reducedDuration) + if (hitObject.StartTime > reducedDuration) break; reducedNoteCount++; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs index c0acd5935a62..e7e216a08449 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data; @@ -17,7 +18,7 @@ public class RhythmEvaluator /// /// Evaluate the difficulty of a hitobject considering its interval change. /// - public static double EvaluateDifficultyOf(DifficultyHitObject hitObject, double hitWindow) + public static double EvaluateDifficultyOf(DifficultyHitObject hitObject) { if (hitObject.BaseObject is not Hit) return 0; @@ -29,6 +30,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject hitObject, double double samePattern = 0; double intervalPenalty = 0; + double hitWindow = hitObject.HitWindow(HitResult.Great) / 2.0; // this is practically incorrect, but kept as is for balancing + if (rhythmData.SameRhythmGroupedHitObjects?.FirstHitObject == hitObject) // Difficulty for SameRhythmGroupedHitObjects { sameRhythm += 10.0 * evaluateDifficultyOf(rhythmData.SameRhythmGroupedHitObjects, hitWindow); diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs index f407e13ff1d6..f9bb38688d6f 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs @@ -7,10 +7,10 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; -using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Utils; +using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing { diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs index 45d0d0a5480f..e41f3ff5e975 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs @@ -17,17 +17,14 @@ public class Rhythm : StrainDecaySkill protected override double SkillMultiplier => 1.0; protected override double StrainDecayBase => 0.4; - private readonly double greatHitWindow; - - public Rhythm(Mod[] mods, double greatHitWindow) + public Rhythm(Mod[] mods) : base(mods) { - this.greatHitWindow = greatHitWindow; } protected override double StrainValueOf(DifficultyHitObject current) { - double difficulty = RhythmEvaluator.EvaluateDifficultyOf(current, greatHitWindow); + double difficulty = RhythmEvaluator.EvaluateDifficultyOf(current); // To prevent abuse of exceedingly long intervals between awkward rhythms, we penalise its difficulty. double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) - 0.5; // Remove base strain diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 42a5dad31520..e9163274ba6c 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -10,13 +10,11 @@ using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Skills; using osu.Game.Rulesets.Taiko.Mods; -using osu.Game.Rulesets.Taiko.Scoring; namespace osu.Game.Rulesets.Taiko.Difficulty { @@ -43,15 +41,12 @@ public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { - HitWindows hitWindows = new TaikoHitWindows(); - hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); - isConvert = beatmap.BeatmapInfo.Ruleset.OnlineID == 0; isRelax = mods.Any(h => h is TaikoModRelax); return new Skill[] { - new Rhythm(mods, hitWindows.WindowFor(HitResult.Great) / clockRate), + new Rhythm(mods), new Reading(mods), new Colour(mods), new Stamina(mods, false, isConvert), diff --git a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs index 9785865192bd..1db231945636 100644 --- a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs +++ b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Difficulty.Preprocessing { @@ -45,6 +46,11 @@ public class DifficultyHitObject /// public readonly double EndTime; + /// + /// Beatmap playback rate. + /// + public readonly double ClockRate; + /// /// Creates a new . /// @@ -62,6 +68,7 @@ public DifficultyHitObject(HitObject hitObject, HitObject lastObject, double clo DeltaTime = (hitObject.StartTime - lastObject.StartTime) / clockRate; StartTime = hitObject.StartTime / clockRate; EndTime = hitObject.GetEndTime() / clockRate; + ClockRate = clockRate; } public DifficultyHitObject Previous(int backwardsIndex) @@ -75,5 +82,26 @@ public DifficultyHitObject Next(int forwardsIndex) int index = Index + (forwardsIndex + 1); return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : default; } + + /// + /// Retrieves the full hit window for a . + /// + public virtual double HitWindow(HitResult hitResult) + { + // Try to get HitWindows from nested hit objects + // This is important for objects such as Slider in osu! where the object itself has HitWindows set to Empty, but the nested SliderHead has proper hit windows + if (BaseObject.HitWindows == HitWindows.Empty) + { + foreach (var nestedHitObject in BaseObject.NestedHitObjects) + { + if (nestedHitObject.HitWindows == HitWindows.Empty) + continue; + + return 2 * nestedHitObject.HitWindows.WindowFor(hitResult) / ClockRate; + } + } + + return 2 * BaseObject.HitWindows.WindowFor(hitResult) / ClockRate; + } } } From a166d0046d66a6d9841052f01b5f20c2e2779844 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Sun, 15 Feb 2026 04:59:15 +0500 Subject: [PATCH 038/121] Change accuracy pp to be uncapped (#36669) This also transfers some of the accuracy pp length bonus to aim which makes it at least somewhat difficulty-aware. Value changes are very mild (10pp at most for most players) image --- .../Difficulty/OsuPerformanceCalculator.cs | 6 ++++-- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 96281297d70c..5c78917184ae 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -199,7 +199,7 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut double aimValue = OsuStrainSkill.DifficultyToPerformance(aimDifficulty); - double lengthBonus = 0.95 + 0.3 * Math.Min(1.0, totalHits / 2000.0) + + double lengthBonus = 0.95 + 0.35 * Math.Min(1.0, totalHits / 2000.0) + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); aimValue *= lengthBonus; @@ -293,7 +293,9 @@ private double computeAccuracyValue(ScoreInfo score, OsuDifficultyAttributes att double accuracyValue = Math.Pow(1.52163, overallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83; // Bonus for many hitcircles - it's harder to keep good accuracy up for longer. - accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)); + accuracyValue *= amountHitObjectsWithAccuracy < 1000 + ? Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3) + : Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.1); // Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given. if (score.Mods.Any(m => m is OsuModBlinds)) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 146ef8167dd9..d71794b9aa2b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -33,7 +33,7 @@ public Aim(Mod[] mods, bool includeSliders) private double skillMultiplierAim => 26.0; private double skillMultiplierSpeed => 1.3; - private double skillMultiplierTotal => 1.01; + private double skillMultiplierTotal => 1.0; private double meanExponent => 1.2; private readonly List sliderStrains = new List(); From 62fdd421ce7af46f9c6ebd4c60e42b3e989de0e2 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Sun, 15 Feb 2026 05:40:19 +0500 Subject: [PATCH 039/121] Adjust wide angle bonus to be closer to SpeedAim distance scaling (#36670) --- osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 4 ++-- osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 8573d36c70a8..a75c0382cbf4 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -99,7 +99,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3)); // Apply full wide angle bonus for distance more than SINGLE_SPACING_THRESHOLD - wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(currDistance, 0, SpeedAimEvaluator.SINGLE_SPACING_THRESHOLD); + wideAngleBonus *= angleBonus * Math.Pow(DifficultyCalculationUtils.Smoothstep(currDistance, 0, SpeedAimEvaluator.SINGLE_SPACING_THRESHOLD), 3.0); // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle // https://www.desmos.com/calculator/dp0v0nvowc diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index d71794b9aa2b..edca971d8c3f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -31,8 +31,8 @@ public Aim(Mod[] mods, bool includeSliders) private double currentAimStrain; private double currentSpeedStrain; - private double skillMultiplierAim => 26.0; - private double skillMultiplierSpeed => 1.3; + private double skillMultiplierAim => 25.85; + private double skillMultiplierSpeed => 1.35; private double skillMultiplierTotal => 1.0; private double meanExponent => 1.2; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 715197b4b513..f6679f944340 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : HarmonicSkill { - private double skillMultiplier => 1.035; + private double skillMultiplier => 1.04; private readonly List sliderStrains = new List(); From de507899e2a79b544755bc1584bcf1313927f402 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:12:12 +0500 Subject: [PATCH 040/121] Adjust RX-related multipliers (#36697) Mostly just keeping RX sane. I don't think we want to go too deep into the balancing here --- osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs | 4 ++-- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index 43107c8dce1f..768a1f3e73e3 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -76,9 +76,9 @@ public double ComputeReadingRating(double readingDifficultyValue) readingRating = Math.Pow(readingRating, 0.8); if (mods.Any(m => m is OsuModRelax)) - readingRating *= 0.7; + readingRating *= 0.6; else if (mods.Any(m => m is OsuModAutopilot)) - readingRating *= 0.4; + readingRating *= 0.3; if (mods.Any(m => m is OsuModMagnetised)) { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index edca971d8c3f..8371f0f28b05 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -60,6 +60,11 @@ protected override double StrainValueAt(DifficultyHitObject current) speedDifficulty = Math.Pow(speedDifficulty, 0.95); } + if (Mods.Any(m => m is OsuModRelax)) + { + speedDifficulty *= 0.0; + } + currentAimStrain *= decayAim; currentAimStrain += aimDifficulty * (1 - decayAim) * skillMultiplierAim; From 716cd8d9ab7b9362552370669d0b9cad4dd3c84e Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:37:36 +0500 Subject: [PATCH 041/121] Fix Aim's `skillMultiplierTotal` being applied incorrectly (#36699) Fortunately its 1.0 so it didn't break anything Co-authored-by: James Wilson --- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 8371f0f28b05..6de1e5e9c459 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -44,7 +44,7 @@ public Aim(Mod[] mods, bool includeSliders) protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => DifficultyCalculationUtils.Norm(meanExponent, currentAimStrain * strainDecayAim(time - current.Previous(0).StartTime), - currentSpeedStrain * strainDecaySpeed(time - current.Previous(0).StartTime)); + currentSpeedStrain * strainDecaySpeed(time - current.Previous(0).StartTime)) * skillMultiplierTotal; protected override double StrainValueAt(DifficultyHitObject current) { @@ -71,12 +71,12 @@ protected override double StrainValueAt(DifficultyHitObject current) currentSpeedStrain *= decaySpeed; currentSpeedStrain += speedDifficulty * (1 - decaySpeed) * skillMultiplierSpeed; - double totalStrain = DifficultyCalculationUtils.Norm(meanExponent, currentAimStrain, currentSpeedStrain); + double totalStrain = DifficultyCalculationUtils.Norm(meanExponent, currentAimStrain, currentSpeedStrain) * skillMultiplierTotal; if (current.BaseObject is Slider) sliderStrains.Add(totalStrain); - return totalStrain * skillMultiplierTotal; + return totalStrain; } public double GetDifficultSliders() From a14e87fdd90d43d5e6e562504e1a9debb1cfb80c Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:48:58 +0500 Subject: [PATCH 042/121] Q1 2026 osu! Balancing pass (#36734) Nerf mid-level snap aim, buff raw tapping, buff flow slightly, buff highbpm aim slightly https://pp.huismetbenen.nl/rankings/players/q1-2026-balancing --- osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs | 4 ++-- .../Difficulty/Evaluators/RhythmEvaluator.cs | 4 ++-- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 4 ++-- osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index a75c0382cbf4..b9436b448b96 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -12,9 +12,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators public static class AimEvaluator { private const double wide_angle_multiplier = 1.5; - private const double acute_angle_multiplier = 2.3; + private const double acute_angle_multiplier = 2.6; private const double slider_multiplier = 1.5; - private const double velocity_change_multiplier = 0.75; + private const double velocity_change_multiplier = 0.9; private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation /// diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index f97bb85b562a..bd7dcfd59262 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -16,8 +16,8 @@ public static class RhythmEvaluator { private const int history_time_max = 5 * 1000; // 5 seconds private const int history_objects_max = 32; - private const double rhythm_overall_multiplier = 0.9; - private const double rhythm_ratio_multiplier = 30.0; + private const double rhythm_overall_multiplier = 0.8; + private const double rhythm_ratio_multiplier = 32.0; /// /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 6de1e5e9c459..2e3cb96458ed 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -31,8 +31,8 @@ public Aim(Mod[] mods, bool includeSliders) private double currentAimStrain; private double currentSpeedStrain; - private double skillMultiplierAim => 25.85; - private double skillMultiplierSpeed => 1.35; + private double skillMultiplierAim => 25.0; + private double skillMultiplierSpeed => 1.4; private double skillMultiplierTotal => 1.0; private double meanExponent => 1.2; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index f6679f944340..211d9d57b9ac 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : HarmonicSkill { - private double skillMultiplier => 1.04; + private double skillMultiplier => 1.07; private readonly List sliderStrains = new List(); From 2d6fa65262517ea5d5d765386ca1b9b5d6035821 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Fri, 27 Feb 2026 00:13:21 +0500 Subject: [PATCH 043/121] Use full hitwindow in taiko Rhythm skill (#36671) --- .../Difficulty/Evaluators/RhythmEvaluator.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs index e7e216a08449..9cbc5bf2de77 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs @@ -30,7 +30,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject hitObject) double samePattern = 0; double intervalPenalty = 0; - double hitWindow = hitObject.HitWindow(HitResult.Great) / 2.0; // this is practically incorrect, but kept as is for balancing + double hitWindow = hitObject.HitWindow(HitResult.Great); if (rhythmData.SameRhythmGroupedHitObjects?.FirstHitObject == hitObject) // Difficulty for SameRhythmGroupedHitObjects { @@ -63,8 +63,8 @@ private static double evaluateDifficultyOf(SameRhythmHitObjectGrouping sameRhyth { intervalDifficulty *= DifficultyCalculationUtils.Logistic( durationDifference / hitWindow, - midpointOffset: 0.7, - multiplier: 1.0, + midpointOffset: 0.35, + multiplier: 2, maxValue: 1); } } @@ -72,8 +72,8 @@ private static double evaluateDifficultyOf(SameRhythmHitObjectGrouping sameRhyth // Penalise patterns that can be hit within a single hit window. intervalDifficulty *= DifficultyCalculationUtils.Logistic( sameRhythmGroupedHitObjects.Duration / hitWindow, - midpointOffset: 0.6, - multiplier: 1, + midpointOffset: 0.3, + multiplier: 2, maxValue: 1); return Math.Pow(intervalDifficulty, 0.75); From f241d9c84e8dbaa23bd5d29284d59087eb0bbd28 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Tue, 3 Mar 2026 04:14:31 +0500 Subject: [PATCH 044/121] Fix combined slider velocity calculation (#36773) This changes how the current/previous velocity is being calculated in Aim. Currently it's being calculated as an addition of 2 velocities together which isn't really correct, instead here velocity is being calculated as a (combined distances) / (combined times). On practice this buffs underweighted slider maps while overweighted ones stay about the same https://pp.huismetbenen.nl/rankings/players/use-proper-slider-velocity --- .../Difficulty/Evaluators/AimEvaluator.cs | 14 +++++--------- .../Preprocessing/OsuDifficultyHitObject.cs | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index b9436b448b96..4e28b28b88dd 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -13,7 +13,7 @@ public static class AimEvaluator { private const double wide_angle_multiplier = 1.5; private const double acute_angle_multiplier = 2.6; - private const double slider_multiplier = 1.5; + private const double slider_multiplier = 2.9; private const double velocity_change_multiplier = 0.9; private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation @@ -46,10 +46,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // But if the last object is a slider, then we extend the travel velocity through the slider into the current object. if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) { - double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end. - double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object - - currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity. + double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance; + currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime); } // As above, do the same for the previous hitobject. @@ -58,10 +56,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance) { - double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime; - double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime; - - prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity); + double sliderDistance = osuLastLastObj.LazyTravelDistance + osuLastObj.LazyJumpDistance; + prevVelocity = Math.Max(prevVelocity, sliderDistance / osuLastObj.AdjustedDeltaTime); } double wideAngleBonus = 0; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index b9b985fdc7c5..cee768b06f3b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -199,7 +199,7 @@ private void setDistances(double clockRate) if (BaseObject is Slider currentSlider) { // Bonus for repeat sliders until a better per nested object strain system can be achieved. - TravelDistance = LazyTravelDistance * Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); + TravelDistance = LazyTravelDistance * Math.Max(1, Math.Pow(currentSlider.RepeatCount, 0.2)); TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME); } From b1227e1f128034eaf6825ed0c1ea0d93422e5aec Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Tue, 3 Mar 2026 04:38:32 +0500 Subject: [PATCH 045/121] Aim strain influence reduction (#36792) Currently, live aim evaluation prioritizes high bpms with lower distances over low bpms with higher distances, something you may have heard colloquially called "d/t^2" in here. This creates an inherent DT advantage / NM & HR disadvantage in the average aim case. This change makes it so the evaluation is nudged closer to the ideal distance/time aim evaluation. In practice, higher spacing (distance) and/or slower jumps should generally see a buff, lower spacing (distance) and/or faster jumps should generally see a nerf. This includes some 3-mod scores as HR spacing is underweighted regardless of bpm. It also affects streams the same way, where faster high bpm low spacing streams (speedflow) are nerfed and high spacing low bpm streams are buffed. As a disclaimer: this isn't a complete fix, it is moreso a step in the right direction (hence, a Nudge, per se) towards the ideal d/t world. This also doesn't change anything angle-related so high-spacing farm maps will gain pp - this will be addressed separately after this change. https://pp.huismetbenen.nl/rankings/players/reduce-strain-influence --------- Co-authored-by: James Wilson --- .../Difficulty/Evaluators/AimEvaluator.cs | 10 +++++----- .../Difficulty/Evaluators/SpeedAimEvaluator.cs | 4 ++-- .../Difficulty/OsuRatingCalculator.cs | 2 +- .../Difficulty/Preprocessing/OsuDifficultyHitObject.cs | 4 ++-- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 4e28b28b88dd..5f1a5a379c2d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -11,10 +11,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { public static class AimEvaluator { - private const double wide_angle_multiplier = 1.5; + private const double wide_angle_multiplier = 1.4; private const double acute_angle_multiplier = 2.6; - private const double slider_multiplier = 2.9; - private const double velocity_change_multiplier = 0.9; + private const double slider_multiplier = 2.0; + private const double velocity_change_multiplier = 1.1; private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation /// @@ -158,7 +158,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Add in additional slider velocity bonus. if (withSliderTravelDistance) - aimStrain += sliderBonus * slider_multiplier; + aimStrain += (sliderBonus < 1 ? sliderBonus : Math.Pow(sliderBonus, 0.75)) * slider_multiplier; // Apply high circle size bonus aimStrain *= osuCurrObj.SmallCircleBonus; @@ -171,7 +171,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // We decrease strain for distances 1 / (1 - Math.Pow(0.15, ms / 1000)) + private static double highBpmBonus(double ms, double distance) => 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.75))) * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs index d56ffbfe14bf..9211ec39cf88 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs @@ -33,10 +33,10 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) distance = Math.Min(distance, SINGLE_SPACING_THRESHOLD); // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold - double distanceBonus = Math.Pow(distance / SINGLE_SPACING_THRESHOLD, 3.95); + double distanceBonus = Math.Pow(distance / SINGLE_SPACING_THRESHOLD, 2.9); // Apply reduced small circle bonus because flow aim difficulty on small circles doesn't scale as hard as jumps - distanceBonus *= Math.Sqrt(osuCurrObj.SmallCircleBonus); + distanceBonus *= Math.Pow(osuCurrObj.SmallCircleBonus, 0.7); double strain = distanceBonus * 1000 / osuCurrObj.AdjustedDeltaTime; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index 768a1f3e73e3..bc9de3494fa2 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -29,7 +29,7 @@ public double ComputeAimRating(double aimDifficultyValue) if (mods.Any(m => m is OsuModAutopilot)) return 0; - double aimRating = CalculateDifficultyRating(aimDifficultyValue); + double aimRating = Math.Pow(aimDifficultyValue, 0.58) * 0.0346; if (mods.Any(m => m is OsuModRelax)) aimRating *= 0.9; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index cee768b06f3b..b0b1c5132fa0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -134,7 +134,7 @@ public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME); LastObjectEndDeltaTime = lastDifficultyObject != null ? Math.Max(StartTime - lastDifficultyObject.EndTime, MIN_DELTA_TIME) : AdjustedDeltaTime; - SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40); + SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 60); Preempt = BaseObject.TimePreempt / clockRate; @@ -199,7 +199,7 @@ private void setDistances(double clockRate) if (BaseObject is Slider currentSlider) { // Bonus for repeat sliders until a better per nested object strain system can be achieved. - TravelDistance = LazyTravelDistance * Math.Max(1, Math.Pow(currentSlider.RepeatCount, 0.2)); + TravelDistance = LazyTravelDistance * Math.Max(1, Math.Pow(currentSlider.RepeatCount, 0.3)); TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 2e3cb96458ed..50fc9333a798 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -31,8 +31,8 @@ public Aim(Mod[] mods, bool includeSliders) private double currentAimStrain; private double currentSpeedStrain; - private double skillMultiplierAim => 25.0; - private double skillMultiplierSpeed => 1.4; + private double skillMultiplierAim => 58.5; + private double skillMultiplierSpeed => 2.0; private double skillMultiplierTotal => 1.0; private double meanExponent => 1.2; From 020e6fa1b20eec4f12a00a0e466a84dda7029c92 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:50:49 +0500 Subject: [PATCH 046/121] Reduce strain influence a bit more (#36813) Changes snap aim scaling to d/t^1.65 and speedflow scaling to d/t^1.9 (roughly). This is as much as we can nudge scaling towards d/t without breaking everything --- .../Difficulty/Evaluators/AimEvaluator.cs | 8 ++++---- .../Difficulty/Evaluators/SpeedAimEvaluator.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs | 2 +- .../Difficulty/Preprocessing/OsuDifficultyHitObject.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 5f1a5a379c2d..d0fc0637035f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -11,9 +11,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { public static class AimEvaluator { - private const double wide_angle_multiplier = 1.4; - private const double acute_angle_multiplier = 2.6; - private const double slider_multiplier = 2.0; + private const double wide_angle_multiplier = 1.35; + private const double acute_angle_multiplier = 2.5; + private const double slider_multiplier = 1.9; private const double velocity_change_multiplier = 1.1; private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation @@ -171,7 +171,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // We decrease strain for distances 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.75))) + private static double highBpmBonus(double ms, double distance) => 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.65))) * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs index 9211ec39cf88..afa1245820cf 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs @@ -45,6 +45,6 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) return strain; } - private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, ms / 1000)); + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, Math.Pow(ms / 1000, 0.9))); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index bc9de3494fa2..5826f497d623 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -29,7 +29,7 @@ public double ComputeAimRating(double aimDifficultyValue) if (mods.Any(m => m is OsuModAutopilot)) return 0; - double aimRating = Math.Pow(aimDifficultyValue, 0.58) * 0.0346; + double aimRating = Math.Pow(aimDifficultyValue, 0.62) * 0.0248; if (mods.Any(m => m is OsuModRelax)) aimRating *= 0.9; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index b0b1c5132fa0..33bb6b29c8b5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -134,7 +134,7 @@ public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME); LastObjectEndDeltaTime = lastDifficultyObject != null ? Math.Max(StartTime - lastDifficultyObject.EndTime, MIN_DELTA_TIME) : AdjustedDeltaTime; - SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 60); + SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 70); Preempt = BaseObject.TimePreempt / clockRate; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 50fc9333a798..3c8491a6dcd1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -31,8 +31,8 @@ public Aim(Mod[] mods, bool includeSliders) private double currentAimStrain; private double currentSpeedStrain; - private double skillMultiplierAim => 58.5; - private double skillMultiplierSpeed => 2.0; + private double skillMultiplierAim => 65.2; + private double skillMultiplierSpeed => 2.8; private double skillMultiplierTotal => 1.0; private double meanExponent => 1.2; From b945e2e27776bc23961168e5a98a2ab70132b730 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:11:04 +0500 Subject: [PATCH 047/121] Adjust wide aim bonus cutoff after SpeedAim changes (#36835) Removing exponent since after d/t rescaling it's just making low spaced streams way too nerfed --- osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index d0fc0637035f..9f9bbb7eb32b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -95,7 +95,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3)); // Apply full wide angle bonus for distance more than SINGLE_SPACING_THRESHOLD - wideAngleBonus *= angleBonus * Math.Pow(DifficultyCalculationUtils.Smoothstep(currDistance, 0, SpeedAimEvaluator.SINGLE_SPACING_THRESHOLD), 3.0); + wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smoothstep(currDistance, 0, SpeedAimEvaluator.SINGLE_SPACING_THRESHOLD); // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle // https://www.desmos.com/calculator/dp0v0nvowc From ab2c91e10c0ce829ac2198632a764b839c101d33 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:19:15 +0500 Subject: [PATCH 048/121] Slightly increase TD penalty (#36855) --- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 3c8491a6dcd1..9865d40b77fa 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -56,7 +56,7 @@ protected override double StrainValueAt(DifficultyHitObject current) if (Mods.Any(m => m is OsuModTouchDevice)) { - aimDifficulty = Math.Pow(aimDifficulty, 0.8); + aimDifficulty = Math.Pow(aimDifficulty, 0.76); speedDifficulty = Math.Pow(speedDifficulty, 0.95); } From 8efb10dd2e908d1add2fb9c0902461df15092e09 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:43:52 +0500 Subject: [PATCH 049/121] Increase high cs bonus in SpeedAim (#36856) --- .../Difficulty/Evaluators/SpeedAimEvaluator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs index afa1245820cf..246aa151f581 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs @@ -35,8 +35,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold double distanceBonus = Math.Pow(distance / SINGLE_SPACING_THRESHOLD, 2.9); - // Apply reduced small circle bonus because flow aim difficulty on small circles doesn't scale as hard as jumps - distanceBonus *= Math.Pow(osuCurrObj.SmallCircleBonus, 0.7); + // Apply increased high circle size bonus + distanceBonus *= Math.Pow(osuCurrObj.SmallCircleBonus, 1.5); double strain = distanceBonus * 1000 / osuCurrObj.AdjustedDeltaTime; From c2e9b052e775c96d4b8ff1e664f08345fd268d0a Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:48:27 +0500 Subject: [PATCH 050/121] Add basic flow evaluation (#36902) https://pp.huismetbenen.nl/rankings/players/stanr-aimsep --- ...eedAimEvaluator.cs => AgilityEvaluator.cs} | 23 ++-- .../Difficulty/Evaluators/FlowAimEvaluator.cs | 117 ++++++++++++++++++ .../{AimEvaluator.cs => SnapAimEvaluator.cs} | 19 ++- .../Preprocessing/OsuDifficultyHitObject.cs | 9 ++ .../Difficulty/Skills/Aim.cs | 79 ++++++++---- .../Difficulty/Skills/Speed.cs | 2 +- 6 files changed, 199 insertions(+), 50 deletions(-) rename osu.Game.Rulesets.Osu/Difficulty/Evaluators/{SpeedAimEvaluator.cs => AgilityEvaluator.cs} (53%) create mode 100644 osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs rename osu.Game.Rulesets.Osu/Difficulty/Evaluators/{AimEvaluator.cs => SnapAimEvaluator.cs} (91%) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AgilityEvaluator.cs similarity index 53% rename from osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs rename to osu.Game.Rulesets.Osu/Difficulty/Evaluators/AgilityEvaluator.cs index 246aa151f581..2d0d2897a2be 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AgilityEvaluator.cs @@ -3,20 +3,18 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { - public static class SpeedAimEvaluator + public static class AgilityEvaluator { - public const double SINGLE_SPACING_THRESHOLD = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers + private const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers /// - /// Evaluates the difficulty of aiming the current object, based on: - /// - /// distance between the previous and current object - /// + /// Evaluates the difficulty of fast aiming /// public static double EvaluateDifficultyOf(DifficultyHitObject current) { @@ -29,20 +27,13 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double travelDistance = osuPrevObj?.LazyTravelDistance ?? 0; double distance = travelDistance + osuCurrObj.LazyJumpDistance; - // Cap distance at single_spacing_threshold - distance = Math.Min(distance, SINGLE_SPACING_THRESHOLD); + double distanceScaled = Math.Min(distance, distance_cap) / distance_cap; - // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold - double distanceBonus = Math.Pow(distance / SINGLE_SPACING_THRESHOLD, 2.9); - - // Apply increased high circle size bonus - distanceBonus *= Math.Pow(osuCurrObj.SmallCircleBonus, 1.5); - - double strain = distanceBonus * 1000 / osuCurrObj.AdjustedDeltaTime; + double strain = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime; strain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); - return strain; + return strain * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); } private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, Math.Pow(ms / 1000, 0.9))); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs new file mode 100644 index 000000000000..61451c4f9398 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs @@ -0,0 +1,117 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +{ + public static class FlowAimEvaluator + { + private const double velocity_change_multiplier = 2.0; + + /// + /// Evaluates difficulty of "flow aim" - aiming pattern where player doesn't stop their cursor on every object and instead "flows" through them. + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance) + { + if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) + return 0; + + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); + var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); + + double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance; + double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance; + + double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; + + if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) + { + // If the last object is a slider, then we extend the travel velocity through the slider into the current object. + double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance; + currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime); + } + + double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; + + double flowDifficulty = currVelocity; + + // Apply high circle size bonus to the base velocity + flowDifficulty *= osuCurrObj.SmallCircleBonus; + + // Rhythm changes are harder to flow + flowDifficulty *= 1 + Math.Min(0.25, + Math.Pow((Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) - Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) / 50, 4)); + + if (osuCurrObj.AngularVelocity != null) + { + // Low angular velocity flow (angles are consistent) is easier to follow than erratic flow + flowDifficulty *= 0.8 + Math.Sqrt(osuCurrObj.AngularVelocity.Value / 270.0); + } + + // If all three notes are overlapping - don't reward bonuses as you don't have to do additional movement + double overlappedNotesWeight = 1; + + if (current.Index > 2) + { + double o1 = calculateOverlapFactor(osuCurrObj, osuLastObj); + double o2 = calculateOverlapFactor(osuCurrObj, osuLastLastObj); + double o3 = calculateOverlapFactor(osuLastObj, osuLastLastObj); + + overlappedNotesWeight = 1 - o1 * o2 * o3; + } + + if (osuCurrObj.Angle != null && osuLastObj.Angle != null) + { + // Acute angles are also hard to flow + // We square root velocity to make acute angle switches in streams aren't having difficulty higher than snap + flowDifficulty += Math.Sqrt(currVelocity) * + SnapAimEvaluator.CalcAcuteAngleBonus(osuCurrObj.Angle.Value) * + overlappedNotesWeight; + } + + if (Math.Max(prevVelocity, currVelocity) != 0) + { + if (withSliderTravelDistance) + { + currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; + prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; + } + + // Scale with ratio of difference compared to 0.5 * max dist. + double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1); + + // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. + double overlapVelocityBuff = Math.Min(OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), + Math.Abs(prevVelocity - currVelocity)); + + flowDifficulty += overlapVelocityBuff * distRatio * velocity_change_multiplier; + } + + if (osuCurrObj.BaseObject is Slider) + { + // Include slider velocity to make velocity more consistent with snap + flowDifficulty += osuCurrObj.TravelDistance / osuCurrObj.TravelTime; + } + + // Final velocity is being raised to a power because flow difficulty scales harder with both high distance and time, and we want to account for that + return Math.Pow(flowDifficulty, 1.45); + } + + private static double calculateOverlapFactor(OsuDifficultyHitObject first, OsuDifficultyHitObject second) + { + var firstBase = (OsuHitObject)first.BaseObject; + var secondBase = (OsuHitObject)second.BaseObject; + double objectRadius = firstBase.Radius; + + double distance = Vector2.Distance(firstBase.StackedPosition, secondBase.StackedPosition); + return Math.Clamp(1 - Math.Pow(Math.Max(distance - objectRadius, 0) / objectRadius, 2), 0, 1); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs similarity index 91% rename from osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs rename to osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs index 9f9bbb7eb32b..58bf9dc48eae 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs @@ -9,12 +9,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { - public static class AimEvaluator + public static class SnapAimEvaluator { - private const double wide_angle_multiplier = 1.35; + private const double wide_angle_multiplier = 1.3; private const double acute_angle_multiplier = 2.5; private const double slider_multiplier = 1.9; - private const double velocity_change_multiplier = 1.1; + private const double velocity_change_multiplier = 1.0; private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation /// @@ -78,24 +78,23 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same. { - acuteAngleBonus = calcAcuteAngleBonus(currAngle); + acuteAngleBonus = CalcAcuteAngleBonus(currAngle); // Penalize angle repetition. - acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3))); + acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAcuteAngleBonus(lastAngle), 3))); // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter acuteAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * - DifficultyCalculationUtils.Smootherstep(currDistance, diameter, diameter * 2); + DifficultyCalculationUtils.Smootherstep(currDistance, 0, diameter * 2); } wideAngleBonus = calcWideAngleBonus(currAngle); // Penalize angle repetition. - wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3)); + wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3))); - // Apply full wide angle bonus for distance more than SINGLE_SPACING_THRESHOLD - wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smoothstep(currDistance, 0, SpeedAimEvaluator.SINGLE_SPACING_THRESHOLD); + wideAngleBonus *= angleBonus; // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle // https://www.desmos.com/calculator/dp0v0nvowc @@ -176,6 +175,6 @@ private static double highBpmBonus(double ms, double distance) => 1 / (1 - Math. private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); - private static double calcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); + public static double CalcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 33bb6b29c8b5..848816503dc4 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -116,6 +116,8 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// public double? Angle { get; private set; } + public double? AngularVelocity { get; private set; } + /// /// Selective bonus for maps with higher circle size. /// @@ -260,6 +262,13 @@ private void setDistances(double clockRate) double sliderAngle = calculateSliderAngle(lastDifficultyObject!, lastLastCursorPosition); Angle = Math.Min(angle, sliderAngle); + + if (lastLastDifficultyObject.Angle != null) + { + double angleDifference = Math.Abs(Angle.Value - lastLastDifficultyObject.Angle.Value); + double angleDifferenceAdjusted = Math.Sin(angleDifference / 2) * 180.0; + AngularVelocity = angleDifferenceAdjusted / (AdjustedDeltaTime * 0.1); + } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 9865d40b77fa..101dd2250aaf 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -28,57 +28,90 @@ public Aim(Mod[] mods, bool includeSliders) IncludeSliders = includeSliders; } - private double currentAimStrain; - private double currentSpeedStrain; + private double currentStrain; - private double skillMultiplierAim => 65.2; - private double skillMultiplierSpeed => 2.8; + private double skillMultiplierSnap => 65.2; + private double skillMultiplierAgility => 2.7; + private double skillMultiplierFlow => 262.0; private double skillMultiplierTotal => 1.0; private double meanExponent => 1.2; private readonly List sliderStrains = new List(); - private double strainDecayAim(double ms) => Math.Pow(0.15, ms / 1000); - private double strainDecaySpeed(double ms) => Math.Pow(0.3, ms / 1000); + private double strainDecay(double ms) => Math.Pow(0.15, ms / 1000); protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => - DifficultyCalculationUtils.Norm(meanExponent, - currentAimStrain * strainDecayAim(time - current.Previous(0).StartTime), - currentSpeedStrain * strainDecaySpeed(time - current.Previous(0).StartTime)) * skillMultiplierTotal; + currentStrain * strainDecay(time - current.Previous(0).StartTime); protected override double StrainValueAt(DifficultyHitObject current) { - double decayAim = strainDecayAim(((OsuDifficultyHitObject)current).AdjustedDeltaTime); - double decaySpeed = strainDecaySpeed(((OsuDifficultyHitObject)current).AdjustedDeltaTime); + double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); - double aimDifficulty = AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders); - double speedDifficulty = SpeedAimEvaluator.EvaluateDifficultyOf(current); + double snapDifficulty = SnapAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierSnap; + double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skillMultiplierAgility; + double flowDifficulty = FlowAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierFlow; if (Mods.Any(m => m is OsuModTouchDevice)) { - aimDifficulty = Math.Pow(aimDifficulty, 0.76); - speedDifficulty = Math.Pow(speedDifficulty, 0.95); + snapDifficulty = Math.Pow(snapDifficulty, 0.89); + // we don't adjust agility here since agility represents TD difficulty in a decent enough way + flowDifficulty = Math.Pow(flowDifficulty, 1.1); } if (Mods.Any(m => m is OsuModRelax)) { - speedDifficulty *= 0.0; + agilityDifficulty *= 0.0; + flowDifficulty *= 0.1; } - currentAimStrain *= decayAim; - currentAimStrain += aimDifficulty * (1 - decayAim) * skillMultiplierAim; + double totalDifficulty = calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty); - currentSpeedStrain *= decaySpeed; - currentSpeedStrain += speedDifficulty * (1 - decaySpeed) * skillMultiplierSpeed; - - double totalStrain = DifficultyCalculationUtils.Norm(meanExponent, currentAimStrain, currentSpeedStrain) * skillMultiplierTotal; + currentStrain *= decay; + currentStrain += totalDifficulty * (1 - decay); if (current.BaseObject is Slider) - sliderStrains.Add(totalStrain); + sliderStrains.Add(currentStrain); + + return currentStrain; + } + + private double calculateTotalValue(double snapDifficulty, double agilityDifficulty, double flowDifficulty) + { + // We compare flow to combined snap and agility because snap by itself doesn't have enough difficulty to be above flow on streams + // Agility on the other hand is supposed to measure the rate of cursor velocity changes while snapping + // So snapping every circle on a stream requires an enormous amount of agility at which point it's easier to flow + double combinedSnapDifficulty = DifficultyCalculationUtils.Norm(meanExponent, snapDifficulty, agilityDifficulty); + + double pSnap = calculateSnapFlowProbability(flowDifficulty / combinedSnapDifficulty); + double pFlow = 1 - pSnap; + + double totalDifficulty = combinedSnapDifficulty * pSnap + flowDifficulty * pFlow; + + double totalStrain = totalDifficulty * skillMultiplierTotal; return totalStrain; } + // A function that turns the ratio of snap : flow into the probability of snapping/flowing + // It has the constraints: + // P(snap) + P(flow) = 1 (the object is always either snapped or flowed) + // P(snap) = f(snap/flow), P(flow) = f(flow/snap) (ie snap and flow are symmetric and reversible) + // Therefore: f(x) + f(1/x) = 1 + // 0 <= f(x) <= 1 (cannot have negative or greater than 100% probability of snapping or flowing) + // This logistic function is a solution, which fits nicely with the general idea of interpolation and provides a tuneable constant + private static double calculateSnapFlowProbability(double ratio) + { + const double k = 7.27; + + if (ratio == 0) + return 0; + + if (double.IsNaN(ratio)) + return 1; + + return DifficultyCalculationUtils.Logistic(-k * Math.Log(ratio)); + } + public double GetDifficultSliders() { if (sliderStrains.Count == 0) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 211d9d57b9ac..a50a5cfab687 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : HarmonicSkill { - private double skillMultiplier => 1.07; + private double skillMultiplier => 1.05; private readonly List sliderStrains = new List(); From f3d97c083538e09fca0ef2fad99f4c3e9ca772de Mon Sep 17 00:00:00 2001 From: kwotaq <80002984+kwotaq@users.noreply.github.com> Date: Tue, 10 Mar 2026 01:12:22 +0200 Subject: [PATCH 051/121] Penalize angle repetition depending on the difference between vectors (#36559) This is a slightly more intelligent angle repetition nerf that checks the previous vectors to see if the pattern has any rotation or not, this allows for harsh nerfs on current meta patterning like the N, X and V patterns while keeping things like rotating 1-2s and triangles less nerfed. The effect the vectors have on angle repetition is adjustable. --------- Co-authored-by: StanR --- .../Difficulty/Evaluators/SnapAimEvaluator.cs | 59 +++++++++++++++++-- .../Preprocessing/OsuDifficultyHitObject.cs | 9 +++ .../Difficulty/Skills/Aim.cs | 8 +-- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs index 58bf9dc48eae..15d8ef033e5b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; @@ -11,11 +12,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { public static class SnapAimEvaluator { - private const double wide_angle_multiplier = 1.3; - private const double acute_angle_multiplier = 2.5; - private const double slider_multiplier = 1.9; - private const double velocity_change_multiplier = 1.0; + private const double wide_angle_multiplier = 1.05; + private const double acute_angle_multiplier = 2.41; + private const double slider_multiplier = 1.5; + private const double velocity_change_multiplier = 0.9; private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation + private const double maximum_repetition_nerf = 0.15; + private const double maximum_vector_influence = 0.5; /// /// Evaluates the difficulty of aiming the current object, based on: @@ -117,7 +120,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with if (distance < 1) { - wideAngleBonus *= 1 - 0.35 * (1 - distance); + wideAngleBonus *= 1 - 0.55 * (1 - distance); } } } @@ -149,6 +152,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; } + // Penalize angle repetition. + aimStrain *= vectorAngleRepetition(osuCurrObj, osuLastObj); + aimStrain += wiggleBonus * wiggle_multiplier; aimStrain += velocityChangeBonus * velocity_change_multiplier; @@ -173,6 +179,49 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with private static double highBpmBonus(double ms, double distance) => 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.65))) * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); + private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuDifficultyHitObject previous) + { + if (current.Angle == null || previous.Angle == null) + return 1; + + const double note_limit = 6; + + double constantAngleCount = 0; + + for (int index = 0; index < note_limit; index++) + { + var loopObj = (OsuDifficultyHitObject)current.Previous(index); + + if (loopObj.IsNull()) + break; + + // Only consider vectors in the same jump section, stopping to change rhythm ruins momentum + if (Math.Max(current.AdjustedDeltaTime, loopObj.AdjustedDeltaTime) > 1.1 * Math.Min(current.AdjustedDeltaTime, loopObj.AdjustedDeltaTime)) + break; + + if (loopObj.NormalisedVectorAngle.IsNotNull() && current.NormalisedVectorAngle.IsNotNull()) + { + double angleDifference = Math.Abs(current.NormalisedVectorAngle.Value - loopObj.NormalisedVectorAngle.Value); + // Refer to this desmos for tuning, constants need to be precise so that values stay within the range of 0 and 1. + // https://www.desmos.com/calculator/a8jesv5sv2 + constantAngleCount += Math.Cos(8 * Math.Min(double.DegreesToRadians(11.25), angleDifference)); + } + } + + double vectorRepetition = Math.Pow(Math.Min(0.5 / constantAngleCount, 1), 2); + + double stackFactor = DifficultyCalculationUtils.Smootherstep(current.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_DIAMETER); + + double currAngle = current.Angle.Value; + double lastAngle = previous.Angle.Value; + + double angleDifferenceAdjusted = Math.Cos(2 * Math.Min(double.DegreesToRadians(45), Math.Abs(currAngle - lastAngle) * stackFactor)); + + double baseNerf = 1 - maximum_repetition_nerf * CalcAcuteAngleBonus(lastAngle) * angleDifferenceAdjusted; + + return Math.Pow(baseNerf + (1 - baseNerf) * vectorRepetition * maximum_vector_influence * stackFactor, 2); + } + private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); public static double CalcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 848816503dc4..68950829f3cf 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -118,6 +118,12 @@ public class OsuDifficultyHitObject : DifficultyHitObject public double? AngularVelocity { get; private set; } + /// + /// Angle of the vector created between current and current-1 + /// normalised to consider symmetrical vectors in any axis to be the same angle. + /// + public double? NormalisedVectorAngle { get; private set; } + /// /// Selective bonus for maps with higher circle size. /// @@ -261,6 +267,9 @@ private void setDistances(double clockRate) double angle = calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition); double sliderAngle = calculateSliderAngle(lastDifficultyObject!, lastLastCursorPosition); + Vector2 v = BaseObject.StackedPosition - lastCursorPosition; + NormalisedVectorAngle = Math.Atan2(Math.Abs(v.Y), Math.Abs(v.X)); + Angle = Math.Min(angle, sliderAngle); if (lastLastDifficultyObject.Angle != null) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 101dd2250aaf..dec678519381 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -30,10 +30,10 @@ public Aim(Mod[] mods, bool includeSliders) private double currentStrain; - private double skillMultiplierSnap => 65.2; - private double skillMultiplierAgility => 2.7; - private double skillMultiplierFlow => 262.0; - private double skillMultiplierTotal => 1.0; + private double skillMultiplierSnap => 71.0; + private double skillMultiplierAgility => 2.0; + private double skillMultiplierFlow => 238.0; + private double skillMultiplierTotal => 1.1; private double meanExponent => 1.2; private readonly List sliderStrains = new List(); From e9f59752c9600557b60c25d810d36df9e76364c2 Mon Sep 17 00:00:00 2001 From: "Rian (Reza Mouna Hendrian)" <52914632+Rian8337@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:42:36 +0700 Subject: [PATCH 052/121] Fix Hidden mod perfect stacks buff for osu! reading difficulty (#36918) The `OpacityAt` conditional adds `Preempt` to `StartTime` when the intention is to check for when `previousObj` is hit, which means it should just be `StartTime`. In addition to that, the third conditional's purpose is to ensure that the buff only applies if `currObj` is animating (its opacity is changing from the Hidden mod) when `previousObj` is hit. This restructures it so that it reads more cleanly and communicates its purpose better. [Conversation](https://discord.com/channels/546120878908506119/1374004990875795586/1480886152042119190) --- osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs index 326e98609c31..3363e2fbe067 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -131,7 +131,7 @@ private static double calculateHiddenDifficulty(OsuDifficultyHitObject currObj, var previousObj = (OsuDifficultyHitObject)currObj.Previous(0); // Buff perfect stacks only if current note is completely invisible at the time you click the previous note. - if (currObj.LazyJumpDistance == 0 && currObj.OpacityAt(previousObj.BaseObject.StartTime + previousObj.Preempt, true) == 0 && previousObj.StartTime + previousObj.Preempt > currObj.StartTime) + if (currObj.LazyJumpDistance == 0 && currObj.OpacityAt(previousObj.BaseObject.StartTime, true) == 0 && previousObj.StartTime > currObj.StartTime - currObj.Preempt) hiddenDifficulty += hidden_multiplier * 2500 / Math.Pow(currObj.AdjustedDeltaTime, 1.5); // Perfect stacks are harder the less time between notes return hiddenDifficulty; From c2c8b901d3648e46c894acecfcce6f44c72645d9 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Wed, 11 Mar 2026 02:49:45 +0500 Subject: [PATCH 053/121] Remove `OsuStrainSkill` (#36917) --- .../Difficulty/OsuDifficultyCalculator.cs | 2 +- .../Difficulty/OsuPerformanceCalculator.cs | 6 +- .../Difficulty/Skills/Aim.cs | 44 ++++++++++++- .../Difficulty/Skills/OsuStrainSkill.cs | 62 ------------------- 4 files changed, 48 insertions(+), 66 deletions(-) delete mode 100644 osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index bf4054e75b1c..2a425712b026 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -106,7 +106,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat var simulator = new OsuLegacyScoreSimulator(); var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap); - double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); + double baseAimPerformance = OsuPerformanceCalculator.DifficultyToPerformance(aimRating); double baseSpeedPerformance = HarmonicSkill.DifficultyToPerformance(speedRating); double baseReadingPerformance = HarmonicSkill.DifficultyToPerformance(readingRating); double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 5c78917184ae..bdc80b691110 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -62,6 +62,8 @@ public class OsuPerformanceCalculator : PerformanceCalculator private double aimEstimatedSliderBreaks; private double speedEstimatedSliderBreaks; + public static double DifficultyToPerformance(double difficulty) => 4.0 * Math.Pow(difficulty, 3.0); + public OsuPerformanceCalculator() : base(new OsuRuleset()) { @@ -197,7 +199,7 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut aimDifficulty *= sliderNerfFactor; } - double aimValue = OsuStrainSkill.DifficultyToPerformance(aimDifficulty); + double aimValue = DifficultyToPerformance(aimDifficulty); double lengthBonus = 0.95 + 0.35 * Math.Min(1.0, totalHits / 2000.0) + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); @@ -488,7 +490,7 @@ private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attribute if (speedDeviation == null) return 0; - double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty); + double speedValue = HarmonicSkill.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. diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index dec678519381..979b854cd172 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -4,7 +4,9 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Utils; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; @@ -18,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// /// Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances. /// - public class Aim : OsuStrainSkill + public class Aim : StrainSkill { public readonly bool IncludeSliders; @@ -36,6 +38,17 @@ public Aim(Mod[] mods, bool includeSliders) private double skillMultiplierTotal => 1.1; private double meanExponent => 1.2; + /// + /// The number of sections with the highest strains, which the peak strain reductions will apply to. + /// This is done in order to decrease their impact on the overall difficulty of the map for this skill. + /// + private int reducedSectionCount => 10; + + /// + /// The baseline multiplier applied to the section with the biggest strain. + /// + private double reducedStrainBaseline => 0.75; + private readonly List sliderStrains = new List(); private double strainDecay(double ms) => Math.Pow(0.15, ms / 1000); @@ -127,5 +140,34 @@ public double GetDifficultSliders() public double CountTopWeightedSliders(double difficultyValue) => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, difficultyValue); + + public override double DifficultyValue() + { + double difficulty = 0; + double weight = 1; + + // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These sections will not contribute to the difficulty. + var peaks = GetCurrentStrainPeaks().Where(p => p > 0); + + List strains = peaks.OrderDescending().ToList(); + + // We are reducing the highest strains first to account for extreme difficulty spikes + for (int i = 0; i < Math.Min(strains.Count, reducedSectionCount); i++) + { + double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((float)i / reducedSectionCount, 0, 1))); + strains[i] *= Interpolation.Lerp(reducedStrainBaseline, 1.0, scale); + } + + // Difficulty is the weighted sum of the highest strains from every section. + // We're sorting from highest to lowest strain. + foreach (double strain in strains.OrderDescending()) + { + difficulty += strain * weight; + weight *= DecayWeight; + } + + return difficulty; + } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs deleted file mode 100644 index 916b6e7b2d51..000000000000 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using osu.Game.Rulesets.Difficulty.Skills; -using osu.Game.Rulesets.Mods; -using System.Linq; -using osu.Framework.Utils; - -namespace osu.Game.Rulesets.Osu.Difficulty.Skills -{ - public abstract class OsuStrainSkill : StrainSkill - { - /// - /// The number of sections with the highest strains, which the peak strain reductions will apply to. - /// This is done in order to decrease their impact on the overall difficulty of the map for this skill. - /// - protected virtual int ReducedSectionCount => 10; - - /// - /// The baseline multiplier applied to the section with the biggest strain. - /// - protected virtual double ReducedStrainBaseline => 0.75; - - protected OsuStrainSkill(Mod[] mods) - : base(mods) - { - } - - public override double DifficultyValue() - { - double difficulty = 0; - double weight = 1; - - // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). - // These sections will not contribute to the difficulty. - var peaks = GetCurrentStrainPeaks().Where(p => p > 0); - - List strains = peaks.OrderDescending().ToList(); - - // We are reducing the highest strains first to account for extreme difficulty spikes - for (int i = 0; i < Math.Min(strains.Count, ReducedSectionCount); i++) - { - double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((float)i / ReducedSectionCount, 0, 1))); - strains[i] *= Interpolation.Lerp(ReducedStrainBaseline, 1.0, scale); - } - - // Difficulty is the weighted sum of the highest strains from every section. - // We're sorting from highest to lowest strain. - foreach (double strain in strains.OrderDescending()) - { - difficulty += strain * weight; - weight *= DecayWeight; - } - - return difficulty; - } - - public static double DifficultyToPerformance(double difficulty) => 4.0 * Math.Pow(difficulty, 3.0); - } -} From 29e089c6203a80e23dee5a6503be47cb5f0b057e Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:14:27 +0500 Subject: [PATCH 054/121] Organize osu! difficulty evaluators a bit better (#36921) --- .../Difficulty/Evaluators/{ => Aim}/AgilityEvaluator.cs | 2 +- .../Difficulty/Evaluators/{ => Aim}/FlowAimEvaluator.cs | 2 +- .../Difficulty/Evaluators/{ => Aim}/SnapAimEvaluator.cs | 2 +- .../Difficulty/Evaluators/{ => Speed}/RhythmEvaluator.cs | 2 +- .../Difficulty/Evaluators/{ => Speed}/SpeedEvaluator.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) rename osu.Game.Rulesets.Osu/Difficulty/Evaluators/{ => Aim}/AgilityEvaluator.cs (96%) rename osu.Game.Rulesets.Osu/Difficulty/Evaluators/{ => Aim}/FlowAimEvaluator.cs (99%) rename osu.Game.Rulesets.Osu/Difficulty/Evaluators/{ => Aim}/SnapAimEvaluator.cs (99%) rename osu.Game.Rulesets.Osu/Difficulty/Evaluators/{ => Speed}/RhythmEvaluator.cs (99%) rename osu.Game.Rulesets.Osu/Difficulty/Evaluators/{ => Speed}/SpeedEvaluator.cs (97%) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AgilityEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs similarity index 96% rename from osu.Game.Rulesets.Osu/Difficulty/Evaluators/AgilityEvaluator.cs rename to osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs index 2d0d2897a2be..5208520bb471 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AgilityEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs @@ -7,7 +7,7 @@ using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; -namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim { public static class AgilityEvaluator { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs similarity index 99% rename from osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs rename to osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index 61451c4f9398..924e1c346efe 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -8,7 +8,7 @@ using osu.Game.Rulesets.Osu.Objects; using osuTK; -namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim { public static class FlowAimEvaluator { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs similarity index 99% rename from osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs rename to osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs index 15d8ef033e5b..5df70225a1ef 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -8,7 +8,7 @@ using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; -namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim { public static class SnapAimEvaluator { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs similarity index 99% rename from osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs rename to osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs index bd7dcfd59262..e2ee41162b41 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; -namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed { public static class RhythmEvaluator { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs similarity index 97% rename from osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs rename to osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs index 5b724af2a025..65aab9e4bd64 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs @@ -8,7 +8,7 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; -namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed { public static class SpeedEvaluator { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 979b854cd172..6ff42e66a8c0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -9,7 +9,7 @@ using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Difficulty.Evaluators; +using osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Utils; using osu.Game.Rulesets.Osu.Mods; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index a50a5cfab687..39a3a4919c35 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -5,11 +5,11 @@ using System.Collections.Generic; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Objects; using System.Linq; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; namespace osu.Game.Rulesets.Osu.Difficulty.Skills From 6c9a7292e7bdb6472bd218513b3626ff35351089 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:48:09 +0500 Subject: [PATCH 055/121] Adjust `Speed` harmonic summation params (#36923) Slight balancing. Mostly exists to reduce extremely consistent tapping maps (i.e [Age of Tyranny](https://osu.ppy.sh/beatmapsets/2219231#osu/4704022)) influence over the summation --------- Co-authored-by: James Wilson --- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 6ff42e66a8c0..b2ecb817dc59 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -34,7 +34,7 @@ public Aim(Mod[] mods, bool includeSliders) private double skillMultiplierSnap => 71.0; private double skillMultiplierAgility => 2.0; - private double skillMultiplierFlow => 238.0; + private double skillMultiplierFlow => 244.0; private double skillMultiplierTotal => 1.1; private double meanExponent => 1.2; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 39a3a4919c35..2462186c780d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : HarmonicSkill { - private double skillMultiplier => 1.05; + private double skillMultiplier => 1.15; private readonly List sliderStrains = new List(); @@ -28,7 +28,7 @@ public class Speed : HarmonicSkill private double strainDecayBase => 0.3; protected override double HarmonicScale => 20; - protected override double DecayExponent => 0.85; + protected override double DecayExponent => 0.9; public Speed(Mod[] mods) : base(mods) From b0ee82dc4fb6d957baee8e7a966a46c0f40457e9 Mon Sep 17 00:00:00 2001 From: James Wilson Date: Wed, 11 Mar 2026 03:38:40 +0000 Subject: [PATCH 056/121] Remove `OsuStrainUtils` (#36924) The only method inside this class is used in a single place ever since Speed became a harmonic skill so it doesn't really make sense to keep around to me. As a result, supersedes #33295. Also includes a fix to use `DecayWeight` for the consistent top strain, exactly the same as what we're doing for `CountTopWeightedStrains`. --- .../Difficulty/Skills/Aim.cs | 14 ++++++++-- .../Difficulty/Utils/OsuStrainUtils.cs | 26 ------------------- 2 files changed, 12 insertions(+), 28 deletions(-) delete mode 100644 osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index b2ecb817dc59..06522d8e8563 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -11,7 +11,6 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; -using osu.Game.Rulesets.Osu.Difficulty.Utils; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; @@ -139,7 +138,18 @@ public double GetDifficultSliders() } public double CountTopWeightedSliders(double difficultyValue) - => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, difficultyValue); + { + if (sliderStrains.Count == 0) + return 0; + + double consistentTopStrain = difficultyValue * (1 - DecayWeight); // What would the top strain be if all strain values were identical + + if (consistentTopStrain == 0) + return 0; + + // Use a weighted sum of all strains. Constants are arbitrary and give nice values + return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1)); + } public override double DifficultyValue() { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs b/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs deleted file mode 100644 index 8a78192ee4cf..000000000000 --- a/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using System.Linq; -using osu.Game.Rulesets.Difficulty.Utils; - -namespace osu.Game.Rulesets.Osu.Difficulty.Utils -{ - public static class OsuStrainUtils - { - public static double CountTopWeightedSliders(IReadOnlyCollection sliderStrains, double difficultyValue) - { - if (sliderStrains.Count == 0) - return 0; - - double consistentTopStrain = difficultyValue / 10; // What would the top strain be if all strain values were identical - - if (consistentTopStrain == 0) - return 0; - - // Use a weighted sum of all strains. Constants are arbitrary and give nice values - return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1)); - } - } -} From 1e01001a1f42961aecaf700da4cce891e684c325 Mon Sep 17 00:00:00 2001 From: "Rian (Reza Mouna Hendrian)" <52914632+Rian8337@users.noreply.github.com> Date: Fri, 13 Mar 2026 06:26:42 +0700 Subject: [PATCH 057/121] Use current object's `Preempt` when retrieving past visible objects in osu! reading difficulty (#36944) In `retrievePastVisibleObjects`, the wrong object's `Preempt` is used to determine the time where the currently assessed object becomes visible. This PR fixes that issue. Additionally, the visibility condition in `retrieveCurrentVisibleObjectDensity` is rearranged to read similarly to the one in `retrievePastVisibleObjects`. This does not change current values as none of the mods that can currently award pp modify an object's `TimePreempt` individually. The only affected mod is Freeze Frame, which gains buffs across the board as more objects are calculated to be visible now. --- .../Difficulty/Evaluators/ReadingEvaluator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs index 3363e2fbe067..926abca97125 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -168,7 +168,7 @@ private static IEnumerable retrievePastVisibleObjects(Os if (hitObject.IsNull() || current.StartTime - hitObject.StartTime > reading_window_size || - hitObject.StartTime + hitObject.Preempt < current.StartTime) // Current object not visible at the time object needs to be clicked + hitObject.StartTime < current.StartTime - current.Preempt) // Current object not visible at the time object needs to be clicked break; yield return hitObject; @@ -185,7 +185,7 @@ private static double retrieveCurrentVisibleObjectDensity(OsuDifficultyHitObject while (hitObject != null) { if (hitObject.StartTime - current.StartTime > reading_window_size || - current.StartTime + hitObject.Preempt < hitObject.StartTime) // Object not visible at the time current object needs to be clicked. + current.StartTime < hitObject.StartTime - hitObject.Preempt) // Object not visible at the time current object needs to be clicked. break; double timeBetweenCurrAndLoopObj = hitObject.StartTime - current.StartTime; From 5b8067a4206b4400833a088a8837c12981ff8f9c Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:35:45 +0500 Subject: [PATCH 058/121] Restore precision buff in Agility (#36968) Seems like it got lost somewhere in the #36902. Great upside is that flow cs bonus can be reduced as it's supposed to be now since the bonus is made with d/t^1.65 in mind and flow is d/t. Values-wise it's a small precision buff --- .../Difficulty/Evaluators/Aim/AgilityEvaluator.cs | 2 ++ .../Difficulty/Evaluators/Aim/FlowAimEvaluator.cs | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs index 5208520bb471..10ec462198d7 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs @@ -31,6 +31,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double strain = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime; + strain *= osuCurrObj.SmallCircleBonus; + strain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); return strain * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index 924e1c346efe..ccc4d219d5be 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -42,8 +42,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with double flowDifficulty = currVelocity; - // Apply high circle size bonus to the base velocity - flowDifficulty *= osuCurrObj.SmallCircleBonus; + // Apply high circle size bonus to the base velocity. + // We use reduced CS bonus here because the bonus was made for an evaluator with a different d/t scaling + flowDifficulty *= Math.Pow(osuCurrObj.SmallCircleBonus, 0.75); // Rhythm changes are harder to flow flowDifficulty *= 1 + Math.Min(0.25, From abf1a0059103e511fb8ddde4e71ff9580b9bf4c5 Mon Sep 17 00:00:00 2001 From: James Wilson Date: Mon, 16 Mar 2026 19:32:34 +0000 Subject: [PATCH 059/121] Apply a few minor fixes to flow aim evaluation (#36999) Contains: - A small bug fix from initial separation merge. Snap aim only applies this bonus when slider travel distance is passed, so it should be the same for flow. Cannot find any cases where values are affected, but in theory the only difference this can make is slider factor calculations. - Apply overlapping note factor to the acute bonus, so that direction changes that overlap are awarded less. A small speed multiplier increase is included to offset this - Move angular velocity calculation out of ODHO into the flow evaluator, and fix it referencing the wrong angle (2 objects back instead of the previous object) - 2 tiny code refactors --------- Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- .../Evaluators/Aim/FlowAimEvaluator.cs | 18 ++++++++++++------ .../Preprocessing/OsuDifficultyHitObject.cs | 9 --------- .../Difficulty/Skills/Speed.cs | 2 +- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index ccc4d219d5be..d50e84f00b6b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -50,10 +50,14 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with flowDifficulty *= 1 + Math.Min(0.25, Math.Pow((Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) - Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) / 50, 4)); - if (osuCurrObj.AngularVelocity != null) + if (osuCurrObj.Angle != null && osuLastObj.Angle != null) { + double angleDifference = Math.Abs(osuCurrObj.Angle.Value - osuLastObj.Angle.Value); + double angleDifferenceAdjusted = Math.Sin(angleDifference / 2) * 180.0; + double angularVelocity = angleDifferenceAdjusted / (osuCurrObj.AdjustedDeltaTime * 0.1); + // Low angular velocity flow (angles are consistent) is easier to follow than erratic flow - flowDifficulty *= 0.8 + Math.Sqrt(osuCurrObj.AngularVelocity.Value / 270.0); + flowDifficulty *= 0.8 + Math.Sqrt(angularVelocity / 270.0); } // If all three notes are overlapping - don't reward bonuses as you don't have to do additional movement @@ -68,7 +72,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with overlappedNotesWeight = 1 - o1 * o2 * o3; } - if (osuCurrObj.Angle != null && osuLastObj.Angle != null) + if (osuCurrObj.Angle != null) { // Acute angles are also hard to flow // We square root velocity to make acute angle switches in streams aren't having difficulty higher than snap @@ -82,7 +86,6 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with if (withSliderTravelDistance) { currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; - prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; } // Scale with ratio of difference compared to 0.5 * max dist. @@ -92,10 +95,13 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with double overlapVelocityBuff = Math.Min(OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity)); - flowDifficulty += overlapVelocityBuff * distRatio * velocity_change_multiplier; + flowDifficulty += overlapVelocityBuff * + distRatio * + overlappedNotesWeight * + velocity_change_multiplier; } - if (osuCurrObj.BaseObject is Slider) + if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance) { // Include slider velocity to make velocity more consistent with snap flowDifficulty += osuCurrObj.TravelDistance / osuCurrObj.TravelTime; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 68950829f3cf..221b2c637852 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -116,8 +116,6 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// public double? Angle { get; private set; } - public double? AngularVelocity { get; private set; } - /// /// Angle of the vector created between current and current-1 /// normalised to consider symmetrical vectors in any axis to be the same angle. @@ -271,13 +269,6 @@ private void setDistances(double clockRate) NormalisedVectorAngle = Math.Atan2(Math.Abs(v.Y), Math.Abs(v.X)); Angle = Math.Min(angle, sliderAngle); - - if (lastLastDifficultyObject.Angle != null) - { - double angleDifference = Math.Abs(Angle.Value - lastLastDifficultyObject.Angle.Value); - double angleDifferenceAdjusted = Math.Sin(angleDifference / 2) * 180.0; - AngularVelocity = angleDifferenceAdjusted / (AdjustedDeltaTime * 0.1); - } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 2462186c780d..cdd01187030d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : HarmonicSkill { - private double skillMultiplier => 1.15; + private double skillMultiplier => 1.16; private readonly List sliderStrains = new List(); From fc82dac7aed572c5f1b02117d86a021d440c3636 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:04:54 +0500 Subject: [PATCH 060/121] Remove slider velocity considerations from previous object velocity (#36806) It barely affects values at this point --------- Co-authored-by: James Wilson --- .../Difficulty/Evaluators/Aim/SnapAimEvaluator.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs index 5df70225a1ef..75ecfe89ecc1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -36,7 +36,6 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with var osuCurrObj = (OsuDifficultyHitObject)current; var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); - var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2); const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS; @@ -53,16 +52,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime); } - // As above, do the same for the previous hitobject. double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance; double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; - if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance) - { - double sliderDistance = osuLastLastObj.LazyTravelDistance + osuLastObj.LazyJumpDistance; - prevVelocity = Math.Max(prevVelocity, sliderDistance / osuLastObj.AdjustedDeltaTime); - } - double wideAngleBonus = 0; double acuteAngleBonus = 0; double sliderBonus = 0; @@ -129,9 +121,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with { if (withSliderTravelDistance) { - // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities. - prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.AdjustedDeltaTime; - currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.AdjustedDeltaTime; + // We want to use just the object jump without slider velocity when awarding differences + currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; } // Scale with ratio of difference compared to 0.5 * max dist. From 09bcef1bdd91849c700a10a965ea1c68a5bc5e49 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:10:46 +0500 Subject: [PATCH 061/121] Adjust Aim RX multiplier (#36985) Generally deflationary on the top end and roughly the same at low end. Likely needs a more comprehensive solution, but this at least ensures that even if we don't do it the values will be sane. Co-authored-by: James Wilson --- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 06522d8e8563..0c2c6a71b085 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -72,8 +72,7 @@ protected override double StrainValueAt(DifficultyHitObject current) if (Mods.Any(m => m is OsuModRelax)) { - agilityDifficulty *= 0.0; - flowDifficulty *= 0.1; + agilityDifficulty *= 0.3; } double totalDifficulty = calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty); From fadb1c3f2c0a2a700a94fbbf02b8e9b23c428d6d Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:17:49 +0500 Subject: [PATCH 062/121] Calculate clock time when creating difficulty hit objects (#36962) This change removes `clockRate` precalculation from `DifficultyCalculator`. The idea is that clock rate should be calculated in-place (ideally for every object) since we store and access it using DHOs. This also prevents anyone from accidentally passing clock rate to skills Unfortunately osu uses clock rate to calculate OD for the whole map in `CreateDifficultyAttributes` so we can't make it completely DHO-based, but I think one single in-place call to `ModUtils.CalculateRateWithMods` in `CreateDifficultyAttributes` is fine --------- Co-authored-by: James Wilson --- .../EmptyFreeformDifficultyCalculator.cs | 6 ++--- .../PippidonDifficultyCalculator.cs | 6 ++--- .../EmptyScrollingDifficultyCalculator.cs | 6 ++--- .../PippidonDifficultyCalculator.cs | 6 ++--- .../Difficulty/CatchDifficultyCalculator.cs | 9 ++++--- .../Difficulty/ManiaDifficultyCalculator.cs | 9 ++++--- .../Difficulty/OsuDifficultyCalculator.cs | 11 +++++--- .../Difficulty/TaikoDifficultyCalculator.cs | 9 ++++--- ...DifficultyAdjustmentModCombinationsTest.cs | 6 ++--- .../TestSceneTimedDifficultyCalculation.cs | 9 ++++--- .../TestSceneBeatmapAttributeText.cs | 6 ++--- .../Difficulty/DifficultyCalculator.cs | 25 ++++++++----------- 12 files changed, 59 insertions(+), 49 deletions(-) diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs index 312d3d5e9a64..c7851cc12f02 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs @@ -19,13 +19,13 @@ public EmptyFreeformDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap b { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { return new DifficultyAttributes(mods, 0); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty(); + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } } diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs index f6addab279e7..852576958446 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs @@ -19,13 +19,13 @@ public PippidonDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatma { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { return new DifficultyAttributes(mods, 0); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty(); + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } } diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs index a4dc1762d520..17139218a52d 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs @@ -19,13 +19,13 @@ public EmptyScrollingDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { return new DifficultyAttributes(mods, 0); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty(); + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } } diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs index f6addab279e7..852576958446 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs @@ -19,13 +19,13 @@ public PippidonDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatma { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { return new DifficultyAttributes(mods, 0); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty(); + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index ea1c31b21608..75db566009da 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; +using osu.Game.Utils; namespace osu.Game.Rulesets.Catch.Difficulty { @@ -29,7 +30,7 @@ public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { if (beatmap.HitObjects.Count == 0) return new CatchDifficultyAttributes { Mods = mods }; @@ -44,12 +45,14 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat return attributes; } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { CatchHitObject? lastObject = null; List objects = new List(); + double clockRate = ModUtils.CalculateRateWithMods(mods); + float halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f; // For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay. @@ -71,7 +74,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I return objects; } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { return new Skill[] { diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index bcf16e68088c..1bfa3bec6d7c 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -19,6 +19,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Utils; namespace osu.Game.Rulesets.Mania.Difficulty { @@ -36,7 +37,7 @@ public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset); } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { if (beatmap.HitObjects.Count == 0) return new ManiaDifficultyAttributes { Mods = mods }; @@ -62,11 +63,13 @@ private static int maxComboForObject(HitObject hitObject) return 1; } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { var sortedObjects = beatmap.HitObjects.ToArray(); int totalColumns = ((ManiaBeatmap)beatmap).TotalColumns; + double clockRate = ModUtils.CalculateRateWithMods(mods); + LegacySortHelper.Sort(sortedObjects, Comparer.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime))); List objects = new List(); @@ -88,7 +91,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I // Sorting is done in CreateDifficultyHitObjects, since the full list of hitobjects is required. protected override IEnumerable SortObjects(IEnumerable input) => input; - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] { new Strain(mods, ((ManiaBeatmap)Beatmap).TotalColumns) }; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 2a425712b026..65d708cb3050 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -17,6 +17,7 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; +using osu.Game.Utils; namespace osu.Game.Rulesets.Osu.Difficulty { @@ -45,7 +46,7 @@ public static double CalculateRateAdjustedOverallDifficulty(double overallDiffic return (79.5 - hitWindowGreat) / 6; } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { if (beatmap.HitObjects.Count == 0) return new OsuDifficultyAttributes { Mods = mods }; @@ -77,7 +78,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double difficultSliders = aim.GetDifficultSliders(); - double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, clockRate); + double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, ModUtils.CalculateRateWithMods(mods)); int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle); int sliderCount = beatmap.HitObjects.Count(h => h is Slider); @@ -161,10 +162,12 @@ private double calculateStarRating(double basePerformance) return Math.Cbrt(basePerformance * OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { List objects = new List(); + double clockRate = ModUtils.CalculateRateWithMods(mods); + // The first jump is formed by the first two hitobjects of the map. // If the map has less than two OsuHitObjects, the enumerator will not return anything. for (int i = 1; i < beatmap.HitObjects.Count; i++) @@ -175,7 +178,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I return objects; } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { var skills = new List { diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 2cb49e8a4585..64af2861eca2 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Skills; using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Utils; namespace osu.Game.Rulesets.Taiko.Difficulty { @@ -39,7 +40,7 @@ public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { isConvert = beatmap.BeatmapInfo.Ruleset.OnlineID == 0; isRelax = mods.Any(h => h is TaikoModRelax); @@ -62,13 +63,15 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clo new TaikoModHardRock(), }; - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { var difficultyHitObjects = new List(); var centreObjects = new List(); var rimObjects = new List(); var noteObjects = new List(); + double clockRate = ModUtils.CalculateRateWithMods(mods); + // Generate TaikoDifficultyHitObjects from the beatmap's hit objects. for (int i = 2; i < beatmap.HitObjects.Count; i++) { @@ -92,7 +95,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I return difficultyHitObjects; } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { if (beatmap.HitObjects.Count == 0) return new TaikoDifficultyAttributes { Mods = mods }; diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index 90671093a522..f95e5768b0ed 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -223,17 +223,17 @@ public TestLegacyDifficultyCalculator(params Mod[] mods) protected override Mod[] DifficultyAdjustmentMods { get; } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { throw new NotImplementedException(); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { throw new NotImplementedException(); } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { throw new NotImplementedException(); } diff --git a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs index daf0bec44c0c..e3c5b92b54ad 100644 --- a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs +++ b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; using osu.Game.Tests.Beatmaps; +using osu.Game.Utils; namespace osu.Game.Tests.NonVisual { @@ -172,13 +173,15 @@ public TestDifficultyCalculator(IWorkingBeatmap beatmap) { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) => new TestDifficultyAttributes { Objects = beatmap.HitObjects.ToArray() }; - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { List objects = new List(); + double clockRate = ModUtils.CalculateRateWithMods(mods); + foreach (var obj in beatmap.HitObjects.OfType()) { if (!obj.Skip) @@ -191,7 +194,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I return objects; } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] { new PassThroughSkill(mods) }; + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] { new PassThroughSkill(mods) }; private class PassThroughSkill : Skill { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs index 5acd6cb0847b..36dbed3742ee 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs @@ -208,13 +208,13 @@ public TestDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) => new DifficultyAttributes(mods, mods.OfType().SingleOrDefault()?.Difficulty.Value ?? 0); - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Array.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 7acfbe651fe8..ec94bd704b6c 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -34,7 +34,6 @@ public abstract class DifficultyCalculator protected readonly IWorkingBeatmap WorkingBeatmap; private Mod[] playableMods; - private double clockRate; private readonly IRulesetInfo ruleset; @@ -74,10 +73,10 @@ public DifficultyAttributes Calculate([NotNull] IEnumerable mods, Cancellat // ReSharper disable once PossiblyMistakenUseOfCancellationToken preProcess(mods, cancellationToken); - var skills = CreateSkills(Beatmap, playableMods, clockRate); + var skills = CreateSkills(Beatmap, playableMods); if (!Beatmap.HitObjects.Any()) - return CreateDifficultyAttributes(Beatmap, playableMods, skills, clockRate); + return CreateDifficultyAttributes(Beatmap, playableMods, skills); foreach (var hitObject in getDifficultyHitObjects()) { @@ -88,7 +87,7 @@ public DifficultyAttributes Calculate([NotNull] IEnumerable mods, Cancellat } } - return CreateDifficultyAttributes(Beatmap, playableMods, skills, clockRate); + return CreateDifficultyAttributes(Beatmap, playableMods, skills); } /// @@ -121,7 +120,7 @@ public List CalculateTimed([NotNull] IEnumerable if (!Beatmap.HitObjects.Any()) return attribs; - var skills = CreateSkills(Beatmap, playableMods, clockRate); + var skills = CreateSkills(Beatmap, playableMods); var progressiveBeatmap = new ProgressiveCalculationBeatmap(Beatmap); var difficultyObjects = getDifficultyHitObjects().ToArray(); @@ -142,7 +141,7 @@ public List CalculateTimed([NotNull] IEnumerable currentIndex++; } - attribs.Add(new TimedDifficultyAttributes(obj.GetEndTime(), CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills, clockRate))); + attribs.Add(new TimedDifficultyAttributes(obj.GetEndTime(), CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills))); } return attribs; @@ -174,7 +173,7 @@ public IEnumerable CalculateAllLegacyCombinations(Cancella /// /// Retrieves the s to calculate against. /// - private IEnumerable getDifficultyHitObjects() => SortObjects(CreateDifficultyHitObjects(Beatmap, clockRate)); + private IEnumerable getDifficultyHitObjects() => SortObjects(CreateDifficultyHitObjects(Beatmap, playableMods)); /// /// Performs required tasks before every calculation. @@ -185,8 +184,6 @@ private void preProcess([NotNull] IEnumerable mods, CancellationToken cance { playableMods = mods.Select(m => m.DeepClone()).ToArray(); Beatmap = WorkingBeatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); - - clockRate = ModUtils.CalculateRateWithMods(playableMods); } /// @@ -277,16 +274,15 @@ static IEnumerable createDifficultyAdjustmentModCombinations(ReadOnlyMemory /// This may differ from in the case of timed calculation. /// The s that difficulty was calculated with. /// The skills which processed the beatmap. - /// The rate at which the gameplay clock is run at. - protected abstract DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate); + protected abstract DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills); /// /// Enumerates s to be processed from s in the . /// /// The providing the s to enumerate. - /// The rate at which the gameplay clock is run at. + /// Mods to create difficulty objects with. /// The enumerated s. - protected abstract IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate); + protected abstract IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods); /// /// Creates the s to calculate the difficulty of an . @@ -294,9 +290,8 @@ static IEnumerable createDifficultyAdjustmentModCombinations(ReadOnlyMemory /// The whose difficulty will be calculated. /// This may differ from in the case of timed calculation. /// Mods to calculate difficulty with. - /// Clockrate to calculate difficulty with. /// The s. - protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate); + protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods); /// /// Used to calculate timed difficulty attributes, where only a subset of hitobjects should be visible at any point in time. From b538c1d8bf3a411a2e24095df5c77b99dc21f335 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:35:49 +0500 Subject: [PATCH 063/121] Change the aim scaling slightly (#36967) This slightly buffs aim on above ~7.5 stars maps and slightly nerfs it below that --------- Co-authored-by: James Wilson --- osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index 5826f497d623..a269704dec22 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -29,7 +29,7 @@ public double ComputeAimRating(double aimDifficultyValue) if (mods.Any(m => m is OsuModAutopilot)) return 0; - double aimRating = Math.Pow(aimDifficultyValue, 0.62) * 0.0248; + double aimRating = Math.Pow(aimDifficultyValue, 0.63) * 0.02275; if (mods.Any(m => m is OsuModRelax)) aimRating *= 0.9; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 0c2c6a71b085..47e4d7d0f9f1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -33,7 +33,7 @@ public Aim(Mod[] mods, bool includeSliders) private double skillMultiplierSnap => 71.0; private double skillMultiplierAgility => 2.0; - private double skillMultiplierFlow => 244.0; + private double skillMultiplierFlow => 245.0; private double skillMultiplierTotal => 1.1; private double meanExponent => 1.2; From c7eeccca5eb6192b70f1282a2d8fd9e133cde932 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Sun, 22 Mar 2026 23:12:23 +0500 Subject: [PATCH 064/121] Make miss penalty slightly harsher on first misses (#37040) https://www.desmos.com/calculator/naggvbcz0a This change makes first ~3 misses have harsher miss penalty, while 9+ misses get lighter penalty. 4-9 misscounts stay close to unchanged. --- osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index bdc80b691110..cda246132efd 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -534,7 +534,7 @@ private double calculateTraceableBonus(double sliderFactor = 1) // 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 calculateMissPenalty(double missCount, double difficultStrainCount) => 0.93 / (missCount / (4 * Math.Log(difficultStrainCount)) + 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; From 22ac12c346394be7c950951cdb114e9103f99e8a Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:57:31 +0500 Subject: [PATCH 065/121] Undo Agility strain influence rescaling (#37081) Now that agility isn't "speedaim" that covers both snap and flow we can restore it back into it's original scaling. Arguably it should be _more_ than d/t^2 but that's a thing to explore separately --- .../Difficulty/Evaluators/Aim/AgilityEvaluator.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs index 10ec462198d7..816c5bce1f2e 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs @@ -38,6 +38,6 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) return strain * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); } - private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, Math.Pow(ms / 1000, 0.9))); + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.15, ms / 1000)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 47e4d7d0f9f1..b0eb73256f5f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -32,7 +32,7 @@ public Aim(Mod[] mods, bool includeSliders) private double currentStrain; private double skillMultiplierSnap => 71.0; - private double skillMultiplierAgility => 2.0; + private double skillMultiplierAgility => 2.5; private double skillMultiplierFlow => 245.0; private double skillMultiplierTotal => 1.1; private double meanExponent => 1.2; From 0071c82722d71c85427e37bc7a63254cddff57e4 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:34:36 +0500 Subject: [PATCH 066/121] Adjust high CS bonuses after all the distance-time rescaling (#37088) At this point maybe having a shared small circle bonus in ODHO isn't even worth it since we have different d/t scalings in all evaluators. Bonus was made for snap (~d/t^1.65), here it's adjusted to be higher on agility (~d/t^2) and lower on flow (~d/t). Practically affects like [1 map](https://osu.ppy.sh/beatmapsets/2191876#osu/5192354) --- .../Difficulty/Evaluators/Aim/AgilityEvaluator.cs | 2 +- .../Difficulty/Evaluators/Aim/FlowAimEvaluator.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs index 816c5bce1f2e..ab500ccf351d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs @@ -31,7 +31,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double strain = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime; - strain *= osuCurrObj.SmallCircleBonus; + strain *= Math.Pow(osuCurrObj.SmallCircleBonus, 1.5); strain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index d50e84f00b6b..9b1903e6137c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -44,7 +44,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Apply high circle size bonus to the base velocity. // We use reduced CS bonus here because the bonus was made for an evaluator with a different d/t scaling - flowDifficulty *= Math.Pow(osuCurrObj.SmallCircleBonus, 0.75); + flowDifficulty *= Math.Sqrt(osuCurrObj.SmallCircleBonus); // Rhythm changes are harder to flow flowDifficulty *= 1 + Math.Min(0.25, From 0f37874aeb15ca3d39e2b2b5d30c7ce3c9c0c9e1 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:40:09 +0500 Subject: [PATCH 067/121] Increase Aim strain decay base (#37099) This buffs longer jump sections and slightly nerfs short jump spikes. Mostly a way to slightly restore non-aimslop DT aim maps since they got hit with all the nerfs a bit too hard --------- Co-authored-by: James Wilson --- .../Difficulty/Evaluators/Aim/AgilityEvaluator.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs index ab500ccf351d..98a5c7680e15 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs @@ -38,6 +38,6 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) return strain * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); } - private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.15, ms / 1000)); + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.2, ms / 1000)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index b0eb73256f5f..25d4b3fd78f0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -32,9 +32,9 @@ public Aim(Mod[] mods, bool includeSliders) private double currentStrain; private double skillMultiplierSnap => 71.0; - private double skillMultiplierAgility => 2.5; + private double skillMultiplierAgility => 2.35; private double skillMultiplierFlow => 245.0; - private double skillMultiplierTotal => 1.1; + private double skillMultiplierTotal => 1.11; private double meanExponent => 1.2; /// @@ -50,7 +50,7 @@ public Aim(Mod[] mods, bool includeSliders) private readonly List sliderStrains = new List(); - private double strainDecay(double ms) => Math.Pow(0.15, ms / 1000); + private double strainDecay(double ms) => Math.Pow(0.2, ms / 1000); protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime); From b31d1b3a08605daf8d0f8f6786eeadf5ec4aeeff Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:47:55 +0200 Subject: [PATCH 068/121] Account for alternating angles in reading angle factor (#36466) This rework target is C-type nerf, as this map gets absurd amount of reading pp, because angles are considered very unrepetitive. What results in this score being worth absurd 1.4k pp. This PR is up to heavy discussion because it can be made much more general, touching more maps. So I wait on pp committee opinion on what of the parts can be removed. Current checks for angle to be nerfed: - The smaller angle has to be very sharp: <20 degrees, full power on <5 degrees - The larger angle has to be wide: >60 degrees, full power on >120 degrees If pattern meets all the criteria - it would be considered repetitive. For now practically no map meets this criteria to significant amount except C-type. --- .../Difficulty/Evaluators/ReadingEvaluator.cs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs index 926abca97125..2d1d5f4d73a7 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -208,6 +208,10 @@ private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current) int index = 0; double currentTimeGap = 0; + OsuDifficultyHitObject loopObjPrev0 = current; + OsuDifficultyHitObject? loopObjPrev1 = null; + OsuDifficultyHitObject? loopObjPrev2 = null; + while (currentTimeGap < minimum_angle_relevancy_time) { var loopObj = (OsuDifficultyHitObject)current.Previous(index); @@ -221,13 +225,34 @@ private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current) if (loopObj.Angle.IsNotNull() && current.Angle.IsNotNull()) { double angleDifference = Math.Abs(current.Angle.Value - loopObj.Angle.Value); + double angleDifferenceAlternating = Math.PI; + + if (loopObjPrev0.Angle != null && loopObjPrev1?.Angle != null && loopObjPrev2?.Angle != null) + { + angleDifferenceAlternating = Math.Abs(loopObjPrev1.Angle.Value - loopObj.Angle.Value); + angleDifferenceAlternating += Math.Abs(loopObjPrev2.Angle.Value - loopObjPrev0.Angle.Value); + + double weight = 1.0; + + // Be sure that one of the angles is very sharp, when other is wide + weight *= DifficultyCalculationUtils.ReverseLerp(Math.Min(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 20, 5); + weight *= DifficultyCalculationUtils.ReverseLerp(Math.Max(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 60, 120); + + // Lerp between max angle difference and rescaled alternating difference, with more harsh scaling compared to normal difference + angleDifferenceAlternating = double.Lerp(Math.PI, 0.1 * angleDifferenceAlternating, weight); + } + double stackFactor = DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); - constantAngleCount += Math.Cos(3 * Math.Min(double.DegreesToRadians(30), angleDifference * stackFactor)) * longIntervalFactor; + constantAngleCount += Math.Cos(3 * Math.Min(double.DegreesToRadians(30), Math.Min(angleDifference, angleDifferenceAlternating) * stackFactor)) * longIntervalFactor; } currentTimeGap = current.StartTime - loopObj.StartTime; index++; + + loopObjPrev2 = loopObjPrev1; + loopObjPrev1 = loopObjPrev0; + loopObjPrev0 = loopObj; } return Math.Clamp(2 / constantAngleCount, 0.2, 1); From b0047e45703293508e42b594f24d5109f757864e Mon Sep 17 00:00:00 2001 From: Nathan Corbett <75299710+Finadoggie@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:08:20 -0700 Subject: [PATCH 069/121] Use variable-length strains to fix chunking-related issues (#33351) - Closes https://github.com/ppy/osu/issues/25254 Solves chunking-related issues by starting a new chunk for every object, and allowing chunks to be different lengths. This is retrofitted to existing calculations, and doesn't have other ramifications in the way something like tr3s's continuous strains do. The effect of this can be seen in the video below. In live pp, the map would lose sr when increasing the rate at certain points, but in the video, the sr rises consistently as expected. https://github.com/user-attachments/assets/2d58946a-9e0c-4f6b-912a-71dfe75c0f8a A multiplier has been added inside `DifficultyValue()` to account for SR being slightly lower from more granular summation. When testing this rework in PerformanceCalculatorGUI, I recommend using [this fork](https://github.com/Finadoggie/osu-tools/tree/variable-length-strains). I can't guarantee that the visuals it shows are correct, but they are definitely more correct than not. Edit: Use [this fork](https://github.com/Finadoggie/osu-tools/tree/unsynced-variable-length-strains) for testing now. Old one is now for testing SynchronizedVariableLengthStrainSkill --------- Co-authored-by: tsunyoku Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- .../OsuDifficultyCalculatorTest.cs | 24 ++ .../Difficulty/Skills/Aim.cs | 84 ++++-- .../Skills/VariableLengthStrainSkill.cs | 277 ++++++++++++++++++ .../Beatmaps/DifficultyCalculatorTest.cs | 6 +- 4 files changed, 371 insertions(+), 20 deletions(-) create mode 100644 osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index e7a6d8ecffc2..94768c15a515 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -34,6 +34,30 @@ public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxComb public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); + [TestCase(239, "diffcalc-test")] + [TestCase(54, "zero-length-sliders")] + [TestCase(4, "very-fast-slider")] + public void TestOffsetChanges(int expectedMaxCombo, string name) + { + const double offset_iterations = 400; + var beatmap = GetBeatmap(name); + + var attributes = CreateDifficultyCalculator(beatmap).Calculate(); + double expectedStarRating = attributes.StarRating; + + for (int i = 0; i < offset_iterations; i++) + { + foreach (var beatmapHitObject in beatmap.Beatmap.HitObjects) + beatmapHitObject.StartTime++; + + attributes = CreateDifficultyCalculator(beatmap).Calculate(); + + // Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences. + Assert.That(attributes.StarRating, Is.EqualTo(expectedStarRating).Within(0.00001)); + Assert.That(attributes.MaxCombo, Is.EqualTo(expectedMaxCombo)); + } + } + protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset().RulesetInfo, beatmap); protected override Ruleset CreateRuleset() => new OsuRuleset(); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 25d4b3fd78f0..77e519c7895a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// /// Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances. /// - public class Aim : StrainSkill + public class Aim : VariableLengthStrainSkill { public readonly bool IncludeSliders; @@ -41,12 +41,12 @@ public Aim(Mod[] mods, bool includeSliders) /// The number of sections with the highest strains, which the peak strain reductions will apply to. /// This is done in order to decrease their impact on the overall difficulty of the map for this skill. /// - private int reducedSectionCount => 10; + private int reducedSectionTime => 4000; /// /// The baseline multiplier applied to the section with the biggest strain. /// - private double reducedStrainBaseline => 0.75; + private double reducedStrainBaseline => 0.727; private readonly List sliderStrains = new List(); @@ -153,30 +153,80 @@ public double CountTopWeightedSliders(double difficultyValue) public override double DifficultyValue() { double difficulty = 0; - double weight = 1; + double time = 0; + var strains = getReducedStrainPeaks(); + + // Difficulty is a continuous weighted sum of the sorted strains + foreach (StrainPeak strain in strains) + { + /* Weighting function can be thought of as: + b + ∫ DecayWeight^x dx + a + where a = startTime and b = endTime + + Technically, the function below has been slightly modified from the equation above. + The real function would be + double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime); + ... + return difficulty / Math.Log(1 / DecayWeight); + E.g. for a DecayWeight of 0.9, we're multiplying by 10 instead of 9.49122... + + This change makes it so that a map composed solely of MaxSectionLength chunks will have the exact same value when summed in this class and StrainSkill. + Doing this ensures the relationship between strain values and difficulty values remains the same between the two classes. + */ + double startTime = time; + double endTime = time + strain.SectionLength / MaxSectionLength; + + double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime); + + difficulty += strain.Value * weight; + time = endTime; + } + + return difficulty / (1 - DecayWeight); + } + + /// + /// Returns a sorted enumerable of strain peaks with the highest values reduced. + /// + /// + private IEnumerable getReducedStrainPeaks() + { // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). // These sections will not contribute to the difficulty. - var peaks = GetCurrentStrainPeaks().Where(p => p > 0); + var peaks = GetCurrentStrainPeaks().Where(p => p.Value > 0); + + List strains = peaks.OrderByDescending(p => p.Value).ToList(); - List strains = peaks.OrderDescending().ToList(); + const int chunk_size = 20; + double time = 0; + int strainsToRemove = 0; // All strains are removed at the end for optimization purposes // We are reducing the highest strains first to account for extreme difficulty spikes - for (int i = 0; i < Math.Min(strains.Count, reducedSectionCount); i++) + // Strains are split into 20ms chunks to try to mitigate inconsistencies caused by reducing strains + while (strains.Count > strainsToRemove && time < reducedSectionTime) { - double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((float)i / reducedSectionCount, 0, 1))); - strains[i] *= Interpolation.Lerp(reducedStrainBaseline, 1.0, scale); - } + StrainPeak strain = strains[strainsToRemove]; - // Difficulty is the weighted sum of the highest strains from every section. - // We're sorting from highest to lowest strain. - foreach (double strain in strains.OrderDescending()) - { - difficulty += strain * weight; - weight *= DecayWeight; + for (double addedTime = 0; addedTime < strain.SectionLength; addedTime += chunk_size) + { + double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((time + addedTime) / reducedSectionTime, 0, 1))); + + strains.Add(new StrainPeak( + strain.Value * Interpolation.Lerp(reducedStrainBaseline, 1.0, scale), + Math.Min(chunk_size, strain.SectionLength - addedTime) + )); + } + + time += strain.SectionLength; + strainsToRemove++; } - return difficulty; + strains.RemoveRange(0, strainsToRemove); + + return strains.OrderByDescending(p => p.Value); } } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs new file mode 100644 index 000000000000..a45af7453983 --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs @@ -0,0 +1,277 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Extensions; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Difficulty.Skills +{ + /// + /// Similar to , but instead of strains having a fixed length, strains can be any length. + /// A new is created for each . + /// + public abstract class VariableLengthStrainSkill : Skill + { + /// + /// The weight by which each strain value decays. + /// + protected virtual double DecayWeight => 0.9; + + /// + /// The maximum length of each strain section. + /// + protected virtual int MaxSectionLength => 400; + + private double currentSectionPeak; // We also keep track of the peak strain in the current section. + private double currentSectionBegin; + private double currentSectionEnd; + + /// + /// The number of `MaxSectionLength` sections calculated such that enough of the difficulty value is preserved. + /// WARNING: This should be overridden if strains are ever used outside of , + /// or if is overridden to not use the default geometric sum. This should be removed + /// in the future when a better memory-saving technique is implemented. + /// + private double maxStoredSections => 11 / (1 - DecayWeight); + + private readonly List strainPeaks = new List(); + + private double totalLength; + + /// + /// Stores previous strains so that, if a high difficulty hit object is followed by a lower + /// difficulty hit object, the high difficulty hit object gets a full strain instead of being cut short. + /// + private readonly List<(double StrainValue, double StartTime)> queuedStrains = new List<(double, double)>(); + + protected VariableLengthStrainSkill(Mod[] mods) + : base(mods) + { + } + + /// + /// Returns the strain value at . This value is calculated with or without respect to previous objects. + /// + protected abstract double StrainValueAt(DifficultyHitObject current); + + /// + /// Process a and update current strain values accordingly. + /// + protected sealed override double ProcessInternal(DifficultyHitObject current) + { + // If we're on the first object, set up the first section to end `MaxSectionLength` after it. + if (current.Index == 0) + { + currentSectionBegin = current.StartTime; + currentSectionEnd = currentSectionBegin + MaxSectionLength; + + // No work is required for first object after calculating difficulty + currentSectionPeak = StrainValueAt(current); + return currentSectionPeak; + } + + backfillPeaks(current); + + double currentStrain = StrainValueAt(current); + + // If the current strain is larger than the current peak, begin a new peak + // Otherwise, add the current strain to the queue + if (currentStrain > currentSectionPeak) + { + // Clear the queue since none of the strains inside of it will be contributing to the difficulty. + queuedStrains.Clear(); + + // End the current section with the new peak + saveCurrentPeak(current.StartTime - currentSectionBegin); + + // Set up the new section to start at the current object with the current strain + currentSectionBegin = current.StartTime; + currentSectionEnd = currentSectionBegin + MaxSectionLength; + currentSectionPeak = currentStrain; + } + else + { + // Empty the queue of smaller elements as they won't be relevant to difficulty + while (queuedStrains.Count > 0 && queuedStrains[^1].StrainValue < currentStrain) + queuedStrains.RemoveAt(queuedStrains.Count - 1); + + queuedStrains.Add((currentStrain, current.StartTime)); + } + + return currentStrain; + } + + /// + /// Fills the space between the end of the current section and the current object, if there is any. + /// + /// The object who's is backfilled to. + private void backfillPeaks(DifficultyHitObject current) + { + // If the current object starts after the current section ends + // then we want to start a new section without any harsh drop-off. + // If we have previous strains that influence the current difficulty we will prioritise those first. + // Otherwise, start with the current object's initial strain. + while (current.StartTime > currentSectionEnd) + { + // Save the current peak, marking the end of the section. + saveCurrentPeak(currentSectionEnd - currentSectionBegin); + currentSectionBegin = currentSectionEnd; + + // If we have any strains queued, then we will use those until the object falls into the new section. + if (queuedStrains.Count > 0) + { + (double strain, double startTime) = queuedStrains[0]; + queuedStrains.RemoveAt(0); + + // We want the section to end `MaxSectionLength` after the strain we're using as an influence. + // This effectively means the queued strain will exist in its own section if the gap between the queued strain and current object is large enough. + // This is required to make sure there's no harsh difficulty difference between 2 sections if there was a large gap. + currentSectionEnd = startTime + MaxSectionLength; + startNewSectionFrom(currentSectionBegin, current); + + // If the current object's peak was higher, we don't want to override it with a lower strain. + // Only use the queued strain if it contributes more difficulty. + currentSectionPeak = Math.Max(currentSectionPeak, strain); + } + // If the queue is empty then we should start the section from the current object instead. + // The queue can be empty if we're starting off of the back of a new peak, or if we drained through all the queue + // and the current object is still later than the section end. + else + { + // We don't have any prior strains to take as a reference, so end the new section `MaxSectionLength` after it starts. + currentSectionEnd = currentSectionBegin + MaxSectionLength; + startNewSectionFrom(currentSectionBegin, current); + } + } + } + + /// + /// Saves the current peak strain level to the list of strain peaks, which will be used to calculate an overall difficulty. + /// + private void saveCurrentPeak(double sectionLength) + { + strainPeaks.AddInPlace(new StrainPeak(currentSectionPeak, sectionLength)); + totalLength += sectionLength; + + // Remove from the back of our strain peaks if there's any which are too deep to contribute to difficulty. + // `maxStoredSections` dictates for us how many sections will preserve at least 99.999% of the difficulty value. + while (totalLength > maxStoredSections * MaxSectionLength) + { + totalLength -= strainPeaks[0].SectionLength; + strainPeaks.RemoveAt(0); + } + } + + /// + /// Sets the initial strain level for a new section. + /// + /// The beginning of the new section in milliseconds. + /// The current hit object. + private void startNewSectionFrom(double time, DifficultyHitObject current) + { + // The maximum strain of the new section is not zero by default + // This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level. + currentSectionPeak = CalculateInitialStrain(time, current); + } + + /// + /// Retrieves the peak strain at a point in time. + /// + /// The time to retrieve the peak strain at. + /// The current hit object. + /// The peak strain. + protected abstract double CalculateInitialStrain(double time, DifficultyHitObject current); + + /// + /// Returns a live enumerable of the peak strains for each section of the beatmap, + /// including the peak of the current section. + /// + public IEnumerable GetCurrentStrainPeaks() => strainPeaks.Append(new StrainPeak(currentSectionPeak, currentSectionEnd - currentSectionBegin)); + + /// + /// Returns the calculated difficulty value representing all s that have been processed up to this point. + /// + public override double DifficultyValue() + { + double difficulty = 0; + + // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These sections will not contribute to the difficulty. + var peaks = GetCurrentStrainPeaks().Where(p => p.Value > 0); + + List strains = peaks.OrderByDescending(p => (p.Value, p.SectionLength)).ToList(); + + // Time is measured in units of strains + double time = 0; + + // Difficulty is a continuous weighted sum of the sorted strains + for (int i = 0; i < strains.Count; i++) + { + /* Weighting function can be thought of as: + b + ∫ DecayWeight^x dx + a + where a = startTime and b = endTime + + Technically, the function below has been slightly modified from the equation above. + The real function would be + double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime)) + ... + return difficulty / Math.Log(1 / DecayWeight) + E.g. for a DecayWeight of 0.9, we're multiplying by 10 instead of 9.49122... + + This change makes it so that a map composed solely of MaxSectionLength chunks will have the exact same value when summed in this class and StrainSkill. + Doing this ensures the relationship between strain values and difficulty values remains the same between the two classes. + */ + double startTime = time; + double endTime = time + strains[i].SectionLength; + + double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime); + + difficulty += strains[i].Value * weight; + time = endTime; + } + + return difficulty / (1 - DecayWeight); + } + + /// + /// Calculates the number of strains weighted against the top strain. + /// The result is scaled by clock rate as it affects the total number of strains. + /// + public virtual double CountTopWeightedStrains(double difficultyValue) + { + if (ObjectDifficulties.Count == 0) + return 0.0; + + double consistentTopStrain = difficultyValue * (1 - DecayWeight); // What would the top strain be if all strain values were identical + + if (consistentTopStrain == 0) + return ObjectDifficulties.Count; + + // Use a weighted sum of all strains. Constants are arbitrary and give nice values + return ObjectDifficulties.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); + } + + /// + /// Used to store the difficulty of a section of a map. + /// + public readonly struct StrainPeak : IComparable + { + public StrainPeak(double value, double sectionLength) + { + Value = value; + SectionLength = Math.Round(sectionLength); + } + + public double Value { get; } + public double SectionLength { get; } + + public int CompareTo(StrainPeak other) => Value.CompareTo(other.Value); + } + } +} diff --git a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs index 16434406b509..e98c34d603b0 100644 --- a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs +++ b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs @@ -23,16 +23,16 @@ public abstract class DifficultyCalculatorTest protected abstract string ResourceAssembly { get; } - protected void Test(double expectedStarRating, int expectedMaxCombo, string name, params Mod[] mods) + protected void Test(double? expectedStarRating, int expectedMaxCombo, string name, params Mod[] mods) { - var attributes = CreateDifficultyCalculator(getBeatmap(name)).Calculate(mods); + var attributes = CreateDifficultyCalculator(GetBeatmap(name)).Calculate(mods); // Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences. Assert.That(attributes.StarRating, Is.EqualTo(expectedStarRating).Within(0.00001)); Assert.That(attributes.MaxCombo, Is.EqualTo(expectedMaxCombo)); } - private IWorkingBeatmap getBeatmap(string name) + protected IWorkingBeatmap GetBeatmap(string name) { using (var resStream = openResource($"{resource_namespace}.{name}.osu")) using (var stream = new LineBufferedReader(resStream)) From 4ce4bca6a7391b4effb92bfeb029df2556ae154e Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:49:21 +0300 Subject: [PATCH 070/121] Very slight overall aim buff (#37134) --- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 77e519c7895a..9fbf496c82af 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -34,7 +34,7 @@ public Aim(Mod[] mods, bool includeSliders) private double skillMultiplierSnap => 71.0; private double skillMultiplierAgility => 2.35; private double skillMultiplierFlow => 245.0; - private double skillMultiplierTotal => 1.11; + private double skillMultiplierTotal => 1.12; private double meanExponent => 1.2; /// From e082550ceade7c4a0d4f4f5a470c29afe339558d Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:11:24 +0300 Subject: [PATCH 071/121] Aim balancing (#37147) --- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 9fbf496c82af..b6b9bf3a01c0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -31,9 +31,9 @@ public Aim(Mod[] mods, bool includeSliders) private double currentStrain; - private double skillMultiplierSnap => 71.0; + private double skillMultiplierSnap => 70.9; private double skillMultiplierAgility => 2.35; - private double skillMultiplierFlow => 245.0; + private double skillMultiplierFlow => 243.0; private double skillMultiplierTotal => 1.12; private double meanExponent => 1.2; From c4ef081c4a308dd068c235874ac46ff334b8a46a Mon Sep 17 00:00:00 2001 From: kwotaq <80002984+kwotaq@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:31:03 +0300 Subject: [PATCH 072/121] Refactor Reading strain to match the rest of the skills (#37010) This doesn't affect values as per the original refactor. Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- .../Difficulty/Evaluators/ReadingEvaluator.cs | 5 +++++ osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs index 2d1d5f4d73a7..fe6d49661b9c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -48,6 +48,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd double difficulty = DifficultyCalculationUtils.Norm(1.5, preemptDifficulty, hiddenDifficulty, noteDensityDifficulty); + // Having less time to process information is harder + difficulty *= highBpmBonus(currObj.AdjustedDeltaTime); + return difficulty; } @@ -263,5 +266,7 @@ private static double getTimeNerfFactor(double deltaTime) { return Math.Clamp(2 - deltaTime / (reading_window_size / 2), 0, 1); } + + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.8, ms / 1000)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index c548da5b8d76..414cf1f1f9b8 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -37,9 +37,11 @@ protected override double ObjectDifficultyOf(DifficultyHitObject current) { objectList.Add(current); - currentDifficulty *= strainDecay(current.DeltaTime); + double decay = strainDecay(current.DeltaTime); - currentDifficulty += ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * skillMultiplier; + currentDifficulty *= decay; + + currentDifficulty += ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * (1 - decay) * skillMultiplier; return currentDifficulty; } From f6223d19afe753b94d3a9f6474f8c44ceda50394 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:09:22 +0300 Subject: [PATCH 073/121] Move Aim mod-related difficulty adjustments to after the probability calculation (#37184) Adjusting difficulty before we calculate the flow probability is technically wrong. This makes flow adjustment on RX actually work as it should instead of trying to lower the snap to achieve similar results. TD flow buff was removed because here it actually made some maps _very_ buffed. Values are mostly the same otherwise --- .../Difficulty/OsuRatingCalculator.cs | 3 -- .../Difficulty/Skills/Aim.cs | 29 ++++++++++--------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index a269704dec22..9dc9b1afa430 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -31,9 +31,6 @@ public double ComputeAimRating(double aimDifficultyValue) double aimRating = Math.Pow(aimDifficultyValue, 0.63) * 0.02275; - if (mods.Any(m => m is OsuModRelax)) - aimRating *= 0.9; - if (mods.Any(m => m is OsuModMagnetised)) { float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index b6b9bf3a01c0..535953336ce2 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -35,7 +35,7 @@ public Aim(Mod[] mods, bool includeSliders) private double skillMultiplierAgility => 2.35; private double skillMultiplierFlow => 243.0; private double skillMultiplierTotal => 1.12; - private double meanExponent => 1.2; + private double combinedSnapNormExponent => 1.2; /// /// The number of sections with the highest strains, which the peak strain reductions will apply to. @@ -63,18 +63,6 @@ protected override double StrainValueAt(DifficultyHitObject current) double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skillMultiplierAgility; double flowDifficulty = FlowAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierFlow; - if (Mods.Any(m => m is OsuModTouchDevice)) - { - snapDifficulty = Math.Pow(snapDifficulty, 0.89); - // we don't adjust agility here since agility represents TD difficulty in a decent enough way - flowDifficulty = Math.Pow(flowDifficulty, 1.1); - } - - if (Mods.Any(m => m is OsuModRelax)) - { - agilityDifficulty *= 0.3; - } - double totalDifficulty = calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty); currentStrain *= decay; @@ -91,11 +79,24 @@ private double calculateTotalValue(double snapDifficulty, double agilityDifficul // We compare flow to combined snap and agility because snap by itself doesn't have enough difficulty to be above flow on streams // Agility on the other hand is supposed to measure the rate of cursor velocity changes while snapping // So snapping every circle on a stream requires an enormous amount of agility at which point it's easier to flow - double combinedSnapDifficulty = DifficultyCalculationUtils.Norm(meanExponent, snapDifficulty, agilityDifficulty); + double combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combinedSnapNormExponent, snapDifficulty, agilityDifficulty); double pSnap = calculateSnapFlowProbability(flowDifficulty / combinedSnapDifficulty); double pFlow = 1 - pSnap; + if (Mods.Any(m => m is OsuModTouchDevice)) + { + // we don't adjust agility here since agility represents TD difficulty in a decent enough way + snapDifficulty = Math.Pow(snapDifficulty, 0.89); + combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combinedSnapNormExponent, snapDifficulty, agilityDifficulty); + } + + if (Mods.Any(m => m is OsuModRelax)) + { + combinedSnapDifficulty *= 0.75; + flowDifficulty *= 0.6; + } + double totalDifficulty = combinedSnapDifficulty * pSnap + flowDifficulty * pFlow; double totalStrain = totalDifficulty * skillMultiplierTotal; From 5049ac8e29fa56f4b2f879679876a8260cd37f0e Mon Sep 17 00:00:00 2001 From: kwotaq <80002984+kwotaq@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:50:33 +0300 Subject: [PATCH 074/121] Refactor SnapAimEvaluator bonuses for better readability (#37119) This PR moves the addition of the bonuses closer to their declaration and also renames some variables and functions to more closely resemble the way they're used. The names can be discussed for I'm not sold on them either. --------- Co-authored-by: James Wilson --- .../Evaluators/Aim/FlowAimEvaluator.cs | 2 +- .../Evaluators/Aim/SnapAimEvaluator.cs | 85 +++++++++---------- 2 files changed, 39 insertions(+), 48 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index 9b1903e6137c..cac4febf1dd6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -77,7 +77,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Acute angles are also hard to flow // We square root velocity to make acute angle switches in streams aren't having difficulty higher than snap flowDifficulty += Math.Sqrt(currVelocity) * - SnapAimEvaluator.CalcAcuteAngleBonus(osuCurrObj.Angle.Value) * + SnapAimEvaluator.CalcAngleAcuteness(osuCurrObj.Angle.Value) * overlappedNotesWeight; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs index 75ecfe89ecc1..3a67c84ac3c5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -55,51 +55,37 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance; double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; - double wideAngleBonus = 0; - double acuteAngleBonus = 0; - double sliderBonus = 0; - double velocityChangeBonus = 0; - double wiggleBonus = 0; - double aimStrain = currVelocity; // Start strain with regular velocity. + // Penalize angle repetition. + aimStrain *= vectorAngleRepetition(osuCurrObj, osuLastObj); + if (osuCurrObj.Angle != null && osuLastObj.Angle != null) { double currAngle = osuCurrObj.Angle.Value; double lastAngle = osuLastObj.Angle.Value; // Rewarding angles, take the smaller velocity as base. - double angleBonus = Math.Min(currVelocity, prevVelocity); + double velocityInfluence = Math.Min(currVelocity, prevVelocity); + + double acuteAngleBonus = 0; if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same. { - acuteAngleBonus = CalcAcuteAngleBonus(currAngle); - - // Penalize angle repetition. - acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAcuteAngleBonus(lastAngle), 3))); + acuteAngleBonus = velocityInfluence * CalcAngleAcuteness(currAngle); // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter - acuteAngleBonus *= angleBonus * - DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * + acuteAngleBonus *= DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * DifficultyCalculationUtils.Smootherstep(currDistance, 0, diameter * 2); + + // Penalize angle repetition. + acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAngleAcuteness(lastAngle), 3))); } - wideAngleBonus = calcWideAngleBonus(currAngle); + double wideAngleBonus = velocityInfluence * calcAngleWideness(currAngle); // Penalize angle repetition. - wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3))); - - wideAngleBonus *= angleBonus; - - // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle - // https://www.desmos.com/calculator/dp0v0nvowc - wiggleBonus = angleBonus - * DifficultyCalculationUtils.Smootherstep(currDistance, radius, diameter) - * Math.Pow(DifficultyCalculationUtils.ReverseLerp(currDistance, diameter * 3, diameter), 1.8) - * DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) - * DifficultyCalculationUtils.Smootherstep(prevDistance, radius, diameter) - * Math.Pow(DifficultyCalculationUtils.ReverseLerp(prevDistance, diameter * 3, diameter), 1.8) - * DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); + wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcAngleWideness(lastAngle), 3))); if (osuLast2Obj != null) { @@ -115,6 +101,21 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with wideAngleBonus *= 1 - 0.55 * (1 - distance); } } + + // Add in acute angle bonus or wide angle bonus, whichever is larger. + aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier); + + // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle + // https://www.desmos.com/calculator/dp0v0nvowc + double wiggleBonus = velocityInfluence + * DifficultyCalculationUtils.Smootherstep(currDistance, radius, diameter) + * Math.Pow(DifficultyCalculationUtils.ReverseLerp(currDistance, diameter * 3, diameter), 1.8) + * DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) + * DifficultyCalculationUtils.Smootherstep(prevDistance, radius, diameter) + * Math.Pow(DifficultyCalculationUtils.ReverseLerp(prevDistance, diameter * 3, diameter), 1.8) + * DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); + + aimStrain += wiggleBonus * wiggle_multiplier; } if (Math.Max(prevVelocity, currVelocity) != 0) @@ -131,30 +132,20 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity)); - velocityChangeBonus = overlapVelocityBuff * distRatio; + double velocityChangeBonus = overlapVelocityBuff * distRatio; // Penalize for rhythm changes. velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2); - } - if (osuCurrObj.BaseObject is Slider) - { - // Reward sliders based on velocity. - sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; + aimStrain += velocityChangeBonus * velocity_change_multiplier; } - // Penalize angle repetition. - aimStrain *= vectorAngleRepetition(osuCurrObj, osuLastObj); - - aimStrain += wiggleBonus * wiggle_multiplier; - aimStrain += velocityChangeBonus * velocity_change_multiplier; - - // Add in acute angle bonus or wide angle bonus, whichever is larger. - aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier); - - // Add in additional slider velocity bonus. - if (withSliderTravelDistance) + // Reward sliders based on velocity. + if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance) + { + double sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; aimStrain += (sliderBonus < 1 ? sliderBonus : Math.Pow(sliderBonus, 0.75)) * slider_multiplier; + } // Apply high circle size bonus aimStrain *= osuCurrObj.SmallCircleBonus; @@ -208,13 +199,13 @@ private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuD double angleDifferenceAdjusted = Math.Cos(2 * Math.Min(double.DegreesToRadians(45), Math.Abs(currAngle - lastAngle) * stackFactor)); - double baseNerf = 1 - maximum_repetition_nerf * CalcAcuteAngleBonus(lastAngle) * angleDifferenceAdjusted; + double baseNerf = 1 - maximum_repetition_nerf * CalcAngleAcuteness(lastAngle) * angleDifferenceAdjusted; return Math.Pow(baseNerf + (1 - baseNerf) * vectorRepetition * maximum_vector_influence * stackFactor, 2); } - private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); + private static double calcAngleWideness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); - public static double CalcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); + public static double CalcAngleAcuteness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); } } From b20b3670afb5ccf80c15198470a54510e81c6c18 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:36:38 +0300 Subject: [PATCH 075/121] Fix snap angle repetition (#37286) Classic blunder. --- .../Evaluators/Aim/SnapAimEvaluator.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs index 3a67c84ac3c5..02421ffa0de7 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -72,21 +72,23 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same. { - acuteAngleBonus = velocityInfluence * CalcAngleAcuteness(currAngle); + acuteAngleBonus = CalcAngleAcuteness(currAngle); + + // Penalize angle repetition. It is important to do it _before_ multiplying by anything because we compare raw acuteness here + acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAngleAcuteness(lastAngle), 3))); // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter - acuteAngleBonus *= DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * + acuteAngleBonus *= velocityInfluence * DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * DifficultyCalculationUtils.Smootherstep(currDistance, 0, diameter * 2); - - // Penalize angle repetition. - acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAngleAcuteness(lastAngle), 3))); } - double wideAngleBonus = velocityInfluence * calcAngleWideness(currAngle); + double wideAngleBonus = calcAngleWideness(currAngle); - // Penalize angle repetition. + // Penalize angle repetition. It is important to do it _before_ multiplying by velocity because we compare raw wideness here wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcAngleWideness(lastAngle), 3))); + wideAngleBonus *= velocityInfluence; + if (osuLast2Obj != null) { // If objects just go back and forth through a middle point - don't give as much wide bonus From a057138eafbc5818c52908dd85fc47712ea8a262 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:39:08 +0300 Subject: [PATCH 076/121] Fix low distance snap/flow relation (#37306) Currently all low spaced ( /// Evaluates the difficulty of fast aiming @@ -35,7 +34,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) strain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); - return strain * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); + return strain; } private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.2, ms / 1000)); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index cac4febf1dd6..30f45a3226a5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim { public static class FlowAimEvaluator { - private const double velocity_change_multiplier = 2.0; + private const double velocity_change_multiplier = 0.52; /// /// Evaluates difficulty of "flow aim" - aiming pattern where player doesn't stop their cursor on every object and instead "flows" through them. @@ -108,7 +108,10 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with } // Final velocity is being raised to a power because flow difficulty scales harder with both high distance and time, and we want to account for that - return Math.Pow(flowDifficulty, 1.45); + flowDifficulty = Math.Pow(flowDifficulty, 1.45); + + // Reduce difficulty for low spacing since spacing below radius is always to be flowed + return flowDifficulty * DifficultyCalculationUtils.Smootherstep(currDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); } private static double calculateOverlapFactor(OsuDifficultyHitObject first, OsuDifficultyHitObject second) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs index 02421ffa0de7..a373df706cd5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -152,16 +152,12 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Apply high circle size bonus aimStrain *= osuCurrObj.SmallCircleBonus; - aimStrain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime, osuCurrObj.LazyJumpDistance); + aimStrain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); return aimStrain; } - // We decrease strain for distances 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.65))) - * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.65))); private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuDifficultyHitObject previous) { From 5aff5a4098dade76933f3a8871a8e03ea9f71f4b Mon Sep 17 00:00:00 2001 From: piiid Date: Thu, 16 Apr 2026 16:45:53 -0400 Subject: [PATCH 077/121] Rebalance TC Bonus (#36555) --- .../Difficulty/OsuPerformanceCalculator.cs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index cda246132efd..dc3d9cd19d54 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -243,16 +243,11 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib speedValue *= calculateMissPenalty(relevantMissCount, attributes.SpeedDifficultStrainCount); } - // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. if (score.Mods.Any(m => m is OsuModBlinds)) { // Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given. speedValue *= 1.12; } - else if (score.Mods.Any(m => m is OsuModTraceable)) - { - speedValue *= 1.0 + calculateTraceableBonus(); - } double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes); speedValue *= speedHighDeviationMultiplier; @@ -514,19 +509,21 @@ private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attribute /// private double calculateTraceableBonus(double sliderFactor = 1) { - // Start from normal curve, rewarding lower AR up to AR7 - double traceableBonus = 0.025 * (12.0 - Math.Max(approachRate, 7)); + // We want to reward slider aim less, more so at lower AR + double highApproachRateSliderVisibilityFactor = 0.5 + (Math.Pow(sliderFactor, 6) / 2); + double lowApproachRateSliderVisibilityFactor = Math.Pow(sliderFactor, 6); - // We want to reward slider aim on low AR less - double sliderVisibilityFactor = Math.Pow(sliderFactor, 3); + // Start from normal curve, rewarding lower AR up to AR7 + double traceableBonus = 0.0275; + traceableBonus += 0.025 * (12.0 - Math.Max(approachRate, 7)) * highApproachRateSliderVisibilityFactor; // For AR up to 0 - reduce reward for very low ARs when object is visible if (approachRate < 7) - traceableBonus += 0.02 * (7.0 - Math.Max(approachRate, 0)) * sliderVisibilityFactor; + traceableBonus += 0.025 * (7.0 - Math.Max(approachRate, 0)) * lowApproachRateSliderVisibilityFactor; // Starting from AR0 - cap values so they won't grow to infinity if (approachRate < 0) - traceableBonus += 0.01 * (1 - Math.Pow(1.5, approachRate)) * sliderVisibilityFactor; + traceableBonus += 0.025 * (1 - Math.Pow(1.5, approachRate)) * lowApproachRateSliderVisibilityFactor; return traceableBonus; } From f2047819a88868ffd63455293b732840f3e6e0f8 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:07:25 +0300 Subject: [PATCH 078/121] Rescale wide snap aim bonus (#37411) Low bpm wide angle aim nerf, high bpm wide angle aim buff --- .../Difficulty/Evaluators/Aim/SnapAimEvaluator.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs index a373df706cd5..556b4b361be4 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim { public static class SnapAimEvaluator { - private const double wide_angle_multiplier = 1.05; + private const double wide_angle_multiplier = 9.67; private const double acute_angle_multiplier = 2.41; private const double slider_multiplier = 1.5; private const double velocity_change_multiplier = 0.9; @@ -87,7 +87,18 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Penalize angle repetition. It is important to do it _before_ multiplying by velocity because we compare raw wideness here wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcAngleWideness(lastAngle), 3))); - wideAngleBonus *= velocityInfluence; + // Rescaling velocity for the wide angle bonus + const double wide_angle_time_scale = 1.45; + double wideAngleCurrVelocity = currDistance / Math.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale); + double wideAnglePrevVelocity = prevDistance / Math.Pow(osuLastObj.AdjustedDeltaTime, wide_angle_time_scale); + + if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) + { + double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance; + wideAngleCurrVelocity = Math.Max(wideAngleCurrVelocity, sliderDistance / Math.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale)); + } + + wideAngleBonus *= Math.Min(wideAngleCurrVelocity, wideAnglePrevVelocity); if (osuLast2Obj != null) { From af9f9e771a062be3369f09ab3dd2c090454e5fee Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:05:11 +0300 Subject: [PATCH 079/121] Use Mehs in the okAdjustment (#37264) I'm not sure why they were excluded initially, so creating this PR as a potential fix If there's an actual reason for this then this PR is probably unnecessary Co-authored-by: James Wilson Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- .../Difficulty/OsuPerformanceCalculator.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index dc3d9cd19d54..6ad5c98c3fb5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -391,19 +391,21 @@ private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes att private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes) { - if (!usingClassicSliderAccuracy || countOk == 0) + int nonMissMistakes = countOk + countMeh; + + if (!usingClassicSliderAccuracy || nonMissMistakes == 0) return 0; double missedComboPercent = 1.0 - (double)scoreMaxCombo / attributes.MaxCombo; - double estimatedSliderBreaks = Math.Min(countOk, effectiveMissCount * topWeightedSliderFactor); + double estimatedSliderBreaks = Math.Min(nonMissMistakes, effectiveMissCount * topWeightedSliderFactor); - // Scores with more Oks are more likely to have slider breaks. - double okAdjustment = ((countOk - estimatedSliderBreaks) + 0.5) / countOk; + // Scores with more Oks and Mehs are more likely to have slider breaks. + double nonMissMistakeAdjustment = ((nonMissMistakes - estimatedSliderBreaks) + 0.5) / nonMissMistakes; // There is a low probability of extra slider breaks on effective miss counts close to 1, as score based calculations are good at indicating if only a single break occurred. estimatedSliderBreaks *= DifficultyCalculationUtils.Smoothstep(effectiveMissCount, 1, 2); - return estimatedSliderBreaks * okAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15); + return estimatedSliderBreaks * nonMissMistakeAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15); } /// From a8628fdc0c770b4bac0ac4900427bcebf58c1197 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:17:51 +0300 Subject: [PATCH 080/121] Remove flow acute bonus velocity sqrt (#37436) It's not really needed anymore and the added benefit is that circular alt gets slightly buffed --------- Co-authored-by: James Wilson --- .../Difficulty/Evaluators/Aim/AgilityEvaluator.cs | 2 +- .../Difficulty/Evaluators/Aim/FlowAimEvaluator.cs | 3 +-- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs index 8e3c9d01bc98..7584be237e9b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs @@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim { public static class AgilityEvaluator { - private const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.2; // 1.25 circles distance between centers + private const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.2; // 1.2 circles distance between centers /// /// Evaluates the difficulty of fast aiming diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index 30f45a3226a5..2919f1816417 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -75,8 +75,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with if (osuCurrObj.Angle != null) { // Acute angles are also hard to flow - // We square root velocity to make acute angle switches in streams aren't having difficulty higher than snap - flowDifficulty += Math.Sqrt(currVelocity) * + flowDifficulty += currVelocity * SnapAimEvaluator.CalcAngleAcuteness(osuCurrObj.Angle.Value) * overlappedNotesWeight; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 535953336ce2..1775e8a497fd 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -33,7 +33,7 @@ public Aim(Mod[] mods, bool includeSliders) private double skillMultiplierSnap => 70.9; private double skillMultiplierAgility => 2.35; - private double skillMultiplierFlow => 243.0; + private double skillMultiplierFlow => 242.0; private double skillMultiplierTotal => 1.12; private double combinedSnapNormExponent => 1.2; From 8d079a679087f3eae565903407711c10f0683867 Mon Sep 17 00:00:00 2001 From: Wulpey Date: Thu, 23 Apr 2026 23:33:44 +0300 Subject: [PATCH 081/121] Nerf linear spacings in osu!catch (#37287) This change targets patterns which have no spacing variability and visually are linear. Such patterns don't require additional inputs and you can walk/dash through them. In particular, [this map](https://osu.ppy.sh/beatmapsets/2485027#fruits/5454338) (ab)uses them to the point it was temporarily unranked. The curve for the nerf was chosen arbitrarily, it just seems to do the job. Co-authored-by: James Wilson --- .../Evaluators/MovementEvaluator.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs index 8c44cd35693c..f5790c849bc9 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs @@ -44,6 +44,30 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) / (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 6) / sqrtStrain; } + // Linear spacing nerf. + double linearSpacingCount = 0; + + for (int i = 0; i < Math.Min(current.Index, 10); i++) + { + var catchPrevObj = (CatchDifficultyHitObject)catchCurrent.Previous(i); + + // Only same direction movements matter as they do not take any additional inputs. + if (Math.Sign(catchCurrent.DistanceMoved) != Math.Sign(catchPrevObj.DistanceMoved) || catchCurrent.DistanceMoved == 0 || catchPrevObj.DistanceMoved == 0) + break; + + double currentSpacing = Math.Abs(catchCurrent.DistanceMoved / catchCurrent.StrainTime); + double prevSpacing = Math.Abs(catchPrevObj.DistanceMoved / catchPrevObj.StrainTime); + + double relativeDifference = Math.Abs(currentSpacing / prevSpacing - 1); + + if (relativeDifference > 0.05) + break; + + linearSpacingCount++; + } + + distanceAddition *= Math.Pow(0.7, linearSpacingCount); + // Bonus for edge dashes. if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f) { From 5b073e65281e6ab68ff20e18aa74ffaefee649e0 Mon Sep 17 00:00:00 2001 From: Natelytle <92956514+Natelytle@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:54:43 -0400 Subject: [PATCH 082/121] Standardize difficulty variable naming (#37460) All occurrences of a difficulty variable in an evaluator are now called evalNameDifficulty, of the raw output of this in a skill are now referred to as difficulty, and of the strain adjusted versions are referred to as strain. Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- .../Evaluators/Aim/AgilityEvaluator.cs | 8 ++++---- .../Evaluators/Aim/SnapAimEvaluator.cs | 18 +++++++++--------- .../Evaluators/FlashlightEvaluator.cs | 14 +++++++------- .../Difficulty/Evaluators/ReadingEvaluator.cs | 6 +++--- .../Evaluators/Speed/SpeedEvaluator.cs | 6 +++--- .../Difficulty/Skills/Reading.cs | 8 ++++---- .../Difficulty/Skills/Speed.cs | 12 ++++++------ 7 files changed, 36 insertions(+), 36 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs index 7584be237e9b..8d0b9d5e4bbc 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs @@ -28,13 +28,13 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double distanceScaled = Math.Min(distance, distance_cap) / distance_cap; - double strain = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime; + double agilityDifficulty = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime; - strain *= Math.Pow(osuCurrObj.SmallCircleBonus, 1.5); + agilityDifficulty *= Math.Pow(osuCurrObj.SmallCircleBonus, 1.5); - strain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); + agilityDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); - return strain; + return agilityDifficulty; } private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.2, ms / 1000)); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs index 556b4b361be4..f923e2e186c1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -55,10 +55,10 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance; double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; - double aimStrain = currVelocity; // Start strain with regular velocity. + double snapDifficulty = currVelocity; // Start difficulty with regular velocity. // Penalize angle repetition. - aimStrain *= vectorAngleRepetition(osuCurrObj, osuLastObj); + snapDifficulty *= vectorAngleRepetition(osuCurrObj, osuLastObj); if (osuCurrObj.Angle != null && osuLastObj.Angle != null) { @@ -116,7 +116,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with } // Add in acute angle bonus or wide angle bonus, whichever is larger. - aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier); + snapDifficulty += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier); // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle // https://www.desmos.com/calculator/dp0v0nvowc @@ -128,7 +128,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with * Math.Pow(DifficultyCalculationUtils.ReverseLerp(prevDistance, diameter * 3, diameter), 1.8) * DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); - aimStrain += wiggleBonus * wiggle_multiplier; + snapDifficulty += wiggleBonus * wiggle_multiplier; } if (Math.Max(prevVelocity, currVelocity) != 0) @@ -150,22 +150,22 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Penalize for rhythm changes. velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2); - aimStrain += velocityChangeBonus * velocity_change_multiplier; + snapDifficulty += velocityChangeBonus * velocity_change_multiplier; } // Reward sliders based on velocity. if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance) { double sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; - aimStrain += (sliderBonus < 1 ? sliderBonus : Math.Pow(sliderBonus, 0.75)) * slider_multiplier; + snapDifficulty += (sliderBonus < 1 ? sliderBonus : Math.Pow(sliderBonus, 0.75)) * slider_multiplier; } // Apply high circle size bonus - aimStrain *= osuCurrObj.SmallCircleBonus; + snapDifficulty *= osuCurrObj.SmallCircleBonus; - aimStrain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); + snapDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); - return aimStrain; + return snapDifficulty; } private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.65))); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs index e828eba1cc61..9fca929d8b1a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs @@ -44,7 +44,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnly double smallDistNerf = 1.0; double cumulativeStrainTime = 0.0; - double result = 0.0; + double flashlightDifficulty = 0.0; OsuDifficultyHitObject lastObj = osuCurrent; @@ -72,7 +72,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnly // Bonus based on how visible the object is. double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, mods.OfType().Any(m => !m.OnlyFadeApproachCircles.Value))); - result += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime; + flashlightDifficulty += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime; if (currentObj.Angle != null && osuCurrent.Angle != null) { @@ -85,14 +85,14 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnly lastObj = currentObj; } - result = Math.Pow(smallDistNerf * result, 2.0); + flashlightDifficulty = Math.Pow(smallDistNerf * flashlightDifficulty, 2.0); // Additional bonus for Hidden due to there being no approach circles. if (mods.OfType().Any()) - result *= 1.0 + hidden_bonus; + flashlightDifficulty *= 1.0 + hidden_bonus; // Nerf patterns with repeated angles. - result *= min_angle_multiplier + (1.0 - min_angle_multiplier) / (angleRepeatCount + 1.0); + flashlightDifficulty *= min_angle_multiplier + (1.0 - min_angle_multiplier) / (angleRepeatCount + 1.0); double sliderBonus = 0.0; @@ -112,9 +112,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnly sliderBonus /= (osuSlider.RepeatCount + 1); } - result += sliderBonus * slider_multiplier; + flashlightDifficulty += sliderBonus * slider_multiplier; - return result; + return flashlightDifficulty; } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs index fe6d49661b9c..c27977e06287 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -46,12 +46,12 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd double preemptDifficulty = calculatePreemptDifficulty(velocity, constantAngleNerfFactor, currObj.Preempt); - double difficulty = DifficultyCalculationUtils.Norm(1.5, preemptDifficulty, hiddenDifficulty, noteDensityDifficulty); + double readingDifficulty = DifficultyCalculationUtils.Norm(1.5, preemptDifficulty, hiddenDifficulty, noteDensityDifficulty); // Having less time to process information is harder - difficulty *= highBpmBonus(currObj.AdjustedDeltaTime); + readingDifficulty *= highBpmBonus(currObj.AdjustedDeltaTime); - return difficulty; + return readingDifficulty; } /// diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs index 65aab9e4bd64..3e3b63e9241c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs @@ -44,12 +44,12 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2); // Base difficulty with all bonuses - double difficulty = (1 + speedBonus) * 1000 / strainTime; + double speedDifficulty = (1 + speedBonus) * 1000 / strainTime; - difficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); + speedDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); // Apply penalty if there's doubletappable doubles - return difficulty * doubletapness; + return speedDifficulty * doubletapness; } private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, ms / 1000)); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index 414cf1f1f9b8..0e77e08e2860 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -26,7 +26,7 @@ public Reading(Mod[] mods) hasHiddenMod = mods.OfType().Any(m => !m.OnlyFadeApproachCircles.Value); } - private double currentDifficulty; + private double currentStrain; private double skillMultiplier => 2.5; private double strainDecayBase => 0.8; @@ -39,11 +39,11 @@ protected override double ObjectDifficultyOf(DifficultyHitObject current) double decay = strainDecay(current.DeltaTime); - currentDifficulty *= decay; + currentStrain *= decay; - currentDifficulty += ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * (1 - decay) * skillMultiplier; + currentStrain += ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * (1 - decay) * skillMultiplier; - return currentDifficulty; + return currentStrain; } protected override void ApplyDifficultyTransformation(double[] difficulties) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index cdd01187030d..16e5d5f5b9ca 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -23,7 +23,7 @@ public class Speed : HarmonicSkill private readonly List sliderStrains = new List(); - private double currentDifficulty; + private double currentStrain; private double strainDecayBase => 0.3; @@ -41,17 +41,17 @@ protected override double ObjectDifficultyOf(DifficultyHitObject current) { double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); - currentDifficulty *= decay; - currentDifficulty += SpeedEvaluator.EvaluateDifficultyOf(current) * (1 - decay) * skillMultiplier; + currentStrain *= decay; + currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current) * (1 - decay) * skillMultiplier; double currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); - double totalDifficulty = currentDifficulty * currentRhythm; + double totalStrain = currentStrain * currentRhythm; if (current.BaseObject is Slider) - sliderStrains.Add(totalDifficulty); + sliderStrains.Add(totalStrain); - return totalDifficulty; + return totalStrain; } public double RelevantNoteCount() From b30f10f232ead34f1184a8c82ae051e0d266b179 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Mon, 4 May 2026 12:45:20 +0300 Subject: [PATCH 083/121] Move mod-based difficulty adjustments to skills (#37621) First part of getting rid of the RatingCalculator. Values changes are expected, but apart from TD every other mod adjustment is unranked so it doesn't really matter much --- .../Difficulty/OsuDifficultyCalculator.cs | 2 +- .../Difficulty/OsuRatingCalculator.cs | 71 +------------------ .../Difficulty/Skills/Aim.cs | 26 +++++-- .../Difficulty/Skills/Flashlight.cs | 35 ++++++++- .../Difficulty/Skills/Reading.cs | 25 ++++++- .../Difficulty/Skills/Speed.cs | 22 ++++-- 6 files changed, 98 insertions(+), 83 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 65d708cb3050..2aef0fdfc2ba 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -90,7 +90,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) : 1; - var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, overallDifficulty); + var osuRatingCalculator = new OsuRatingCalculator(totalHits, overallDifficulty); double aimRating = osuRatingCalculator.ComputeAimRating(aimDifficultyValue); double speedRating = osuRatingCalculator.ComputeSpeedRating(speedDifficultyValue); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index 9dc9b1afa430..349677b2a8dd 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -2,10 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; -using osu.Game.Rulesets.Difficulty.Utils; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Difficulty { @@ -13,30 +9,19 @@ public class OsuRatingCalculator { private const double difficulty_multiplier = 0.0675; - private readonly Mod[] mods; private readonly int totalHits; private readonly double overallDifficulty; - public OsuRatingCalculator(Mod[] mods, int totalHits, double overallDifficulty) + public OsuRatingCalculator(int totalHits, double overallDifficulty) { - this.mods = mods; this.totalHits = totalHits; this.overallDifficulty = overallDifficulty; } public double ComputeAimRating(double aimDifficultyValue) { - if (mods.Any(m => m is OsuModAutopilot)) - return 0; - double aimRating = Math.Pow(aimDifficultyValue, 0.63) * 0.02275; - if (mods.Any(m => m is OsuModMagnetised)) - { - float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; - aimRating *= 1.0 - magnetisedStrength; - } - double ratingMultiplier = 1.0; // It is important to consider accuracy difficulty when scaling with accuracy. @@ -47,42 +32,13 @@ public double ComputeAimRating(double aimDifficultyValue) public double ComputeSpeedRating(double speedDifficultyValue) { - if (mods.Any(m => m is OsuModRelax)) - return 0; - - double speedRating = CalculateDifficultyRating(speedDifficultyValue); - - if (mods.Any(m => m is OsuModAutopilot)) - speedRating *= 0.5; - - if (mods.Any(m => m is OsuModMagnetised)) - { - // reduce speed rating because of the speed distance scaling, with maximum reduction being 0.7x - float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; - speedRating *= 1.0 - magnetisedStrength * 0.3; - } - - return speedRating; + return CalculateDifficultyRating(speedDifficultyValue); } public double ComputeReadingRating(double readingDifficultyValue) { double readingRating = CalculateDifficultyRating(readingDifficultyValue); - if (mods.Any(m => m is OsuModTouchDevice)) - readingRating = Math.Pow(readingRating, 0.8); - - if (mods.Any(m => m is OsuModRelax)) - readingRating *= 0.6; - else if (mods.Any(m => m is OsuModAutopilot)) - readingRating *= 0.3; - - if (mods.Any(m => m is OsuModMagnetised)) - { - float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; - readingRating *= 1.0 - magnetisedStrength; - } - double ratingMultiplier = 1.0; ratingMultiplier *= 0.75 + Math.Pow(Math.Max(0, overallDifficulty), 2.2) / 800; @@ -92,31 +48,8 @@ public double ComputeReadingRating(double readingDifficultyValue) public double ComputeFlashlightRating(double flashlightDifficultyValue) { - if (!mods.Any(m => m is OsuModFlashlight)) - return 0; - double flashlightRating = CalculateDifficultyRating(flashlightDifficultyValue); - if (mods.Any(m => m is OsuModTouchDevice)) - flashlightRating = Math.Pow(flashlightRating, 0.8); - - if (mods.Any(m => m is OsuModRelax)) - flashlightRating *= 0.7; - else if (mods.Any(m => m is OsuModAutopilot)) - flashlightRating *= 0.4; - - if (mods.Any(m => m is OsuModMagnetised)) - { - float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; - flashlightRating *= 1.0 - magnetisedStrength; - } - - if (mods.Any(m => m is OsuModDeflate)) - { - float deflateInitialScale = mods.OfType().First().StartScale.Value; - flashlightRating *= Math.Clamp(DifficultyCalculationUtils.ReverseLerp(deflateInitialScale, 11, 1), 0.1, 1); - } - double ratingMultiplier = 1.0; // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 1775e8a497fd..9ad9026ac5ee 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -57,21 +57,35 @@ protected override double CalculateInitialStrain(double time, DifficultyHitObjec protected override double StrainValueAt(DifficultyHitObject current) { + if (Mods.Any(m => m is OsuModAutopilot)) + return 0; + double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); + currentStrain *= decay; + currentStrain += calculateModAdjustedDifficulty(current) * (1 - decay); + + if (current.BaseObject is Slider) + sliderStrains.Add(currentStrain); + + return currentStrain; + } + + private double calculateModAdjustedDifficulty(DifficultyHitObject current) + { double snapDifficulty = SnapAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierSnap; double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skillMultiplierAgility; double flowDifficulty = FlowAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierFlow; double totalDifficulty = calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty); - currentStrain *= decay; - currentStrain += totalDifficulty * (1 - decay); - - if (current.BaseObject is Slider) - sliderStrains.Add(currentStrain); + if (Mods.Any(m => m is OsuModMagnetised)) + { + float magnetisedStrength = Mods.OfType().First().AttractionStrength.Value; + totalDifficulty *= 1.0 - magnetisedStrength; + } - return currentStrain; + return totalDifficulty; } private double calculateTotalValue(double snapDifficulty, double agilityDifficulty, double flowDifficulty) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index 44c39cbb9cd0..59ea3b56eaf5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -5,8 +5,10 @@ using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; +using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { @@ -31,12 +33,43 @@ public Flashlight(Mod[] mods) protected override double StrainValueAt(DifficultyHitObject current) { + if (!Mods.Any(m => m is OsuModFlashlight)) + return 0; + currentStrain *= strainDecay(current.DeltaTime); - currentStrain += FlashlightEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier; + currentStrain += calculateModAdjustedDifficulty(current) * skillMultiplier; return currentStrain; } + private double calculateModAdjustedDifficulty(DifficultyHitObject current) + { + double difficulty = FlashlightEvaluator.EvaluateDifficultyOf(current, Mods); + + if (Mods.Any(m => m is OsuModTouchDevice)) + difficulty = Math.Pow(difficulty, 0.9); + + if (Mods.Any(m => m is OsuModMagnetised)) + { + float magnetisedStrength = Mods.OfType().First().AttractionStrength.Value; + difficulty *= 1.0 - magnetisedStrength; + } + + if (Mods.Any(m => m is OsuModDeflate)) + { + float deflateInitialScale = Mods.OfType().First().StartScale.Value; + difficulty *= Math.Clamp(DifficultyCalculationUtils.ReverseLerp(deflateInitialScale, 11, 1), 0.1, 1); + } + + if (Mods.Any(m => m is OsuModRelax)) + difficulty *= 0.7; + + if (Mods.Any(m => m is OsuModAutopilot)) + difficulty *= 0.4; + + return difficulty; + } + public override double DifficultyValue() => GetCurrentStrainPeaks().Sum(); public static double DifficultyToPerformance(double difficulty) => 25 * Math.Pow(difficulty, 2); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index 0e77e08e2860..2b006f4a8662 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -40,12 +40,33 @@ protected override double ObjectDifficultyOf(DifficultyHitObject current) double decay = strainDecay(current.DeltaTime); currentStrain *= decay; - - currentStrain += ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * (1 - decay) * skillMultiplier; + currentStrain += calculateModAdjustedDifficulty(current) * (1 - decay) * skillMultiplier; return currentStrain; } + private double calculateModAdjustedDifficulty(DifficultyHitObject current) + { + double difficulty = ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod); + + if (Mods.Any(m => m is OsuModTouchDevice)) + difficulty = Math.Pow(difficulty, 0.89); + + if (Mods.Any(m => m is OsuModMagnetised)) + { + float magnetisedStrength = Mods.OfType().First().AttractionStrength.Value; + difficulty *= 1.0 - magnetisedStrength; + } + + if (Mods.Any(m => m is OsuModRelax)) + difficulty *= 0.4; + + if (Mods.Any(m => m is OsuModAutopilot)) + difficulty *= 0.1; + + return difficulty; + } + protected override void ApplyDifficultyTransformation(double[] difficulties) { const double reduced_difficulty_base_line = 0.0; // Assume the first seconds are completely memorised diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 16e5d5f5b9ca..d51632a7ffad 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -3,14 +3,15 @@ using System; using System.Collections.Generic; -using osu.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Objects; using System.Linq; +using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { @@ -39,10 +40,13 @@ public Speed(Mod[] mods) protected override double ObjectDifficultyOf(DifficultyHitObject current) { + if (Mods.Any(m => m is OsuModRelax)) + return 0; + double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); currentStrain *= decay; - currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current) * (1 - decay) * skillMultiplier; + currentStrain += calculateModAdjustedDifficulty(current) * (1 - decay) * skillMultiplier; double currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); @@ -54,6 +58,16 @@ protected override double ObjectDifficultyOf(DifficultyHitObject current) return totalStrain; } + private double calculateModAdjustedDifficulty(DifficultyHitObject current) + { + double difficulty = SpeedEvaluator.EvaluateDifficultyOf(current); + + if (Mods.Any(m => m is OsuModAutopilot)) + difficulty *= 0.5; + + return difficulty; + } + public double RelevantNoteCount() { if (ObjectDifficulties.Count == 0) From bf405fb9b009e854a51409f17dc1404be09f0b5c Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Mon, 4 May 2026 23:29:33 +0300 Subject: [PATCH 084/121] Another batch of small rhythm evaluation fixes (#37609) ## [Reduce rhythm effective ratio for patterns that are speeding up](https://github.com/ppy/osu/commit/56f66abf82b687d88e6fe83928d6dfa0f657b0a0) Assuming the same ratio difficulty, speeding up rhythms are a bit easier to play than slowing down ones ## [Reduce rhythm complexity sum if the final island is long](https://github.com/ppy/osu/commit/912d81cbfedee286b634c4bcb9c4e286d91a3d88) Currently due to how the rhythm complexity sum works rhythm difficulty gets applied to most of the object's island - historical decay isn't enough to counter difficulty carryover so we end up having non-zero rhythm difficulty on long consistent rhythm patterns (for example if a stream starts with an unusual ratio it will have non-zero rhythm difficulty regardless of its length, even if its hundreds of objects long). This applies a global difficulty reduction depending on the current object's island length. https://www.desmos.com/calculator/kvnpasbpt2 ## [Fix islands being initialised incorrectly if the rhythm section is longer than the historical cutoff](https://github.com/ppy/osu/commit/ddf0fe758b691ca6e2a157fe335a41c4048e7e9b) Due to the change above we now have to properly initialise islands at the start of the rhythm loop - currently if the object we're evaluating is a part of a >32 object consistent rhythm pattern (say a stream) it never actually gets its island properly initialised. It doesn't matter on the current system, but it does matter if we want to reduce complexity sum using island length as uninitialised island always has length of 1 so patterns that are >32 objects long stop getting their complexity reduced after the 32th object --------- Co-authored-by: James Wilson --- .../Evaluators/Speed/RhythmEvaluator.cs | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs index e2ee41162b41..4d40289c40c3 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -16,8 +16,8 @@ public static class RhythmEvaluator { private const int history_time_max = 5 * 1000; // 5 seconds private const int history_objects_max = 32; - private const double rhythm_overall_multiplier = 0.8; - private const double rhythm_ratio_multiplier = 32.0; + private const double rhythm_overall_multiplier = 0.95; + private const double rhythm_ratio_multiplier = 26.0; /// /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . @@ -70,6 +70,10 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double prevDelta = Math.Max(prevObj.DeltaTime, 1e-7); double lastDelta = Math.Max(lastObj.DeltaTime, 1e-7); + // Make sure to always have the current island initialised - if we don't do it here it will only initialise on the next rhythm change + if (island.Delta == int.MaxValue) + island = new Island((int)currDelta, deltaDifferenceEpsilon); + // calculate how much current delta difference deserves a rhythm bonus // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) double deltaDifference = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta); @@ -96,14 +100,17 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) effectiveRatio = Math.Min(sliderEffectiveRatio, effectiveRatio); } + bool isSpeedingUp = prevDelta > currDelta + deltaDifferenceEpsilon; + + if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon) + { + // island is still progressing + island.AddDelta((int)currDelta); + } + if (firstDeltaSwitch) { - if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon) - { - // island is still progressing - island.AddDelta((int)currDelta); - } - else + if (Math.Abs(prevDelta - currDelta) > deltaDifferenceEpsilon) { // bpm change is into slider, this is easy acc window if (currObj.BaseObject is Slider) @@ -122,6 +129,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (previousIsland.DeltaCount == island.DeltaCount) effectiveRatio *= 0.5; + if (isSpeedingUp) + effectiveRatio *= 0.65; + var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island)); if (islandCount != default) @@ -140,7 +150,10 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) } else { - islandCounts.Add((island, 1)); + if (island.DeltaCount > 0) + { + islandCounts.Add((island, 1)); + } } // scale down the difficulty if the object is doubletappable @@ -182,6 +195,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) prevObj = currObj; } + // If the current island is long we don't want the sum to have as big of an effect + rhythmComplexitySum *= DifficultyCalculationUtils.ReverseLerp(island.DeltaCount, 22, 3); + return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though); } From 5a801e224fecbdbb996bf6c823fed825d87b75eb Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Wed, 6 May 2026 10:12:36 +0300 Subject: [PATCH 085/121] Add distance factor to anti-doubletap (#37636) Circles that are spaced from each other can't be doubletapped, therefore they were excluded from the low OD penalty in speed. Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- .../Difficulty/Preprocessing/OsuDifficultyHitObject.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 221b2c637852..413588749ab2 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; @@ -194,7 +195,10 @@ public double GetDoubletapness(OsuDifficultyHitObject? osuNextObj) double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindow(HitResult.Great)), 5); - return 1.0 - Math.Pow(speedRatio, 1 - windowRatio); + // Can't doubletap if circles don't intersect + double distanceFactor = Math.Pow(DifficultyCalculationUtils.ReverseLerp(LazyJumpDistance, NORMALISED_DIAMETER, NORMALISED_RADIUS), 2); + + return 1.0 - Math.Pow(speedRatio, distanceFactor * (1 - windowRatio)); } return 0; From 2b1d4aa245aecdfe7660cf860b520523ce3f7e29 Mon Sep 17 00:00:00 2001 From: molneya <62799417+molneya@users.noreply.github.com> Date: Wed, 6 May 2026 20:17:17 +0800 Subject: [PATCH 086/121] Remove accuracy bonus when flashlight is enabled (#36900) When flashlight and reading weren't their own separate skills, pp bonuses were put into the aim/speed/accuracy values to make them worth something over nomod. Now that flashlight and hidden are calculated as their own skills, it doesn't make sense to provide a bonus because accurate values are calculated in the skills themselves. The global flashlight skill multiplier was increased to counteract the slight nerf of removing this bonus. Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs | 3 --- osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 6ad5c98c3fb5..c2e8b38c61b3 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -303,9 +303,6 @@ private double computeAccuracyValue(ScoreInfo score, OsuDifficultyAttributes att accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10); } - if (score.Mods.Any(m => m is OsuModFlashlight)) - accuracyValue *= 1.02; - return accuracyValue; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index 59ea3b56eaf5..df309aa7d19d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -22,7 +22,7 @@ public Flashlight(Mod[] mods) { } - private double skillMultiplier => 0.056; + private double skillMultiplier => 0.058; private double strainDecayBase => 0.15; private double currentStrain; From d18edb54b0847a64bd5d21351fba699c6456e8ec Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Wed, 6 May 2026 15:31:20 +0300 Subject: [PATCH 087/121] Make Ok adjustment formula more stable (#37263) Current formula can output drastically different values on simlar countOk / estimatedSliderBreaks amounts. For example if countOk is 10, then changing estimatedSliderBreaks from 9 to 10 is gonna change okAdjustment from 0.15 to 0.05, what is a 3 times lower resulting "estimated sliderbreaks" value. This PR is fixing that in the most simple way - by increasing the buffer constants, so very small variables won't skew the result as much. --------- Co-authored-by: James Wilson Co-authored-by: StanR Co-authored-by: StanR <8269193+stanriders@users.noreply.github.com> --- osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index c2e8b38c61b3..7a2674a10a1d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -397,7 +397,8 @@ private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, Os double estimatedSliderBreaks = Math.Min(nonMissMistakes, effectiveMissCount * topWeightedSliderFactor); // Scores with more Oks and Mehs are more likely to have slider breaks. - double nonMissMistakeAdjustment = ((nonMissMistakes - estimatedSliderBreaks) + 0.5) / nonMissMistakes; + // We add an arbitrary value to both sides of the division to make it more stable on extreme ends. + double nonMissMistakeAdjustment = (nonMissMistakes - estimatedSliderBreaks + 4.5) / (nonMissMistakes + 4); // There is a low probability of extra slider breaks on effective miss counts close to 1, as score based calculations are good at indicating if only a single break occurred. estimatedSliderBreaks *= DifficultyCalculationUtils.Smoothstep(effectiveMissCount, 1, 2); From c5871069cd467dc1c43928f681088e7b9161c7cb Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Wed, 6 May 2026 19:54:24 +0300 Subject: [PATCH 088/121] Refactor aim/speed sliderbreak estimation calculation calls (#37654) --- .../Difficulty/OsuPerformanceCalculator.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 7a2674a10a1d..3c9a1bbbf428 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -122,6 +122,12 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s effectiveMissCount = Math.Max(countMiss, effectiveMissCount); effectiveMissCount = Math.Min(totalHits, effectiveMissCount); + if (effectiveMissCount > 0) + { + aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(osuAttributes.AimTopWeightedSliderFactor, osuAttributes); + speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(osuAttributes.SpeedTopWeightedSliderFactor, osuAttributes); + } + double multiplier = PERFORMANCE_BASE_MULTIPLIER; if (score.Mods.Any(m => m is OsuModNoFail)) @@ -207,8 +213,6 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut if (effectiveMissCount > 0) { - aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.AimTopWeightedSliderFactor, attributes); - double relevantMissCount = Math.Min(effectiveMissCount + aimEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss); aimValue *= calculateMissPenalty(relevantMissCount, attributes.AimDifficultStrainCount); @@ -236,8 +240,6 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib if (effectiveMissCount > 0) { - speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.SpeedTopWeightedSliderFactor, attributes); - double relevantMissCount = Math.Min(effectiveMissCount + speedEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss); speedValue *= calculateMissPenalty(relevantMissCount, attributes.SpeedDifficultStrainCount); From a84f5a76c8bdd18da870182261bf076109cf84ee Mon Sep 17 00:00:00 2001 From: Eloise Date: Wed, 13 May 2026 20:31:15 +0100 Subject: [PATCH 089/121] osu!taiko penalty for frequent rhythm changes with long gaps (#37200) Co-authored-by: Jay Lawton Co-authored-by: James Wilson --- .../Difficulty/Evaluators/RhythmEvaluator.cs | 30 ++++++++++++++++++- .../Difficulty/TaikoDifficultyCalculator.cs | 2 +- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs index 9cbc5bf2de77..8e73744b5318 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs @@ -29,6 +29,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject hitObject) double sameRhythm = 0; double samePattern = 0; double intervalPenalty = 0; + double gapPenalty = 0; double hitWindow = hitObject.HitWindow(HitResult.Great); @@ -36,12 +37,13 @@ public static double EvaluateDifficultyOf(DifficultyHitObject hitObject) { sameRhythm += 10.0 * evaluateDifficultyOf(rhythmData.SameRhythmGroupedHitObjects, hitWindow); intervalPenalty = repeatedIntervalPenalty(rhythmData.SameRhythmGroupedHitObjects, hitWindow); + gapPenalty = longGapPenalty(rhythmData.SameRhythmGroupedHitObjects.Previous); } if (rhythmData.SamePatternsGroupedHitObjects?.FirstHitObject == hitObject) // Difficulty for SamePatternsGroupedHitObjects samePattern += 1.15 * ratioDifficulty(rhythmData.SamePatternsGroupedHitObjects.IntervalRatio); - difficulty += Math.Max(sameRhythm, samePattern) * intervalPenalty; + difficulty += Math.Max(sameRhythm, samePattern) * intervalPenalty * gapPenalty; return difficulty; } @@ -125,6 +127,32 @@ double sameInterval(SameRhythmHitObjectGrouping startObject, int intervalCount) } } + /// + /// Frequent rhythm changes containing long gaps (i.e. 1/4 + 1/6 with 1/2 gaps) award more difficulty than expected. + /// Due to limitations of the current rhythm evaluation, these cases are targeted and penalised. + /// The previous hit object grouping is used as often the rhythm change *two* rhythms after a long gap awards the unexpected difficulty. + /// + private static double longGapPenalty(SameRhythmHitObjectGrouping? previous) + { + if (previous == null) + return 1.0; + + double gapInterval = previous.FirstHitObject.DeltaTime; + double rhythmInterval = previous.HitObjectInterval ?? gapInterval; + double rhythmLength = previous.HitObjects.Count; + + // The ratio of the gap before this rhythm to the rhythm itself. + double gapRatio = gapInterval / Math.Max(rhythmInterval, 1); + + // The gap ratio normalised to represent if the gap is long. + double gapFactor = DifficultyCalculationUtils.Logistic(gapRatio, 1.75, 20); + + // The length in objects of this rhythm normalised to represent if the rhythm change is frequent enough to be penalised. + double lengthFactor = DifficultyCalculationUtils.ReverseLerp(rhythmLength, 8, 2); + + return 1.0 - 0.75 * gapFactor * lengthFactor; + } + /// /// Calculates the difficulty of a given ratio using a combination of periodic penalties and bonuses. /// diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 64af2861eca2..43edbfb15183 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty public class TaikoDifficultyCalculator : DifficultyCalculator { private const double difficulty_multiplier = 0.084375; - private const double rhythm_skill_multiplier = 0.750 * difficulty_multiplier; + private const double rhythm_skill_multiplier = 0.770 * difficulty_multiplier; private const double reading_skill_multiplier = 0.100 * difficulty_multiplier; private const double colour_skill_multiplier = 0.375 * difficulty_multiplier; private const double stamina_skill_multiplier = 0.445 * difficulty_multiplier; From 9a734d4de3df615621253643ae9fdc705d7be5e2 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Tue, 19 May 2026 17:24:50 +0500 Subject: [PATCH 090/121] Move remaining osu! rating adjustments to skills (#37623) Co-authored-by: James Wilson --- .../Difficulty/OsuDifficultyCalculator.cs | 38 +++-------- .../Difficulty/OsuPerformanceCalculator.cs | 18 +++-- .../Difficulty/OsuRatingCalculator.cs | 67 ------------------- .../Preprocessing/OsuDifficultyHitObject.cs | 13 ++++ .../Difficulty/Skills/Aim.cs | 6 +- .../Difficulty/Skills/Flashlight.cs | 23 +++++-- .../Difficulty/Skills/Reading.cs | 7 +- .../Difficulty/Skills/Speed.cs | 4 +- .../Preprocessing/DifficultyHitObject.cs | 16 +++-- 9 files changed, 78 insertions(+), 114 deletions(-) delete mode 100644 osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 2aef0fdfc2ba..8e5f23a12566 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -15,8 +15,6 @@ using osu.Game.Rulesets.Osu.Difficulty.Utils; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Scoring; -using osu.Game.Rulesets.Scoring; using osu.Game.Utils; namespace osu.Game.Rulesets.Osu.Difficulty @@ -30,22 +28,6 @@ public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { } - public static double CalculateRateAdjustedApproachRate(double approachRate, double clockRate) - { - double preempt = IBeatmapDifficultyInfo.DifficultyRange(approachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN) / clockRate; - return IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN); - } - - public static double CalculateRateAdjustedOverallDifficulty(double overallDifficulty, double clockRate) - { - HitWindows hitWindows = new OsuHitWindows(); - hitWindows.SetDifficulty(overallDifficulty); - - double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; - - return (79.5 - hitWindowGreat) / 6; - } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { if (beatmap.HitObjects.Count == 0) @@ -78,8 +60,6 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double difficultSliders = aim.GetDifficultSliders(); - double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, ModUtils.CalculateRateWithMods(mods)); - int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle); int sliderCount = beatmap.HitObjects.Count(h => h is Slider); int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); @@ -87,19 +67,17 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat int totalHits = beatmap.HitObjects.Count; double sliderFactor = aimDifficultyValue > 0 - ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) + ? calculateDifficultyRating(aimNoSlidersDifficultyValue) / calculateDifficultyRating(aimDifficultyValue) // TODO: this is intentionally left incorrect : 1; - var osuRatingCalculator = new OsuRatingCalculator(totalHits, overallDifficulty); - - double aimRating = osuRatingCalculator.ComputeAimRating(aimDifficultyValue); - double speedRating = osuRatingCalculator.ComputeSpeedRating(speedDifficultyValue); - double readingRating = osuRatingCalculator.ComputeReadingRating(readingDifficultyValue); + double aimRating = calculateAimDifficultyRating(aimDifficultyValue); + double speedRating = calculateDifficultyRating(speedDifficultyValue); + double readingRating = calculateDifficultyRating(readingDifficultyValue); double flashlightRating = 0.0; if (flashlight is not null) - flashlightRating = osuRatingCalculator.ComputeFlashlightRating(flashlight.DifficultyValue()); + flashlightRating = calculateDifficultyRating(flashlight.DifficultyValue()); double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits); double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(WorkingBeatmap.Beatmap); @@ -157,6 +135,10 @@ public static double SumCognitionDifficulty(double reading, double flashlight) return DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, reading, flashlight * Math.Clamp(flashlight / reading, 0.25, 1.0)); } + private double calculateAimDifficultyRating(double difficultyValue) => Math.Pow(difficultyValue, 0.63) * 0.02275; + + private double calculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * 0.0675; + private double calculateStarRating(double basePerformance) { return Math.Cbrt(basePerformance * OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER); @@ -189,7 +171,7 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) }; if (mods.Any(h => h is OsuModFlashlight)) - skills.Add(new Flashlight(mods)); + skills.Add(new Flashlight(mods, beatmap.HitObjects.Count)); return skills.ToArray(); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 3c9a1bbbf428..4b1c56c4192d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -5,13 +5,15 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Game.Rulesets.Difficulty.Utils; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Utils; @@ -99,8 +101,8 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s okHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate; mehHitWindow = hitWindows.WindowFor(HitResult.Meh) / clockRate; - approachRate = OsuDifficultyCalculator.CalculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate); - overallDifficulty = OsuDifficultyCalculator.CalculateRateAdjustedOverallDifficulty(difficulty.OverallDifficulty, clockRate); + approachRate = calculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate); + overallDifficulty = (79.5 - greatHitWindow) / 6; drainRate = difficulty.DrainRate; double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes); @@ -536,6 +538,12 @@ private double calculateTraceableBonus(double sliderFactor = 1) private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.93 / (missCount / (4 * Math.Log(difficultStrainCount)) + 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 double calculateRateAdjustedApproachRate(double approachRate, double clockRate) + { + double preempt = IBeatmapDifficultyInfo.DifficultyRange(approachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN) / clockRate; + return IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN); + } + 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.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs deleted file mode 100644 index 349677b2a8dd..000000000000 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; - -namespace osu.Game.Rulesets.Osu.Difficulty -{ - public class OsuRatingCalculator - { - private const double difficulty_multiplier = 0.0675; - - private readonly int totalHits; - private readonly double overallDifficulty; - - public OsuRatingCalculator(int totalHits, double overallDifficulty) - { - this.totalHits = totalHits; - this.overallDifficulty = overallDifficulty; - } - - public double ComputeAimRating(double aimDifficultyValue) - { - double aimRating = Math.Pow(aimDifficultyValue, 0.63) * 0.02275; - - double ratingMultiplier = 1.0; - - // It is important to consider accuracy difficulty when scaling with accuracy. - ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500; - - return aimRating * Math.Cbrt(ratingMultiplier); - } - - public double ComputeSpeedRating(double speedDifficultyValue) - { - return CalculateDifficultyRating(speedDifficultyValue); - } - - public double ComputeReadingRating(double readingDifficultyValue) - { - double readingRating = CalculateDifficultyRating(readingDifficultyValue); - - double ratingMultiplier = 1.0; - - ratingMultiplier *= 0.75 + Math.Pow(Math.Max(0, overallDifficulty), 2.2) / 800; - - return readingRating * Math.Cbrt(ratingMultiplier); - } - - public double ComputeFlashlightRating(double flashlightDifficultyValue) - { - double flashlightRating = CalculateDifficultyRating(flashlightDifficultyValue); - - double ratingMultiplier = 1.0; - - // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. - ratingMultiplier *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) + - (totalHits > 200 ? 0.2 * Math.Min(1.0, (totalHits - 200) / 200.0) : 0.0); - - // It is important to consider accuracy difficulty when scaling with accuracy. - ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500; - - return flashlightRating * Math.Sqrt(ratingMultiplier); - } - - public static double CalculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * difficulty_multiplier; - } -} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 413588749ab2..6e1cb16b4ef8 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -128,6 +128,19 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// public double SmallCircleBonus { get; private set; } + /// + /// Object's immediate OverallDifficulty value calculated from the raw hitwindow. + /// + public double OverallDifficulty + { + get + { + double hitWindowGreat = RawHitWindow(HitResult.Great) / ClockRate; + + return (79.5 - hitWindowGreat) / 6; + } + } + private readonly OsuDifficultyHitObject? lastLastDifficultyObject; private readonly OsuDifficultyHitObject? lastDifficultyObject; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 9ad9026ac5ee..cdf651fe122f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -63,7 +63,7 @@ protected override double StrainValueAt(DifficultyHitObject current) double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); currentStrain *= decay; - currentStrain += calculateModAdjustedDifficulty(current) * (1 - decay); + currentStrain += calculateAdjustedDifficulty(current) * (1 - decay); if (current.BaseObject is Slider) sliderStrains.Add(currentStrain); @@ -71,7 +71,7 @@ protected override double StrainValueAt(DifficultyHitObject current) return currentStrain; } - private double calculateModAdjustedDifficulty(DifficultyHitObject current) + private double calculateAdjustedDifficulty(DifficultyHitObject current) { double snapDifficulty = SnapAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierSnap; double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skillMultiplierAgility; @@ -85,6 +85,8 @@ private double calculateModAdjustedDifficulty(DifficultyHitObject current) totalDifficulty *= 1.0 - magnetisedStrength; } + totalDifficulty *= 0.985 + Math.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2) / 4000; + return totalDifficulty; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index df309aa7d19d..0346e8a5cc42 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -8,6 +8,7 @@ using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Difficulty.Skills @@ -17,9 +18,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Flashlight : StrainSkill { - public Flashlight(Mod[] mods) + private readonly int totalObjects; + + public Flashlight(Mod[] mods, int totalObjects) : base(mods) { + this.totalObjects = totalObjects; } private double skillMultiplier => 0.058; @@ -37,12 +41,12 @@ protected override double StrainValueAt(DifficultyHitObject current) return 0; currentStrain *= strainDecay(current.DeltaTime); - currentStrain += calculateModAdjustedDifficulty(current) * skillMultiplier; + currentStrain += calculateAdjustedDifficulty(current) * skillMultiplier; return currentStrain; } - private double calculateModAdjustedDifficulty(DifficultyHitObject current) + private double calculateAdjustedDifficulty(DifficultyHitObject current) { double difficulty = FlashlightEvaluator.EvaluateDifficultyOf(current, Mods); @@ -67,10 +71,21 @@ private double calculateModAdjustedDifficulty(DifficultyHitObject current) if (Mods.Any(m => m is OsuModAutopilot)) difficulty *= 0.4; + difficulty *= 0.985 + Math.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2) / 4000; + return difficulty; } - public override double DifficultyValue() => GetCurrentStrainPeaks().Sum(); + public override double DifficultyValue() + { + double sum = GetCurrentStrainPeaks().Sum(); + + // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. + sum *= 0.7 + 0.1 * Math.Min(1.0, totalObjects / 200.0) + + (totalObjects > 200 ? 0.2 * Math.Min(1.0, (totalObjects - 200) / 200.0) : 0.0); + + return sum; + } public static double DifficultyToPerformance(double difficulty) => 25 * Math.Pow(difficulty, 2); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index 2b006f4a8662..ce9310a1a80a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -10,6 +10,7 @@ using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Difficulty.Skills @@ -40,12 +41,12 @@ protected override double ObjectDifficultyOf(DifficultyHitObject current) double decay = strainDecay(current.DeltaTime); currentStrain *= decay; - currentStrain += calculateModAdjustedDifficulty(current) * (1 - decay) * skillMultiplier; + currentStrain += calculateAdjustedDifficulty(current) * (1 - decay) * skillMultiplier; return currentStrain; } - private double calculateModAdjustedDifficulty(DifficultyHitObject current) + private double calculateAdjustedDifficulty(DifficultyHitObject current) { double difficulty = ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod); @@ -64,6 +65,8 @@ private double calculateModAdjustedDifficulty(DifficultyHitObject current) if (Mods.Any(m => m is OsuModAutopilot)) difficulty *= 0.1; + difficulty *= 0.825 + Math.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2.2) / 1125.0; + return difficulty; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index d51632a7ffad..bc0383f074de 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -46,7 +46,7 @@ protected override double ObjectDifficultyOf(DifficultyHitObject current) double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); currentStrain *= decay; - currentStrain += calculateModAdjustedDifficulty(current) * (1 - decay) * skillMultiplier; + currentStrain += calculateAdjustedDifficulty(current) * (1 - decay) * skillMultiplier; double currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); @@ -58,7 +58,7 @@ protected override double ObjectDifficultyOf(DifficultyHitObject current) return totalStrain; } - private double calculateModAdjustedDifficulty(DifficultyHitObject current) + private double calculateAdjustedDifficulty(DifficultyHitObject current) { double difficulty = SpeedEvaluator.EvaluateDifficultyOf(current); diff --git a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs index f1ffc8b1b019..3499977b3b9a 100644 --- a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs +++ b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs @@ -84,9 +84,17 @@ public DifficultyHitObject Next(int forwardsIndex) } /// - /// Retrieves the full hit window for a . + /// Retrieves the full rate-adjusted hit window for a . /// - public virtual double HitWindow(HitResult hitResult) + public double HitWindow(HitResult hitResult) + { + return 2 * RawHitWindow(hitResult) / ClockRate; + } + + /// + /// Retrieves the hit window for a . + /// + protected virtual double RawHitWindow(HitResult hitResult) { // Try to get HitWindows from nested hit objects // This is important for objects such as Slider in osu! where the object itself has HitWindows set to Empty, but the nested SliderHead has proper hit windows @@ -97,11 +105,11 @@ public virtual double HitWindow(HitResult hitResult) if (nestedHitObject.HitWindows == HitWindows.Empty) continue; - return 2 * nestedHitObject.HitWindows.WindowFor(hitResult) / ClockRate; + return nestedHitObject.HitWindows.WindowFor(hitResult); } } - return 2 * BaseObject.HitWindows.WindowFor(hitResult) / ClockRate; + return BaseObject.HitWindows.WindowFor(hitResult); } } } From b073cdc01e5b882b940d3e4d5bcf7b10218643c8 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Tue, 19 May 2026 17:43:30 +0500 Subject: [PATCH 091/121] Fix `sliderFactor` using incorrect aim rating conversion curve (#37624) --- osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 8e5f23a12566..eed6a87d843a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -67,7 +67,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat int totalHits = beatmap.HitObjects.Count; double sliderFactor = aimDifficultyValue > 0 - ? calculateDifficultyRating(aimNoSlidersDifficultyValue) / calculateDifficultyRating(aimDifficultyValue) // TODO: this is intentionally left incorrect + ? calculateAimDifficultyRating(aimNoSlidersDifficultyValue) / calculateAimDifficultyRating(aimDifficultyValue) : 1; double aimRating = calculateAimDifficultyRating(aimDifficultyValue); From 6aaa10cf028f3ba56b77b2d6c66bdc09bdfcd84f Mon Sep 17 00:00:00 2001 From: James Wilson Date: Wed, 20 May 2026 10:17:45 +0100 Subject: [PATCH 092/121] Update difficulty calculation tests (#37829) Final changes are now locked in, so update tests ready for PR to `master`. --- .../CatchDifficultyCalculatorTest.cs | 4 ++-- .../OsuDifficultyCalculatorTest.cs | 20 +++++++++---------- .../nan-slider-expected-conversion.json | 2 +- .../Resources/Testing/Beatmaps/nan-slider.osu | 5 +++++ .../Visual/Editing/TestSceneEditorSaving.cs | 1 - 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs index 6a70173c4a1a..2a9c2cee64ac 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs @@ -14,11 +14,11 @@ public class CatchDifficultyCalculatorTest : DifficultyCalculatorTest { protected override string ResourceAssembly => "osu.Game.Rulesets.Catch.Tests"; - [TestCase(4.0505463516206195d, 127, "diffcalc-test")] + [TestCase(4.039861734717169d, 127, "diffcalc-test")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(5.1696411260785498d, 127, "diffcalc-test")] + [TestCase(5.1527173897800873d, 127, "diffcalc-test")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new CatchModDoubleTime()); diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index 94768c15a515..0d648ae2a0be 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -15,22 +15,22 @@ public class OsuDifficultyCalculatorTest : DifficultyCalculatorTest { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests"; - [TestCase(6.6232533278125061d, 239, "diffcalc-test")] - [TestCase(1.5045783545699611d, 54, "zero-length-sliders")] - [TestCase(0.43333836671191595d, 4, "very-fast-slider")] - [TestCase(0.13841532030395723d, 2, "nan-slider")] + [TestCase(6.5243170265483581d, 239, "diffcalc-test")] + [TestCase(1.3280410795791415d, 54, "zero-length-sliders")] + [TestCase(0.40867325147697559d, 4, "very-fast-slider")] + [TestCase(0.87058175794353554d, 6, "nan-slider")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(9.6491691624112761d, 239, "diffcalc-test")] - [TestCase(1.756936832498702d, 54, "zero-length-sliders")] - [TestCase(0.57771197086735004d, 4, "very-fast-slider")] + [TestCase(9.4677607900646308d, 239, "diffcalc-test")] + [TestCase(1.6856612715618886d, 54, "zero-length-sliders")] + [TestCase(0.53588473186572561d, 4, "very-fast-slider")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); - [TestCase(6.6232533278125061d, 239, "diffcalc-test")] - [TestCase(1.5045783545699611d, 54, "zero-length-sliders")] - [TestCase(0.43333836671191595d, 4, "very-fast-slider")] + [TestCase(6.5243170265483581d, 239, "diffcalc-test")] + [TestCase(1.3280410795791415d, 54, "zero-length-sliders")] + [TestCase(0.40867325147697559d, 4, "very-fast-slider")] public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider-expected-conversion.json b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider-expected-conversion.json index 86a4a278f14a..1ce08dc13009 100755 --- a/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider-expected-conversion.json +++ b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider-expected-conversion.json @@ -1 +1 @@ -{"Mappings":[{"StartTime":77497.0,"Objects":[{"StartTime":77497.0,"EndTime":77497.0,"X":298.0,"Y":290.0},{"StartTime":77533.0,"EndTime":77533.0,"X":276.162567,"Y":293.0336}]}]} \ No newline at end of file +{"Mappings":[{"StartTime":76911.0,"Objects":[{"StartTime":76911.0,"EndTime":76911.0,"X":283.402,"Y":275.402}]},{"StartTime":77053.0,"Objects":[{"StartTime":77053.0,"EndTime":77053.0,"X":287.0515,"Y":279.0515}]},{"StartTime":77196.0,"Objects":[{"StartTime":77196.0,"EndTime":77196.0,"X":290.701019,"Y":282.701019}]},{"StartTime":77339.0,"Objects":[{"StartTime":77339.0,"EndTime":77339.0,"X":294.3505,"Y":286.3505}]},{"StartTime":77497.0,"Objects":[{"StartTime":77497.0,"EndTime":77497.0,"X":298.0,"Y":290.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":77533.0,"EndTime":77533.0,"X":276.162567,"Y":293.0336,"StackOffset":{"X":0.0,"Y":0.0}}]}]} \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider.osu b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider.osu index fa545a761484..84200f9c4aa3 100755 --- a/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider.osu +++ b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider.osu @@ -9,10 +9,15 @@ SliderMultiplier:2 SliderTickRate:1 [TimingPoints] +76911,285.7142857142857,4,1,0,100,1,8 77211,-100,4,3,50,70,0,0 77497,8.40402703648439,4,3,51,70,1,8 77497,NaN,4,3,51,70,0,8 77498,285.714285714286,4,3,51,70,1,0 [HitObjects] +298,290,76911,5,0,1:0:0:0: +298,290,77053,1,0,1:0:0:0: +298,290,77196,1,0,1:0:0:0: +298,290,77339,1,0,1:0:0:0: 298,290,77497,6,0,B|234:298|192:279|192:279|180:299|180:299|205:311|238:318|238:318|230:347|217:371|217:371|137:370|80:340|80:340|65:259|73:143|102:68|102:68|149:49|199:34|199:34|213:54|213:54|267:38|324:40|324:40|332:18|332:18|385:20|435:27|435:27|480:93|517:204|521:286|521:286|474:329|396:350|396:350|377:329|363:302|363:302|393:287|415:271|415:271|398:254|398:254|362:282|299:290,1,1723.66345596313,10|0,1:0|3:0,3:0:0:0: diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index a50f1cca2934..70b6a741fe21 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -145,7 +145,6 @@ public void TestLengthAndStarRatingUpdated() AddStep("Get working beatmap", () => working = Game.BeatmapManager.GetWorkingBeatmap(EditorBeatmap.BeatmapInfo, true)); AddAssert("Beatmap length is zero", () => working.BeatmapInfo.Length == 0); - checkDifficultyIncreased(); AddStep("Move forward", () => InputManager.Key(Key.Right)); AddStep("Place another hitcircle", () => InputManager.Click(MouseButton.Left)); From 017f57b71071d9b2772fcd53e506da852fea89c3 Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:43:39 +0500 Subject: [PATCH 093/121] Use constant difficulty for single-note rhythm islands (#38082) Intended as a fix to the timing changes mid-stream, but also buffs complex triples slightly as well --- .../Difficulty/Evaluators/Speed/RhythmEvaluator.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs index 4d40289c40c3..4a3aa951c2e6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -160,7 +160,15 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double doubletapness = prevObj.GetDoubletapness(currObj); effectiveRatio *= 1 - doubletapness * 0.75; - rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay; + if (island.DeltaCount > 1) + { + rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay; + } + else + { + // constant difficulty for single-note islands + rhythmComplexitySum += 0.7 * currHistoricalDecay; + } startRatio = effectiveRatio; From 149bf92c2ad8e9e987409d184ce871db9155a00e Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 24 Jun 2026 16:06:46 +0900 Subject: [PATCH 094/121] Remove storage of epsilon per island --- .../Evaluators/Speed/RhythmEvaluator.cs | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs index 4a3aa951c2e6..d842405c8a85 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -31,8 +31,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindow(HitResult.Great) * 0.3; - var island = new Island(deltaDifferenceEpsilon); - var previousIsland = new Island(deltaDifferenceEpsilon); + var island = new Island(int.MaxValue); + var previousIsland = new Island(int.MaxValue); // we can't use dictionary here because we need to compare island with a tolerance // which is impossible to pass into the hash comparer @@ -72,7 +72,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // Make sure to always have the current island initialised - if we don't do it here it will only initialise on the next rhythm change if (island.Delta == int.MaxValue) - island = new Island((int)currDelta, deltaDifferenceEpsilon); + island = new Island((int)currDelta); // calculate how much current delta difference deserves a rhythm bonus // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) @@ -117,7 +117,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) effectiveRatio *= 0.5; // repeated island polarity (2 -> 4, 3 -> 5) - if (island.IsSimilarPolarity(previousIsland)) + if (island.IsSimilarPolarity(previousIsland, deltaDifferenceEpsilon)) effectiveRatio *= 0.5; // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. @@ -132,21 +132,21 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (isSpeedingUp) effectiveRatio *= 0.65; - var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island)); + (Island Island, int Count) tuple = islandCounts.FirstOrDefault(x => x.Island.AlmostEquals(island, deltaDifferenceEpsilon)); - if (islandCount != default) + if (tuple != default) { - int countIndex = islandCounts.IndexOf(islandCount); + int countIndex = islandCounts.IndexOf(tuple); // only add island to island counts if they're going one after another - if (previousIsland.Equals(island)) - islandCount.Count++; + if (previousIsland.AlmostEquals(island, deltaDifferenceEpsilon)) + tuple.Count++; // repeated island (ex: triplet -> triplet) double power = DifficultyCalculationUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33); - effectiveRatio *= Math.Min(3.0 / islandCount.Count, Math.Pow(1.0 / islandCount.Count, power)); + effectiveRatio *= Math.Min(3.0 / tuple.Count, Math.Pow(1.0 / tuple.Count, power)); - islandCounts[countIndex] = (islandCount.Island, islandCount.Count); + islandCounts[countIndex] = (tuple.Island, tuple.Count); } else { @@ -177,7 +177,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (prevDelta + deltaDifferenceEpsilon < currDelta) // we're slowing down, stop counting firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. - island = new Island((int)currDelta, deltaDifferenceEpsilon); + island = new Island((int)currDelta); } } else if (prevDelta > currDelta + deltaDifferenceEpsilon) // we're speeding up @@ -196,7 +196,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) startRatio = effectiveRatio; - island = new Island((int)currDelta, deltaDifferenceEpsilon); + island = new Island((int)currDelta); } lastObj = prevObj; @@ -217,25 +217,20 @@ private static double getEffectiveRatio(double deltaDifference) return 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction)); } - private class Island : IEquatable + /// + /// An island is a thing. I'm not sure what thing it is, but it's definitely a thing. + /// TODO: document this stuff please. + /// + private class Island { - private readonly double deltaDifferenceEpsilon; + public int Delta { get; private set; } + public int DeltaCount { get; private set; } = 1; - public Island(double epsilon) + public Island(int delta) { - deltaDifferenceEpsilon = epsilon; - } - - public Island(int delta, double epsilon) - { - deltaDifferenceEpsilon = epsilon; Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME); - DeltaCount++; } - public int Delta { get; private set; } = int.MaxValue; - public int DeltaCount { get; private set; } - public void AddDelta(int delta) { if (Delta == int.MaxValue) @@ -244,22 +239,22 @@ public void AddDelta(int delta) DeltaCount++; } - public bool IsSimilarPolarity(Island other) + public bool IsSimilarPolarity(Island other, double epsilon) { // single delta islands shouldn't be compared if (DeltaCount <= 1 || other.DeltaCount <= 1) return false; - return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon && + return Math.Abs(Delta - other.Delta) < epsilon && DeltaCount % 2 == other.DeltaCount % 2; } - public bool Equals(Island? other) + public bool AlmostEquals(Island? other, double epsilon) { if (other == null) return false; - return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon && + return Math.Abs(Delta - other.Delta) < epsilon && DeltaCount == other.DeltaCount; } From af2b16374e3deb0118adaabd4eff9123399b81c0 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 24 Jun 2026 17:05:12 +0900 Subject: [PATCH 095/121] Fix incorrect null checks --- .../Evaluators/Aim/SnapAimEvaluator.cs | 16 +++++++++------- .../Difficulty/Evaluators/ReadingEvaluator.cs | 7 +++---- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs index f923e2e186c1..31e1bd1054a1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; @@ -16,7 +15,10 @@ public static class SnapAimEvaluator private const double acute_angle_multiplier = 2.41; private const double slider_multiplier = 1.5; private const double velocity_change_multiplier = 0.9; - private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation + + // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation + private const double wiggle_multiplier = 1.02; + private const double maximum_repetition_nerf = 0.15; private const double maximum_vector_influence = 0.5; @@ -181,18 +183,18 @@ private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuD for (int index = 0; index < note_limit; index++) { - var loopObj = (OsuDifficultyHitObject)current.Previous(index); + OsuDifficultyHitObject? prevObj = (OsuDifficultyHitObject)current.Previous(index); - if (loopObj.IsNull()) + if (prevObj == null) break; // Only consider vectors in the same jump section, stopping to change rhythm ruins momentum - if (Math.Max(current.AdjustedDeltaTime, loopObj.AdjustedDeltaTime) > 1.1 * Math.Min(current.AdjustedDeltaTime, loopObj.AdjustedDeltaTime)) + if (Math.Max(current.AdjustedDeltaTime, prevObj.AdjustedDeltaTime) > 1.1 * Math.Min(current.AdjustedDeltaTime, prevObj.AdjustedDeltaTime)) break; - if (loopObj.NormalisedVectorAngle.IsNotNull() && current.NormalisedVectorAngle.IsNotNull()) + if (prevObj.NormalisedVectorAngle != null && current.NormalisedVectorAngle != null) { - double angleDifference = Math.Abs(current.NormalisedVectorAngle.Value - loopObj.NormalisedVectorAngle.Value); + double angleDifference = Math.Abs(current.NormalisedVectorAngle.Value - prevObj.NormalisedVectorAngle.Value); // Refer to this desmos for tuning, constants need to be precise so that values stay within the range of 0 and 1. // https://www.desmos.com/calculator/a8jesv5sv2 constantAngleCount += Math.Cos(8 * Math.Min(double.DegreesToRadians(11.25), angleDifference)); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs index c27977e06287..888fdbd4a8db 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; @@ -169,7 +168,7 @@ private static IEnumerable retrievePastVisibleObjects(Os { OsuDifficultyHitObject hitObject = (OsuDifficultyHitObject)current.Previous(i); - if (hitObject.IsNull() || + if (hitObject == null || current.StartTime - hitObject.StartTime > reading_window_size || hitObject.StartTime < current.StartTime - current.Preempt) // Current object not visible at the time object needs to be clicked break; @@ -219,13 +218,13 @@ private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current) { var loopObj = (OsuDifficultyHitObject)current.Previous(index); - if (loopObj.IsNull()) + if (loopObj == null) break; // Account less for objects that are close to the time limit. double longIntervalFactor = 1 - DifficultyCalculationUtils.ReverseLerp(loopObj.AdjustedDeltaTime, maximum_angle_relevancy_time, minimum_angle_relevancy_time); - if (loopObj.Angle.IsNotNull() && current.Angle.IsNotNull()) + if (loopObj.Angle != null && current.Angle != null) { double angleDifference = Math.Abs(current.Angle.Value - loopObj.Angle.Value); double angleDifferenceAlternating = Math.PI; From 7be83480eb6f730c684b87b9824d3a00e36be28b Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 24 Jun 2026 18:45:17 +0900 Subject: [PATCH 096/121] Fix incorrectly defined constants --- .../Difficulty/Skills/Aim.cs | 26 +++++++++---------- .../Difficulty/Skills/Flashlight.cs | 8 +++--- .../Difficulty/Skills/Reading.cs | 8 +++--- .../Difficulty/Skills/Speed.cs | 9 +++---- .../Difficulty/Skills/Stamina.cs | 8 +++--- 5 files changed, 29 insertions(+), 30 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index cdf651fe122f..938bac918cb0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -31,11 +31,11 @@ public Aim(Mod[] mods, bool includeSliders) private double currentStrain; - private double skillMultiplierSnap => 70.9; - private double skillMultiplierAgility => 2.35; - private double skillMultiplierFlow => 242.0; - private double skillMultiplierTotal => 1.12; - private double combinedSnapNormExponent => 1.2; + private const double skill_multiplier_snap = 70.9; + private const double skill_multiplier_agility = 2.35; + private const double skill_multiplier_flow = 242.0; + private const double skill_multiplier_total = 1.12; + private const double combined_snap_norm_exponent = 1.2; /// /// The number of sections with the highest strains, which the peak strain reductions will apply to. @@ -46,7 +46,7 @@ public Aim(Mod[] mods, bool includeSliders) /// /// The baseline multiplier applied to the section with the biggest strain. /// - private double reducedStrainBaseline => 0.727; + private const double reduced_strain_baseline = 0.727; private readonly List sliderStrains = new List(); @@ -73,9 +73,9 @@ protected override double StrainValueAt(DifficultyHitObject current) private double calculateAdjustedDifficulty(DifficultyHitObject current) { - double snapDifficulty = SnapAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierSnap; - double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skillMultiplierAgility; - double flowDifficulty = FlowAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierFlow; + double snapDifficulty = SnapAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skill_multiplier_snap; + double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skill_multiplier_agility; + double flowDifficulty = FlowAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skill_multiplier_flow; double totalDifficulty = calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty); @@ -95,7 +95,7 @@ private double calculateTotalValue(double snapDifficulty, double agilityDifficul // We compare flow to combined snap and agility because snap by itself doesn't have enough difficulty to be above flow on streams // Agility on the other hand is supposed to measure the rate of cursor velocity changes while snapping // So snapping every circle on a stream requires an enormous amount of agility at which point it's easier to flow - double combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combinedSnapNormExponent, snapDifficulty, agilityDifficulty); + double combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combined_snap_norm_exponent, snapDifficulty, agilityDifficulty); double pSnap = calculateSnapFlowProbability(flowDifficulty / combinedSnapDifficulty); double pFlow = 1 - pSnap; @@ -104,7 +104,7 @@ private double calculateTotalValue(double snapDifficulty, double agilityDifficul { // we don't adjust agility here since agility represents TD difficulty in a decent enough way snapDifficulty = Math.Pow(snapDifficulty, 0.89); - combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combinedSnapNormExponent, snapDifficulty, agilityDifficulty); + combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combined_snap_norm_exponent, snapDifficulty, agilityDifficulty); } if (Mods.Any(m => m is OsuModRelax)) @@ -115,7 +115,7 @@ private double calculateTotalValue(double snapDifficulty, double agilityDifficul double totalDifficulty = combinedSnapDifficulty * pSnap + flowDifficulty * pFlow; - double totalStrain = totalDifficulty * skillMultiplierTotal; + double totalStrain = totalDifficulty * skill_multiplier_total; return totalStrain; } @@ -232,7 +232,7 @@ private IEnumerable getReducedStrainPeaks() double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((time + addedTime) / reducedSectionTime, 0, 1))); strains.Add(new StrainPeak( - strain.Value * Interpolation.Lerp(reducedStrainBaseline, 1.0, scale), + strain.Value * Interpolation.Lerp(reduced_strain_baseline, 1.0, scale), Math.Min(chunk_size, strain.SectionLength - addedTime) )); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index 0346e8a5cc42..f325aaa0ce4a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -26,12 +26,12 @@ public Flashlight(Mod[] mods, int totalObjects) this.totalObjects = totalObjects; } - private double skillMultiplier => 0.058; - private double strainDecayBase => 0.15; + private const double skill_multiplier = 0.058; + private const double strain_decay_base = 0.15; private double currentStrain; - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + private double strainDecay(double ms) => Math.Pow(strain_decay_base, ms / 1000); protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime); @@ -41,7 +41,7 @@ protected override double StrainValueAt(DifficultyHitObject current) return 0; currentStrain *= strainDecay(current.DeltaTime); - currentStrain += calculateAdjustedDifficulty(current) * skillMultiplier; + currentStrain += calculateAdjustedDifficulty(current) * skill_multiplier; return currentStrain; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index ce9310a1a80a..6f81c8204217 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -29,10 +29,10 @@ public Reading(Mod[] mods) private double currentStrain; - private double skillMultiplier => 2.5; - private double strainDecayBase => 0.8; + private const double skill_multiplier = 2.5; + private const double strain_decay_base = 0.8; - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + private double strainDecay(double ms) => Math.Pow(strain_decay_base, ms / 1000); protected override double ObjectDifficultyOf(DifficultyHitObject current) { @@ -41,7 +41,7 @@ protected override double ObjectDifficultyOf(DifficultyHitObject current) double decay = strainDecay(current.DeltaTime); currentStrain *= decay; - currentStrain += calculateAdjustedDifficulty(current) * (1 - decay) * skillMultiplier; + currentStrain += calculateAdjustedDifficulty(current) * (1 - decay) * skill_multiplier; return currentStrain; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index bc0383f074de..92cece6bf2dd 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -20,13 +20,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : HarmonicSkill { - private double skillMultiplier => 1.16; - private readonly List sliderStrains = new List(); private double currentStrain; - private double strainDecayBase => 0.3; + private const double skill_multiplier = 1.16; + private const double strain_decay_base = 0.3; protected override double HarmonicScale => 20; protected override double DecayExponent => 0.9; @@ -36,7 +35,7 @@ public Speed(Mod[] mods) { } - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + private double strainDecay(double ms) => Math.Pow(strain_decay_base, ms / 1000); protected override double ObjectDifficultyOf(DifficultyHitObject current) { @@ -46,7 +45,7 @@ protected override double ObjectDifficultyOf(DifficultyHitObject current) double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); currentStrain *= decay; - currentStrain += calculateAdjustedDifficulty(current) * (1 - decay) * skillMultiplier; + currentStrain += calculateAdjustedDifficulty(current) * (1 - decay) * skill_multiplier; double currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs index 5e18163fe0ec..75836713f884 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs @@ -16,8 +16,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// public class Stamina : StrainSkill { - private double skillMultiplier => 1.1; - private double strainDecayBase => 0.4; + private const double skill_multiplier = 1.1; + private const double strain_decay_base = 0.4; public readonly bool SingleColourStamina; private readonly bool isConvert; @@ -37,12 +37,12 @@ public Stamina(Mod[] mods, bool singleColourStamina, bool isConvert) this.isConvert = isConvert; } - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + private double strainDecay(double ms) => Math.Pow(strain_decay_base, ms / 1000); protected override double StrainValueAt(DifficultyHitObject current) { currentStrain *= strainDecay(current.DeltaTime); - double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; + double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) * skill_multiplier; // Safely prevents previous strains from shifting as new notes are added. var currentObject = current as TaikoDifficultyHitObject; From 3a0d78a8c967af4dbaabfb3d47343129e7996cb1 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 25 Jun 2026 16:45:32 +0900 Subject: [PATCH 097/121] Refactor `CalculateNestedScorePerObject` to avoid array storage and multiple iterations --- .../Difficulty/Utils/LegacyScoreUtils.cs | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs b/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs index df1683fb2946..183f2e53c4fb 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs @@ -19,25 +19,28 @@ public static double CalculateNestedScorePerObject(IBeatmap beatmap, int objectC const double big_tick_score = 30; const double small_tick_score = 10; - var sliders = beatmap.HitObjects.OfType().ToArray(); - - // 1 for head, 1 for tail - int amountOfBigTicks = sliders.Length * 2; - - // Add slider repeats - amountOfBigTicks += sliders.Select(s => s.RepeatCount).Sum(); - - int amountOfSmallTicks = sliders.Select(s => s.NestedHitObjects.Count(nho => nho is SliderTick)).Sum(); - - double sliderScore = amountOfBigTicks * big_tick_score + amountOfSmallTicks * small_tick_score; - + int amountOfBigTicks = 0; + int amountOfSmallTicks = 0; double spinnerScore = 0; - foreach (var spinner in beatmap.HitObjects.OfType()) + foreach (var obj in beatmap.HitObjects) { - spinnerScore += calculateSpinnerScore(spinner); + switch (obj) + { + case Slider s: + // 1 for head, 1 for tail, plus repeats + amountOfBigTicks += 2 + s.RepeatCount; + amountOfSmallTicks += s.NestedHitObjects.Count(nho => nho is SliderTick); + break; + + case Spinner sp: + spinnerScore += calculateSpinnerScore(sp); + break; + } } + double sliderScore = amountOfBigTicks * big_tick_score + amountOfSmallTicks * small_tick_score; + return (sliderScore + spinnerScore) / objectCount; } From 06577a2e789c3910ea3ab2dfd042c7ea80348f4c Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 25 Jun 2026 20:18:29 +0900 Subject: [PATCH 098/121] Initial tidy pass of `RhythmEvaluator` This is... something. --- .../Evaluators/Speed/RhythmEvaluator.cs | 36 +++++++++---------- .../Preprocessing/OsuDifficultyHitObject.cs | 23 ++++++------ 2 files changed, 28 insertions(+), 31 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs index d842405c8a85..def13de1fc50 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -50,12 +50,13 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) rhythmStart++; OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)current.Previous(rhythmStart); - OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)current.Previous(rhythmStart + 1); + OsuDifficultyHitObject prevPrevObj = (OsuDifficultyHitObject)current.Previous(rhythmStart + 1); // we go from the furthest object back to the current one for (int i = rhythmStart; i > 0; i--) { OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1); + if (currObj.BaseObject is Spinner) continue; @@ -66,9 +67,12 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count. // Use custom cap value to ensure that at this point delta time is actually zero - double currDelta = Math.Max(currObj.DeltaTime, 1e-7); - double prevDelta = Math.Max(prevObj.DeltaTime, 1e-7); - double lastDelta = Math.Max(lastObj.DeltaTime, 1e-7); + const double delta_min_value = 1e-7; + + double currDelta = Math.Max(currObj.DeltaTime, delta_min_value); + double prevDelta = Math.Max(prevObj.DeltaTime, delta_min_value); + + double currPrevDeltaDelta = Math.Abs(prevDelta - currDelta); // Make sure to always have the current island initialised - if we don't do it here it will only initialise on the next rhythm change if (island.Delta == int.MaxValue) @@ -81,7 +85,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // reduce ratio bonus if delta difference is too big double differenceMultiplier = Math.Clamp(2.0 - deltaDifference / 8.0, 0.0, 1.0); - double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon); + double windowPenalty = Math.Clamp((currPrevDeltaDelta - deltaDifferenceEpsilon) / deltaDifferenceEpsilon, 0, 1); double effectiveRatio = getEffectiveRatio(deltaDifference) * windowPenalty * differenceMultiplier; @@ -100,9 +104,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) effectiveRatio = Math.Min(sliderEffectiveRatio, effectiveRatio); } - bool isSpeedingUp = prevDelta > currDelta + deltaDifferenceEpsilon; - - if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon) + if (currPrevDeltaDelta < deltaDifferenceEpsilon) { // island is still progressing island.AddDelta((int)currDelta); @@ -110,7 +112,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (firstDeltaSwitch) { - if (Math.Abs(prevDelta - currDelta) > deltaDifferenceEpsilon) + if (currPrevDeltaDelta > deltaDifferenceEpsilon) { // bpm change is into slider, this is easy acc window if (currObj.BaseObject is Slider) @@ -121,7 +123,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) effectiveRatio *= 0.5; // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. - if (lastDelta > prevDelta + deltaDifferenceEpsilon && prevDelta > currDelta + deltaDifferenceEpsilon) + if (Math.Max(prevPrevObj.DeltaTime, delta_min_value) > prevDelta + deltaDifferenceEpsilon && prevDelta > currDelta + deltaDifferenceEpsilon) effectiveRatio *= 0.125; // repeated island size (ex: triplet -> triplet) @@ -129,6 +131,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (previousIsland.DeltaCount == island.DeltaCount) effectiveRatio *= 0.5; + bool isSpeedingUp = prevDelta > currDelta + deltaDifferenceEpsilon; + if (isSpeedingUp) effectiveRatio *= 0.65; @@ -148,12 +152,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) islandCounts[countIndex] = (tuple.Island, tuple.Count); } - else + else if (island.DeltaCount > 0) { - if (island.DeltaCount > 0) - { - islandCounts.Add((island, 1)); - } + islandCounts.Add((island, 1)); } // scale down the difficulty if the object is doubletappable @@ -172,11 +173,10 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) startRatio = effectiveRatio; - previousIsland = island; - if (prevDelta + deltaDifferenceEpsilon < currDelta) // we're slowing down, stop counting firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. + previousIsland = island; island = new Island((int)currDelta); } } @@ -199,7 +199,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) island = new Island((int)currDelta); } - lastObj = prevObj; + prevPrevObj = prevObj; prevObj = currObj; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 6e1cb16b4ef8..22c26f2b0b79 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -196,25 +196,22 @@ public double OpacityAt(double time, bool hidden) /// /// Returns how possible is it to doubletap this object together with the next one and get perfect judgement in range from 0 to 1 /// - public double GetDoubletapness(OsuDifficultyHitObject? osuNextObj) + public double GetDoubletapness(OsuDifficultyHitObject? nextObj) { - if (osuNextObj != null) - { - double currDeltaTime = Math.Max(1, DeltaTime); - double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime); + if (nextObj == null) return 0; - double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); + double currDeltaTime = Math.Max(1, DeltaTime); + double nextDeltaTime = Math.Max(1, nextObj.DeltaTime); - double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); - double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindow(HitResult.Great)), 5); + double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); - // Can't doubletap if circles don't intersect - double distanceFactor = Math.Pow(DifficultyCalculationUtils.ReverseLerp(LazyJumpDistance, NORMALISED_DIAMETER, NORMALISED_RADIUS), 2); + double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); + double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindow(HitResult.Great)), 5); - return 1.0 - Math.Pow(speedRatio, distanceFactor * (1 - windowRatio)); - } + // Can't doubletap if circles don't intersect + double distanceFactor = Math.Pow(DifficultyCalculationUtils.ReverseLerp(LazyJumpDistance, NORMALISED_DIAMETER, NORMALISED_RADIUS), 2); - return 0; + return 1.0 - Math.Pow(speedRatio, distanceFactor * (1 - windowRatio)); } private void setDistances(double clockRate) From c90e24656d1ce7ed23d377a51ac4dd854ab06a96 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 25 Jun 2026 17:13:29 +0900 Subject: [PATCH 099/121] Pre-allocate `DifficultyHitObject`s list length of known length --- osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs | 2 +- osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs | 2 +- osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index 75db566009da..9260ef44da23 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -49,7 +49,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I { CatchHitObject? lastObject = null; - List objects = new List(); + List objects = new List(beatmap.HitObjects.Count); double clockRate = ModUtils.CalculateRateWithMods(mods); diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index 1bfa3bec6d7c..9b8e79edd167 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -72,7 +72,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I LegacySortHelper.Sort(sortedObjects, Comparer.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime))); - List objects = new List(); + List objects = new List(beatmap.HitObjects.Count); List[] perColumnObjects = new List[totalColumns]; for (int column = 0; column < totalColumns; column++) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index eed6a87d843a..860f62709702 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -146,7 +146,7 @@ private double calculateStarRating(double basePerformance) protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { - List objects = new List(); + List objects = new List(beatmap.HitObjects.Count); double clockRate = ModUtils.CalculateRateWithMods(mods); diff --git a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs index e3c5b92b54ad..4da5ba13cb6b 100644 --- a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs +++ b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs @@ -178,7 +178,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { - List objects = new List(); + List objects = new List(beatmap.HitObjects.Count); double clockRate = ModUtils.CalculateRateWithMods(mods); From 967ac387b87f160424a09a845d860b2de0f5ce01 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 26 Jun 2026 17:49:51 +0900 Subject: [PATCH 100/121] Remove dead code This is never used. Maybe it will be in the future but it's basically copy-pasted version of `Aim` so let's just keep things simple for now. --- .../Skills/VariableLengthStrainSkill.cs | 47 ------------------- 1 file changed, 47 deletions(-) diff --git a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs index a45af7453983..e4890bd722fe 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs @@ -192,53 +192,6 @@ private void startNewSectionFrom(double time, DifficultyHitObject current) /// public IEnumerable GetCurrentStrainPeaks() => strainPeaks.Append(new StrainPeak(currentSectionPeak, currentSectionEnd - currentSectionBegin)); - /// - /// Returns the calculated difficulty value representing all s that have been processed up to this point. - /// - public override double DifficultyValue() - { - double difficulty = 0; - - // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). - // These sections will not contribute to the difficulty. - var peaks = GetCurrentStrainPeaks().Where(p => p.Value > 0); - - List strains = peaks.OrderByDescending(p => (p.Value, p.SectionLength)).ToList(); - - // Time is measured in units of strains - double time = 0; - - // Difficulty is a continuous weighted sum of the sorted strains - for (int i = 0; i < strains.Count; i++) - { - /* Weighting function can be thought of as: - b - ∫ DecayWeight^x dx - a - where a = startTime and b = endTime - - Technically, the function below has been slightly modified from the equation above. - The real function would be - double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime)) - ... - return difficulty / Math.Log(1 / DecayWeight) - E.g. for a DecayWeight of 0.9, we're multiplying by 10 instead of 9.49122... - - This change makes it so that a map composed solely of MaxSectionLength chunks will have the exact same value when summed in this class and StrainSkill. - Doing this ensures the relationship between strain values and difficulty values remains the same between the two classes. - */ - double startTime = time; - double endTime = time + strains[i].SectionLength; - - double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime); - - difficulty += strains[i].Value * weight; - time = endTime; - } - - return difficulty / (1 - DecayWeight); - } - /// /// Calculates the number of strains weighted against the top strain. /// The result is scaled by clock rate as it affects the total number of strains. From 30ab9c922346dded4cf5c78800fce41c1d068bc0 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 26 Jun 2026 17:56:46 +0900 Subject: [PATCH 101/121] Rename weird "doubletapness" to something more appropriate --- .../Difficulty/Evaluators/Speed/RhythmEvaluator.cs | 3 +-- .../Difficulty/Evaluators/Speed/SpeedEvaluator.cs | 4 ++-- .../Difficulty/Preprocessing/OsuDifficultyHitObject.cs | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs index def13de1fc50..397d001dfb73 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -158,8 +158,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) } // scale down the difficulty if the object is doubletappable - double doubletapness = prevObj.GetDoubletapness(currObj); - effectiveRatio *= 1 - doubletapness * 0.75; + effectiveRatio *= 1 - prevObj.CalculateDoubleTapFeasibility(currObj) * 0.75; if (island.DeltaCount > 1) { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs index 3e3b63e9241c..436e9ee481e8 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs @@ -30,7 +30,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) var osuCurrObj = (OsuDifficultyHitObject)current; double strainTime = osuCurrObj.AdjustedDeltaTime; - double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0)); + double doubleTapFeasibility = 1.0 - osuCurrObj.CalculateDoubleTapFeasibility((OsuDifficultyHitObject?)osuCurrObj.Next(0)); // Cap deltatime to the OD 300 hitwindow. // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. @@ -49,7 +49,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) speedDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); // Apply penalty if there's doubletappable doubles - return speedDifficulty * doubletapness; + return speedDifficulty * doubleTapFeasibility; } private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, ms / 1000)); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 22c26f2b0b79..132cf515ff0e 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -196,7 +196,7 @@ public double OpacityAt(double time, bool hidden) /// /// Returns how possible is it to doubletap this object together with the next one and get perfect judgement in range from 0 to 1 /// - public double GetDoubletapness(OsuDifficultyHitObject? nextObj) + public double CalculateDoubleTapFeasibility(OsuDifficultyHitObject? nextObj) { if (nextObj == null) return 0; From 209634cc576ba6896ff12d3df85a8a6b2c6a205b Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 26 Jun 2026 18:00:12 +0900 Subject: [PATCH 102/121] "Note" -> "Object" --- .../Difficulty/OsuDifficultyCalculator.cs | 2 +- .../Difficulty/Skills/Reading.cs | 4 +-- .../Difficulty/Skills/Speed.cs | 10 +++---- .../Difficulty/Skills/HarmonicSkill.cs | 26 +++++++++---------- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 860f62709702..c56acadc2ad2 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -48,7 +48,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double speedDifficultStrainCount = speed.CountTopWeightedObjectDifficulties(speedDifficultyValue); double readingDifficultNoteCount = reading.CountTopWeightedObjectDifficulties(readingDifficultyValue); - double speedNotes = speed.RelevantNoteCount(); + double speedNotes = speed.RelevantObjectCount(); double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders(aimNoSlidersDifficultyValue); double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains(aimNoSlidersDifficultyValue); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index 6f81c8204217..24a26d51e904 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -110,10 +110,10 @@ public override double CountTopWeightedObjectDifficulties(double difficultyValue if (ObjectDifficulties.Count == 0) return 0.0; - if (NoteWeightSum == 0) + if (ObjectWeightSum == 0) return 0.0; - double consistentTopNote = difficultyValue / NoteWeightSum; // What would the top difficulty be if all object difficulties were identical + double consistentTopNote = difficultyValue / ObjectWeightSum; // What would the top difficulty be if all object difficulties were identical if (consistentTopNote == 0) return 0; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 92cece6bf2dd..51a05b1ac9f4 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -67,7 +67,7 @@ private double calculateAdjustedDifficulty(DifficultyHitObject current) return difficulty; } - public double RelevantNoteCount() + public double RelevantObjectCount() { if (ObjectDifficulties.Count == 0) return 0; @@ -85,16 +85,16 @@ public double CountTopWeightedSliders(double difficultyValue) if (sliderStrains.Count == 0) return 0; - if (NoteWeightSum == 0) + if (ObjectWeightSum == 0) return 0.0; - double consistentTopNote = difficultyValue / NoteWeightSum; // What would the top note be if all note values were identical + double consistentTopObject = difficultyValue / ObjectWeightSum; // What would the top note be if all note values were identical - if (consistentTopNote == 0) + if (consistentTopObject == 0) return 0; // Use a weighted sum of all notes. Constants are arbitrary and give nice values - return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopNote, 0.88, 10, 1.1)); + return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopObject, 0.88, 10, 1.1)); } } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs index cdd6048b8610..96c8fc38bc50 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs @@ -12,10 +12,10 @@ namespace osu.Game.Rulesets.Difficulty.Skills public abstract class HarmonicSkill : Skill { /// - /// The sum of note weights, calculated during summation. + /// The sum of object weights, calculated during summation. /// Required for any calculations which need to normalise difficulty value. /// - protected double NoteWeightSum; + protected double ObjectWeightSum; /// /// Scaling factor applied as HarmonicScale / (1 + index) during weight calculations. @@ -44,7 +44,7 @@ protected sealed override double ProcessInternal(DifficultyHitObject current) /// /// Transforms the object difficulties specifically for final difficulty summation. - /// This can be used to decrease weight of certain notes based on a skill-specific criteria. + /// This can be used to decrease weight of certain objects based on a skill-specific criteria. /// protected virtual void ApplyDifficultyTransformation(double[] difficulties) { @@ -55,8 +55,8 @@ public override double DifficultyValue() if (ObjectDifficulties.Count == 0) return 0; - // Notes with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). - // These notes will not contribute to the difficulty. + // Objects with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These objects will not contribute to the difficulty. double[] difficulties = ObjectDifficulties.Where(p => p > 0).ToArray(); if (difficulties.Length == 0) @@ -67,14 +67,14 @@ public override double DifficultyValue() double difficulty = 0; int index = 0; - foreach (double note in difficulties.OrderDescending()) + foreach (double obj in difficulties.OrderDescending()) { - // Use a harmonic sum that considers each note of the map according to a predefined weight. + // Use a harmonic sum that considers each object of the map according to a predefined weight. double weight = (1 + (HarmonicScale / (1 + index))) / (Math.Pow(index, DecayExponent) + 1 + (HarmonicScale / (1 + index))); - NoteWeightSum += weight; + ObjectWeightSum += weight; - difficulty += note * weight; + difficulty += obj * weight; index += 1; } @@ -89,15 +89,15 @@ public virtual double CountTopWeightedObjectDifficulties(double difficultyValue) if (ObjectDifficulties.Count == 0) return 0.0; - if (NoteWeightSum == 0) + if (ObjectWeightSum == 0) return 0.0; - double consistentTopNote = difficultyValue / NoteWeightSum; // What would the top difficulty be if all object difficulties were identical + double consistentTopObject = difficultyValue / ObjectWeightSum; // What would the top difficulty be if all object difficulties were identical - if (consistentTopNote == 0) + if (consistentTopObject == 0) return 0; - return ObjectDifficulties.Sum(d => DifficultyCalculationUtils.Logistic(d / consistentTopNote, 0.88, 10, 1.1)); + return ObjectDifficulties.Sum(d => DifficultyCalculationUtils.Logistic(d / consistentTopObject, 0.88, 10, 1.1)); } public static double DifficultyToPerformance(double difficulty) => 4.0 * Math.Pow(difficulty, 3.0); From 78c11bfe8288eb255a02eefa389746f6f0c632d9 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 26 Jun 2026 18:12:56 +0900 Subject: [PATCH 103/121] Reduce storage overheads of `OsuDifficultyHitObject` --- .../Preprocessing/OsuDifficultyHitObject.cs | 21 +++++++------------ .../Preprocessing/DifficultyHitObject.cs | 8 +++---- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 132cf515ff0e..88b9cabea006 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -45,7 +45,7 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// Time (in ms) between the object first appearing and the time it needs to be clicked. /// adjusted by clock rate. /// - public readonly double Preempt; + public double Preempt => BaseObject.TimePreempt / ClockRate; /// /// Normalised distance from the start position of the previous to the start position of this . @@ -126,7 +126,7 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// /// Selective bonus for maps with higher circle size. /// - public double SmallCircleBonus { get; private set; } + public double SmallCircleBonus => Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 70); /// /// Object's immediate OverallDifficulty value calculated from the raw hitwindow. @@ -141,22 +141,12 @@ public double OverallDifficulty } } - private readonly OsuDifficultyHitObject? lastLastDifficultyObject; - private readonly OsuDifficultyHitObject? lastDifficultyObject; - public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int index) : base(hitObject, lastObject, clockRate, objects, index) { - lastLastDifficultyObject = index > 1 ? (OsuDifficultyHitObject)objects[index - 2] : null; - lastDifficultyObject = index > 0 ? (OsuDifficultyHitObject)objects[index - 1] : null; - // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME); - LastObjectEndDeltaTime = lastDifficultyObject != null ? Math.Max(StartTime - lastDifficultyObject.EndTime, MIN_DELTA_TIME) : AdjustedDeltaTime; - - SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 70); - - Preempt = BaseObject.TimePreempt / clockRate; + LastObjectEndDeltaTime = Previous() is DifficultyHitObject last ? Math.Max(StartTime - last.EndTime, MIN_DELTA_TIME) : AdjustedDeltaTime; computeSliderCursorPosition(); setDistances(clockRate); @@ -232,6 +222,9 @@ private void setDistances(double clockRate) // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps. float scalingFactor = NORMALISED_RADIUS / (float)BaseObject.Radius; + var lastDifficultyObject = Previous() as OsuDifficultyHitObject; + var lastLastDifficultyObject = Previous(1) as OsuDifficultyHitObject; + Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition; JumpDistance = (LastObject.StackedPosition - BaseObject.StackedPosition).Length * scalingFactor; @@ -277,7 +270,7 @@ private void setDistances(double clockRate) Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastDifficultyObject); double angle = calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition); - double sliderAngle = calculateSliderAngle(lastDifficultyObject!, lastLastCursorPosition); + double sliderAngle = calculateSliderAngle(lastDifficultyObject, lastLastCursorPosition); Vector2 v = BaseObject.StackedPosition - lastCursorPosition; NormalisedVectorAngle = Math.Atan2(Math.Abs(v.Y), Math.Abs(v.X)); diff --git a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs index 3499977b3b9a..55c6060e0cd6 100644 --- a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs +++ b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs @@ -71,15 +71,15 @@ public DifficultyHitObject(HitObject hitObject, HitObject lastObject, double clo ClockRate = clockRate; } - public DifficultyHitObject Previous(int backwardsIndex) + public DifficultyHitObject Previous(int skipCount = 0) { - int index = Index - (backwardsIndex + 1); + int index = Index - (skipCount + 1); return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : null; } - public DifficultyHitObject Next(int forwardsIndex) + public DifficultyHitObject Next(int skipCount = 0) { - int index = Index + (forwardsIndex + 1); + int index = Index + (skipCount + 1); return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : null; } From c4e770dd17b5b3d4473f227c1b26666768987200 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 26 Jun 2026 18:38:09 +0900 Subject: [PATCH 104/121] Add note about `VariableLengthStrainSkill` upgrade path --- .../Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs index e4890bd722fe..3f7fdd4209b3 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs @@ -14,6 +14,10 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// Similar to , but instead of strains having a fixed length, strains can be any length. /// A new is created for each . /// + /// + /// This class intends to replace eventually as it fixes bugs with that implementation. + /// Has not yet been applied globally as it changes resultant PP values in ways which may require discretion. + /// public abstract class VariableLengthStrainSkill : Skill { /// From 22e5cbf9401f47fc9e4486ba571dddfa9a29ca77 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sun, 28 Jun 2026 15:34:36 +0900 Subject: [PATCH 105/121] Attempt to fix xmldoc references --- .../Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs index 3f7fdd4209b3..244932140003 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs @@ -36,8 +36,8 @@ public abstract class VariableLengthStrainSkill : Skill /// /// The number of `MaxSectionLength` sections calculated such that enough of the difficulty value is preserved. - /// WARNING: This should be overridden if strains are ever used outside of , - /// or if is overridden to not use the default geometric sum. This should be removed + /// WARNING: This should be overridden if strains are ever used outside of , + /// or if is overridden to not use the default geometric sum. This should be removed /// in the future when a better memory-saving technique is implemented. /// private double maxStoredSections => 11 / (1 - DecayWeight); From 4d5b268d2e75f9d5d4b199fb744ea2f2542fb854 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 26 Jun 2026 19:08:07 +0900 Subject: [PATCH 106/121] Remove unnecessary `virtual` usage --- osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index f066be0ec750..e26ce15dd26c 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -66,7 +66,7 @@ protected sealed override double ProcessInternal(DifficultyHitObject current) /// Calculates the number of strains weighted against the top strain. /// The result is scaled by clock rate as it affects the total number of strains. /// - public virtual double CountTopWeightedStrains(double difficultyValue) + public double CountTopWeightedStrains(double difficultyValue) { if (ObjectDifficulties.Count == 0) return 0.0; From 26c38a5927dc99799cfda35ab09fd165636a8dc2 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 26 Jun 2026 19:55:33 +0900 Subject: [PATCH 107/121] Avoid one final list copy --- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 938bac918cb0..1b1d0236ddf1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -241,9 +241,7 @@ private IEnumerable getReducedStrainPeaks() strainsToRemove++; } - strains.RemoveRange(0, strainsToRemove); - - return strains.OrderByDescending(p => p.Value); + return strains.Skip(strainsToRemove).OrderByDescending(p => p.Value); } } } From 4bc6a97aef65d23a23f15e9e36f87feb89c73d4a Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sun, 28 Jun 2026 15:02:18 +0900 Subject: [PATCH 108/121] Use more efficient `Math.Pow` when exponent is an integer --- osu.Game.Benchmarks/BenchmarkMathPow.cs | 27 +++++++++ .../Difficulty/CatchPerformanceCalculator.cs | 9 +-- .../Evaluators/MovementEvaluator.cs | 9 +-- .../Evaluators/OverallStrainEvaluator.cs | 2 +- .../Difficulty/Skills/Strain.cs | 3 +- .../Evaluators/Aim/AgilityEvaluator.cs | 5 +- .../Evaluators/Aim/FlowAimEvaluator.cs | 10 ++-- .../Evaluators/Aim/SnapAimEvaluator.cs | 44 +++++++-------- .../Evaluators/FlashlightEvaluator.cs | 5 +- .../Difficulty/Evaluators/ReadingEvaluator.cs | 30 +++++----- .../Evaluators/Speed/RhythmEvaluator.cs | 8 +-- .../Evaluators/Speed/SpeedEvaluator.cs | 6 +- .../Difficulty/OsuDifficultyCalculator.cs | 6 +- .../OsuLegacyScoreMissCalculator.cs | 5 +- .../Difficulty/OsuPerformanceCalculator.cs | 56 +++++++++---------- .../Preprocessing/OsuDifficultyHitObject.cs | 8 +-- .../Difficulty/Skills/Aim.cs | 18 +++--- .../Difficulty/Skills/Flashlight.cs | 10 ++-- .../Difficulty/Skills/Reading.cs | 8 +-- .../Difficulty/Skills/Speed.cs | 4 +- .../Difficulty/Evaluators/ColourEvaluator.cs | 8 +-- .../Difficulty/Evaluators/ReadingEvaluator.cs | 6 +- .../Difficulty/Evaluators/RhythmEvaluator.cs | 16 +++--- .../Difficulty/Skills/Reading.cs | 2 +- .../Difficulty/Skills/Rhythm.cs | 2 +- .../Difficulty/Skills/Stamina.cs | 7 +-- .../Difficulty/TaikoDifficultyCalculator.cs | 8 +-- .../Difficulty/TaikoPerformanceCalculator.cs | 20 +++---- .../Scoring/TaikoScoreProcessor.cs | 3 +- .../Difficulty/Skills/HarmonicSkill.cs | 7 +-- .../Difficulty/Skills/StrainDecaySkill.cs | 4 +- ...icultyCalculationUtils.cs => DiffUtils.cs} | 25 +++++++-- 32 files changed, 213 insertions(+), 168 deletions(-) create mode 100644 osu.Game.Benchmarks/BenchmarkMathPow.cs rename osu.Game/Rulesets/Difficulty/Utils/{DifficultyCalculationUtils.cs => DiffUtils.cs} (92%) diff --git a/osu.Game.Benchmarks/BenchmarkMathPow.cs b/osu.Game.Benchmarks/BenchmarkMathPow.cs new file mode 100644 index 000000000000..7148cbf2ea2c --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkMathPow.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using BenchmarkDotNet.Attributes; +using osu.Game.Rulesets.Difficulty.Utils; + +namespace osu.Game.Benchmarks +{ + public class BenchmarkMathPow : BenchmarkTest + { + [Params(0, 1, 1.25, 2.0, 3, 5)] + public double Exponent { get; set; } + + [Benchmark] + public void MathPow() + { + double _ = Math.Pow(1.299995, Exponent); + } + + [Benchmark] + public void DiffUtilsPow() + { + double _ = DiffUtils.Pow(1.299995, Exponent); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs index 4b8bcb435cd0..a9c6ce3dd729 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs @@ -6,6 +6,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; @@ -37,7 +38,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s numMiss = score.GetCountMiss() ?? 0; // HitResult.Miss PLUS HitResult.LargeTickMiss // We are heavily relying on aim in catch the beat - double value = Math.Pow(5.0 * Math.Max(1.0, catchAttributes.StarRating / 0.0049) - 4.0, 2.0) / 100000.0; + double value = DiffUtils.Pow(5.0 * Math.Max(1.0, catchAttributes.StarRating / 0.0049) - 4.0, 2.0) / 100000.0; // Longer maps are worth more. "Longer" means how many hits there are which can contribute to combo int numTotalHits = totalComboHits(); @@ -47,11 +48,11 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s (numTotalHits > 2500 ? Math.Log10(numTotalHits / 2500.0) * 0.475 : 0.0); value *= lengthBonus; - value *= Math.Pow(0.97, numMiss); + value *= DiffUtils.Pow(0.97, numMiss); // Combo scaling if (catchAttributes.MaxCombo > 0) - value *= Math.Min(Math.Pow(score.MaxCombo, 0.35) / Math.Pow(catchAttributes.MaxCombo, 0.35), 1.0); + value *= Math.Min(DiffUtils.Pow(score.MaxCombo, 0.35) / DiffUtils.Pow(catchAttributes.MaxCombo, 0.35), 1.0); var difficulty = score.BeatmapInfo!.Difficulty.Clone(); @@ -86,7 +87,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s if (score.Mods.Any(m => m is ModFlashlight)) value *= 1.35 * lengthBonus; - value *= Math.Pow(accuracy(), 5.5); + value *= DiffUtils.Pow(accuracy(), 5.5); if (score.Mods.Any(m => m is ModNoFail)) value *= Math.Max(0.90, 1.0 - 0.02 * numMiss); diff --git a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs index f5790c849bc9..12d455478a4d 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs @@ -4,6 +4,7 @@ using System; using osu.Game.Rulesets.Catch.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; namespace osu.Game.Rulesets.Catch.Difficulty.Evaluators { @@ -23,7 +24,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier); - double distanceAddition = (Math.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510); + double distanceAddition = (DiffUtils.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510); double sqrtStrain = Math.Sqrt(weightedStrainTime); double edgeDashBonus = 0; @@ -36,7 +37,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double bonusFactor = Math.Min(50, Math.Abs(catchCurrent.DistanceMoved)) / 50; double antiflowFactor = Math.Max(Math.Min(70, Math.Abs(catchLast.DistanceMoved)) / 70, 0.38); - distanceAddition += direction_change_bonus / Math.Sqrt(catchLast.StrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - Math.Pow(weightedStrainTime / 1000, 3), 0); + distanceAddition += direction_change_bonus / Math.Sqrt(catchLast.StrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - DiffUtils.Pow(weightedStrainTime / 1000, 3), 0); } // Base bonus for every movement, giving some weight to streams. @@ -66,7 +67,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) linearSpacingCount++; } - distanceAddition *= Math.Pow(0.7, linearSpacingCount); + distanceAddition *= DiffUtils.Pow(0.7, linearSpacingCount); // Bonus for edge dashes. if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f) @@ -75,7 +76,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) edgeDashBonus += 5.7; distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20) - * Math.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values + * DiffUtils.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values } // There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than diff --git a/osu.Game.Rulesets.Mania/Difficulty/Evaluators/OverallStrainEvaluator.cs b/osu.Game.Rulesets.Mania/Difficulty/Evaluators/OverallStrainEvaluator.cs index 97782f764485..526e6a42f2e7 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Evaluators/OverallStrainEvaluator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Evaluators/OverallStrainEvaluator.cs @@ -53,7 +53,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // 0.0 +--------+-+---------------> Release Difference / ms // release_threshold if (isOverlapping) - holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold); + holdAddition = DiffUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold); return (1 + holdAddition) * holdFactor; } diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs index 037b7e35110a..5b682fdd9fae 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs @@ -4,6 +4,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mania.Difficulty.Evaluators; using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; @@ -52,6 +53,6 @@ protected override double CalculateInitialStrain(double offset, DifficultyHitObj + applyDecay(overallStrain, offset - current.Previous(0).StartTime, overall_decay_base); private double applyDecay(double value, double deltaTime, double decayBase) - => value * Math.Pow(decayBase, deltaTime / 1000); + => value * DiffUtils.Pow(decayBase, deltaTime / 1000); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs index 8d0b9d5e4bbc..9ab7e996db78 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs @@ -3,6 +3,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -30,13 +31,13 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double agilityDifficulty = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime; - agilityDifficulty *= Math.Pow(osuCurrObj.SmallCircleBonus, 1.5); + agilityDifficulty *= DiffUtils.Pow(osuCurrObj.SmallCircleBonus, 1.5); agilityDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); return agilityDifficulty; } - private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.2, ms / 1000)); + private static double highBpmBonus(double ms) => 1 / (1 - DiffUtils.Pow(0.2, ms / 1000)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index 2919f1816417..af403df759fe 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -48,7 +48,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Rhythm changes are harder to flow flowDifficulty *= 1 + Math.Min(0.25, - Math.Pow((Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) - Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) / 50, 4)); + DiffUtils.Pow((Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) - Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) / 50, 4)); if (osuCurrObj.Angle != null && osuLastObj.Angle != null) { @@ -88,7 +88,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with } // Scale with ratio of difference compared to 0.5 * max dist. - double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1); + double distRatio = DiffUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1); // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. double overlapVelocityBuff = Math.Min(OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), @@ -107,10 +107,10 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with } // Final velocity is being raised to a power because flow difficulty scales harder with both high distance and time, and we want to account for that - flowDifficulty = Math.Pow(flowDifficulty, 1.45); + flowDifficulty = DiffUtils.Pow(flowDifficulty, 1.45); // Reduce difficulty for low spacing since spacing below radius is always to be flowed - return flowDifficulty * DifficultyCalculationUtils.Smootherstep(currDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); + return flowDifficulty * DiffUtils.Smootherstep(currDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); } private static double calculateOverlapFactor(OsuDifficultyHitObject first, OsuDifficultyHitObject second) @@ -120,7 +120,7 @@ private static double calculateOverlapFactor(OsuDifficultyHitObject first, OsuDi double objectRadius = firstBase.Radius; double distance = Vector2.Distance(firstBase.StackedPosition, secondBase.StackedPosition); - return Math.Clamp(1 - Math.Pow(Math.Max(distance - objectRadius, 0) / objectRadius, 2), 0, 1); + return Math.Clamp(1 - DiffUtils.Pow(Math.Max(distance - objectRadius, 0) / objectRadius, 2), 0, 1); } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs index 31e1bd1054a1..5a53b691cacb 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -77,27 +77,27 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with acuteAngleBonus = CalcAngleAcuteness(currAngle); // Penalize angle repetition. It is important to do it _before_ multiplying by anything because we compare raw acuteness here - acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAngleAcuteness(lastAngle), 3))); + acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, DiffUtils.Pow(CalcAngleAcuteness(lastAngle), 3))); // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter - acuteAngleBonus *= velocityInfluence * DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * - DifficultyCalculationUtils.Smootherstep(currDistance, 0, diameter * 2); + acuteAngleBonus *= velocityInfluence * DiffUtils.Smootherstep(DiffUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * + DiffUtils.Smootherstep(currDistance, 0, diameter * 2); } double wideAngleBonus = calcAngleWideness(currAngle); // Penalize angle repetition. It is important to do it _before_ multiplying by velocity because we compare raw wideness here - wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcAngleWideness(lastAngle), 3))); + wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, DiffUtils.Pow(calcAngleWideness(lastAngle), 3))); // Rescaling velocity for the wide angle bonus const double wide_angle_time_scale = 1.45; - double wideAngleCurrVelocity = currDistance / Math.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale); - double wideAnglePrevVelocity = prevDistance / Math.Pow(osuLastObj.AdjustedDeltaTime, wide_angle_time_scale); + double wideAngleCurrVelocity = currDistance / DiffUtils.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale); + double wideAnglePrevVelocity = prevDistance / DiffUtils.Pow(osuLastObj.AdjustedDeltaTime, wide_angle_time_scale); if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) { double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance; - wideAngleCurrVelocity = Math.Max(wideAngleCurrVelocity, sliderDistance / Math.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale)); + wideAngleCurrVelocity = Math.Max(wideAngleCurrVelocity, sliderDistance / DiffUtils.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale)); } wideAngleBonus *= Math.Min(wideAngleCurrVelocity, wideAnglePrevVelocity); @@ -123,12 +123,12 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle // https://www.desmos.com/calculator/dp0v0nvowc double wiggleBonus = velocityInfluence - * DifficultyCalculationUtils.Smootherstep(currDistance, radius, diameter) - * Math.Pow(DifficultyCalculationUtils.ReverseLerp(currDistance, diameter * 3, diameter), 1.8) - * DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) - * DifficultyCalculationUtils.Smootherstep(prevDistance, radius, diameter) - * Math.Pow(DifficultyCalculationUtils.ReverseLerp(prevDistance, diameter * 3, diameter), 1.8) - * DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); + * DiffUtils.Smootherstep(currDistance, radius, diameter) + * DiffUtils.Pow(DiffUtils.ReverseLerp(currDistance, diameter * 3, diameter), 1.8) + * DiffUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) + * DiffUtils.Smootherstep(prevDistance, radius, diameter) + * DiffUtils.Pow(DiffUtils.ReverseLerp(prevDistance, diameter * 3, diameter), 1.8) + * DiffUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); snapDifficulty += wiggleBonus * wiggle_multiplier; } @@ -142,7 +142,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with } // Scale with ratio of difference compared to 0.5 * max dist. - double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1); + double distRatio = DiffUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1); // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity)); @@ -150,7 +150,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with double velocityChangeBonus = overlapVelocityBuff * distRatio; // Penalize for rhythm changes. - velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2); + velocityChangeBonus *= DiffUtils.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2); snapDifficulty += velocityChangeBonus * velocity_change_multiplier; } @@ -159,7 +159,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance) { double sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; - snapDifficulty += (sliderBonus < 1 ? sliderBonus : Math.Pow(sliderBonus, 0.75)) * slider_multiplier; + snapDifficulty += (sliderBonus < 1 ? sliderBonus : DiffUtils.Pow(sliderBonus, 0.75)) * slider_multiplier; } // Apply high circle size bonus @@ -170,7 +170,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with return snapDifficulty; } - private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.65))); + private static double highBpmBonus(double ms) => 1 / (1 - DiffUtils.Pow(0.03, DiffUtils.Pow(ms / 1000, 0.65))); private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuDifficultyHitObject previous) { @@ -201,9 +201,9 @@ private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuD } } - double vectorRepetition = Math.Pow(Math.Min(0.5 / constantAngleCount, 1), 2); + double vectorRepetition = DiffUtils.Pow(Math.Min(0.5 / constantAngleCount, 1), 2); - double stackFactor = DifficultyCalculationUtils.Smootherstep(current.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_DIAMETER); + double stackFactor = DiffUtils.Smootherstep(current.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_DIAMETER); double currAngle = current.Angle.Value; double lastAngle = previous.Angle.Value; @@ -212,11 +212,11 @@ private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuD double baseNerf = 1 - maximum_repetition_nerf * CalcAngleAcuteness(lastAngle) * angleDifferenceAdjusted; - return Math.Pow(baseNerf + (1 - baseNerf) * vectorRepetition * maximum_vector_influence * stackFactor, 2); + return DiffUtils.Pow(baseNerf + (1 - baseNerf) * vectorRepetition * maximum_vector_influence * stackFactor, 2); } - private static double calcAngleWideness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); + private static double calcAngleWideness(double angle) => DiffUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); - public static double CalcAngleAcuteness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); + public static double CalcAngleAcuteness(double angle) => DiffUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs index 9fca929d8b1a..e39783ff8964 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Mods; @@ -85,7 +86,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnly lastObj = currentObj; } - flashlightDifficulty = Math.Pow(smallDistNerf * flashlightDifficulty, 2.0); + flashlightDifficulty = DiffUtils.Pow(smallDistNerf * flashlightDifficulty, 2); // Additional bonus for Hidden due to there being no approach circles. if (mods.OfType().Any()) @@ -102,7 +103,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnly double pixelTravelDistance = osuCurrent.LazyTravelDistance / scalingFactor; // Reward sliders based on velocity. - sliderBonus = Math.Pow(Math.Max(0.0, pixelTravelDistance / osuCurrent.TravelTime - min_velocity), 0.5); + sliderBonus = DiffUtils.Pow(Math.Max(0.0, pixelTravelDistance / osuCurrent.TravelTime - min_velocity), 0.5); // Longer sliders require more memorisation. sliderBonus *= pixelTravelDistance; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs index 888fdbd4a8db..7177857f9e69 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -45,7 +45,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd double preemptDifficulty = calculatePreemptDifficulty(velocity, constantAngleNerfFactor, currObj.Preempt); - double readingDifficulty = DifficultyCalculationUtils.Norm(1.5, preemptDifficulty, hiddenDifficulty, noteDensityDifficulty); + double readingDifficulty = DiffUtils.Norm(1.5, preemptDifficulty, hiddenDifficulty, noteDensityDifficulty); // Having less time to process information is harder readingDifficulty *= highBpmBonus(currObj.AdjustedDeltaTime); @@ -71,17 +71,17 @@ private static double calculateDensityDifficulty(OsuDifficultyHitObject? nextObj if (nextObj != null) { // Reduce difficulty if movement to next object is small - futureObjectDifficultyInfluence *= DifficultyCalculationUtils.Smootherstep(nextObj.LazyJumpDistance, 15, distance_influence_threshold); + futureObjectDifficultyInfluence *= DiffUtils.Smootherstep(nextObj.LazyJumpDistance, 15, distance_influence_threshold); } // Value higher note densities exponentially - double noteDensityDifficulty = Math.Pow(pastObjectDifficultyInfluence + futureObjectDifficultyInfluence, 1.7) * 0.4 * constantAngleNerfFactor * velocity; + double noteDensityDifficulty = DiffUtils.Pow(pastObjectDifficultyInfluence + futureObjectDifficultyInfluence, 1.7) * 0.4 * constantAngleNerfFactor * velocity; // Award only denser than average maps. noteDensityDifficulty = Math.Max(0, noteDensityDifficulty - density_difficulty_base); // Apply a soft cap to general density reading to account for partial memorization - noteDensityDifficulty = Math.Pow(noteDensityDifficulty, 0.45) * density_multiplier; + noteDensityDifficulty = DiffUtils.Pow(noteDensityDifficulty, 0.45) * density_multiplier; return noteDensityDifficulty; } @@ -98,7 +98,7 @@ private static double calculatePreemptDifficulty(double velocity, double constan { // Arbitrary curve for the base value preempt difficulty should have as approach rate increases. // https://www.desmos.com/calculator/c175335a71 - double preemptDifficulty = Math.Pow((preempt_starting_point - preempt + Math.Abs(preempt - preempt_starting_point)) / 2, 2.5) / preempt_balancing_factor; + double preemptDifficulty = DiffUtils.Pow((preempt_starting_point - preempt + Math.Abs(preempt - preempt_starting_point)) / 2, 2.5) / preempt_balancing_factor; preemptDifficulty *= constantAngleNerfFactor * velocity; @@ -120,21 +120,21 @@ private static double calculateHiddenDifficulty(OsuDifficultyHitObject currObj, double constantAngleNerfFactor) { // Higher preempt means that time spent invisible is higher too, we want to reward that - double preemptFactor = Math.Pow(currObj.Preempt, 2.2) * 0.01; + double preemptFactor = DiffUtils.Pow(currObj.Preempt, 2.2) * 0.01; // Account for both past and current densities - double densityFactor = Math.Pow(currentVisibleObjectDensity + pastObjectDifficultyInfluence, 3.3) * 3; + double densityFactor = DiffUtils.Pow(currentVisibleObjectDensity + pastObjectDifficultyInfluence, 3.3) * 3; double hiddenDifficulty = (preemptFactor + densityFactor) * constantAngleNerfFactor * velocity * 0.01; // Apply a soft cap to general HD reading to account for partial memorization - hiddenDifficulty = Math.Pow(hiddenDifficulty, 0.4) * hidden_multiplier; + hiddenDifficulty = DiffUtils.Pow(hiddenDifficulty, 0.4) * hidden_multiplier; var previousObj = (OsuDifficultyHitObject)currObj.Previous(0); // Buff perfect stacks only if current note is completely invisible at the time you click the previous note. if (currObj.LazyJumpDistance == 0 && currObj.OpacityAt(previousObj.BaseObject.StartTime, true) == 0 && previousObj.StartTime > currObj.StartTime - currObj.Preempt) - hiddenDifficulty += hidden_multiplier * 2500 / Math.Pow(currObj.AdjustedDeltaTime, 1.5); // Perfect stacks are harder the less time between notes + hiddenDifficulty += hidden_multiplier * 2500 / DiffUtils.Pow(currObj.AdjustedDeltaTime, 1.5); // Perfect stacks are harder the less time between notes return hiddenDifficulty; } @@ -148,7 +148,7 @@ private static double getPastObjectDifficultyInfluence(OsuDifficultyHitObject cu double loopDifficulty = currObj.OpacityAt(loopObj.BaseObject.StartTime, false); // When aiming an object small distances mean previous objects may be cheesed, so it doesn't matter whether they were arranged confusingly. - loopDifficulty *= DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 15, distance_influence_threshold); + loopDifficulty *= DiffUtils.Smootherstep(loopObj.LazyJumpDistance, 15, distance_influence_threshold); // Account less for objects close to the max reading window double timeBetweenCurrAndLoopObj = currObj.StartTime - loopObj.StartTime; @@ -222,7 +222,7 @@ private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current) break; // Account less for objects that are close to the time limit. - double longIntervalFactor = 1 - DifficultyCalculationUtils.ReverseLerp(loopObj.AdjustedDeltaTime, maximum_angle_relevancy_time, minimum_angle_relevancy_time); + double longIntervalFactor = 1 - DiffUtils.ReverseLerp(loopObj.AdjustedDeltaTime, maximum_angle_relevancy_time, minimum_angle_relevancy_time); if (loopObj.Angle != null && current.Angle != null) { @@ -237,14 +237,14 @@ private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current) double weight = 1.0; // Be sure that one of the angles is very sharp, when other is wide - weight *= DifficultyCalculationUtils.ReverseLerp(Math.Min(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 20, 5); - weight *= DifficultyCalculationUtils.ReverseLerp(Math.Max(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 60, 120); + weight *= DiffUtils.ReverseLerp(Math.Min(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 20, 5); + weight *= DiffUtils.ReverseLerp(Math.Max(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 60, 120); // Lerp between max angle difference and rescaled alternating difference, with more harsh scaling compared to normal difference angleDifferenceAlternating = double.Lerp(Math.PI, 0.1 * angleDifferenceAlternating, weight); } - double stackFactor = DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); + double stackFactor = DiffUtils.Smootherstep(loopObj.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); constantAngleCount += Math.Cos(3 * Math.Min(double.DegreesToRadians(30), Math.Min(angleDifference, angleDifferenceAlternating) * stackFactor)) * longIntervalFactor; } @@ -266,6 +266,6 @@ private static double getTimeNerfFactor(double deltaTime) return Math.Clamp(2 - deltaTime / (reading_window_size / 2), 0, 1); } - private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.8, ms / 1000)); + private static double highBpmBonus(double ms) => 1 / (1 - DiffUtils.Pow(0.8, ms / 1000)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs index 397d001dfb73..9a41106f2dd6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -147,8 +147,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) tuple.Count++; // repeated island (ex: triplet -> triplet) - double power = DifficultyCalculationUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33); - effectiveRatio *= Math.Min(3.0 / tuple.Count, Math.Pow(1.0 / tuple.Count, power)); + double power = DiffUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33); + effectiveRatio *= Math.Min(3.0 / tuple.Count, DiffUtils.Pow(1.0 / tuple.Count, power)); islandCounts[countIndex] = (tuple.Island, tuple.Count); } @@ -203,7 +203,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) } // If the current island is long we don't want the sum to have as big of an effect - rhythmComplexitySum *= DifficultyCalculationUtils.ReverseLerp(island.DeltaCount, 22, 3); + rhythmComplexitySum *= DiffUtils.ReverseLerp(island.DeltaCount, 22, 3); return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though); } @@ -213,7 +213,7 @@ private static double getEffectiveRatio(double deltaDifference) // Take only the fractional part of the value since we're only interested in punishing multiples double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference); - return 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction)); + return 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DiffUtils.SmoothstepBellCurve(deltaDifferenceFraction)); } /// diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs index 436e9ee481e8..8047a91f04f6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs @@ -40,8 +40,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double speedBonus = 0.0; // Add additional scaling bonus for streams/bursts higher than 200bpm - if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus) - speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2); + if (DiffUtils.MillisecondsToBPM(strainTime) > min_speed_bonus) + speedBonus = 0.75 * DiffUtils.Pow((DiffUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2); // Base difficulty with all bonuses double speedDifficulty = (1 + speedBonus) * 1000 / strainTime; @@ -52,6 +52,6 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) return speedDifficulty * doubleTapFeasibility; } - private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, ms / 1000)); + private static double highBpmBonus(double ms) => 1 / (1 - DiffUtils.Pow(0.3, ms / 1000)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index c56acadc2ad2..7afe5391df11 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -91,7 +91,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); double baseCognitionPerformance = SumCognitionDifficulty(baseReadingPerformance, baseFlashlightPerformance); - double basePerformance = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseCognitionPerformance); + double basePerformance = DiffUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseCognitionPerformance); double starRating = calculateStarRating(basePerformance); @@ -132,10 +132,10 @@ public static double SumCognitionDifficulty(double reading, double flashlight) return reading; // Nerf flashlight value in cognition sum when reading is greater than flashlight - return DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, reading, flashlight * Math.Clamp(flashlight / reading, 0.25, 1.0)); + return DiffUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, reading, flashlight * Math.Clamp(flashlight / reading, 0.25, 1.0)); } - private double calculateAimDifficultyRating(double difficultyValue) => Math.Pow(difficultyValue, 0.63) * 0.02275; + private double calculateAimDifficultyRating(double difficultyValue) => DiffUtils.Pow(difficultyValue, 0.63) * 0.02275; private double calculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * 0.0675; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs index 8bde33d292c1..f7f52b1c03f0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Scoring; @@ -117,14 +118,14 @@ private double calculateMaximumComboBasedMissCount() // If sliders in the map are hard - it's likely for player to drop sliderends // If map has easy sliders - it's more likely for player to sliderbreak - double likelyMissedSliderendPortion = 0.04 + 0.06 * Math.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2); + double likelyMissedSliderendPortion = 0.04 + 0.06 * DiffUtils.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2); // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it // In classic scores we can't know the amount of dropped sliders so we estimate it double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount); if (score.MaxCombo < fullComboThreshold) - missCount = Math.Pow(fullComboThreshold / Math.Max(1.0, score.MaxCombo), 2.5); + missCount = DiffUtils.Pow(fullComboThreshold / Math.Max(1.0, score.MaxCombo), 2.5); // In classic scores there can't be more misses than a sum of all non-perfect judgements missCount = Math.Min(missCount, totalImperfectHits); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 4b1c56c4192d..1a8e8a1529dd 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -64,7 +64,7 @@ public class OsuPerformanceCalculator : PerformanceCalculator private double aimEstimatedSliderBreaks; private double speedEstimatedSliderBreaks; - public static double DifficultyToPerformance(double difficulty) => 4.0 * Math.Pow(difficulty, 3.0); + public static double DifficultyToPerformance(double difficulty) => 4.0 * DiffUtils.Pow(difficulty, 3); public OsuPerformanceCalculator() : base(new OsuRuleset()) @@ -136,7 +136,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount); if (score.Mods.Any(m => m is OsuModSpunOut) && totalHits > 0) - multiplier *= 1.0 - Math.Pow((double)osuAttributes.SpinnerCount / totalHits, 0.85); + multiplier *= 1.0 - DiffUtils.Pow((double)osuAttributes.SpinnerCount / totalHits, 0.85); if (score.Mods.Any(h => h is OsuModRelax)) { @@ -144,7 +144,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s // we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0 // this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11) double okMultiplier = 0.75 * Math.Max(0.0, overallDifficulty > 0.0 ? 1 - overallDifficulty / 13.33 : 1.0); - double mehMultiplier = Math.Max(0.0, overallDifficulty > 0.0 ? 1 - Math.Pow(overallDifficulty / 13.33, 5) : 1.0); + double mehMultiplier = Math.Max(0.0, overallDifficulty > 0.0 ? 1 - DiffUtils.Pow(overallDifficulty / 13.33, 5) : 1.0); // As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it. effectiveMissCount = Math.Min(effectiveMissCount + countOk * okMultiplier + countMeh * mehMultiplier, totalHits); @@ -160,7 +160,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s double flashlightValue = computeFlashlightValue(score, osuAttributes); double cognitionValue = OsuDifficultyCalculator.SumCognitionDifficulty(readingValue, flashlightValue); - double totalValue = DifficultyCalculationUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, cognitionValue) * multiplier; + double totalValue = DiffUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, cognitionValue) * multiplier; return new OsuPerformanceAttributes { @@ -203,7 +203,7 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut estimateImproperlyFollowedDifficultSliders = Math.Clamp(countSliderEndsDropped + countSliderTickMiss, 0, attributes.AimDifficultSliderCount); } - double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateImproperlyFollowedDifficultSliders / attributes.AimDifficultSliderCount, 3) + attributes.SliderFactor; + double sliderNerfFactor = (1 - attributes.SliderFactor) * DiffUtils.Pow(1 - estimateImproperlyFollowedDifficultSliders / attributes.AimDifficultSliderCount, 3) + attributes.SliderFactor; aimDifficulty *= sliderNerfFactor; } @@ -222,7 +222,7 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. if (score.Mods.Any(m => m is OsuModBlinds)) - aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * drainRate * drainRate); + aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * DiffUtils.Pow(accuracy, 16)) * (1 - 0.003 * drainRate * drainRate); else if (score.Mods.Any(m => m is OsuModTraceable)) { aimValue *= 1.0 + calculateTraceableBonus(attributes.SliderFactor); @@ -258,13 +258,13 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib // An effective hit window is created based on the speed SR. The higher the speed difficulty, the shorter the hit window. // For example, a speed SR of 4.0 leads to an effective hit window of 20ms, which is OD 10. - double effectiveHitWindow = 20 * Math.Pow(4 / attributes.SpeedDifficulty, 0.35); + double effectiveHitWindow = 20 * DiffUtils.Pow(4 / attributes.SpeedDifficulty, 0.35); // Find the proportion of 300s on speed notes assuming the hit window was the effective hit window. - double effectiveAccuracy = DifficultyCalculationUtils.Erf(effectiveHitWindow / (double)speedDeviation); + double effectiveAccuracy = DiffUtils.Erf(effectiveHitWindow / (double)speedDeviation); // Scale speed value by normalized accuracy. - speedValue *= Math.Pow(effectiveAccuracy, 2); + speedValue *= DiffUtils.Pow(effectiveAccuracy, 2); return speedValue; } @@ -291,12 +291,12 @@ private double computeAccuracyValue(ScoreInfo score, OsuDifficultyAttributes att // Lots of arbitrary values from testing. // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution. - double accuracyValue = Math.Pow(1.52163, overallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83; + double accuracyValue = DiffUtils.Pow(1.52163, overallDifficulty) * DiffUtils.Pow(betterAccuracyPercentage, 24) * 2.83; // Bonus for many hitcircles - it's harder to keep good accuracy up for longer. accuracyValue *= amountHitObjectsWithAccuracy < 1000 - ? Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3) - : Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.1); + ? DiffUtils.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3) + : DiffUtils.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.1); // Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given. if (score.Mods.Any(m => m is OsuModBlinds)) @@ -304,7 +304,7 @@ private double computeAccuracyValue(ScoreInfo score, OsuDifficultyAttributes att else if (score.Mods.Any(m => m is OsuModTraceable)) { // Decrease bonus for AR > 10 - accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10); + accuracyValue *= 1 + 0.08 * DiffUtils.ReverseLerp(approachRate, 11.5, 10); } return accuracyValue; @@ -319,7 +319,7 @@ private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes a // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. if (effectiveMissCount > 0) - flashlightValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875)); + flashlightValue *= 0.97 * DiffUtils.Pow(1 - DiffUtils.Pow(effectiveMissCount / totalHits, 0.775), DiffUtils.Pow(effectiveMissCount, .875)); flashlightValue *= getComboScalingFactor(attributes); @@ -337,7 +337,7 @@ private double computeReadingValue(OsuDifficultyAttributes attributes) readingValue *= calculateMissPenalty(effectiveMissCount + aimEstimatedSliderBreaks, attributes.ReadingDifficultNoteCount); // Scale the reading value with accuracy _harshly_. - readingValue *= Math.Pow(accuracy, 3); + readingValue *= DiffUtils.Pow(accuracy, 3); return readingValue; } @@ -353,7 +353,7 @@ private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes att { // If sliders in the map are hard - it's likely for player to drop sliderends // If map has easy sliders - it's more likely for player to sliderbreak - double likelyMissedSliderendPortion = 0.04 + 0.06 * Math.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2); + double likelyMissedSliderendPortion = 0.04 + 0.06 * DiffUtils.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2); // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it // In classic scores we can't know the amount of dropped sliders so we estimate it @@ -405,9 +405,9 @@ private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, Os double nonMissMistakeAdjustment = (nonMissMistakes - estimatedSliderBreaks + 4.5) / (nonMissMistakes + 4); // There is a low probability of extra slider breaks on effective miss counts close to 1, as score based calculations are good at indicating if only a single break occurred. - estimatedSliderBreaks *= DifficultyCalculationUtils.Smoothstep(effectiveMissCount, 1, 2); + estimatedSliderBreaks *= DiffUtils.Smoothstep(effectiveMissCount, 1, 2); - return estimatedSliderBreaks * nonMissMistakeAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15); + return estimatedSliderBreaks * nonMissMistakeAdjustment * DiffUtils.Logistic(missedComboPercent, 0.33, 15); } /// @@ -459,12 +459,12 @@ private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, Os if (pLowerBound > 0.01) { // Compute deviation assuming greats and oks are normally distributed. - deviation = greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound)); + deviation = greatHitWindow / (Math.Sqrt(2) * DiffUtils.ErfInv(pLowerBound)); // Subtract the deviation provided by tails that land outside the ok hit window from the deviation computed above. // This is equivalent to calculating the deviation of a normal distribution truncated at +-okHitWindow. - double okHitWindowTailAmount = Math.Sqrt(2 / Math.PI) * okHitWindow * Math.Exp(-0.5 * Math.Pow(okHitWindow / deviation, 2)) - / (deviation * DifficultyCalculationUtils.Erf(okHitWindow / (Math.Sqrt(2) * deviation))); + double okHitWindowTailAmount = Math.Sqrt(2 / Math.PI) * okHitWindow * Math.Exp(-0.5 * DiffUtils.Pow(okHitWindow / deviation, 2)) + / (deviation * DiffUtils.Erf(okHitWindow / (Math.Sqrt(2) * deviation))); deviation *= Math.Sqrt(1 - okHitWindowTailAmount); } @@ -477,7 +477,7 @@ private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, Os // Compute and add the variance for mehs, assuming that they are uniformly distributed. double mehVariance = (mehHitWindow * mehHitWindow + okHitWindow * mehHitWindow + okHitWindow * okHitWindow) / 3; - deviation = Math.Sqrt(((relevantCountGreat + relevantCountOk) * Math.Pow(deviation, 2) + relevantCountMeh * mehVariance) / (relevantCountGreat + relevantCountOk + relevantCountMeh)); + deviation = Math.Sqrt(((relevantCountGreat + relevantCountOk) * DiffUtils.Pow(deviation, 2) + relevantCountMeh * mehVariance) / (relevantCountGreat + relevantCountOk + relevantCountMeh)); return deviation; } @@ -493,7 +493,7 @@ private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attribute // 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); + double excessSpeedDifficultyCutoff = 100 + 220 * DiffUtils.Pow(22 / speedDeviation.Value, 6.5); if (speedValue <= excessSpeedDifficultyCutoff) return 1.0; @@ -502,7 +502,7 @@ private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attribute double adjustedSpeedValue = scale * (Math.Log((speedValue - excessSpeedDifficultyCutoff) / scale + 1) + excessSpeedDifficultyCutoff / scale); // 220 UR and less are considered tapped correctly to ensure that normal scores will be punished as little as possible - double lerp = 1 - DifficultyCalculationUtils.ReverseLerp(speedDeviation.Value, 22.0, 27.0); + double lerp = 1 - DiffUtils.ReverseLerp(speedDeviation.Value, 22.0, 27.0); adjustedSpeedValue = double.Lerp(adjustedSpeedValue, speedValue, lerp); return adjustedSpeedValue / speedValue; @@ -514,8 +514,8 @@ private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attribute private double calculateTraceableBonus(double sliderFactor = 1) { // We want to reward slider aim less, more so at lower AR - double highApproachRateSliderVisibilityFactor = 0.5 + (Math.Pow(sliderFactor, 6) / 2); - double lowApproachRateSliderVisibilityFactor = Math.Pow(sliderFactor, 6); + double highApproachRateSliderVisibilityFactor = 0.5 + (DiffUtils.Pow(sliderFactor, 6) / 2); + double lowApproachRateSliderVisibilityFactor = DiffUtils.Pow(sliderFactor, 6); // Start from normal curve, rewarding lower AR up to AR7 double traceableBonus = 0.0275; @@ -527,7 +527,7 @@ private double calculateTraceableBonus(double sliderFactor = 1) // Starting from AR0 - cap values so they won't grow to infinity if (approachRate < 0) - traceableBonus += 0.025 * (1 - Math.Pow(1.5, approachRate)) * lowApproachRateSliderVisibilityFactor; + traceableBonus += 0.025 * (1 - DiffUtils.Pow(1.5, approachRate)) * lowApproachRateSliderVisibilityFactor; return traceableBonus; } @@ -536,7 +536,7 @@ private double calculateTraceableBonus(double sliderFactor = 1) // 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.93 / (missCount / (4 * Math.Log(difficultStrainCount)) + 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 double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(DiffUtils.Pow(scoreMaxCombo, 0.8) / DiffUtils.Pow(attributes.MaxCombo, 0.8), 1.0); private double calculateRateAdjustedApproachRate(double approachRate, double clockRate) { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 88b9cabea006..5f6abcf94fba 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -196,12 +196,12 @@ public double CalculateDoubleTapFeasibility(OsuDifficultyHitObject? nextObj) double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); - double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindow(HitResult.Great)), 5); + double windowRatio = DiffUtils.Pow(Math.Min(1, currDeltaTime / HitWindow(HitResult.Great)), 5); // Can't doubletap if circles don't intersect - double distanceFactor = Math.Pow(DifficultyCalculationUtils.ReverseLerp(LazyJumpDistance, NORMALISED_DIAMETER, NORMALISED_RADIUS), 2); + double distanceFactor = DiffUtils.Pow(DiffUtils.ReverseLerp(LazyJumpDistance, NORMALISED_DIAMETER, NORMALISED_RADIUS), 2); - return 1.0 - Math.Pow(speedRatio, distanceFactor * (1 - windowRatio)); + return 1.0 - DiffUtils.Pow(speedRatio, distanceFactor * (1 - windowRatio)); } private void setDistances(double clockRate) @@ -209,7 +209,7 @@ private void setDistances(double clockRate) if (BaseObject is Slider currentSlider) { // Bonus for repeat sliders until a better per nested object strain system can be achieved. - TravelDistance = LazyTravelDistance * Math.Max(1, Math.Pow(currentSlider.RepeatCount, 0.3)); + TravelDistance = LazyTravelDistance * Math.Max(1, DiffUtils.Pow(currentSlider.RepeatCount, 0.3)); TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 1b1d0236ddf1..8a31fb85b851 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -50,7 +50,7 @@ public Aim(Mod[] mods, bool includeSliders) private readonly List sliderStrains = new List(); - private double strainDecay(double ms) => Math.Pow(0.2, ms / 1000); + private double strainDecay(double ms) => DiffUtils.Pow(0.2, ms / 1000); protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime); @@ -85,7 +85,7 @@ private double calculateAdjustedDifficulty(DifficultyHitObject current) totalDifficulty *= 1.0 - magnetisedStrength; } - totalDifficulty *= 0.985 + Math.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2) / 4000; + totalDifficulty *= 0.985 + DiffUtils.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2) / 4000; return totalDifficulty; } @@ -95,7 +95,7 @@ private double calculateTotalValue(double snapDifficulty, double agilityDifficul // We compare flow to combined snap and agility because snap by itself doesn't have enough difficulty to be above flow on streams // Agility on the other hand is supposed to measure the rate of cursor velocity changes while snapping // So snapping every circle on a stream requires an enormous amount of agility at which point it's easier to flow - double combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combined_snap_norm_exponent, snapDifficulty, agilityDifficulty); + double combinedSnapDifficulty = DiffUtils.Norm(combined_snap_norm_exponent, snapDifficulty, agilityDifficulty); double pSnap = calculateSnapFlowProbability(flowDifficulty / combinedSnapDifficulty); double pFlow = 1 - pSnap; @@ -103,8 +103,8 @@ private double calculateTotalValue(double snapDifficulty, double agilityDifficul if (Mods.Any(m => m is OsuModTouchDevice)) { // we don't adjust agility here since agility represents TD difficulty in a decent enough way - snapDifficulty = Math.Pow(snapDifficulty, 0.89); - combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combined_snap_norm_exponent, snapDifficulty, agilityDifficulty); + snapDifficulty = DiffUtils.Pow(snapDifficulty, 0.89); + combinedSnapDifficulty = DiffUtils.Norm(combined_snap_norm_exponent, snapDifficulty, agilityDifficulty); } if (Mods.Any(m => m is OsuModRelax)) @@ -137,7 +137,7 @@ private static double calculateSnapFlowProbability(double ratio) if (double.IsNaN(ratio)) return 1; - return DifficultyCalculationUtils.Logistic(-k * Math.Log(ratio)); + return DiffUtils.Logistic(-k * Math.Log(ratio)); } public double GetDifficultSliders() @@ -164,7 +164,7 @@ public double CountTopWeightedSliders(double difficultyValue) return 0; // Use a weighted sum of all strains. Constants are arbitrary and give nice values - return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1)); + return sliderStrains.Sum(s => DiffUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1)); } public override double DifficultyValue() @@ -185,7 +185,7 @@ public override double DifficultyValue() Technically, the function below has been slightly modified from the equation above. The real function would be - double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime); + double weight = DiffUtils.Pow(DecayWeight, startTime) - DiffUtils.Pow(DecayWeight, endTime); ... return difficulty / Math.Log(1 / DecayWeight); E.g. for a DecayWeight of 0.9, we're multiplying by 10 instead of 9.49122... @@ -196,7 +196,7 @@ Doing this ensures the relationship between strain values and difficulty values double startTime = time; double endTime = time + strain.SectionLength / MaxSectionLength; - double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime); + double weight = DiffUtils.Pow(DecayWeight, startTime) - DiffUtils.Pow(DecayWeight, endTime); difficulty += strain.Value * weight; time = endTime; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index f325aaa0ce4a..f15e74e3fcbe 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -31,7 +31,7 @@ public Flashlight(Mod[] mods, int totalObjects) private double currentStrain; - private double strainDecay(double ms) => Math.Pow(strain_decay_base, ms / 1000); + private double strainDecay(double ms) => DiffUtils.Pow(strain_decay_base, ms / 1000); protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime); @@ -51,7 +51,7 @@ private double calculateAdjustedDifficulty(DifficultyHitObject current) double difficulty = FlashlightEvaluator.EvaluateDifficultyOf(current, Mods); if (Mods.Any(m => m is OsuModTouchDevice)) - difficulty = Math.Pow(difficulty, 0.9); + difficulty = DiffUtils.Pow(difficulty, 0.9); if (Mods.Any(m => m is OsuModMagnetised)) { @@ -62,7 +62,7 @@ private double calculateAdjustedDifficulty(DifficultyHitObject current) if (Mods.Any(m => m is OsuModDeflate)) { float deflateInitialScale = Mods.OfType().First().StartScale.Value; - difficulty *= Math.Clamp(DifficultyCalculationUtils.ReverseLerp(deflateInitialScale, 11, 1), 0.1, 1); + difficulty *= Math.Clamp(DiffUtils.ReverseLerp(deflateInitialScale, 11, 1), 0.1, 1); } if (Mods.Any(m => m is OsuModRelax)) @@ -71,7 +71,7 @@ private double calculateAdjustedDifficulty(DifficultyHitObject current) if (Mods.Any(m => m is OsuModAutopilot)) difficulty *= 0.4; - difficulty *= 0.985 + Math.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2) / 4000; + difficulty *= 0.985 + DiffUtils.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2) / 4000; return difficulty; } @@ -87,6 +87,6 @@ public override double DifficultyValue() return sum; } - public static double DifficultyToPerformance(double difficulty) => 25 * Math.Pow(difficulty, 2); + public static double DifficultyToPerformance(double difficulty) => 25 * DiffUtils.Pow(difficulty, 2); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index 24a26d51e904..bcac64ffff8f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -32,7 +32,7 @@ public Reading(Mod[] mods) private const double skill_multiplier = 2.5; private const double strain_decay_base = 0.8; - private double strainDecay(double ms) => Math.Pow(strain_decay_base, ms / 1000); + private double strainDecay(double ms) => DiffUtils.Pow(strain_decay_base, ms / 1000); protected override double ObjectDifficultyOf(DifficultyHitObject current) { @@ -51,7 +51,7 @@ private double calculateAdjustedDifficulty(DifficultyHitObject current) double difficulty = ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod); if (Mods.Any(m => m is OsuModTouchDevice)) - difficulty = Math.Pow(difficulty, 0.89); + difficulty = DiffUtils.Pow(difficulty, 0.89); if (Mods.Any(m => m is OsuModMagnetised)) { @@ -65,7 +65,7 @@ private double calculateAdjustedDifficulty(DifficultyHitObject current) if (Mods.Any(m => m is OsuModAutopilot)) difficulty *= 0.1; - difficulty *= 0.825 + Math.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2.2) / 1125.0; + difficulty *= 0.825 + DiffUtils.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2.2) / 1125.0; return difficulty; } @@ -118,7 +118,7 @@ public override double CountTopWeightedObjectDifficulties(double difficultyValue if (consistentTopNote == 0) return 0; - return ObjectDifficulties.Sum(d => DifficultyCalculationUtils.Logistic(d / consistentTopNote, 1.15, 5, 1.1)); + return ObjectDifficulties.Sum(d => DiffUtils.Logistic(d / consistentTopNote, 1.15, 5, 1.1)); } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 51a05b1ac9f4..9cabefc53e7a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -35,7 +35,7 @@ public Speed(Mod[] mods) { } - private double strainDecay(double ms) => Math.Pow(strain_decay_base, ms / 1000); + private double strainDecay(double ms) => DiffUtils.Pow(strain_decay_base, ms / 1000); protected override double ObjectDifficultyOf(DifficultyHitObject current) { @@ -94,7 +94,7 @@ public double CountTopWeightedSliders(double difficultyValue) return 0; // Use a weighted sum of all notes. Constants are arbitrary and give nice values - return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopObject, 0.88, 10, 1.1)); + return sliderStrains.Sum(s => DiffUtils.Logistic(s / consistentTopObject, 0.88, 10, 1.1)); } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs index d8d30e3fef50..5e9089651698 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs @@ -61,7 +61,7 @@ private static double consistentRatioPenalty(TaikoDifficultyHitObject hitObject, // As a fallback, calculate the maximum deviation from the average of the recent ratios to ensure slightly off-snapped objects don't bypass the penalty. double maxRatioDeviation = recentRatios.Max(r => Math.Abs(r - recentRatios.Average())); - double consistentRatioPenalty = 0.7 + 0.3 * DifficultyCalculationUtils.Smootherstep(maxRatioDeviation, 0.0, 1.0); + double consistentRatioPenalty = 0.7 + 0.3 * DiffUtils.Smootherstep(maxRatioDeviation, 0.0, 1.0); return consistentRatioPenalty; } @@ -91,12 +91,12 @@ public static double EvaluateDifficultyOf(DifficultyHitObject hitObject) } private static double evaluateMonoStreakDifficulty(MonoStreak monoStreak) => - DifficultyCalculationUtils.Logistic(exponent: Math.E * monoStreak.Index - 2 * Math.E) * evaluateAlternatingMonoPatternDifficulty(monoStreak.Parent) * 0.5; + DiffUtils.Logistic(exponent: Math.E * monoStreak.Index - 2 * Math.E) * evaluateAlternatingMonoPatternDifficulty(monoStreak.Parent) * 0.5; private static double evaluateAlternatingMonoPatternDifficulty(AlternatingMonoPattern alternatingMonoPattern) => - DifficultyCalculationUtils.Logistic(exponent: Math.E * alternatingMonoPattern.Index - 2 * Math.E) * evaluateRepeatingHitPatternsDifficulty(alternatingMonoPattern.Parent); + DiffUtils.Logistic(exponent: Math.E * alternatingMonoPattern.Index - 2 * Math.E) * evaluateRepeatingHitPatternsDifficulty(alternatingMonoPattern.Parent); private static double evaluateRepeatingHitPatternsDifficulty(RepeatingHitPatterns repeatingHitPattern) => - 2 * (1 - DifficultyCalculationUtils.Logistic(exponent: Math.E * repeatingHitPattern.RepetitionInterval - 2 * Math.E)); + 2 * (1 - DiffUtils.Logistic(exponent: Math.E * repeatingHitPattern.RepetitionInterval - 2 * Math.E)); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs index 58719796133f..4092c4e688eb 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs @@ -38,17 +38,17 @@ public static double EvaluateDifficultyOf(TaikoDifficultyHitObject noteObject) // Apply a cap to prevent outlier values on maps that exceed the editor's parameters. double effectiveBPM = Math.Max(1.0, noteObject.EffectiveBPM); - double midVelocityDifficulty = 0.5 * DifficultyCalculationUtils.Logistic(effectiveBPM, midVelocity.Center, 1.0 / (midVelocity.Range / 10)); + double midVelocityDifficulty = 0.5 * DiffUtils.Logistic(effectiveBPM, midVelocity.Center, 1.0 / (midVelocity.Range / 10)); // Expected DeltaTime is the DeltaTime this note would need to be spaced equally to a base slider velocity 1/4 note. double expectedDeltaTime = 21000.0 / effectiveBPM; double objectDensity = expectedDeltaTime / Math.Max(1.0, noteObject.DeltaTime); // High density is penalised at high velocity as it is generally considered easier to read. See https://www.desmos.com/calculator/u63f3ntdsi - double densityPenalty = DifficultyCalculationUtils.Logistic(objectDensity, 0.925, 15); + double densityPenalty = DiffUtils.Logistic(objectDensity, 0.925, 15); double highVelocityDifficulty = (1.0 - 0.33 * densityPenalty) - * DifficultyCalculationUtils.Logistic(effectiveBPM, highVelocity.Center + 8 * densityPenalty, (1.0 + 0.5 * densityPenalty) / (highVelocity.Range / 10)); + * DiffUtils.Logistic(effectiveBPM, highVelocity.Center + 8 * densityPenalty, (1.0 + 0.5 * densityPenalty) / (highVelocity.Range / 10)); return midVelocityDifficulty + highVelocityDifficulty; } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs index 8e73744b5318..afe757e151bc 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs @@ -63,7 +63,7 @@ private static double evaluateDifficultyOf(SameRhythmHitObjectGrouping sameRhyth if (durationDifference > 0) { - intervalDifficulty *= DifficultyCalculationUtils.Logistic( + intervalDifficulty *= DiffUtils.Logistic( durationDifference / hitWindow, midpointOffset: 0.35, multiplier: 2, @@ -72,13 +72,13 @@ private static double evaluateDifficultyOf(SameRhythmHitObjectGrouping sameRhyth } // Penalise patterns that can be hit within a single hit window. - intervalDifficulty *= DifficultyCalculationUtils.Logistic( + intervalDifficulty *= DiffUtils.Logistic( sameRhythmGroupedHitObjects.Duration / hitWindow, midpointOffset: 0.3, multiplier: 2, maxValue: 1); - return Math.Pow(intervalDifficulty, 0.75); + return DiffUtils.Pow(intervalDifficulty, 0.75); } /// @@ -145,10 +145,10 @@ private static double longGapPenalty(SameRhythmHitObjectGrouping? previous) double gapRatio = gapInterval / Math.Max(rhythmInterval, 1); // The gap ratio normalised to represent if the gap is long. - double gapFactor = DifficultyCalculationUtils.Logistic(gapRatio, 1.75, 20); + double gapFactor = DiffUtils.Logistic(gapRatio, 1.75, 20); // The length in objects of this rhythm normalised to represent if the rhythm change is frequent enough to be penalised. - double lengthFactor = DifficultyCalculationUtils.ReverseLerp(rhythmLength, 8, 2); + double lengthFactor = DiffUtils.ReverseLerp(rhythmLength, 8, 2); return 1.0 - 0.75 * gapFactor * lengthFactor; } @@ -171,10 +171,10 @@ private static double ratioDifficulty(double ratio, int terms = 8) difficulty += terms / (1 + ratio); // Give bonus to near-1 ratios - difficulty += DifficultyCalculationUtils.BellCurve(ratio, 1, 0.5); + difficulty += DiffUtils.BellCurve(ratio, 1, 0.5); // Penalize ratios that are VERY near 1 - difficulty -= DifficultyCalculationUtils.BellCurve(ratio, 1, 0.3); + difficulty -= DiffUtils.BellCurve(ratio, 1, 0.3); difficulty = Math.Max(difficulty, 0); difficulty /= Math.Sqrt(8); @@ -186,6 +186,6 @@ private static double ratioDifficulty(double ratio, int terms = 8) /// Multiplier for a given denominator term. /// private static double termPenalty(double ratio, int denominator, double power, double multiplier) => - -multiplier * Math.Pow(Math.Cos(denominator * Math.PI * ratio), power); + -multiplier * DiffUtils.Pow(Math.Cos(denominator * Math.PI * ratio), power); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs index 7be1107b7042..7e3006dfbf19 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs @@ -37,7 +37,7 @@ protected override double StrainValueOf(DifficultyHitObject current) var taikoObject = (TaikoDifficultyHitObject)current; int index = taikoObject.ColourData.MonoStreak?.HitObjects.IndexOf(taikoObject) ?? 0; - currentStrain *= DifficultyCalculationUtils.Logistic(index, 4, -1 / 25.0, 0.5) + 0.5; + currentStrain *= DiffUtils.Logistic(index, 4, -1 / 25.0, 0.5) + 0.5; currentStrain *= StrainDecayBase; currentStrain += ReadingEvaluator.EvaluateDifficultyOf(taikoObject) * SkillMultiplier; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs index e41f3ff5e975..d04d50ee880d 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs @@ -28,7 +28,7 @@ protected override double StrainValueOf(DifficultyHitObject current) // To prevent abuse of exceedingly long intervals between awkward rhythms, we penalise its difficulty. double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) - 0.5; // Remove base strain - difficulty *= DifficultyCalculationUtils.Logistic(staminaDifficulty, 1 / 15.0, 50.0); + difficulty *= DiffUtils.Logistic(staminaDifficulty, 1 / 15.0, 50.0); return difficulty; } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs index 75836713f884..023d7b6574a0 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; @@ -37,7 +36,7 @@ public Stamina(Mod[] mods, bool singleColourStamina, bool isConvert) this.isConvert = isConvert; } - private double strainDecay(double ms) => Math.Pow(strain_decay_base, ms / 1000); + private double strainDecay(double ms) => DiffUtils.Pow(strain_decay_base, ms / 1000); protected override double StrainValueAt(DifficultyHitObject current) { @@ -48,7 +47,7 @@ protected override double StrainValueAt(DifficultyHitObject current) var currentObject = current as TaikoDifficultyHitObject; int index = currentObject?.ColourData.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0; - double monoLengthBonus = isConvert ? 1.0 : 1.0 + 0.5 * DifficultyCalculationUtils.ReverseLerp(index, 5, 20); + double monoLengthBonus = isConvert ? 1.0 : 1.0 + 0.5 * DiffUtils.ReverseLerp(index, 5, 20); // Mono-streak bonus is only applied to colour-based stamina to reward longer sequences of same-colour hits within patterns. if (!SingleColourStamina) @@ -58,7 +57,7 @@ protected override double StrainValueAt(DifficultyHitObject current) // For converted maps, difficulty often comes entirely from long mono streams with no colour variation. // To avoid over-rewarding these maps based purely on stamina strain, we dampen the strain value once the index exceeds 10. - return SingleColourStamina ? DifficultyCalculationUtils.Logistic(-(index - 10) / 2.0, currentStrain) : currentStrain; + return SingleColourStamina ? DiffUtils.Logistic(-(index - 10) / 2.0, currentStrain) : currentStrain; } protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 43edbfb15183..dab87c235758 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -113,14 +113,14 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double colourSkill = colour.DifficultyValue() * colour_skill_multiplier; double staminaSkill = staminaDifficultyValue * stamina_skill_multiplier; double monoStaminaSkill = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; - double monoStaminaFactor = staminaSkill == 0 ? 1 : Math.Pow(monoStaminaSkill / staminaSkill, 5); + double monoStaminaFactor = staminaSkill == 0 ? 1 : DiffUtils.Pow(monoStaminaSkill / staminaSkill, 5); double staminaDifficultStrains = stamina.CountTopWeightedStrains(staminaDifficultyValue); // As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm. - patternMultiplier = Math.Pow(staminaSkill * colourSkill, 0.10); + patternMultiplier = DiffUtils.Pow(staminaSkill * colourSkill, 0.10); - strainLengthBonus = 1 + 0.15 * DifficultyCalculationUtils.ReverseLerp(staminaDifficultStrains, 1000, 1555); + strainLengthBonus = 1 + 0.15 * DiffUtils.ReverseLerp(staminaDifficultStrains, 1000, 1555); double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, out double consistencyFactor); double starRating = rescale(combinedRating * 1.4); @@ -221,7 +221,7 @@ private List combinePeaks(IReadOnlyList rhythmPeaks, IReadOnlyLi double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier * strainLengthBonus; staminaPeak /= isConvert || isRelax ? 1.5 : 1.0; // Available finger count is increased by 150%, thus we adjust accordingly. - double peak = DifficultyCalculationUtils.Norm(2, DifficultyCalculationUtils.Norm(1.5, colourPeak, staminaPeak), rhythmPeak, readingPeak); + double peak = DiffUtils.Norm(2, DiffUtils.Norm(1.5, colourPeak, staminaPeak), rhythmPeak, readingPeak); // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). // These sections will not contribute to the difficulty. diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index df9da49c4b80..9ec205496ddf 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -90,18 +90,18 @@ private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes double rhythmMaximumUnstableRate = computeDeviationUpperBound(0.8) * 10; // The fraction of star rating made up by rhythm difficulty, normalised to represent rhythm's perceived contribution to star rating. - double rhythmFactor = DifficultyCalculationUtils.ReverseLerp(attributes.RhythmDifficulty / attributes.StarRating, 0.15, 0.4); + double rhythmFactor = DiffUtils.ReverseLerp(attributes.RhythmDifficulty / attributes.StarRating, 0.15, 0.4); // A penalty removing improperly played rhythm difficulty from star rating based on estimated unstable rate. - double rhythmPenalty = 1 - DifficultyCalculationUtils.Logistic( + double rhythmPenalty = 1 - DiffUtils.Logistic( estimatedUnstableRate.Value, midpointOffset: (rhythmExpectedUnstableRate + rhythmMaximumUnstableRate) / 2, multiplier: 10 / (rhythmMaximumUnstableRate - rhythmExpectedUnstableRate), - maxValue: 0.25 * Math.Pow(rhythmFactor, 3) + maxValue: 0.25 * DiffUtils.Pow(rhythmFactor, 3) ); double baseDifficulty = 5 * Math.Max(1.0, attributes.StarRating * rhythmPenalty / 0.110) - 4.0; - double difficultyValue = Math.Min(Math.Pow(baseDifficulty, 3) / 69052.51, Math.Pow(baseDifficulty, 2.25) / 1250.0); + double difficultyValue = Math.Min(DiffUtils.Pow(baseDifficulty, 3) / 69052.51, DiffUtils.Pow(baseDifficulty, 2.25) / 1250.0); difficultyValue *= 1 + 0.10 * Math.Max(0, attributes.StarRating - 10); @@ -111,7 +111,7 @@ private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes // Scales miss penalty by the total difficult hits of a map, making misses more punishing on maps with less total difficulty. double missPenalty = 0.97 + 0.03 * totalDifficultHits / (totalDifficultHits + 1500); - difficultyValue *= Math.Pow(missPenalty, countMiss); + difficultyValue *= DiffUtils.Pow(missPenalty, countMiss); if (score.Mods.Any(m => m is ModHidden)) { @@ -139,7 +139,7 @@ private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes double monoAccScalingExponent = 2 + attributes.MonoStaminaFactor; double monoAccScalingShift = 500 - 100 * (attributes.MonoStaminaFactor * 3); - return difficultyValue * Math.Pow(DifficultyCalculationUtils.Erf(monoAccScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), monoAccScalingExponent); + return difficultyValue * DiffUtils.Pow(DiffUtils.Erf(monoAccScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), monoAccScalingExponent); } private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) @@ -147,10 +147,10 @@ private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes a if (greatHitWindow <= 0 || estimatedUnstableRate == null) return 0; - double accuracyValue = 470 * Math.Pow(0.9885, estimatedUnstableRate.Value); + double accuracyValue = 470 * DiffUtils.Pow(0.9885, estimatedUnstableRate.Value); // Scales up the bonus for lower unstable rate as star rating increases. - accuracyValue *= 1 + Math.Pow(50 / estimatedUnstableRate.Value, 2) * Math.Pow(attributes.StarRating, 2.8) / 600; + accuracyValue *= 1 + DiffUtils.Pow(50 / estimatedUnstableRate.Value, 2) * DiffUtils.Pow(attributes.StarRating, 2.8) / 600; if (score.Mods.Any(m => m is ModHidden) && !isConvert) accuracyValue *= 1.075; @@ -159,7 +159,7 @@ private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes a accuracyValue *= 1 + 0.3 * totalDifficultHits / (totalDifficultHits + 4000); // Applies a bonus to maps with more total memory required with HDFL. - double memoryLengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); + double memoryLengthBonus = Math.Min(1.15, DiffUtils.Pow(totalHits / 1500.0, 0.3)); if (score.Mods.Any(m => m is ModFlashlight) && score.Mods.Any(m => m is ModHidden) && !isConvert) accuracyValue *= Math.Max(1.0, 1.05 * memoryLengthBonus); @@ -185,7 +185,7 @@ private double computeDeviationUpperBound(double accuracy) double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); // We can be 99% confident that the deviation is not higher than: - return greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound)); + return greatHitWindow / (Math.Sqrt(2) * DiffUtils.ErfInv(pLowerBound)); } private int totalHits => countGreat + countOk + countMeh + countMiss; diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs index 7e40d575bc90..2fa765edd7a5 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; @@ -22,7 +23,7 @@ public TaikoScoreProcessor() protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) { return 250000 * comboProgress - + 750000 * Math.Pow(Accuracy.Value, 3.6) * accuracyProgress + + 750000 * DiffUtils.Pow(Accuracy.Value, 3.6) * accuracyProgress + bonusPortion; } diff --git a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs index 96c8fc38bc50..5af5ce66a673 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; @@ -70,7 +69,7 @@ public override double DifficultyValue() foreach (double obj in difficulties.OrderDescending()) { // Use a harmonic sum that considers each object of the map according to a predefined weight. - double weight = (1 + (HarmonicScale / (1 + index))) / (Math.Pow(index, DecayExponent) + 1 + (HarmonicScale / (1 + index))); + double weight = (1 + (HarmonicScale / (1 + index))) / (DiffUtils.Pow(index, DecayExponent) + 1 + (HarmonicScale / (1 + index))); ObjectWeightSum += weight; @@ -97,9 +96,9 @@ public virtual double CountTopWeightedObjectDifficulties(double difficultyValue) if (consistentTopObject == 0) return 0; - return ObjectDifficulties.Sum(d => DifficultyCalculationUtils.Logistic(d / consistentTopObject, 0.88, 10, 1.1)); + return ObjectDifficulties.Sum(d => DiffUtils.Logistic(d / consistentTopObject, 0.88, 10, 1.1)); } - public static double DifficultyToPerformance(double difficulty) => 4.0 * Math.Pow(difficulty, 3.0); + public static double DifficultyToPerformance(double difficulty) => 4.0 * DiffUtils.Pow(difficulty, 3); } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs index 8fab61ed6269..431d2faba2ed 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs @@ -1,8 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Difficulty.Skills @@ -49,6 +49,6 @@ protected override double StrainValueAt(DifficultyHitObject current) /// protected abstract double StrainValueOf(DifficultyHitObject current); - private double strainDecay(double ms) => Math.Pow(StrainDecayBase, ms / 1000); + private double strainDecay(double ms) => DiffUtils.Pow(StrainDecayBase, ms / 1000); } } diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs similarity index 92% rename from osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs rename to osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs index c813627d5162..db63048761a4 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs @@ -6,14 +6,14 @@ namespace osu.Game.Rulesets.Difficulty.Utils { - public static partial class DifficultyCalculationUtils + public static partial class DiffUtils { /// /// Converts BPM value into milliseconds /// /// Beats per minute /// Which rhythm delimiter to use, default is 1/4 - /// BPM conveted to milliseconds + /// BPM converted to milliseconds public static double BPMToMilliseconds(double bpm, int delimiter = 4) { return 60000.0 / delimiter / bpm; @@ -24,7 +24,7 @@ public static double BPMToMilliseconds(double bpm, int delimiter = 4) /// /// Milliseconds /// Which rhythm delimiter to use, default is 1/4 - /// Milliseconds conveted to beats per minute + /// Milliseconds converted to beats per minute public static double MillisecondsToBPM(double ms, int delimiter = 4) { return 60000.0 / (ms * delimiter); @@ -54,7 +54,7 @@ public static double MillisecondsToBPM(double ms, int delimiter = 4) /// The value of p to calculate the norm for. /// The coefficients of the vector. /// The p-norm of the vector. - public static double Norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p); + public static double Norm(double p, params double[] values) => Pow(values.Sum(x => Pow(x, p)), 1 / p); /// /// Calculates a Gaussian-based bell curve function (https://en.wikipedia.org/wiki/Gaussian_function) @@ -64,7 +64,7 @@ public static double MillisecondsToBPM(double ms, int delimiter = 4) /// The width (spread) of the curve /// Multiplier to adjust the curve's height /// The output of the bell curve function of - public static double BellCurve(double x, double mean, double width, double multiplier = 1.0) => multiplier * Math.Exp(Math.E * -(Math.Pow(x - mean, 2) / Math.Pow(width, 2))); + public static double BellCurve(double x, double mean, double width, double multiplier = 1.0) => multiplier * Math.Exp(Math.E * -(Pow(x - mean, 2) / Pow(width, 2))); /// /// Calculates a Smoothstep Bellcurve that returns returns 1 for x = mean, and smoothly reducing it's value to 0 over width @@ -179,12 +179,25 @@ public static double ErfInv(double x) double baseApprox = Math.Sqrt(t1 * t1 - t2) - t1; // Correction reduces max error from -0.005 to -0.00045. - double c = x >= 0.85 ? Math.Pow((x - 0.85) / 0.293, 8) : 0; + double c = x >= 0.85 ? Pow((x - 0.85) / 0.293, 8) : 0; double erfInv = sgn * (Math.Sqrt(baseApprox) + c); return erfInv; } + public static double Pow(double x, double exponent) => Math.Pow(x, exponent); + + public static double Pow(double x, int exponent) => exponent switch + { + 0 => 1, + 1 => x, + 2 => x * x, + 3 => x * x * x, + 4 => x * x * x * x, + 5 => x * x * x * x * x, + _ => Math.Pow(x, exponent) + }; + /// /// Inverse complementary error function (https://en.wikipedia.org/wiki/Error_function) /// From 041370509dd3d7debb78f1cbdc76633b9cbf07e5 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 29 Jun 2026 01:26:51 +0900 Subject: [PATCH 109/121] Simple LINQ free optimisation to `Norm()` --- osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs index db63048761a4..1fc035ec7a34 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; +using System.Runtime.CompilerServices; namespace osu.Game.Rulesets.Difficulty.Utils { @@ -54,7 +54,15 @@ public static double MillisecondsToBPM(double ms, int delimiter = 4) /// The value of p to calculate the norm for. /// The coefficients of the vector. /// The p-norm of the vector. - public static double Norm(double p, params double[] values) => Pow(values.Sum(x => Pow(x, p)), 1 / p); + public static double Norm(double p, params double[] values) + { + double sum = 0; + + foreach (double x in values) + sum += Math.Pow(x, p); + + return Math.Pow(sum, 1.0 / p); + } /// /// Calculates a Gaussian-based bell curve function (https://en.wikipedia.org/wiki/Gaussian_function) From f66eb58c65150195e0b37eca03c6f78adb2455d9 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 26 Jun 2026 19:21:37 +0900 Subject: [PATCH 110/121] Avoid duplicating list when not required (see `Speed` skill use case) --- osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs | 8 ++++++-- .../Rulesets/Difficulty/Skills/HarmonicSkill.cs | 13 ++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index bcac64ffff8f..1587f3ca6ca0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -70,17 +70,21 @@ private double calculateAdjustedDifficulty(DifficultyHitObject current) return difficulty; } - protected override void ApplyDifficultyTransformation(double[] difficulties) + protected override List GetTransformedDifficulties(List difficulties) { + difficulties = difficulties.Where(v => v > 0).ToList(); + const double reduced_difficulty_base_line = 0.0; // Assume the first seconds are completely memorised int reducedNoteCount = calculateReducedNoteCount(); - for (int i = 0; i < Math.Min(difficulties.Length, reducedNoteCount); i++) + for (int i = 0; i < Math.Min(difficulties.Count, reducedNoteCount); i++) { double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((double)i / reducedNoteCount, 0, 1))); difficulties[i] *= Interpolation.Lerp(reduced_difficulty_base_line, 1.0, scale); } + + return difficulties; } private int calculateReducedNoteCount() diff --git a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs index 5af5ce66a673..c6e081272efa 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; @@ -45,9 +46,7 @@ protected sealed override double ProcessInternal(DifficultyHitObject current) /// Transforms the object difficulties specifically for final difficulty summation. /// This can be used to decrease weight of certain objects based on a skill-specific criteria. /// - protected virtual void ApplyDifficultyTransformation(double[] difficulties) - { - } + protected virtual List GetTransformedDifficulties(List difficulties) => difficulties; public override double DifficultyValue() { @@ -56,17 +55,17 @@ public override double DifficultyValue() // Objects with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). // These objects will not contribute to the difficulty. - double[] difficulties = ObjectDifficulties.Where(p => p > 0).ToArray(); + var difficulties = ObjectDifficulties; - if (difficulties.Length == 0) + if (difficulties.Count == 0) return 0; - ApplyDifficultyTransformation(difficulties); + difficulties = GetTransformedDifficulties(difficulties); double difficulty = 0; int index = 0; - foreach (double obj in difficulties.OrderDescending()) + foreach (double obj in difficulties.OrderDescending().Where(v => v > 0)) { // Use a harmonic sum that considers each object of the map according to a predefined weight. double weight = (1 + (HarmonicScale / (1 + index))) / (DiffUtils.Pow(index, DecayExponent) + 1 + (HarmonicScale / (1 + index))); From 9755da9f1ffdc0ad8973bcab4d9a1c6921970fc0 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 26 Jun 2026 18:56:19 +0900 Subject: [PATCH 111/121] Cache `HitWindowGreat` to avoid constant fetches --- .../Evaluators/Speed/RhythmEvaluator.cs | 3 +-- .../Evaluators/Speed/SpeedEvaluator.cs | 3 +-- .../Preprocessing/OsuDifficultyHitObject.cs | 13 ++-------- .../Difficulty/Evaluators/RhythmEvaluator.cs | 5 ++-- .../Preprocessing/DifficultyHitObject.cs | 24 +++++++++---------- 5 files changed, 17 insertions(+), 31 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs index 9a41106f2dd6..dbb201ce93ee 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -8,7 +8,6 @@ using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed { @@ -29,7 +28,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double rhythmComplexitySum = 0; - double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindow(HitResult.Great) * 0.3; + double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; var island = new Island(int.MaxValue); var previousIsland = new Island(int.MaxValue); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs index 8047a91f04f6..095dc116bf3b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs @@ -6,7 +6,6 @@ using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed { @@ -34,7 +33,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // Cap deltatime to the OD 300 hitwindow. // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. - strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindow(HitResult.Great)) / 0.93, 0.92, 1); + strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1); // speedBonus will be 0.0 for BPM < 200 double speedBonus = 0.0; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 5f6abcf94fba..ced184299bf8 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -9,7 +9,6 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Scoring; using osuTK; namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing @@ -131,15 +130,7 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// /// Object's immediate OverallDifficulty value calculated from the raw hitwindow. /// - public double OverallDifficulty - { - get - { - double hitWindowGreat = RawHitWindow(HitResult.Great) / ClockRate; - - return (79.5 - hitWindowGreat) / 6; - } - } + public double OverallDifficulty => (79.5 - HitWindowGreat / 2) / 6; public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int index) : base(hitObject, lastObject, clockRate, objects, index) @@ -196,7 +187,7 @@ public double CalculateDoubleTapFeasibility(OsuDifficultyHitObject? nextObj) double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); - double windowRatio = DiffUtils.Pow(Math.Min(1, currDeltaTime / HitWindow(HitResult.Great)), 5); + double windowRatio = DiffUtils.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 5); // Can't doubletap if circles don't intersect double distanceFactor = DiffUtils.Pow(DiffUtils.ReverseLerp(LazyJumpDistance, NORMALISED_DIAMETER, NORMALISED_RADIUS), 2); diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs index afe757e151bc..5bf46611d0ff 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data; @@ -13,7 +12,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators { - public class RhythmEvaluator + public static class RhythmEvaluator { /// /// Evaluate the difficulty of a hitobject considering its interval change. @@ -31,7 +30,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject hitObject) double intervalPenalty = 0; double gapPenalty = 0; - double hitWindow = hitObject.HitWindow(HitResult.Great); + double hitWindow = hitObject.HitWindowGreat; if (rhythmData.SameRhythmGroupedHitObjects?.FirstHitObject == hitObject) // Difficulty for SameRhythmGroupedHitObjects { diff --git a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs index 55c6060e0cd6..8cacf582e5c5 100644 --- a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs +++ b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs @@ -51,6 +51,8 @@ public class DifficultyHitObject /// public readonly double ClockRate; + public readonly double HitWindowGreat; + /// /// Creates a new . /// @@ -69,6 +71,7 @@ public DifficultyHitObject(HitObject hitObject, HitObject lastObject, double clo StartTime = hitObject.StartTime / clockRate; EndTime = hitObject.GetEndTime() / clockRate; ClockRate = clockRate; + HitWindowGreat = HitWindow(HitResult.Great); } public DifficultyHitObject Previous(int skipCount = 0) @@ -86,30 +89,25 @@ public DifficultyHitObject Next(int skipCount = 0) /// /// Retrieves the full rate-adjusted hit window for a . /// - public double HitWindow(HitResult hitResult) - { - return 2 * RawHitWindow(hitResult) / ClockRate; - } + protected double HitWindow(HitResult hitResult) => 2 * getRawHitWindow(hitResult) / ClockRate; /// /// Retrieves the hit window for a . /// - protected virtual double RawHitWindow(HitResult hitResult) + private double getRawHitWindow(HitResult hitResult) { // Try to get HitWindows from nested hit objects // This is important for objects such as Slider in osu! where the object itself has HitWindows set to Empty, but the nested SliderHead has proper hit windows - if (BaseObject.HitWindows == HitWindows.Empty) - { - foreach (var nestedHitObject in BaseObject.NestedHitObjects) - { - if (nestedHitObject.HitWindows == HitWindows.Empty) - continue; + if (BaseObject.HitWindows != HitWindows.Empty) + return BaseObject.HitWindows.WindowFor(hitResult); + foreach (var nestedHitObject in BaseObject.NestedHitObjects) + { + if (nestedHitObject.HitWindows != HitWindows.Empty) return nestedHitObject.HitWindows.WindowFor(hitResult); - } } - return BaseObject.HitWindows.WindowFor(hitResult); + return 0; } } } From ec52f0d4b1a3a832a36505364f02fac94904ab94 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 26 Jun 2026 19:46:53 +0900 Subject: [PATCH 112/121] Tidy up and reorder strain storage to avoid list copy overhead --- .../Difficulty/Skills/Aim.cs | 16 +++---- .../Skills/VariableLengthStrainSkill.cs | 42 ++++++++++++++----- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 8a31fb85b851..5e2b880c5da5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -213,24 +213,26 @@ private IEnumerable getReducedStrainPeaks() { // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). // These sections will not contribute to the difficulty. - var peaks = GetCurrentStrainPeaks().Where(p => p.Value > 0); - List strains = peaks.OrderByDescending(p => p.Value).ToList(); + List strains = GetCurrentStrainPeaks() + .Where(p => p.Value > 0) + .ToList(); const int chunk_size = 20; double time = 0; - int strainsToRemove = 0; // All strains are removed at the end for optimization purposes + int skipCount = 0; // We are reducing the highest strains first to account for extreme difficulty spikes // Strains are split into 20ms chunks to try to mitigate inconsistencies caused by reducing strains - while (strains.Count > strainsToRemove && time < reducedSectionTime) + while (strains.Count > skipCount && time < reducedSectionTime) { - StrainPeak strain = strains[strainsToRemove]; + StrainPeak strain = strains[skipCount]; for (double addedTime = 0; addedTime < strain.SectionLength; addedTime += chunk_size) { double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((time + addedTime) / reducedSectionTime, 0, 1))); + // intentionally add at end and sort afterwards, should be cheaper. strains.Add(new StrainPeak( strain.Value * Interpolation.Lerp(reduced_strain_baseline, 1.0, scale), Math.Min(chunk_size, strain.SectionLength - addedTime) @@ -238,10 +240,10 @@ private IEnumerable getReducedStrainPeaks() } time += strain.SectionLength; - strainsToRemove++; + skipCount++; } - return strains.Skip(strainsToRemove).OrderByDescending(p => p.Value); + return strains.Skip(skipCount).OrderByDescending(p => p.Value); } } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs index 244932140003..c94f469a1c9e 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs @@ -23,12 +23,12 @@ public abstract class VariableLengthStrainSkill : Skill /// /// The weight by which each strain value decays. /// - protected virtual double DecayWeight => 0.9; + protected readonly double DecayWeight; /// /// The maximum length of each strain section. /// - protected virtual int MaxSectionLength => 400; + protected readonly int MaxSectionLength; private double currentSectionPeak; // We also keep track of the peak strain in the current section. private double currentSectionBegin; @@ -40,7 +40,7 @@ public abstract class VariableLengthStrainSkill : Skill /// or if is overridden to not use the default geometric sum. This should be removed /// in the future when a better memory-saving technique is implemented. /// - private double maxStoredSections => 11 / (1 - DecayWeight); + private readonly double maxStoredLength; private readonly List strainPeaks = new List(); @@ -52,9 +52,19 @@ public abstract class VariableLengthStrainSkill : Skill /// private readonly List<(double StrainValue, double StartTime)> queuedStrains = new List<(double, double)>(); - protected VariableLengthStrainSkill(Mod[] mods) + /// + /// Create a new . + /// + /// The mods. + /// The weight by which each strain value decays. + /// The maximum length of each strain section. + protected VariableLengthStrainSkill(Mod[] mods, double decayWeight = 0.9, int maxSectionLength = 400) : base(mods) { + DecayWeight = decayWeight; + MaxSectionLength = maxSectionLength; + + maxStoredLength = 11 / (1 - DecayWeight); } /// @@ -162,11 +172,11 @@ private void saveCurrentPeak(double sectionLength) totalLength += sectionLength; // Remove from the back of our strain peaks if there's any which are too deep to contribute to difficulty. - // `maxStoredSections` dictates for us how many sections will preserve at least 99.999% of the difficulty value. - while (totalLength > maxStoredSections * MaxSectionLength) + // `maxStoredLength` dictates for us how many sections will preserve at least 99.999% of the difficulty value. + while (totalLength > maxStoredLength * MaxSectionLength) { - totalLength -= strainPeaks[0].SectionLength; - strainPeaks.RemoveAt(0); + totalLength -= strainPeaks[^1].SectionLength; + strainPeaks.RemoveAt(strainPeaks.Count - 1); } } @@ -190,11 +200,22 @@ private void startNewSectionFrom(double time, DifficultyHitObject current) /// The peak strain. protected abstract double CalculateInitialStrain(double time, DifficultyHitObject current); + private bool peaksFinalised; + /// /// Returns a live enumerable of the peak strains for each section of the beatmap, /// including the peak of the current section. /// - public IEnumerable GetCurrentStrainPeaks() => strainPeaks.Append(new StrainPeak(currentSectionPeak, currentSectionEnd - currentSectionBegin)); + public IEnumerable GetCurrentStrainPeaks() + { + if (!peaksFinalised) + { + saveCurrentPeak(currentSectionEnd - currentSectionBegin); + peaksFinalised = true; + } + + return strainPeaks; + } /// /// Calculates the number of strains weighted against the top strain. @@ -228,7 +249,8 @@ public StrainPeak(double value, double sectionLength) public double Value { get; } public double SectionLength { get; } - public int CompareTo(StrainPeak other) => Value.CompareTo(other.Value); + // Reverse sort, highest is first. + public int CompareTo(StrainPeak other) => other.Value.CompareTo(Value); } } } From 4b44876de07a17581c720ac446ab119022896b14 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sun, 28 Jun 2026 23:55:57 +0900 Subject: [PATCH 113/121] More inlining and faster smoothstep with baked arguments --- .../Evaluators/Speed/RhythmEvaluator.cs | 2 ++ osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs index dbb201ce93ee..921f527dbd06 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; @@ -207,6 +208,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static double getEffectiveRatio(double deltaDifference) { // Take only the fractional part of the value since we're only interested in punishing multiples diff --git a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs index 1fc035ec7a34..aa9814a9d6d8 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs @@ -75,19 +75,31 @@ public static double Norm(double p, params double[] values) public static double BellCurve(double x, double mean, double width, double multiplier = 1.0) => multiplier * Math.Exp(Math.E * -(Pow(x - mean, 2) / Pow(width, 2))); /// - /// Calculates a Smoothstep Bellcurve that returns returns 1 for x = mean, and smoothly reducing it's value to 0 over width + /// Calculates a Smoothstep bell curve that returns 1 for x = mean, and smoothly reducing it's value to 0 over width /// /// Value to calculate the function for /// Value of x, for which return value will be the highest (=1) /// Range [mean - width, mean + width] where function will change values /// The output of the smoothstep bell curve function of - public static double SmoothstepBellCurve(double x, double mean = 0.5, double width = 0.5) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double SmoothstepBellCurve(double x, double mean, double width) { x -= mean; x = x > 0 ? (width - x) : (width + x); return Smoothstep(x, 0, width); } + /// + /// Calculates a Smoothstep bell curve that returns 1 for x = mean, and smoothly reducing it's value to 0 over width + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double SmoothstepBellCurve(double x) + { + x = 0.5 - Math.Abs(x - 0.5); + x = Math.Clamp(x * 2.0, 0.0, 1.0); + return x * x * (3.0 - 2.0 * x); + } + /// /// Smoothstep function (https://en.wikipedia.org/wiki/Smoothstep) /// From ba4a24e2d5d92fee5749864ec4b8b30113e16804 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 29 Jun 2026 02:40:56 +0900 Subject: [PATCH 114/121] Remove impossible null check --- .../Difficulty/Evaluators/Speed/RhythmEvaluator.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs index 921f527dbd06..dcec55402a53 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -249,11 +249,8 @@ public bool IsSimilarPolarity(Island other, double epsilon) DeltaCount % 2 == other.DeltaCount % 2; } - public bool AlmostEquals(Island? other, double epsilon) + public bool AlmostEquals(Island other, double epsilon) { - if (other == null) - return false; - return Math.Abs(Delta - other.Delta) < epsilon && DeltaCount == other.DeltaCount; } From 8de316ee812de81ad1c1083502a44290cc234bcf Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 29 Jun 2026 02:41:26 +0900 Subject: [PATCH 115/121] Use quicker `Pow` in optimised `Norm` implementation --- osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs index aa9814a9d6d8..93e2ffe00550 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs @@ -59,9 +59,9 @@ public static double Norm(double p, params double[] values) double sum = 0; foreach (double x in values) - sum += Math.Pow(x, p); + sum += Pow(x, p); - return Math.Pow(sum, 1.0 / p); + return Pow(sum, 1.0 / p); } /// From 34416814a220eb40024de2d397f8247b2fd2f8a4 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 29 Jun 2026 02:50:15 +0900 Subject: [PATCH 116/121] Remove LINQ overhead from `RhythmEvaluator` --- .../Evaluators/Speed/RhythmEvaluator.cs | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs index dcec55402a53..3aaeab6ef725 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Runtime.CompilerServices; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; @@ -136,28 +135,32 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (isSpeedingUp) effectiveRatio *= 0.65; - (Island Island, int Count) tuple = islandCounts.FirstOrDefault(x => x.Island.AlmostEquals(island, deltaDifferenceEpsilon)); + bool found = false; - if (tuple != default) + foreach ((Island Island, int Count) tuple in islandCounts) { - int countIndex = islandCounts.IndexOf(tuple); - - // only add island to island counts if they're going one after another - if (previousIsland.AlmostEquals(island, deltaDifferenceEpsilon)) - tuple.Count++; - - // repeated island (ex: triplet -> triplet) - double power = DiffUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33); - effectiveRatio *= Math.Min(3.0 / tuple.Count, DiffUtils.Pow(1.0 / tuple.Count, power)); - - islandCounts[countIndex] = (tuple.Island, tuple.Count); + if (tuple.Island.AlmostEquals(island, deltaDifferenceEpsilon)) + { + int countIndex = islandCounts.IndexOf(tuple); + int count = tuple.Count; + + // only add island to island counts if they're going one after another + if (previousIsland.AlmostEquals(island, deltaDifferenceEpsilon)) + islandCounts[countIndex] = (tuple.Island, ++count); + + // repeated island (ex: triplet -> triplet) + double power = DiffUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33); + effectiveRatio *= Math.Min(3.0 / count, DiffUtils.Pow(1.0 / count, power)); + + found = true; + break; + } } - else if (island.DeltaCount > 0) - { + + if (!found && island.DeltaCount > 0) islandCounts.Add((island, 1)); - } - // scale down the difficulty if the object is doubletappable + // scale down the difficulty if the object is double-tappable effectiveRatio *= 1 - prevObj.CalculateDoubleTapFeasibility(currObj) * 0.75; if (island.DeltaCount > 1) From 4dfdf441d70101a250192a47f6f5579ae4d53dab Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:19:57 +0500 Subject: [PATCH 117/121] `pp-dev` cleanup pass (#38179) I'd push these straight into the branch but I don't have perms /shrug --- .../Evaluators/Aim/AgilityEvaluator.cs | 4 +- .../Evaluators/Aim/FlowAimEvaluator.cs | 4 +- .../Evaluators/Aim/SnapAimEvaluator.cs | 21 ++-- .../Evaluators/FlashlightEvaluator.cs | 16 +-- .../Difficulty/Evaluators/ReadingEvaluator.cs | 18 ++-- .../Evaluators/Speed/RhythmEvaluator.cs | 99 ++++++++++--------- .../Evaluators/Speed/SpeedEvaluator.cs | 6 +- .../Difficulty/OsuPerformanceCalculator.cs | 4 +- .../Difficulty/Skills/Aim.cs | 13 +-- .../Difficulty/Skills/Flashlight.cs | 7 +- .../Difficulty/Skills/Reading.cs | 7 +- .../Difficulty/Skills/Speed.cs | 7 +- .../Difficulty/TaikoPerformanceCalculator.cs | 4 +- .../Rulesets/Difficulty/Utils/DiffUtils.cs | 5 + 14 files changed, 114 insertions(+), 101 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs index 9ab7e996db78..bd5204faaf8d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs @@ -11,8 +11,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim { public static class AgilityEvaluator { - private const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.2; // 1.2 circles distance between centers - /// /// Evaluates the difficulty of fast aiming /// @@ -21,6 +19,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (current.BaseObject is Spinner) return 0; + const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.2; // 1.2 circles distance between centers + var osuCurrObj = (OsuDifficultyHitObject)current; var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index af403df759fe..3707d4061342 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -12,8 +12,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim { public static class FlowAimEvaluator { - private const double velocity_change_multiplier = 0.52; - /// /// Evaluates difficulty of "flow aim" - aiming pattern where player doesn't stop their cursor on every object and instead "flows" through them. /// @@ -22,6 +20,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) return 0; + const double velocity_change_multiplier = 0.52; + var osuCurrObj = (OsuDifficultyHitObject)current; var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs index 5a53b691cacb..451f67b45090 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -11,17 +11,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim { public static class SnapAimEvaluator { - private const double wide_angle_multiplier = 9.67; - private const double acute_angle_multiplier = 2.41; - private const double slider_multiplier = 1.5; - private const double velocity_change_multiplier = 0.9; - - // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation - private const double wiggle_multiplier = 1.02; - - private const double maximum_repetition_nerf = 0.15; - private const double maximum_vector_influence = 0.5; - /// /// Evaluates the difficulty of aiming the current object, based on: /// @@ -36,6 +25,14 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) return 0; + const double wide_angle_multiplier = 9.67; + const double acute_angle_multiplier = 2.41; + const double slider_multiplier = 1.5; + const double velocity_change_multiplier = 0.9; + + // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation + const double wiggle_multiplier = 1.02; + var osuCurrObj = (OsuDifficultyHitObject)current; var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2); @@ -178,6 +175,8 @@ private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuD return 1; const double note_limit = 6; + const double maximum_repetition_nerf = 0.15; + const double maximum_vector_influence = 0.5; double constantAngleCount = 0; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs index e39783ff8964..a6d13be579ec 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs @@ -15,14 +15,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { public static class FlashlightEvaluator { - private const double max_opacity_bonus = 0.4; - private const double hidden_bonus = 0.2; - - private const double min_velocity = 0.5; - private const double slider_multiplier = 1.3; - - private const double min_angle_multiplier = 0.2; - /// /// Evaluates the difficulty of memorising and hitting an object, based on: /// @@ -38,6 +30,14 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnly if (current.BaseObject is Spinner) return 0; + const double max_opacity_bonus = 0.4; + const double hidden_bonus = 0.2; + + const double min_velocity = 0.5; + const double slider_multiplier = 1.3; + + const double min_angle_multiplier = 0.2; + var osuCurrent = (OsuDifficultyHitObject)current; var osuHitObject = (OsuHitObject)(osuCurrent.BaseObject); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs index 7177857f9e69..99826ed4170b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -14,13 +14,6 @@ public static class ReadingEvaluator { private const double reading_window_size = 3000; // 3 seconds private const double distance_influence_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.5; // 1.5 circles distance between centers - private const double hidden_multiplier = 0.28; - private const double density_multiplier = 2.4; - private const double density_difficulty_base = 2.5; - private const double preempt_balancing_factor = 140000; - private const double preempt_starting_point = 500; // AR 9.66 in milliseconds - private const double minimum_angle_relevancy_time = 2000; // 2 seconds - private const double maximum_angle_relevancy_time = 200; public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden) { @@ -65,6 +58,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd private static double calculateDensityDifficulty(OsuDifficultyHitObject? nextObj, double velocity, double constantAngleNerfFactor, double pastObjectDifficultyInfluence, double currentVisibleObjectDensity) { + const double density_multiplier = 2.4; + const double density_difficulty_base = 2.5; + // Consider future densities too because it can make the path the cursor takes less clear double futureObjectDifficultyInfluence = Math.Sqrt(currentVisibleObjectDensity); @@ -96,6 +92,9 @@ private static double calculateDensityDifficulty(OsuDifficultyHitObject? nextObj /// private static double calculatePreemptDifficulty(double velocity, double constantAngleNerfFactor, double preempt) { + const double preempt_balancing_factor = 140000; + const double preempt_starting_point = 500; // AR 9.66 in milliseconds + // Arbitrary curve for the base value preempt difficulty should have as approach rate increases. // https://www.desmos.com/calculator/c175335a71 double preemptDifficulty = DiffUtils.Pow((preempt_starting_point - preempt + Math.Abs(preempt - preempt_starting_point)) / 2, 2.5) / preempt_balancing_factor; @@ -119,6 +118,8 @@ private static double calculatePreemptDifficulty(double velocity, double constan private static double calculateHiddenDifficulty(OsuDifficultyHitObject currObj, double pastObjectDifficultyInfluence, double currentVisibleObjectDensity, double velocity, double constantAngleNerfFactor) { + const double hidden_multiplier = 0.28; + // Higher preempt means that time spent invisible is higher too, we want to reward that double preemptFactor = DiffUtils.Pow(currObj.Preempt, 2.2) * 0.01; @@ -206,6 +207,9 @@ private static double retrieveCurrentVisibleObjectDensity(OsuDifficultyHitObject // https://www.desmos.com/calculator/eb057a4822 private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current) { + const double minimum_angle_relevancy_time = 2000; // 2 seconds + const double maximum_angle_relevancy_time = 200; + double constantAngleCount = 0; int index = 0; double currentTimeGap = 0; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs index 3aaeab6ef725..498b130991e3 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -13,11 +13,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed { public static class RhythmEvaluator { - private const int history_time_max = 5 * 1000; // 5 seconds - private const int history_objects_max = 32; - private const double rhythm_overall_multiplier = 0.95; - private const double rhythm_ratio_multiplier = 26.0; - /// /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . /// @@ -26,6 +21,10 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (current.BaseObject is Spinner) return 0; + const int history_time_max = 5 * 1000; // 5 seconds + const int history_objects_max = 32; + const double rhythm_overall_multiplier = 0.95; + double rhythmComplexitySum = 0; double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; @@ -33,11 +32,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) var island = new Island(int.MaxValue); var previousIsland = new Island(int.MaxValue); - // we can't use dictionary here because we need to compare island with a tolerance - // which is impossible to pass into the hash comparer - var islandCounts = new List<(Island Island, int Count)>(); + var islands = new List(); - double startRatio = 0; // store the ratio of the current start of an island to buff for tighter rhythms + double startDifficulty = 0; // store the difficulty of the current start of an island to buff for tighter rhythms bool firstDeltaSwitch = false; @@ -71,7 +68,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double currDelta = Math.Max(currObj.DeltaTime, delta_min_value); double prevDelta = Math.Max(prevObj.DeltaTime, delta_min_value); - double currPrevDeltaDelta = Math.Abs(prevDelta - currDelta); + double deltaDifference = Math.Abs(prevDelta - currDelta); // Make sure to always have the current island initialised - if we don't do it here it will only initialise on the next rhythm change if (island.Delta == int.MaxValue) @@ -79,14 +76,14 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // calculate how much current delta difference deserves a rhythm bonus // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) - double deltaDifference = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta); + double deltaDifferenceRatio = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta); // reduce ratio bonus if delta difference is too big - double differenceMultiplier = Math.Clamp(2.0 - deltaDifference / 8.0, 0.0, 1.0); + double differenceMultiplier = Math.Clamp(2.0 - deltaDifferenceRatio / 8.0, 0.0, 1.0); - double windowPenalty = Math.Clamp((currPrevDeltaDelta - deltaDifferenceEpsilon) / deltaDifferenceEpsilon, 0, 1); + double windowPenalty = Math.Clamp((deltaDifference - deltaDifferenceEpsilon) / deltaDifferenceEpsilon, 0, 1); - double effectiveRatio = getEffectiveRatio(deltaDifference) * windowPenalty * differenceMultiplier; + double effectiveDifficulty = getEffectiveDifficulty(deltaDifferenceRatio) * windowPenalty * differenceMultiplier; // if previous object is a slider it might be easier to tap since you don't have to do a whole tapping motion // while a full deltatime might end up some weird ratio the "unpress->tap" motion might be simple @@ -94,16 +91,16 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (prevObj.BaseObject is Slider) { double sliderLazyEndDelta = currObj.MinimumJumpTime; - double sliderLazyDeltaDifference = Math.Max(sliderLazyEndDelta, currDelta) / Math.Min(sliderLazyEndDelta, currDelta); + double sliderLazyDeltaDifferenceRatio = Math.Max(sliderLazyEndDelta, currDelta) / Math.Min(sliderLazyEndDelta, currDelta); double sliderRealEndDelta = currObj.LastObjectEndDeltaTime; - double sliderRealDeltaDifference = Math.Max(sliderRealEndDelta, currDelta) / Math.Min(sliderRealEndDelta, currDelta); + double sliderRealDeltaDifferenceRatio = Math.Max(sliderRealEndDelta, currDelta) / Math.Min(sliderRealEndDelta, currDelta); - double sliderEffectiveRatio = Math.Min(getEffectiveRatio(sliderLazyDeltaDifference), getEffectiveRatio(sliderRealDeltaDifference)); - effectiveRatio = Math.Min(sliderEffectiveRatio, effectiveRatio); + double sliderEffectiveDifficulty = Math.Min(getEffectiveDifficulty(sliderLazyDeltaDifferenceRatio), getEffectiveDifficulty(sliderRealDeltaDifferenceRatio)); + effectiveDifficulty = Math.Min(sliderEffectiveDifficulty, effectiveDifficulty); } - if (currPrevDeltaDelta < deltaDifferenceEpsilon) + if (deltaDifference < deltaDifferenceEpsilon) { // island is still progressing island.AddDelta((int)currDelta); @@ -111,46 +108,43 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (firstDeltaSwitch) { - if (currPrevDeltaDelta > deltaDifferenceEpsilon) + if (deltaDifference > deltaDifferenceEpsilon) { // bpm change is into slider, this is easy acc window if (currObj.BaseObject is Slider) - effectiveRatio *= 0.5; + effectiveDifficulty *= 0.5; // repeated island polarity (2 -> 4, 3 -> 5) if (island.IsSimilarPolarity(previousIsland, deltaDifferenceEpsilon)) - effectiveRatio *= 0.5; + effectiveDifficulty *= 0.5; // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. if (Math.Max(prevPrevObj.DeltaTime, delta_min_value) > prevDelta + deltaDifferenceEpsilon && prevDelta > currDelta + deltaDifferenceEpsilon) - effectiveRatio *= 0.125; + effectiveDifficulty *= 0.125; // repeated island size (ex: triplet -> triplet) // TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation if (previousIsland.DeltaCount == island.DeltaCount) - effectiveRatio *= 0.5; + effectiveDifficulty *= 0.5; bool isSpeedingUp = prevDelta > currDelta + deltaDifferenceEpsilon; if (isSpeedingUp) - effectiveRatio *= 0.65; + effectiveDifficulty *= 0.65; bool found = false; - foreach ((Island Island, int Count) tuple in islandCounts) + foreach (var existingIsland in islands) { - if (tuple.Island.AlmostEquals(island, deltaDifferenceEpsilon)) + if (existingIsland.AlmostEquals(island, deltaDifferenceEpsilon)) { - int countIndex = islandCounts.IndexOf(tuple); - int count = tuple.Count; - - // only add island to island counts if they're going one after another + // only increase island occurrences if they're going one after another if (previousIsland.AlmostEquals(island, deltaDifferenceEpsilon)) - islandCounts[countIndex] = (tuple.Island, ++count); + existingIsland.Occurrences++; // repeated island (ex: triplet -> triplet) double power = DiffUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33); - effectiveRatio *= Math.Min(3.0 / count, DiffUtils.Pow(1.0 / count, power)); + effectiveDifficulty *= Math.Min(3.0 / existingIsland.Occurrences, DiffUtils.Pow(1.0 / existingIsland.Occurrences, power)); found = true; break; @@ -158,14 +152,14 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) } if (!found && island.DeltaCount > 0) - islandCounts.Add((island, 1)); + islands.Add(island); // scale down the difficulty if the object is double-tappable - effectiveRatio *= 1 - prevObj.CalculateDoubleTapFeasibility(currObj) * 0.75; + effectiveDifficulty *= 1 - prevObj.CalculateDoubleTapFeasibility(currObj) * 0.75; if (island.DeltaCount > 1) { - rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay; + rhythmComplexitySum += Math.Sqrt(effectiveDifficulty * startDifficulty) * currHistoricalDecay; } else { @@ -173,7 +167,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) rhythmComplexitySum += 0.7 * currHistoricalDecay; } - startRatio = effectiveRatio; + startDifficulty = effectiveDifficulty; if (prevDelta + deltaDifferenceEpsilon < currDelta) // we're slowing down, stop counting firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. @@ -189,14 +183,14 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // bpm change is into slider, this is easy acc window if (currObj.BaseObject is Slider) - effectiveRatio *= 0.6; + effectiveDifficulty *= 0.6; // bpm change was from a slider, this is easier typically than circle -> circle // unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders if (prevObj.BaseObject is Slider) - effectiveRatio *= 0.6; + effectiveDifficulty *= 0.6; - startRatio = effectiveRatio; + startDifficulty = effectiveDifficulty; island = new Island((int)currDelta); } @@ -208,27 +202,40 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // If the current island is long we don't want the sum to have as big of an effect rhythmComplexitySum *= DiffUtils.ReverseLerp(island.DeltaCount, 22, 3); - return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though); + return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static double getEffectiveRatio(double deltaDifference) + private static double getEffectiveDifficulty(double deltaDifferenceRatio) { + const double rhythm_ratio_difficulty_multiplier = 26.0; + // Take only the fractional part of the value since we're only interested in punishing multiples - double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference); + double deltaDifferenceFraction = deltaDifferenceRatio - Math.Truncate(deltaDifferenceRatio); - return 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DiffUtils.SmoothstepBellCurve(deltaDifferenceFraction)); + return 1.0 + rhythm_ratio_difficulty_multiplier * Math.Min(0.5, DiffUtils.SmoothstepBellCurve(deltaDifferenceFraction)); } /// - /// An island is a thing. I'm not sure what thing it is, but it's definitely a thing. - /// TODO: document this stuff please. + /// An island is a group of consecutive objects with the same delta time. /// private class Island { + /// + /// Delta time of every object in this island + /// public int Delta { get; private set; } + + /// + /// How long the island is + /// public int DeltaCount { get; private set; } = 1; + /// + /// How many times island already occured + /// + public int Occurrences { get; set; } = 1; + public Island(int delta) { Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs index 095dc116bf3b..7caa03a0b9c6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs @@ -11,9 +11,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed { public static class SpeedEvaluator { - private const double min_speed_bonus = 200; // 200 BPM 1/4th - private const double speed_balancing_factor = 40; - /// /// Evaluates the difficulty of tapping the current object, based on: /// @@ -26,6 +23,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (current.BaseObject is Spinner) return 0; + const double min_speed_bonus = 200; // 200 BPM 1/4th + const double speed_balancing_factor = 40; + var osuCurrObj = (OsuDifficultyHitObject)current; double strainTime = osuCurrObj.AdjustedDeltaTime; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 1a8e8a1529dd..eb721be5c070 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -459,12 +459,12 @@ private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, Os if (pLowerBound > 0.01) { // Compute deviation assuming greats and oks are normally distributed. - deviation = greatHitWindow / (Math.Sqrt(2) * DiffUtils.ErfInv(pLowerBound)); + deviation = greatHitWindow / (DiffUtils.SQRT2 * DiffUtils.ErfInv(pLowerBound)); // Subtract the deviation provided by tails that land outside the ok hit window from the deviation computed above. // This is equivalent to calculating the deviation of a normal distribution truncated at +-okHitWindow. double okHitWindowTailAmount = Math.Sqrt(2 / Math.PI) * okHitWindow * Math.Exp(-0.5 * DiffUtils.Pow(okHitWindow / deviation, 2)) - / (deviation * DiffUtils.Erf(okHitWindow / (Math.Sqrt(2) * deviation))); + / (deviation * DiffUtils.Erf(okHitWindow / (DiffUtils.SQRT2 * deviation))); deviation *= Math.Sqrt(1 - okHitWindowTailAmount); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 5e2b880c5da5..11e78c00bbf1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -31,12 +31,6 @@ public Aim(Mod[] mods, bool includeSliders) private double currentStrain; - private const double skill_multiplier_snap = 70.9; - private const double skill_multiplier_agility = 2.35; - private const double skill_multiplier_flow = 242.0; - private const double skill_multiplier_total = 1.12; - private const double combined_snap_norm_exponent = 1.2; - /// /// The number of sections with the highest strains, which the peak strain reductions will apply to. /// This is done in order to decrease their impact on the overall difficulty of the map for this skill. @@ -73,6 +67,10 @@ protected override double StrainValueAt(DifficultyHitObject current) private double calculateAdjustedDifficulty(DifficultyHitObject current) { + const double skill_multiplier_snap = 70.9; + const double skill_multiplier_agility = 2.35; + const double skill_multiplier_flow = 242.0; + double snapDifficulty = SnapAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skill_multiplier_snap; double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skill_multiplier_agility; double flowDifficulty = FlowAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skill_multiplier_flow; @@ -92,6 +90,9 @@ private double calculateAdjustedDifficulty(DifficultyHitObject current) private double calculateTotalValue(double snapDifficulty, double agilityDifficulty, double flowDifficulty) { + const double skill_multiplier_total = 1.12; + const double combined_snap_norm_exponent = 1.2; + // We compare flow to combined snap and agility because snap by itself doesn't have enough difficulty to be above flow on streams // Agility on the other hand is supposed to measure the rate of cursor velocity changes while snapping // So snapping every circle on a stream requires an enormous amount of agility at which point it's easier to flow diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index f15e74e3fcbe..85666881ad70 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -26,17 +26,16 @@ public Flashlight(Mod[] mods, int totalObjects) this.totalObjects = totalObjects; } - private const double skill_multiplier = 0.058; - private const double strain_decay_base = 0.15; - private double currentStrain; - private double strainDecay(double ms) => DiffUtils.Pow(strain_decay_base, ms / 1000); + private double strainDecay(double ms) => DiffUtils.Pow(0.15, ms / 1000); protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime); protected override double StrainValueAt(DifficultyHitObject current) { + const double skill_multiplier = 0.058; + if (!Mods.Any(m => m is OsuModFlashlight)) return 0; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index 1587f3ca6ca0..b28352131aef 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -29,13 +29,12 @@ public Reading(Mod[] mods) private double currentStrain; - private const double skill_multiplier = 2.5; - private const double strain_decay_base = 0.8; - - private double strainDecay(double ms) => DiffUtils.Pow(strain_decay_base, ms / 1000); + private double strainDecay(double ms) => DiffUtils.Pow(0.8, ms / 1000); protected override double ObjectDifficultyOf(DifficultyHitObject current) { + const double skill_multiplier = 2.5; + objectList.Add(current); double decay = strainDecay(current.DeltaTime); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 9cabefc53e7a..a4432d445816 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -24,9 +24,6 @@ public class Speed : HarmonicSkill private double currentStrain; - private const double skill_multiplier = 1.16; - private const double strain_decay_base = 0.3; - protected override double HarmonicScale => 20; protected override double DecayExponent => 0.9; @@ -35,10 +32,12 @@ public Speed(Mod[] mods) { } - private double strainDecay(double ms) => DiffUtils.Pow(strain_decay_base, ms / 1000); + private double strainDecay(double ms) => DiffUtils.Pow(0.3, ms / 1000); protected override double ObjectDifficultyOf(DifficultyHitObject current) { + const double skill_multiplier = 1.16; + if (Mods.Any(m => m is OsuModRelax)) return 0; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 9ec205496ddf..bbb24ff732de 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -139,7 +139,7 @@ private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes double monoAccScalingExponent = 2 + attributes.MonoStaminaFactor; double monoAccScalingShift = 500 - 100 * (attributes.MonoStaminaFactor * 3); - return difficultyValue * DiffUtils.Pow(DiffUtils.Erf(monoAccScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), monoAccScalingExponent); + return difficultyValue * DiffUtils.Pow(DiffUtils.Erf(monoAccScalingShift / (DiffUtils.SQRT2 * estimatedUnstableRate.Value)), monoAccScalingExponent); } private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) @@ -185,7 +185,7 @@ private double computeDeviationUpperBound(double accuracy) double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); // We can be 99% confident that the deviation is not higher than: - return greatHitWindow / (Math.Sqrt(2) * DiffUtils.ErfInv(pLowerBound)); + return greatHitWindow / (DiffUtils.SQRT2 * DiffUtils.ErfInv(pLowerBound)); } private int totalHits => countGreat + countOk + countMeh + countMiss; diff --git a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs index 93e2ffe00550..0660a08bcb18 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs @@ -8,6 +8,11 @@ namespace osu.Game.Rulesets.Difficulty.Utils { public static partial class DiffUtils { + /// + /// Square root of 2 + /// + public const double SQRT2 = 1.4142135623730950; + /// /// Converts BPM value into milliseconds /// From 081f4979e70a60bdbcbda69b93aad54f393a30a6 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 29 Jun 2026 15:27:27 +0900 Subject: [PATCH 118/121] Fix outdated benchmark code --- osu.Game.Benchmarks/BenchmarkMathPow.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/osu.Game.Benchmarks/BenchmarkMathPow.cs b/osu.Game.Benchmarks/BenchmarkMathPow.cs index 7148cbf2ea2c..0efcce095576 100644 --- a/osu.Game.Benchmarks/BenchmarkMathPow.cs +++ b/osu.Game.Benchmarks/BenchmarkMathPow.cs @@ -13,15 +13,24 @@ public class BenchmarkMathPow : BenchmarkTest public double Exponent { get; set; } [Benchmark] - public void MathPow() + public double MathPow() { - double _ = Math.Pow(1.299995, Exponent); + return Math.Pow(1.299995, Exponent); } [Benchmark] - public void DiffUtilsPow() + public double DiffUtilsPowDouble() { - double _ = DiffUtils.Pow(1.299995, Exponent); + return DiffUtils.Pow(1.299995, Exponent); + } + + [Benchmark] + public double DiffUtilsPowInt() + { + if ((int)Exponent != Exponent) + throw new NotSupportedException(); + + return DiffUtils.Pow(1.299995, (int)Exponent); } } } From b7caf49ba90db977f58cee7c9741b09bf813ea8d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 29 Jun 2026 15:29:32 +0900 Subject: [PATCH 119/121] Add Add documentation of `Pow` optimisations --- osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs index 0660a08bcb18..03d712a75e74 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs @@ -210,6 +210,8 @@ public static double ErfInv(double x) return erfInv; } + // In actual debug testing it's very rare for a (double, double) call to end up with a rounded int value in the first place. + // Making an explicit overload is slightly faster than running the `switch` in such cases. public static double Pow(double x, double exponent) => Math.Pow(x, exponent); public static double Pow(double x, int exponent) => exponent switch @@ -219,7 +221,7 @@ public static double ErfInv(double x) 2 => x * x, 3 => x * x * x, 4 => x * x * x * x, - 5 => x * x * x * x * x, + 5 => x * x * x * x * x, // This is the largest value used in diffcalc right now. _ => Math.Pow(x, exponent) }; From 6de031e49d8e536be00af7355469a8cebdc6723a Mon Sep 17 00:00:00 2001 From: James Wilson Date: Sat, 25 Jul 2026 15:48:03 +0100 Subject: [PATCH 120/121] Apply a few misc diff calc clean-ups (#38199) Hopefully each commit should be self-explanatory. --- .../Evaluators/MovementEvaluator.cs | 2 +- .../Evaluators/Aim/FlowAimEvaluator.cs | 7 +- .../Evaluators/Aim/SnapAimEvaluator.cs | 7 +- .../Difficulty/OsuDifficultyCalculator.cs | 6 +- .../Difficulty/Skills/Aim.cs | 21 +-- .../Difficulty/Skills/Speed.cs | 3 +- osu.Game.Tests/NonVisual/ReverseQueueTest.cs | 146 ------------------ .../Difficulty/Skills/HarmonicSkill.cs | 11 +- .../Rulesets/Difficulty/Skills/StrainSkill.cs | 3 +- .../Skills/VariableLengthStrainSkill.cs | 3 +- .../Rulesets/Difficulty/Utils/DiffUtils.cs | 4 +- .../Rulesets/Difficulty/Utils/ReverseQueue.cs | 134 ---------------- 12 files changed, 29 insertions(+), 318 deletions(-) delete mode 100644 osu.Game.Tests/NonVisual/ReverseQueueTest.cs delete mode 100644 osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs diff --git a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs index 12d455478a4d..9f103b810f27 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs @@ -46,7 +46,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) } // Linear spacing nerf. - double linearSpacingCount = 0; + int linearSpacingCount = 0; for (int i = 0; i < Math.Min(current.Index, 10); i++) { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index 3707d4061342..cea98ff010f0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -17,13 +17,14 @@ public static class FlowAimEvaluator /// public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance) { - if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)current.Previous(); + + if (current.BaseObject is Spinner || current.Index <= 1 || osuLastObj.BaseObject is Spinner) return 0; const double velocity_change_multiplier = 0.52; - var osuCurrObj = (OsuDifficultyHitObject)current; - var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs index 451f67b45090..a345b2aa5fb7 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -22,7 +22,10 @@ public static class SnapAimEvaluator /// public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance) { - if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)current.Previous(); + + if (current.BaseObject is Spinner || current.Index <= 1 || osuLastObj.BaseObject is Spinner) return 0; const double wide_angle_multiplier = 9.67; @@ -33,8 +36,6 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation const double wiggle_multiplier = 1.02; - var osuCurrObj = (OsuDifficultyHitObject)current; - var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2); const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 7afe5391df11..0bcfb8c158e7 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -66,11 +66,13 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat int totalHits = beatmap.HitObjects.Count; + double aimRating = calculateAimDifficultyRating(aimDifficultyValue); + double aimNoSlidersRating = calculateAimDifficultyRating(aimNoSlidersDifficultyValue); + double sliderFactor = aimDifficultyValue > 0 - ? calculateAimDifficultyRating(aimNoSlidersDifficultyValue) / calculateAimDifficultyRating(aimDifficultyValue) + ? aimNoSlidersRating / aimRating : 1; - double aimRating = calculateAimDifficultyRating(aimDifficultyValue); double speedRating = calculateDifficultyRating(speedDifficultyValue); double readingRating = calculateDifficultyRating(readingDifficultyValue); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 11e78c00bbf1..30a20d2827d5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -31,17 +31,6 @@ public Aim(Mod[] mods, bool includeSliders) private double currentStrain; - /// - /// The number of sections with the highest strains, which the peak strain reductions will apply to. - /// This is done in order to decrease their impact on the overall difficulty of the map for this skill. - /// - private int reducedSectionTime => 4000; - - /// - /// The baseline multiplier applied to the section with the biggest strain. - /// - private const double reduced_strain_baseline = 0.727; - private readonly List sliderStrains = new List(); private double strainDecay(double ms) => DiffUtils.Pow(0.2, ms / 1000); @@ -151,7 +140,7 @@ public double GetDifficultSliders() if (maxSliderStrain == 0) return 0; - return sliderStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0)))); + return sliderStrains.Sum(strain => DiffUtils.Logistic(strain / maxSliderStrain, 0.5, 12.0)); } public double CountTopWeightedSliders(double difficultyValue) @@ -212,9 +201,11 @@ Doing this ensures the relationship between strain values and difficulty values /// private IEnumerable getReducedStrainPeaks() { + const int reduced_section_time = 4000; + const double reduced_strain_baseline = 0.727; + // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). // These sections will not contribute to the difficulty. - List strains = GetCurrentStrainPeaks() .Where(p => p.Value > 0) .ToList(); @@ -225,13 +216,13 @@ private IEnumerable getReducedStrainPeaks() // We are reducing the highest strains first to account for extreme difficulty spikes // Strains are split into 20ms chunks to try to mitigate inconsistencies caused by reducing strains - while (strains.Count > skipCount && time < reducedSectionTime) + while (strains.Count > skipCount && time < reduced_section_time) { StrainPeak strain = strains[skipCount]; for (double addedTime = 0; addedTime < strain.SectionLength; addedTime += chunk_size) { - double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((time + addedTime) / reducedSectionTime, 0, 1))); + double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((time + addedTime) / reduced_section_time, 0, 1))); // intentionally add at end and sort afterwards, should be cheaper. strains.Add(new StrainPeak( diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index a4432d445816..f8ab313cb76a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; @@ -76,7 +75,7 @@ public double RelevantObjectCount() if (maxStrain == 0) return 0; - return ObjectDifficulties.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); + return ObjectDifficulties.Sum(strain => DiffUtils.Logistic(strain / maxStrain, 0.5, 12.0)); } public double CountTopWeightedSliders(double difficultyValue) diff --git a/osu.Game.Tests/NonVisual/ReverseQueueTest.cs b/osu.Game.Tests/NonVisual/ReverseQueueTest.cs deleted file mode 100644 index 7b87ba248115..000000000000 --- a/osu.Game.Tests/NonVisual/ReverseQueueTest.cs +++ /dev/null @@ -1,146 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using NUnit.Framework; -using NUnit.Framework.Legacy; -using osu.Game.Rulesets.Difficulty.Utils; - -namespace osu.Game.Tests.NonVisual -{ - [TestFixture] - public class ReverseQueueTest - { - private ReverseQueue queue; - - [SetUp] - public void Setup() - { - queue = new ReverseQueue(4); - } - - [Test] - public void TestEmptyQueue() - { - ClassicAssert.AreEqual(0, queue.Count); - - Assert.Throws(() => - { - char unused = queue[0]; - }); - - int count = 0; - foreach (char unused in queue) - count++; - - ClassicAssert.AreEqual(0, count); - } - - [Test] - public void TestEnqueue() - { - // Assert correct values and reverse index after enqueueing - queue.Enqueue('a'); - queue.Enqueue('b'); - queue.Enqueue('c'); - - ClassicAssert.AreEqual('c', queue[0]); - ClassicAssert.AreEqual('b', queue[1]); - ClassicAssert.AreEqual('a', queue[2]); - - // Assert correct values and reverse index after enqueueing beyond initial capacity of 4 - queue.Enqueue('d'); - queue.Enqueue('e'); - queue.Enqueue('f'); - - ClassicAssert.AreEqual('f', queue[0]); - ClassicAssert.AreEqual('e', queue[1]); - ClassicAssert.AreEqual('d', queue[2]); - ClassicAssert.AreEqual('c', queue[3]); - ClassicAssert.AreEqual('b', queue[4]); - ClassicAssert.AreEqual('a', queue[5]); - } - - [Test] - public void TestDequeue() - { - queue.Enqueue('a'); - queue.Enqueue('b'); - queue.Enqueue('c'); - queue.Enqueue('d'); - queue.Enqueue('e'); - queue.Enqueue('f'); - - // Assert correct item return and no longer in queue after dequeueing - ClassicAssert.AreEqual('a', queue[5]); - char dequeuedItem = queue.Dequeue(); - - ClassicAssert.AreEqual('a', dequeuedItem); - ClassicAssert.AreEqual(5, queue.Count); - ClassicAssert.AreEqual('f', queue[0]); - ClassicAssert.AreEqual('b', queue[4]); - Assert.Throws(() => - { - char unused = queue[5]; - }); - - // Assert correct state after enough enqueues and dequeues to wrap around array (queue.start = 0 again) - queue.Enqueue('g'); - queue.Enqueue('h'); - queue.Enqueue('i'); - queue.Dequeue(); - queue.Dequeue(); - queue.Dequeue(); - queue.Dequeue(); - queue.Dequeue(); - queue.Dequeue(); - queue.Dequeue(); - - ClassicAssert.AreEqual(1, queue.Count); - ClassicAssert.AreEqual('i', queue[0]); - } - - [Test] - public void TestClear() - { - queue.Enqueue('a'); - queue.Enqueue('b'); - queue.Enqueue('c'); - queue.Enqueue('d'); - queue.Enqueue('e'); - queue.Enqueue('f'); - - // Assert queue is empty after clearing - queue.Clear(); - - ClassicAssert.AreEqual(0, queue.Count); - Assert.Throws(() => - { - char unused = queue[0]; - }); - } - - [Test] - public void TestEnumerator() - { - queue.Enqueue('a'); - queue.Enqueue('b'); - queue.Enqueue('c'); - queue.Enqueue('d'); - queue.Enqueue('e'); - queue.Enqueue('f'); - - char[] expectedValues = { 'f', 'e', 'd', 'c', 'b', 'a' }; - int expectedValueIndex = 0; - - // Assert items are enumerated in correct order - foreach (char item in queue) - { - ClassicAssert.AreEqual(expectedValues[expectedValueIndex], item); - expectedValueIndex++; - } - } - } -} diff --git a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs index c6e081272efa..f514a11281ee 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs @@ -53,18 +53,13 @@ public override double DifficultyValue() if (ObjectDifficulties.Count == 0) return 0; - // Objects with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). - // These objects will not contribute to the difficulty. - var difficulties = ObjectDifficulties; - - if (difficulties.Count == 0) - return 0; - - difficulties = GetTransformedDifficulties(difficulties); + List difficulties = GetTransformedDifficulties(ObjectDifficulties); double difficulty = 0; int index = 0; + // Objects with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These objects will not contribute to the difficulty. foreach (double obj in difficulties.OrderDescending().Where(v => v > 0)) { // Use a harmonic sum that considers each object of the map according to a predefined weight. diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index e26ce15dd26c..269888ebd7ed 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Difficulty.Skills @@ -77,7 +78,7 @@ public double CountTopWeightedStrains(double difficultyValue) return ObjectDifficulties.Count; // Use a weighted sum of all strains. Constants are arbitrary and give nice values - return ObjectDifficulties.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); + return ObjectDifficulties.Sum(s => DiffUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1)); } /// diff --git a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs index c94f469a1c9e..4d18f76669f5 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Difficulty.Skills @@ -232,7 +233,7 @@ public virtual double CountTopWeightedStrains(double difficultyValue) return ObjectDifficulties.Count; // Use a weighted sum of all strains. Constants are arbitrary and give nice values - return ObjectDifficulties.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); + return ObjectDifficulties.Sum(s => DiffUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1)); } /// diff --git a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs index 03d712a75e74..4548e0a18f81 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs @@ -39,9 +39,9 @@ public static double MillisecondsToBPM(double ms, int delimiter = 4) /// Calculates a S-shaped logistic function (https://en.wikipedia.org/wiki/Logistic_function) /// /// Value to calculate the function for - /// Maximum value returnable by the function - /// Growth rate of the function /// How much the function midpoint is offset from zero + /// Growth rate of the function + /// Maximum value returnable by the function /// The output of logistic function of public static double Logistic(double x, double midpointOffset, double multiplier, double maxValue = 1) => maxValue / (1 + Math.Exp(multiplier * (midpointOffset - x))); diff --git a/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs b/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs deleted file mode 100644 index 17c3c51cfba7..000000000000 --- a/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using System.Collections; -using System.Collections.Generic; - -namespace osu.Game.Rulesets.Difficulty.Utils -{ - /// - /// An indexed queue where items are indexed beginning from the most recently enqueued item. - /// Enqueuing an item pushes all existing indexes up by one and inserts the item at index 0. - /// Dequeuing an item removes the item from the highest index and returns it. - /// - public class ReverseQueue : IEnumerable - { - /// - /// The number of elements in the . - /// - public int Count { get; private set; } - - private T[] items; - private int capacity; - private int start; - - public ReverseQueue(int initialCapacity) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(initialCapacity); - - items = new T[initialCapacity]; - capacity = initialCapacity; - start = 0; - Count = 0; - } - - /// - /// Retrieves the item at an index in the . - /// - /// The index of the item to retrieve. The most recently enqueued item is at index 0. - public T this[int index] - { - get - { - if (index < 0 || index > Count - 1) - throw new ArgumentOutOfRangeException(nameof(index)); - - int reverseIndex = Count - 1 - index; - return items[(start + reverseIndex) % capacity]; - } - } - - /// - /// Enqueues an item to this . - /// - /// The item to enqueue. - public void Enqueue(T item) - { - if (Count == capacity) - { - // Double the buffer size - var buffer = new T[capacity * 2]; - - // Copy items to new queue - for (int i = 0; i < Count; i++) - { - buffer[i] = items[(start + i) % capacity]; - } - - // Replace array with new buffer - items = buffer; - capacity *= 2; - start = 0; - } - - items[(start + Count) % capacity] = item; - Count++; - } - - /// - /// Dequeues the least recently enqueued item from the and returns it. - /// - /// The item dequeued from the . - public T Dequeue() - { - var item = items[start]; - start = (start + 1) % capacity; - Count--; - return item; - } - - /// - /// Clears the of all items. - /// - public void Clear() - { - start = 0; - Count = 0; - } - - /// - /// Returns an enumerator which enumerates items in the starting from the most recently enqueued item. - /// - public IEnumerator GetEnumerator() => new Enumerator(this); - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - public struct Enumerator : IEnumerator - { - private ReverseQueue reverseQueue; - private int currentIndex; - - internal Enumerator(ReverseQueue reverseQueue) - { - this.reverseQueue = reverseQueue; - currentIndex = -1; // The first MoveNext() should bring the iterator to 0 - } - - public bool MoveNext() => ++currentIndex < reverseQueue.Count; - - public void Reset() => currentIndex = -1; - - public readonly T Current => reverseQueue[currentIndex]; - - readonly object IEnumerator.Current => Current; - - public void Dispose() - { - reverseQueue = null; - } - } - } -} From 347790b3bfa27413b99921bfa087871a20ab75de Mon Sep 17 00:00:00 2001 From: piiidosu Date: Mon, 27 Jul 2026 16:51:44 +1200 Subject: [PATCH 121/121] reading peak difficulty reduction with early reading reduction --- .../Difficulty/Skills/Reading.cs | 62 +++++++++++++------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index b28352131aef..e40fd27008a6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -71,41 +71,63 @@ private double calculateAdjustedDifficulty(DifficultyHitObject current) protected override List GetTransformedDifficulties(List difficulties) { - difficulties = difficulties.Where(v => v > 0).ToList(); + if (difficulties.Count == 0) + return difficulties; + + const double reduced_difficulty_duration = 40 * 1000; + const double reduced_difficulty_base_line = 0.5; // Assume the first seconds are completely memorised - const double reduced_difficulty_base_line = 0.0; // Assume the first seconds are completely memorised + double firstStartTime = objectList.First().StartTime; + double reducedDuration = firstStartTime + reduced_difficulty_duration; - int reducedNoteCount = calculateReducedNoteCount(); + if (objectList.Count == 0 || difficulties.Count == 0) + return difficulties; - for (int i = 0; i < Math.Min(difficulties.Count, reducedNoteCount); i++) + for (int i = 0; i < Math.Min(objectList.Count, difficulties.Count); i++) { - double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((double)i / reducedNoteCount, 0, 1))); + DifficultyHitObject hitObject = objectList[i]; + + if (hitObject.StartTime > reducedDuration) + break; + + double ratio = (hitObject.StartTime - firstStartTime) / (reducedDuration - firstStartTime); + double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp(ratio, 0, 1))); difficulties[i] *= Interpolation.Lerp(reduced_difficulty_base_line, 1.0, scale); } - return difficulties; - } + const double memory_per_object = 0.02; + const double memory_over_time = 0.04 / 1000; + const int maximum_sections_memorised = 40; + const int minimum_sections_memorised = 10; - private int calculateReducedNoteCount() - { - const double reduced_difficulty_duration = 60 * 1000; + int sectionsMemorised = Math.Max((int)(memory_per_object * difficulties.Count), (int)(memory_over_time * (objectList[0].StartTime - objectList[^1].StartTime))); + sectionsMemorised = Math.Clamp(sectionsMemorised, minimum_sections_memorised, maximum_sections_memorised); - if (objectList.Count == 0) - return 0; + for (int i = 0; i < sectionsMemorised; i++) + { - double reducedDuration = objectList.First().StartTime + reduced_difficulty_duration; + int index = 0; - int reducedNoteCount = 0; + for (int j = 1; j < difficulties.Count; j++) + { + if (difficulties[j] > difficulties[index]) + { + index = j; + } + } - foreach (var hitObject in objectList) - { - if (hitObject.StartTime > reducedDuration) - break; + int lower = Math.Max(0, index - 9); + int upper = Math.Min(difficulties.Count - 1, index + 9); - reducedNoteCount++; + for (int j = lower; j <= upper; j++) + { + difficulties[j] *= 1 - ((10 - Math.Abs(index - j)) / 200.0); + } } - return reducedNoteCount; + difficulties = difficulties.Where(v => v > 0).ToList(); + + return difficulties; } public override double CountTopWeightedObjectDifficulties(double difficultyValue)