use super::DifficultyAttributes;
use crate::{Beatmap, Mods, PpRaw, PpResult, StarResult};
#[derive(Clone, Debug)]
#[allow(clippy::upper_case_acronyms)]
pub struct OsuPP<'m> {
pub map: &'m Beatmap,
pub attributes: Option<DifficultyAttributes>,
pub mods: u32,
pub combo: Option<usize>,
pub acc: Option<f32>,
pub n300: Option<usize>,
pub n100: Option<usize>,
pub n50: Option<usize>,
pub n_misses: usize,
pub passed_objects: Option<usize>,
}
impl<'m> OsuPP<'m> {
#[inline]
pub fn new(map: &'m Beatmap) -> Self {
Self {
map,
attributes: None,
mods: 0,
combo: None,
acc: None,
n300: None,
n100: None,
n50: None,
n_misses: 0,
passed_objects: None,
}
}
#[inline]
pub fn attributes(mut self, attributes: impl OsuAttributeProvider) -> Self {
if let Some(attributes) = attributes.attributes() {
self.attributes.replace(attributes);
}
self
}
#[inline]
pub fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
#[inline]
pub fn combo(mut self, combo: usize) -> Self {
self.combo.replace(combo);
self
}
#[inline]
pub fn n300(mut self, n300: usize) -> Self {
self.n300.replace(n300);
self
}
#[inline]
pub fn n100(mut self, n100: usize) -> Self {
self.n100.replace(n100);
self
}
#[inline]
pub fn n50(mut self, n50: usize) -> Self {
self.n50.replace(n50);
self
}
#[inline]
pub fn misses(mut self, n_misses: usize) -> Self {
self.n_misses = n_misses;
self
}
#[inline]
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects.replace(passed_objects);
self
}
pub fn accuracy(mut self, acc: f32) -> Self {
self.set_accuracy(acc);
self
}
#[inline(always)]
pub fn set_accuracy(&mut self, acc: f32) {
let n_objects = self
.passed_objects
.unwrap_or_else(|| self.map.hit_objects.len());
let acc = acc / 100.0;
if self.n100.or(self.n50).is_some() {
let mut n100 = self.n100.unwrap_or(0);
let mut n50 = self.n50.unwrap_or(0);
let placed_points = 2 * n100 + n50 + self.n_misses;
let missing_objects = n_objects - n100 - n50 - self.n_misses;
let missing_points =
((6.0 * acc * n_objects as f32).round() as usize).saturating_sub(placed_points);
let mut n300 = missing_objects.min(missing_points / 6);
n50 += missing_objects - n300;
if let Some(orig_n50) = self.n50.filter(|_| self.n100.is_none()) {
let difference = n50 - orig_n50;
let n = n300.min(difference / 4);
n300 -= n;
n100 += 5 * n;
n50 -= 4 * n;
}
self.n300.replace(n300);
self.n100.replace(n100);
self.n50.replace(n50);
} else {
let misses = self.n_misses.min(n_objects);
let target_total = (acc * n_objects as f32 * 6.0).round() as usize;
let delta = target_total - (n_objects - misses);
let mut n300 = delta / 5;
let mut n100 = (delta % 5).min(n_objects - n300 - misses);
let mut n50 = n_objects - n300 - n100 - misses;
let n = n300.min(n50 / 4);
n300 -= n;
n100 += 5 * n;
n50 -= 4 * n;
self.n300.replace(n300);
self.n100.replace(n100);
self.n50.replace(n50);
}
let acc = (6 * self.n300.unwrap() + 2 * self.n100.unwrap() + self.n50.unwrap()) as f32
/ (6 * n_objects) as f32;
self.acc.replace(acc);
}
fn assert_hitresults(&mut self) {
if self.acc.is_none() {
let n_objects = self
.passed_objects
.unwrap_or_else(|| self.map.hit_objects.len());
let remaining = n_objects
.saturating_sub(self.n300.unwrap_or(0))
.saturating_sub(self.n100.unwrap_or(0))
.saturating_sub(self.n50.unwrap_or(0))
.saturating_sub(self.n_misses);
if remaining > 0 {
if self.n300.is_none() {
self.n300.replace(remaining);
} else if self.n100.is_none() {
self.n100.replace(remaining);
} else if self.n50.is_none() {
self.n50.replace(remaining);
} else {
*self.n300.as_mut().unwrap() += remaining;
}
}
let n300 = *self.n300.get_or_insert(0);
let n100 = *self.n100.get_or_insert(0);
let n50 = *self.n50.get_or_insert(0);
let numerator = n300 * 6 + n100 * 2 + n50;
self.acc.replace(numerator as f32 / n_objects as f32 / 6.0);
}
}
#[cfg(feature = "no_leniency")]
pub fn calculate(&mut self) -> PpResult {
self.calculate_with_func(super::no_leniency::stars)
}
#[cfg(feature = "no_sliders_no_leniency")]
pub fn calculate(&mut self) -> PpResult {
self.calculate_with_func(super::no_sliders_no_leniency::stars)
}
#[cfg(feature = "all_included")]
pub fn calculate(&mut self) -> PpResult {
self.calculate_with_func(super::all_included::stars)
}
#[cfg(not(any(
feature = "no_leniency",
feature = "no_sliders_no_leniency",
feature = "all_included"
)))]
pub(crate) fn calculate(&self) -> PpResult {
unreachable!()
}
#[inline]
pub async fn calculate_async(&mut self) -> PpResult {
self.calculate()
}
fn calculate_with_func(
&mut self,
stars_func: impl FnOnce(&Beatmap, u32, Option<usize>) -> StarResult,
) -> PpResult {
if self.attributes.is_none() {
let attributes = stars_func(self.map, self.mods, self.passed_objects)
.attributes()
.unwrap();
self.attributes.replace(attributes);
}
self.assert_hitresults();
let total_hits = self.total_hits() as f32;
let mut multiplier = 1.12;
if self.mods.nf() {
multiplier *= (1.0 - 0.02 * self.n_misses as f32).max(0.9);
}
if self.mods.so() {
let n_spinners = self.attributes.as_ref().unwrap().n_spinners;
multiplier *= 1.0 - (n_spinners as f32 / total_hits).powf(0.85);
}
let aim_value = self.compute_aim_value(total_hits);
let speed_value = self.compute_speed_value(total_hits);
let acc_value = self.compute_accuracy_value(total_hits);
let pp = (aim_value.powf(1.1) + speed_value.powf(1.1) + acc_value.powf(1.1))
.powf(1.0 / 1.1)
* multiplier;
let attributes = StarResult::Osu(self.attributes.clone().unwrap());
PpResult {
mode: 0,
mods: self.mods,
pp,
raw: PpRaw::new(
Some(aim_value),
Some(speed_value),
None,
Some(acc_value),
pp,
),
attributes,
}
}
fn compute_aim_value(&self, total_hits: f32) -> f32 {
let attributes = self.attributes.as_ref().unwrap();
let raw_aim = if self.mods.td() {
attributes.aim_strain.powf(0.8)
} else {
attributes.aim_strain
};
let mut aim_value = (5.0 * (raw_aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let len_bonus = 0.95
+ 0.4 * (total_hits / 2000.0).min(1.0)
+ (total_hits > 2000.0) as u8 as f32 * 0.5 * (total_hits / 2000.0).log10();
aim_value *= len_bonus;
#[cfg(not(feature = "ppysb_edition"))]
if self.n_misses > 0 {
aim_value *= 0.97
* (1.0 - (self.n_misses as f32 / total_hits).powf(0.775))
.powi(self.n_misses as i32);
}
#[cfg(feature = "ppysb_edition")]
if self.n_misses > 0 {
let n50 = self.n50.unwrap_or(0);
if self.mods.rx() && n50 > 0 {
aim_value *= 0.97
* (1.0 - (self.n_misses as f32 / total_hits).powf(0.775))
.powf(self.n_misses as f32 + (n50 as f32 * 0.35));
} else {
aim_value *= 0.97
* (1.0 - (self.n_misses as f32 / total_hits).powf(0.775))
.powi(self.n_misses as i32);
}
}
if let Some(combo) = self.combo.filter(|_| attributes.max_combo > 0) {
aim_value *= ((combo as f32 / attributes.max_combo as f32).powf(0.8)).min(1.0);
}
#[cfg(not(feature = "ppysb_edition"))]
let ar_bonus = {
let ar_factor = if attributes.ar > 10.33 {
attributes.ar - 10.33
} else if attributes.ar < 8.0 {
0.025 * (8.0 - attributes.ar)
} else {
0.0
};
let ar_total_hits_factor = (1.0 + (-(0.007 * (total_hits - 400.0))).exp()).recip();
1.0 + (0.03 + 0.37 * ar_total_hits_factor) * ar_factor
};
#[cfg(feature = "ppysb_edition")]
let ar_bonus = {
if self.mods.rx() {
if attributes.ar > 10.67 {
1.0 + (attributes.ar.powf(1.75) * 0.0005 * (total_hits - 600.0)).min(0.2)
} else if attributes.ar < 9.5 {
1.0 + (0.05 * (9.5 - attributes.ar.powf(1.75)) * 0.0005 * (total_hits - 600.0))
.min(0.2)
} else {
0.0
}
} else {
let ar_factor = if attributes.ar > 10.33 {
attributes.ar - 10.33
} else if attributes.ar < 8.0 {
0.025 * (8.0 - attributes.ar)
} else {
0.0
};
let ar_total_hits_factor = (1.0 + (-(0.007 * (total_hits - 400.0))).exp()).recip();
1.0 + (0.03 + 0.37 * ar_total_hits_factor) * ar_factor
}
};
#[cfg(not(feature = "ppysb_edition"))]
if self.mods.hd() {
aim_value *= 1.0 + 0.04 * (12.0 - attributes.ar);
}
#[cfg(feature = "ppysb_edition")]
if self.mods.hd() {
if self.mods.rx() {
aim_value *= 1.0 + 0.05 * (11.5 - attributes.ar)
} else {
aim_value *= 1.0 + 0.04 * (12.0 - attributes.ar);
}
}
let fl_bonus = if self.mods.fl() {
1.0 + 0.35 * (total_hits / 200.0).min(1.0)
+ (total_hits > 200.0) as u8 as f32 * 0.3 * ((total_hits - 200.0) / 300.0).min(1.0)
+ (total_hits > 500.0) as u8 as f32 * (total_hits - 500.0) / 1200.0
} else {
1.0
};
aim_value *= ar_bonus.max(fl_bonus);
#[cfg(not(feature = "ppysb_edition"))]
{
aim_value *= 0.5 + self.acc.unwrap_or(0.0) / 2.0;
aim_value *= 0.98 + attributes.od * attributes.od / 2500.0;
}
#[cfg(feature = "ppysb_edition")]
if self.mods.rx() {
let acc = self.acc.unwrap_or(0.0);
aim_value *= 0.25 + acc / (1.0 + (1.0 / 3.0));
if attributes.od > 10.0 {
aim_value *= 1.0 + (10.0 - attributes.od).powf(2.0) / 25.0
};
aim_value *= 0.6 + acc.powf(4.0) / 2.0
} else {
aim_value *= 0.5 + self.acc.unwrap_or(0.0) / 2.0;
aim_value *= 0.98 + attributes.od * attributes.od / 2500.0;
}
#[cfg(feature = "ppysb_edition")]
if self.mods.rx() {
let slider_total_combo =
attributes.max_combo - attributes.n_circles - attributes.n_spinners;
let slider_combo_percentage =
(slider_total_combo as f32) / (attributes.max_combo as f32);
let combo_per_slider = slider_total_combo as f32 / self.map.n_sliders as f32;
aim_value *= if slider_combo_percentage > 0.5 && combo_per_slider < 2.1 {
1.0 + ((slider_combo_percentage * 100.0 - 50.0).powf(0.3)
* (1.5 / ((combo_per_slider - 2.0) * 10.0)).powf(0.5))
/ 10.0
* 1.1
} else {
1.05
}
.min(1.4);
}
#[cfg(feature = "ppysb_edition")]
if self.mods.ap() {
aim_value *= 0.2;
}
#[cfg(all(feature = "relax_nerf", not(feature = "ppysb_edition")))]
if self.mods.rx() {
aim_value *= 0.9;
} else if self.mods.ap() {
aim_value *= 0.3;
}
aim_value
}
fn compute_speed_value(&self, total_hits: f32) -> f32 {
let attributes = self.attributes.as_ref().unwrap();
let mut speed_value =
(5.0 * (attributes.speed_strain / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let len_bonus = 0.95
+ 0.4 * (total_hits / 2000.0).min(1.0)
+ (total_hits > 2000.0) as u8 as f32 * 0.5 * (total_hits / 2000.0).log10();
speed_value *= len_bonus;
if self.n_misses > 0 {
speed_value *= 0.97
* (1.0 - (self.n_misses as f32 / total_hits).powf(0.775))
.powf((self.n_misses as f32).powf(0.875));
}
if let Some(combo) = self.combo.filter(|_| attributes.max_combo > 0) {
speed_value *= ((combo as f32 / attributes.max_combo as f32).powf(0.8)).min(1.0);
}
let ar_factor = if attributes.ar > 10.33 {
attributes.ar - 10.33
} else {
0.0
};
let ar_total_hits_factor = (1.0 + (-(0.007 * (total_hits - 400.0))).exp()).recip();
speed_value *= 1.0 + (0.03 + 0.37 * ar_total_hits_factor) * ar_factor;
if self.mods.hd() {
speed_value *= 1.0 + 0.04 * (12.0 - attributes.ar);
}
let od_factor = 0.95 + attributes.od * attributes.od / 750.0;
let acc_factor = self
.acc
.unwrap()
.powf((14.5 - attributes.od.max(8.0)) / 2.0);
speed_value *= od_factor * acc_factor;
speed_value *= 0.98_f32.powf(
(self.n50.unwrap_or(0) as f32 >= total_hits / 500.0) as u8 as f32
* (self.n50.unwrap_or(0) as f32 - total_hits / 500.0),
);
#[cfg(all(feature = "relax_nerf", not(feature = "ppysb_edition")))]
if self.mods.rx() {
speed_value *= 0.3;
} else if self.mods.ap() {
speed_value *= 0.9;
}
#[cfg(feature = "ppysb_edition")]
if self.mods.rx() {
speed_value *= 0.2;
} else if self.mods.ap() {
speed_value *= 1.345;
}
speed_value
}
fn compute_accuracy_value(&self, total_hits: f32) -> f32 {
let attributes = self.attributes.as_ref().unwrap();
let n_circles = attributes.n_circles as f32;
let n300 = self.n300.unwrap_or(0) as f32;
let n100 = self.n100.unwrap_or(0) as f32;
let n50 = self.n50.unwrap_or(0) as f32;
let better_acc_percentage = (n_circles > 0.0) as u8 as f32
* (((n300 - (total_hits - n_circles)) * 6.0 + n100 * 2.0 + n50) / (n_circles * 6.0))
.max(0.0);
#[cfg(not(feature = "ppysb_edition"))]
let mut acc_value = 1.52163_f32.powf(attributes.od) * better_acc_percentage.powi(24) * 2.83;
#[cfg(feature = "ppysb_edition")]
let mut acc_value = 1.52163_f32.powf(attributes.od)
* better_acc_percentage.powi(if self.mods.rx() { 28 } else { 24 })
* 2.83;
acc_value *= ((n_circles as f32 / 1000.0).powf(0.3)).min(1.15);
if self.mods.hd() {
acc_value *= 1.08;
}
if self.mods.fl() {
acc_value *= 1.02;
}
#[cfg(feature = "ppysb_edition")]
if self.mods.ap() {
acc_value *= 1.54;
}
#[cfg(all(feature = "relax_nerf", not(feature = "ppysb_edition")))]
if self.mods.rx() || self.mods.ap() {
acc_value *= 0.8;
}
#[cfg(feature = "score_v2_buff")]
if !self.mods.rx() && self.mods.v2() {
acc_value *= 1.25;
}
acc_value
}
#[inline]
fn total_hits(&self) -> usize {
let n_objects = self
.passed_objects
.unwrap_or_else(|| self.map.hit_objects.len());
(self.n300.unwrap_or(0) + self.n100.unwrap_or(0) + self.n50.unwrap_or(0) + self.n_misses)
.min(n_objects)
}
}
pub trait OsuAttributeProvider {
fn attributes(self) -> Option<DifficultyAttributes>;
}
impl OsuAttributeProvider for DifficultyAttributes {
#[inline]
fn attributes(self) -> Option<DifficultyAttributes> {
Some(self)
}
}
impl OsuAttributeProvider for StarResult {
#[inline]
fn attributes(self) -> Option<DifficultyAttributes> {
#[allow(irrefutable_let_patterns)]
if let Self::Osu(attributes) = self {
Some(attributes)
} else {
None
}
}
}
impl OsuAttributeProvider for PpResult {
#[inline]
fn attributes(self) -> Option<DifficultyAttributes> {
self.attributes.attributes()
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::Beatmap;
#[test]
fn osu_only_accuracy() {
let map = Beatmap::default();
let total_objects = 1234;
let target_acc = 97.5;
let calculator = OsuPP::new(&map)
.passed_objects(total_objects)
.accuracy(target_acc);
let numerator = 6 * calculator.n300.unwrap_or(0)
+ 2 * calculator.n100.unwrap_or(0)
+ calculator.n50.unwrap_or(0);
let denominator = 6 * total_objects;
let acc = 100.0 * numerator as f32 / denominator as f32;
assert!(
(target_acc - acc).abs() < 1.0,
"Expected: {} | Actual: {}",
target_acc,
acc
);
}
#[test]
fn osu_accuracy_and_n50() {
let map = Beatmap::default();
let total_objects = 1234;
let target_acc = 97.5;
let n50 = 30;
let calculator = OsuPP::new(&map)
.passed_objects(total_objects)
.n50(n50)
.accuracy(target_acc);
assert!(
(calculator.n50.unwrap() as i32 - n50 as i32).abs() <= 4,
"Expected: {} | Actual: {}",
n50,
calculator.n50.unwrap()
);
let numerator = 6 * calculator.n300.unwrap_or(0)
+ 2 * calculator.n100.unwrap_or(0)
+ calculator.n50.unwrap_or(0);
let denominator = 6 * total_objects;
let acc = 100.0 * numerator as f32 / denominator as f32;
assert!(
(target_acc - acc).abs() < 1.0,
"Expected: {} | Actual: {}",
target_acc,
acc
);
}
#[test]
fn osu_missing_objects() {
let map = Beatmap::default();
let total_objects = 1234;
let n300 = 1000;
let n100 = 200;
let n50 = 30;
let mut calculator = OsuPP::new(&map)
.passed_objects(total_objects)
.n300(n300)
.n100(n100)
.n50(n50);
calculator.assert_hitresults();
let n_objects = calculator.n300.unwrap()
+ calculator.n100.unwrap()
+ calculator.n50.unwrap()
+ calculator.n_misses;
assert_eq!(
total_objects, n_objects,
"Expected: {} | Actual: {}",
total_objects, n_objects
);
}
}