osu: added pp computation
This commit is contained in:
@@ -9,7 +9,7 @@ A standalone crate to calculate star ratings and performance points for all [osu
|
||||
- [x] taiko sr
|
||||
- [x] ctb sr
|
||||
- [x] mania sr
|
||||
- [ ] osu pp
|
||||
- [ ] taiko pp
|
||||
- [x] osu pp (need sr & testing!)
|
||||
- [x] taiko pp (need testing!)
|
||||
- [x] ctb pp (need testing!)
|
||||
- [x] mania pp (need testing!)
|
||||
|
||||
+10
-6
@@ -2,11 +2,13 @@ mod curve;
|
||||
mod difficulty_object;
|
||||
mod math_util;
|
||||
mod osu_object;
|
||||
mod pp;
|
||||
mod skill;
|
||||
mod skill_kind;
|
||||
|
||||
use difficulty_object::DifficultyObject;
|
||||
use osu_object::OsuObject;
|
||||
pub use pp::*;
|
||||
use skill::Skill;
|
||||
use skill_kind::SkillKind;
|
||||
|
||||
@@ -16,9 +18,9 @@ const SECTION_LEN: f32 = 400.0;
|
||||
const DIFFICULTY_MULTIPLIER: f32 = 0.0675;
|
||||
|
||||
/// Star calculation for osu!standard maps
|
||||
pub fn stars(map: &Beatmap, mods: impl Mods) -> f32 {
|
||||
pub fn stars(map: &Beatmap, mods: impl Mods) -> DifficultyAttributes {
|
||||
if map.hit_objects.len() < 2 {
|
||||
return 0.0;
|
||||
return todo!();
|
||||
}
|
||||
|
||||
let attributes = map.attributes().mods(mods);
|
||||
@@ -77,7 +79,9 @@ pub fn stars(map: &Beatmap, mods: impl Mods) -> f32 {
|
||||
let aim_rating = skills[0].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let speed_rating = skills[1].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
aim_rating + speed_rating + (aim_rating - speed_rating).abs() / 2.0
|
||||
let stars = aim_rating + speed_rating + (aim_rating - speed_rating).abs() / 2.0;
|
||||
|
||||
todo!()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -101,7 +105,7 @@ mod tests {
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
let stars = stars(&map, 0);
|
||||
let stars = stars(&map, 0).stars;
|
||||
|
||||
println!("Stars: {}", stars);
|
||||
}
|
||||
@@ -131,7 +135,7 @@ mod tests {
|
||||
(1241370, 1 << 6, 11.144720506574934),// DT
|
||||
(1241370, 1 << 4, 7.641688110458715), // HR
|
||||
(1241370, 1 << 1, 6.316288616688052), // EZ
|
||||
|
||||
|
||||
// Slider fiesta
|
||||
// (1657535, 1 << 8, 4.1727975286379895),// HT
|
||||
// (1657535, 0, 5.16048239944917), // NM
|
||||
@@ -151,7 +155,7 @@ mod tests {
|
||||
Err(why) => panic!("Error while parsing map {}: {}", map_id, why),
|
||||
};
|
||||
|
||||
let stars = stars(&map, mods);
|
||||
let stars = stars(&map, mods).stars;
|
||||
|
||||
assert!(
|
||||
(stars - expected_stars).abs() < margin,
|
||||
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
use super::stars;
|
||||
|
||||
use parse::{Beatmap, Mods};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DifficultyAttributes {
|
||||
pub stars: f32,
|
||||
pub ar: f32,
|
||||
pub od: f32,
|
||||
pub speed_strain: f32,
|
||||
pub aim_strain: f32,
|
||||
pub max_combo: usize,
|
||||
}
|
||||
|
||||
pub struct PpResult {
|
||||
pub pp: f32,
|
||||
pub attributes: DifficultyAttributes,
|
||||
}
|
||||
|
||||
pub trait PpProvider {
|
||||
fn pp(&self) -> PpCalculator;
|
||||
}
|
||||
|
||||
impl PpProvider for Beatmap {
|
||||
#[inline]
|
||||
fn pp(&self) -> PpCalculator {
|
||||
PpCalculator::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Allow partial plays
|
||||
pub struct PpCalculator<'m> {
|
||||
map: &'m Beatmap,
|
||||
attributes: Option<DifficultyAttributes>,
|
||||
mods: u32,
|
||||
combo: Option<usize>,
|
||||
acc: Option<f32>,
|
||||
|
||||
n300: Option<usize>,
|
||||
n100: Option<usize>,
|
||||
n50: Option<usize>,
|
||||
n_misses: usize,
|
||||
}
|
||||
|
||||
impl<'m> PpCalculator<'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,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn attributes(mut self, attributes: DifficultyAttributes) -> Self {
|
||||
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
|
||||
}
|
||||
|
||||
/// Generate the hit results with respect to the given accuracy between `0` and `100`.
|
||||
///
|
||||
/// Be sure to set `misses` beforehand!
|
||||
pub fn accuracy(mut self, acc: f32) -> Self {
|
||||
let n_objects = self.map.hit_objects.len();
|
||||
|
||||
if self.n100.or(self.n50).is_none() {
|
||||
self.n300.replace(
|
||||
n_objects - self.n100.unwrap_or(0) - self.n50.unwrap_or(0) - self.n_misses,
|
||||
);
|
||||
self.n100.get_or_insert(0);
|
||||
self.n50.get_or_insert(0);
|
||||
} else {
|
||||
let target_total = (acc * n_objects as f32 * 6.0).round() as usize;
|
||||
let delta = target_total - (n_objects - self.n_misses);
|
||||
|
||||
self.n300.replace(delta / 5);
|
||||
self.n100.replace(delta % 5);
|
||||
self.n50
|
||||
.replace(n_objects - self.n300.unwrap() - self.n100.unwrap() - self.n_misses);
|
||||
}
|
||||
|
||||
self.acc.replace(acc / 100.0);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn calculate(mut self) -> PpResult {
|
||||
if self.attributes.is_none() {
|
||||
let attribtes = stars(self.map, self.mods);
|
||||
self.attributes.replace(attribtes);
|
||||
}
|
||||
|
||||
if self.acc.is_none() {
|
||||
let n_objects = 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);
|
||||
self.n100.get_or_insert(0);
|
||||
self.n50.get_or_insert(0);
|
||||
} else if self.n100.is_none() {
|
||||
self.n100.replace(remaining);
|
||||
self.n50.get_or_insert(0);
|
||||
} else if self.n50.is_none() {
|
||||
self.n50.replace(remaining);
|
||||
} else {
|
||||
*self.n300.as_mut().unwrap() += remaining;
|
||||
}
|
||||
}
|
||||
|
||||
let numerator =
|
||||
self.n50.unwrap() * 50 + self.n100.unwrap() * 100 + self.n300.unwrap() * 300;
|
||||
self.acc.replace(numerator as f32 / n_objects as f32);
|
||||
}
|
||||
|
||||
let total_hits = self.total_hits();
|
||||
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 spinner_count = self
|
||||
.map
|
||||
.hit_objects
|
||||
.iter()
|
||||
.filter(|h| h.is_spinner())
|
||||
.count();
|
||||
|
||||
multiplier *= 1.0 - (spinner_count as f32 / total_hits as f32).powf(0.85);
|
||||
}
|
||||
|
||||
let aim_value = self.compute_aim_value(total_hits as f32);
|
||||
let speed_value = self.compute_speed_value(total_hits as f32);
|
||||
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;
|
||||
|
||||
PpResult {
|
||||
pp,
|
||||
attributes: self.attributes.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_aim_value(&self, total_hits: f32) -> f32 {
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
|
||||
// TD penalty
|
||||
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;
|
||||
|
||||
// Longer maps are worth more
|
||||
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;
|
||||
|
||||
// Penalize misses
|
||||
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);
|
||||
}
|
||||
|
||||
// Combo scaling
|
||||
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);
|
||||
}
|
||||
|
||||
// AR bonus
|
||||
let mut ar_factor = 0.0;
|
||||
if attributes.ar > 10.33 {
|
||||
ar_factor += 0.4 * (attributes.ar - 10.33);
|
||||
} else if attributes.ar < 8.0 {
|
||||
ar_factor += 0.1 * (8.0 - attributes.ar);
|
||||
}
|
||||
aim_value *= 1.0 + ar_factor.min(ar_factor * total_hits / 1000.0);
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
aim_value *= 1.0 + 0.04 * (12.0 - attributes.ar);
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
aim_value *= 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;
|
||||
}
|
||||
|
||||
// Scale with accuracy
|
||||
aim_value *= 0.5 + self.acc.unwrap() / 2.0;
|
||||
aim_value *= 0.98 + attributes.od * attributes.od / 2500.0;
|
||||
|
||||
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;
|
||||
|
||||
// Longer maps are worth more
|
||||
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;
|
||||
|
||||
// Penalize misses
|
||||
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));
|
||||
}
|
||||
|
||||
// Combo scaling
|
||||
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);
|
||||
}
|
||||
|
||||
// AR bonus
|
||||
if attributes.ar > 10.33 {
|
||||
let ar_factor = 0.4 * (attributes.ar - 10.33);
|
||||
speed_value *= 1.0 + ar_factor.min(ar_factor * total_hits / 1000.0);
|
||||
}
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
speed_value *= 1.0 + 0.04 * (12.0 - attributes.ar);
|
||||
}
|
||||
|
||||
// Scaling the speed value with accuracy and OD
|
||||
speed_value *= (0.95 + attributes.od * attributes.od / 750.0)
|
||||
* self
|
||||
.acc
|
||||
.unwrap()
|
||||
.powf((14.5 - attributes.od.max(8.0)) / 8.0);
|
||||
|
||||
// Penalize n50s
|
||||
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),
|
||||
);
|
||||
|
||||
speed_value
|
||||
}
|
||||
|
||||
fn compute_accuracy_value(&self, total_hits: usize) -> f32 {
|
||||
let n_circles = self
|
||||
.map
|
||||
.hit_objects
|
||||
.iter()
|
||||
.filter(|h| h.is_circle())
|
||||
.count();
|
||||
|
||||
let better_acc_percentage = (n_circles > 0) as u8 as f32
|
||||
* (((self.n300.unwrap() - (total_hits - n_circles)) * 6
|
||||
+ self.n100.unwrap_or(0) * 2
|
||||
+ self.n50.unwrap_or(0)) as f32
|
||||
/ (n_circles * 6) as f32)
|
||||
.max(0.0);
|
||||
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
|
||||
let mut acc_value = 1.52163_f32.powf(attributes.od) * better_acc_percentage.powi(24) * 2.83;
|
||||
|
||||
// Bonus for many hitcircles
|
||||
acc_value *= ((n_circles as f32 / 1000.0).powf(0.3)).min(1.15);
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
acc_value *= 1.08;
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
acc_value *= 1.02;
|
||||
}
|
||||
|
||||
acc_value
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn total_hits(&self) -> usize {
|
||||
self.n300.unwrap_or(0) + self.n100.unwrap_or(0) + self.n50.unwrap_or(0) + self.n_misses
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,158 @@ fn norm(p: f32, a: f32, b: f32, c: f32) -> f32 {
|
||||
(a.powf(p) + b.powf(p) + c.powf(p)).powf(p.recip())
|
||||
}
|
||||
|
||||
pub struct PpResult {
|
||||
pub pp: f32,
|
||||
pub stars: f32,
|
||||
}
|
||||
|
||||
pub trait PpProvider {
|
||||
fn pp(&self) -> PpCalculator;
|
||||
}
|
||||
|
||||
impl PpProvider for Beatmap {
|
||||
#[inline]
|
||||
fn pp(&self) -> PpCalculator {
|
||||
PpCalculator::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Allow partial plays
|
||||
pub struct PpCalculator<'m> {
|
||||
map: &'m Beatmap,
|
||||
stars: Option<f32>,
|
||||
mods: u32,
|
||||
max_combo: usize,
|
||||
combo: Option<usize>,
|
||||
acc: f32,
|
||||
n_misses: usize,
|
||||
}
|
||||
|
||||
impl<'m> PpCalculator<'m> {
|
||||
#[inline]
|
||||
pub fn new(map: &'m Beatmap) -> Self {
|
||||
let max_combo = map.hit_objects.iter().filter(|h| h.is_circle()).count();
|
||||
|
||||
Self {
|
||||
map,
|
||||
stars: None,
|
||||
mods: 0,
|
||||
max_combo,
|
||||
combo: None,
|
||||
acc: 100.0,
|
||||
n_misses: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn stars(mut self, stars: f32) -> Self {
|
||||
self.stars.replace(stars);
|
||||
|
||||
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 misses(mut self, n_misses: usize) -> Self {
|
||||
self.n_misses = n_misses;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the accuracy between 0.0 and 100.0;
|
||||
#[inline]
|
||||
pub fn accuracy(mut self, acc: f32) -> Self {
|
||||
self.acc = acc / 100.0;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn calculate(self) -> PpResult {
|
||||
let stars = self.stars.unwrap_or_else(|| stars(self.map, self.mods));
|
||||
|
||||
let mut multiplier = 1.1;
|
||||
|
||||
if self.mods.nf() {
|
||||
multiplier *= 0.9;
|
||||
}
|
||||
|
||||
if self.mods.hd() {
|
||||
multiplier *= 1.1;
|
||||
}
|
||||
|
||||
// TODO: Consider HR & co?
|
||||
let hit_window = difficulty_range(self.map.od) as i32 as f32 / self.mods.speed();
|
||||
|
||||
let strain_value = self.compute_strain_value(stars);
|
||||
let acc_value = self.compute_accuracy_value(hit_window);
|
||||
|
||||
let pp = (strain_value.powf(1.1) + acc_value.powf(1.1)).powf(1.0 / 1.1) * multiplier;
|
||||
|
||||
PpResult { stars, pp }
|
||||
}
|
||||
|
||||
fn compute_strain_value(&self, stars: f32) -> f32 {
|
||||
let exp_base = 5.0 * (stars / 0.0075).max(1.0) - 4.0;
|
||||
let mut strain = exp_base * exp_base / 100_000.0;
|
||||
|
||||
// Longer maps are worth more
|
||||
let len_bonus = 1.0 + 0.1 * (self.max_combo as f32 / 1500.0).min(1.0);
|
||||
strain *= len_bonus;
|
||||
|
||||
// Penalize misses exponentially
|
||||
strain *= 0.985_f32.powi(self.n_misses as i32);
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
strain *= 1.025;
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
strain *= 1.05 * len_bonus;
|
||||
}
|
||||
|
||||
// Scale with accuracy
|
||||
strain * self.acc
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn compute_accuracy_value(&self, hit_window: f32) -> f32 {
|
||||
(150.0 / hit_window).powf(1.1)
|
||||
* self.acc.powi(15)
|
||||
* 22.0
|
||||
* (self.max_combo as f32 / 1500.0).powf(0.3).min(1.15)
|
||||
}
|
||||
}
|
||||
|
||||
const HITWINDOW_MIN: f32 = 50.0;
|
||||
const HITWINDOW_AVG: f32 = 35.0;
|
||||
const HITWINDOW_MAX: f32 = 20.0;
|
||||
|
||||
#[inline]
|
||||
fn difficulty_range(od: f32) -> f32 {
|
||||
if od > 5.0 {
|
||||
HITWINDOW_AVG + (HITWINDOW_MAX - HITWINDOW_AVG) * (od - 5.0) / 5.0
|
||||
} else if od < 5.0 {
|
||||
HITWINDOW_AVG - (HITWINDOW_AVG - HITWINDOW_MIN) * (5.0 - od) / 5.0
|
||||
} else {
|
||||
HITWINDOW_AVG
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user