From 2c88e60ed3e67b174a171b2531ebf9ae5b3f169a Mon Sep 17 00:00:00 2001 From: StanR Date: Mon, 24 Mar 2025 02:08:41 +0500 Subject: [PATCH 01/58] Add difficulty calculation benchmarks (#32542) --- .../BenchmarkDifficultyCalculation.cs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 osu.Game.Benchmarks/BenchmarkDifficultyCalculation.cs diff --git a/osu.Game.Benchmarks/BenchmarkDifficultyCalculation.cs b/osu.Game.Benchmarks/BenchmarkDifficultyCalculation.cs new file mode 100644 index 0000000000..eaa4f5cc28 --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkDifficultyCalculation.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using BenchmarkDotNet.Attributes; +using osu.Framework.IO.Stores; +using osu.Game.Beatmaps; +using osu.Game.IO; +using osu.Game.IO.Archives; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; +using osu.Game.Tests.Resources; + +namespace osu.Game.Benchmarks +{ + public class BenchmarkDifficultyCalculation : BenchmarkTest + { + private WorkingBeatmap osuBeatmap = null!; + private WorkingBeatmap taikoBeatmap = null!; + private WorkingBeatmap catchBeatmap = null!; + private WorkingBeatmap maniaBeatmap = null!; + + public override void SetUp() + { + using var resources = new DllResourceStore(typeof(TestResources).Assembly); + + using var archive = resources.GetStream("Resources/Archives/241526 Soleily - Renatus.osz"); + using var archiveReader = new ZipArchiveReader(archive); + + osuBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (Gamu) [Insane].osu"); + taikoBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (MMzz) [Oni].osu"); + catchBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (Deif) [Salad].osu"); + maniaBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (ExPew) [Another].osu"); + } + + private WorkingBeatmap readBeatmap(ZipArchiveReader archiveReader, string beatmapName) + { + using var beatmapStream = new MemoryStream(); + archiveReader.GetStream(beatmapName).CopyTo(beatmapStream); + + beatmapStream.Seek(0, SeekOrigin.Begin); + using var reader = new LineBufferedReader(beatmapStream); + + var decoder = Beatmaps.Formats.Decoder.GetDecoder(reader); + return new FlatWorkingBeatmap(decoder.Decode(reader)); + } + + [Benchmark] + public void CalculateDifficultyOsu() => new OsuRuleset().CreateDifficultyCalculator(osuBeatmap).Calculate(); + + [Benchmark] + public void CalculateDifficultyTaiko() => new TaikoRuleset().CreateDifficultyCalculator(taikoBeatmap).Calculate(); + + [Benchmark] + public void CalculateDifficultyCatch() => new CatchRuleset().CreateDifficultyCalculator(catchBeatmap).Calculate(); + + [Benchmark] + public void CalculateDifficultyMania() => new ManiaRuleset().CreateDifficultyCalculator(maniaBeatmap).Calculate(); + + [Benchmark] + public void CalculateDifficultyOsuHundredTimes() + { + var diffcalc = new OsuRuleset().CreateDifficultyCalculator(osuBeatmap); + + for (int i = 0; i < 100; i++) + { + diffcalc.Calculate(); + } + } + } +} From 8b11be5ac03d172a5b0cc3a7b58fbe12aded39af Mon Sep 17 00:00:00 2001 From: Jay Lawton Date: Mon, 24 Mar 2025 20:07:23 +1000 Subject: [PATCH 02/58] osu!taiko skills refactor (#32426) Co-authored-by: James Wilson --- .../Difficulty/TaikoDifficultyCalculator.cs | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index e0bc0e177c..83b02f0b30 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -108,35 +108,43 @@ namespace osu.Game.Rulesets.Taiko.Difficulty var stamina = skills.OfType().Single(s => !s.SingleColourStamina); var singleColourStamina = skills.OfType().Single(s => s.SingleColourStamina); - double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier; - double readingRating = reading.DifficultyValue() * reading_skill_multiplier; - double colourRating = colour.DifficultyValue() * colour_skill_multiplier; - double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier; - double monoStaminaRating = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; - double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5); + 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 monoStaminaSkill = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; + double monoStaminaFactor = staminaSkill == 0 ? 1 : Math.Pow(monoStaminaSkill / staminaSkill, 5); double colourDifficultStrains = colour.CountTopWeightedStrains(); double rhythmDifficultStrains = rhythm.CountTopWeightedStrains(); double staminaDifficultStrains = stamina.CountTopWeightedStrains(); // As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm. - patternMultiplier = Math.Pow(staminaRating * colourRating, 0.10); + patternMultiplier = Math.Pow(staminaSkill * colourSkill, 0.10); strainLengthBonus = 1 + Math.Min(Math.Max((staminaDifficultStrains - 1000) / 3700, 0), 0.15) - + Math.Min(Math.Max((staminaRating - 7.0) / 1.0, 0), 0.05); + + Math.Min(Math.Max((staminaSkill - 7.0) / 1.0, 0), 0.05); double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, isRelax, isConvert); double starRating = rescale(combinedRating * 1.4); + // Calculate proportional contribution of each skill to the combinedRating. + double skillRating = starRating / (rhythmSkill + readingSkill + colourSkill + staminaSkill); + + double rhythmDifficulty = rhythmSkill * skillRating; + double readingDifficulty = readingSkill * skillRating; + double colourDifficulty = colourSkill * skillRating; + double staminaDifficulty = staminaSkill * skillRating; + TaikoDifficultyAttributes attributes = new TaikoDifficultyAttributes { StarRating = starRating, Mods = mods, - RhythmDifficulty = rhythmRating, - ReadingDifficulty = readingRating, - ColourDifficulty = colourRating, - StaminaDifficulty = staminaRating, + RhythmDifficulty = rhythmDifficulty, + ReadingDifficulty = readingDifficulty, + ColourDifficulty = colourDifficulty, + StaminaDifficulty = staminaDifficulty, MonoStaminaFactor = monoStaminaFactor, RhythmTopStrains = rhythmDifficultStrains, ColourTopStrains = colourDifficultStrains, From 0c3ee1938ed71d506d6c67ed3c719a58ffda9291 Mon Sep 17 00:00:00 2001 From: wulpine Date: Mon, 24 Mar 2025 20:04:40 +0300 Subject: [PATCH 03/58] Fix osu!catch SR buzz slider detection (#32412) * Use `normalized_hitobject_radius` during osu!catch buzz slider detection Currently the algorithm considers some buzz sliders as standstills when in reality they require movement. This happens because `HalfCatcherWidth` isn't normalized while `exactDistanceMoved` is, leading to an inaccurate comparison. `normalized_hitobject_radius` is the normalized value of `HalfCatcherWidth` and replacing one with the other fixes the problem. * Rename `normalized_hitobject_radius` to `normalized_half_catcher_width` The current name is confusing because hit objects have no radius in the context of osu!catch difficulty calculation. The new name conveys the actual purpose of the value. * Only set `normalized_half_catcher_width` in `CatchDifficultyHitObject` Prevents potential bugs if the value were to be changed in one of the classes but not in both. * Use `CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH` directly Requested during code review. --------- Co-authored-by: James Wilson --- .../Preprocessing/CatchDifficultyHitObject.cs | 4 ++-- .../Difficulty/Skills/Movement.cs | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs index 3bcfce3a56..9a7bbb4e9e 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing { public class CatchDifficultyHitObject : DifficultyHitObject { - private const float normalized_hitobject_radius = 41.0f; + public const float NORMALIZED_HALF_CATCHER_WIDTH = 41.0f; public new PalpableCatchHitObject BaseObject => (PalpableCatchHitObject)base.BaseObject; @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing : base(hitObject, lastObject, clockRate, objects, index) { // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. - float scalingFactor = normalized_hitobject_radius / halfCatcherWidth; + float scalingFactor = NORMALIZED_HALF_CATCHER_WIDTH / halfCatcherWidth; NormalizedPosition = BaseObject.EffectiveX * scalingFactor; LastNormalizedPosition = LastObject.EffectiveX * scalingFactor; diff --git a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs index 559e9dafa0..b69bfb9215 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -12,7 +12,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills public class Movement : StrainDecaySkill { private const float absolute_player_positioning_error = 16f; - private const float normalized_hitobject_radius = 41.0f; private const double direction_change_bonus = 21.0; protected override double SkillMultiplier => 1; @@ -55,8 +54,8 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills float playerPosition = Math.Clamp( lastPlayerPosition.Value, - catchCurrent.NormalizedPosition - (normalized_hitobject_radius - absolute_player_positioning_error), - catchCurrent.NormalizedPosition + (normalized_hitobject_radius - absolute_player_positioning_error) + catchCurrent.NormalizedPosition - (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH - absolute_player_positioning_error), + catchCurrent.NormalizedPosition + (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH - absolute_player_positioning_error) ); float distanceMoved = playerPosition - lastPlayerPosition.Value; @@ -83,7 +82,8 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills } // Base bonus for every movement, giving some weight to streams. - distanceAddition += 12.5 * Math.Min(Math.Abs(distanceMoved), normalized_hitobject_radius * 2) / (normalized_hitobject_radius * 6) / sqrtStrain; + distanceAddition += 12.5 * Math.Min(Math.Abs(distanceMoved), CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2) / (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 6) + / sqrtStrain; } // Bonus for edge dashes. @@ -102,10 +102,11 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills } // There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than - // the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and normalized_hitobject_radius offsets + // the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH offsets // We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified. - // To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and normalized_hitobject_radius) - if (Math.Abs(exactDistanceMoved) <= HalfCatcherWidth * 2 && exactDistanceMoved == -lastExactDistanceMoved && catchCurrent.StrainTime == lastStrainTime) + // To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH) + if (Math.Abs(exactDistanceMoved) <= CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2 && exactDistanceMoved == -lastExactDistanceMoved + && catchCurrent.StrainTime == lastStrainTime) { if (isInBuzzSection) distanceAddition = 0; From 69c90f9926f7d4b4e07ab53d69a2e3d15af5a165 Mon Sep 17 00:00:00 2001 From: James Wilson Date: Sun, 6 Apr 2025 09:36:18 +0100 Subject: [PATCH 04/58] Use `Precision.AlmostEquals` to compare deviation lower bound (#32694) --- 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 a667d12a44..98ab39eb24 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Audio.Track; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Utils; @@ -409,7 +410,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty double limitValue = okHitWindow / Math.Sqrt(3); // If precision is not enough to compute true deviation - use limit value - if (pLowerBound == 0 || randomValue >= 1 || deviation > limitValue) + if (Precision.AlmostEquals(pLowerBound, 0.0) || randomValue >= 1 || deviation > limitValue) deviation = limitValue; // Then compute the variance for mehs. From 30f9716db95ccaf47fa2366cbe11d7f17524c895 Mon Sep 17 00:00:00 2001 From: StanR Date: Wed, 9 Apr 2025 17:48:18 +0500 Subject: [PATCH 05/58] Reduce RX Ok multiplier (#32434) --- 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 98ab39eb24..7e2d68b9d8 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -136,10 +136,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (score.Mods.Any(h => h is OsuModRelax)) { - // https://www.desmos.com/calculator/bc9eybdthb + // https://www.desmos.com/calculator/vspzsop6td // 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 = Math.Max(0.0, overallDifficulty > 0.0 ? 1 - Math.Pow(overallDifficulty / 13.33, 1.8) : 1.0); + 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); // 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. From cf7fdc06277d0081a87bb44aba5115c9230098d7 Mon Sep 17 00:00:00 2001 From: StanR Date: Wed, 9 Apr 2025 18:37:26 +0500 Subject: [PATCH 06/58] Move difficulty calculation fields from `Slider` to `OsuDifficultyHitObject` (#32410) * Move difficulty calculation fields from `Slider` to `OsuDifficultyHitObject` * Remove redundant check * Use `LastObject` where possible * Update tests * Make `LazyTravelDistance` `double` --------- Co-authored-by: James Wilson --- .../OsuDifficultyCalculatorTest.cs | 14 ++-- .../Evaluators/FlashlightEvaluator.cs | 2 +- .../Difficulty/OsuDifficultyCalculator.cs | 3 +- .../Preprocessing/OsuDifficultyHitObject.cs | 80 +++++++++++-------- osu.Game.Rulesets.Osu/Objects/Slider.cs | 18 ----- 5 files changed, 56 insertions(+), 61 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index defd02b830..75e6dc6f09 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -16,21 +16,21 @@ namespace osu.Game.Rulesets.Osu.Tests protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests"; [TestCase(6.7331304290522747d, 239, "diffcalc-test")] - [TestCase(1.4602604078137214d, 54, "zero-length-sliders")] - [TestCase(0.43052813047866129d, 4, "very-fast-slider")] + [TestCase(1.4595591215544095d, 54, "zero-length-sliders")] + [TestCase(0.4339253366122357d, 4, "very-fast-slider")] [TestCase(0.14143808967817237d, 2, "nan-slider")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(9.6779746353001634d, 239, "diffcalc-test")] - [TestCase(1.7691451263718989d, 54, "zero-length-sliders")] - [TestCase(0.55785578988249407d, 4, "very-fast-slider")] + [TestCase(9.6779397290273756d, 239, "diffcalc-test")] + [TestCase(1.7680515258663754d, 54, "zero-length-sliders")] + [TestCase(0.56174427678665129d, 4, "very-fast-slider")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); [TestCase(6.7331304290522747d, 239, "diffcalc-test")] - [TestCase(1.4602604078137214d, 54, "zero-length-sliders")] - [TestCase(0.43052813047866129d, 4, "very-fast-slider")] + [TestCase(1.4595591215544095d, 54, "zero-length-sliders")] + [TestCase(0.4339253366122357d, 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/Difficulty/Evaluators/FlashlightEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs index 9d05f0b074..d64a2c2f15 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs @@ -95,7 +95,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators if (osuCurrent.BaseObject is Slider osuSlider) { // Invert the scaling factor to determine the true travel distance independent of circle size. - double pixelTravelDistance = osuSlider.LazyTravelDistance / scalingFactor; + double pixelTravelDistance = osuCurrent.LazyTravelDistance / scalingFactor; // Reward sliders based on velocity. sliderBonus = Math.Pow(Math.Max(0.0, pixelTravelDistance / osuCurrent.TravelTime - min_velocity), 0.5); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index eb2cb95972..5da6df236e 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -124,8 +124,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty // If the map has less than two OsuHitObjects, the enumerator will not return anything. for (int i = 1; i < beatmap.HitObjects.Count; i++) { - var lastLast = i > 1 ? beatmap.HitObjects[i - 2] : null; - objects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], lastLast, clockRate, objects, objects.Count)); + objects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], clockRate, objects, objects.Count)); } return objects; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 5e4c5c1ee9..4329a25f34 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -28,6 +28,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f; protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject; + protected new OsuHitObject LastObject => (OsuHitObject)base.LastObject; /// /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms. @@ -75,6 +76,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double TravelTime { get; private set; } + /// + /// The position of the cursor at the point of completion of this if it is a + /// and was hit with as few movements as possible. + /// + public Vector2? LazyEndPosition { get; private set; } + + /// + /// The distance travelled by the cursor upon completion of this if it is a + /// and was hit with as few movements as possible. + /// + public double LazyTravelDistance { get; private set; } + + /// + /// The time taken by the cursor upon completion of this if it is a + /// and was hit with as few movements as possible. + /// + public double LazyTravelTime { get; private set; } + /// /// Angle the player has to take to hit this . /// Calculated as the angle between the circles (current-2, current-1, current). @@ -86,14 +105,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double HitWindowGreat { get; private set; } - private readonly OsuHitObject? lastLastObject; - private readonly OsuHitObject lastObject; + private readonly OsuDifficultyHitObject? lastLastDifficultyObject; + private readonly OsuDifficultyHitObject? lastDifficultyObject; - public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject? lastLastObject, double clockRate, List objects, int index) + public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int index) : base(hitObject, lastObject, clockRate, objects, index) { - this.lastLastObject = lastLastObject as OsuHitObject; - this.lastObject = (OsuHitObject)lastObject; + 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. StrainTime = Math.Max(DeltaTime, MIN_DELTA_TIME); @@ -107,6 +126,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate; } + computeSliderCursorPosition(); setDistances(clockRate); } @@ -161,14 +181,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing { if (BaseObject is Slider currentSlider) { - computeSliderCursorPosition(currentSlider); // Bonus for repeat sliders until a better per nested object strain system can be achieved. - TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); - TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME); + TravelDistance = LazyTravelDistance * Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); + TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME); } // 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) + if (BaseObject is Spinner || LastObject is Spinner) return; // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps. @@ -180,15 +199,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing scalingFactor *= 1 + smallCircleBonus; } - Vector2 lastCursorPosition = getEndCursorPosition(lastObject); + Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition; LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; MinimumJumpTime = StrainTime; MinimumJumpDistance = LazyJumpDistance; - if (lastObject is Slider lastSlider) + if (LastObject is Slider lastSlider && lastDifficultyObject != null) { - double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME); + double lastTravelTime = Math.Max(lastDifficultyObject.LazyTravelTime / clockRate, MIN_DELTA_TIME); MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, MIN_DELTA_TIME); // @@ -217,11 +236,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing MinimumJumpDistance = Math.Max(0, Math.Min(LazyJumpDistance - (maximum_slider_radius - assumed_slider_radius), tailJumpDistance - maximum_slider_radius)); } - if (lastLastObject != null && !(lastLastObject is Spinner)) + if (lastLastDifficultyObject != null && lastLastDifficultyObject.BaseObject is not Spinner) { - Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastObject); + Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastDifficultyObject); - Vector2 v1 = lastLastCursorPosition - lastObject.StackedPosition; + Vector2 v1 = lastLastCursorPosition - LastObject.StackedPosition; Vector2 v2 = BaseObject.StackedPosition - lastCursorPosition; float dot = Vector2.Dot(v1, v2); @@ -231,9 +250,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing } } - private void computeSliderCursorPosition(Slider slider) + private void computeSliderCursorPosition() { - if (slider.LazyEndPosition != null) + if (BaseObject is not Slider slider) + return; + + if (LazyEndPosition != null) return; // TODO: This commented version is actually correct by the new lazer implementation, but intentionally held back from @@ -280,15 +302,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing nestedObjects = reordered; } - slider.LazyTravelTime = trackingEndTime - slider.StartTime; + LazyTravelTime = trackingEndTime - slider.StartTime; - double endTimeMin = slider.LazyTravelTime / slider.SpanDuration; + double endTimeMin = LazyTravelTime / slider.SpanDuration; if (endTimeMin % 2 >= 1) endTimeMin = 1 - endTimeMin % 1; else endTimeMin %= 1; - slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived. + LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived. Vector2 currCursorPosition = slider.StackedPosition; @@ -310,7 +332,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing // There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement. // For sliders that are circular, the lazy end position may actually be farther away than the sliders true end. // This code is designed to prevent buffing situations where lazy end is actually a less efficient movement. - Vector2 lazyMovement = Vector2.Subtract((Vector2)slider.LazyEndPosition, currCursorPosition); + Vector2 lazyMovement = Vector2.Subtract((Vector2)LazyEndPosition, currCursorPosition); if (lazyMovement.Length < currMovement.Length) currMovement = lazyMovement; @@ -328,25 +350,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing // this finds the positional delta from the required radius and the current position, and updates the currCursorPosition accordingly, as well as rewarding distance. currCursorPosition = Vector2.Add(currCursorPosition, Vector2.Multiply(currMovement, (float)((currMovementLength - requiredMovement) / currMovementLength))); currMovementLength *= (currMovementLength - requiredMovement) / currMovementLength; - slider.LazyTravelDistance += (float)currMovementLength; + LazyTravelDistance += currMovementLength; } if (i == nestedObjects.Count - 1) - slider.LazyEndPosition = currCursorPosition; + LazyEndPosition = currCursorPosition; } } - private Vector2 getEndCursorPosition(OsuHitObject hitObject) + private Vector2 getEndCursorPosition(OsuDifficultyHitObject difficultyHitObject) { - Vector2 pos = hitObject.StackedPosition; - - if (hitObject is Slider slider) - { - computeSliderCursorPosition(slider); - pos = slider.LazyEndPosition ?? pos; - } - - return pos; + return difficultyHitObject.LazyEndPosition ?? difficultyHitObject.BaseObject.StackedPosition; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index e484efb408..94e98fbef7 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -68,24 +68,6 @@ namespace osu.Game.Rulesets.Osu.Objects } } - /// - /// The position of the cursor at the point of completion of this if it was hit - /// with as few movements as possible. This is set and used by difficulty calculation. - /// - internal Vector2? LazyEndPosition; - - /// - /// The distance travelled by the cursor upon completion of this if it was hit - /// with as few movements as possible. This is set and used by difficulty calculation. - /// - internal float LazyTravelDistance; - - /// - /// The time taken by the cursor upon completion of this if it was hit - /// with as few movements as possible. This is set and used by difficulty calculation. - /// - internal double LazyTravelTime; - public IList> NodeSamples { get; set; } = new List>(); [JsonIgnore] From 7a9d31adb6c05d532de0eaebc56afcdd14f7917b Mon Sep 17 00:00:00 2001 From: wulpine Date: Thu, 10 Apr 2025 18:47:11 +0300 Subject: [PATCH 07/58] Move osu!catch movement diffcalc to an evaluator (#32655) * Move osu!catch movement state into `CatchDifficultyHitObject` In order to port `Movement` to an evaluator, the state has to be either moved elsewhere or calculated inside the evaluator. The latter requires backtracking for every hit object, which in the worst case is continued until the beginning of the map is reached. Limiting backtracking can lead to difficulty value changes. Thus, the first option was chosen for its simplicity. * Move osu!catch movement difficulty calculation to an evaluator Makes the code more in line with the other game modes. * Add documentation for `CatchDifficultyHitObject` fields --------- Co-authored-by: James Wilson --- .../Evaluators/MovementEvaluator.cs | 65 ++++++++++++++ .../Preprocessing/CatchDifficultyHitObject.cs | 56 ++++++++++++ .../Difficulty/Skills/Movement.cs | 89 +------------------ 3 files changed, 123 insertions(+), 87 deletions(-) create mode 100644 osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs diff --git a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs new file mode 100644 index 0000000000..618b183943 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs @@ -0,0 +1,65 @@ +// 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.Catch.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Preprocessing; + +namespace osu.Game.Rulesets.Catch.Difficulty.Evaluators +{ + public static class MovementEvaluator + { + private const double direction_change_bonus = 21.0; + + public static double EvaluateDifficultyOf(DifficultyHitObject current, double catcherSpeedMultiplier) + { + var catchCurrent = (CatchDifficultyHitObject)current; + var catchLast = (CatchDifficultyHitObject)current.Previous(0); + var catchLastLast = (CatchDifficultyHitObject)current.Previous(1); + + double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier); + + double distanceAddition = (Math.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510); + double sqrtStrain = Math.Sqrt(weightedStrainTime); + + double edgeDashBonus = 0; + + // Direction change bonus. + if (Math.Abs(catchCurrent.DistanceMoved) > 0.1) + { + if (current.Index >= 1 && Math.Abs(catchLast.DistanceMoved) > 0.1 && Math.Sign(catchCurrent.DistanceMoved) != Math.Sign(catchLast.DistanceMoved)) + { + 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); + } + + // Base bonus for every movement, giving some weight to streams. + distanceAddition += 12.5 * Math.Min(Math.Abs(catchCurrent.DistanceMoved), CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2) + / (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 6) / sqrtStrain; + } + + // Bonus for edge dashes. + if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f) + { + if (!catchCurrent.LastObject.HyperDash) + 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 + } + + // There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than + // the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH offsets + // We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified. + // To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH) + if (current.Index >= 2 && Math.Abs(catchCurrent.ExactDistanceMoved) <= CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2 + && catchCurrent.ExactDistanceMoved == -catchLast.ExactDistanceMoved && catchLast.ExactDistanceMoved == -catchLastLast.ExactDistanceMoved + && catchCurrent.StrainTime == catchLast.StrainTime && catchLast.StrainTime == catchLastLast.StrainTime) + distanceAddition = 0; + + return distanceAddition / weightedStrainTime; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs index 9a7bbb4e9e..18b24f731d 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs @@ -12,14 +12,48 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing public class CatchDifficultyHitObject : DifficultyHitObject { public const float NORMALIZED_HALF_CATCHER_WIDTH = 41.0f; + private const float absolute_player_positioning_error = 16.0f; public new PalpableCatchHitObject BaseObject => (PalpableCatchHitObject)base.BaseObject; public new PalpableCatchHitObject LastObject => (PalpableCatchHitObject)base.LastObject; + /// + /// Normalized position of . + /// public readonly float NormalizedPosition; + + /// + /// Normalized position of . + /// public readonly float LastNormalizedPosition; + /// + /// Normalized position of the player required to catch , assuming the player moves as little as possible. + /// + public float PlayerPosition { get; private set; } + + /// + /// Normalized position of the player after catching . + /// + public float LastPlayerPosition { get; private set; } + + /// + /// Normalized distance between and . + /// + /// + /// The sign of the value indicates the direction of the movement: negative is left and positive is right. + /// + public float DistanceMoved { get; private set; } + + /// + /// Normalized distance the player has to move from in order to catch at its . + /// + /// + /// The sign of the value indicates the direction of the movement: negative is left and positive is right. + /// + public float ExactDistanceMoved { get; private set; } + /// /// Milliseconds elapsed since the start time of the previous , with a minimum of 40ms. /// @@ -36,6 +70,28 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure StrainTime = Math.Max(40, DeltaTime); + + setMovementState(); + } + + private void setMovementState() + { + LastPlayerPosition = Index == 0 ? LastNormalizedPosition : ((CatchDifficultyHitObject)Previous(0)).PlayerPosition; + + PlayerPosition = Math.Clamp( + LastPlayerPosition, + NormalizedPosition - (NORMALIZED_HALF_CATCHER_WIDTH - absolute_player_positioning_error), + NormalizedPosition + (NORMALIZED_HALF_CATCHER_WIDTH - absolute_player_positioning_error) + ); + + DistanceMoved = PlayerPosition - LastPlayerPosition; + + // For the exact position we consider that the catcher is in the correct position for both objects + ExactDistanceMoved = NormalizedPosition - LastPlayerPosition; + + // After a hyperdash we ARE in the correct position. Always! + if (LastObject.HyperDash) + PlayerPosition = NormalizedPosition; } } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs index b69bfb9215..90055b9aa3 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -1,8 +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; -using osu.Game.Rulesets.Catch.Difficulty.Preprocessing; +using osu.Game.Rulesets.Catch.Difficulty.Evaluators; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; @@ -11,9 +10,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills { public class Movement : StrainDecaySkill { - private const float absolute_player_positioning_error = 16f; - private const double direction_change_bonus = 21.0; - protected override double SkillMultiplier => 1; protected override double StrainDecayBase => 0.2; @@ -23,12 +19,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills protected readonly float HalfCatcherWidth; - private float? lastPlayerPosition; - private float lastDistanceMoved; - private float lastExactDistanceMoved; - private double lastStrainTime; - private bool isInBuzzSection; - /// /// The speed multiplier applied to the player's catcher. /// @@ -48,82 +38,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills protected override double StrainValueOf(DifficultyHitObject current) { - var catchCurrent = (CatchDifficultyHitObject)current; - - lastPlayerPosition ??= catchCurrent.LastNormalizedPosition; - - float playerPosition = Math.Clamp( - lastPlayerPosition.Value, - catchCurrent.NormalizedPosition - (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH - absolute_player_positioning_error), - catchCurrent.NormalizedPosition + (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH - absolute_player_positioning_error) - ); - - float distanceMoved = playerPosition - lastPlayerPosition.Value; - - // For the exact position we consider that the catcher is in the correct position for both objects - float exactDistanceMoved = catchCurrent.NormalizedPosition - lastPlayerPosition.Value; - - double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier); - - double distanceAddition = (Math.Pow(Math.Abs(distanceMoved), 1.3) / 510); - double sqrtStrain = Math.Sqrt(weightedStrainTime); - - double edgeDashBonus = 0; - - // Direction change bonus. - if (Math.Abs(distanceMoved) > 0.1) - { - if (Math.Abs(lastDistanceMoved) > 0.1 && Math.Sign(distanceMoved) != Math.Sign(lastDistanceMoved)) - { - double bonusFactor = Math.Min(50, Math.Abs(distanceMoved)) / 50; - double antiflowFactor = Math.Max(Math.Min(70, Math.Abs(lastDistanceMoved)) / 70, 0.38); - - distanceAddition += direction_change_bonus / Math.Sqrt(lastStrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - Math.Pow(weightedStrainTime / 1000, 3), 0); - } - - // Base bonus for every movement, giving some weight to streams. - distanceAddition += 12.5 * Math.Min(Math.Abs(distanceMoved), CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2) / (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 6) - / sqrtStrain; - } - - // Bonus for edge dashes. - if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f) - { - if (!catchCurrent.LastObject.HyperDash) - edgeDashBonus += 5.7; - else - { - // After a hyperdash we ARE in the correct position. Always! - playerPosition = catchCurrent.NormalizedPosition; - } - - 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 - } - - // There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than - // the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH offsets - // We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified. - // To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH) - if (Math.Abs(exactDistanceMoved) <= CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2 && exactDistanceMoved == -lastExactDistanceMoved - && catchCurrent.StrainTime == lastStrainTime) - { - if (isInBuzzSection) - distanceAddition = 0; - else - isInBuzzSection = true; - } - else - { - isInBuzzSection = false; - } - - lastPlayerPosition = playerPosition; - lastDistanceMoved = distanceMoved; - lastStrainTime = catchCurrent.StrainTime; - lastExactDistanceMoved = exactDistanceMoved; - - return distanceAddition / weightedStrainTime; + return MovementEvaluator.EvaluateDifficultyOf(current, catcherSpeedMultiplier); } } } From 2aeb80a8bd0e3363e89d47dc6c39591de4a21384 Mon Sep 17 00:00:00 2001 From: James Wilson Date: Sun, 27 Apr 2025 12:30:05 +0100 Subject: [PATCH 08/58] Move all score-independent bonuses into star rating (#31351) * basis refactor to allow for more complex SR calculations * move all possible bonuses into star rating * decrease star rating scaling to account for overall gains * add extra FL guard for safety * move star rating multiplier into a constant * Reorganise some things * Add HD and SO to difficulty adjustment mods * Move non-legacy mod multipliers back to PP * Some merge fixes * Fix application of flashlight rating multiplier * Fix Hidden bonuses being applied when Blinds mod is in use * Move part of speed OD scaling into difficulty * Move length bonus back to PP * Remove blinds special case * Revert star rating multiplier decrease * More balancing --------- Co-authored-by: StanR --- .../Difficulty/OsuDifficultyCalculator.cs | 210 ++++++++++++++---- .../Difficulty/OsuPerformanceCalculator.cs | 43 +--- 2 files changed, 168 insertions(+), 85 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 5da6df236e..a5071f0441 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -1,8 +1,6 @@ // 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.Generic; using System.Linq; @@ -15,12 +13,16 @@ using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; 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; namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuDifficultyCalculator : DifficultyCalculator { + private const double performance_base_multiplier = 1.15; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. private const double difficulty_multiplier = 0.0675; + private const double star_rating_multiplier = 0.0265; public override int Version => 20250306; @@ -29,53 +31,65 @@ namespace osu.Game.Rulesets.Osu.Difficulty { } + public static double CalculateDifficultyMultiplier(Mod[] mods, int totalHits, int spinnerCount) + { + double multiplier = performance_base_multiplier; + + if (mods.Any(m => m is OsuModSpunOut) && totalHits > 0) + multiplier *= 1.0 - Math.Pow((double)spinnerCount / totalHits, 0.85); + + return multiplier; + } + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) { if (beatmap.HitObjects.Count == 0) return new OsuDifficultyAttributes { Mods = mods }; var aim = skills.OfType().Single(a => a.IncludeSliders); - double aimRating = Math.Sqrt(aim.DifficultyValue()) * difficulty_multiplier; - double aimDifficultyStrainCount = aim.CountTopWeightedStrains(); + var aimWithoutSliders = skills.OfType().Single(a => !a.IncludeSliders); + var speed = skills.OfType().Single(); + var flashlight = skills.OfType().SingleOrDefault(); + + double speedNotes = speed.RelevantNoteCount(); + + double aimDifficultStrainCount = aim.CountTopWeightedStrains(); + double speedDifficultStrainCount = speed.CountTopWeightedStrains(); + double difficultSliders = aim.GetDifficultSliders(); - var aimWithoutSliders = skills.OfType().Single(a => !a.IncludeSliders); - double aimRatingNoSliders = Math.Sqrt(aimWithoutSliders.DifficultyValue()) * difficulty_multiplier; + double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; + double approachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5; + + HitWindows hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + + double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; + + double overallDifficulty = (80 - hitWindowGreat) / 6; + + 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); + + int totalHits = beatmap.HitObjects.Count; + + double drainRate = beatmap.Difficulty.DrainRate; + + double aimRating = computeAimRating(aim.DifficultyValue(), mods, totalHits, approachRate, overallDifficulty); + double aimRatingNoSliders = computeAimRating(aimWithoutSliders.DifficultyValue(), mods, totalHits, approachRate, overallDifficulty); + double speedRating = computeSpeedRating(speed.DifficultyValue(), mods, totalHits, approachRate, overallDifficulty); + + double flashlightRating = 0.0; + + if (flashlight is not null) + flashlightRating = computeFlashlightRating(flashlight.DifficultyValue(), mods, totalHits, overallDifficulty); + double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1; - var speed = skills.OfType().Single(); - double speedRating = Math.Sqrt(speed.DifficultyValue()) * difficulty_multiplier; - double speedNotes = speed.RelevantNoteCount(); - double speedDifficultyStrainCount = speed.CountTopWeightedStrains(); - - var flashlight = skills.OfType().SingleOrDefault(); - double flashlightRating = flashlight == null ? 0.0 : Math.Sqrt(flashlight.DifficultyValue()) * difficulty_multiplier; - - if (mods.Any(m => m is OsuModTouchDevice)) - { - aimRating = Math.Pow(aimRating, 0.8); - flashlightRating = Math.Pow(flashlightRating, 0.8); - } - - if (mods.Any(h => h is OsuModRelax)) - { - aimRating *= 0.9; - speedRating = 0.0; - flashlightRating *= 0.7; - } - else if (mods.Any(h => h is OsuModAutopilot)) - { - speedRating *= 0.5; - aimRating = 0.0; - flashlightRating *= 0.4; - } - double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); - double baseFlashlightPerformance = 0.0; - - if (mods.Any(h => h is OsuModFlashlight)) - baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); + double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); double basePerformance = Math.Pow( @@ -84,16 +98,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 ); + double multiplier = CalculateDifficultyMultiplier(mods, totalHits, spinnerCount); + double starRating = basePerformance > 0.00001 - ? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) + ? Math.Cbrt(multiplier) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; - double drainRate = beatmap.Difficulty.DrainRate; - - int hitCirclesCount = 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); - OsuDifficultyAttributes attributes = new OsuDifficultyAttributes { StarRating = starRating, @@ -104,11 +114,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty SpeedNoteCount = speedNotes, FlashlightDifficulty = flashlightRating, SliderFactor = sliderFactor, - AimDifficultStrainCount = aimDifficultyStrainCount, - SpeedDifficultStrainCount = speedDifficultyStrainCount, + AimDifficultStrainCount = aimDifficultStrainCount, + SpeedDifficultStrainCount = speedDifficultStrainCount, DrainRate = drainRate, MaxCombo = beatmap.GetMaxCombo(), - HitCircleCount = hitCirclesCount, + HitCircleCount = hitCircleCount, SliderCount = sliderCount, SpinnerCount = spinnerCount, }; @@ -116,6 +126,109 @@ namespace osu.Game.Rulesets.Osu.Difficulty return attributes; } + private double computeAimRating(double aimDifficultyValue, Mod[] mods, int totalHits, double approachRate, double overallDifficulty) + { + if (mods.Any(m => m is OsuModAutopilot)) + return 0; + + double aimRating = Math.Sqrt(aimDifficultyValue) * difficulty_multiplier; + + if (mods.Any(m => m is OsuModTouchDevice)) + aimRating = Math.Pow(aimRating, 0.8); + + if (mods.Any(m => m is OsuModRelax)) + aimRating *= 0.9; + + 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 *= 1.0 + approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. + + if (mods.Any(m => m is OsuModHidden)) + { + // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. + ratingMultiplier *= 1.0 + 0.04 * (12.0 - approachRate); + } + + // 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); + } + + private double computeSpeedRating(double speedDifficultyValue, Mod[] mods, int totalHits, double approachRate, double overallDifficulty) + { + if (mods.Any(m => m is OsuModRelax)) + return 0; + + double speedRating = Math.Sqrt(speedDifficultyValue) * difficulty_multiplier; + + if (mods.Any(m => m is OsuModAutopilot)) + speedRating *= 0.5; + + 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); + + if (mods.Any(m => m is OsuModAutopilot)) + approachRateFactor = 0.0; + + ratingMultiplier *= 1.0 + approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. + + if (mods.Any(m => m is OsuModHidden)) + { + // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. + ratingMultiplier *= 1.0 + 0.04 * (12.0 - approachRate); + } + + ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750; + + return speedRating * Math.Cbrt(ratingMultiplier); + } + + private double computeFlashlightRating(double flashlightDifficultyValue, Mod[] mods, int totalHits, double overallDifficulty) + { + if (!mods.Any(m => m is OsuModFlashlight)) + return 0; + + double flashlightRating = Math.Sqrt(flashlightDifficultyValue) * difficulty_multiplier; + + 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; + + 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); + } + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { List objects = new List(); @@ -153,7 +266,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty new OsuModEasy(), new OsuModHardRock(), new OsuModFlashlight(), - new MultiMod(new OsuModFlashlight(), new OsuModHidden()) + new OsuModHidden(), + new OsuModSpunOut(), }; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 7e2d68b9d8..3ff6af9b0b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -21,8 +21,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuPerformanceCalculator : PerformanceCalculator { - public const double PERFORMANCE_BASE_MULTIPLIER = 1.15; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. - private bool usingClassicSliderAccuracy; private double accuracy; @@ -126,14 +124,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty effectiveMissCount = Math.Max(countMiss, effectiveMissCount); effectiveMissCount = Math.Min(totalHits, effectiveMissCount); - double multiplier = PERFORMANCE_BASE_MULTIPLIER; + double multiplier = OsuDifficultyCalculator.CalculateDifficultyMultiplier(score.Mods, totalHits, osuAttributes.SpinnerCount); if (score.Mods.Any(m => m is OsuModNoFail)) 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); - if (score.Mods.Any(h => h is OsuModRelax)) { // https://www.desmos.com/calculator/vspzsop6td @@ -210,28 +205,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (effectiveMissCount > 0) aimValue *= calculateMissPenalty(effectiveMissCount, attributes.AimDifficultStrainCount); - 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 (score.Mods.Any(h => h is OsuModRelax)) - approachRateFactor = 0.0; - - aimValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR. - + // 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); - else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) + else if (score.Mods.Any(m => m is OsuModTraceable)) { // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. aimValue *= 1.0 + 0.04 * (12.0 - approachRate); } aimValue *= accuracy; - // It is important to consider accuracy difficulty when scaling with accuracy. - aimValue *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500; return aimValue; } @@ -250,21 +233,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (effectiveMissCount > 0) speedValue *= calculateMissPenalty(effectiveMissCount, attributes.SpeedDifficultStrainCount); - double approachRateFactor = 0.0; - if (approachRate > 10.33) - approachRateFactor = 0.3 * (approachRate - 10.33); - - if (score.Mods.Any(h => h is OsuModAutopilot)) - approachRateFactor = 0.0; - - speedValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR. - + // 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 OsuModHidden || m is OsuModTraceable)) + else if (score.Mods.Any(m => m is OsuModTraceable)) { // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. speedValue *= 1.0 + 0.04 * (12.0 - approachRate); @@ -281,7 +256,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty 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 *= (0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2); + speedValue *= Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2); return speedValue; } @@ -338,14 +313,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty flashlightValue *= getComboScalingFactor(attributes); - // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. - flashlightValue *= 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); - // Scale the flashlight value with accuracy _slightly_. flashlightValue *= 0.5 + accuracy / 2.0; - // It is important to also consider accuracy difficulty when doing that. - flashlightValue *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500; return flashlightValue; } From 4f298760de5964ee238cc4cb0ffc7ed8cf764e55 Mon Sep 17 00:00:00 2001 From: Nathan Corbett <75299710+Finadoggie@users.noreply.github.com> Date: Sun, 27 Apr 2025 04:57:51 -0700 Subject: [PATCH 09/58] Use sliders in acc pp if scorev2 is enabled (#32634) Co-authored-by: StanR --- osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 3ff6af9b0b..1e314cec3d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -22,6 +22,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty public class OsuPerformanceCalculator : PerformanceCalculator { private bool usingClassicSliderAccuracy; + private bool usingScoreV2; private double accuracy; private int scoreMaxCombo; @@ -64,6 +65,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty var osuAttributes = (OsuDifficultyAttributes)attributes; usingClassicSliderAccuracy = score.Mods.OfType().Any(m => m.NoSliderHeadAccuracy.Value); + usingScoreV2 = score.Mods.Any(m => m is ModScoreV2); accuracy = score.Accuracy; scoreMaxCombo = score.MaxCombo; @@ -269,7 +271,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty // This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window. double betterAccuracyPercentage; int amountHitObjectsWithAccuracy = attributes.HitCircleCount; - if (!usingClassicSliderAccuracy) + if (!usingClassicSliderAccuracy || usingScoreV2) amountHitObjectsWithAccuracy += attributes.SliderCount; if (amountHitObjectsWithAccuracy > 0) From ce73dbbcc6a03c9495cfb948270edd64371cc7b9 Mon Sep 17 00:00:00 2001 From: StanR Date: Thu, 1 May 2025 15:52:43 +0500 Subject: [PATCH 10/58] Add diffcalc considerations for Magnetised mod (#33004) * Add diffcalc considerations for Magnetised mod * Make speed reduction scale with power too --- .../Difficulty/OsuDifficultyCalculator.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index a5071f0441..e865427862 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -139,6 +139,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (mods.Any(m => m is OsuModRelax)) aimRating *= 0.9; + if (mods.Any(m => m is OsuModMagnetised)) + { + float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; + aimRating *= 1.0 - magnetisedStrength; + } + double ratingMultiplier = 1.0; double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + @@ -177,6 +183,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty 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; + } + double ratingMultiplier = 1.0; double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + @@ -217,6 +230,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty 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; + } + double ratingMultiplier = 1.0; // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. From 3165b147eeccf2ab14165571cec73a71c6981135 Mon Sep 17 00:00:00 2001 From: KermitNuggies <50683296+TextAdventurer12@users.noreply.github.com> Date: Tue, 13 May 2025 01:05:07 +1200 Subject: [PATCH 11/58] Use proportion of difficult sliders to better estimate sliderbreaks on classic accuracy scores (#31234) * scale misscount by proportion of difficult sliders * cap sliderbreak count at count100 + count50 * use countMiss instead of effectiveMissCount as the base for sliderbreaks * make code inspector happy + cleanup * refactor to remove unnecesary calculation and need for new tuple * scale sliderbreaks with combo * use aimNoSliders for sliderbreak factor * code cleanup * make inspect code happy * use diffcalcutils * fix errors (oops) * scaling changes * fix div by zeros * Fix compilation error * Add online attributes for new difficulty attributes * Formatting * Rebase fixes * Make `CountTopWeightedSliders` to remove weird protected `SliderStrains` list * Prevent top weighted slider factor from being Infinity --------- Co-authored-by: tsunyoku --- .../Difficulty/OsuDifficultyAttributes.cs | 20 ++++++++++++++ .../Difficulty/OsuDifficultyCalculator.cs | 10 +++++++ .../Difficulty/OsuPerformanceCalculator.cs | 24 +++++++++++++++-- .../Difficulty/Skills/Aim.cs | 6 +++-- .../Difficulty/Skills/Speed.cs | 10 +++++++ .../Difficulty/Utils/OsuStrainUtils.cs | 26 +++++++++++++++++++ .../Difficulty/DifficultyAttributes.cs | 2 ++ 7 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index f7d8c649c1..deefeb915c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -53,6 +53,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("slider_factor")] public double SliderFactor { get; set; } + /// + /// Describes how much of is contributed to by hitcircles or sliders + /// A value closer to 0.0 indicates most of is contributed by hitcircles + /// A value closer to Infinity indicates most of is contributed by sliders + /// + [JsonProperty("aim_top_weighted_slider_factor")] + public double AimTopWeightedSliderFactor { get; set; } + + /// + /// Describes how much of is contributed to by hitcircles or sliders + /// A value closer to 0.0 indicates most of is contributed by hitcircles + /// A value closer to Infinity indicates most of is contributed by sliders + /// + [JsonProperty("speed_top_weighted_slider_factor")] + public double SpeedTopWeightedSliderFactor { get; set; } + [JsonProperty("aim_difficult_strain_count")] public double AimDifficultStrainCount { get; set; } @@ -97,6 +113,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount); yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount); yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount); + yield return (ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR, AimTopWeightedSliderFactor); + yield return (ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR, SpeedTopWeightedSliderFactor); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -112,6 +130,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT]; SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT]; AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT]; + AimTopWeightedSliderFactor = values[ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR]; + SpeedTopWeightedSliderFactor = values[ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR]; DrainRate = onlineInfo.DrainRate; HitCircleCount = onlineInfo.CircleCount; SliderCount = onlineInfo.SliderCount; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index e865427862..fa142e4429 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -56,6 +56,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty double aimDifficultStrainCount = aim.CountTopWeightedStrains(); double speedDifficultStrainCount = speed.CountTopWeightedStrains(); + double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders(); + double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains(); + + double aimTopWeightedSliderFactor = aimNoSlidersTopWeightedSliderCount / Math.Max(1, aimNoSlidersDifficultStrainCount - aimNoSlidersTopWeightedSliderCount); + + double speedTopWeightedSliderCount = speed.CountTopWeightedSliders(); + double speedTopWeightedSliderFactor = speedTopWeightedSliderCount / Math.Max(1, speedDifficultStrainCount - speedTopWeightedSliderCount); + double difficultSliders = aim.GetDifficultSliders(); double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; @@ -116,6 +124,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty SliderFactor = sliderFactor, AimDifficultStrainCount = aimDifficultStrainCount, SpeedDifficultStrainCount = speedDifficultStrainCount, + AimTopWeightedSliderFactor = aimTopWeightedSliderFactor, + SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor, DrainRate = drainRate, MaxCombo = beatmap.GetMaxCombo(), HitCircleCount = hitCircleCount, diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 1e314cec3d..3335609e6f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -205,7 +205,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty aimValue *= lengthBonus; if (effectiveMissCount > 0) - aimValue *= calculateMissPenalty(effectiveMissCount, attributes.AimDifficultStrainCount); + { + double estimatedSliderbreaks = calculateEstimatedSliderbreaks(attributes.AimTopWeightedSliderFactor, attributes); + aimValue *= calculateMissPenalty(effectiveMissCount + estimatedSliderbreaks, attributes.AimDifficultStrainCount); + } // 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)) @@ -233,7 +236,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty speedValue *= lengthBonus; if (effectiveMissCount > 0) - speedValue *= calculateMissPenalty(effectiveMissCount, attributes.SpeedDifficultStrainCount); + { + double estimatedSliderbreaks = calculateEstimatedSliderbreaks(attributes.SpeedTopWeightedSliderFactor, attributes); + speedValue *= calculateMissPenalty(effectiveMissCount + estimatedSliderbreaks, 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)) @@ -321,6 +327,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty return flashlightValue; } + private double calculateEstimatedSliderbreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes) + { + if (!usingClassicSliderAccuracy || countOk == 0) + return 0; + + double missedComboPercent = 1.0 - (double)scoreMaxCombo / attributes.MaxCombo; + double estimatedSliderbreaks = Math.Min(countOk, effectiveMissCount * topWeightedSliderFactor); + + // scores with more oks are more likely to have sliderbreaks + double okAdjustment = ((countOk - estimatedSliderbreaks) + 0.5) / countOk; + + return estimatedSliderbreaks * okAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15); + } + /// /// Estimates player's deviation on speed notes using , assuming worst-case. /// Treats all speed notes as hit circles. diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 6f1b680211..633f29d6ff 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; +using osu.Game.Rulesets.Osu.Difficulty.Utils; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Skills @@ -41,9 +42,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills currentStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplier; if (current.BaseObject is Slider) - { sliderStrains.Add(currentStrain); - } return currentStrain; } @@ -54,10 +53,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills return 0; double maxSliderStrain = sliderStrains.Max(); + if (maxSliderStrain == 0) return 0; return sliderStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0)))); } + + public double CountTopWeightedSliders() => 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 bdeea0e918..334f763be3 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -2,11 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System; +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.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; using System.Linq; +using osu.Game.Rulesets.Osu.Difficulty.Utils; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { @@ -21,6 +24,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills private double currentStrain; private double currentRhythm; + private readonly List sliderStrains = new List(); + protected override int ReducedSectionCount => 5; public Speed(Mod[] mods) @@ -41,6 +46,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills double totalStrain = currentStrain * currentRhythm; + if (current.BaseObject is Slider) + sliderStrains.Add(totalStrain); + return totalStrain; } @@ -55,5 +63,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); } + + public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue()); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs b/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs new file mode 100644 index 0000000000..8a78192ee4 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs @@ -0,0 +1,26 @@ +// 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)); + } + } +} diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index 59511973f7..f2b5642236 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -26,6 +26,8 @@ namespace osu.Game.Rulesets.Difficulty protected const int ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT = 25; protected const int ATTRIB_ID_MONO_STAMINA_FACTOR = 29; protected const int ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT = 31; + protected const int ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR = 33; + protected const int ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR = 35; /// /// The mods which were applied to the beatmap. From d22b3fb200284caada0e0e1731d112b368b0d5ec Mon Sep 17 00:00:00 2001 From: James Wilson Date: Wed, 14 May 2025 13:37:08 +0100 Subject: [PATCH 12/58] Remove track usage in difficulty and performance calculations (#33132) --- .../Difficulty/CatchPerformanceCalculator.cs | 6 ++---- .../Difficulty/OsuPerformanceCalculator.cs | 6 ++---- .../Difficulty/TaikoPerformanceCalculator.cs | 6 ++---- osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs | 5 +---- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs index 62a9fe250e..4b38cfac50 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs @@ -3,13 +3,13 @@ using System; using System.Linq; -using osu.Framework.Audio.Track; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; +using osu.Game.Utils; namespace osu.Game.Rulesets.Catch.Difficulty { @@ -57,9 +57,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty score.Mods.OfType().ForEach(m => m.ApplyToDifficulty(difficulty)); - var track = new TrackVirtual(10000); - score.Mods.OfType().ForEach(m => m.ApplyToTrack(track)); - double clockRate = track.Rate; + double clockRate = ModUtils.CalculateRateWithMods(score.Mods); // this is the same as osu!, so there's potential to share the implementation... maybe double preempt = IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450) / clockRate; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 3335609e6f..431bc24357 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Audio.Track; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -16,6 +15,7 @@ using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Utils; namespace osu.Game.Rulesets.Osu.Difficulty { @@ -81,9 +81,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty score.Mods.OfType().ForEach(m => m.ApplyToDifficulty(difficulty)); - var track = new TrackVirtual(10000); - score.Mods.OfType().ForEach(m => m.ApplyToTrack(track)); - clockRate = track.Rate; + clockRate = ModUtils.CalculateRateWithMods(score.Mods); HitWindows hitWindows = new OsuHitWindows(); hitWindows.SetDifficulty(difficulty.OverallDifficulty); diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 9e049df87c..3c4e1164f1 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Audio.Track; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Utils; @@ -13,6 +12,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Scoring; +using osu.Game.Utils; namespace osu.Game.Rulesets.Taiko.Difficulty { @@ -43,9 +43,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); - var track = new TrackVirtual(10000); - score.Mods.OfType().ForEach(m => m.ApplyToTrack(track)); - clockRate = track.Rate; + clockRate = ModUtils.CalculateRateWithMods(score.Mods); var difficulty = score.BeatmapInfo!.Difficulty.Clone(); diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 5c840a8357..a7eed0dda1 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -8,7 +8,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using JetBrains.Annotations; -using osu.Framework.Audio.Track; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Lists; using osu.Game.Beatmaps; @@ -181,9 +180,7 @@ namespace osu.Game.Rulesets.Difficulty playableMods = mods.Select(m => m.DeepClone()).ToArray(); Beatmap = beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); - var track = new TrackVirtual(10000); - playableMods.OfType().ForEach(m => m.ApplyToTrack(track)); - clockRate = track.Rate; + clockRate = ModUtils.CalculateRateWithMods(playableMods); } /// From 9314ea94b5fa62cbe8b07e99ffd627eb440ccc32 Mon Sep 17 00:00:00 2001 From: Givikap120 <89256026+Givikap120@users.noreply.github.com> Date: Sat, 17 May 2025 02:38:12 +0300 Subject: [PATCH 13/58] Change effective misscount to be based on legacy score and combo at the same time (#33066) * implement stuff * fix basic issues * rework calculations * sanity check * don't use score based misscount if no scorev1 present * Update OsuPerformanceCalculator.cs * update misscount diff attribute names * add raw score misscount attribute * introduce more reasonable high bound for misscount * code quality changes * Fix osu!catch SR buzz slider detection (#32412) * Use `normalized_hitobject_radius` during osu!catch buzz slider detection Currently the algorithm considers some buzz sliders as standstills when in reality they require movement. This happens because `HalfCatcherWidth` isn't normalized while `exactDistanceMoved` is, leading to an inaccurate comparison. `normalized_hitobject_radius` is the normalized value of `HalfCatcherWidth` and replacing one with the other fixes the problem. * Rename `normalized_hitobject_radius` to `normalized_half_catcher_width` The current name is confusing because hit objects have no radius in the context of osu!catch difficulty calculation. The new name conveys the actual purpose of the value. * Only set `normalized_half_catcher_width` in `CatchDifficultyHitObject` Prevents potential bugs if the value were to be changed in one of the classes but not in both. * Use `CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH` directly Requested during code review. --------- Co-authored-by: James Wilson * Move osu!catch movement diffcalc to an evaluator (#32655) * Move osu!catch movement state into `CatchDifficultyHitObject` In order to port `Movement` to an evaluator, the state has to be either moved elsewhere or calculated inside the evaluator. The latter requires backtracking for every hit object, which in the worst case is continued until the beginning of the map is reached. Limiting backtracking can lead to difficulty value changes. Thus, the first option was chosen for its simplicity. * Move osu!catch movement difficulty calculation to an evaluator Makes the code more in line with the other game modes. * Add documentation for `CatchDifficultyHitObject` fields --------- Co-authored-by: James Wilson * Move all score-independent bonuses into star rating (#31351) * basis refactor to allow for more complex SR calculations * move all possible bonuses into star rating * decrease star rating scaling to account for overall gains * add extra FL guard for safety * move star rating multiplier into a constant * Reorganise some things * Add HD and SO to difficulty adjustment mods * Move non-legacy mod multipliers back to PP * Some merge fixes * Fix application of flashlight rating multiplier * Fix Hidden bonuses being applied when Blinds mod is in use * Move part of speed OD scaling into difficulty * Move length bonus back to PP * Remove blinds special case * Revert star rating multiplier decrease * More balancing --------- Co-authored-by: StanR * Add diffcalc considerations for Magnetised mod (#33004) * Add diffcalc considerations for Magnetised mod * Make speed reduction scale with power too * cleaning up * Update OsuPerformanceCalculator.cs * Update OsuPerformanceCalculator.cs * add new check to avoid overestimation * fix code style * fix nvicka * add database attributes * Refactor * Rename `Working` to `WorkingBeatmap` * Remove redundant condition * Remove useless variable * Remove `get` wording * Rename `calculateScoreAtCombo` * Remove redundant operator * Add comments to explain how score-based miss count derivations work * Remove redundant `decimal` calculations * use static method to improve performance * move stuff around for readability * move logic into helper class * fix the bug * Delete OsuLegacyScoreProcessor.cs * Delete ILegacyScoreProcessor.cs * revert static method for multiplier * use only basic combo score attribute * Clean-up * Remove unused param * Update osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs Co-authored-by: StanR * rename variables * Add `LegacyScoreUtils` * Add fail safe * Move `countMiss` * Better explain `CalculateRelevantScoreComboPerObject` * Add `OsuLegacyScoreMissCalculator` * Move `CalculateScoreAtCombo` and `CalculateRelevantScoreComboPerObject` * Remove unused variables * Move `GetLegacyScoreMultiplier` * Add `estimated` wording --------- Co-authored-by: wulpine Co-authored-by: James Wilson Co-authored-by: StanR Co-authored-by: StanR --- .../Difficulty/OsuDifficultyAttributes.cs | 15 ++ .../Difficulty/OsuDifficultyCalculator.cs | 10 + .../OsuLegacyScoreMissCalculator.cs | 187 ++++++++++++++++++ .../Difficulty/OsuPerformanceAttributes.cs | 6 + .../Difficulty/OsuPerformanceCalculator.cs | 76 ++++--- .../Difficulty/Utils/LegacyScoreUtils.cs | 51 +++++ .../Difficulty/DifficultyAttributes.cs | 3 + .../Difficulty/DifficultyCalculator.cs | 10 +- 8 files changed, 331 insertions(+), 27 deletions(-) create mode 100644 osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs create mode 100644 osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index deefeb915c..0bbf1d3df6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -75,6 +75,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("speed_difficult_strain_count")] public double SpeedDifficultStrainCount { get; set; } + [JsonProperty("slider_nested_score_per_object")] + public double SliderNestedScorePerObject { get; set; } + + [JsonProperty("legacy_score_base_multiplier")] + public double LegacyScoreBaseMultiplier { get; set; } + + [JsonProperty("maximum_legacy_combo_score")] + public double MaximumLegacyComboScore { get; set; } + /// /// The beatmap's drain rate. This doesn't scale with rate-adjusting mods. /// @@ -115,6 +124,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount); yield return (ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR, AimTopWeightedSliderFactor); yield return (ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR, SpeedTopWeightedSliderFactor); + yield return (ATTRIB_ID_SLIDER_NESTED_SCORE_PER_OBJECT, SliderNestedScorePerObject); + yield return (ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER, LegacyScoreBaseMultiplier); + yield return (ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE, MaximumLegacyComboScore); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -132,6 +144,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT]; AimTopWeightedSliderFactor = values[ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR]; SpeedTopWeightedSliderFactor = values[ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR]; + SliderNestedScorePerObject = values[ATTRIB_ID_SLIDER_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; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index fa142e4429..7c8de87884 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -11,6 +11,7 @@ using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Skills; +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; @@ -112,6 +113,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty ? Math.Cbrt(multiplier) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; + double sliderNestedScorePerObject = LegacyScoreUtils.CalculateSliderNestedScorePerObject(beatmap, totalHits); + double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap); + + var simulator = new OsuLegacyScoreSimulator(); + var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap); + OsuDifficultyAttributes attributes = new OsuDifficultyAttributes { StarRating = starRating, @@ -131,6 +138,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty HitCircleCount = hitCircleCount, SliderCount = sliderCount, SpinnerCount = spinnerCount, + SliderNestedScorePerObject = sliderNestedScorePerObject, + LegacyScoreBaseMultiplier = legacyScoreBaseMultiplier, + MaximumLegacyComboScore = scoreAttributes.ComboScore }; return attributes; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs new file mode 100644 index 0000000000..53837b78a0 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs @@ -0,0 +1,187 @@ +// 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.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Rulesets.Osu.Difficulty +{ + public class OsuLegacyScoreMissCalculator + { + private readonly ScoreInfo score; + private readonly OsuDifficultyAttributes attributes; + + public OsuLegacyScoreMissCalculator(ScoreInfo scoreInfo, OsuDifficultyAttributes attributes) + { + score = scoreInfo; + this.attributes = attributes; + } + + public double Calculate() + { + if (attributes.MaxCombo == 0 || score.LegacyTotalScore == null) + return 0; + + double scoreV1Multiplier = attributes.LegacyScoreBaseMultiplier * getLegacyScoreMultiplier(); + double relevantComboPerObject = calculateRelevantScoreComboPerObject(); + + double maximumMissCount = calculateMaximumComboBasedMissCount(); + + double scoreObtainedDuringMaxCombo = calculateScoreAtCombo(score.MaxCombo, relevantComboPerObject, scoreV1Multiplier); + double remainingScore = score.LegacyTotalScore.Value - scoreObtainedDuringMaxCombo; + + if (remainingScore <= 0) + return maximumMissCount; + + double remainingCombo = attributes.MaxCombo - score.MaxCombo; + double expectedRemainingScore = calculateScoreAtCombo(remainingCombo, relevantComboPerObject, scoreV1Multiplier); + + double scoreBasedMissCount = expectedRemainingScore / remainingScore; + + // If there's less then one miss detected - let combo-based miss count decide if this is FC or not + scoreBasedMissCount = Math.Max(scoreBasedMissCount, 1); + + // Cap result by very harsh version of combo-based miss count + return Math.Min(scoreBasedMissCount, maximumMissCount); + } + + /// + /// Calculates the amount of score that would be achieved at a given combo. + /// + private double calculateScoreAtCombo(double combo, double relevantComboPerObject, double scoreV1Multiplier) + { + int countGreat = score.Statistics.GetValueOrDefault(HitResult.Great); + int countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); + int countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); + int countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); + + int totalHits = countGreat + countOk + countMeh + countMiss; + + double estimatedObjects = combo / relevantComboPerObject - 1; + + // The combo portion of ScoreV1 follows arithmetic progression + // Therefore, we calculate the combo portion of score using the combo per object and our current combo. + double comboScore = relevantComboPerObject > 0 ? (2 * (relevantComboPerObject - 1) + (estimatedObjects - 1) * relevantComboPerObject) * estimatedObjects / 2 : 0; + + // We then apply the accuracy and ScoreV1 multipliers to the resulting score. + comboScore *= score.Accuracy * 300 / 25 * scoreV1Multiplier; + + double objectsHit = (totalHits - countMiss) * combo / attributes.MaxCombo; + + // Score also has a non-combo portion we need to create the final score value. + double nonComboScore = (300 + attributes.SliderNestedScorePerObject) * score.Accuracy * objectsHit; + + return comboScore + nonComboScore; + } + + /// + /// Calculates the relevant combo per object for legacy score. + /// This assumes a uniform distribution for circles and sliders. + /// This handles cases where objects (such as buzz sliders) do not fit a normal arithmetic progression model. + /// + private double calculateRelevantScoreComboPerObject() + { + double comboScore = attributes.MaximumLegacyComboScore; + + // We then reverse apply the ScoreV1 multipliers to get the raw value. + comboScore /= 300.0 / 25.0 * attributes.LegacyScoreBaseMultiplier; + + // Reverse the arithmetic progression to work out the amount of combo per object based on the score. + double result = (attributes.MaxCombo - 2) * attributes.MaxCombo; + result /= Math.Max(attributes.MaxCombo + 2 * (comboScore - 1), 1); + + return result; + } + + /// + /// This function is a harsher version of current combo-based miss count, used to provide reasonable value for cases where score-based miss count can't do this. + /// + private double calculateMaximumComboBasedMissCount() + { + int countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); + + if (attributes.SliderCount <= 0) + return countMiss; + + int countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); + int countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); + + int totalImperfectHits = countOk + countMeh + countMiss; + + double missCount = 0; + + // 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; + + if (score.MaxCombo < fullComboThreshold) + missCount = Math.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); + + return missCount; + } + + /// + /// Logic copied from . + /// + private double getLegacyScoreMultiplier() + { + bool scoreV2 = score.Mods.Any(m => m is ModScoreV2); + + double multiplier = 1.0; + + foreach (var mod in score.Mods) + { + switch (mod) + { + case OsuModNoFail: + multiplier *= scoreV2 ? 1.0 : 0.5; + break; + + case OsuModEasy: + multiplier *= 0.5; + break; + + case OsuModHalfTime: + case OsuModDaycore: + multiplier *= 0.3; + break; + + case OsuModHidden: + multiplier *= 1.06; + break; + + case OsuModHardRock: + multiplier *= scoreV2 ? 1.10 : 1.06; + break; + + case OsuModDoubleTime: + case OsuModNightcore: + multiplier *= scoreV2 ? 1.20 : 1.12; + break; + + case OsuModFlashlight: + multiplier *= 1.12; + break; + + case OsuModSpunOut: + multiplier *= 0.9; + break; + + case OsuModRelax: + case OsuModAutopilot: + return 0; + } + } + + return multiplier; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs index de4491a31b..f889ce3137 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs @@ -27,6 +27,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("speed_deviation")] public double? SpeedDeviation { get; set; } + [JsonProperty("combo_based_estimated_miss_count")] + public double ComboBasedEstimatedMissCount { get; set; } + + [JsonProperty("score_based_estimated_miss_count")] + public double? ScoreBasedEstimatedMissCount { get; set; } + public override IEnumerable GetAttributesForDisplay() { foreach (var attribute in base.GetAttributesForDisplay()) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 431bc24357..1c9334d208 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -7,12 +7,12 @@ using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Utils; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Utils; @@ -95,30 +95,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty overallDifficulty = (80 - greatHitWindow) / 6; approachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5; - if (osuAttributes.SliderCount > 0) + double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes); + double? scoreBasedEstimatedMissCount = null; + + if (usingClassicSliderAccuracy && score.LegacyTotalScore != null) { - if (usingClassicSliderAccuracy) - { - // 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 * osuAttributes.SliderCount; + var legacyScoreMissCalculator = new OsuLegacyScoreMissCalculator(score, osuAttributes); + scoreBasedEstimatedMissCount = legacyScoreMissCalculator.Calculate(); - if (scoreMaxCombo < fullComboThreshold) - effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); - - // In classic scores there can't be more misses than a sum of all non-perfect judgements - effectiveMissCount = Math.Min(effectiveMissCount, totalImperfectHits); - } - else - { - double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped; - - if (scoreMaxCombo < fullComboThreshold) - effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); - - // Combine regular misses with tick misses since tick misses break combo as well - effectiveMissCount = Math.Min(effectiveMissCount, countSliderTickMiss + countMiss); - } + effectiveMissCount = scoreBasedEstimatedMissCount.Value; + } + else + { + // Use combo-based miss count if this isn't a legacy score + effectiveMissCount = comboBasedEstimatedMissCount; } effectiveMissCount = Math.Max(countMiss, effectiveMissCount); @@ -163,6 +153,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty Accuracy = accuracyValue, Flashlight = flashlightValue, EffectiveMissCount = effectiveMissCount, + ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount, + ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount, SpeedDeviation = speedDeviation, Total = totalValue }; @@ -325,6 +317,39 @@ namespace osu.Game.Rulesets.Osu.Difficulty return flashlightValue; } + private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes attributes) + { + if (attributes.SliderCount <= 0) + return countMiss; + + double missCount = countMiss; + + if (usingClassicSliderAccuracy) + { + // 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; + + if (scoreMaxCombo < fullComboThreshold) + missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); + + // In classic scores there can't be more misses than a sum of all non-perfect judgements + missCount = Math.Min(missCount, totalImperfectHits); + } + else + { + double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped; + + if (scoreMaxCombo < fullComboThreshold) + missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); + + // Combine regular misses with tick misses since tick misses break combo as well + missCount = Math.Min(missCount, countSliderTickMiss + countMiss); + } + + return missCount; + } + private double calculateEstimatedSliderbreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes) { if (!usingClassicSliderAccuracy || countOk == 0) @@ -336,6 +361,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty // scores with more oks are more likely to have sliderbreaks double okAdjustment = ((countOk - estimatedSliderbreaks) + 0.5) / countOk; + // 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); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs b/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs new file mode 100644 index 0000000000..d1df378b47 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs @@ -0,0 +1,51 @@ +// 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.Beatmaps; +using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Utils +{ + public static class LegacyScoreUtils + { + /// + /// Calculates the average amount of score per object that is caused by slider ticks. + /// + public static double CalculateSliderNestedScorePerObject(IBeatmap beatmap, int objectCount) + { + 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 totalScore = amountOfBigTicks * big_tick_score + amountOfSmallTicks * small_tick_score; + + return totalScore / objectCount; + } + + public static int CalculateDifficultyPeppyStars(IBeatmap beatmap) + { + int objectCount = beatmap.HitObjects.Count; + int drainLength = 0; + + if (objectCount > 0) + { + int breakLength = beatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum(); + drainLength = ((int)Math.Round(beatmap.HitObjects[^1].StartTime) - (int)Math.Round(beatmap.HitObjects[0].StartTime) - breakLength) / 1000; + } + + return LegacyRulesetExtensions.CalculateDifficultyPeppyStars(beatmap.Difficulty, objectCount, drainLength); + } + } +} diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index f2b5642236..e01ce6fde5 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -28,6 +28,9 @@ namespace osu.Game.Rulesets.Difficulty protected const int ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT = 31; protected const int ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR = 33; protected const int ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR = 35; + protected const int ATTRIB_ID_SLIDER_NESTED_SCORE_PER_OBJECT = 37; + protected const int ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER = 39; + protected const int ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE = 41; /// /// The mods which were applied to the beatmap. diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index a7eed0dda1..4a404c1e57 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -28,11 +28,15 @@ namespace osu.Game.Rulesets.Difficulty /// protected IBeatmap Beatmap { get; private set; } + /// + /// The working beatmap for which difficulty will be calculated. + /// + protected readonly IWorkingBeatmap WorkingBeatmap; + private Mod[] playableMods; private double clockRate; private readonly IRulesetInfo ruleset; - private readonly IWorkingBeatmap beatmap; /// /// A yymmdd version which is used to discern when reprocessing is required. @@ -42,7 +46,7 @@ namespace osu.Game.Rulesets.Difficulty protected DifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { this.ruleset = ruleset; - this.beatmap = beatmap; + WorkingBeatmap = beatmap; } /// @@ -178,7 +182,7 @@ namespace osu.Game.Rulesets.Difficulty private void preProcess([NotNull] IEnumerable mods, CancellationToken cancellationToken) { playableMods = mods.Select(m => m.DeepClone()).ToArray(); - Beatmap = beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); + Beatmap = WorkingBeatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); clockRate = ModUtils.CalculateRateWithMods(playableMods); } From 553a8601ed24fcdaeb4fd62db0ae672983a860b6 Mon Sep 17 00:00:00 2001 From: James Wilson Date: Sun, 18 May 2025 13:50:29 +0100 Subject: [PATCH 14/58] Add `AimEstimatedSliderBreaks` and `SpeedEstimatedSliderBreaks` performance attributes (#33181) --- .../Difficulty/OsuPerformanceAttributes.cs | 6 +++++ .../Difficulty/OsuPerformanceCalculator.cs | 25 +++++++++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs index f889ce3137..8577eff11f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs @@ -33,6 +33,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("score_based_estimated_miss_count")] public double? ScoreBasedEstimatedMissCount { get; set; } + [JsonProperty("aim_estimated_slider_breaks")] + public double AimEstimatedSliderBreaks { get; set; } + + [JsonProperty("speed_estimated_slider_breaks")] + public double SpeedEstimatedSliderBreaks { get; set; } + public override IEnumerable GetAttributesForDisplay() { foreach (var attribute in base.GetAttributesForDisplay()) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 1c9334d208..8802c4a1c2 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -55,6 +55,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty private double? speedDeviation; + private double aimEstimatedSliderBreaks; + private double speedEstimatedSliderBreaks; + public OsuPerformanceCalculator() : base(new OsuRuleset()) { @@ -155,6 +158,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty EffectiveMissCount = effectiveMissCount, ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount, ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount, + AimEstimatedSliderBreaks = aimEstimatedSliderBreaks, + SpeedEstimatedSliderBreaks = speedEstimatedSliderBreaks, SpeedDeviation = speedDeviation, Total = totalValue }; @@ -196,8 +201,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (effectiveMissCount > 0) { - double estimatedSliderbreaks = calculateEstimatedSliderbreaks(attributes.AimTopWeightedSliderFactor, attributes); - aimValue *= calculateMissPenalty(effectiveMissCount + estimatedSliderbreaks, attributes.AimDifficultStrainCount); + aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.AimTopWeightedSliderFactor, attributes); + aimValue *= calculateMissPenalty(effectiveMissCount + aimEstimatedSliderBreaks, attributes.AimDifficultStrainCount); } // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. @@ -227,8 +232,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (effectiveMissCount > 0) { - double estimatedSliderbreaks = calculateEstimatedSliderbreaks(attributes.SpeedTopWeightedSliderFactor, attributes); - speedValue *= calculateMissPenalty(effectiveMissCount + estimatedSliderbreaks, attributes.SpeedDifficultStrainCount); + speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.SpeedTopWeightedSliderFactor, attributes); + speedValue *= calculateMissPenalty(effectiveMissCount + speedEstimatedSliderBreaks, attributes.SpeedDifficultStrainCount); } // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. @@ -350,21 +355,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty return missCount; } - private double calculateEstimatedSliderbreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes) + private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes) { if (!usingClassicSliderAccuracy || countOk == 0) return 0; double missedComboPercent = 1.0 - (double)scoreMaxCombo / attributes.MaxCombo; - double estimatedSliderbreaks = Math.Min(countOk, effectiveMissCount * topWeightedSliderFactor); + double estimatedSliderBreaks = Math.Min(countOk, effectiveMissCount * topWeightedSliderFactor); - // scores with more oks are more likely to have sliderbreaks - double okAdjustment = ((countOk - estimatedSliderbreaks) + 0.5) / countOk; + // scores with more oks are more likely to have slider breaks + double okAdjustment = ((countOk - estimatedSliderBreaks) + 0.5) / countOk; // 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 *= DifficultyCalculationUtils.Smoothstep(effectiveMissCount, 1, 2); - return estimatedSliderbreaks * okAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15); + return estimatedSliderBreaks * okAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15); } /// From 4a343ceaf1bc7d9750de658db773b5e74ec7702d Mon Sep 17 00:00:00 2001 From: James Wilson Date: Wed, 21 May 2025 12:25:20 +0100 Subject: [PATCH 15/58] Move `Ruleset` and `DifficultyCalculator` allocations to global setup (#33220) --- .../BenchmarkDifficultyCalculation.cs | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/osu.Game.Benchmarks/BenchmarkDifficultyCalculation.cs b/osu.Game.Benchmarks/BenchmarkDifficultyCalculation.cs index eaa4f5cc28..01e50827ba 100644 --- a/osu.Game.Benchmarks/BenchmarkDifficultyCalculation.cs +++ b/osu.Game.Benchmarks/BenchmarkDifficultyCalculation.cs @@ -8,6 +8,7 @@ using osu.Game.Beatmaps; using osu.Game.IO; using osu.Game.IO.Archives; using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Taiko; @@ -17,10 +18,10 @@ namespace osu.Game.Benchmarks { public class BenchmarkDifficultyCalculation : BenchmarkTest { - private WorkingBeatmap osuBeatmap = null!; - private WorkingBeatmap taikoBeatmap = null!; - private WorkingBeatmap catchBeatmap = null!; - private WorkingBeatmap maniaBeatmap = null!; + private DifficultyCalculator osuCalculator = null!; + private DifficultyCalculator taikoCalculator = null!; + private DifficultyCalculator catchCalculator = null!; + private DifficultyCalculator maniaCalculator = null!; public override void SetUp() { @@ -29,10 +30,15 @@ namespace osu.Game.Benchmarks using var archive = resources.GetStream("Resources/Archives/241526 Soleily - Renatus.osz"); using var archiveReader = new ZipArchiveReader(archive); - osuBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (Gamu) [Insane].osu"); - taikoBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (MMzz) [Oni].osu"); - catchBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (Deif) [Salad].osu"); - maniaBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (ExPew) [Another].osu"); + var osuBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (Gamu) [Insane].osu"); + var taikoBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (MMzz) [Oni].osu"); + var catchBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (Deif) [Salad].osu"); + var maniaBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (ExPew) [Another].osu"); + + osuCalculator = new OsuRuleset().CreateDifficultyCalculator(osuBeatmap); + taikoCalculator = new TaikoRuleset().CreateDifficultyCalculator(taikoBeatmap); + catchCalculator = new CatchRuleset().CreateDifficultyCalculator(catchBeatmap); + maniaCalculator = new ManiaRuleset().CreateDifficultyCalculator(maniaBeatmap); } private WorkingBeatmap readBeatmap(ZipArchiveReader archiveReader, string beatmapName) @@ -48,25 +54,23 @@ namespace osu.Game.Benchmarks } [Benchmark] - public void CalculateDifficultyOsu() => new OsuRuleset().CreateDifficultyCalculator(osuBeatmap).Calculate(); + public void CalculateDifficultyOsu() => osuCalculator.Calculate(); [Benchmark] - public void CalculateDifficultyTaiko() => new TaikoRuleset().CreateDifficultyCalculator(taikoBeatmap).Calculate(); + public void CalculateDifficultyTaiko() => taikoCalculator.Calculate(); [Benchmark] - public void CalculateDifficultyCatch() => new CatchRuleset().CreateDifficultyCalculator(catchBeatmap).Calculate(); + public void CalculateDifficultyCatch() => catchCalculator.Calculate(); [Benchmark] - public void CalculateDifficultyMania() => new ManiaRuleset().CreateDifficultyCalculator(maniaBeatmap).Calculate(); + public void CalculateDifficultyMania() => maniaCalculator.Calculate(); [Benchmark] public void CalculateDifficultyOsuHundredTimes() { - var diffcalc = new OsuRuleset().CreateDifficultyCalculator(osuBeatmap); - for (int i = 0; i < 100; i++) { - diffcalc.Calculate(); + osuCalculator.Calculate(); } } } From 60eaf088df5c75fdcf7ad54e27907bb13a145178 Mon Sep 17 00:00:00 2001 From: StanR Date: Thu, 22 May 2025 13:11:51 +0500 Subject: [PATCH 16/58] Buff precision difficulty rating in osu! (#28877) * Buff precision difficulty rating in osu! * Fix position repetition calculation * Fix aim evaluator crashing, move small circle bonus calculation, adjust the curve slightly * Refactor * Fix code quality * Semicolon * Apply small circle bonus to speed too * Fix formatting --------- Co-authored-by: James Wilson --- .../Difficulty/Evaluators/AimEvaluator.cs | 19 +++++++++++++++++++ .../Difficulty/Evaluators/SpeedEvaluator.cs | 3 +++ .../Preprocessing/OsuDifficultyHitObject.cs | 13 +++++++------ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index d1c92ed6a7..15ccb8b1f0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -34,6 +34,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators 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; const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER; @@ -103,6 +104,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators * DifficultyCalculationUtils.Smootherstep(osuLastObj.LazyJumpDistance, radius, diameter) * Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuLastObj.LazyJumpDistance, diameter * 3, diameter), 1.8) * DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); + + if (osuLast2Obj != null) + { + // If objects just go back and forth through a middle point - don't give as much wide bonus + // Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object + var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject; + var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject; + + float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length; + + if (distance < 1) + { + wideAngleBonus *= 1 - 0.35 * (1 - distance); + } + } } } @@ -139,6 +155,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators if (withSliderTravelDistance) aimStrain += sliderBonus * slider_multiplier; + // Apply high circle size bonus + aimStrain *= osuCurrObj.SmallCircleBonus; + return aimStrain; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index 769220ece0..ee9b46eecb 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -60,6 +60,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators // 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; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 4329a25f34..8ad72daeb5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -105,6 +105,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double HitWindowGreat { get; private set; } + /// + /// Selective bonus for maps with higher circle size. + /// + public double SmallCircleBonus { get; private set; } + private readonly OsuDifficultyHitObject? lastLastDifficultyObject; private readonly OsuDifficultyHitObject? lastDifficultyObject; @@ -117,6 +122,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. StrainTime = Math.Max(DeltaTime, MIN_DELTA_TIME); + SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40); + if (BaseObject is Slider sliderObject) { HitWindowGreat = 2 * sliderObject.HeadCircle.HitWindows.WindowFor(HitResult.Great) / clockRate; @@ -193,12 +200,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps. float scalingFactor = NORMALISED_RADIUS / (float)BaseObject.Radius; - if (BaseObject.Radius < 30) - { - float smallCircleBonus = Math.Min(30 - (float)BaseObject.Radius, 5) / 50; - scalingFactor *= 1 + smallCircleBonus; - } - Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition; LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; From ee055ba8f5a44f8f7bda903ba4fbdf87cbcc94ab Mon Sep 17 00:00:00 2001 From: Givikap120 <89256026+Givikap120@users.noreply.github.com> Date: Fri, 23 May 2025 01:27:16 +0300 Subject: [PATCH 17/58] Add spinners support to combo based estimated misscount (#33170) * add spinner support * Make `CalculateSpinnerScore` private & clarify comments --------- Co-authored-by: James Wilson --- .../Difficulty/OsuDifficultyAttributes.cs | 8 +-- .../Difficulty/OsuDifficultyCalculator.cs | 4 +- .../OsuLegacyScoreMissCalculator.cs | 2 +- .../Difficulty/Utils/LegacyScoreUtils.cs | 58 +++++++++++++++++-- .../Difficulty/DifficultyAttributes.cs | 2 +- 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index 0bbf1d3df6..9cab454142 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -75,8 +75,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("speed_difficult_strain_count")] public double SpeedDifficultStrainCount { get; set; } - [JsonProperty("slider_nested_score_per_object")] - public double SliderNestedScorePerObject { get; set; } + [JsonProperty("nested_score_per_object")] + public double NestedScorePerObject { get; set; } [JsonProperty("legacy_score_base_multiplier")] public double LegacyScoreBaseMultiplier { get; set; } @@ -124,7 +124,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount); yield return (ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR, AimTopWeightedSliderFactor); yield return (ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR, SpeedTopWeightedSliderFactor); - yield return (ATTRIB_ID_SLIDER_NESTED_SCORE_PER_OBJECT, SliderNestedScorePerObject); + 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); } @@ -144,7 +144,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT]; AimTopWeightedSliderFactor = values[ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR]; SpeedTopWeightedSliderFactor = values[ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR]; - SliderNestedScorePerObject = values[ATTRIB_ID_SLIDER_NESTED_SCORE_PER_OBJECT]; + 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; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 7c8de87884..dd9d4d4c23 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -113,7 +113,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty ? Math.Cbrt(multiplier) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; - double sliderNestedScorePerObject = LegacyScoreUtils.CalculateSliderNestedScorePerObject(beatmap, totalHits); + double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits); double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap); var simulator = new OsuLegacyScoreSimulator(); @@ -138,7 +138,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty HitCircleCount = hitCircleCount, SliderCount = sliderCount, SpinnerCount = spinnerCount, - SliderNestedScorePerObject = sliderNestedScorePerObject, + NestedScorePerObject = sliderNestedScorePerObject, LegacyScoreBaseMultiplier = legacyScoreBaseMultiplier, MaximumLegacyComboScore = scoreAttributes.ComboScore }; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs index 53837b78a0..207ecde81a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs @@ -74,7 +74,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty double objectsHit = (totalHits - countMiss) * combo / attributes.MaxCombo; // Score also has a non-combo portion we need to create the final score value. - double nonComboScore = (300 + attributes.SliderNestedScorePerObject) * score.Accuracy * objectsHit; + double nonComboScore = (300 + attributes.NestedScorePerObject) * score.Accuracy * objectsHit; return comboScore + nonComboScore; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs b/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs index d1df378b47..df1683fb29 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs @@ -12,9 +12,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Utils public static class LegacyScoreUtils { /// - /// Calculates the average amount of score per object that is caused by slider ticks. + /// Calculates the average amount of score per object that is caused by nested judgements such as slider-ticks and spinners. /// - public static double CalculateSliderNestedScorePerObject(IBeatmap beatmap, int objectCount) + public static double CalculateNestedScorePerObject(IBeatmap beatmap, int objectCount) { const double big_tick_score = 30; const double small_tick_score = 10; @@ -29,9 +29,59 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Utils int amountOfSmallTicks = sliders.Select(s => s.NestedHitObjects.Count(nho => nho is SliderTick)).Sum(); - double totalScore = amountOfBigTicks * big_tick_score + amountOfSmallTicks * small_tick_score; + double sliderScore = amountOfBigTicks * big_tick_score + amountOfSmallTicks * small_tick_score; - return totalScore / objectCount; + double spinnerScore = 0; + + foreach (var spinner in beatmap.HitObjects.OfType()) + { + spinnerScore += calculateSpinnerScore(spinner); + } + + return (sliderScore + spinnerScore) / objectCount; + } + + /// + /// Logic borrowed from for basic score calculations. + /// + private static double calculateSpinnerScore(Spinner spinner) + { + const int spin_score = 100; + const int bonus_spin_score = 1000; + + // The spinner object applies a lenience because gameplay mechanics differ from osu-stable. + // We'll redo the calculations to match osu-stable here... + const double maximum_rotations_per_second = 477.0 / 60; + + // Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises bonus score. + // As we're primarily concerned with computing the maximum theoretical final score, + // this will have the final effect of slightly underestimating bonus score achieved on stable when converting from score V1. + const double minimum_rotations_per_second = 3; + + double secondsDuration = spinner.Duration / 1000; + + // The total amount of half spins possible for the entire spinner. + int totalHalfSpinsPossible = (int)(secondsDuration * maximum_rotations_per_second * 2); + // The amount of half spins that are required to successfully complete the spinner (i.e. get a 300). + int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimum_rotations_per_second); + // To be able to receive bonus points, the spinner must be rotated another 1.5 times. + int halfSpinsRequiredBeforeBonus = halfSpinsRequiredForCompletion + 3; + + long score = 0; + + int fullSpins = (totalHalfSpinsPossible / 2); + + // Normal spin score + score += spin_score * fullSpins; + + int bonusSpins = (totalHalfSpinsPossible - halfSpinsRequiredBeforeBonus) / 2; + + // Reduce amount of bonus spins because we want to represent the more average case, rather than the best one. + bonusSpins = Math.Max(0, bonusSpins - fullSpins / 2); + + score += bonus_spin_score * bonusSpins; + + return score; } public static int CalculateDifficultyPeppyStars(IBeatmap beatmap) diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index e01ce6fde5..5e792d1b75 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Difficulty protected const int ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT = 31; protected const int ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR = 33; protected const int ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR = 35; - protected const int ATTRIB_ID_SLIDER_NESTED_SCORE_PER_OBJECT = 37; + protected const int ATTRIB_ID_NESTED_SCORE_PER_OBJECT = 37; protected const int ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER = 39; protected const int ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE = 41; From ace74824b8f0a8fcc7a625538f51530a3bc0a9d2 Mon Sep 17 00:00:00 2001 From: Jay Lawton Date: Fri, 23 May 2025 21:57:37 +1000 Subject: [PATCH 18/58] Add a `consistency` factor to osu!taiko diffcalc (#33233) * add consistency attribute * write attributes to json for serialisation * comment change * fix json, add mechanical difficulty * write new attributes to database --------- Co-authored-by: James Wilson --- .../Difficulty/TaikoDifficultyAttributes.cs | 25 +++++++++++-- .../Difficulty/TaikoDifficultyCalculator.cs | 37 ++++++++++++++++--- .../Difficulty/DifficultyAttributes.cs | 4 ++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index b8051054e7..eacf843487 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -10,14 +10,23 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { public class TaikoDifficultyAttributes : DifficultyAttributes { + /// + /// The difficulty corresponding to the mechanical skills in osu!taiko. + /// This includes colour and stamina combined. + /// + [JsonProperty("mechanical_difficulty")] + public double MechanicalDifficulty { get; set; } + /// /// The difficulty corresponding to the rhythm skill. /// + [JsonProperty("rhythm_difficulty")] public double RhythmDifficulty { get; set; } /// /// The difficulty corresponding to the reading skill. /// + [JsonProperty("reading_difficulty")] public double ReadingDifficulty { get; set; } /// @@ -36,9 +45,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty [JsonProperty("mono_stamina_factor")] public double MonoStaminaFactor { get; set; } - public double RhythmTopStrains { get; set; } - - public double ColourTopStrains { get; set; } + /// + /// The factor corresponding to the consistency of a map. + /// + [JsonProperty("consistency_factor")] + public double ConsistencyFactor { get; set; } public double StaminaTopStrains { get; set; } @@ -48,7 +59,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty yield return v; yield return (ATTRIB_ID_DIFFICULTY, StarRating); + yield return (ATTRIB_ID_MECHANICAL_DIFFICULTY, MechanicalDifficulty); + yield return (ATTRIB_ID_RHYTHM_DIFFICULTY, RhythmDifficulty); + yield return (ATTRIB_ID_READING_DIFFICULTY, ReadingDifficulty); yield return (ATTRIB_ID_MONO_STAMINA_FACTOR, MonoStaminaFactor); + yield return (ATTRIB_ID_CONSISTENCY_FACTOR, ConsistencyFactor); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -56,7 +71,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty base.FromDatabaseAttributes(values, onlineInfo); StarRating = values[ATTRIB_ID_DIFFICULTY]; + MechanicalDifficulty = values[ATTRIB_ID_MECHANICAL_DIFFICULTY]; + RhythmDifficulty = values[ATTRIB_ID_RHYTHM_DIFFICULTY]; + ReadingDifficulty = values[ATTRIB_ID_READING_DIFFICULTY]; MonoStaminaFactor = values[ATTRIB_ID_MONO_STAMINA_FACTOR]; + ConsistencyFactor = values[ATTRIB_ID_CONSISTENCY_FACTOR]; } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 83b02f0b30..0b9ef6a27f 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -115,8 +115,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty double monoStaminaSkill = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; double monoStaminaFactor = staminaSkill == 0 ? 1 : Math.Pow(monoStaminaSkill / staminaSkill, 5); - double colourDifficultStrains = colour.CountTopWeightedStrains(); - double rhythmDifficultStrains = rhythm.CountTopWeightedStrains(); double staminaDifficultStrains = stamina.CountTopWeightedStrains(); // As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm. @@ -126,7 +124,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty + Math.Min(Math.Max((staminaDifficultStrains - 1000) / 3700, 0), 0.15) + Math.Min(Math.Max((staminaSkill - 7.0) / 1.0, 0), 0.05); - double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, isRelax, isConvert); + double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, isRelax, isConvert, out double consistencyFactor); double starRating = rescale(combinedRating * 1.4); // Calculate proportional contribution of each skill to the combinedRating. @@ -136,19 +134,20 @@ namespace osu.Game.Rulesets.Taiko.Difficulty double readingDifficulty = readingSkill * skillRating; double colourDifficulty = colourSkill * skillRating; double staminaDifficulty = staminaSkill * skillRating; + double mechanicalDifficulty = colourDifficulty + staminaDifficulty; // Mechanical difficulty is the sum of colour and stamina difficulties. TaikoDifficultyAttributes attributes = new TaikoDifficultyAttributes { StarRating = starRating, Mods = mods, + MechanicalDifficulty = mechanicalDifficulty, RhythmDifficulty = rhythmDifficulty, ReadingDifficulty = readingDifficulty, ColourDifficulty = colourDifficulty, StaminaDifficulty = staminaDifficulty, MonoStaminaFactor = monoStaminaFactor, - RhythmTopStrains = rhythmDifficultStrains, - ColourTopStrains = colourDifficultStrains, StaminaTopStrains = staminaDifficultStrains, + ConsistencyFactor = consistencyFactor, MaxCombo = beatmap.GetMaxCombo(), }; @@ -162,7 +161,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). /// - private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour colour, Stamina stamina, bool isRelax, bool isConvert) + private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour colour, Stamina stamina, bool isRelax, bool isConvert, out double consistencyFactor) { List peaks = new List(); @@ -196,9 +195,35 @@ namespace osu.Game.Rulesets.Taiko.Difficulty weight *= 0.9; } + consistencyFactor = calculateConsistencyFactor(peaks); + return difficulty; } + /// + /// Calculates a consistency factor based on how 'spiked' the strain peaks are. + /// Higher values indicate more consistent difficulty, lower values indicate diff-spike heavy maps. + /// + private double calculateConsistencyFactor(List peaks) + { + // If there are too few sections in a map, assume it is consistent. + if (peaks.Count < 3) + return 1.0; + + List sorted = peaks.OrderDescending().ToList(); + + double topPeak = sorted[0]; + double secondTopPeak = sorted.Count > 1 ? sorted[1] : topPeak; + + // Compute the average of the middle 50% of strain values. + double midAvg = sorted.Skip(sorted.Count / 4).Take(sorted.Count / 2).Average(); + + // A higher ratio means the top sections are much harder than the average, indicating inconsistency. + double spikeSeverity = (topPeak + secondTopPeak) / 2.0 / midAvg; + + return 1.0 / spikeSeverity; + } + /// /// Applies a final re-scaling of the star rating. /// diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index 5e792d1b75..20cac77f8b 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -31,6 +31,10 @@ namespace osu.Game.Rulesets.Difficulty protected const int ATTRIB_ID_NESTED_SCORE_PER_OBJECT = 37; protected const int ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER = 39; protected const int ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE = 41; + protected const int ATTRIB_ID_MECHANICAL_DIFFICULTY = 43; + protected const int ATTRIB_ID_RHYTHM_DIFFICULTY = 45; + protected const int ATTRIB_ID_READING_DIFFICULTY = 47; + protected const int ATTRIB_ID_CONSISTENCY_FACTOR = 49; /// /// The mods which were applied to the beatmap. From 01d9c526d9342943771da83bb0bbe3d8d011b28c Mon Sep 17 00:00:00 2001 From: Givikap120 <89256026+Givikap120@users.noreply.github.com> Date: Mon, 26 May 2025 13:16:48 +0300 Subject: [PATCH 19/58] Rebalance HD bonus (#33237) * initial commit * changed HD curve * removed AR variable * update for new rework * nerf HD acc bonus for AR>10 * add another HD nerf for AR>10 * Update OsuDifficultyCalculator.cs * fix speed part being missing * Update OsuDifficultyCalculator.cs * rework to difficulty-based high AR nerf * move TC back to perfcalc * fix nvicka * fix comment * use utils function instead of manual one * Clean up * Use "visibility" term instead * Store `mechanicalDifficultyRating` field * Rename `isFullyHidden` to `isAlwaysPartiallyVisible` and clarify intent * Remove redundant comment * Add `calculateDifficultyRating` method --------- Co-authored-by: James Wilson --- .../Difficulty/OsuDifficultyCalculator.cs | 98 ++++++++++++++++--- .../Difficulty/OsuPerformanceCalculator.cs | 11 ++- 2 files changed, 90 insertions(+), 19 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index dd9d4d4c23..c048fedd02 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -8,6 +8,7 @@ using osu.Game.Beatmaps; 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; @@ -27,6 +28,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty public override int Version => 20250306; + private double mechanicalDifficultyRating; + public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { @@ -42,6 +45,30 @@ namespace osu.Game.Rulesets.Osu.Difficulty return multiplier; } + /// + /// Calculates a visibility bonus that is applicable to Hidden and Traceable. + /// + public static double CalculateVisibilityBonus(Mod[] mods, double approachRate, double visibilityFactor = 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 AR5 + double readingBonus = 0.04 * (12.0 - Math.Max(approachRate, 5)); + + readingBonus *= visibilityFactor; + + // For AR up to 0 - reduce reward for very low ARs when object is visible + if (approachRate < 5) + readingBonus += (isAlwaysPartiallyVisible ? 0.04 : 0.03) * (5.0 - Math.Max(approachRate, 0)); + + // Starting from AR0 - cap values so they won't grow to infinity + if (approachRate < 0) + readingBonus += (isAlwaysPartiallyVisible ? 0.1 : 0.075) * (1 - Math.Pow(1.5, approachRate)); + + return readingBonus; + } + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) { if (beatmap.HitObjects.Count == 0) @@ -85,9 +112,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty double drainRate = beatmap.Difficulty.DrainRate; - double aimRating = computeAimRating(aim.DifficultyValue(), mods, totalHits, approachRate, overallDifficulty); - double aimRatingNoSliders = computeAimRating(aimWithoutSliders.DifficultyValue(), mods, totalHits, approachRate, overallDifficulty); - double speedRating = computeSpeedRating(speed.DifficultyValue(), mods, totalHits, approachRate, overallDifficulty); + double aimDifficultyValue = aim.DifficultyValue(); + double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue(); + double speedDifficultyValue = speed.DifficultyValue(); + + mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue); + + double aimRating = computeAimRating(aimDifficultyValue, mods, totalHits, approachRate, overallDifficulty); + double aimRatingNoSliders = computeAimRating(aimNoSlidersDifficultyValue, mods, totalHits, approachRate, overallDifficulty); + double speedRating = computeSpeedRating(speedDifficultyValue, mods, totalHits, approachRate, overallDifficulty); double flashlightRating = 0.0; @@ -108,10 +141,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty ); double multiplier = CalculateDifficultyMultiplier(mods, totalHits, spinnerCount); - - double starRating = basePerformance > 0.00001 - ? Math.Cbrt(multiplier) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) - : 0; + double starRating = calculateStarRating(basePerformance, multiplier); double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits); double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap); @@ -151,7 +181,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (mods.Any(m => m is OsuModAutopilot)) return 0; - double aimRating = Math.Sqrt(aimDifficultyValue) * difficulty_multiplier; + double aimRating = calculateDifficultyRating(aimDifficultyValue); if (mods.Any(m => m is OsuModTouchDevice)) aimRating = Math.Pow(aimRating, 0.8); @@ -183,8 +213,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (mods.Any(m => m is OsuModHidden)) { - // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. - ratingMultiplier *= 1.0 + 0.04 * (12.0 - approachRate); + double visibilityFactor = calculateAimVisibilityFactor(approachRate); + ratingMultiplier *= 1.0 + CalculateVisibilityBonus(mods, approachRate, visibilityFactor); } // It is important to consider accuracy difficulty when scaling with accuracy. @@ -198,7 +228,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (mods.Any(m => m is OsuModRelax)) return 0; - double speedRating = Math.Sqrt(speedDifficultyValue) * difficulty_multiplier; + double speedRating = calculateDifficultyRating(speedDifficultyValue); if (mods.Any(m => m is OsuModAutopilot)) speedRating *= 0.5; @@ -226,8 +256,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (mods.Any(m => m is OsuModHidden)) { - // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. - ratingMultiplier *= 1.0 + 0.04 * (12.0 - approachRate); + double visibilityFactor = calculateSpeedVisibilityFactor(approachRate); + ratingMultiplier *= 1.0 + CalculateVisibilityBonus(mods, approachRate, visibilityFactor); } ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750; @@ -240,7 +270,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (!mods.Any(m => m is OsuModFlashlight)) return 0; - double flashlightRating = Math.Sqrt(flashlightDifficultyValue) * difficulty_multiplier; + double flashlightRating = calculateDifficultyRating(flashlightDifficultyValue); if (mods.Any(m => m is OsuModTouchDevice)) flashlightRating = Math.Pow(flashlightRating, 0.8); @@ -268,6 +298,46 @@ namespace osu.Game.Rulesets.Osu.Difficulty 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); + } + + private static double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue) + { + double aimValue = OsuStrainSkill.DifficultyToPerformance(calculateDifficultyRating(aimDifficultyValue)); + double speedValue = OsuStrainSkill.DifficultyToPerformance(calculateDifficultyRating(speedDifficultyValue)); + + double totalValue = Math.Pow(Math.Pow(aimValue, 1.1) + Math.Pow(speedValue, 1.1), 1 / 1.1); + + return calculateStarRating(totalValue, performance_base_multiplier); + } + + private static double calculateStarRating(double basePerformance, double multiplier) + { + if (basePerformance <= 0.00001) + return 0; + + return Math.Cbrt(multiplier) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4); + } + + private static double calculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * difficulty_multiplier; + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { List objects = new List(); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 8802c4a1c2..e5e42e6d4f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -210,8 +210,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate); else if (score.Mods.Any(m => m is OsuModTraceable)) { - // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. - aimValue *= 1.0 + 0.04 * (12.0 - approachRate); + aimValue *= 1.0 + OsuDifficultyCalculator.CalculateVisibilityBonus(score.Mods, approachRate); } aimValue *= accuracy; @@ -244,8 +243,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty } else if (score.Mods.Any(m => m is OsuModTraceable)) { - // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. - speedValue *= 1.0 + 0.04 * (12.0 - approachRate); + speedValue *= 1.0 + OsuDifficultyCalculator.CalculateVisibilityBonus(score.Mods, approachRate); } double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes); @@ -295,7 +293,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (score.Mods.Any(m => m is OsuModBlinds)) accuracyValue *= 1.14; else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) - accuracyValue *= 1.08; + { + // Decrease bonus for AR > 10 + accuracyValue *= 1 + 0.08 * Math.Clamp((11.5 - approachRate) / (11.5 - 10), 0, 1); + } if (score.Mods.Any(m => m is OsuModFlashlight)) accuracyValue *= 1.02; From 63654ad1e0262f3daf14efb9ea611117aebe0404 Mon Sep 17 00:00:00 2001 From: Givikap120 <89256026+Givikap120@users.noreply.github.com> Date: Wed, 28 May 2025 15:59:23 +0300 Subject: [PATCH 20/58] Replace HD acc scaling adjust with reverse lerp util (#33271) --- 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 e5e42e6d4f..7ef3fc5407 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -295,7 +295,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) { // Decrease bonus for AR > 10 - accuracyValue *= 1 + 0.08 * Math.Clamp((11.5 - approachRate) / (11.5 - 10), 0, 1); + accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10); } if (score.Mods.Any(m => m is OsuModFlashlight)) From 366f2469efcddd825baef8c41e03099bab9e0acb Mon Sep 17 00:00:00 2001 From: Givikap120 <89256026+Givikap120@users.noreply.github.com> Date: Tue, 3 Jun 2025 11:29:16 +0300 Subject: [PATCH 21/58] Fix incorrect limit for sliderbreak estimation (#33110) * fix incorrect clamp * Add inline comment to explain `possibleBreaks` calculation * move limit to aim and speed functions * fix negative okMehAdjustment * fix cases where lazer effective misscount gets reduced * Simplify scope of changes * Correct variable name --------- Co-authored-by: James Wilson --- .../Difficulty/OsuPerformanceCalculator.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 7ef3fc5407..ac9ace2a24 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -202,7 +202,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (effectiveMissCount > 0) { aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.AimTopWeightedSliderFactor, attributes); - aimValue *= calculateMissPenalty(effectiveMissCount + aimEstimatedSliderBreaks, attributes.AimDifficultStrainCount); + + double relevantMissCount = Math.Min(effectiveMissCount + aimEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss); + + aimValue *= calculateMissPenalty(relevantMissCount, attributes.AimDifficultStrainCount); } // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. @@ -232,7 +235,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (effectiveMissCount > 0) { speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.SpeedTopWeightedSliderFactor, attributes); - speedValue *= calculateMissPenalty(effectiveMissCount + speedEstimatedSliderBreaks, attributes.SpeedDifficultStrainCount); + + double relevantMissCount = Math.Min(effectiveMissCount + speedEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss); + + 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. @@ -364,7 +370,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty double missedComboPercent = 1.0 - (double)scoreMaxCombo / attributes.MaxCombo; double estimatedSliderBreaks = Math.Min(countOk, effectiveMissCount * topWeightedSliderFactor); - // scores with more oks are more likely to have slider breaks + // Scores with more Oks are more likely to have slider breaks. double okAdjustment = ((countOk - estimatedSliderBreaks) + 0.5) / countOk; // 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. From b982c3cd20c67264c6d38f6b518349af9e8e5e99 Mon Sep 17 00:00:00 2001 From: Eloise Date: Tue, 3 Jun 2025 14:47:42 +0100 Subject: [PATCH 22/58] Remove stamina skill buff from strain length bonus (#33380) Co-authored-by: James Wilson --- .../Difficulty/TaikoDifficultyCalculator.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 0b9ef6a27f..9e265a3cc6 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -120,9 +120,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty // 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); - strainLengthBonus = 1 - + Math.Min(Math.Max((staminaDifficultStrains - 1000) / 3700, 0), 0.15) - + Math.Min(Math.Max((staminaSkill - 7.0) / 1.0, 0), 0.05); + strainLengthBonus = 1 + 0.15 * DifficultyCalculationUtils.ReverseLerp(staminaDifficultStrains, 1000, 1555); double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, isRelax, isConvert, out double consistencyFactor); double starRating = rescale(combinedRating * 1.4); From 6a9aeda5d4c9430e2e71592873c45194c20dcbb3 Mon Sep 17 00:00:00 2001 From: Eloise Date: Fri, 6 Jun 2025 14:46:33 +0100 Subject: [PATCH 23/58] Remove multipliers nerfing ez (#33415) --- .../Difficulty/TaikoPerformanceCalculator.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 3c4e1164f1..c0929ef41e 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -68,9 +68,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (score.Mods.Any(m => m is ModHidden) && !isConvert) multiplier *= 1.075; - if (score.Mods.Any(m => m is ModEasy)) - multiplier *= 0.950; - double difficultyValue = computeDifficultyValue(score, taikoAttributes); double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert); double totalValue = @@ -101,9 +98,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty difficultyValue *= Math.Pow(0.986, effectiveMissCount); - if (score.Mods.Any(m => m is ModEasy)) - difficultyValue *= 0.90; - if (score.Mods.Any(m => m is ModHidden)) difficultyValue *= 1.025; From 642b938358d85eba4d8d1c55a6c70ab21e69a6ae Mon Sep 17 00:00:00 2001 From: Wulpey Date: Sat, 7 Jun 2025 17:17:46 +0300 Subject: [PATCH 24/58] Reduce combo scaling for osu!catch (#33417) * Reduce combo scaling for osu!catch This is a conservative reduction, a middle point between the current scaling and the CSR proposals. * Reduce osu!catch combo scaling further 0.45 makes little difference so let's reduce it a bit more. --------- Co-authored-by: James Wilson --- .../Difficulty/CatchPerformanceCalculator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs index 4b38cfac50..4b8bcb435c 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs @@ -51,7 +51,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty // Combo scaling if (catchAttributes.MaxCombo > 0) - value *= Math.Min(Math.Pow(score.MaxCombo, 0.8) / Math.Pow(catchAttributes.MaxCombo, 0.8), 1.0); + value *= Math.Min(Math.Pow(score.MaxCombo, 0.35) / Math.Pow(catchAttributes.MaxCombo, 0.35), 1.0); var difficulty = score.BeatmapInfo!.Difficulty.Clone(); From 6ae8a6838958672bcd6328c0b738787bbde9d29b Mon Sep 17 00:00:00 2001 From: Eloise Date: Sun, 8 Jun 2025 09:48:28 +0100 Subject: [PATCH 25/58] osu!taiko simplify pp summing and make performance attributes accurate (#33500) * Change pp summing and adjust multipliers * Add back convert consideration for hidden * And the other one whoops --------- Co-authored-by: StanR --- .../Difficulty/TaikoPerformanceCalculator.cs | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index c0929ef41e..20e2f955df 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -63,18 +63,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty // Converts are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation. bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1; - double multiplier = 1.13; - - if (score.Mods.Any(m => m is ModHidden) && !isConvert) - multiplier *= 1.075; - - double difficultyValue = computeDifficultyValue(score, taikoAttributes); - double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert); - double totalValue = - Math.Pow( - Math.Pow(difficultyValue, 1.1) + - Math.Pow(accuracyValue, 1.1), 1.0 / 1.1 - ) * multiplier; + double difficultyValue = computeDifficultyValue(score, taikoAttributes, isConvert) * 1.08; + double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert) * 1.1; return new TaikoPerformanceAttributes { @@ -82,11 +72,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty Accuracy = accuracyValue, EffectiveMissCount = effectiveMissCount, EstimatedUnstableRate = estimatedUnstableRate, - Total = totalValue + Total = difficultyValue + accuracyValue }; } - private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) + private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) { double baseDifficulty = 5 * Math.Max(1.0, attributes.StarRating / 0.110) - 4.0; double difficultyValue = Math.Min(Math.Pow(baseDifficulty, 3) / 69052.51, Math.Pow(baseDifficulty, 2.25) / 1250.0); @@ -99,7 +89,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty difficultyValue *= Math.Pow(0.986, effectiveMissCount); if (score.Mods.Any(m => m is ModHidden)) - difficultyValue *= 1.025; + difficultyValue *= (isConvert) ? 1.025 : 1.1; if (score.Mods.Any(m => m is ModFlashlight)) difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus); @@ -121,6 +111,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty double accuracyValue = Math.Pow(70 / estimatedUnstableRate.Value, 1.1) * Math.Pow(attributes.StarRating, 0.4) * 100.0; + if (score.Mods.Any(m => m is ModHidden) && !isConvert) + accuracyValue *= 1.075; + double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); // Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values. From c4b07413b1b5983ca05081d44679859d75eaccef Mon Sep 17 00:00:00 2001 From: Natelytle <92956514+Natelytle@users.noreply.github.com> Date: Sun, 8 Jun 2025 18:41:13 -0400 Subject: [PATCH 26/58] Refactor and re-comment osu! standard deviation calculations (#33218) * Refactor * Fix typo * Prevent double.PositiveInfinity from occuring * Fix leftover code branch * Fix some idiot putting Math.Max instead of Math.Min * Address NaN values --------- Co-authored-by: James Wilson --- .../Difficulty/OsuPerformanceCalculator.cs | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index ac9ace2a24..272fe9bb65 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; @@ -398,7 +397,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty double relevantCountOk = Math.Min(countOk, speedNoteCount - relevantCountMiss - relevantCountMeh); double relevantCountGreat = Math.Max(0, speedNoteCount - relevantCountMiss - relevantCountMeh - relevantCountOk); - return calculateDeviation(attributes, relevantCountGreat, relevantCountOk, relevantCountMeh, relevantCountMiss); + return calculateDeviation(relevantCountGreat, relevantCountOk, relevantCountMeh); } /// @@ -407,45 +406,45 @@ namespace osu.Game.Rulesets.Osu.Difficulty /// will always return the same deviation. Misses are ignored because they are usually due to misaiming. /// Greats and oks are assumed to follow a normal distribution, whereas mehs are assumed to follow a uniform distribution. /// - private double? calculateDeviation(OsuDifficultyAttributes attributes, double relevantCountGreat, double relevantCountOk, double relevantCountMeh, double relevantCountMiss) + private double? calculateDeviation(double relevantCountGreat, double relevantCountOk, double relevantCountMeh) { if (relevantCountGreat + relevantCountOk + relevantCountMeh <= 0) return null; - double objectCount = relevantCountGreat + relevantCountOk + relevantCountMeh + relevantCountMiss; - - // The probability that a player hits a circle is unknown, but we can estimate it to be - // the number of greats on circles divided by the number of circles, and then add one - // to the number of circles as a bias correction. - double n = Math.Max(1, objectCount - relevantCountMiss - relevantCountMeh); - const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed). - - // Proportion of greats hit on circles, ignoring misses and 50s. + // The sample proportion of successful hits. + double n = Math.Max(1, relevantCountGreat + relevantCountOk); double p = relevantCountGreat / n; - // We can be 99% confident that p is at least this value. - double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); + // 99% critical value for the normal distribution (one-tailed). + const double z = 2.32634787404; - // Compute the deviation assuming greats and oks are normally distributed, and mehs are uniformly distributed. - // Begin with greats and oks first. Ignoring mehs, we can be 99% confident that the deviation is not higher than: - double deviation = greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound)); + // We can be 99% confident that the population proportion is at least this value. + double pLowerBound = Math.Min(p, (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4)); - double randomValue = Math.Sqrt(2 / Math.PI) * okHitWindow * Math.Exp(-0.5 * Math.Pow(okHitWindow / deviation, 2)) - / (deviation * DifficultyCalculationUtils.Erf(okHitWindow / (Math.Sqrt(2) * deviation))); + double deviation; - deviation *= Math.Sqrt(1 - randomValue); + // Tested max precision for the deviation calculation. + if (pLowerBound > 1e-06) + { + // Compute deviation assuming greats and oks are normally distributed. + deviation = greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound)); - // Value deviation approach as greatCount approaches 0 - double limitValue = okHitWindow / Math.Sqrt(3); + // 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))); - // If precision is not enough to compute true deviation - use limit value - if (Precision.AlmostEquals(pLowerBound, 0.0) || randomValue >= 1 || deviation > limitValue) - deviation = limitValue; + deviation *= Math.Sqrt(1 - okHitWindowTailAmount); + } + else + { + // A tested limit value for the case of a score only containing oks. + deviation = okHitWindow / Math.Sqrt(3); + } - // Then compute the variance for mehs. + // Compute and add the variance for mehs, assuming that they are uniformly distributed. double mehVariance = (mehHitWindow * mehHitWindow + okHitWindow * mehHitWindow + okHitWindow * okHitWindow) / 3; - // Find the total deviation. deviation = Math.Sqrt(((relevantCountGreat + relevantCountOk) * Math.Pow(deviation, 2) + relevantCountMeh * mehVariance) / (relevantCountGreat + relevantCountOk + relevantCountMeh)); return deviation; From 5df41c08f44196dde01e3e5107a4bcee73cc0b22 Mon Sep 17 00:00:00 2001 From: Eloise Date: Sun, 8 Jun 2025 23:47:26 +0100 Subject: [PATCH 27/58] osu!taiko new miss penalty using consistency factor (#33409) * New formulas for effective miss count and penalty * More elaborate comments * More comment stuff --- .../Difficulty/TaikoPerformanceCalculator.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 20e2f955df..6deb2fdb04 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -56,9 +56,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty estimatedUnstableRate = computeDeviationUpperBound() * 10; - // The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000. - if (totalSuccessfulHits > 0) - effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss; + // Effective miss count is calculated by raising the fraction of hits missed to a power based on the map's consistency factor. + // This is because in less consistently difficult maps, each miss removes more of the map's total difficulty. + effectiveMissCount = totalHits * Math.Pow( + (double)countMiss / totalHits, + Math.Pow(taikoAttributes.ConsistencyFactor, 0.2) + ); // Converts are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation. bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1; @@ -86,7 +89,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0); difficultyValue *= lengthBonus; - difficultyValue *= Math.Pow(0.986, effectiveMissCount); + // Scales miss penalty by the total hits of a map, making misses more punishing on maps with fewer objects. + double missPenalty = Math.Pow(0.5, 30.0 / totalHits); + difficultyValue *= Math.Pow(missPenalty, effectiveMissCount); if (score.Mods.Any(m => m is ModHidden)) difficultyValue *= (isConvert) ? 1.025 : 1.1; From 699fbb1a85eb986f8a4803e67f5e624802a1b7ea Mon Sep 17 00:00:00 2001 From: StanR Date: Mon, 9 Jun 2025 14:02:49 +0300 Subject: [PATCH 28/58] Decouple velocity change bonus from wide angle bonus (#33541) * Decouple velocity change bonus from wide angle bonus * Replace sin with smoothstep * Set multiplier back to 0.75 --------- Co-authored-by: James Wilson --- .../Difficulty/Evaluators/AimEvaluator.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 15ccb8b1f0..7a898ade1c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -129,7 +129,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.StrainTime; // Scale with ratio of difference compared to 0.5 * max dist. - double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2); + 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(diameter * 1.25 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); @@ -147,9 +147,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators } aimStrain += wiggleBonus * wiggle_multiplier; + aimStrain += velocityChangeBonus * velocity_change_multiplier; - // Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger. - aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier + 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) From 7066f3def7beae3e88e8c5cc97b7c4d1fa40e71a Mon Sep 17 00:00:00 2001 From: Eloise Date: Tue, 10 Jun 2025 11:31:11 +0100 Subject: [PATCH 29/58] osu!taiko changes to length bonus using consistency factor (#33582) * Implement new formulas for length bonus * Add comment(s) * Fix up HDFL thing --- .../Difficulty/TaikoPerformanceCalculator.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 6deb2fdb04..2633218f7d 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -86,7 +86,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty difficultyValue *= 1 + 0.10 * Math.Max(0, attributes.StarRating - 10); - double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0); + // Applies a bonus to maps with more total difficulty, calculating this with a map's total hits and consistency factor. + double totalDifficultHits = totalHits * Math.Pow(attributes.ConsistencyFactor, 0.5); + double lengthBonus = 1 + 0.25 * totalDifficultHits / (totalDifficultHits + 4000); difficultyValue *= lengthBonus; // Scales miss penalty by the total hits of a map, making misses more punishing on maps with fewer objects. @@ -119,11 +121,16 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (score.Mods.Any(m => m is ModHidden) && !isConvert) accuracyValue *= 1.075; - double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); + // Applies a bonus to maps with more total difficulty, calculating this with a map's total hits and consistency factor. + double totalDifficultHits = totalHits * Math.Pow(attributes.ConsistencyFactor, 0.5); + double lengthBonus = 1 + 0.4 * totalDifficultHits / (totalDifficultHits + 4000); + accuracyValue *= lengthBonus; + + // 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)); - // Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values. if (score.Mods.Any(m => m is ModFlashlight) && score.Mods.Any(m => m is ModHidden) && !isConvert) - accuracyValue *= Math.Max(1.0, 1.05 * lengthBonus); + accuracyValue *= Math.Max(1.0, 1.05 * memoryLengthBonus); return accuracyValue; } From 87023b22ea84c2635c8d52d7bcddcdd394bae473 Mon Sep 17 00:00:00 2001 From: StanR Date: Tue, 10 Jun 2025 14:20:55 +0300 Subject: [PATCH 30/58] Remove wide/wiggle angle bonus rhythm requirements (#31409) * Remove aim angle bonuses angle restrictions * Remove unrelated change * Only apply acute bonus for similar rhythms * Cleanup * Fix incorrect multiplication order * Remove unrelated wide bonus change * Remove redundant check * Award less wide/wiggle bonus for sliders * Balancing --------- Co-authored-by: James Wilson --- .../Difficulty/Evaluators/AimEvaluator.cs | 71 ++++++++++--------- .../Difficulty/Skills/Aim.cs | 2 +- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 7a898ade1c..828e217455 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -70,54 +70,57 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double aimStrain = currVelocity; // Start strain with regular velocity. - if (Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime) < 1.25 * Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime)) // If rhythms are the same. + if (osuCurrObj.Angle != null && osuLastObj.Angle != null) { - 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); + + if (Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime) < 1.25 * Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime)) // If rhythms are the same. { - double currAngle = osuCurrObj.Angle.Value; - double lastAngle = osuLastObj.Angle.Value; - - // Rewarding angles, take the smaller velocity as base. - double angleBonus = Math.Min(currVelocity, prevVelocity); - - wideAngleBonus = calcWideAngleBonus(currAngle); acuteAngleBonus = calcAcuteAngleBonus(currAngle); // Penalize angle repetition. - wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3)); acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3))); - // Apply full wide angle bonus for distance more than one diameter - wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter); - // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter acuteAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2), 300, 400) * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2); + } - // 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(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(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); + wideAngleBonus = calcWideAngleBonus(currAngle); - if (osuLast2Obj != null) + // 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(osuCurrObj.LazyJumpDistance, 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(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(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); + + if (osuLast2Obj != null) + { + // If objects just go back and forth through a middle point - don't give as much wide bonus + // Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object + var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject; + var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject; + + float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length; + + if (distance < 1) { - // If objects just go back and forth through a middle point - don't give as much wide bonus - // Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object - var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject; - var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject; - - float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length; - - if (distance < 1) - { - wideAngleBonus *= 1 - 0.35 * (1 - distance); - } + wideAngleBonus *= 1 - 0.35 * (1 - distance); } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 633f29d6ff..137113092d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills private double currentStrain; - private double skillMultiplier => 25.6; + private double skillMultiplier => 25.45; private double strainDecayBase => 0.15; private readonly List sliderStrains = new List(); From 19e9bffc11f026ebff2dc2831668e606c701f71f Mon Sep 17 00:00:00 2001 From: James Wilson Date: Wed, 11 Jun 2025 17:46:46 +0100 Subject: [PATCH 31/58] Q2 osu! PP rebalance (#33640) * Rebalance aim and speed * Rebalance star rating * Attempt further speed balancing * More balancing * More balancing * Buff aim a bit * More speed balancing * Global rebalance * Speed balancing * Global rebalancing * More speed balancing * Buff aim * MORE BALANCING * Revert "Rebalance star rating" This reverts commit f48c7445e12174c65b74edfef863cb3ae3cc29ff. --- osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs | 2 +- osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs | 2 +- 6 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 828e217455..f8dcdfd5e7 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.6; + 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; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index d503dd2bcc..a2fcf8f11c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { 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_overall_multiplier = 1.0; private const double rhythm_ratio_multiplier = 12.0; /// diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index ee9b46eecb..8cc0fc209a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators 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.9; + private const double distance_multiplier = 0.8; /// /// Evaluates the difficulty of tapping the current object, based on: diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index c048fedd02..c5d85602c6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuDifficultyCalculator : DifficultyCalculator { - private const double performance_base_multiplier = 1.15; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + private 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. private const double difficulty_multiplier = 0.0675; private const double star_rating_multiplier = 0.0265; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 137113092d..5816d27a5e 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills private double currentStrain; - private double skillMultiplier => 25.45; + private double skillMultiplier => 26; 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 334f763be3..7fd1e044ae 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : OsuStrainSkill { - private double skillMultiplier => 1.46; + private double skillMultiplier => 1.47; private double strainDecayBase => 0.3; private double currentStrain; From b783bb70e947c3d2367017cee58747d79a1ffa53 Mon Sep 17 00:00:00 2001 From: Givikap120 <89256026+Givikap120@users.noreply.github.com> Date: Fri, 13 Jun 2025 11:02:31 +0300 Subject: [PATCH 32/58] Optimize rhythm evaluation by replacing curve (#33423) * Update RhythmEvaluator.cs * add smoothstep bell curve * Update osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs Co-authored-by: StanR * Update osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs Co-authored-by: StanR * Rename variables --------- Co-authored-by: StanR Co-authored-by: James Wilson --- .../Difficulty/Evaluators/RhythmEvaluator.cs | 13 ++++++++----- .../Difficulty/Utils/DifficultyCalculationUtils.cs | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index a2fcf8f11c..c00fa4c23e 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -68,16 +68,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators // 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 deltaDifferenceRatio = Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta); - double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / deltaDifferenceRatio), 2)); + 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 fraction = Math.Max(prevDelta / currDelta, currDelta / prevDelta); - double fractionMultiplier = Math.Clamp(2.0 - fraction / 8.0, 0.0, 1.0); + 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 * fractionMultiplier; + double effectiveRatio = windowPenalty * currRatio * differenceMultiplier; if (firstDeltaSwitch) { diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs index 78df8a139b..362a26ec41 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs @@ -66,6 +66,20 @@ namespace osu.Game.Rulesets.Difficulty.Utils /// 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))); + /// + /// Calculates a Smoothstep Bellcurve that returns 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) + { + x -= mean; + x = x > 0 ? (width - x) : (width + x); + return Smoothstep(x, 0, width); + } + /// /// Smoothstep function (https://en.wikipedia.org/wiki/Smoothstep) /// From d5ef8c85240b306020344ab35c8524783bdf36e3 Mon Sep 17 00:00:00 2001 From: Natelytle <92956514+Natelytle@users.noreply.github.com> Date: Wed, 18 Jun 2025 09:14:01 -0400 Subject: [PATCH 33/58] Replace error functions in DifficultyCalculationUtils with good-enough approximations (#33717) * Reimplement error functions * Fix bug with adjustment for negative values * Formatting --------- Co-authored-by: tsunyoku --- .../Difficulty/OsuPerformanceCalculator.cs | 2 +- .../Utils/DifficultyCalculationUtils.cs | 74 ++ ...ifficultyCalculationUtils_ErrorFunction.cs | 688 ------------------ 3 files changed, 75 insertions(+), 689 deletions(-) delete mode 100644 osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils_ErrorFunction.cs diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 272fe9bb65..5c593422fc 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -424,7 +424,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty double deviation; // Tested max precision for the deviation calculation. - if (pLowerBound > 1e-06) + if (pLowerBound > 0.01) { // Compute deviation assuming greats and oks are normally distributed. deviation = greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound)); diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs index 362a26ec41..c813627d51 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs @@ -116,5 +116,79 @@ namespace osu.Game.Rulesets.Difficulty.Utils { return Math.Clamp((x - start) / (end - start), 0.0, 1.0); } + + /// + /// Error function (https://en.wikipedia.org/wiki/Error_function) + /// + /// Value to calculate the function for + public static double Erf(double x) + { + if (x == 0) + return 0; + + if (double.IsPositiveInfinity(x)) + return 1; + + if (double.IsNegativeInfinity(x)) + return -1; + + if (double.IsNaN(x)) + return double.NaN; + + // Constants for approximation (Abramowitz and Stegun formula 7.1.26) + double t = 1.0 / (1.0 + 0.3275911 * Math.Abs(x)); + double tau = t * (0.254829592 + + t * (-0.284496736 + + t * (1.421413741 + + t * (-1.453152027 + + t * 1.061405429)))); + + double erf = 1.0 - tau * Math.Exp(-x * x); + + return x >= 0 ? erf : -erf; + } + + /// + /// Complementary error function (https://en.wikipedia.org/wiki/Error_function) + /// + /// Value to calculate the function for + public static double Erfc(double x) => 1 - Erf(x); + + /// + /// Inverse error function (https://en.wikipedia.org/wiki/Error_function) + /// + /// Value to calculate the function for + public static double ErfInv(double x) + { + if (x <= -1) + return double.NegativeInfinity; + + if (x >= 1) + return double.PositiveInfinity; + + if (x == 0) + return 0; + + const double a = 0.147; + double sgn = Math.Sign(x); + x = Math.Abs(x); + + double ln = Math.Log(1 - x * x); + double t1 = 2 / (Math.PI * a) + ln / 2; + double t2 = ln / a; + 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 erfInv = sgn * (Math.Sqrt(baseApprox) + c); + + return erfInv; + } + + /// + /// Inverse complementary error function (https://en.wikipedia.org/wiki/Error_function) + /// + /// Value to calculate the function for + public static double ErfcInv(double x) => ErfInv(1 - x); } } diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils_ErrorFunction.cs b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils_ErrorFunction.cs deleted file mode 100644 index 4b89cbe7cc..0000000000 --- a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils_ErrorFunction.cs +++ /dev/null @@ -1,688 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -// All code is referenced from the following: -// https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/SpecialFunctions/Erf.cs - -/* - Copyright (c) 2002-2022 Math.NET -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -using System; - -namespace osu.Game.Rulesets.Difficulty.Utils -{ - public partial class DifficultyCalculationUtils - { - /// - /// ************************************** - /// COEFFICIENTS FOR METHOD ErfImp * - /// ************************************** - /// - /// Polynomial coefficients for a numerator of ErfImp - /// calculation for Erf(x) in the interval [1e-10, 0.5]. - /// - private static readonly double[] erf_imp_an = { 0.00337916709551257388990745, -0.00073695653048167948530905, -0.374732337392919607868241, 0.0817442448733587196071743, -0.0421089319936548595203468, 0.0070165709512095756344528, -0.00495091255982435110337458, 0.000871646599037922480317225 }; - - /// Polynomial coefficients for a denominator of ErfImp - /// calculation for Erf(x) in the interval [1e-10, 0.5]. - /// - private static readonly double[] erf_imp_ad = { 1, -0.218088218087924645390535, 0.412542972725442099083918, -0.0841891147873106755410271, 0.0655338856400241519690695, -0.0120019604454941768171266, 0.00408165558926174048329689, -0.000615900721557769691924509 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [0.5, 0.75]. - /// - private static readonly double[] erf_imp_bn = { -0.0361790390718262471360258, 0.292251883444882683221149, 0.281447041797604512774415, 0.125610208862766947294894, 0.0274135028268930549240776, 0.00250839672168065762786937 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [0.5, 0.75]. - /// - private static readonly double[] erf_imp_bd = { 1, 1.8545005897903486499845, 1.43575803037831418074962, 0.582827658753036572454135, 0.124810476932949746447682, 0.0113724176546353285778481 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [0.75, 1.25]. - /// - private static readonly double[] erf_imp_cn = { -0.0397876892611136856954425, 0.153165212467878293257683, 0.191260295600936245503129, 0.10276327061989304213645, 0.029637090615738836726027, 0.0046093486780275489468812, 0.000307607820348680180548455 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [0.75, 1.25]. - /// - private static readonly double[] erf_imp_cd = { 1, 1.95520072987627704987886, 1.64762317199384860109595, 0.768238607022126250082483, 0.209793185936509782784315, 0.0319569316899913392596356, 0.00213363160895785378615014 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [1.25, 2.25]. - /// - private static readonly double[] erf_imp_dn = { -0.0300838560557949717328341, 0.0538578829844454508530552, 0.0726211541651914182692959, 0.0367628469888049348429018, 0.00964629015572527529605267, 0.00133453480075291076745275, 0.778087599782504251917881e-4 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [1.25, 2.25]. - /// - private static readonly double[] erf_imp_dd = { 1, 1.75967098147167528287343, 1.32883571437961120556307, 0.552528596508757581287907, 0.133793056941332861912279, 0.0179509645176280768640766, 0.00104712440019937356634038, -0.106640381820357337177643e-7 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [2.25, 3.5]. - /// - private static readonly double[] erf_imp_en = { -0.0117907570137227847827732, 0.014262132090538809896674, 0.0202234435902960820020765, 0.00930668299990432009042239, 0.00213357802422065994322516, 0.00025022987386460102395382, 0.120534912219588189822126e-4 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [2.25, 3.5]. - /// - private static readonly double[] erf_imp_ed = { 1, 1.50376225203620482047419, 0.965397786204462896346934, 0.339265230476796681555511, 0.0689740649541569716897427, 0.00771060262491768307365526, 0.000371421101531069302990367 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [3.5, 5.25]. - /// - private static readonly double[] erf_imp_fn = { -0.00546954795538729307482955, 0.00404190278731707110245394, 0.0054963369553161170521356, 0.00212616472603945399437862, 0.000394984014495083900689956, 0.365565477064442377259271e-4, 0.135485897109932323253786e-5 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [3.5, 5.25]. - /// - private static readonly double[] erf_imp_fd = { 1, 1.21019697773630784832251, 0.620914668221143886601045, 0.173038430661142762569515, 0.0276550813773432047594539, 0.00240625974424309709745382, 0.891811817251336577241006e-4, -0.465528836283382684461025e-11 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [5.25, 8]. - /// - private static readonly double[] erf_imp_gn = { -0.00270722535905778347999196, 0.0013187563425029400461378, 0.00119925933261002333923989, 0.00027849619811344664248235, 0.267822988218331849989363e-4, 0.923043672315028197865066e-6 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [5.25, 8]. - /// - private static readonly double[] erf_imp_gd = { 1, 0.814632808543141591118279, 0.268901665856299542168425, 0.0449877216103041118694989, 0.00381759663320248459168994, 0.000131571897888596914350697, 0.404815359675764138445257e-11 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [8, 11.5]. - /// - private static readonly double[] erf_imp_hn = { -0.00109946720691742196814323, 0.000406425442750422675169153, 0.000274499489416900707787024, 0.465293770646659383436343e-4, 0.320955425395767463401993e-5, 0.778286018145020892261936e-7 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [8, 11.5]. - /// - private static readonly double[] erf_imp_hd = { 1, 0.588173710611846046373373, 0.139363331289409746077541, 0.0166329340417083678763028, 0.00100023921310234908642639, 0.24254837521587225125068e-4 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [11.5, 17]. - /// - private static readonly double[] erf_imp_in = { -0.00056907993601094962855594, 0.000169498540373762264416984, 0.518472354581100890120501e-4, 0.382819312231928859704678e-5, 0.824989931281894431781794e-7 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [11.5, 17]. - /// - private static readonly double[] erf_imp_id = { 1, 0.339637250051139347430323, 0.043472647870310663055044, 0.00248549335224637114641629, 0.535633305337152900549536e-4, -0.117490944405459578783846e-12 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [17, 24]. - /// - private static readonly double[] erf_imp_jn = { -0.000241313599483991337479091, 0.574224975202501512365975e-4, 0.115998962927383778460557e-4, 0.581762134402593739370875e-6, 0.853971555085673614607418e-8 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [17, 24]. - /// - private static readonly double[] erf_imp_jd = { 1, 0.233044138299687841018015, 0.0204186940546440312625597, 0.000797185647564398289151125, 0.117019281670172327758019e-4 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [24, 38]. - /// - private static readonly double[] erf_imp_kn = { -0.000146674699277760365803642, 0.162666552112280519955647e-4, 0.269116248509165239294897e-5, 0.979584479468091935086972e-7, 0.101994647625723465722285e-8 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [24, 38]. - /// - private static readonly double[] erf_imp_kd = { 1, 0.165907812944847226546036, 0.0103361716191505884359634, 0.000286593026373868366935721, 0.298401570840900340874568e-5 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [38, 60]. - /// - private static readonly double[] erf_imp_ln = { -0.583905797629771786720406e-4, 0.412510325105496173512992e-5, 0.431790922420250949096906e-6, 0.993365155590013193345569e-8, 0.653480510020104699270084e-10 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [38, 60]. - /// - private static readonly double[] erf_imp_ld = { 1, 0.105077086072039915406159, 0.00414278428675475620830226, 0.726338754644523769144108e-4, 0.477818471047398785369849e-6 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [60, 85]. - /// - private static readonly double[] erf_imp_mn = { -0.196457797609229579459841e-4, 0.157243887666800692441195e-5, 0.543902511192700878690335e-7, 0.317472492369117710852685e-9 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [60, 85]. - /// - private static readonly double[] erf_imp_md = { 1, 0.052803989240957632204885, 0.000926876069151753290378112, 0.541011723226630257077328e-5, 0.535093845803642394908747e-15 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [85, 110]. - /// - private static readonly double[] erf_imp_nn = { -0.789224703978722689089794e-5, 0.622088451660986955124162e-6, 0.145728445676882396797184e-7, 0.603715505542715364529243e-10 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [85, 110]. - /// - private static readonly double[] erf_imp_nd = { 1, 0.0375328846356293715248719, 0.000467919535974625308126054, 0.193847039275845656900547e-5 }; - - /// - /// ************************************** - /// COEFFICIENTS FOR METHOD ErfInvImp * - /// ************************************** - /// - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0, 0.5]. - /// - private static readonly double[] erv_inv_imp_an = { -0.000508781949658280665617, -0.00836874819741736770379, 0.0334806625409744615033, -0.0126926147662974029034, -0.0365637971411762664006, 0.0219878681111168899165, 0.00822687874676915743155, -0.00538772965071242932965 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0, 0.5]. - /// - private static readonly double[] erv_inv_imp_ad = { 1, -0.970005043303290640362, -1.56574558234175846809, 1.56221558398423026363, 0.662328840472002992063, -0.71228902341542847553, -0.0527396382340099713954, 0.0795283687341571680018, -0.00233393759374190016776, 0.000886216390456424707504 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.5, 0.75]. - /// - private static readonly double[] erv_inv_imp_bn = { -0.202433508355938759655, 0.105264680699391713268, 8.37050328343119927838, 17.6447298408374015486, -18.8510648058714251895, -44.6382324441786960818, 17.445385985570866523, 21.1294655448340526258, -3.67192254707729348546 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.5, 0.75]. - /// - private static readonly double[] erv_inv_imp_bd = { 1, 6.24264124854247537712, 3.9713437953343869095, -28.6608180499800029974, -20.1432634680485188801, 48.5609213108739935468, 10.8268667355460159008, -22.6436933413139721736, 1.72114765761200282724 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x less than 3. - /// - private static readonly double[] erv_inv_imp_cn = { -0.131102781679951906451, -0.163794047193317060787, 0.117030156341995252019, 0.387079738972604337464, 0.337785538912035898924, 0.142869534408157156766, 0.0290157910005329060432, 0.00214558995388805277169, -0.679465575181126350155e-6, 0.285225331782217055858e-7, -0.681149956853776992068e-9 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x less than 3. - /// - private static readonly double[] erv_inv_imp_cd = { 1, 3.46625407242567245975, 5.38168345707006855425, 4.77846592945843778382, 2.59301921623620271374, 0.848854343457902036425, 0.152264338295331783612, 0.01105924229346489121 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 3 and 6. - /// - private static readonly double[] erv_inv_imp_dn = { -0.0350353787183177984712, -0.00222426529213447927281, 0.0185573306514231072324, 0.00950804701325919603619, 0.00187123492819559223345, 0.000157544617424960554631, 0.460469890584317994083e-5, -0.230404776911882601748e-9, 0.266339227425782031962e-11 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 3 and 6. - /// - private static readonly double[] erv_inv_imp_dd = { 1, 1.3653349817554063097, 0.762059164553623404043, 0.220091105764131249824, 0.0341589143670947727934, 0.00263861676657015992959, 0.764675292302794483503e-4 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 6 and 18. - /// - private static readonly double[] erv_inv_imp_en = { -0.0167431005076633737133, -0.00112951438745580278863, 0.00105628862152492910091, 0.000209386317487588078668, 0.149624783758342370182e-4, 0.449696789927706453732e-6, 0.462596163522878599135e-8, -0.281128735628831791805e-13, 0.99055709973310326855e-16 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 6 and 18. - /// - private static readonly double[] erv_inv_imp_ed = { 1, 0.591429344886417493481, 0.138151865749083321638, 0.0160746087093676504695, 0.000964011807005165528527, 0.275335474764726041141e-4, 0.282243172016108031869e-6 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 18 and 44. - /// - private static readonly double[] erv_inv_imp_fn = { -0.0024978212791898131227, -0.779190719229053954292e-5, 0.254723037413027451751e-4, 0.162397777342510920873e-5, 0.396341011304801168516e-7, 0.411632831190944208473e-9, 0.145596286718675035587e-11, -0.116765012397184275695e-17 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 18 and 44. - /// - private static readonly double[] erv_inv_imp_fd = { 1, 0.207123112214422517181, 0.0169410838120975906478, 0.000690538265622684595676, 0.145007359818232637924e-4, 0.144437756628144157666e-6, 0.509761276599778486139e-9 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x greater than 44. - /// - private static readonly double[] erv_inv_imp_gn = { -0.000539042911019078575891, -0.28398759004727721098e-6, 0.899465114892291446442e-6, 0.229345859265920864296e-7, 0.225561444863500149219e-9, 0.947846627503022684216e-12, 0.135880130108924861008e-14, -0.348890393399948882918e-21 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x greater than 44. - /// - private static readonly double[] erv_inv_imp_gd = { 1, 0.0845746234001899436914, 0.00282092984726264681981, 0.468292921940894236786e-4, 0.399968812193862100054e-6, 0.161809290887904476097e-8, 0.231558608310259605225e-11 }; - - /// Calculates the error function. - /// The value to evaluate. - /// the error function evaluated at given value. - /// - /// - /// returns 1 if x == double.PositiveInfinity. - /// returns -1 if x == double.NegativeInfinity. - /// - /// - public static double Erf(double x) - { - if (x == 0) - { - return 0; - } - - if (double.IsPositiveInfinity(x)) - { - return 1; - } - - if (double.IsNegativeInfinity(x)) - { - return -1; - } - - if (double.IsNaN(x)) - { - return double.NaN; - } - - return erfImp(x, false); - } - - /// Calculates the complementary error function. - /// The value to evaluate. - /// the complementary error function evaluated at given value. - /// - /// - /// returns 0 if x == double.PositiveInfinity. - /// returns 2 if x == double.NegativeInfinity. - /// - /// - public static double Erfc(double x) - { - if (x == 0) - { - return 1; - } - - if (double.IsPositiveInfinity(x)) - { - return 0; - } - - if (double.IsNegativeInfinity(x)) - { - return 2; - } - - if (double.IsNaN(x)) - { - return double.NaN; - } - - return erfImp(x, true); - } - - /// Calculates the inverse error function evaluated at z. - /// The inverse error function evaluated at given value. - /// - /// - /// returns double.PositiveInfinity if z >= 1.0. - /// returns double.NegativeInfinity if z <= -1.0. - /// - /// - /// Calculates the inverse error function evaluated at z. - /// value to evaluate. - /// the inverse error function evaluated at Z. - public static double ErfInv(double z) - { - if (z == 0.0) - { - return 0.0; - } - - if (z >= 1.0) - { - return double.PositiveInfinity; - } - - if (z <= -1.0) - { - return double.NegativeInfinity; - } - - double p, q, s; - - if (z < 0) - { - p = -z; - q = 1 - p; - s = -1; - } - else - { - p = z; - q = 1 - z; - s = 1; - } - - return erfInvImpl(p, q, s); - } - - /// - /// Implementation of the error function. - /// - /// Where to evaluate the error function. - /// Whether to compute 1 - the error function. - /// the error function. - private static double erfImp(double z, bool invert) - { - if (z < 0) - { - if (!invert) - { - return -erfImp(-z, false); - } - - if (z < -0.5) - { - return 2 - erfImp(-z, true); - } - - return 1 + erfImp(-z, false); - } - - double result; - - // Big bunch of selection statements now to pick which - // implementation to use, try to put most likely options - // first: - if (z < 0.5) - { - // We're going to calculate erf: - if (z < 1e-10) - { - result = (z * 1.125) + (z * 0.003379167095512573896158903121545171688); - } - else - { - // Worst case absolute error found: 6.688618532e-21 - result = (z * 1.125) + (z * evaluatePolynomial(z, erf_imp_an) / evaluatePolynomial(z, erf_imp_ad)); - } - } - else if (z < 110) - { - // We'll be calculating erfc: - invert = !invert; - double r, b; - - if (z < 0.75) - { - // Worst case absolute error found: 5.582813374e-21 - r = evaluatePolynomial(z - 0.5, erf_imp_bn) / evaluatePolynomial(z - 0.5, erf_imp_bd); - b = 0.3440242112F; - } - else if (z < 1.25) - { - // Worst case absolute error found: 4.01854729e-21 - r = evaluatePolynomial(z - 0.75, erf_imp_cn) / evaluatePolynomial(z - 0.75, erf_imp_cd); - b = 0.419990927F; - } - else if (z < 2.25) - { - // Worst case absolute error found: 2.866005373e-21 - r = evaluatePolynomial(z - 1.25, erf_imp_dn) / evaluatePolynomial(z - 1.25, erf_imp_dd); - b = 0.4898625016F; - } - else if (z < 3.5) - { - // Worst case absolute error found: 1.045355789e-21 - r = evaluatePolynomial(z - 2.25, erf_imp_en) / evaluatePolynomial(z - 2.25, erf_imp_ed); - b = 0.5317370892F; - } - else if (z < 5.25) - { - // Worst case absolute error found: 8.300028706e-22 - r = evaluatePolynomial(z - 3.5, erf_imp_fn) / evaluatePolynomial(z - 3.5, erf_imp_fd); - b = 0.5489973426F; - } - else if (z < 8) - { - // Worst case absolute error found: 1.700157534e-21 - r = evaluatePolynomial(z - 5.25, erf_imp_gn) / evaluatePolynomial(z - 5.25, erf_imp_gd); - b = 0.5571740866F; - } - else if (z < 11.5) - { - // Worst case absolute error found: 3.002278011e-22 - r = evaluatePolynomial(z - 8, erf_imp_hn) / evaluatePolynomial(z - 8, erf_imp_hd); - b = 0.5609807968F; - } - else if (z < 17) - { - // Worst case absolute error found: 6.741114695e-21 - r = evaluatePolynomial(z - 11.5, erf_imp_in) / evaluatePolynomial(z - 11.5, erf_imp_id); - b = 0.5626493692F; - } - else if (z < 24) - { - // Worst case absolute error found: 7.802346984e-22 - r = evaluatePolynomial(z - 17, erf_imp_jn) / evaluatePolynomial(z - 17, erf_imp_jd); - b = 0.5634598136F; - } - else if (z < 38) - { - // Worst case absolute error found: 2.414228989e-22 - r = evaluatePolynomial(z - 24, erf_imp_kn) / evaluatePolynomial(z - 24, erf_imp_kd); - b = 0.5638477802F; - } - else if (z < 60) - { - // Worst case absolute error found: 5.896543869e-24 - r = evaluatePolynomial(z - 38, erf_imp_ln) / evaluatePolynomial(z - 38, erf_imp_ld); - b = 0.5640528202F; - } - else if (z < 85) - { - // Worst case absolute error found: 3.080612264e-21 - r = evaluatePolynomial(z - 60, erf_imp_mn) / evaluatePolynomial(z - 60, erf_imp_md); - b = 0.5641309023F; - } - else - { - // Worst case absolute error found: 8.094633491e-22 - r = evaluatePolynomial(z - 85, erf_imp_nn) / evaluatePolynomial(z - 85, erf_imp_nd); - b = 0.5641584396F; - } - - double g = Math.Exp(-z * z) / z; - result = (g * b) + (g * r); - } - else - { - // Any value of z larger than 28 will underflow to zero: - result = 0; - invert = !invert; - } - - if (invert) - { - result = 1 - result; - } - - return result; - } - - /// Calculates the complementary inverse error function evaluated at z. - /// The complementary inverse error function evaluated at given value. - /// We have tested this implementation against the arbitrary precision mpmath library - /// and found cases where we can only guarantee 9 significant figures correct. - /// - /// returns double.PositiveInfinity if z <= 0.0. - /// returns double.NegativeInfinity if z >= 2.0. - /// - /// - /// calculates the complementary inverse error function evaluated at z. - /// value to evaluate. - /// the complementary inverse error function evaluated at Z. - public static double ErfcInv(double z) - { - if (z <= 0.0) - { - return double.PositiveInfinity; - } - - if (z >= 2.0) - { - return double.NegativeInfinity; - } - - double p, q, s; - - if (z > 1) - { - q = 2 - z; - p = 1 - q; - s = -1; - } - else - { - p = 1 - z; - q = z; - s = 1; - } - - return erfInvImpl(p, q, s); - } - - /// - /// The implementation of the inverse error function. - /// - /// First intermediate parameter. - /// Second intermediate parameter. - /// Third intermediate parameter. - /// the inverse error function. - private static double erfInvImpl(double p, double q, double s) - { - double result; - - if (p <= 0.5) - { - // Evaluate inverse erf using the rational approximation: - // - // x = p(p+10)(Y+R(p)) - // - // Where Y is a constant, and R(p) is optimized for a low - // absolute error compared to |Y|. - // - // double: Max error found: 2.001849e-18 - // long double: Max error found: 1.017064e-20 - // Maximum Deviation Found (actual error term at infinite precision) 8.030e-21 - const float y = 0.0891314744949340820313f; - double g = p * (p + 10); - double r = evaluatePolynomial(p, erv_inv_imp_an) / evaluatePolynomial(p, erv_inv_imp_ad); - result = (g * y) + (g * r); - } - else if (q >= 0.25) - { - // Rational approximation for 0.5 > q >= 0.25 - // - // x = sqrt(-2*log(q)) / (Y + R(q)) - // - // Where Y is a constant, and R(q) is optimized for a low - // absolute error compared to Y. - // - // double : Max error found: 7.403372e-17 - // long double : Max error found: 6.084616e-20 - // Maximum Deviation Found (error term) 4.811e-20 - const float y = 2.249481201171875f; - double g = Math.Sqrt(-2 * Math.Log(q)); - double xs = q - 0.25; - double r = evaluatePolynomial(xs, erv_inv_imp_bn) / evaluatePolynomial(xs, erv_inv_imp_bd); - result = g / (y + r); - } - else - { - // For q < 0.25 we have a series of rational approximations all - // of the general form: - // - // let: x = sqrt(-log(q)) - // - // Then the result is given by: - // - // x(Y+R(x-B)) - // - // where Y is a constant, B is the lowest value of x for which - // the approximation is valid, and R(x-B) is optimized for a low - // absolute error compared to Y. - // - // Note that almost all code will really go through the first - // or maybe second approximation. After than we're dealing with very - // small input values indeed: 80 and 128 bit long double's go all the - // way down to ~ 1e-5000 so the "tail" is rather long... - double x = Math.Sqrt(-Math.Log(q)); - - if (x < 3) - { - // Max error found: 1.089051e-20 - const float y = 0.807220458984375f; - double xs = x - 1.125; - double r = evaluatePolynomial(xs, erv_inv_imp_cn) / evaluatePolynomial(xs, erv_inv_imp_cd); - result = (y * x) + (r * x); - } - else if (x < 6) - { - // Max error found: 8.389174e-21 - const float y = 0.93995571136474609375f; - double xs = x - 3; - double r = evaluatePolynomial(xs, erv_inv_imp_dn) / evaluatePolynomial(xs, erv_inv_imp_dd); - result = (y * x) + (r * x); - } - else if (x < 18) - { - // Max error found: 1.481312e-19 - const float y = 0.98362827301025390625f; - double xs = x - 6; - double r = evaluatePolynomial(xs, erv_inv_imp_en) / evaluatePolynomial(xs, erv_inv_imp_ed); - result = (y * x) + (r * x); - } - else if (x < 44) - { - // Max error found: 5.697761e-20 - const float y = 0.99714565277099609375f; - double xs = x - 18; - double r = evaluatePolynomial(xs, erv_inv_imp_fn) / evaluatePolynomial(xs, erv_inv_imp_fd); - result = (y * x) + (r * x); - } - else - { - // Max error found: 1.279746e-20 - const float y = 0.99941349029541015625f; - double xs = x - 44; - double r = evaluatePolynomial(xs, erv_inv_imp_gn) / evaluatePolynomial(xs, erv_inv_imp_gd); - result = (y * x) + (r * x); - } - } - - return s * result; - } - - /// - /// Evaluate a polynomial at point x. - /// Coefficients are ordered ascending by power with power k at index k. - /// Example: coefficients [3,-1,2] represent y=2x^2-x+3. - /// - /// The location where to evaluate the polynomial at. - /// The coefficients of the polynomial, coefficient for power k at index k. - /// - /// is a null reference. - /// - private static double evaluatePolynomial(double z, params double[] coefficients) - { - // 2020-10-07 jbialogrodzki #730 Since this is public API we should probably - // handle null arguments? It doesn't seem to have been done consistently in this class though. - ArgumentNullException.ThrowIfNull(coefficients); - - // 2020-10-07 jbialogrodzki #730 Zero polynomials need explicit handling. - // Without this check, we attempted to peek coefficients at negative indices! - int n = coefficients.Length; - - if (n == 0) - { - return 0; - } - - double sum = coefficients[n - 1]; - - for (int i = n - 2; i >= 0; --i) - { - sum *= z; - sum += coefficients[i]; - } - - return sum; - } - } -} From cf4d6bea72eefef1ca2e4e32fc483b50ee9f4686 Mon Sep 17 00:00:00 2001 From: Natelytle <92956514+Natelytle@users.noreply.github.com> Date: Fri, 27 Jun 2025 18:46:52 -0400 Subject: [PATCH 34/58] Implement difficulty evaluators in the osu! mania ruleset (#33411) * stuff * Implement evaluators * Typo * Fixes * clarifying comment * Fix CalculateInitialStrain * Remove debug line * Small code quality fix * Address comments, slight code quality fixes * Change comment for clarity --------- Co-authored-by: StanR --- .../Evaluators/IndividualStrainEvaluator.cs | 37 ++++++++++ .../Evaluators/OverallStrainEvaluator.cs | 61 +++++++++++++++ .../Difficulty/ManiaDifficultyCalculator.cs | 11 ++- .../Preprocessing/ManiaDifficultyHitObject.cs | 52 ++++++++++++- .../Difficulty/Skills/Strain.cs | 74 ++++--------------- 5 files changed, 172 insertions(+), 63 deletions(-) create mode 100644 osu.Game.Rulesets.Mania/Difficulty/Evaluators/IndividualStrainEvaluator.cs create mode 100644 osu.Game.Rulesets.Mania/Difficulty/Evaluators/OverallStrainEvaluator.cs diff --git a/osu.Game.Rulesets.Mania/Difficulty/Evaluators/IndividualStrainEvaluator.cs b/osu.Game.Rulesets.Mania/Difficulty/Evaluators/IndividualStrainEvaluator.cs new file mode 100644 index 0000000000..297beb2840 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Difficulty/Evaluators/IndividualStrainEvaluator.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Utils; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; + +namespace osu.Game.Rulesets.Mania.Difficulty.Evaluators +{ + public class IndividualStrainEvaluator + { + public static double EvaluateDifficultyOf(DifficultyHitObject current) + { + var maniaCurrent = (ManiaDifficultyHitObject)current; + double startTime = maniaCurrent.StartTime; + double endTime = maniaCurrent.EndTime; + + double holdFactor = 1.0; // Factor to all additional strains in case something else is held + + // We award a bonus if this note starts and ends before the end of another hold note. + foreach (var maniaPrevious in maniaCurrent.PreviousHitObjects) + { + if (maniaPrevious is null) + continue; + + if (Precision.DefinitelyBigger(maniaPrevious.EndTime, endTime, 1) && + Precision.DefinitelyBigger(startTime, maniaPrevious.StartTime, 1)) + { + holdFactor = 1.25; + break; + } + } + + return 2.0 * holdFactor; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Difficulty/Evaluators/OverallStrainEvaluator.cs b/osu.Game.Rulesets.Mania/Difficulty/Evaluators/OverallStrainEvaluator.cs new file mode 100644 index 0000000000..97782f7644 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Difficulty/Evaluators/OverallStrainEvaluator.cs @@ -0,0 +1,61 @@ +// 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.Framework.Utils; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; + +namespace osu.Game.Rulesets.Mania.Difficulty.Evaluators +{ + public class OverallStrainEvaluator + { + private const double release_threshold = 30; + + public static double EvaluateDifficultyOf(DifficultyHitObject current) + { + var maniaCurrent = (ManiaDifficultyHitObject)current; + double startTime = maniaCurrent.StartTime; + double endTime = maniaCurrent.EndTime; + bool isOverlapping = false; + + double closestEndTime = Math.Abs(endTime - startTime); // Lowest value we can assume with the current information + double holdFactor = 1.0; // Factor to all additional strains in case something else is held + double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly + + foreach (var maniaPrevious in maniaCurrent.PreviousHitObjects) + { + if (maniaPrevious is null) + continue; + + // The current note is overlapped if a previous note or end is overlapping the current note body + isOverlapping |= Precision.DefinitelyBigger(maniaPrevious.EndTime, startTime, 1) && + Precision.DefinitelyBigger(endTime, maniaPrevious.EndTime, 1) && + Precision.DefinitelyBigger(startTime, maniaPrevious.StartTime, 1); + + // We give a slight bonus to everything if something is held meanwhile + if (Precision.DefinitelyBigger(maniaPrevious.EndTime, endTime, 1) && + Precision.DefinitelyBigger(startTime, maniaPrevious.StartTime, 1)) + holdFactor = 1.25; + + closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - maniaPrevious.EndTime)); + } + + // The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending. + // Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away. + // holdAddition + // ^ + // 1.0 + - - - - - -+----------- + // | / + // 0.5 + - - - - -/ Sigmoid Curve + // | /| + // 0.0 +--------+-+---------------> Release Difference / ms + // release_threshold + if (isOverlapping) + holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold); + + return (1 + holdAddition) * holdFactor; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index 06b8018f2b..bcf16e6808 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -65,13 +65,22 @@ namespace osu.Game.Rulesets.Mania.Difficulty protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { var sortedObjects = beatmap.HitObjects.ToArray(); + int totalColumns = ((ManiaBeatmap)beatmap).TotalColumns; LegacySortHelper.Sort(sortedObjects, Comparer.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime))); List objects = new List(); + List[] perColumnObjects = new List[totalColumns]; + + for (int column = 0; column < totalColumns; column++) + perColumnObjects[column] = new List(); for (int i = 1; i < sortedObjects.Length; i++) - objects.Add(new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate, objects, objects.Count)); + { + var currentObject = new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate, objects, perColumnObjects, objects.Count); + objects.Add(currentObject); + perColumnObjects[currentObject.Column].Add(currentObject); + } return objects; } diff --git a/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs b/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs index a67d38b29f..91b6a2b861 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs @@ -12,9 +12,59 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Preprocessing { public new ManiaHitObject BaseObject => (ManiaHitObject)base.BaseObject; - public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int index) + private readonly List[] perColumnObjects; + + private readonly int columnIndex; + + public readonly int Column; + + // The hit object earlier in time than this note in each column + public readonly ManiaDifficultyHitObject?[] PreviousHitObjects; + + public readonly double ColumnStrainTime; + + public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, List[] perColumnObjects, int index) : base(hitObject, lastObject, clockRate, objects, index) { + int totalColumns = perColumnObjects.Length; + this.perColumnObjects = perColumnObjects; + Column = BaseObject.Column; + columnIndex = perColumnObjects[Column].Count; + PreviousHitObjects = new ManiaDifficultyHitObject[totalColumns]; + ColumnStrainTime = StartTime - PrevInColumn(0)?.StartTime ?? StartTime; + + if (index > 0) + { + ManiaDifficultyHitObject prevNote = (ManiaDifficultyHitObject)objects[index - 1]; + + for (int i = 0; i < prevNote.PreviousHitObjects.Length; i++) + PreviousHitObjects[i] = prevNote.PreviousHitObjects[i]; + + // intentionally depends on processing order to match live. + PreviousHitObjects[prevNote.Column] = prevNote; + } + } + + /// + /// The previous object in the same column as this , exclusive of Long Note tails. + /// + /// The number of notes to go back. + /// The object in this column notes back, or null if this is the first note in the column. + public ManiaDifficultyHitObject? PrevInColumn(int backwardsIndex) + { + int index = columnIndex - (backwardsIndex + 1); + return index >= 0 && index < perColumnObjects[Column].Count ? (ManiaDifficultyHitObject)perColumnObjects[Column][index] : null; + } + + /// + /// The next object in the same column as this , exclusive of Long Note tails. + /// + /// The number of notes to go forward. + /// The object in this column notes forward, or null if this is the last note in the column. + public ManiaDifficultyHitObject? NextInColumn(int forwardsIndex) + { + int index = columnIndex + (forwardsIndex + 1); + return index >= 0 && index < perColumnObjects[Column].Count ? (ManiaDifficultyHitObject)perColumnObjects[Column][index] : null; } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs index bb4261ea13..037b7e3511 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs @@ -2,10 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; -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.Mania.Difficulty.Evaluators; using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; @@ -15,23 +14,17 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills { private const double individual_decay_base = 0.125; private const double overall_decay_base = 0.30; - private const double release_threshold = 30; protected override double SkillMultiplier => 1; protected override double StrainDecayBase => 1; - private readonly double[] startTimes; - private readonly double[] endTimes; private readonly double[] individualStrains; - - private double individualStrain; + private double highestIndividualStrain; private double overallStrain; public Strain(Mod[] mods, int totalColumns) : base(mods) { - startTimes = new double[totalColumns]; - endTimes = new double[totalColumns]; individualStrains = new double[totalColumns]; overallStrain = 1; } @@ -39,65 +32,24 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills protected override double StrainValueOf(DifficultyHitObject current) { var maniaCurrent = (ManiaDifficultyHitObject)current; - double startTime = maniaCurrent.StartTime; - double endTime = maniaCurrent.EndTime; - int column = maniaCurrent.BaseObject.Column; - bool isOverlapping = false; - double closestEndTime = Math.Abs(endTime - startTime); // Lowest value we can assume with the current information - double holdFactor = 1.0; // Factor to all additional strains in case something else is held - double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly + individualStrains[maniaCurrent.Column] = applyDecay(individualStrains[maniaCurrent.Column], maniaCurrent.ColumnStrainTime, individual_decay_base); + individualStrains[maniaCurrent.Column] += IndividualStrainEvaluator.EvaluateDifficultyOf(current); - for (int i = 0; i < endTimes.Length; ++i) - { - // The current note is overlapped if a previous note or end is overlapping the current note body - isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) && - Precision.DefinitelyBigger(endTime, endTimes[i], 1) && - Precision.DefinitelyBigger(startTime, startTimes[i], 1); + // Take the hardest individualStrain for notes that happen at the same time (in a chord). + // This is to ensure the order in which the notes are processed does not affect the resultant total strain. + highestIndividualStrain = maniaCurrent.DeltaTime <= 1 ? Math.Max(highestIndividualStrain, individualStrains[maniaCurrent.Column]) : individualStrains[maniaCurrent.Column]; - // We give a slight bonus to everything if something is held meanwhile - if (Precision.DefinitelyBigger(endTimes[i], endTime, 1) && - Precision.DefinitelyBigger(startTime, startTimes[i], 1)) - holdFactor = 1.25; - - closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - endTimes[i])); - } - - // The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending. - // Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away. - // holdAddition - // ^ - // 1.0 + - - - - - -+----------- - // | / - // 0.5 + - - - - -/ Sigmoid Curve - // | /| - // 0.0 +--------+-+---------------> Release Difference / ms - // release_threshold - if (isOverlapping) - holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold); - - // Decay and increase individualStrains in own column - individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base); - individualStrains[column] += 2.0 * holdFactor; - - // For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns - individualStrain = maniaCurrent.DeltaTime <= 1 ? Math.Max(individualStrain, individualStrains[column]) : individualStrains[column]; - - // Decay and increase overallStrain - overallStrain = applyDecay(overallStrain, current.DeltaTime, overall_decay_base); - overallStrain += (1 + holdAddition) * holdFactor; - - // Update startTimes and endTimes arrays - startTimes[column] = startTime; - endTimes[column] = endTime; + overallStrain = applyDecay(overallStrain, maniaCurrent.DeltaTime, overall_decay_base); + overallStrain += OverallStrainEvaluator.EvaluateDifficultyOf(current); // By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section. - return individualStrain + overallStrain - CurrentStrain; + return highestIndividualStrain + overallStrain - CurrentStrain; } - protected override double CalculateInitialStrain(double offset, DifficultyHitObject current) - => applyDecay(individualStrain, offset - current.Previous(0).StartTime, individual_decay_base) - + applyDecay(overallStrain, offset - current.Previous(0).StartTime, overall_decay_base); + protected override double CalculateInitialStrain(double offset, DifficultyHitObject current) => + applyDecay(highestIndividualStrain, offset - current.Previous(0).StartTime, individual_decay_base) + + 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); From a75e0c3850d4e23a4b004cbf3d0ff0b188040f03 Mon Sep 17 00:00:00 2001 From: Givikap120 <89256026+Givikap120@users.noreply.github.com> Date: Tue, 8 Jul 2025 18:37:41 +0300 Subject: [PATCH 35/58] Refactor AR and OD calculations in osu! pp calculation (#34065) * Add AR and OD calculation functions * use created functions in perfcalc --- .../Difficulty/OsuDifficultyCalculator.cs | 27 ++++++++++++------- .../Difficulty/OsuPerformanceCalculator.cs | 7 ++--- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index c5d85602c6..2907f5f58e 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -69,6 +69,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty return readingBonus; } + 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, double clockRate) { if (beatmap.HitObjects.Count == 0) @@ -94,15 +110,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty double difficultSliders = aim.GetDifficultSliders(); - double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; - double approachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5; - - HitWindows hitWindows = new OsuHitWindows(); - hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); - - double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; - - double overallDifficulty = (80 - hitWindowGreat) / 6; + double approachRate = CalculateRateAdjustedApproachRate(beatmap.Difficulty.ApproachRate, clockRate); + double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, clockRate); int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle); int sliderCount = beatmap.HitObjects.Count(h => h is Slider); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 966f8da261..49626eb7b6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Scoring; @@ -92,10 +91,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty okHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate; mehHitWindow = hitWindows.WindowFor(HitResult.Meh) / clockRate; - double preempt = IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450) / clockRate; - - overallDifficulty = (79.5 - greatHitWindow) / 6; - approachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5; + approachRate = OsuDifficultyCalculator.CalculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate); + overallDifficulty = OsuDifficultyCalculator.CalculateRateAdjustedOverallDifficulty(difficulty.OverallDifficulty, clockRate); double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes); double? scoreBasedEstimatedMissCount = null; From ddf9d6b8c8ce6ce47fa6cb9db55b98bfc2c04ac5 Mon Sep 17 00:00:00 2001 From: Jay Lawton Date: Tue, 22 Jul 2025 19:37:51 +1000 Subject: [PATCH 36/58] ensure `monolengthbonus` applies to new strain contribution only (#33635) * stamina fix * review changes * fix naming --------- Co-authored-by: StanR --- .../Difficulty/Skills/Stamina.cs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs index 0e1f3d41cf..7c0c76d3ba 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs @@ -42,20 +42,28 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills protected override double StrainValueAt(DifficultyHitObject current) { currentStrain *= strainDecay(current.DeltaTime); - currentStrain += StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; + double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; // Safely prevents previous strains from shifting as new notes are added. var currentObject = current as TaikoDifficultyHitObject; int index = currentObject?.ColourData.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0; - double monolengthBonus = isConvert ? 1 : 1 + Math.Min(Math.Max((index - 5) / 50.0, 0), 0.30); + double monoLengthBonus = isConvert ? 1.0 : 1.0 + 0.3 * DifficultyCalculationUtils.ReverseLerp(index, 5, 20); - if (SingleColourStamina) - return DifficultyCalculationUtils.Logistic(-(index - 10) / 2.0, currentStrain); + // Mono-streak bonus is only applied to colour-based stamina to reward longer sequences of same-colour hits within patterns. + if (!SingleColourStamina) + staminaDifficulty *= monoLengthBonus; - return currentStrain * monolengthBonus; + currentStrain += staminaDifficulty; + + // 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; } - protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => SingleColourStamina ? 0 : currentStrain * strainDecay(time - current.Previous(0).StartTime); + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => + SingleColourStamina + ? 0 + : currentStrain * strainDecay(time - current.Previous(0).StartTime); } } From 56b072cfd9cf8c01d00ba3358ebd2f381e53ab3d Mon Sep 17 00:00:00 2001 From: Givikap120 <89256026+Givikap120@users.noreply.github.com> Date: Thu, 24 Jul 2025 18:41:36 +0300 Subject: [PATCH 37/58] remove high CS bonus from slider bonus (#34214) Co-authored-by: StanR --- 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 f8dcdfd5e7..5942448855 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -155,13 +155,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators // 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; - return aimStrain; } From 945db7b431d8abcb2918e191f23112ff17da4319 Mon Sep 17 00:00:00 2001 From: James Wilson Date: Fri, 25 Jul 2025 07:50:23 +0100 Subject: [PATCH 38/58] Fix backwards logic on visibility bonus (#34369) Co-authored-by: StanR --- osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 2907f5f58e..513352825f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -51,7 +51,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty public static double CalculateVisibilityBonus(Mod[] mods, double approachRate, double visibilityFactor = 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(); + bool isAlwaysPartiallyVisible = mods.OfType().Any(m => m.OnlyFadeApproachCircles.Value) || mods.OfType().Any(); // Start from normal curve, rewarding lower AR up to AR5 double readingBonus = 0.04 * (12.0 - Math.Max(approachRate, 5)); @@ -60,11 +60,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty // For AR up to 0 - reduce reward for very low ARs when object is visible if (approachRate < 5) - readingBonus += (isAlwaysPartiallyVisible ? 0.04 : 0.03) * (5.0 - Math.Max(approachRate, 0)); + readingBonus += (isAlwaysPartiallyVisible ? 0.03 : 0.04) * (5.0 - Math.Max(approachRate, 0)); // Starting from AR0 - cap values so they won't grow to infinity if (approachRate < 0) - readingBonus += (isAlwaysPartiallyVisible ? 0.1 : 0.075) * (1 - Math.Pow(1.5, approachRate)); + readingBonus += (isAlwaysPartiallyVisible ? 0.075 : 0.1) * (1 - Math.Pow(1.5, approachRate)); return readingBonus; } From 28d36dd3bd6d50e713d05bd5ee8ffdd454b4be8e Mon Sep 17 00:00:00 2001 From: James Wilson Date: Fri, 25 Jul 2025 16:47:21 +0100 Subject: [PATCH 39/58] Move rating calculations to `OsuRatingCalculator` (#33265) * Move rating calculations to `OsuRatingCalculator` * Use `CalculateDifficultyRating` --- .../Difficulty/OsuDifficultyCalculator.cs | 200 ++---------------- .../Difficulty/OsuPerformanceCalculator.cs | 4 +- .../Difficulty/OsuRatingCalculator.cs | 199 +++++++++++++++++ 3 files changed, 216 insertions(+), 187 deletions(-) create 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 513352825f..337bda3221 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -8,7 +8,6 @@ using osu.Game.Beatmaps; 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; @@ -23,13 +22,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty public class OsuDifficultyCalculator : DifficultyCalculator { private 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. - private const double difficulty_multiplier = 0.0675; private const double star_rating_multiplier = 0.0265; public override int Version => 20250306; - private double mechanicalDifficultyRating; - public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { @@ -45,30 +41,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty return multiplier; } - /// - /// Calculates a visibility bonus that is applicable to Hidden and Traceable. - /// - public static double CalculateVisibilityBonus(Mod[] mods, double approachRate, double visibilityFactor = 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 AR5 - double readingBonus = 0.04 * (12.0 - Math.Max(approachRate, 5)); - - readingBonus *= visibilityFactor; - - // For AR up to 0 - reduce reward for very low ARs when object is visible - if (approachRate < 5) - readingBonus += (isAlwaysPartiallyVisible ? 0.03 : 0.04) * (5.0 - Math.Max(approachRate, 0)); - - // Starting from AR0 - cap values so they won't grow to infinity - if (approachRate < 0) - readingBonus += (isAlwaysPartiallyVisible ? 0.075 : 0.1) * (1 - Math.Pow(1.5, approachRate)); - - return readingBonus; - } - public static double CalculateRateAdjustedApproachRate(double approachRate, double clockRate) { double preempt = IBeatmapDifficultyInfo.DifficultyRange(approachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN) / clockRate; @@ -125,19 +97,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue(); double speedDifficultyValue = speed.DifficultyValue(); - mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue); + double mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue); - double aimRating = computeAimRating(aimDifficultyValue, mods, totalHits, approachRate, overallDifficulty); - double aimRatingNoSliders = computeAimRating(aimNoSlidersDifficultyValue, mods, totalHits, approachRate, overallDifficulty); - double speedRating = computeSpeedRating(speedDifficultyValue, mods, totalHits, approachRate, overallDifficulty); + var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating); + + double aimRating = osuRatingCalculator.ComputeAimRating(aimDifficultyValue); + double aimRatingNoSliders = osuRatingCalculator.ComputeAimRating(aimNoSlidersDifficultyValue); + double speedRating = osuRatingCalculator.ComputeSpeedRating(speedDifficultyValue); double flashlightRating = 0.0; if (flashlight is not null) - flashlightRating = computeFlashlightRating(flashlight.DifficultyValue(), mods, totalHits, overallDifficulty); + flashlightRating = osuRatingCalculator.ComputeFlashlightRating(flashlight.DifficultyValue()); double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1; + double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits); + double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap); + + var simulator = new OsuLegacyScoreSimulator(); + var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap); + double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); @@ -152,12 +132,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty double multiplier = CalculateDifficultyMultiplier(mods, totalHits, spinnerCount); double starRating = calculateStarRating(basePerformance, multiplier); - double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits); - double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap); - - var simulator = new OsuLegacyScoreSimulator(); - var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap); - OsuDifficultyAttributes attributes = new OsuDifficultyAttributes { StarRating = starRating, @@ -185,152 +159,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty return attributes; } - private double computeAimRating(double aimDifficultyValue, Mod[] mods, int totalHits, double approachRate, double overallDifficulty) - { - if (mods.Any(m => m is OsuModAutopilot)) - return 0; - - 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; - - if (mods.Any(m => m is OsuModMagnetised)) - { - float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; - aimRating *= 1.0 - magnetisedStrength; - } - - 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 *= 1.0 + approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. - - if (mods.Any(m => m is OsuModHidden)) - { - double visibilityFactor = calculateAimVisibilityFactor(approachRate); - ratingMultiplier *= 1.0 + CalculateVisibilityBonus(mods, approachRate, visibilityFactor); - } - - // 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); - } - - private double computeSpeedRating(double speedDifficultyValue, Mod[] mods, int totalHits, double approachRate, double overallDifficulty) - { - 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; - } - - 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); - - if (mods.Any(m => m is OsuModAutopilot)) - approachRateFactor = 0.0; - - ratingMultiplier *= 1.0 + approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. - - if (mods.Any(m => m is OsuModHidden)) - { - double visibilityFactor = calculateSpeedVisibilityFactor(approachRate); - ratingMultiplier *= 1.0 + CalculateVisibilityBonus(mods, approachRate, visibilityFactor); - } - - ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750; - - return speedRating * Math.Cbrt(ratingMultiplier); - } - - private double computeFlashlightRating(double flashlightDifficultyValue, Mod[] mods, int totalHits, double overallDifficulty) - { - 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; - } - - 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); - } - - 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); - } - private static double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue) { - double aimValue = OsuStrainSkill.DifficultyToPerformance(calculateDifficultyRating(aimDifficultyValue)); - double speedValue = OsuStrainSkill.DifficultyToPerformance(calculateDifficultyRating(speedDifficultyValue)); + 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); @@ -345,8 +177,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty return Math.Cbrt(multiplier) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4); } - private static double calculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * difficulty_multiplier; - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { List objects = new List(); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 49626eb7b6..11e9714ed8 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -209,7 +209,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate); else if (score.Mods.Any(m => m is OsuModTraceable)) { - aimValue *= 1.0 + OsuDifficultyCalculator.CalculateVisibilityBonus(score.Mods, approachRate); + aimValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate); } aimValue *= accuracy; @@ -245,7 +245,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty } else if (score.Mods.Any(m => m is OsuModTraceable)) { - speedValue *= 1.0 + OsuDifficultyCalculator.CalculateVisibilityBonus(score.Mods, approachRate); + speedValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate); } double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs new file mode 100644 index 0000000000..e505ed07e4 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -0,0 +1,199 @@ +// 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.Utils; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Difficulty +{ + public class OsuRatingCalculator + { + private const double difficulty_multiplier = 0.0675; + + private readonly Mod[] mods; + private readonly int totalHits; + private readonly double approachRate; + private readonly double overallDifficulty; + private readonly double mechanicalDifficultyRating; + + public OsuRatingCalculator(Mod[] mods, int totalHits, double approachRate, double overallDifficulty, double mechanicalDifficultyRating) + { + this.mods = mods; + this.totalHits = totalHits; + this.approachRate = approachRate; + this.overallDifficulty = overallDifficulty; + this.mechanicalDifficultyRating = mechanicalDifficultyRating; + } + + public double ComputeAimRating(double aimDifficultyValue) + { + if (mods.Any(m => m is OsuModAutopilot)) + return 0; + + 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; + + if (mods.Any(m => m is OsuModMagnetised)) + { + float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; + aimRating *= 1.0 - magnetisedStrength; + } + + 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 *= 1.0 + approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. + + if (mods.Any(m => m is OsuModHidden)) + { + double visibilityFactor = calculateAimVisibilityFactor(approachRate); + ratingMultiplier *= 1.0 + CalculateVisibilityBonus(mods, approachRate, visibilityFactor); + } + + // 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) + { + 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; + } + + 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); + + if (mods.Any(m => m is OsuModAutopilot)) + approachRateFactor = 0.0; + + ratingMultiplier *= 1.0 + approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. + + if (mods.Any(m => m is OsuModHidden)) + { + double visibilityFactor = calculateSpeedVisibilityFactor(approachRate); + ratingMultiplier *= 1.0 + CalculateVisibilityBonus(mods, approachRate, visibilityFactor); + } + + ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750; + + return speedRating * Math.Cbrt(ratingMultiplier); + } + + 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; + } + + 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); + } + + 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) + { + // 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 AR5 + double readingBonus = 0.04 * (12.0 - Math.Max(approachRate, 5)); + + readingBonus *= visibilityFactor; + + // For AR up to 0 - reduce reward for very low ARs when object is visible + if (approachRate < 5) + readingBonus += (isAlwaysPartiallyVisible ? 0.03 : 0.04) * (5.0 - Math.Max(approachRate, 0)); + + // Starting from AR0 - cap values so they won't grow to infinity + if (approachRate < 0) + readingBonus += (isAlwaysPartiallyVisible ? 0.075 : 0.1) * (1 - Math.Pow(1.5, approachRate)); + + return readingBonus; + } + + public static double CalculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * difficulty_multiplier; + } +} From 83765abe34da7f0e980dea3e3de6fceeeb88afe4 Mon Sep 17 00:00:00 2001 From: StanR Date: Sat, 26 Jul 2025 00:51:30 +0500 Subject: [PATCH 40/58] Make visibility-based bonuses be additive to `ratingMultiplier` instead of multiplicative (#34367) * Make visibility-based bonuses be additive to `ratingMultiplier` instead of multiplicative * Slightly buff low AR HD, slightly nerf low AR TC --- .../Difficulty/OsuRatingCalculator.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index e505ed07e4..5d51eee1ba 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -61,12 +61,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (mods.Any(h => h is OsuModRelax)) approachRateFactor = 0.0; - ratingMultiplier *= 1.0 + approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. + ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. if (mods.Any(m => m is OsuModHidden)) { double visibilityFactor = calculateAimVisibilityFactor(approachRate); - ratingMultiplier *= 1.0 + CalculateVisibilityBonus(mods, approachRate, visibilityFactor); + ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor); } // It is important to consider accuracy difficulty when scaling with accuracy. @@ -104,12 +104,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (mods.Any(m => m is OsuModAutopilot)) approachRateFactor = 0.0; - ratingMultiplier *= 1.0 + approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. + ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. if (mods.Any(m => m is OsuModHidden)) { double visibilityFactor = calculateSpeedVisibilityFactor(approachRate); - ratingMultiplier *= 1.0 + CalculateVisibilityBonus(mods, approachRate, visibilityFactor); + ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor); } ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750; @@ -178,14 +178,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty // 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 AR5 - double readingBonus = 0.04 * (12.0 - Math.Max(approachRate, 5)); + // Start from normal curve, rewarding lower AR up to AR7 + double readingBonus = 0.04 * (12.0 - Math.Max(approachRate, 7)); readingBonus *= visibilityFactor; // For AR up to 0 - reduce reward for very low ARs when object is visible - if (approachRate < 5) - readingBonus += (isAlwaysPartiallyVisible ? 0.03 : 0.04) * (5.0 - Math.Max(approachRate, 0)); + if (approachRate < 7) + readingBonus += (isAlwaysPartiallyVisible ? 0.03 : 0.045) * (7.0 - Math.Max(approachRate, 0)); // Starting from AR0 - cap values so they won't grow to infinity if (approachRate < 0) From e54779ceee3d4d1450ac90bc10c8c2b9e8389393 Mon Sep 17 00:00:00 2001 From: Jay Lawton Date: Mon, 28 Jul 2025 00:31:46 +1000 Subject: [PATCH 41/58] Fix colour penalties being bypassed via repeated ratio variance (#33641) * fix a lil bit of colour * review comments * fix empty initialiser --- .../Difficulty/Evaluators/ColourEvaluator.cs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs index b715dfc37a..d8d30e3fef 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs @@ -2,6 +2,8 @@ // 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.Taiko.Difficulty.Preprocessing; @@ -24,7 +26,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators int consistentRatioCount = 0; double totalRatioCount = 0.0; + List recentRatios = new List(); TaikoDifficultyHitObject current = hitObject; + var previousHitObject = (TaikoDifficultyHitObject)current.Previous(1); for (int i = 0; i < maxObjectsToCheck; i++) { @@ -32,11 +36,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators if (current.Index <= 1) break; - var previousHitObject = (TaikoDifficultyHitObject)current.Previous(1); - double currentRatio = current.RhythmData.Ratio; double previousRatio = previousHitObject.RhythmData.Ratio; + recentRatios.Add(currentRatio); + // A consistent interval is defined as the percentage difference between the two rhythmic ratios with the margin of error. if (Math.Abs(1 - currentRatio / previousRatio) <= threshold) { @@ -45,14 +49,21 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators break; } - // Move to the previous object current = previousHitObject; } // Ensure no division by zero - double ratioPenalty = 1 - totalRatioCount / (consistentRatioCount + 1) * 0.80; + if (consistentRatioCount > 0) + return 1 - totalRatioCount / (consistentRatioCount + 1) * 0.80; - return ratioPenalty; + if (recentRatios.Count <= 1) return 1.0; + + // 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); + + return consistentRatioPenalty; } /// From 803e30f50fd7ff37fb79ec27eb9a230e1936384a Mon Sep 17 00:00:00 2001 From: Eloise Date: Mon, 28 Jul 2025 15:58:54 +0200 Subject: [PATCH 42/58] osu!taiko consistency factor changes using object strains (#34327) * Calculate consistency factor from object strains * Use `totalDifficultHits` in performance calc --------- Co-authored-by: James Wilson --- .../Difficulty/TaikoDifficultyCalculator.cs | 90 +++++++++---------- .../Difficulty/TaikoPerformanceAttributes.cs | 3 - .../Difficulty/TaikoPerformanceCalculator.cs | 24 ++--- .../Rulesets/Difficulty/Skills/StrainSkill.cs | 2 + 4 files changed, 54 insertions(+), 65 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 9e265a3cc6..d2229e9786 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -31,6 +31,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty private double strainLengthBonus; private double patternMultiplier; + private bool isRelax; private bool isConvert; public override int Version => 20250306; @@ -46,6 +47,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); isConvert = beatmap.BeatmapInfo.Ruleset.OnlineID == 0; + isRelax = mods.Any(h => h is TaikoModRelax); return new Skill[] { @@ -100,8 +102,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (beatmap.HitObjects.Count == 0) return new TaikoDifficultyAttributes { Mods = mods }; - bool isRelax = mods.Any(h => h is TaikoModRelax); - var rhythm = skills.OfType().Single(); var reading = skills.OfType().Single(); var colour = skills.OfType().Single(); @@ -122,7 +122,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty strainLengthBonus = 1 + 0.15 * DifficultyCalculationUtils.ReverseLerp(staminaDifficultStrains, 1000, 1555); - double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, isRelax, isConvert, out double consistencyFactor); + double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, out double consistencyFactor); double starRating = rescale(combinedRating * 1.4); // Calculate proportional contribution of each skill to the combinedRating. @@ -159,14 +159,47 @@ namespace osu.Game.Rulesets.Taiko.Difficulty /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). /// - private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour colour, Stamina stamina, bool isRelax, bool isConvert, out double consistencyFactor) + private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour colour, Stamina stamina, out double consistencyFactor) { - List peaks = new List(); + List peaks = combinePeaks( + rhythm.GetCurrentStrainPeaks().ToList(), + reading.GetCurrentStrainPeaks().ToList(), + colour.GetCurrentStrainPeaks().ToList(), + stamina.GetCurrentStrainPeaks().ToList() + ); - var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList(); - var readingPeaks = reading.GetCurrentStrainPeaks().ToList(); - var colourPeaks = colour.GetCurrentStrainPeaks().ToList(); - var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList(); + double difficulty = 0; + double weight = 1; + + foreach (double strain in peaks.OrderDescending()) + { + difficulty += strain * weight; + weight *= 0.9; + } + + List hitObjectStrainPeaks = combinePeaks( + rhythm.GetObjectStrains().ToList(), + reading.GetObjectStrains().ToList(), + colour.GetObjectStrains().ToList(), + stamina.GetObjectStrains().ToList() + ); + + // The average of the top 5% of strain peaks from hit objects. + double topAverageHitObjectStrain = hitObjectStrainPeaks.OrderDescending().Take(1 + hitObjectStrainPeaks.Count / 20).Average(); + + // Calculates a consistency factor as the sum of difficulty from hit objects compared to if every object were as hard as the hardest. + // The top average strain is used instead of the very hardest to prevent exceptionally hard objects lowering the factor. + consistencyFactor = hitObjectStrainPeaks.Sum() / (topAverageHitObjectStrain * hitObjectStrainPeaks.Count); + + return difficulty; + } + + /// + /// 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) + { + var combinedPeaks = new List(); for (int i = 0; i < colourPeaks.Count; i++) { @@ -181,45 +214,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty // 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. if (peak > 0) - peaks.Add(peak); + combinedPeaks.Add(peak); } - double difficulty = 0; - double weight = 1; - - foreach (double strain in peaks.OrderDescending()) - { - difficulty += strain * weight; - weight *= 0.9; - } - - consistencyFactor = calculateConsistencyFactor(peaks); - - return difficulty; - } - - /// - /// Calculates a consistency factor based on how 'spiked' the strain peaks are. - /// Higher values indicate more consistent difficulty, lower values indicate diff-spike heavy maps. - /// - private double calculateConsistencyFactor(List peaks) - { - // If there are too few sections in a map, assume it is consistent. - if (peaks.Count < 3) - return 1.0; - - List sorted = peaks.OrderDescending().ToList(); - - double topPeak = sorted[0]; - double secondTopPeak = sorted.Count > 1 ? sorted[1] : topPeak; - - // Compute the average of the middle 50% of strain values. - double midAvg = sorted.Skip(sorted.Count / 4).Take(sorted.Count / 2).Average(); - - // A higher ratio means the top sections are much harder than the average, indicating inconsistency. - double spikeSeverity = (topPeak + secondTopPeak) / 2.0 / midAvg; - - return 1.0 / spikeSeverity; + return combinedPeaks; } /// diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs index 7c74e43db1..ef40c2e58b 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs @@ -15,9 +15,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty [JsonProperty("accuracy")] public double Accuracy { get; set; } - [JsonProperty("effective_miss_count")] - public double EffectiveMissCount { get; set; } - [JsonProperty("estimated_unstable_rate")] public double? EstimatedUnstableRate { get; set; } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 2633218f7d..b510c8a796 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty private double clockRate; private double greatHitWindow; - private double effectiveMissCount; + private double totalDifficultHits; public TaikoPerformanceCalculator() : base(new TaikoRuleset()) @@ -56,12 +56,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty estimatedUnstableRate = computeDeviationUpperBound() * 10; - // Effective miss count is calculated by raising the fraction of hits missed to a power based on the map's consistency factor. - // This is because in less consistently difficult maps, each miss removes more of the map's total difficulty. - effectiveMissCount = totalHits * Math.Pow( - (double)countMiss / totalHits, - Math.Pow(taikoAttributes.ConsistencyFactor, 0.2) - ); + // Total difficult hits measures the total difficulty of a map based on its consistency factor. + totalDifficultHits = totalHits * taikoAttributes.ConsistencyFactor; // Converts are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation. bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1; @@ -73,7 +69,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { Difficulty = difficultyValue, Accuracy = accuracyValue, - EffectiveMissCount = effectiveMissCount, EstimatedUnstableRate = estimatedUnstableRate, Total = difficultyValue + accuracyValue }; @@ -86,14 +81,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty difficultyValue *= 1 + 0.10 * Math.Max(0, attributes.StarRating - 10); - // Applies a bonus to maps with more total difficulty, calculating this with a map's total hits and consistency factor. - double totalDifficultHits = totalHits * Math.Pow(attributes.ConsistencyFactor, 0.5); + // Applies a bonus to maps with more total difficulty. double lengthBonus = 1 + 0.25 * totalDifficultHits / (totalDifficultHits + 4000); difficultyValue *= lengthBonus; - // Scales miss penalty by the total hits of a map, making misses more punishing on maps with fewer objects. - double missPenalty = Math.Pow(0.5, 30.0 / totalHits); - difficultyValue *= Math.Pow(missPenalty, effectiveMissCount); + // Scales miss penalty by the total difficult hits of a map, making misses more punishing on maps with less total difficulty. + double missPenalty = Math.Pow(0.5, 30.0 / totalDifficultHits); + difficultyValue *= Math.Pow(missPenalty, countMiss); if (score.Mods.Any(m => m is ModHidden)) difficultyValue *= (isConvert) ? 1.025 : 1.1; @@ -122,9 +116,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty accuracyValue *= 1.075; // Applies a bonus to maps with more total difficulty, calculating this with a map's total hits and consistency factor. - double totalDifficultHits = totalHits * Math.Pow(attributes.ConsistencyFactor, 0.5); - double lengthBonus = 1 + 0.4 * totalDifficultHits / (totalDifficultHits + 4000); - accuracyValue *= lengthBonus; + accuracyValue *= 1 + 0.4 * 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)); diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index 3ba67793dc..b6272bf56b 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -116,6 +116,8 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// 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 eaaca60b1dbb95b7029f699e761c1fc34e69c649 Mon Sep 17 00:00:00 2001 From: Eloise Date: Tue, 29 Jul 2025 20:03:13 +0200 Subject: [PATCH 43/58] osu!taiko new acc pp formula + rhythm difficulty penalty (#34188) * New acc curve * Penalise rhythm difficulty based on unstable rate * Rename mono acc stuff for more clarity * Fix nullable * Rename stuff * Get actual estimation for SS unstable rate * Double space my bad --------- Co-authored-by: James Wilson --- .../Difficulty/TaikoPerformanceCalculator.cs | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index b510c8a796..fb106caf39 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -54,7 +54,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty greatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate; - estimatedUnstableRate = computeDeviationUpperBound() * 10; + estimatedUnstableRate = (countGreat == 0 || greatHitWindow <= 0) + ? null + : computeDeviationUpperBound(countGreat / (double)totalHits) * 10; // Total difficult hits measures the total difficulty of a map based on its consistency factor. totalDifficultHits = totalHits * taikoAttributes.ConsistencyFactor; @@ -76,7 +78,27 @@ namespace osu.Game.Rulesets.Taiko.Difficulty private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) { - double baseDifficulty = 5 * Math.Max(1.0, attributes.StarRating / 0.110) - 4.0; + if (estimatedUnstableRate == null) + return 0; + + // The estimated unstable rate for 100% accuracy, at which all rhythm difficulty has been played successfully. + double rhythmExpectedUnstableRate = computeDeviationUpperBound(1.0) * 10; + + // The unstable rate at which it can be assumed all rhythm difficulty has been ignored. + double rhythmMaximumUnstableRate = 2 * rhythmExpectedUnstableRate; + + // 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.35); + + // A penalty removing improperly played rhythm difficulty from star rating based on estimated unstable rate. + double rhythmPenalty = 1 - DifficultyCalculationUtils.Logistic( + estimatedUnstableRate.Value, + midpointOffset: (rhythmExpectedUnstableRate + rhythmMaximumUnstableRate) / 2, + multiplier: 10 / (rhythmMaximumUnstableRate - rhythmExpectedUnstableRate), + maxValue: 0.2 * Math.Pow(rhythmFactor, 2) + ); + + 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); difficultyValue *= 1 + 0.10 * Math.Max(0, attributes.StarRating - 10); @@ -95,14 +117,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (score.Mods.Any(m => m is ModFlashlight)) difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus); - if (estimatedUnstableRate == null) - return 0; - // Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps. - double accScalingExponent = 2 + attributes.MonoStaminaFactor; - double accScalingShift = 500 - 100 * (attributes.MonoStaminaFactor * 3); + double monoAccScalingExponent = 2 + attributes.MonoStaminaFactor; + double monoAccScalingShift = 500 - 100 * (attributes.MonoStaminaFactor * 3); - return difficultyValue * Math.Pow(DifficultyCalculationUtils.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent); + return difficultyValue * Math.Pow(DifficultyCalculationUtils.Erf(monoAccScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), monoAccScalingExponent); } private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) @@ -110,7 +129,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (greatHitWindow <= 0 || estimatedUnstableRate == null) return 0; - double accuracyValue = Math.Pow(70 / estimatedUnstableRate.Value, 1.1) * Math.Pow(attributes.StarRating, 0.4) * 100.0; + double accuracyValue = 470 * Math.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) / 125; if (score.Mods.Any(m => m is ModHidden) && !isConvert) accuracyValue *= 1.075; @@ -132,17 +154,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty /// and the hit judgements, assuming the player's mean hit error is 0. The estimation is consistent in that /// two SS scores on the same map with the same settings will always return the same deviation. /// - private double? computeDeviationUpperBound() + private double computeDeviationUpperBound(double accuracy) { - if (countGreat == 0 || greatHitWindow <= 0) - return null; - const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed). double n = totalHits; // Proportion of greats hit. - double p = countGreat / n; + double p = accuracy; // We can be 99% confident that p is at least this value. double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); From dbb16fc83487a01a3f8c31852977e2025744fc2d Mon Sep 17 00:00:00 2001 From: Eloise Date: Wed, 30 Jul 2025 21:48:45 +0200 Subject: [PATCH 44/58] osu!taiko reduce multiplier for hidden on lazer (#34089) * Reduce multiplier for hidden on lazer * Refactor * Quality * The space --- .../Difficulty/TaikoPerformanceCalculator.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index fb106caf39..27ba31d918 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -61,10 +61,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty // Total difficult hits measures the total difficulty of a map based on its consistency factor. totalDifficultHits = totalHits * taikoAttributes.ConsistencyFactor; - // Converts are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation. + // Converts and the classic mod are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation. bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1; + bool isClassic = score.Mods.Any(m => m is ModClassic); - double difficultyValue = computeDifficultyValue(score, taikoAttributes, isConvert) * 1.08; + double difficultyValue = computeDifficultyValue(score, taikoAttributes, isConvert, isClassic) * 1.08; double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert) * 1.1; return new TaikoPerformanceAttributes @@ -76,7 +77,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty }; } - private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) + private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert, bool isClassic) { if (estimatedUnstableRate == null) return 0; @@ -112,7 +113,15 @@ namespace osu.Game.Rulesets.Taiko.Difficulty difficultyValue *= Math.Pow(missPenalty, countMiss); if (score.Mods.Any(m => m is ModHidden)) - difficultyValue *= (isConvert) ? 1.025 : 1.1; + { + double hiddenBonus = isConvert ? 0.025 : 0.1; + + // A penalty is applied to the bonus for hidden on non-classic scores, as the playfield can be made wider to make fast reading easier. + if (!isClassic) + hiddenBonus *= 0.2; + + difficultyValue *= 1 + hiddenBonus; + } if (score.Mods.Any(m => m is ModFlashlight)) difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus); From 802e5594724c1e7bea0ddeb474210b23b515b854 Mon Sep 17 00:00:00 2001 From: StanR Date: Wed, 6 Aug 2025 21:10:00 +0500 Subject: [PATCH 45/58] Add DF flashlight rating reduction (#34081) * Add DF flashlight rating reduction * Use reverse lerp --- osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index 5d51eee1ba..8793582847 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -138,6 +138,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty 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. From fa1fea02dcce596043b704e048c0ab575bc16873 Mon Sep 17 00:00:00 2001 From: James Wilson Date: Thu, 7 Aug 2025 19:13:00 +0100 Subject: [PATCH 46/58] Fix edge case that estimates sliderbreaks in impossible scenarios (#34544) * Test theory crafting * Place in more appropriate place * fix a bit better * Move things around * Reduce diff --------- Co-authored-by: StanR --- .../Difficulty/OsuLegacyScoreMissCalculator.cs | 13 +++++++++++++ .../Difficulty/OsuPerformanceCalculator.cs | 11 +++++++++++ 2 files changed, 24 insertions(+) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs index 207ecde81a..0d406ea72a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs @@ -125,6 +125,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty // In classic scores there can't be more misses than a sum of all non-perfect judgements missCount = Math.Min(missCount, totalImperfectHits); + // Every slider has *at least* 2 combo attributed in classic mechanics. + // If they broke on a slider with a tick, then this still works since they would have lost at least 2 combo (the tick and the end) + // Using this as a max means a score that loses 1 combo on a map can't possibly have been a slider break. + // It must have been a slider end. + int maxPossibleSliderBreaks = Math.Min(attributes.SliderCount, (attributes.MaxCombo - score.MaxCombo) / 2); + + int scoreMissCount = score.Statistics.GetValueOrDefault(HitResult.Miss); + + double sliderBreaks = missCount - scoreMissCount; + + if (sliderBreaks > maxPossibleSliderBreaks) + missCount = scoreMissCount + maxPossibleSliderBreaks; + return missCount; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 11e9714ed8..7230c52f9c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -343,6 +343,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty // In classic scores there can't be more misses than a sum of all non-perfect judgements missCount = Math.Min(missCount, totalImperfectHits); + + // Every slider has *at least* 2 combo attributed in classic mechanics. + // If they broke on a slider with a tick, then this still works since they would have lost at least 2 combo (the tick and the end) + // Using this as a max means a score that loses 1 combo on a map can't possibly have been a slider break. + // It must have been a slider end. + int maxPossibleSliderBreaks = Math.Min(attributes.SliderCount, (attributes.MaxCombo - scoreMaxCombo) / 2); + + double sliderBreaks = missCount - countMiss; + + if (sliderBreaks > maxPossibleSliderBreaks) + missCount = countMiss + maxPossibleSliderBreaks; } else { From dce4132209eb28f012a6ed7255f811612fc0045b Mon Sep 17 00:00:00 2001 From: Givikap120 <89256026+Givikap120@users.noreply.github.com> Date: Sat, 9 Aug 2025 22:40:37 +0300 Subject: [PATCH 47/58] Nerf Low AR HD bonus for slideraim (#34215) * Refactor slider factor calculation * Nerf low AR HD bonus for slideraim * finish merge * Fixes * Fix comment --------- Co-authored-by: James Wilson --- .../Difficulty/OsuDifficultyCalculator.cs | 6 ++---- .../Difficulty/OsuPerformanceCalculator.cs | 2 +- .../Difficulty/OsuRatingCalculator.cs | 15 ++++++++++----- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 337bda3221..8e87610dfb 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -98,11 +98,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty double speedDifficultyValue = speed.DifficultyValue(); 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); + var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating, sliderFactor); double aimRating = osuRatingCalculator.ComputeAimRating(aimDifficultyValue); - double aimRatingNoSliders = osuRatingCalculator.ComputeAimRating(aimNoSlidersDifficultyValue); double speedRating = osuRatingCalculator.ComputeSpeedRating(speedDifficultyValue); double flashlightRating = 0.0; @@ -110,8 +110,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (flashlight is not null) flashlightRating = osuRatingCalculator.ComputeFlashlightRating(flashlight.DifficultyValue()); - double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1; - double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits); double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 7230c52f9c..c076b6cfe6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -209,7 +209,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate); else if (score.Mods.Any(m => m is OsuModTraceable)) { - aimValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate); + aimValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate, attributes.SliderFactor); } aimValue *= accuracy; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index 8793582847..4d78db4788 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -18,14 +18,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty 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) + public OsuRatingCalculator(Mod[] mods, int totalHits, double approachRate, double overallDifficulty, double mechanicalDifficultyRating, double sliderFactor) { this.mods = mods; this.totalHits = totalHits; this.approachRate = approachRate; this.overallDifficulty = overallDifficulty; this.mechanicalDifficultyRating = mechanicalDifficultyRating; + this.sliderFactor = sliderFactor; } public double ComputeAimRating(double aimDifficultyValue) @@ -66,7 +68,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (mods.Any(m => m is OsuModHidden)) { double visibilityFactor = calculateAimVisibilityFactor(approachRate); - ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor); + ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor, sliderFactor); } // It is important to consider accuracy difficulty when scaling with accuracy. @@ -179,7 +181,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty /// /// Calculates a visibility bonus that is applicable to Hidden and Traceable. /// - public static double CalculateVisibilityBonus(Mod[] mods, double approachRate, double visibilityFactor = 1) + 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(); @@ -189,13 +191,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty 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.03 : 0.045) * (7.0 - Math.Max(approachRate, 0)); + readingBonus += (isAlwaysPartiallyVisible ? 0.03 : 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.075 : 0.1) * (1 - Math.Pow(1.5, approachRate)); + readingBonus += (isAlwaysPartiallyVisible ? 0.075 : 0.1) * (1 - Math.Pow(1.5, approachRate)) * sliderVisibilityFactor; return readingBonus; } From 087f0565e6cfbb9197ae3d5167d64f2455090800 Mon Sep 17 00:00:00 2001 From: Jay Lawton Date: Thu, 28 Aug 2025 23:19:13 +1000 Subject: [PATCH 48/58] Implement `deltatimenormaliser` into rhythm grouping logic (#33403) * additions * review fixes * Formatting * comments + review * fix * fix renaming and namespace * balancing + round --------- Co-authored-by: tsunyoku Co-authored-by: StanR --- .../Data/SameRhythmHitObjectGrouping.cs | 41 +++++++++--- .../Difficulty/TaikoDifficultyCalculator.cs | 2 +- .../Difficulty/Utils/DeltaTimeNormaliser.cs | 66 +++++++++++++++++++ .../Difficulty/Utils/IntervalGroupingUtils.cs | 11 ++-- 4 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 osu.Game.Rulesets.Taiko/Difficulty/Utils/DeltaTimeNormaliser.cs diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs index 9caa9b9958..256de13785 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs @@ -1,7 +1,9 @@ // 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; using osu.Game.Rulesets.Taiko.Difficulty.Utils; @@ -18,6 +20,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data public readonly SameRhythmHitObjectGrouping? Previous; + private static readonly double snap_tolerance = IntervalGroupingUtils.MarginOfError; + /// /// of the first hit object. /// @@ -29,13 +33,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data public double Duration => HitObjects[^1].StartTime - HitObjects[0].StartTime; /// - /// The interval in ms of each hit object in this . This is only defined if there is + /// The normalised interval in ms of each hit object in this . This is only defined if there is /// more than two hit objects in this . /// public readonly double? HitObjectInterval; /// - /// The ratio of between this and the previous . In the + /// The normalised ratio of between this and the previous . In the /// case where one or both of the is undefined, this will have a value of 1. /// public readonly double HitObjectIntervalRatio; @@ -48,16 +52,37 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data Previous = previous; HitObjects = hitObjects; - // Calculate the average interval between hitobjects, or null if there are fewer than two - HitObjectInterval = HitObjects.Count < 2 ? null : Duration / (HitObjects.Count - 1); + // Cluster and normalise each hitobjects delta-time. + var normaliseHitObjects = DeltaTimeNormaliser.Normalise(hitObjects, snap_tolerance); + + var normalisedHitObjectDeltaTime = hitObjects + .Skip(1) + .Select(hitObject => normaliseHitObjects[hitObject]) + .ToList(); + + // Secondary check to ensure there isn't any 'noise' or outliers by taking the modal delta time. + double modalDelta = normalisedHitObjectDeltaTime.Count > 0 + ? Math.Round(normalisedHitObjectDeltaTime[0]) + : 0; + + // Calculate the average interval between hitobjects. + HitObjectInterval = normalisedHitObjectDeltaTime.Count > 0 + ? previous?.HitObjectInterval is double previousDelta && Math.Abs(modalDelta - previousDelta) <= snap_tolerance + ? previousDelta + : modalDelta + : null; // Calculate the ratio between this group's interval and the previous group's interval - HitObjectIntervalRatio = Previous?.HitObjectInterval != null && HitObjectInterval != null - ? HitObjectInterval.Value / Previous.HitObjectInterval.Value - : 1; + HitObjectIntervalRatio = previous?.HitObjectInterval is double previousInterval && HitObjectInterval is double currentInterval + ? currentInterval / previousInterval + : 1.0; // Calculate the interval from the previous group's start time - Interval = Previous != null ? StartTime - Previous.StartTime : double.PositiveInfinity; + Interval = previous == null + ? double.PositiveInfinity + : Math.Abs(StartTime - previous.StartTime) <= snap_tolerance + ? 0 + : StartTime - previous.StartTime; } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index d2229e9786..92c6dac3a1 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -23,7 +23,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.65 * difficulty_multiplier; + private const double rhythm_skill_multiplier = 0.620 * 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; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Utils/DeltaTimeNormaliser.cs b/osu.Game.Rulesets.Taiko/Difficulty/Utils/DeltaTimeNormaliser.cs new file mode 100644 index 0000000000..5e959f3f25 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Utils/DeltaTimeNormaliser.cs @@ -0,0 +1,66 @@ +// 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.Taiko.Difficulty.Preprocessing; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Utils +{ + /// + /// Normalises deltaTime values for TaikoDifficultyHitObjects. + /// + public static class DeltaTimeNormaliser + { + /// + /// Combines deltaTime values that differ by at most + /// and replaces each value with the median of its range. This is used to reduce timing noise + /// and improve rhythm grouping consistency, especially for maps with inconsistent or 'off-snapped' timing. + /// + public static Dictionary Normalise( + IReadOnlyList hitObjects, + double marginOfError) + { + var deltaTimes = hitObjects.Select(h => h.DeltaTime).Distinct().OrderBy(d => d).ToList(); + + var sets = new List>(); + List? current = null; + + foreach (double value in deltaTimes) + { + // Add to the current group if within margin of error + if (current != null && Math.Abs(value - current[0]) <= marginOfError) + { + current.Add(value); + continue; + } + + // Otherwise begin a new group + current = new List { value }; + sets.Add(current); + } + + // Compute median for each group + var medianLookup = new Dictionary(); + + foreach (var set in sets) + { + set.Sort(); + int mid = set.Count / 2; + double median = set.Count % 2 == 1 + ? set[mid] + : (set[mid - 1] + set[mid]) / 2; + + foreach (double v in set) + medianLookup[v] = median; + } + + // Assign each hitobjects deltaTime the corresponding median value + return hitObjects.ToDictionary( + h => h, + h => medianLookup.TryGetValue(h.DeltaTime, out double median) ? median : h.DeltaTime + ); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Utils/IntervalGroupingUtils.cs b/osu.Game.Rulesets.Taiko/Difficulty/Utils/IntervalGroupingUtils.cs index 5ab58ad4f3..fa39e8af50 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Utils/IntervalGroupingUtils.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Utils/IntervalGroupingUtils.cs @@ -8,6 +8,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils { public static class IntervalGroupingUtils { + // The margin of error when comparing intervals for grouping, or snapping intervals to a common value. + public static double MarginOfError = 5.0; + public static List> GroupByInterval(IReadOnlyList objects) where T : IHasInterval { var groups = new List>(); @@ -21,8 +24,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils private static List createNextGroup(IReadOnlyList objects, ref int i) where T : IHasInterval { - const double margin_of_error = 5; - // This never compares the first two elements in the group. // This sounds wrong but is apparently "as intended" (https://github.com/ppy/osu/pull/31636#discussion_r1942673329) var groupedObjects = new List { objects[i] }; @@ -30,11 +31,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils for (; i < objects.Count - 1; i++) { - if (!Precision.AlmostEquals(objects[i].Interval, objects[i + 1].Interval, margin_of_error)) + if (!Precision.AlmostEquals(objects[i].Interval, objects[i + 1].Interval, MarginOfError)) { // When an interval change occurs, include the object with the differing interval in the case it increased // See https://github.com/ppy/osu/pull/31636#discussion_r1942368372 for rationale. - if (objects[i + 1].Interval > objects[i].Interval + margin_of_error) + if (objects[i + 1].Interval > objects[i].Interval + MarginOfError) { groupedObjects.Add(objects[i]); i++; @@ -49,7 +50,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils // Check if the last two objects in the object form a "flat" rhythm pattern within the specified margin of error. // If true, add the current object to the group and increment the index to process the next object. - if (objects.Count > 2 && i < objects.Count && Precision.AlmostEquals(objects[^1].Interval, objects[^2].Interval, margin_of_error)) + if (objects.Count > 2 && i < objects.Count && Precision.AlmostEquals(objects[^1].Interval, objects[^2].Interval, MarginOfError)) { groupedObjects.Add(objects[i]); i++; From 90ac249f5eab5344ab52e0c1d397ce000c2788fc Mon Sep 17 00:00:00 2001 From: James Wilson Date: Sun, 31 Aug 2025 08:32:28 +0100 Subject: [PATCH 49/58] Move SpunOut penalty back to PP (#34838) This isn't a super common mod compared to every other one on the list, it's probably not worth the storage (and memory in case of stable) implications. We can look at revisiting this once we have actual spinner difficulty considerations --- .../Difficulty/OsuDifficultyCalculator.cs | 23 ++++--------------- .../Difficulty/OsuPerformanceCalculator.cs | 7 +++++- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 8e87610dfb..d7fa159d10 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -21,7 +21,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuDifficultyCalculator : DifficultyCalculator { - private 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. private const double star_rating_multiplier = 0.0265; public override int Version => 20250306; @@ -31,16 +30,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty { } - public static double CalculateDifficultyMultiplier(Mod[] mods, int totalHits, int spinnerCount) - { - double multiplier = performance_base_multiplier; - - if (mods.Any(m => m is OsuModSpunOut) && totalHits > 0) - multiplier *= 1.0 - Math.Pow((double)spinnerCount / totalHits, 0.85); - - return multiplier; - } - public static double CalculateRateAdjustedApproachRate(double approachRate, double clockRate) { double preempt = IBeatmapDifficultyInfo.DifficultyRange(approachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN) / clockRate; @@ -127,8 +116,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 ); - double multiplier = CalculateDifficultyMultiplier(mods, totalHits, spinnerCount); - double starRating = calculateStarRating(basePerformance, multiplier); + double starRating = calculateStarRating(basePerformance); OsuDifficultyAttributes attributes = new OsuDifficultyAttributes { @@ -157,22 +145,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty return attributes; } - private static double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue) + private double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue) { 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); - return calculateStarRating(totalValue, performance_base_multiplier); + return calculateStarRating(totalValue); } - private static double calculateStarRating(double basePerformance, double multiplier) + private double calculateStarRating(double basePerformance) { if (basePerformance <= 0.00001) return 0; - return Math.Cbrt(multiplier) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4); + return Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4); } protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) @@ -213,7 +201,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty new OsuModHardRock(), new OsuModFlashlight(), new OsuModHidden(), - new OsuModSpunOut(), }; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index c076b6cfe6..777495570d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -19,6 +19,8 @@ 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. + private bool usingClassicSliderAccuracy; private bool usingScoreV2; @@ -113,11 +115,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty effectiveMissCount = Math.Max(countMiss, effectiveMissCount); effectiveMissCount = Math.Min(totalHits, effectiveMissCount); - double multiplier = OsuDifficultyCalculator.CalculateDifficultyMultiplier(score.Mods, totalHits, osuAttributes.SpinnerCount); + double multiplier = PERFORMANCE_BASE_MULTIPLIER; if (score.Mods.Any(m => m is OsuModNoFail)) 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); + if (score.Mods.Any(h => h is OsuModRelax)) { // https://www.desmos.com/calculator/vspzsop6td From 6a35b7237b31b18678de217742708f604100df7c Mon Sep 17 00:00:00 2001 From: James Wilson Date: Sun, 31 Aug 2025 13:04:56 +0100 Subject: [PATCH 50/58] Prevent Taiko difficulty crash if a map only contains 0-strains (#34829) * Prevent Taiko difficulty crash if a map only contains 0-strains * Add second check for safety This is accessing a different array of strains. I'd rather be safe than sorry. * Add guard in PP too * Make `MarginOfError` a const --- .../Rhythm/Data/SameRhythmHitObjectGrouping.cs | 2 +- .../Difficulty/TaikoDifficultyCalculator.cs | 12 ++++++++++++ .../Difficulty/TaikoPerformanceCalculator.cs | 2 +- .../Difficulty/Utils/IntervalGroupingUtils.cs | 8 ++++---- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs index 256de13785..89c150eb5f 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data public readonly SameRhythmHitObjectGrouping? Previous; - private static readonly double snap_tolerance = IntervalGroupingUtils.MarginOfError; + private const double snap_tolerance = IntervalGroupingUtils.MARGIN_OF_ERROR; /// /// of the first hit object. diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 92c6dac3a1..88791dd531 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -168,6 +168,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty stamina.GetCurrentStrainPeaks().ToList() ); + if (peaks.Count == 0) + { + consistencyFactor = 0; + return 0; + } + double difficulty = 0; double weight = 1; @@ -184,6 +190,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty stamina.GetObjectStrains().ToList() ); + if (hitObjectStrainPeaks.Count == 0) + { + consistencyFactor = 0; + return 0; + } + // The average of the top 5% of strain peaks from hit objects. double topAverageHitObjectStrain = hitObjectStrainPeaks.OrderDescending().Take(1 + hitObjectStrainPeaks.Count / 20).Average(); diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 27ba31d918..22e390cd03 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -79,7 +79,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert, bool isClassic) { - if (estimatedUnstableRate == null) + if (estimatedUnstableRate == null || totalDifficultHits == 0) return 0; // The estimated unstable rate for 100% accuracy, at which all rhythm difficulty has been played successfully. diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Utils/IntervalGroupingUtils.cs b/osu.Game.Rulesets.Taiko/Difficulty/Utils/IntervalGroupingUtils.cs index fa39e8af50..38129b24e6 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Utils/IntervalGroupingUtils.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Utils/IntervalGroupingUtils.cs @@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils public static class IntervalGroupingUtils { // The margin of error when comparing intervals for grouping, or snapping intervals to a common value. - public static double MarginOfError = 5.0; + public const double MARGIN_OF_ERROR = 5.0; public static List> GroupByInterval(IReadOnlyList objects) where T : IHasInterval { @@ -31,11 +31,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils for (; i < objects.Count - 1; i++) { - if (!Precision.AlmostEquals(objects[i].Interval, objects[i + 1].Interval, MarginOfError)) + if (!Precision.AlmostEquals(objects[i].Interval, objects[i + 1].Interval, MARGIN_OF_ERROR)) { // When an interval change occurs, include the object with the differing interval in the case it increased // See https://github.com/ppy/osu/pull/31636#discussion_r1942368372 for rationale. - if (objects[i + 1].Interval > objects[i].Interval + MarginOfError) + if (objects[i + 1].Interval > objects[i].Interval + MARGIN_OF_ERROR) { groupedObjects.Add(objects[i]); i++; @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils // Check if the last two objects in the object form a "flat" rhythm pattern within the specified margin of error. // If true, add the current object to the group and increment the index to process the next object. - if (objects.Count > 2 && i < objects.Count && Precision.AlmostEquals(objects[^1].Interval, objects[^2].Interval, MarginOfError)) + if (objects.Count > 2 && i < objects.Count && Precision.AlmostEquals(objects[^1].Interval, objects[^2].Interval, MARGIN_OF_ERROR)) { groupedObjects.Add(objects[i]); i++; From 84309f57c5fd3f14422c150a44bcecafcb0dd894 Mon Sep 17 00:00:00 2001 From: StanR Date: Tue, 2 Sep 2025 14:22:12 +0500 Subject: [PATCH 51/58] Reduce rhythm difficulty if current object is doubletappable (#34877) * Reduce rhythm difficulty if current object is doubletappable * Buff rhythm multiplier --- .../Difficulty/Evaluators/RhythmEvaluator.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index c00fa4c23e..9e6bae6c01 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators 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 = 12.0; + private const double rhythm_ratio_multiplier = 15.0; /// /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . @@ -26,6 +26,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators if (current.BaseObject is Spinner) return 0; + var currentOsuObject = (OsuDifficultyHitObject)current; + double rhythmComplexitySum = 0; double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; @@ -173,7 +175,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators prevObj = currObj; } - return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) + 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; } private class Island : IEquatable From a78c78ecdd8d8bee926afa155f2da5f9e46c885d Mon Sep 17 00:00:00 2001 From: James Wilson Date: Tue, 2 Sep 2025 11:19:34 +0100 Subject: [PATCH 52/58] Update difficulty calculation tests for osu ruleset (#34828) --- .../OsuDifficultyCalculatorTest.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index 75e6dc6f09..e7a6d8ecff 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests"; - [TestCase(6.7331304290522747d, 239, "diffcalc-test")] - [TestCase(1.4595591215544095d, 54, "zero-length-sliders")] - [TestCase(0.4339253366122357d, 4, "very-fast-slider")] - [TestCase(0.14143808967817237d, 2, "nan-slider")] + [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")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(9.6779397290273756d, 239, "diffcalc-test")] - [TestCase(1.7680515258663754d, 54, "zero-length-sliders")] - [TestCase(0.56174427678665129d, 4, "very-fast-slider")] + [TestCase(9.6491691624112761d, 239, "diffcalc-test")] + [TestCase(1.756936832498702d, 54, "zero-length-sliders")] + [TestCase(0.57771197086735004d, 4, "very-fast-slider")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); - [TestCase(6.7331304290522747d, 239, "diffcalc-test")] - [TestCase(1.4595591215544095d, 54, "zero-length-sliders")] - [TestCase(0.4339253366122357d, 4, "very-fast-slider")] + [TestCase(6.6232533278125061d, 239, "diffcalc-test")] + [TestCase(1.5045783545699611d, 54, "zero-length-sliders")] + [TestCase(0.43333836671191595d, 4, "very-fast-slider")] public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); From c7f50f35b7e6160f434cb8f5498c150aeaadd712 Mon Sep 17 00:00:00 2001 From: Eloise Date: Mon, 15 Sep 2025 09:43:26 +0100 Subject: [PATCH 53/58] osu!taiko final balancing before deploy (#34962) * Change maximum UR estimation + buff rhythm * Penalty for classic ezhd * Buff mono bonus to counterbalance logic fix * New miss penalty + slightly nerf length bonus * Adjust rhythm values * Adjust penalty and buff high SR acc * Exclude HDFL from hidden reading penalties * Make comment a lil nicer --------- Co-authored-by: James Wilson --- .../Difficulty/Skills/Stamina.cs | 2 +- .../Difficulty/TaikoDifficultyCalculator.cs | 2 +- .../Difficulty/TaikoPerformanceCalculator.cs | 27 ++++++++++++------- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs index 7c0c76d3ba..5e18163fe0 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs @@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills var currentObject = current as TaikoDifficultyHitObject; int index = currentObject?.ColourData.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0; - double monoLengthBonus = isConvert ? 1.0 : 1.0 + 0.3 * DifficultyCalculationUtils.ReverseLerp(index, 5, 20); + double monoLengthBonus = isConvert ? 1.0 : 1.0 + 0.5 * DifficultyCalculationUtils.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) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 88791dd531..cdb5a36f65 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -23,7 +23,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.620 * difficulty_multiplier; + private const double rhythm_skill_multiplier = 0.750 * 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; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 22e390cd03..df9da49c4b 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -86,17 +86,18 @@ namespace osu.Game.Rulesets.Taiko.Difficulty double rhythmExpectedUnstableRate = computeDeviationUpperBound(1.0) * 10; // The unstable rate at which it can be assumed all rhythm difficulty has been ignored. - double rhythmMaximumUnstableRate = 2 * rhythmExpectedUnstableRate; + // 0.8 represents 80% of total hits being greats, or 90% accuracy in-game + 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.35); + double rhythmFactor = DifficultyCalculationUtils.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( estimatedUnstableRate.Value, midpointOffset: (rhythmExpectedUnstableRate + rhythmMaximumUnstableRate) / 2, multiplier: 10 / (rhythmMaximumUnstableRate - rhythmExpectedUnstableRate), - maxValue: 0.2 * Math.Pow(rhythmFactor, 2) + maxValue: 0.25 * Math.Pow(rhythmFactor, 3) ); double baseDifficulty = 5 * Math.Max(1.0, attributes.StarRating * rhythmPenalty / 0.110) - 4.0; @@ -109,16 +110,24 @@ namespace osu.Game.Rulesets.Taiko.Difficulty difficultyValue *= lengthBonus; // Scales miss penalty by the total difficult hits of a map, making misses more punishing on maps with less total difficulty. - double missPenalty = Math.Pow(0.5, 30.0 / totalDifficultHits); + double missPenalty = 0.97 + 0.03 * totalDifficultHits / (totalDifficultHits + 1500); difficultyValue *= Math.Pow(missPenalty, countMiss); if (score.Mods.Any(m => m is ModHidden)) { double hiddenBonus = isConvert ? 0.025 : 0.1; - // A penalty is applied to the bonus for hidden on non-classic scores, as the playfield can be made wider to make fast reading easier. - if (!isClassic) - hiddenBonus *= 0.2; + // Hidden+flashlight plays are excluded from reading-based penalties to hidden. + if (!score.Mods.Any(m => m is ModFlashlight)) + { + // A penalty is applied to the bonus for hidden on non-classic scores, as the playfield can be made wider to make fast reading easier. + if (!isClassic) + hiddenBonus *= 0.2; + + // A penalty is applied to classic easy+hidden scores, as notes disappear later making fast reading easier. + if (score.Mods.Any(m => m is ModEasy) && isClassic) + hiddenBonus *= 0.5; + } difficultyValue *= 1 + hiddenBonus; } @@ -141,13 +150,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty double accuracyValue = 470 * Math.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) / 125; + accuracyValue *= 1 + Math.Pow(50 / estimatedUnstableRate.Value, 2) * Math.Pow(attributes.StarRating, 2.8) / 600; if (score.Mods.Any(m => m is ModHidden) && !isConvert) accuracyValue *= 1.075; // Applies a bonus to maps with more total difficulty, calculating this with a map's total hits and consistency factor. - accuracyValue *= 1 + 0.4 * totalDifficultHits / (totalDifficultHits + 4000); + 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)); From 0a3844d3ef26a2eb2f1e7d74491bc7bae9a8cf3a Mon Sep 17 00:00:00 2001 From: James Wilson Date: Mon, 15 Sep 2025 10:46:27 +0100 Subject: [PATCH 54/58] Update tests (#35026) --- .../TaikoDifficultyCalculatorTest.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs index 76b86eb4d6..a4b33b7c15 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs @@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Taiko.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko"; - [TestCase(3.305554470092722d, 200, "diffcalc-test")] - [TestCase(3.305554470092722d, 200, "diffcalc-test-strong")] + [TestCase(3.3190848563395079d, 200, "diffcalc-test")] + [TestCase(3.3190848563395079d, 200, "diffcalc-test-strong")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(4.4472572672057815d, 200, "diffcalc-test")] - [TestCase(4.4472572672057815d, 200, "diffcalc-test-strong")] + [TestCase(4.4551414906554987d, 200, "diffcalc-test")] + [TestCase(4.4551414906554987d, 200, "diffcalc-test-strong")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime()); From 2749184c38eb1083057a9738af769fe82e7b41d0 Mon Sep 17 00:00:00 2001 From: James Wilson Date: Mon, 15 Sep 2025 11:46:00 +0100 Subject: [PATCH 55/58] Remove databasing of `MechanicalDifficulty` and `ReadingDifficulty` attributes (#35028) * Remove databasing of `MechanicalDifficulty` and `ReadingDifficulty` attributes * Update attribute IDs --- .../Difficulty/TaikoDifficultyAttributes.cs | 6 ------ osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs | 6 ++---- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index eacf843487..c5cc04449c 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -14,7 +14,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty /// The difficulty corresponding to the mechanical skills in osu!taiko. /// This includes colour and stamina combined. /// - [JsonProperty("mechanical_difficulty")] public double MechanicalDifficulty { get; set; } /// @@ -26,7 +25,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty /// /// The difficulty corresponding to the reading skill. /// - [JsonProperty("reading_difficulty")] public double ReadingDifficulty { get; set; } /// @@ -59,9 +57,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty yield return v; yield return (ATTRIB_ID_DIFFICULTY, StarRating); - yield return (ATTRIB_ID_MECHANICAL_DIFFICULTY, MechanicalDifficulty); yield return (ATTRIB_ID_RHYTHM_DIFFICULTY, RhythmDifficulty); - yield return (ATTRIB_ID_READING_DIFFICULTY, ReadingDifficulty); yield return (ATTRIB_ID_MONO_STAMINA_FACTOR, MonoStaminaFactor); yield return (ATTRIB_ID_CONSISTENCY_FACTOR, ConsistencyFactor); } @@ -71,9 +67,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty base.FromDatabaseAttributes(values, onlineInfo); StarRating = values[ATTRIB_ID_DIFFICULTY]; - MechanicalDifficulty = values[ATTRIB_ID_MECHANICAL_DIFFICULTY]; RhythmDifficulty = values[ATTRIB_ID_RHYTHM_DIFFICULTY]; - ReadingDifficulty = values[ATTRIB_ID_READING_DIFFICULTY]; MonoStaminaFactor = values[ATTRIB_ID_MONO_STAMINA_FACTOR]; ConsistencyFactor = values[ATTRIB_ID_CONSISTENCY_FACTOR]; } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index 20cac77f8b..5e431dc357 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -31,10 +31,8 @@ namespace osu.Game.Rulesets.Difficulty protected const int ATTRIB_ID_NESTED_SCORE_PER_OBJECT = 37; protected const int ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER = 39; protected const int ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE = 41; - protected const int ATTRIB_ID_MECHANICAL_DIFFICULTY = 43; - protected const int ATTRIB_ID_RHYTHM_DIFFICULTY = 45; - protected const int ATTRIB_ID_READING_DIFFICULTY = 47; - protected const int ATTRIB_ID_CONSISTENCY_FACTOR = 49; + protected const int ATTRIB_ID_RHYTHM_DIFFICULTY = 43; + protected const int ATTRIB_ID_CONSISTENCY_FACTOR = 45; /// /// The mods which were applied to the beatmap. From 7852df639a86a07d3dea919721f07012433a1218 Mon Sep 17 00:00:00 2001 From: Givy120 <89256026+Givikap120@users.noreply.github.com> Date: Tue, 16 Sep 2025 16:31:14 +0300 Subject: [PATCH 56/58] Use DeltaTime in RhythmEvaluator to increase stability (#32790) * Update RhythmEvaluator.cs * Rename `StrainTime` into `AdjustedDeltaTime` --------- Co-authored-by: StanR --- .../Difficulty/Evaluators/AimEvaluator.cs | 16 ++++++++-------- .../Difficulty/Evaluators/FlashlightEvaluator.cs | 2 +- .../Difficulty/Evaluators/RhythmEvaluator.cs | 7 ++++--- .../Difficulty/Evaluators/SpeedEvaluator.cs | 2 +- .../Preprocessing/OsuDifficultyHitObject.cs | 10 +++++----- osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs | 2 +- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 5942448855..dcf8ac0fed 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators 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.StrainTime; + double currVelocity = osuCurrObj.LazyJumpDistance / 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 +52,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators } // As above, do the same for the previous hitobject. - double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime; + double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.AdjustedDeltaTime; if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance) { @@ -78,7 +78,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators // Rewarding angles, take the smaller velocity as base. double angleBonus = Math.Min(currVelocity, prevVelocity); - if (Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime) < 1.25 * Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime)) // If rhythms are the same. + if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same. { acuteAngleBonus = calcAcuteAngleBonus(currAngle); @@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter acuteAngleBonus *= angleBonus * - DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2), 300, 400) * + DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2); } @@ -128,19 +128,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators 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.StrainTime; - currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.StrainTime; + 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); // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. - double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); + double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity)); velocityChangeBonus = overlapVelocityBuff * distRatio; // Penalize for rhythm changes. - velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime) / Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime), 2); + velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2); } if (osuLastObj.BaseObject is Slider) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs index d64a2c2f15..55192df7af 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs @@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators var currentObj = (OsuDifficultyHitObject)current.Previous(i); var currentHitObject = (OsuHitObject)(currentObj.BaseObject); - cumulativeStrainTime += lastObj.StrainTime; + cumulativeStrainTime += lastObj.AdjustedDeltaTime; if (!(currentObj.BaseObject is Spinner)) { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index 9e6bae6c01..9349083951 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -64,9 +64,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count. - double currDelta = currObj.StrainTime; - double prevDelta = prevObj.StrainTime; - double lastDelta = lastObj.StrainTime; + // Use custom cap value to ensure that 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); // 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) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index 8cc0fc209a..a58c1d3685 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators var osuCurrObj = (OsuDifficultyHitObject)current; var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; - double strainTime = osuCurrObj.StrainTime; + double strainTime = osuCurrObj.AdjustedDeltaTime; double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0)); // Cap deltatime to the OD 300 hitwindow. diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 8ad72daeb5..5e9fc10ef8 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -31,9 +31,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing protected new OsuHitObject LastObject => (OsuHitObject)base.LastObject; /// - /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms. + /// capped to a minimum of ms. /// - public readonly double StrainTime; + public readonly double AdjustedDeltaTime; /// /// Normalised distance from the "lazy" end position of the previous to the start position of this . @@ -120,7 +120,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing lastDifficultyObject = index > 0 ? (OsuDifficultyHitObject)objects[index - 1] : null; // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. - StrainTime = Math.Max(DeltaTime, MIN_DELTA_TIME); + AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME); SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40); @@ -203,13 +203,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition; LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; - MinimumJumpTime = StrainTime; + MinimumJumpTime = AdjustedDeltaTime; MinimumJumpDistance = LazyJumpDistance; if (LastObject is Slider lastSlider && lastDifficultyObject != null) { double lastTravelTime = Math.Max(lastDifficultyObject.LazyTravelTime / clockRate, MIN_DELTA_TIME); - MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, MIN_DELTA_TIME); + MinimumJumpTime = Math.Max(AdjustedDeltaTime - lastTravelTime, MIN_DELTA_TIME); // // There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects. diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 7fd1e044ae..8fe3df4347 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills protected override double StrainValueAt(DifficultyHitObject current) { - currentStrain *= strainDecay(((OsuDifficultyHitObject)current).StrainTime); + currentStrain *= strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier; currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); From 82ac42cae31639a3f031814b5c34604bea39fb6c Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 23 Sep 2025 13:42:31 +0900 Subject: [PATCH 57/58] Replace nested ternaries with ifs --- .../Data/SameRhythmHitObjectGrouping.cs | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs index 89c150eb5f..59215c043b 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs @@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data public readonly double HitObjectIntervalRatio; /// - public double Interval { get; } + public double Interval { get; } = double.PositiveInfinity; public SameRhythmHitObjectGrouping(SameRhythmHitObjectGrouping? previous, List hitObjects) { @@ -66,11 +66,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data : 0; // Calculate the average interval between hitobjects. - HitObjectInterval = normalisedHitObjectDeltaTime.Count > 0 - ? previous?.HitObjectInterval is double previousDelta && Math.Abs(modalDelta - previousDelta) <= snap_tolerance - ? previousDelta - : modalDelta - : null; + if (normalisedHitObjectDeltaTime.Count > 0) + { + if (previous?.HitObjectInterval is double previousDelta && Math.Abs(modalDelta - previousDelta) <= snap_tolerance) + HitObjectInterval = previousDelta; + else + HitObjectInterval = modalDelta; + } // Calculate the ratio between this group's interval and the previous group's interval HitObjectIntervalRatio = previous?.HitObjectInterval is double previousInterval && HitObjectInterval is double currentInterval @@ -78,11 +80,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data : 1.0; // Calculate the interval from the previous group's start time - Interval = previous == null - ? double.PositiveInfinity - : Math.Abs(StartTime - previous.StartTime) <= snap_tolerance - ? 0 - : StartTime - previous.StartTime; + if (previous != null) + { + if (Math.Abs(StartTime - previous.StartTime) <= snap_tolerance) + Interval = 0; + else + Interval = StartTime - previous.StartTime; + } } } } From 743a94bd22b7c0ce280232a323963828a1f70d96 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 7 Oct 2025 16:30:40 +0900 Subject: [PATCH 58/58] Re-remove duplicate error functions --- ...ifficultyCalculationUtils.ErrorFunction.cs | 688 ------------------ 1 file changed, 688 deletions(-) delete mode 100644 osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.ErrorFunction.cs diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.ErrorFunction.cs b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.ErrorFunction.cs deleted file mode 100644 index 4b89cbe7cc..0000000000 --- a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.ErrorFunction.cs +++ /dev/null @@ -1,688 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -// All code is referenced from the following: -// https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/SpecialFunctions/Erf.cs - -/* - Copyright (c) 2002-2022 Math.NET -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -using System; - -namespace osu.Game.Rulesets.Difficulty.Utils -{ - public partial class DifficultyCalculationUtils - { - /// - /// ************************************** - /// COEFFICIENTS FOR METHOD ErfImp * - /// ************************************** - /// - /// Polynomial coefficients for a numerator of ErfImp - /// calculation for Erf(x) in the interval [1e-10, 0.5]. - /// - private static readonly double[] erf_imp_an = { 0.00337916709551257388990745, -0.00073695653048167948530905, -0.374732337392919607868241, 0.0817442448733587196071743, -0.0421089319936548595203468, 0.0070165709512095756344528, -0.00495091255982435110337458, 0.000871646599037922480317225 }; - - /// Polynomial coefficients for a denominator of ErfImp - /// calculation for Erf(x) in the interval [1e-10, 0.5]. - /// - private static readonly double[] erf_imp_ad = { 1, -0.218088218087924645390535, 0.412542972725442099083918, -0.0841891147873106755410271, 0.0655338856400241519690695, -0.0120019604454941768171266, 0.00408165558926174048329689, -0.000615900721557769691924509 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [0.5, 0.75]. - /// - private static readonly double[] erf_imp_bn = { -0.0361790390718262471360258, 0.292251883444882683221149, 0.281447041797604512774415, 0.125610208862766947294894, 0.0274135028268930549240776, 0.00250839672168065762786937 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [0.5, 0.75]. - /// - private static readonly double[] erf_imp_bd = { 1, 1.8545005897903486499845, 1.43575803037831418074962, 0.582827658753036572454135, 0.124810476932949746447682, 0.0113724176546353285778481 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [0.75, 1.25]. - /// - private static readonly double[] erf_imp_cn = { -0.0397876892611136856954425, 0.153165212467878293257683, 0.191260295600936245503129, 0.10276327061989304213645, 0.029637090615738836726027, 0.0046093486780275489468812, 0.000307607820348680180548455 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [0.75, 1.25]. - /// - private static readonly double[] erf_imp_cd = { 1, 1.95520072987627704987886, 1.64762317199384860109595, 0.768238607022126250082483, 0.209793185936509782784315, 0.0319569316899913392596356, 0.00213363160895785378615014 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [1.25, 2.25]. - /// - private static readonly double[] erf_imp_dn = { -0.0300838560557949717328341, 0.0538578829844454508530552, 0.0726211541651914182692959, 0.0367628469888049348429018, 0.00964629015572527529605267, 0.00133453480075291076745275, 0.778087599782504251917881e-4 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [1.25, 2.25]. - /// - private static readonly double[] erf_imp_dd = { 1, 1.75967098147167528287343, 1.32883571437961120556307, 0.552528596508757581287907, 0.133793056941332861912279, 0.0179509645176280768640766, 0.00104712440019937356634038, -0.106640381820357337177643e-7 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [2.25, 3.5]. - /// - private static readonly double[] erf_imp_en = { -0.0117907570137227847827732, 0.014262132090538809896674, 0.0202234435902960820020765, 0.00930668299990432009042239, 0.00213357802422065994322516, 0.00025022987386460102395382, 0.120534912219588189822126e-4 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [2.25, 3.5]. - /// - private static readonly double[] erf_imp_ed = { 1, 1.50376225203620482047419, 0.965397786204462896346934, 0.339265230476796681555511, 0.0689740649541569716897427, 0.00771060262491768307365526, 0.000371421101531069302990367 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [3.5, 5.25]. - /// - private static readonly double[] erf_imp_fn = { -0.00546954795538729307482955, 0.00404190278731707110245394, 0.0054963369553161170521356, 0.00212616472603945399437862, 0.000394984014495083900689956, 0.365565477064442377259271e-4, 0.135485897109932323253786e-5 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [3.5, 5.25]. - /// - private static readonly double[] erf_imp_fd = { 1, 1.21019697773630784832251, 0.620914668221143886601045, 0.173038430661142762569515, 0.0276550813773432047594539, 0.00240625974424309709745382, 0.891811817251336577241006e-4, -0.465528836283382684461025e-11 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [5.25, 8]. - /// - private static readonly double[] erf_imp_gn = { -0.00270722535905778347999196, 0.0013187563425029400461378, 0.00119925933261002333923989, 0.00027849619811344664248235, 0.267822988218331849989363e-4, 0.923043672315028197865066e-6 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [5.25, 8]. - /// - private static readonly double[] erf_imp_gd = { 1, 0.814632808543141591118279, 0.268901665856299542168425, 0.0449877216103041118694989, 0.00381759663320248459168994, 0.000131571897888596914350697, 0.404815359675764138445257e-11 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [8, 11.5]. - /// - private static readonly double[] erf_imp_hn = { -0.00109946720691742196814323, 0.000406425442750422675169153, 0.000274499489416900707787024, 0.465293770646659383436343e-4, 0.320955425395767463401993e-5, 0.778286018145020892261936e-7 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [8, 11.5]. - /// - private static readonly double[] erf_imp_hd = { 1, 0.588173710611846046373373, 0.139363331289409746077541, 0.0166329340417083678763028, 0.00100023921310234908642639, 0.24254837521587225125068e-4 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [11.5, 17]. - /// - private static readonly double[] erf_imp_in = { -0.00056907993601094962855594, 0.000169498540373762264416984, 0.518472354581100890120501e-4, 0.382819312231928859704678e-5, 0.824989931281894431781794e-7 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [11.5, 17]. - /// - private static readonly double[] erf_imp_id = { 1, 0.339637250051139347430323, 0.043472647870310663055044, 0.00248549335224637114641629, 0.535633305337152900549536e-4, -0.117490944405459578783846e-12 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [17, 24]. - /// - private static readonly double[] erf_imp_jn = { -0.000241313599483991337479091, 0.574224975202501512365975e-4, 0.115998962927383778460557e-4, 0.581762134402593739370875e-6, 0.853971555085673614607418e-8 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [17, 24]. - /// - private static readonly double[] erf_imp_jd = { 1, 0.233044138299687841018015, 0.0204186940546440312625597, 0.000797185647564398289151125, 0.117019281670172327758019e-4 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [24, 38]. - /// - private static readonly double[] erf_imp_kn = { -0.000146674699277760365803642, 0.162666552112280519955647e-4, 0.269116248509165239294897e-5, 0.979584479468091935086972e-7, 0.101994647625723465722285e-8 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [24, 38]. - /// - private static readonly double[] erf_imp_kd = { 1, 0.165907812944847226546036, 0.0103361716191505884359634, 0.000286593026373868366935721, 0.298401570840900340874568e-5 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [38, 60]. - /// - private static readonly double[] erf_imp_ln = { -0.583905797629771786720406e-4, 0.412510325105496173512992e-5, 0.431790922420250949096906e-6, 0.993365155590013193345569e-8, 0.653480510020104699270084e-10 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [38, 60]. - /// - private static readonly double[] erf_imp_ld = { 1, 0.105077086072039915406159, 0.00414278428675475620830226, 0.726338754644523769144108e-4, 0.477818471047398785369849e-6 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [60, 85]. - /// - private static readonly double[] erf_imp_mn = { -0.196457797609229579459841e-4, 0.157243887666800692441195e-5, 0.543902511192700878690335e-7, 0.317472492369117710852685e-9 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [60, 85]. - /// - private static readonly double[] erf_imp_md = { 1, 0.052803989240957632204885, 0.000926876069151753290378112, 0.541011723226630257077328e-5, 0.535093845803642394908747e-15 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [85, 110]. - /// - private static readonly double[] erf_imp_nn = { -0.789224703978722689089794e-5, 0.622088451660986955124162e-6, 0.145728445676882396797184e-7, 0.603715505542715364529243e-10 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [85, 110]. - /// - private static readonly double[] erf_imp_nd = { 1, 0.0375328846356293715248719, 0.000467919535974625308126054, 0.193847039275845656900547e-5 }; - - /// - /// ************************************** - /// COEFFICIENTS FOR METHOD ErfInvImp * - /// ************************************** - /// - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0, 0.5]. - /// - private static readonly double[] erv_inv_imp_an = { -0.000508781949658280665617, -0.00836874819741736770379, 0.0334806625409744615033, -0.0126926147662974029034, -0.0365637971411762664006, 0.0219878681111168899165, 0.00822687874676915743155, -0.00538772965071242932965 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0, 0.5]. - /// - private static readonly double[] erv_inv_imp_ad = { 1, -0.970005043303290640362, -1.56574558234175846809, 1.56221558398423026363, 0.662328840472002992063, -0.71228902341542847553, -0.0527396382340099713954, 0.0795283687341571680018, -0.00233393759374190016776, 0.000886216390456424707504 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.5, 0.75]. - /// - private static readonly double[] erv_inv_imp_bn = { -0.202433508355938759655, 0.105264680699391713268, 8.37050328343119927838, 17.6447298408374015486, -18.8510648058714251895, -44.6382324441786960818, 17.445385985570866523, 21.1294655448340526258, -3.67192254707729348546 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.5, 0.75]. - /// - private static readonly double[] erv_inv_imp_bd = { 1, 6.24264124854247537712, 3.9713437953343869095, -28.6608180499800029974, -20.1432634680485188801, 48.5609213108739935468, 10.8268667355460159008, -22.6436933413139721736, 1.72114765761200282724 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x less than 3. - /// - private static readonly double[] erv_inv_imp_cn = { -0.131102781679951906451, -0.163794047193317060787, 0.117030156341995252019, 0.387079738972604337464, 0.337785538912035898924, 0.142869534408157156766, 0.0290157910005329060432, 0.00214558995388805277169, -0.679465575181126350155e-6, 0.285225331782217055858e-7, -0.681149956853776992068e-9 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x less than 3. - /// - private static readonly double[] erv_inv_imp_cd = { 1, 3.46625407242567245975, 5.38168345707006855425, 4.77846592945843778382, 2.59301921623620271374, 0.848854343457902036425, 0.152264338295331783612, 0.01105924229346489121 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 3 and 6. - /// - private static readonly double[] erv_inv_imp_dn = { -0.0350353787183177984712, -0.00222426529213447927281, 0.0185573306514231072324, 0.00950804701325919603619, 0.00187123492819559223345, 0.000157544617424960554631, 0.460469890584317994083e-5, -0.230404776911882601748e-9, 0.266339227425782031962e-11 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 3 and 6. - /// - private static readonly double[] erv_inv_imp_dd = { 1, 1.3653349817554063097, 0.762059164553623404043, 0.220091105764131249824, 0.0341589143670947727934, 0.00263861676657015992959, 0.764675292302794483503e-4 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 6 and 18. - /// - private static readonly double[] erv_inv_imp_en = { -0.0167431005076633737133, -0.00112951438745580278863, 0.00105628862152492910091, 0.000209386317487588078668, 0.149624783758342370182e-4, 0.449696789927706453732e-6, 0.462596163522878599135e-8, -0.281128735628831791805e-13, 0.99055709973310326855e-16 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 6 and 18. - /// - private static readonly double[] erv_inv_imp_ed = { 1, 0.591429344886417493481, 0.138151865749083321638, 0.0160746087093676504695, 0.000964011807005165528527, 0.275335474764726041141e-4, 0.282243172016108031869e-6 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 18 and 44. - /// - private static readonly double[] erv_inv_imp_fn = { -0.0024978212791898131227, -0.779190719229053954292e-5, 0.254723037413027451751e-4, 0.162397777342510920873e-5, 0.396341011304801168516e-7, 0.411632831190944208473e-9, 0.145596286718675035587e-11, -0.116765012397184275695e-17 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 18 and 44. - /// - private static readonly double[] erv_inv_imp_fd = { 1, 0.207123112214422517181, 0.0169410838120975906478, 0.000690538265622684595676, 0.145007359818232637924e-4, 0.144437756628144157666e-6, 0.509761276599778486139e-9 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x greater than 44. - /// - private static readonly double[] erv_inv_imp_gn = { -0.000539042911019078575891, -0.28398759004727721098e-6, 0.899465114892291446442e-6, 0.229345859265920864296e-7, 0.225561444863500149219e-9, 0.947846627503022684216e-12, 0.135880130108924861008e-14, -0.348890393399948882918e-21 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x greater than 44. - /// - private static readonly double[] erv_inv_imp_gd = { 1, 0.0845746234001899436914, 0.00282092984726264681981, 0.468292921940894236786e-4, 0.399968812193862100054e-6, 0.161809290887904476097e-8, 0.231558608310259605225e-11 }; - - /// Calculates the error function. - /// The value to evaluate. - /// the error function evaluated at given value. - /// - /// - /// returns 1 if x == double.PositiveInfinity. - /// returns -1 if x == double.NegativeInfinity. - /// - /// - public static double Erf(double x) - { - if (x == 0) - { - return 0; - } - - if (double.IsPositiveInfinity(x)) - { - return 1; - } - - if (double.IsNegativeInfinity(x)) - { - return -1; - } - - if (double.IsNaN(x)) - { - return double.NaN; - } - - return erfImp(x, false); - } - - /// Calculates the complementary error function. - /// The value to evaluate. - /// the complementary error function evaluated at given value. - /// - /// - /// returns 0 if x == double.PositiveInfinity. - /// returns 2 if x == double.NegativeInfinity. - /// - /// - public static double Erfc(double x) - { - if (x == 0) - { - return 1; - } - - if (double.IsPositiveInfinity(x)) - { - return 0; - } - - if (double.IsNegativeInfinity(x)) - { - return 2; - } - - if (double.IsNaN(x)) - { - return double.NaN; - } - - return erfImp(x, true); - } - - /// Calculates the inverse error function evaluated at z. - /// The inverse error function evaluated at given value. - /// - /// - /// returns double.PositiveInfinity if z >= 1.0. - /// returns double.NegativeInfinity if z <= -1.0. - /// - /// - /// Calculates the inverse error function evaluated at z. - /// value to evaluate. - /// the inverse error function evaluated at Z. - public static double ErfInv(double z) - { - if (z == 0.0) - { - return 0.0; - } - - if (z >= 1.0) - { - return double.PositiveInfinity; - } - - if (z <= -1.0) - { - return double.NegativeInfinity; - } - - double p, q, s; - - if (z < 0) - { - p = -z; - q = 1 - p; - s = -1; - } - else - { - p = z; - q = 1 - z; - s = 1; - } - - return erfInvImpl(p, q, s); - } - - /// - /// Implementation of the error function. - /// - /// Where to evaluate the error function. - /// Whether to compute 1 - the error function. - /// the error function. - private static double erfImp(double z, bool invert) - { - if (z < 0) - { - if (!invert) - { - return -erfImp(-z, false); - } - - if (z < -0.5) - { - return 2 - erfImp(-z, true); - } - - return 1 + erfImp(-z, false); - } - - double result; - - // Big bunch of selection statements now to pick which - // implementation to use, try to put most likely options - // first: - if (z < 0.5) - { - // We're going to calculate erf: - if (z < 1e-10) - { - result = (z * 1.125) + (z * 0.003379167095512573896158903121545171688); - } - else - { - // Worst case absolute error found: 6.688618532e-21 - result = (z * 1.125) + (z * evaluatePolynomial(z, erf_imp_an) / evaluatePolynomial(z, erf_imp_ad)); - } - } - else if (z < 110) - { - // We'll be calculating erfc: - invert = !invert; - double r, b; - - if (z < 0.75) - { - // Worst case absolute error found: 5.582813374e-21 - r = evaluatePolynomial(z - 0.5, erf_imp_bn) / evaluatePolynomial(z - 0.5, erf_imp_bd); - b = 0.3440242112F; - } - else if (z < 1.25) - { - // Worst case absolute error found: 4.01854729e-21 - r = evaluatePolynomial(z - 0.75, erf_imp_cn) / evaluatePolynomial(z - 0.75, erf_imp_cd); - b = 0.419990927F; - } - else if (z < 2.25) - { - // Worst case absolute error found: 2.866005373e-21 - r = evaluatePolynomial(z - 1.25, erf_imp_dn) / evaluatePolynomial(z - 1.25, erf_imp_dd); - b = 0.4898625016F; - } - else if (z < 3.5) - { - // Worst case absolute error found: 1.045355789e-21 - r = evaluatePolynomial(z - 2.25, erf_imp_en) / evaluatePolynomial(z - 2.25, erf_imp_ed); - b = 0.5317370892F; - } - else if (z < 5.25) - { - // Worst case absolute error found: 8.300028706e-22 - r = evaluatePolynomial(z - 3.5, erf_imp_fn) / evaluatePolynomial(z - 3.5, erf_imp_fd); - b = 0.5489973426F; - } - else if (z < 8) - { - // Worst case absolute error found: 1.700157534e-21 - r = evaluatePolynomial(z - 5.25, erf_imp_gn) / evaluatePolynomial(z - 5.25, erf_imp_gd); - b = 0.5571740866F; - } - else if (z < 11.5) - { - // Worst case absolute error found: 3.002278011e-22 - r = evaluatePolynomial(z - 8, erf_imp_hn) / evaluatePolynomial(z - 8, erf_imp_hd); - b = 0.5609807968F; - } - else if (z < 17) - { - // Worst case absolute error found: 6.741114695e-21 - r = evaluatePolynomial(z - 11.5, erf_imp_in) / evaluatePolynomial(z - 11.5, erf_imp_id); - b = 0.5626493692F; - } - else if (z < 24) - { - // Worst case absolute error found: 7.802346984e-22 - r = evaluatePolynomial(z - 17, erf_imp_jn) / evaluatePolynomial(z - 17, erf_imp_jd); - b = 0.5634598136F; - } - else if (z < 38) - { - // Worst case absolute error found: 2.414228989e-22 - r = evaluatePolynomial(z - 24, erf_imp_kn) / evaluatePolynomial(z - 24, erf_imp_kd); - b = 0.5638477802F; - } - else if (z < 60) - { - // Worst case absolute error found: 5.896543869e-24 - r = evaluatePolynomial(z - 38, erf_imp_ln) / evaluatePolynomial(z - 38, erf_imp_ld); - b = 0.5640528202F; - } - else if (z < 85) - { - // Worst case absolute error found: 3.080612264e-21 - r = evaluatePolynomial(z - 60, erf_imp_mn) / evaluatePolynomial(z - 60, erf_imp_md); - b = 0.5641309023F; - } - else - { - // Worst case absolute error found: 8.094633491e-22 - r = evaluatePolynomial(z - 85, erf_imp_nn) / evaluatePolynomial(z - 85, erf_imp_nd); - b = 0.5641584396F; - } - - double g = Math.Exp(-z * z) / z; - result = (g * b) + (g * r); - } - else - { - // Any value of z larger than 28 will underflow to zero: - result = 0; - invert = !invert; - } - - if (invert) - { - result = 1 - result; - } - - return result; - } - - /// Calculates the complementary inverse error function evaluated at z. - /// The complementary inverse error function evaluated at given value. - /// We have tested this implementation against the arbitrary precision mpmath library - /// and found cases where we can only guarantee 9 significant figures correct. - /// - /// returns double.PositiveInfinity if z <= 0.0. - /// returns double.NegativeInfinity if z >= 2.0. - /// - /// - /// calculates the complementary inverse error function evaluated at z. - /// value to evaluate. - /// the complementary inverse error function evaluated at Z. - public static double ErfcInv(double z) - { - if (z <= 0.0) - { - return double.PositiveInfinity; - } - - if (z >= 2.0) - { - return double.NegativeInfinity; - } - - double p, q, s; - - if (z > 1) - { - q = 2 - z; - p = 1 - q; - s = -1; - } - else - { - p = 1 - z; - q = z; - s = 1; - } - - return erfInvImpl(p, q, s); - } - - /// - /// The implementation of the inverse error function. - /// - /// First intermediate parameter. - /// Second intermediate parameter. - /// Third intermediate parameter. - /// the inverse error function. - private static double erfInvImpl(double p, double q, double s) - { - double result; - - if (p <= 0.5) - { - // Evaluate inverse erf using the rational approximation: - // - // x = p(p+10)(Y+R(p)) - // - // Where Y is a constant, and R(p) is optimized for a low - // absolute error compared to |Y|. - // - // double: Max error found: 2.001849e-18 - // long double: Max error found: 1.017064e-20 - // Maximum Deviation Found (actual error term at infinite precision) 8.030e-21 - const float y = 0.0891314744949340820313f; - double g = p * (p + 10); - double r = evaluatePolynomial(p, erv_inv_imp_an) / evaluatePolynomial(p, erv_inv_imp_ad); - result = (g * y) + (g * r); - } - else if (q >= 0.25) - { - // Rational approximation for 0.5 > q >= 0.25 - // - // x = sqrt(-2*log(q)) / (Y + R(q)) - // - // Where Y is a constant, and R(q) is optimized for a low - // absolute error compared to Y. - // - // double : Max error found: 7.403372e-17 - // long double : Max error found: 6.084616e-20 - // Maximum Deviation Found (error term) 4.811e-20 - const float y = 2.249481201171875f; - double g = Math.Sqrt(-2 * Math.Log(q)); - double xs = q - 0.25; - double r = evaluatePolynomial(xs, erv_inv_imp_bn) / evaluatePolynomial(xs, erv_inv_imp_bd); - result = g / (y + r); - } - else - { - // For q < 0.25 we have a series of rational approximations all - // of the general form: - // - // let: x = sqrt(-log(q)) - // - // Then the result is given by: - // - // x(Y+R(x-B)) - // - // where Y is a constant, B is the lowest value of x for which - // the approximation is valid, and R(x-B) is optimized for a low - // absolute error compared to Y. - // - // Note that almost all code will really go through the first - // or maybe second approximation. After than we're dealing with very - // small input values indeed: 80 and 128 bit long double's go all the - // way down to ~ 1e-5000 so the "tail" is rather long... - double x = Math.Sqrt(-Math.Log(q)); - - if (x < 3) - { - // Max error found: 1.089051e-20 - const float y = 0.807220458984375f; - double xs = x - 1.125; - double r = evaluatePolynomial(xs, erv_inv_imp_cn) / evaluatePolynomial(xs, erv_inv_imp_cd); - result = (y * x) + (r * x); - } - else if (x < 6) - { - // Max error found: 8.389174e-21 - const float y = 0.93995571136474609375f; - double xs = x - 3; - double r = evaluatePolynomial(xs, erv_inv_imp_dn) / evaluatePolynomial(xs, erv_inv_imp_dd); - result = (y * x) + (r * x); - } - else if (x < 18) - { - // Max error found: 1.481312e-19 - const float y = 0.98362827301025390625f; - double xs = x - 6; - double r = evaluatePolynomial(xs, erv_inv_imp_en) / evaluatePolynomial(xs, erv_inv_imp_ed); - result = (y * x) + (r * x); - } - else if (x < 44) - { - // Max error found: 5.697761e-20 - const float y = 0.99714565277099609375f; - double xs = x - 18; - double r = evaluatePolynomial(xs, erv_inv_imp_fn) / evaluatePolynomial(xs, erv_inv_imp_fd); - result = (y * x) + (r * x); - } - else - { - // Max error found: 1.279746e-20 - const float y = 0.99941349029541015625f; - double xs = x - 44; - double r = evaluatePolynomial(xs, erv_inv_imp_gn) / evaluatePolynomial(xs, erv_inv_imp_gd); - result = (y * x) + (r * x); - } - } - - return s * result; - } - - /// - /// Evaluate a polynomial at point x. - /// Coefficients are ordered ascending by power with power k at index k. - /// Example: coefficients [3,-1,2] represent y=2x^2-x+3. - /// - /// The location where to evaluate the polynomial at. - /// The coefficients of the polynomial, coefficient for power k at index k. - /// - /// is a null reference. - /// - private static double evaluatePolynomial(double z, params double[] coefficients) - { - // 2020-10-07 jbialogrodzki #730 Since this is public API we should probably - // handle null arguments? It doesn't seem to have been done consistently in this class though. - ArgumentNullException.ThrowIfNull(coefficients); - - // 2020-10-07 jbialogrodzki #730 Zero polynomials need explicit handling. - // Without this check, we attempted to peek coefficients at negative indices! - int n = coefficients.Length; - - if (n == 0) - { - return 0; - } - - double sum = coefficients[n - 1]; - - for (int i = n - 2; i >= 0; --i) - { - sum *= z; - sum += coefficients[i]; - } - - return sum; - } - } -}