/// <summary> /// Correction #2 - The Next Object /// Estimate how next object placement affects the difficulty of hitting current object. /// </summary> private static double calculateNextObjectPlacementCorrection(MovementExtractionParameters p) { if (p.NextObject == null || p.LastToCurrent.RelativeLength == 0) { return(0); } Debug.Assert(p.CurrentToNext != null); double movementLengthRatio = p.LastToCurrent.TimeDelta / p.CurrentToNext.Value.TimeDelta; double movementAngleCosine = cosineOfAngleBetweenPairs(p.LastToCurrent, p.CurrentToNext.Value); if (movementLengthRatio > movement_length_ratio_threshold) { if (p.CurrentToNext.Value.RelativeLength == 0) { return(0); } double correctionNextMoving = correction_moving_spline.Interpolate(movementAngleCosine); double movingness = SpecialFunctions.Logistic(p.CurrentToNext.Value.RelativeLength * 6 - 5) - SpecialFunctions.Logistic(-5); return(movingness * correctionNextMoving * 0.5); } if (movementLengthRatio < 1 / movement_length_ratio_threshold) { if (p.CurrentToNext.Value.RelativeLength == 0) { return(0); } return((1 - movementAngleCosine) * SpecialFunctions.Logistic((p.CurrentToNext.Value.RelativeLength * movementLengthRatio - 1.5) * 4) * 0.15); } p.CurrentObjectTemporallyCenteredBetweenNeighbours = true; // rescale CurrentToNext so that it's comparable timescale-wise to LastToCurrent var timeNormalizedNext = p.CurrentToNext.Value.RelativeVector / p.CurrentToNext.Value.TimeDelta * p.LastToCurrent.TimeDelta; // transform nextObject to coordinates anchored in lastObject double nextTransformedX = timeNormalizedNext.DotProduct(p.LastToCurrent.RelativeVector) / p.LastToCurrent.RelativeLength; double nextTransformedY = (timeNormalizedNext - nextTransformedX * p.LastToCurrent.RelativeVector / p.LastToCurrent.RelativeLength).L2Norm(); double correctionNextFlow = AngleCorrection.FLOW_NEXT.Evaluate(p.LastToCurrent.RelativeLength, nextTransformedX, nextTransformedY); double correctionNextSnap = AngleCorrection.SNAP_NEXT.Evaluate(p.LastToCurrent.RelativeLength, nextTransformedX, nextTransformedY); p.LastToNextFlowiness = SpecialFunctions.Logistic((correctionNextSnap - correctionNextFlow - 0.05) * 20); return(Math.Max(PowerMean.Of(correctionNextFlow, correctionNextSnap, -10) - 0.1, 0) * 0.5); }
/// <summary> /// Correction #1 - The Previous Object /// Estimate how second-last object placement affects the difficulty of hitting current object. /// </summary> private static double calculatePreviousObjectPlacementCorrection(MovementExtractionParameters p) { if (p.SecondLastObject == null || p.LastToCurrent.RelativeLength == 0) { return(0); } Debug.Assert(p.SecondLastToLast != null); double movementLengthRatio = p.LastToCurrent.TimeDelta / p.SecondLastToLast.Value.TimeDelta; double movementAngleCosine = cosineOfAngleBetweenPairs(p.SecondLastToLast.Value, p.LastToCurrent); if (movementLengthRatio > movement_length_ratio_threshold) { if (p.SecondLastToLast.Value.RelativeLength == 0) { return(0); } double angleCorrection = correction_moving_spline.Interpolate(movementAngleCosine); double movingness = SpecialFunctions.Logistic(p.SecondLastToLast.Value.RelativeLength * 6 - 5) - SpecialFunctions.Logistic(-5); return(movingness * angleCorrection * 1.5); } if (movementLengthRatio < 1 / movement_length_ratio_threshold) { if (p.SecondLastToLast.Value.RelativeLength == 0) { return(0); } return((1 - movementAngleCosine) * SpecialFunctions.Logistic((p.SecondLastToLast.Value.RelativeLength * movementLengthRatio - 1.5) * 4) * 0.3); } p.LastObjectTemporallyCenteredBetweenNeighbours = true; // rescale SecondLastToLast so that it's comparable timescale-wise to LastToCurrent var timeNormalisedSecondLastToLast = -p.SecondLastToLast.Value.RelativeVector / p.SecondLastToLast.Value.TimeDelta * p.LastToCurrent.TimeDelta; // transform secondLastObject to coordinates anchored in lastObject double secondLastTransformedX = timeNormalisedSecondLastToLast.DotProduct(p.LastToCurrent.RelativeVector) / p.LastToCurrent.RelativeLength; double secondLastTransformedY = (timeNormalisedSecondLastToLast - secondLastTransformedX * p.LastToCurrent.RelativeVector / p.LastToCurrent.RelativeLength).L2Norm(); double correctionSecondLastFlow = AngleCorrection.FLOW_SECONDLAST.Evaluate(p.LastToCurrent.RelativeLength, secondLastTransformedX, secondLastTransformedY); double correctionSecondLastSnap = AngleCorrection.SNAP_SECONDLAST.Evaluate(p.LastToCurrent.RelativeLength, secondLastTransformedX, secondLastTransformedY); double correctionSecondLastStop = SpecialFunctions.Logistic(10 * Math.Sqrt(secondLastTransformedX * secondLastTransformedX + secondLastTransformedY * secondLastTransformedY + 1) - 12); p.SecondLastToCurrentFlowiness = SpecialFunctions.Logistic((correctionSecondLastSnap - correctionSecondLastFlow - 0.05) * 20); return(PowerMean.Of(new[] { correctionSecondLastFlow, correctionSecondLastSnap, correctionSecondLastStop }, -10) * 1.3); }
/// <summary> /// Correction #3 - 4-object pattern /// Estimate how the whole pattern consisting of second-last to next objects affects the difficulty of hitting current object. /// This only takes effect when the pattern is not so spaced (i.e. does not contain jumps) /// </summary> private static double calculateFourObjectPatternCorrection(MovementExtractionParameters p) { if (!p.LastObjectTemporallyCenteredBetweenNeighbours || !p.CurrentObjectTemporallyCenteredBetweenNeighbours) { return(0); } Debug.Assert(p.CurrentToNext != null); Debug.Assert(p.SecondLastToLast != null); double gap = (p.LastToCurrent.RelativeVector - p.CurrentToNext.Value.RelativeVector / 2 - p.SecondLastToLast.Value.RelativeVector / 2).L2Norm() / (p.LastToCurrent.RelativeLength + 0.1); return((SpecialFunctions.Logistic((gap - 1) * 8) - SpecialFunctions.Logistic(-6)) * SpecialFunctions.Logistic((p.SecondLastToLast.Value.RelativeLength - 0.7) * 10) * SpecialFunctions.Logistic((p.CurrentToNext.Value.RelativeLength - 0.7) * 10) * PowerMean.Of(p.SecondLastToCurrentFlowiness, p.LastToNextFlowiness, 2) * 0.6); }
/// <summary> /// Calculates attributes related to tapping difficulty. /// </summary> public static TapAttributes CalculateTapAttributes(List <OsuHitObject> hitObjects, double clockRate) { var(strainHistory, tapDiff) = calculateTapStrain(hitObjects, 0, clockRate); double burstStrain = strainHistory.Max(v => v[0]); var streamnessMask = CalculateStreamnessMask(hitObjects, burstStrain, clockRate); double streamNoteCount = streamnessMask.Sum(); var(_, mashTapDiff) = calculateTapStrain(hitObjects, 1, clockRate); return(new TapAttributes { TapDifficulty = tapDiff, StreamNoteCount = streamNoteCount, MashedTapDifficulty = mashTapDiff, StrainHistory = strainHistory.Select(x => PowerMean.Of(x, 2.0)).ToArray() }); }
/// <summary> /// Calculates the strain values at each note and the maximum strain values /// </summary> private static (List <Vector <double> >, double) calculateTapStrain(List <OsuHitObject> hitObjects, double mashLevel, double clockRate) { var strainHistory = new List <Vector <double> > { Vector <double> .Build.Dense(timescale_count), Vector <double> .Build.Dense(timescale_count) }; var currStrain = Vector <double> .Build.Dense(timescale_count); // compute strain at each object and store the results into strainHistory if (hitObjects.Count >= 2) { double prevPrevTime = hitObjects[0].StartTime / 1000.0; double prevTime = hitObjects[1].StartTime / 1000.0; for (int i = 2; i < hitObjects.Count; i++) { double currTime = hitObjects[i].StartTime / 1000.0; // compute current strain after decay currStrain = currStrain.PointwiseMultiply((-decay_coeffs * (currTime - prevTime) / clockRate).PointwiseExp()); strainHistory.Add(currStrain.PointwisePower(1.1 / 3) * 1.5); double distance = (hitObjects[i].StackedPosition - hitObjects[i - 1].StackedPosition).Length / (2 * hitObjects[i].Radius); double spacedBuff = calculateSpacedness(distance) * spaced_buff_factor; double deltaTime = Math.Max((currTime - prevPrevTime) / clockRate, 0.01); // for 1/4 notes above 200 bpm the exponent is -2.7, otherwise it's -2 double strainAddition = Math.Max(Math.Pow(deltaTime, -2.7) * 0.265, Math.Pow(deltaTime, -2)); currStrain += decay_coeffs * strainAddition * Math.Pow(calculateMashNerfFactor(distance, mashLevel), 3) * Math.Pow(1 + spacedBuff, 3); prevPrevTime = prevTime; prevTime = currTime; } } // compute difficulty by aggregating strainHistory var strainResult = Vector <double> .Build.Dense(timescale_count); for (int j = 0; j < decay_coeffs.Count; j++) { double[] singleStrainHistory = new double[hitObjects.Count]; for (int i = 0; i < hitObjects.Count; i++) { singleStrainHistory[i] = strainHistory[i][j]; } Array.Sort(singleStrainHistory); Array.Reverse(singleStrainHistory); double singleStrainResult = 0; double k = 1 - 0.04 * Math.Sqrt(decay_coeffs[j]); for (int i = 0; i < hitObjects.Count; i++) { singleStrainResult += singleStrainHistory[i] * Math.Pow(k, i); } strainResult[j] = singleStrainResult * (1 - k) * timescale_factors[j]; } double diff = PowerMean.Of(strainResult, 2); return(strainHistory, diff); }
public override double Calculate(Dictionary <string, double> categoryRatings = null) { mods = Score.Mods; accuracy = Score.Accuracy; scoreMaxCombo = Score.MaxCombo; countGreat = Score.Statistics.GetOrDefault(HitResult.Great); countOk = Score.Statistics.GetOrDefault(HitResult.Ok); countMeh = Score.Statistics.GetOrDefault(HitResult.Meh); countMiss = Score.Statistics.GetOrDefault(HitResult.Miss); greatWindow = 79.5 - 6 * Attributes.OverallDifficulty; double multiplier = 2.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things // guess the number of misses + slider breaks from combo double comboBasedMissCount; if (Attributes.SliderCount == 0) { if (scoreMaxCombo < Attributes.MaxCombo) { comboBasedMissCount = (double)Attributes.MaxCombo / scoreMaxCombo; } else { comboBasedMissCount = 0; } } else { double fullComboThreshold = Attributes.MaxCombo - 0.1 * Attributes.SliderCount; if (scoreMaxCombo < fullComboThreshold) { comboBasedMissCount = fullComboThreshold / scoreMaxCombo; } else { comboBasedMissCount = Math.Pow((Attributes.MaxCombo - scoreMaxCombo) / (0.1 * Attributes.SliderCount), 3); } } effectiveMissCount = Math.Max(countMiss, comboBasedMissCount); // Custom multipliers for NoFail and SpunOut. if (mods.Any(m => m is OsuModNoFail)) { multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount); } if (mods.Any(m => m is OsuModSpunOut)) { multiplier *= 1.0 - Math.Pow((double)Attributes.SpinnerCount / totalHits, 0.85); } double aimValue = computeAimValue(); double tapValue = computeTapValue(); double accuracyValue = computeAccuracyValue(); double totalValue = PowerMean.Of(new[] { aimValue, tapValue, accuracyValue }, total_value_exponent) * multiplier; if (categoryRatings != null) { categoryRatings.Add("Aim", aimValue); categoryRatings.Add("Tap", tapValue); categoryRatings.Add("Accuracy", accuracyValue); categoryRatings.Add("OD", Attributes.OverallDifficulty); categoryRatings.Add("AR", Attributes.ApproachRate); categoryRatings.Add("Max Combo", Attributes.MaxCombo); } return(totalValue); }
private double computeAimValue() { if (Attributes.TotalObjectCount <= 1) { return(0); } // Get player's throughput according to combo int comboTpCount = Attributes.ComboThroughputs.Length; var comboPercentages = Generate.LinearSpaced(comboTpCount, 1.0 / comboTpCount, 1); double scoreComboPercentage = ((double)scoreMaxCombo) / Attributes.MaxCombo; double comboTp = LinearSpline.InterpolateSorted(comboPercentages, Attributes.ComboThroughputs) .Interpolate(scoreComboPercentage); // Get player's throughput according to miss count double missTp = LinearSpline.InterpolateSorted(Attributes.MissCounts, Attributes.MissThroughputs) .Interpolate(effectiveMissCount); missTp = Math.Max(missTp, 0); // Combine combo based throughput and miss count based throughput double tp = PowerMean.Of(comboTp, missTp, 20); // Hidden mod if (mods.Any(h => h is OsuModHidden)) { double hiddenFactor = Attributes.AimHiddenFactor; // the buff starts decreasing at AR9.75 and reaches 0 at AR10.75 if (Attributes.ApproachRate > 10.75) { hiddenFactor = 1; } else if (Attributes.ApproachRate > 9.75) { hiddenFactor = 1 + (1 - Math.Pow(Math.Sin((Attributes.ApproachRate - 9.75) * Math.PI / 2), 2)) * (hiddenFactor - 1); } tp *= hiddenFactor; } // Account for cheesing double modifiedAcc = getModifiedAcc(); double accOnCheeseNotes = 1 - (1 - modifiedAcc) * Math.Sqrt(totalHits / Attributes.CheeseNoteCount); // accOnCheeseNotes can be negative. The formula below ensures a positive acc while // preserving the value when accOnCheeseNotes is close to 1 double accOnCheeseNotesPositive = Math.Exp(accOnCheeseNotes - 1); double urOnCheeseNotes = 10 * greatWindow / (Math.Sqrt(2) * SpecialFunctions.ErfInv(accOnCheeseNotesPositive)); double cheeseLevel = SpecialFunctions.Logistic(((urOnCheeseNotes * Attributes.AimDifficulty) - 3200) / 2000); double cheeseFactor = LinearSpline.InterpolateSorted(Attributes.CheeseLevels, Attributes.CheeseFactors) .Interpolate(cheeseLevel); if (mods.Any(m => m is OsuModTouchDevice)) { tp = Math.Min(tp, 1.47 * Math.Pow(tp, 0.8)); } double aimValue = tpToPP(tp * cheeseFactor); // penalize misses aimValue *= Math.Pow(0.96, Math.Max(effectiveMissCount - miss_count_leniency, 0)); // Buff long maps aimValue *= 1 + (SpecialFunctions.Logistic((totalHits - 2800) / 500.0) - SpecialFunctions.Logistic(-2800 / 500.0)) * 0.22; // Buff very high AR and low AR double approachRateFactor = 1.0; if (Attributes.ApproachRate > 10) { approachRateFactor += (0.05 + 0.35 * Math.Pow(Math.Sin(Math.PI * Math.Min(totalHits, 1250) / 2500), 1.7)) * Math.Pow(Attributes.ApproachRate - 10, 2); } else if (Attributes.ApproachRate < 8.0) { approachRateFactor += 0.01 * (8.0 - Attributes.ApproachRate); } aimValue *= approachRateFactor; if (mods.Any(h => h is OsuModFlashlight)) { // Apply object-based bonus for flashlight. aimValue *= 1.0 + 0.35 * Math.Min(1.0, totalHits / 200.0) + (totalHits > 200 ? 0.3 * Math.Min(1.0, (totalHits - 200) / 300.0) + (totalHits > 500 ? (totalHits - 500) / 2000.0 : 0.0) : 0.0); } // Scale the aim value down with accuracy double accLeniency = greatWindow * Attributes.AimDifficulty / 300; double accPenalty = (0.09 / (accuracy - 1.3) + 0.3) * (accLeniency + 1.5); aimValue *= 0.2 + SpecialFunctions.Logistic(-((accPenalty - 0.24953) / 0.18)); return(aimValue); }
protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double clockRate) { var hitObjects = beatmap.HitObjects as List <OsuHitObject>; double mapLength = 0; if (beatmap.HitObjects.Count > 0) { mapLength = (beatmap.HitObjects.Last().StartTime - beatmap.HitObjects.First().StartTime) / 1000 / clockRate; } double preemptNoClockRate = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.ApproachRate, 1800, 1200, 450); var noteDensities = NoteDensity.Calculate(hitObjects, preemptNoClockRate); // Tap var tapAttributes = Tap.CalculateTapAttributes(hitObjects, clockRate); // Finger Control double fingerControlDiff = FingerControl.CalculateFingerControlDiff(hitObjects, clockRate); // Aim var aimAttributes = Aim.CalculateAimAttributes(hitObjects, clockRate, tapAttributes.StrainHistory, noteDensities); double tapStarRating = tap_multiplier * Math.Pow(tapAttributes.TapDifficulty, star_rating_exponent); double aimStarRating = aim_multiplier * Math.Pow(aimAttributes.FcProbabilityThroughput, star_rating_exponent); double fingerControlStarRating = finger_control_multiplier * Math.Pow(fingerControlDiff, star_rating_exponent); double combinedStarRating = PowerMean.Of(new[] { tapStarRating, aimStarRating, fingerControlStarRating }, 7) * 1.131; HitWindows hitWindows = new OsuHitWindows(); hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); // Todo: These int casts are temporary to achieve 1:1 results with osu!stable, and should be removed in the future double hitWindowGreat = (int)(hitWindows.WindowFor(HitResult.Great)) / clockRate; double preempt = (int)BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.ApproachRate, 1800, 1200, 450) / clockRate; 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); int beatmapMaxCombo = beatmap.HitObjects.Count; // Add the ticks + tail of the slider. 1 is subtracted because the "headcircle" would be counted twice (once for the slider itself in the line above) beatmapMaxCombo += beatmap.HitObjects.OfType <Slider>().Sum(s => s.NestedHitObjects.Count - 1); return(new OsuDifficultyAttributes { StarRating = combinedStarRating, Mods = mods, Length = mapLength, TapStarRating = tapStarRating, TapDifficulty = tapAttributes.TapDifficulty, StreamNoteCount = tapAttributes.StreamNoteCount, MashTapDifficulty = tapAttributes.MashedTapDifficulty, FingerControlStarRating = fingerControlStarRating, FingerControlDifficulty = fingerControlDiff, AimStarRating = aimStarRating, AimDifficulty = aimAttributes.FcProbabilityThroughput, AimHiddenFactor = aimAttributes.HiddenFactor, ComboThroughputs = aimAttributes.ComboThroughputs, MissThroughputs = aimAttributes.MissThroughputs, MissCounts = aimAttributes.MissCounts, CheeseNoteCount = aimAttributes.CheeseNoteCount, CheeseLevels = aimAttributes.CheeseLevels, CheeseFactors = aimAttributes.CheeseFactors, ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5, OverallDifficulty = (80 - hitWindowGreat) / 6, MaxCombo = beatmapMaxCombo, TotalObjectCount = beatmap.HitObjects.Count, HitCircleCount = hitCirclesCount, SliderCount = sliderCount, SpinnerCount = spinnerCount }); }