use std::{cmp, f64::consts::PI};
use crate::{
GameMods,
osu::{
OsuDifficultyAttributes, OsuPerformanceAttributes, OsuScoreState,
difficulty::{
rating::OsuRatingCalculator,
skills::{aim::Aim, flashlight::Flashlight, speed::Speed, strain::OsuStrainSkill},
},
legacy_score_miss_calc::OsuLegacyScoreMissCalculator,
},
util::{
difficulty::{erf, erf_inv, logistic, reverse_lerp, smoothstep},
float_ext::FloatExt,
},
};
pub const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14;
pub(super) struct OsuPerformanceCalculator<'mods> {
attrs: OsuDifficultyAttributes,
mods: &'mods GameMods,
acc: f64,
state: OsuScoreState,
using_classic_slider_acc: bool,
}
impl<'a> OsuPerformanceCalculator<'a> {
pub const fn new(
attrs: OsuDifficultyAttributes,
mods: &'a GameMods,
acc: f64,
state: OsuScoreState,
using_classic_slider_acc: bool,
) -> Self {
Self {
attrs,
mods,
acc,
state,
using_classic_slider_acc,
}
}
}
impl OsuPerformanceCalculator<'_> {
pub fn calculate(self) -> OsuPerformanceAttributes {
let total_hits = self.state.hitresults.total_hits();
if total_hits == 0 {
return OsuPerformanceAttributes {
difficulty: self.attrs,
..Default::default()
};
}
let acc = self.acc;
let state = &self.state;
let attrs = &self.attrs;
let mods = self.mods;
let using_classic_slider_acc = self.using_classic_slider_acc;
let combo_based_estimated_miss_count = self.calculate_combo_based_estimated_miss_count();
let mut score_based_estimated_miss_count = None;
let mut effective_miss_count = if using_classic_slider_acc
&& state.legacy_total_score.is_some()
{
let legacy_score_miss_calc = OsuLegacyScoreMissCalculator::new(state, acc, mods, attrs);
*score_based_estimated_miss_count.insert(legacy_score_miss_calc.calculate())
} else {
combo_based_estimated_miss_count
};
effective_miss_count = effective_miss_count.max(f64::from(state.hitresults.misses));
effective_miss_count = effective_miss_count.min(f64::from(state.hitresults.total_hits()));
let total_hits = f64::from(total_hits);
let mut multiplier = PERFORMANCE_BASE_MULTIPLIER;
if self.mods.nf() {
multiplier *= (1.0 - 0.02 * effective_miss_count).max(0.9);
}
if self.mods.so() && total_hits > 0.0 {
multiplier *= 1.0 - (f64::from(self.attrs.n_spinners) / total_hits).powf(0.85);
}
if self.mods.rx() {
let od = self.attrs.od();
let (n100_mult, n50_mult) = if od > 0.0 {
(
0.75 * (1.0 - od / 13.33).max(0.0),
(1.0 - (od / 13.33).powf(5.0)).max(0.0),
)
} else {
(1.0, 1.0)
};
effective_miss_count = (effective_miss_count
+ f64::from(self.state.hitresults.n100) * n100_mult
+ f64::from(self.state.hitresults.n50) * n50_mult)
.min(total_hits);
}
let speed_deviation = self.calculate_speed_deviation();
let mut aim_estimated_slider_breaks = 0.0;
let mut speed_estimated_slider_breaks = 0.0;
let aim_value =
self.compute_aim_value(effective_miss_count, &mut aim_estimated_slider_breaks);
let speed_value = self.compute_speed_value(
speed_deviation,
effective_miss_count,
&mut speed_estimated_slider_breaks,
);
let acc_value = self.compute_accuracy_value();
let flashlight_value = self.compute_flashlight_value(effective_miss_count);
let pp = (aim_value.powf(1.1)
+ speed_value.powf(1.1)
+ acc_value.powf(1.1)
+ flashlight_value.powf(1.1))
.powf(1.0 / 1.1)
* multiplier;
OsuPerformanceAttributes {
difficulty: self.attrs,
pp_acc: acc_value,
pp_aim: aim_value,
pp_flashlight: flashlight_value,
pp_speed: speed_value,
pp,
effective_miss_count,
speed_deviation,
combo_based_estimated_miss_count,
score_based_estimated_miss_count,
aim_estimated_slider_breaks,
speed_estimated_slider_breaks,
}
}
fn compute_aim_value(
&self,
effective_miss_count: f64,
aim_estimated_slider_breaks: &mut f64,
) -> f64 {
if self.mods.ap() {
return 0.0;
}
let mut aim_difficulty = self.attrs.aim;
if self.attrs.n_sliders > 0 && self.attrs.aim_difficult_slider_count > 0.0 {
let estimate_improperly_followed_difficult_sliders = if self.using_classic_slider_acc {
let maximum_possible_dropped_sliders = self.total_imperfect_hits();
f64::clamp(
f64::min(
maximum_possible_dropped_sliders,
f64::from(self.attrs.max_combo - self.state.max_combo),
),
0.0,
self.attrs.aim_difficult_slider_count,
)
} else {
f64::clamp(
f64::from(self.n_slider_ends_dropped() + self.n_large_tick_miss()),
0.0,
self.attrs.aim_difficult_slider_count,
)
};
let slider_nerf_factor = (1.0 - self.attrs.slider_factor)
* f64::powf(
1.0 - estimate_improperly_followed_difficult_sliders
/ self.attrs.aim_difficult_slider_count,
3.0,
)
+ self.attrs.slider_factor;
aim_difficulty *= slider_nerf_factor;
}
let mut aim_value = Aim::difficulty_to_performance(aim_difficulty);
let total_hits = self.total_hits();
let len_bonus = 0.95
+ 0.4 * (total_hits / 2000.0).min(1.0)
+ f64::from(u8::from(total_hits > 2000.0)) * (total_hits / 2000.0).log10() * 0.5;
aim_value *= len_bonus;
if effective_miss_count > 0.0 {
*aim_estimated_slider_breaks = self.calculate_estimated_slider_breaks(
self.attrs.aim_top_weighted_slider_factor,
effective_miss_count,
);
let relevant_miss_count = (effective_miss_count + *aim_estimated_slider_breaks)
.min(self.total_imperfect_hits() + f64::from(self.n_large_tick_miss()));
aim_value *= Self::calculate_miss_penalty(
relevant_miss_count,
self.attrs.aim_difficult_strain_count,
);
}
if self.mods.bl() {
aim_value *= 1.3
+ (total_hits
* (0.0016 / (1.0 + 2.0 * effective_miss_count))
* self.acc.powf(16.0))
* (1.0 - 0.003 * self.attrs.hp * self.attrs.hp);
} else if self.mods.tc() {
aim_value *= 1.0
+ OsuRatingCalculator::calculate_visibility_bonus(
self.mods,
self.attrs.ar,
Some(self.attrs.slider_factor),
None,
);
}
aim_value *= self.acc;
aim_value
}
fn compute_speed_value(
&self,
speed_deviation: Option<f64>,
effective_miss_count: f64,
speed_estimated_slider_breaks: &mut f64,
) -> f64 {
let Some(speed_deviation) = speed_deviation.filter(|_| !self.mods.rx()) else {
return 0.0;
};
let mut speed_value = Speed::difficulty_to_performance(self.attrs.speed);
let total_hits = self.total_hits();
let len_bonus = 0.95
+ 0.4 * (total_hits / 2000.0).min(1.0)
+ f64::from(u8::from(total_hits > 2000.0)) * (total_hits / 2000.0).log10() * 0.5;
speed_value *= len_bonus;
if effective_miss_count > 0.0 {
*speed_estimated_slider_breaks = self.calculate_estimated_slider_breaks(
self.attrs.speed_top_weighted_slider_factor,
effective_miss_count,
);
let relevant_miss_count = (effective_miss_count + *speed_estimated_slider_breaks)
.min(self.total_imperfect_hits() + f64::from(self.n_large_tick_miss()));
speed_value *= Self::calculate_miss_penalty(
relevant_miss_count,
self.attrs.speed_difficult_strain_count,
);
}
if self.mods.bl() {
speed_value *= 1.12;
} else if self.mods.tc() {
speed_value *= 1.0
+ OsuRatingCalculator::calculate_visibility_bonus(
self.mods,
self.attrs.ar,
None,
None,
);
}
let speed_high_deviation_mult = self.calculate_speed_high_deviation_nerf(speed_deviation);
speed_value *= speed_high_deviation_mult;
let relevant_total_diff = f64::max(0.0, total_hits - self.attrs.speed_note_count);
let hitresults = &self.state.hitresults;
let relevant_n300 = (f64::from(hitresults.n300) - relevant_total_diff).max(0.0);
let relevant_n100 = (f64::from(hitresults.n100)
- (relevant_total_diff - f64::from(hitresults.n300)).max(0.0))
.max(0.0);
let relevant_n50 = (f64::from(hitresults.n50)
- (relevant_total_diff - f64::from(hitresults.n300 + hitresults.n100)).max(0.0))
.max(0.0);
let relevant_acc = if self.attrs.speed_note_count.eq(0.0) {
0.0
} else {
(relevant_n300 * 6.0 + relevant_n100 * 2.0 + relevant_n50)
/ (self.attrs.speed_note_count * 6.0)
};
let od = self.attrs.od();
speed_value *= f64::powf((self.acc + relevant_acc) / 2.0, (14.5 - od) / 2.0);
speed_value
}
fn compute_accuracy_value(&self) -> f64 {
if self.mods.rx() {
return 0.0;
}
let mut amount_hit_objects_with_acc = self.attrs.n_circles;
if !self.using_classic_slider_acc {
amount_hit_objects_with_acc += self.attrs.n_sliders;
}
let hitresults = &self.state.hitresults;
let mut better_acc_percentage = if amount_hit_objects_with_acc > 0 {
f64::from(
(hitresults.n300 as i32
- (cmp::max(
hitresults.total_hits() as i32 - amount_hit_objects_with_acc as i32,
0,
)))
* 6
+ hitresults.n100 as i32 * 2
+ hitresults.n50 as i32,
) / f64::from(amount_hit_objects_with_acc * 6)
} else {
0.0
};
if better_acc_percentage < 0.0 {
better_acc_percentage = 0.0;
}
let mut acc_value =
1.52163_f64.powf(self.attrs.od()) * better_acc_percentage.powf(24.0) * 2.83;
acc_value *= (f64::from(amount_hit_objects_with_acc) / 1000.0)
.powf(0.3)
.min(1.15);
if self.mods.bl() {
acc_value *= 1.14;
} else if self.mods.hd() || self.mods.tc() {
acc_value *= 1.0 + 0.08 * reverse_lerp(self.attrs.ar, 11.5, 10.0);
}
if self.mods.fl() {
acc_value *= 1.02;
}
acc_value
}
fn compute_flashlight_value(&self, effective_miss_count: f64) -> f64 {
if !self.mods.fl() {
return 0.0;
}
let mut flashlight_value = Flashlight::difficulty_to_performance(self.attrs.flashlight);
let total_hits = self.total_hits();
if effective_miss_count > 0.0 {
flashlight_value *= 0.97
* (1.0 - (effective_miss_count / total_hits).powf(0.775))
.powf(effective_miss_count.powf(0.875));
}
flashlight_value *= self.get_combo_scaling_factor();
flashlight_value *= 0.5 + self.acc / 2.0;
flashlight_value
}
fn calculate_combo_based_estimated_miss_count(&self) -> f64 {
let Self {
state,
attrs,
using_classic_slider_acc,
..
} = self;
if attrs.n_sliders == 0 {
return f64::from(state.hitresults.misses);
}
let mut miss_count = f64::from(state.hitresults.misses);
if *using_classic_slider_acc {
let full_combo_threshold =
f64::from(attrs.max_combo) - 0.1 * f64::from(attrs.n_sliders);
if f64::from(state.max_combo) < full_combo_threshold {
miss_count = full_combo_threshold / f64::from(state.max_combo).max(1.0);
}
miss_count = miss_count.min(self.total_imperfect_hits());
let max_possible_slider_breaks = cmp::min(
attrs.n_sliders,
(attrs.max_combo.saturating_sub(state.max_combo)) / 2,
);
let slider_breaks = miss_count - f64::from(state.hitresults.misses);
if slider_breaks > f64::from(max_possible_slider_breaks) {
miss_count = f64::from(state.hitresults.misses + max_possible_slider_breaks);
}
} else {
let full_combo_threshold = f64::from(attrs.max_combo - self.n_slider_ends_dropped());
if f64::from(state.max_combo) < full_combo_threshold {
miss_count = full_combo_threshold / f64::from(state.max_combo).max(1.0);
}
miss_count = miss_count.min(f64::from(
self.n_large_tick_miss() + state.hitresults.misses,
));
}
miss_count
}
fn calculate_estimated_slider_breaks(
&self,
top_weighted_slider_factor: f64,
effective_miss_count: f64,
) -> f64 {
let Self {
attrs,
state,
using_classic_slider_acc,
..
} = self;
if !using_classic_slider_acc || state.hitresults.n100 == 0 {
return 0.0;
}
let missed_combo_percent = 1.0 - f64::from(state.max_combo) / f64::from(attrs.max_combo);
let mut estimated_slider_breaks = (effective_miss_count * top_weighted_slider_factor)
.min(f64::from(state.hitresults.n100));
let ok_adjustment = ((f64::from(state.hitresults.n100) - estimated_slider_breaks) + 0.5)
/ f64::from(state.hitresults.n100);
estimated_slider_breaks *= smoothstep(effective_miss_count, 1.0, 2.0);
estimated_slider_breaks * ok_adjustment * logistic(missed_combo_percent, 0.33, 15.0, None)
}
fn calculate_speed_deviation(&self) -> Option<f64> {
if self.total_successful_hits() == 0 {
return None;
}
let hitresults = &self.state.hitresults;
let mut speed_note_count = self.attrs.speed_note_count;
speed_note_count +=
(f64::from(hitresults.total_hits()) - self.attrs.speed_note_count) * 0.1;
let relevant_count_miss = f64::min(f64::from(hitresults.misses), speed_note_count);
let relevant_count_meh = f64::min(
f64::from(hitresults.n50),
speed_note_count - relevant_count_miss,
);
let relevant_count_ok = f64::min(
f64::from(hitresults.n100),
speed_note_count - relevant_count_miss - relevant_count_meh,
);
let relevant_count_great = f64::max(
0.0,
speed_note_count - relevant_count_miss - relevant_count_meh - relevant_count_ok,
);
self.calculate_deviation(relevant_count_great, relevant_count_ok, relevant_count_meh)
}
fn calculate_deviation(
&self,
relevant_count_great: f64,
relevant_count_ok: f64,
relevant_count_meh: f64,
) -> Option<f64> {
if relevant_count_great + relevant_count_ok + relevant_count_meh <= 0.0 {
return None;
}
let n = f64::max(1.0, relevant_count_great + relevant_count_ok);
let p = relevant_count_great / n;
#[expect(
clippy::items_after_statements,
clippy::unreadable_literal,
reason = "staying in-sync with lazer"
)]
const Z: f64 = 2.32634787404;
let p_lower_bound = ((n * p + Z * Z / 2.0) / (n + Z * Z)
- Z / (n + Z * Z) * f64::sqrt(n * p * (1.0 - p) + Z * Z / 4.0))
.min(p);
let great_hit_window: f64 = self.attrs.great_hit_window;
let ok_hit_window: f64 = self.attrs.ok_hit_window;
let meh_hit_window: f64 = self.attrs.meh_hit_window;
let mut deviation;
if p_lower_bound > 0.01 {
deviation = great_hit_window / (f64::sqrt(2.0) * erf_inv(p_lower_bound));
let ok_hit_window_tail_amount = f64::sqrt(2.0 / PI)
* ok_hit_window
* f64::exp(-0.5 * f64::powf(ok_hit_window / deviation, 2.0))
/ (deviation * erf(ok_hit_window / (f64::sqrt(2.0) * deviation)));
deviation *= f64::sqrt(1.0 - ok_hit_window_tail_amount);
} else {
deviation = ok_hit_window / f64::sqrt(3.0);
}
let meh_variance = (meh_hit_window * meh_hit_window
+ ok_hit_window * meh_hit_window
+ ok_hit_window * ok_hit_window)
/ 3.0;
let deviation = f64::sqrt(
((relevant_count_great + relevant_count_ok) * f64::powf(deviation, 2.0)
+ relevant_count_meh * meh_variance)
/ (relevant_count_great + relevant_count_ok + relevant_count_meh),
);
Some(deviation)
}
fn calculate_speed_high_deviation_nerf(&self, speed_deviation: f64) -> f64 {
let speed_value = Speed::difficulty_to_performance(self.attrs.speed);
let excess_speed_difficulty_cutoff = 100.0 + 220.0 * f64::powf(22.0 / speed_deviation, 6.5);
if speed_value <= excess_speed_difficulty_cutoff {
return 1.0;
}
#[expect(clippy::items_after_statements, reason = "staying in-sync with lazer")]
const SCALE: f64 = 50.0;
let mut adjusted_speed_value = SCALE
* (f64::ln((speed_value - excess_speed_difficulty_cutoff) / SCALE + 1.0)
+ excess_speed_difficulty_cutoff / SCALE);
let lerp = 1.0 - reverse_lerp(speed_deviation, 22.0, 27.0);
adjusted_speed_value = f64::lerp(adjusted_speed_value, speed_value, lerp);
adjusted_speed_value / speed_value
}
fn calculate_miss_penalty(miss_count: f64, diff_strain_count: f64) -> f64 {
0.96 / ((miss_count / (4.0 * diff_strain_count.ln().powf(0.94))) + 1.0)
}
fn get_combo_scaling_factor(&self) -> f64 {
if self.attrs.max_combo == 0 {
1.0
} else {
(f64::from(self.state.max_combo).powf(0.8) / f64::from(self.attrs.max_combo).powf(0.8))
.min(1.0)
}
}
const fn total_hits(&self) -> f64 {
self.state.hitresults.total_hits() as f64
}
const fn total_successful_hits(&self) -> u32 {
self.state.hitresults.n300 + self.state.hitresults.n100 + self.state.hitresults.n50
}
fn total_imperfect_hits(&self) -> f64 {
f64::from(
self.state.hitresults.n100 + self.state.hitresults.n50 + self.state.hitresults.misses,
)
}
const fn n_slider_ends_dropped(&self) -> u32 {
self.attrs.n_sliders - self.state.hitresults.slider_end_hits
}
const fn n_large_tick_miss(&self) -> u32 {
if self.using_classic_slider_acc {
0
} else {
self.attrs.n_large_ticks - self.state.hitresults.large_tick_hits
}
}
}