diff --git a/osucatch-editor-realtimeviewer/BeatmapConverter.cs b/osucatch-editor-realtimeviewer/BeatmapConverter.cs
index db4eb5f..a78fae7 100644
--- a/osucatch-editor-realtimeviewer/BeatmapConverter.cs
+++ b/osucatch-editor-realtimeviewer/BeatmapConverter.cs
@@ -1,12 +1,15 @@
-using osu.Game.Beatmaps;
+using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Catch;
+using osu.Game.Rulesets.Catch.Difficulty;
+using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;
using osu.Game.Rulesets.Catch.Difficulty.Skills;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.UI;
+using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
@@ -196,40 +199,56 @@ public void CalDistanceToNext(IBeatmap beatmap, PalpableCatchHitObject hitObject
}
+ ///
+ /// Calculates the star rating of using the version
+ /// difficulty calculation pipeline
+ /// ( + MovementEvaluator + ),
+ /// and writes the per-object star rating to .
+ ///
public double CalDifficulty(IBeatmap beatmap, List comboHitObjects)
{
const double difficulty_multiplier = 4.59;
- const float normalized_hitobject_radius = 41.0f;
+ const double clock_rate = 1.0;
+
float halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f;
// For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay.
halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f);
- // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps.
- float scalingFactor = normalized_hitobject_radius / halfCatcherWidth;
- StrainSkill skill = new Movement(halfCatcherWidth, 1);
+ var skill = new Movement(NoMod);
- for (int i = 0; i < comboHitObjects.Count; i++)
- {
- CalDifficultyToLast(skill, comboHitObjects[i], scalingFactor);
- }
+ CalDifficultyToLast(skill, comboHitObjects, halfCatcherWidth, clock_rate);
return Math.Sqrt(skill.DifficultyValue()) * difficulty_multiplier;
}
- public void CalDifficultyToLast(StrainSkill skill, PalpableCatchHitObject hitObject, float scalingFactor)
+ ///
+ /// Feeds all combo-contributing objects of the beatmap through the difficulty pipeline.
+ ///
+ public void CalDifficultyToLast(Movement skill, List comboHitObjects, float halfCatcherWidth, double clockRate)
{
- if (hitObject.lastObject == null)
+ List difficultyHitObjects = new List(comboHitObjects.Count);
+
+ for (int i = 0; i < comboHitObjects.Count; i++)
{
- hitObject.DifficultyToLast = 0;
- return;
+ var hitObject = comboHitObjects[i];
+ var lastObject = hitObject.lastObject;
+
+ if (lastObject == null)
+ {
+ hitObject.DifficultyToLast = 0;
+ continue;
+ }
+
+ difficultyHitObjects.Add(new CatchDifficultyHitObject(hitObject, lastObject, clockRate, halfCatcherWidth, difficultyHitObjects, difficultyHitObjects.Count));
}
- hitObject.NormalizedPosition = hitObject.EffectiveX * scalingFactor;
- hitObject.LastNormalizedPosition = hitObject.lastObject.EffectiveX * scalingFactor;
- // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure
- hitObject.DeltaTime = hitObject.StartTime - hitObject.lastObject.StartTime;
- hitObject.StrainTime = Math.Max(40, hitObject.DeltaTime);
- skill.Process(hitObject);
+ foreach (var difficultyHitObject in difficultyHitObjects)
+ {
+ skill.Process(difficultyHitObject);
+
+ var hitObject = (PalpableCatchHitObject)difficultyHitObject.BaseObject;
+ hitObject.DifficultyToLast = difficultyHitObject.DifficultyToLast;
+ }
}
private void CalFruitCountInCombo(List comboHitObjects)
diff --git a/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs
new file mode 100644
index 0000000..7d4ad53
--- /dev/null
+++ b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs
@@ -0,0 +1,19 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Rulesets.Difficulty;
+
+namespace osu.Game.Rulesets.Catch.Difficulty
+{
+ public class CatchDifficultyAttributes : DifficultyAttributes
+ {
+ public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes()
+ {
+ foreach (var v in base.ToDatabaseAttributes())
+ yield return v;
+
+ // Todo: osu!catch should not output star rating in the 'aim' attribute.
+ yield return (ATTRIB_ID_AIM, StarRating);
+ }
+ }
+}
diff --git a/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs
new file mode 100644
index 0000000..d08fdf8
--- /dev/null
+++ b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs
@@ -0,0 +1,126 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Catch.Beatmaps;
+using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;using osu.Game.Rulesets.Catch.Difficulty.Skills;
+using osu.Game.Rulesets.Catch.Mods;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Catch.UI;
+using osu.Game.Rulesets.Difficulty;
+using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Skills;
+using osu.Game.Rulesets.Mods;
+
+namespace osu.Game.Rulesets.Catch.Difficulty
+{
+ ///
+ /// The osu!catch difficulty calculator.
+ ///
+ ///
+ /// The difficulty calculation in this viewer is performed per-hitobject (see
+ /// osucatch_editor_realtimeviewer.BeatmapConverter.CalDifficulty) so that a star rating
+ /// can be drawn beside each fruit, but it walks the exact same
+ /// / /
+ /// pipeline as the official calculator below.
+ ///
+ public class CatchDifficultyCalculator : DifficultyCalculator
+ {
+ private const double difficulty_multiplier = 4.59;
+
+ ///
+ /// osu!catch difficulty calculation version.
+ ///
+ ///
+ /// Version history:
+ /// 20260706 osu!catch star rating update: movement difficulty split into
+ /// (preprocessing),
+ /// (evaluation) and (skill), adding the linear spacing nerf and
+ /// the three-object buzz pattern detection.
+ ///
+ public override int Version => 20260706;
+
+ public CatchDifficultyCalculator(Ruleset ruleset, IWorkingBeatmap beatmap)
+ : base(ruleset, beatmap)
+ {
+ }
+
+ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
+ {
+ if (beatmap.HitObjects.Count == 0 || skills.Length == 0)
+ return new CatchDifficultyAttributes { Mods = mods };
+
+ CatchDifficultyAttributes attributes = new CatchDifficultyAttributes
+ {
+ StarRating = Math.Sqrt(skills.OfType().Single().DifficultyValue()) * difficulty_multiplier,
+ Mods = mods,
+ MaxCombo = getMaxCombo(beatmap),
+ };
+
+ return attributes;
+ }
+
+ protected override List CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
+ {
+ CatchHitObject? lastObject = null;
+
+ List objects = new List(beatmap.HitObjects.Count);
+
+ float halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f;
+
+ // For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay.
+ halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f);
+
+ // In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream.
+ foreach (var hitObject in CatchBeatmap.GetPalpableObjects(beatmap.HitObjects))
+ {
+ // We want to only consider fruits that contribute to the combo.
+ if (hitObject is Banana || hitObject is TinyDroplet)
+ continue;
+
+ if (lastObject != null)
+ objects.Add(new CatchDifficultyHitObject(hitObject, lastObject, clockRate, halfCatcherWidth, objects, objects.Count));
+
+ lastObject = hitObject;
+ }
+
+ return objects;
+ }
+
+ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
+ {
+ return new Skill[]
+ {
+ new Movement(mods),
+ };
+ }
+
+ // This project's mod set only contains EZ / HR, neither of which adjusts the gameplay clock rate.
+ protected override Mod[] DifficultyAdjustmentMods => new Mod[]
+ {
+ new CatchModEasy(),
+ new CatchModHardRock(),
+ };
+
+ ///
+ /// Returns the maximum achievable combo for the beatmap.
+ ///
+ ///
+ /// Only fruits and droplets contribute to the combo; bananas and tiny droplets do not.
+ ///
+ private static int getMaxCombo(IBeatmap beatmap)
+ {
+ int combo = 0;
+
+ foreach (var hitObject in CatchBeatmap.GetPalpableObjects(beatmap.HitObjects))
+ {
+ if (hitObject is Banana || hitObject is TinyDroplet)
+ continue;
+
+ combo++;
+ }
+
+ return combo;
+ }
+ }
+}
diff --git a/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs
new file mode 100644
index 0000000..9f103b8
--- /dev/null
+++ b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs
@@ -0,0 +1,94 @@
+// 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;
+using osu.Game.Rulesets.Difficulty.Utils;
+
+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)
+ {
+ var catchCurrent = (CatchDifficultyHitObject)current;
+ var catchLast = (CatchDifficultyHitObject)current.Previous(0);
+ var catchLastLast = (CatchDifficultyHitObject)current.Previous(1);
+
+ // In catch, clockrate adjustments do not only affect the timings of hitobjects,
+ // but also the speed of the player's catcher, which has an impact on difficulty
+ double catcherSpeedMultiplier = current.ClockRate;
+
+ double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier);
+
+ double distanceAddition = (DiffUtils.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 - DiffUtils.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;
+ }
+
+ // Linear spacing nerf.
+ int linearSpacingCount = 0;
+
+ for (int i = 0; i < Math.Min(current.Index, 10); i++)
+ {
+ var catchPrevObj = (CatchDifficultyHitObject)catchCurrent.Previous(i);
+
+ // Only same direction movements matter as they do not take any additional inputs.
+ if (Math.Sign(catchCurrent.DistanceMoved) != Math.Sign(catchPrevObj.DistanceMoved) || catchCurrent.DistanceMoved == 0 || catchPrevObj.DistanceMoved == 0)
+ break;
+
+ double currentSpacing = Math.Abs(catchCurrent.DistanceMoved / catchCurrent.StrainTime);
+ double prevSpacing = Math.Abs(catchPrevObj.DistanceMoved / catchPrevObj.StrainTime);
+
+ double relativeDifference = Math.Abs(currentSpacing / prevSpacing - 1);
+
+ if (relativeDifference > 0.05)
+ break;
+
+ linearSpacingCount++;
+ }
+
+ distanceAddition *= DiffUtils.Pow(0.7, linearSpacingCount);
+
+ // 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)
+ * DiffUtils.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values
+ }
+
+ // There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than
+ // 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/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs
new file mode 100644
index 0000000..18b24f7
--- /dev/null
+++ b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs
@@ -0,0 +1,97 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Objects;
+
+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.
+ ///
+ public readonly double StrainTime;
+
+ public CatchDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, float halfCatcherWidth, List objects, int index)
+ : 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_HALF_CATCHER_WIDTH / halfCatcherWidth;
+
+ NormalizedPosition = BaseObject.EffectiveX * scalingFactor;
+ LastNormalizedPosition = LastObject.EffectiveX * scalingFactor;
+
+ // 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/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs
index cf11146..2a05d38 100644
--- a/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs
+++ b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs
@@ -1,17 +1,15 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Catch.Difficulty.Evaluators;
+using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
+using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Catch.Difficulty.Skills
{
- public class Movement : StrainDecaySkill
+ 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;
protected override double StrainDecayBase => 0.2;
@@ -19,105 +17,14 @@ public class Movement : StrainDecaySkill
protected override int SectionLength => 750;
- 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.
- ///
- private readonly double catcherSpeedMultiplier;
-
- public Movement(float halfCatcherWidth, double clockRate)
- : base()
+ public Movement(Mod[] mods)
+ : base(mods)
{
- HalfCatcherWidth = halfCatcherWidth;
-
- // In catch, clockrate adjustments do not only affect the timings of hitobjects,
- // but also the speed of the player's catcher, which has an impact on difficulty
- // TODO: Support variable clockrates caused by mods such as ModTimeRamp
- // (perhaps by using IApplicableToRate within the CatchDifficultyHitObject constructor to set a catcher speed for each object before processing)
- catcherSpeedMultiplier = clockRate;
}
- protected override double StrainValueOf(PalpableCatchHitObject catchCurrent)
+ protected override double StrainValueOf(CatchDifficultyHitObject current)
{
- lastPlayerPosition ??= catchCurrent.LastNormalizedPosition;
-
- float playerPosition = Math.Clamp(
- lastPlayerPosition.Value,
- catchCurrent.NormalizedPosition - (normalized_hitobject_radius - absolute_player_positioning_error),
- catchCurrent.NormalizedPosition + (normalized_hitobject_radius - 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), normalized_hitobject_radius * 2) / (normalized_hitobject_radius * 6) / sqrtStrain;
- }
-
- // Bonus for edge dashes.
- if (catchCurrent.lastObject != null && 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_hitobject_radius 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)
- {
- 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);
}
}
-}
\ No newline at end of file
+}
diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
new file mode 100644
index 0000000..80c11ad
--- /dev/null
+++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
@@ -0,0 +1,62 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Rulesets.Mods;
+
+namespace osu.Game.Rulesets.Difficulty
+{
+ ///
+ /// Describes the difficulty of a beatmap, as output by a .
+ ///
+ public class DifficultyAttributes
+ {
+ protected const int ATTRIB_ID_AIM = 1;
+ protected const int ATTRIB_ID_SPEED = 3;
+ protected const int ATTRIB_ID_MAX_COMBO = 9;
+ protected const int ATTRIB_ID_DIFFICULTY = 11;
+
+ ///
+ /// The mods which were applied to the beatmap.
+ ///
+ public Mod[] Mods { get; set; } = Array.Empty();
+
+ ///
+ /// The combined star rating of all skills.
+ ///
+ public double StarRating { get; set; }
+
+ ///
+ /// The maximum achievable combo.
+ ///
+ public int MaxCombo { get; set; }
+
+ ///
+ /// Creates new .
+ ///
+ public DifficultyAttributes()
+ {
+ }
+
+ ///
+ /// Creates new .
+ ///
+ /// The mods which were applied to the beatmap.
+ /// The combined star rating of all skills.
+ public DifficultyAttributes(Mod[] mods, double starRating)
+ {
+ Mods = mods;
+ StarRating = starRating;
+ }
+
+ ///
+ /// Converts this to osu-web compatible database attribute mappings.
+ ///
+ ///
+ /// See: osu_difficulty_attribs table.
+ ///
+ public virtual IEnumerable<(int attributeId, object value)> ToDatabaseAttributes()
+ {
+ yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
+ }
+ }
+}
diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
new file mode 100644
index 0000000..36c7323
--- /dev/null
+++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
@@ -0,0 +1,166 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Threading;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Skills;
+using osu.Game.Rulesets.Mods;
+
+namespace osu.Game.Rulesets.Difficulty
+{
+ ///
+ /// A beatmap difficulty calculator.
+ ///
+ public abstract class DifficultyCalculator
+ {
+ ///
+ /// The beatmap for which difficulty is being calculated.
+ ///
+ protected readonly IBeatmap Beatmap;
+
+ private readonly Mod[] mods;
+
+ protected DifficultyCalculator(Ruleset ruleset, IWorkingBeatmap beatmap)
+ {
+ mods = Array.Empty();
+
+ Beatmap = beatmap.GetPlayableBeatmap(ruleset, mods);
+ }
+
+ ///
+ /// The difficulty calculation version.
+ ///
+ ///
+ /// This is used to determine which difficulty values are outdated and need to be recalculated.
+ ///
+ public abstract int Version { get; }
+
+ ///
+ /// Mods for which the difficulty calculation is allowed to be customised by.
+ ///
+ protected abstract Mod[] DifficultyAdjustmentMods { get; }
+
+ ///
+ /// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap.
+ ///
+ /// A collection of difficulty attributes.
+ public DifficultyAttributes Calculate()
+ {
+ return Calculate(mods);
+ }
+
+ ///
+ /// Calculates the difficulty of the beatmap using a specific mod combination.
+ ///
+ /// The mods that should be applied to the beatmap.
+ /// A structure describing the difficulty of the beatmap.
+ public DifficultyAttributes Calculate(Mod[] mods)
+ {
+ return ComputeDifficultyAttributes(mods);
+ }
+
+ ///
+ /// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap.
+ ///
+ /// The cancellation token.
+ /// A collection of difficulty attributes.
+ public IEnumerable CalculateAll(CancellationToken cancellationToken = default)
+ {
+ foreach (var combination in CreateDifficultyAdjustmentModCombinations())
+ {
+ if (cancellationToken.IsCancellationRequested)
+ throw new OperationCanceledException();
+
+ yield return Calculate(combination);
+ }
+ }
+
+ ///
+ /// Computes the difficulty attributes for the beatmap.
+ ///
+ /// The mods to apply.
+ /// The difficulty attributes.
+ private DifficultyAttributes ComputeDifficultyAttributes(Mod[] mods)
+ {
+ IBeatmap beatmap = Beatmap.Clone();
+
+ foreach (var mod in mods.OfType())
+ mod.ApplyToDifficulty(beatmap.Difficulty);
+
+ // This embedded port of the difficulty framework only supports osu!catch, whose
+ // difficulty adjustment mods (EZ / HR) never change the gameplay clock rate.
+ const double clock_rate = 1.0;
+
+ if (beatmap.HitObjects.Count == 0)
+ return CreateDifficultyAttributes(beatmap, mods, Array.Empty());
+
+ var difficultyHitObjects = CreateDifficultyHitObjects(beatmap, clock_rate);
+ var skills = CreateSkills(beatmap, mods);
+
+ if (difficultyHitObjects.Count > 0)
+ {
+ foreach (var skill in skills)
+ {
+ foreach (var difficultyHitObject in difficultyHitObjects)
+ skill.Process(difficultyHitObject);
+ }
+ }
+
+ return CreateDifficultyAttributes(beatmap, mods, skills);
+ }
+
+ ///
+ /// Creates all combinations which customise the difficulty calculation.
+ ///
+ ///
+ /// All combinations will contain at least one mod from .
+ ///
+ public IEnumerable CreateDifficultyAdjustmentModCombinations()
+ {
+ Mod[] adjustmentMods = DifficultyAdjustmentMods;
+
+ if (adjustmentMods.Length == 0)
+ yield break;
+
+ // Generate all non-empty subsets of the adjustment mods (2^n - 1 combinations).
+ for (int i = 1; i < 1 << adjustmentMods.Length; i++)
+ {
+ var combination = new List();
+
+ for (int j = 0; j < adjustmentMods.Length; j++)
+ {
+ if ((i & (1 << j)) > 0)
+ combination.Add(adjustmentMods[j]);
+ }
+
+ yield return combination.ToArray();
+ }
+ }
+
+ ///
+ /// Creates the to be returned for a beatmap.
+ ///
+ /// The beatmap to create the attributes for.
+ /// The mods to apply.
+ /// The skills which processed the beatmap.
+ /// The difficulty attributes.
+ protected abstract DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills);
+
+ ///
+ /// Creates the s to be processed.
+ ///
+ /// The beatmap to create the objects for.
+ /// The rate at which the gameplay clock is run at.
+ /// The difficulty hit objects.
+ protected abstract List CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate);
+
+ ///
+ /// Creates the s to process the s with.
+ ///
+ /// The beatmap to create the skills for.
+ /// The mods to apply.
+ /// The skills.
+ protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods);
+ }
+}
diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs
new file mode 100644
index 0000000..fe04cf1
--- /dev/null
+++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs
@@ -0,0 +1,98 @@
+// 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 osu.Game.Rulesets.Objects;
+
+namespace osu.Game.Rulesets.Difficulty.Preprocessing
+{
+ ///
+ /// Wraps a and provides additional information to be used for difficulty calculation.
+ ///
+ ///
+ /// Ported from osu! (ppy/osu) for the embedded difficulty calculation framework.
+ /// The upstream hit window / HitResult members are intentionally omitted, as this project
+ /// only embeds the parts of osu! required for osu!catch star rating calculation.
+ ///
+ public class DifficultyHitObject
+ {
+ private readonly IReadOnlyList difficultyHitObjects;
+
+ ///
+ /// The index of this in the list of all s.
+ ///
+ public int Index;
+
+ ///
+ /// The this wraps.
+ ///
+ public readonly HitObject BaseObject;
+
+ ///
+ /// The last which occurs before .
+ ///
+ public readonly HitObject LastObject;
+
+ ///
+ /// Amount of time elapsed between and , adjusted by clockrate.
+ ///
+ public readonly double DeltaTime;
+
+ ///
+ /// Clockrate adjusted start time of .
+ ///
+ public readonly double StartTime;
+
+ ///
+ /// Clockrate adjusted end time of .
+ ///
+ public readonly double EndTime;
+
+ ///
+ /// Beatmap playback rate.
+ ///
+ public readonly double ClockRate;
+
+ ///
+ /// The star rating contribution of , as calculated by the strain skill which processed it.
+ ///
+ ///
+ /// This project-specific extra is used to draw the "difficulty stars" label beside hitobjects,
+ /// and is not part of the upstream osu! difficulty framework.
+ ///
+ public double DifficultyToLast;
+
+ ///
+ /// Creates a new .
+ ///
+ /// The which this wraps.
+ /// The last which occurs before in the beatmap.
+ /// The rate at which the gameplay clock is run at.
+ /// The list of s in the current beatmap.
+ /// The index of this in list.
+ public DifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int index)
+ {
+ difficultyHitObjects = objects;
+ Index = index;
+ BaseObject = hitObject;
+ LastObject = lastObject;
+ DeltaTime = (hitObject.StartTime - lastObject.StartTime) / clockRate;
+ StartTime = hitObject.StartTime / clockRate;
+ EndTime = hitObject.GetEndTime() / clockRate;
+ ClockRate = clockRate;
+ }
+
+ public DifficultyHitObject Previous(int skipCount = 0)
+ {
+ int index = Index - (skipCount + 1);
+ return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : null;
+ }
+
+ public DifficultyHitObject Next(int skipCount = 0)
+ {
+ int index = Index + (skipCount + 1);
+ return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : null;
+ }
+ }
+}
diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/Skill.cs
new file mode 100644
index 0000000..be86c81
--- /dev/null
+++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/Skill.cs
@@ -0,0 +1,53 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Mods;
+
+namespace osu.Game.Rulesets.Difficulty.Skills
+{
+ ///
+ /// A bare minimal abstract skill for fully custom skill implementations.
+ ///
+ ///
+ /// This class should be considered a "processing" class and not persisted.
+ ///
+ public abstract class Skill
+ {
+ ///
+ /// Mods for use in skill calculations.
+ ///
+ protected IReadOnlyList Mods => mods;
+
+ ///
+ /// List of calculated per-object difficulties, populated by Process
+ ///
+ protected readonly List ObjectDifficulties = new List();
+
+ private readonly Mod[] mods;
+
+ protected Skill(Mod[] mods)
+ {
+ this.mods = mods;
+ }
+
+ ///
+ /// Process a .
+ ///
+ /// The to process.
+ public virtual void Process(DifficultyHitObject current)
+ {
+ double difficultyValue = ProcessInternal(current);
+ ObjectDifficulties.Add(difficultyValue);
+ }
+
+ protected abstract double ProcessInternal(DifficultyHitObject current);
+
+ ///
+ /// Returns the calculated difficulty value representing all s that have been processed up to this point.
+ ///
+ public abstract double DifficultyValue();
+
+ public IReadOnlyList GetObjectDifficulties() => ObjectDifficulties;
+ }
+}
diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs
index bd99ac2..2208eed 100644
--- a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs
+++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs
@@ -1,7 +1,8 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Difficulty.Skills
{
@@ -9,7 +10,8 @@ namespace osu.Game.Rulesets.Difficulty.Skills
/// Used to processes strain values of s, keep track of strain levels caused by the processed objects
/// and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
///
- public abstract class StrainDecaySkill : StrainSkill
+ public abstract class StrainDecaySkill : StrainSkill
+ where TObject : DifficultyHitObject
{
///
/// Strain values are multiplied by this number for the given skill. Used to balance the value of different skills between each other.
@@ -27,14 +29,14 @@ public abstract class StrainDecaySkill : StrainSkill
///
protected double CurrentStrain { get; private set; }
- protected StrainDecaySkill()
- : base()
+ protected StrainDecaySkill(Mod[] mods)
+ : base(mods)
{
}
- protected override double CalculateInitialStrain(double time, PalpableCatchHitObject current) => CurrentStrain * strainDecay(time - ((current.lastObject == null) ? 0.0 : current.lastObject.StartTime));
+ protected override double CalculateInitialStrain(double time, TObject current) => CurrentStrain * strainDecay(time - current.Previous(0).StartTime);
- protected override double StrainValueAt(PalpableCatchHitObject current)
+ protected override double StrainValueAt(TObject current)
{
CurrentStrain *= strainDecay(current.DeltaTime);
CurrentStrain += StrainValueOf(current) * SkillMultiplier;
@@ -45,7 +47,7 @@ protected override double StrainValueAt(PalpableCatchHitObject current)
///
/// Calculates the strain value of a . This value is affected by previously processed objects.
///
- protected abstract double StrainValueOf(PalpableCatchHitObject current);
+ protected abstract double StrainValueOf(TObject current);
private double strainDecay(double ms) => Math.Pow(StrainDecayBase, ms / 1000);
}
diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs
index 71492d9..8f6af0a 100644
--- a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs
+++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs
@@ -1,7 +1,8 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Difficulty.Skills
{
@@ -9,10 +10,12 @@ namespace osu.Game.Rulesets.Difficulty.Skills
/// Used to processes strain values of s, keep track of strain levels caused by the processed objects
/// and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
///
- public abstract class StrainSkill
+ ///
+ /// The ruleset-specific implementation processed by this skill.
+ ///
+ public abstract class StrainSkill : Skill
+ where TObject : DifficultyHitObject
{
- private const double difficulty_multiplier = 4.59;
-
///
/// The weight by which each strain value decays.
///
@@ -25,37 +28,53 @@ public abstract class StrainSkill
private double currentSectionPeak; // We also keep track of the peak strain level in the current section.
private double currentSectionEnd;
+ private bool firstObjectProcessed;
private readonly List strainPeaks = new List();
- protected StrainSkill()
- : base()
+ protected StrainSkill(Mod[] mods)
+ : base(mods)
{
}
///
/// Returns the strain value at . This value is calculated with or without respect to previous objects.
///
- protected abstract double StrainValueAt(PalpableCatchHitObject current);
+ protected abstract double StrainValueAt(TObject current);
///
/// Process a and update current strain values accordingly.
///
- public void Process(PalpableCatchHitObject current)
+ public override void Process(DifficultyHitObject current)
{
- // The first object doesn't generate a strain, so we begin with an incremented section end
- if (current.CurrentCombo == 0)
- currentSectionEnd = Math.Ceiling(current.StartTime / SectionLength) * SectionLength;
+ var catchCurrent = (TObject)current;
- while (current.StartTime > currentSectionEnd)
+ // The first object doesn't generate a strain, so we begin with an incremented section end.
+ // (Upstream keys off a per-object "first object" flag set on the difficulty hit object; this
+ // embedded port tracks it here instead, as the upstream flag is not part of the reduced port.)
+ if (!firstObjectProcessed)
+ {
+ currentSectionEnd = Math.Ceiling(catchCurrent.StartTime / SectionLength) * SectionLength;
+ firstObjectProcessed = true;
+ }
+
+ while (catchCurrent.StartTime > currentSectionEnd)
{
saveCurrentPeak();
- startNewSectionFrom(currentSectionEnd, current);
+ startNewSectionFrom(currentSectionEnd, catchCurrent);
currentSectionEnd += SectionLength;
}
- double strain = StrainValueAt(current);
- current.DifficultyToLast = Math.Sqrt(strain / (1 - DecayWeight)) * difficulty_multiplier;
+ // StrainValueAt mutates the skill's internal strain state, so it must be called exactly once
+ // per object. Calling it again for the per-object bookkeeping would apply the strain decay a
+ // second time and double-count the strain value, inflating every strain peak.
+ double strain = StrainValueAt(catchCurrent);
+
+ ObjectDifficulties.Add(strain);
+
+ // The per-object star rating shown beside hitobjects in the viewer.
+ catchCurrent.DifficultyToLast = Math.Sqrt(strain / (1 - DecayWeight)) * 4.59;
+
currentSectionPeak = Math.Max(strain, currentSectionPeak);
}
@@ -72,7 +91,7 @@ private void saveCurrentPeak()
///
/// The beginning of the new section in milliseconds.
/// The current hit object.
- private void startNewSectionFrom(double time, PalpableCatchHitObject current)
+ private void startNewSectionFrom(double time, TObject current)
{
// The maximum strain of the new section is not zero by default
// This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level.
@@ -85,7 +104,7 @@ private void startNewSectionFrom(double time, PalpableCatchHitObject current)
/// The time to retrieve the peak strain at.
/// The current hit object.
/// The peak strain.
- protected abstract double CalculateInitialStrain(double time, PalpableCatchHitObject current);
+ protected abstract double CalculateInitialStrain(double time, TObject current);
///
/// Returns a live enumerable of the peak strains for each section of the beatmap,
@@ -96,7 +115,7 @@ private void startNewSectionFrom(double time, PalpableCatchHitObject current)
///
/// Returns the calculated difficulty value representing all s that have been processed up to this point.
///
- public double DifficultyValue()
+ public override double DifficultyValue()
{
double difficulty = 0;
double weight = 1;
@@ -115,5 +134,12 @@ public double DifficultyValue()
return difficulty;
}
+
+ ///
+ /// Not used: computes the strain directly so that
+ /// (which mutates the internal strain state) is invoked exactly once per object.
+ ///
+ protected override double ProcessInternal(DifficultyHitObject current)
+ => throw new NotSupportedException($"{nameof(StrainSkill)} computes strain in {nameof(Process)}.");
}
}
diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs
new file mode 100644
index 0000000..86399f9
--- /dev/null
+++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs
@@ -0,0 +1,114 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+
+namespace osu.Game.Rulesets.Difficulty.Utils
+{
+ ///
+ /// Shared mathematical helpers used by difficulty calculation.
+ ///
+ ///
+ /// Ported from osu! (ppy/osu). Only the helpers required by the osu!catch difficulty
+ /// calculation are embedded here; see upstream DiffUtils for the full set.
+ ///
+ public static class DiffUtils
+ {
+ ///
+ /// Square root of 2
+ ///
+ public const double SQRT2 = 1.4142135623730950;
+
+ ///
+ /// Converts BPM value into milliseconds
+ ///
+ /// Beats per minute
+ /// Which rhythm delimiter to use, default is 1/4
+ /// BPM converted to milliseconds
+ public static double BPMToMilliseconds(double bpm, int delimiter = 4) => 60000.0 / delimiter / bpm;
+
+ ///
+ /// Converts milliseconds value into a BPM value
+ ///
+ /// Milliseconds
+ /// Which rhythm delimiter to use, default is 1/4
+ /// Milliseconds converted to beats per minute
+ public static double MillisecondsToBPM(double ms, int delimiter = 4) => 60000.0 / (ms * delimiter);
+
+ ///
+ /// Calculates a S-shaped logistic function (https://en.wikipedia.org/wiki/Logistic_function)
+ ///
+ /// Value to calculate the function for
+ /// How much the function midpoint is offset from zero
+ /// Growth rate of the function
+ /// Maximum value returnable by the function
+ /// The output of logistic function of
+ public static double Logistic(double x, double midpointOffset, double multiplier, double maxValue = 1) => maxValue / (1 + Math.Exp(multiplier * (midpointOffset - x)));
+
+ ///
+ /// Returns the p-norm of an n-dimensional vector (https://en.wikipedia.org/wiki/Norm_(mathematics))
+ ///
+ /// The value of p to calculate the norm for.
+ /// The coefficients of the vector.
+ /// The p-norm of the vector.
+ public static double Norm(double p, params double[] values)
+ {
+ double sum = 0;
+
+ foreach (double x in values)
+ sum += Pow(x, p);
+
+ return Pow(sum, 1.0 / p);
+ }
+
+ // In actual debug testing it's very rare for a (double, double) call to end up with a rounded int value in the first place.
+ // Making an explicit overload is slightly faster than running the `switch` in such cases.
+ public static double Pow(double x, double exponent) => Math.Pow(x, exponent);
+
+ public static double Pow(double x, int exponent) => exponent switch
+ {
+ 0 => 1,
+ 1 => x,
+ 2 => x * x,
+ 3 => x * x * x,
+ 4 => x * x * x * x,
+ 5 => x * x * x * x * x, // This is the largest value used in diffcalc right now.
+ _ => Math.Pow(x, exponent)
+ };
+
+ ///
+ /// Error function (https://en.wikipedia.org/wiki/Error_function)
+ ///
+ /// Abramowitz and Stegun formula 7.1.26 approximation.
+ 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;
+
+ 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)
+ ///
+ public static double Erfc(double x) => 1 - Erf(x);
+ }
+}