improved osu hitresult generation

This commit is contained in:
MaxOhn
2022-10-15 11:36:41 +02:00
parent fa91703a2a
commit d83217f5d0
8 changed files with 303 additions and 294 deletions
+30 -22
View File
@@ -42,7 +42,7 @@ pub struct CatchPP<'map> {
pub(crate) n_droplets: Option<usize>,
pub(crate) n_tiny_droplets: Option<usize>,
pub(crate) n_tiny_droplet_misses: Option<usize>,
pub(crate) n_misses: usize,
pub(crate) n_misses: Option<usize>,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
}
@@ -61,7 +61,7 @@ impl<'map> CatchPP<'map> {
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: 0,
n_misses: None,
passed_objects: None,
clock_rate: None,
}
@@ -132,7 +132,7 @@ impl<'map> CatchPP<'map> {
/// Specify the amount of fruit / droplet misses of the play.
#[inline]
pub fn misses(mut self, n_misses: usize) -> Self {
self.n_misses = n_misses;
self.n_misses = Some(n_misses);
self
}
@@ -176,7 +176,7 @@ impl<'map> CatchPP<'map> {
self.n_droplets = Some(n_droplets);
self.n_tiny_droplets = Some(n_tiny_droplets);
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self.n_misses = n_misses;
self.n_misses = Some(n_misses);
self
}
@@ -201,15 +201,17 @@ impl<'map> CatchPP<'map> {
let attributes = self.attributes.as_ref().unwrap();
let n_droplets = self
.n_droplets
.unwrap_or_else(|| attributes.n_droplets.saturating_sub(self.n_misses));
let n_droplets = self.n_droplets.unwrap_or_else(|| {
attributes
.n_droplets
.saturating_sub(self.n_misses.unwrap_or(0))
});
let max_combo = attributes.max_combo();
let n_fruits = self.n_fruits.unwrap_or_else(|| {
max_combo
.saturating_sub(self.n_misses)
.saturating_sub(self.n_misses.unwrap_or(0))
.saturating_sub(n_droplets)
});
@@ -237,16 +239,20 @@ impl<'map> CatchPP<'map> {
let correct_combo_hits = self
.n_fruits
.and_then(|f| self.n_droplets.map(|d| f + d + self.n_misses))
.and_then(|f| self.n_droplets.map(|d| f + d + self.n_misses.unwrap_or(0)))
.filter(|h| *h == max_combo);
let correct_fruits = self
.n_fruits
.filter(|f| *f >= attributes.n_fruits.saturating_sub(self.n_misses));
let correct_fruits = self.n_fruits.filter(|f| {
*f >= attributes
.n_fruits
.saturating_sub(self.n_misses.unwrap_or(0))
});
let correct_droplets = self
.n_droplets
.filter(|d| *d >= attributes.n_droplets.saturating_sub(self.n_misses));
let correct_droplets = self.n_droplets.filter(|d| {
*d >= attributes
.n_droplets
.saturating_sub(self.n_misses.unwrap_or(0))
});
let correct_tinies = self
.n_tiny_droplets
@@ -267,7 +273,7 @@ impl<'map> CatchPP<'map> {
let missing = max_combo
.saturating_sub(n_fruits)
.saturating_sub(n_droplets)
.saturating_sub(self.n_misses);
.saturating_sub(self.n_misses.unwrap_or(0));
let missing_fruits =
missing.saturating_sub(attributes.n_droplets.saturating_sub(n_droplets));
@@ -287,7 +293,7 @@ impl<'map> CatchPP<'map> {
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses: self.n_misses,
n_misses: self.n_misses.unwrap_or(0),
};
}
@@ -299,7 +305,7 @@ impl<'map> CatchPP<'map> {
n_droplets: self.n_droplets.unwrap_or(0),
n_tiny_droplets: self.n_tiny_droplets.unwrap_or(0),
n_tiny_droplet_misses: self.n_tiny_droplet_misses.unwrap_or(0),
n_misses: self.n_misses,
n_misses: self.n_misses.unwrap_or(0),
}
}
@@ -542,8 +548,9 @@ mod test {
let numerator = calculator.n_fruits.unwrap_or(0)
+ calculator.n_droplets.unwrap_or(0)
+ calculator.n_tiny_droplets.unwrap_or(0);
let denominator =
numerator + calculator.n_tiny_droplet_misses.unwrap_or(0) + calculator.n_misses;
let denominator = numerator
+ calculator.n_tiny_droplet_misses.unwrap_or(0)
+ calculator.n_misses.unwrap_or(0);
let acc = 100.0 * numerator as f64 / denominator as f64;
assert!(
@@ -582,8 +589,9 @@ mod test {
let numerator = calculator.n_fruits.unwrap_or(0)
+ calculator.n_droplets.unwrap_or(0)
+ calculator.n_tiny_droplets.unwrap_or(0);
let denominator =
numerator + calculator.n_tiny_droplet_misses.unwrap_or(0) + calculator.n_misses;
let denominator = numerator
+ calculator.n_tiny_droplet_misses.unwrap_or(0)
+ calculator.n_misses.unwrap_or(0);
let acc = 100.0 * numerator as f64 / denominator as f64;
assert!(
+1 -1
View File
@@ -200,7 +200,7 @@ mod gradual;
pub use gradual::{GradualDifficultyAttributes, GradualPerformanceAttributes, ScoreState};
mod pp;
pub use pp::{AnyPP, AttributeProvider};
pub use pp::{AnyPP, AttributeProvider, HitResultPriority};
mod stars;
pub use stars::AnyStars;
+20 -5
View File
@@ -20,16 +20,31 @@ pub struct ManiaScoreState {
}
impl ManiaScoreState {
/// Create a new empty score state.
#[inline]
pub fn new() -> Self {
Self::default()
}
/// Return the total amount of hits by adding everything up.
#[inline]
pub fn total_hits(&self) -> usize {
self.n320 + self.n300 + self.n200 + self.n100 + self.n50 + self.n_misses
}
}
impl ManiaScoreState {
/// Create a new empty score state.
pub fn new() -> Self {
Self::default()
/// Calculate the accuracy between `0.0` and `1.0` for this state.
#[inline]
pub fn accuracy(&self) -> f64 {
let total_hits = self.total_hits();
if total_hits == 0 {
return 0.0;
}
let numerator = 6 * (self.n320 + self.n300) + 4 * self.n200 + 2 * self.n100 + self.n50;
let denominator = 6 * total_hits;
numerator as f64 / denominator as f64
}
}
+15 -28
View File
@@ -1,7 +1,9 @@
use std::borrow::Cow;
use super::{ManiaDifficultyAttributes, ManiaPerformanceAttributes, ManiaScoreState, ManiaStars};
use crate::{Beatmap, DifficultyAttributes, GameMode, Mods, OsuPP, PerformanceAttributes};
use crate::{
Beatmap, DifficultyAttributes, GameMode, HitResultPriority, Mods, OsuPP, PerformanceAttributes,
};
// TODO: update
/// Performance calculator on osu!mania maps.
@@ -48,7 +50,7 @@ pub struct ManiaPP<'map> {
pub(crate) n_misses: Option<usize>,
acc: Option<f64>,
hitresult_priority: Option<ManiaHitResultPriority>,
hitresult_priority: Option<HitResultPriority>,
}
impl<'map> ManiaPP<'map> {
@@ -120,7 +122,7 @@ impl<'map> ManiaPP<'map> {
self
}
/// Specify the accuracy of a play.
/// Specify the accuracy of a play between `0` and `100`.
/// This will be used to generate matching hitresults.
#[inline]
pub fn accuracy(mut self, acc: f64) -> Self {
@@ -131,9 +133,9 @@ impl<'map> ManiaPP<'map> {
/// Specify how hitresults should be generated.
///
/// Defauls to [`ManiaHitResultPriority::BestCase`].
/// Defauls to [`HitResultPriority::BestCase`].
#[inline]
pub fn hitresult_priority(mut self, priority: ManiaHitResultPriority) -> Self {
pub fn hitresult_priority(mut self, priority: HitResultPriority) -> Self {
self.hitresult_priority = Some(priority);
self
@@ -237,7 +239,7 @@ impl<'map> ManiaPP<'map> {
}
fn generate_hitresults(&self) -> ManiaScoreState {
let n_objects = self.map.hit_objects.len();
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
let priority = self.hitresult_priority.unwrap_or_default();
let mut state = ManiaScoreState {
@@ -250,6 +252,7 @@ impl<'map> ManiaPP<'map> {
};
if let Some(acc) = self.acc {
// TODO: test
let target_total = (acc * (n_objects * 6) as f64).round() as usize;
let mut delta = target_total.saturating_sub(n_objects.saturating_sub(state.n_misses));
@@ -274,7 +277,7 @@ impl<'map> ManiaPP<'map> {
state.n50 += n_objects.saturating_sub(state.total_hits() - state.n50);
if let ManiaHitResultPriority::BestCase = priority {
if let HitResultPriority::BestCase = priority {
// Shift n50 to n200
if self.n320.or(self.n300).or(self.n200).or(self.n50).is_none() {
let n = (state.n320 + state.n300).min(state.n50 / 2);
@@ -309,7 +312,7 @@ impl<'map> ManiaPP<'map> {
let remaining = n_objects.saturating_sub(state.total_hits());
match priority {
ManiaHitResultPriority::BestCase => {
HitResultPriority::BestCase => {
if self.n320.is_none() {
state.n320 = remaining;
} else if self.n300.is_none() {
@@ -324,7 +327,7 @@ impl<'map> ManiaPP<'map> {
state.n320 = remaining;
}
}
ManiaHitResultPriority::WorstCase => {
HitResultPriority::WorstCase => {
if self.n50.is_none() {
state.n50 = remaining;
} else if self.n100.is_none() {
@@ -405,26 +408,10 @@ impl ManiaPpInner {
return 0.0;
}
let numerator = *n320 * 320 + *n300 * 300 + *n200 * 200 + *n100 * 100 + *n50 * 50;
let denominator = total_hits as f64 * 320.0;
let numerator = *n320 * 32 + *n300 * 30 + *n200 * 20 + *n100 * 10 + *n50 * 5;
let denominator = total_hits * 32;
numerator as f64 / denominator
}
}
/// While generating hitresults that weren't specific, decide how they should be distributed.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ManiaHitResultPriority {
/// Prioritize good hitresults over bad ones
BestCase,
/// Prioritize bad hitresults over good ones
WorstCase,
}
impl Default for ManiaHitResultPriority {
#[inline]
fn default() -> Self {
Self::BestCase
numerator as f64 / denominator as f64
}
}
+22
View File
@@ -23,9 +23,31 @@ pub struct OsuScoreState {
impl OsuScoreState {
/// Create a new empty score state.
#[inline]
pub fn new() -> Self {
Self::default()
}
/// Return the total amount of hits by adding everything up.
#[inline]
pub fn total_hits(&self) -> usize {
self.n300 + self.n100 + self.n50 + self.n_misses
}
/// Calculate the accuracy between `0.0` and `1.0` for this state.
#[inline]
pub fn accuracy(&self) -> f64 {
let total_hits = self.total_hits();
if total_hits == 0 {
return 0.0;
}
let numerator = 6 * self.n300 + 2 * self.n100 + self.n50;
let denominator = 6 * total_hits;
numerator as f64 / denominator as f64
}
}
/// Gradually calculate the performance attributes of an osu!standard map.
+194 -233
View File
@@ -2,7 +2,8 @@ use super::{
OsuDifficultyAttributes, OsuPerformanceAttributes, OsuScoreState, PERFORMANCE_BASE_MULTIPLIER,
};
use crate::{
AnyPP, Beatmap, DifficultyAttributes, GameMode, Mods, OsuStars, PerformanceAttributes,
AnyPP, Beatmap, DifficultyAttributes, GameMode, HitResultPriority, Mods, OsuStars,
PerformanceAttributes,
};
/// Performance calculator on osu!standard maps.
@@ -46,9 +47,10 @@ pub struct OsuPP<'map> {
pub(crate) n300: Option<usize>,
pub(crate) n100: Option<usize>,
pub(crate) n50: Option<usize>,
pub(crate) n_misses: usize,
pub(crate) n_misses: Option<usize>,
pub(crate) passed_objects: Option<usize>,
pub(crate) clock_rate: Option<f64>,
hitresult_priority: Option<HitResultPriority>,
}
impl<'map> OsuPP<'map> {
@@ -65,9 +67,10 @@ impl<'map> OsuPP<'map> {
n300: None,
n100: None,
n50: None,
n_misses: 0,
n_misses: None,
passed_objects: None,
clock_rate: None,
hitresult_priority: None,
}
}
@@ -88,7 +91,7 @@ impl<'map> OsuPP<'map> {
#[inline]
pub fn attributes(mut self, attributes: impl OsuAttributeProvider) -> Self {
if let Some(attributes) = attributes.attributes() {
self.attributes.replace(attributes);
self.attributes = Some(attributes);
}
self
@@ -107,7 +110,17 @@ impl<'map> OsuPP<'map> {
/// Specify the max combo of the play.
#[inline]
pub fn combo(mut self, combo: usize) -> Self {
self.combo.replace(combo);
self.combo = Some(combo);
self
}
/// Specify how hitresults should be generated.
///
/// Defauls to [`HitResultPriority::BestCase`].
#[inline]
pub fn hitresult_priority(mut self, priority: HitResultPriority) -> Self {
self.hitresult_priority = Some(priority);
self
}
@@ -115,7 +128,7 @@ impl<'map> OsuPP<'map> {
/// Specify the amount of 300s of a play.
#[inline]
pub fn n300(mut self, n300: usize) -> Self {
self.n300.replace(n300);
self.n300 = Some(n300);
self
}
@@ -123,7 +136,7 @@ impl<'map> OsuPP<'map> {
/// Specify the amount of 100s of a play.
#[inline]
pub fn n100(mut self, n100: usize) -> Self {
self.n100.replace(n100);
self.n100 = Some(n100);
self
}
@@ -131,7 +144,7 @@ impl<'map> OsuPP<'map> {
/// Specify the amount of 50s of a play.
#[inline]
pub fn n50(mut self, n50: usize) -> Self {
self.n50.replace(n50);
self.n50 = Some(n50);
self
}
@@ -139,7 +152,7 @@ impl<'map> OsuPP<'map> {
/// Specify the amount of misses of a play.
#[inline]
pub fn n_misses(mut self, n_misses: usize) -> Self {
self.n_misses = n_misses;
self.n_misses = Some(n_misses);
self
}
@@ -151,7 +164,7 @@ impl<'map> OsuPP<'map> {
/// [`OsuGradualPerformanceAttributes`](crate::osu::OsuGradualPerformanceAttributes).
#[inline]
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects.replace(passed_objects);
self.passed_objects = Some(passed_objects);
self
}
@@ -181,161 +194,102 @@ impl<'map> OsuPP<'map> {
self.n300 = Some(n300);
self.n100 = Some(n100);
self.n50 = Some(n50);
self.n_misses = n_misses;
self.n_misses = Some(n_misses);
self
}
/// Generate the hit results with respect to the given accuracy between `0` and `100`.
///
/// Be sure to set `misses` beforehand!
/// In case of a partial play, be also sure to set `passed_objects` beforehand!
/// Specify the accuracy of a play between `0` and `100`.
/// This will be used to generate matching hitresults.
pub fn accuracy(mut self, acc: f64) -> Self {
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
let mut 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 f64).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()) {
// Only n50s were changed, try to load some off again onto n100s
let difference = n50 - orig_n50;
let n = n300.min(difference / 4);
n300 -= n;
n100 += 5 * n;
n50 -= 4 * n;
}
self.n300 = Some(n300);
self.n100 = Some(n100);
self.n50 = Some(n50);
acc = (6 * n300 + 2 * n100 + n50) as f64 / (6 * n_objects) as f64;
} else {
let misses = self.n_misses.min(n_objects);
let target_total = (acc * n_objects as f64 * 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;
// Sacrifice n300s to transform n50s into n100s
let n = n300.min(n50 / 4);
n300 -= n;
n100 += 5 * n;
n50 -= 4 * n;
self.n300 = Some(n300);
self.n100 = Some(n100);
self.n50 = Some(n50);
acc = (6 * n300 + 2 * n100 + n50) as f64 / (6 * n_objects) as f64;
}
self.acc = Some(acc);
self.acc = Some(acc / 100.0);
self
}
fn assert_hitresults(self, attributes: OsuDifficultyAttributes) -> OsuPPInner {
let mut n300 = self.n300;
let mut n100 = self.n100;
let mut n50 = self.n50;
fn generate_hitresults(&self, max_combo: usize) -> OsuScoreState {
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
let priority = self.hitresult_priority.unwrap_or_default();
let mut state = OsuScoreState {
max_combo: self.combo.unwrap_or(max_combo),
n300: self.n300.unwrap_or(0),
n100: self.n100.unwrap_or(0),
n50: self.n50.unwrap_or(0),
n_misses: self.n_misses.unwrap_or(0),
};
if let Some(acc) = self.acc {
let n300 = n300.unwrap_or(0);
let n100 = n100.unwrap_or(0);
let n50 = n50.unwrap_or(0);
// TODO: test
let target_total = (acc * (n_objects * 6) as f64).round() as usize;
let total_hits = (n300 + n100 + n50 + self.n_misses).min(n_objects) as f64;
let mut delta = target_total.saturating_sub(n_objects.saturating_sub(state.n_misses));
let effective_misses =
calculate_effective_misses(&attributes, self.combo, n100, n50, self.n_misses);
OsuPPInner {
mods: self.mods,
combo: self.combo.unwrap_or(attributes.max_combo),
acc,
n300,
n100,
n50,
n_misses: self.n_misses,
total_hits,
effective_miss_count: effective_misses,
attributes,
if self.n50.is_some() {
delta /= 2;
}
} else {
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
let remaining = n_objects
.saturating_sub(n300.unwrap_or(0))
.saturating_sub(n100.unwrap_or(0))
.saturating_sub(n50.unwrap_or(0))
.saturating_sub(self.n_misses);
if self.n100.is_some() {
delta /= 2;
}
if remaining > 0 {
if let Some(n300) = n300.as_mut() {
if n100.is_none() {
n100 = Some(remaining);
} else if n50.is_none() {
n50 = Some(remaining);
} else {
*n300 += remaining;
}
} else {
n300 = Some(remaining);
if let Some(n300) = self.n300 {
delta = delta.saturating_sub(n300 * 6);
} else {
state.n300 = delta / 5;
}
if self.n100.is_none() {
state.n100 = delta % 5;
}
state.n50 += n_objects.saturating_sub(state.total_hits() - state.n50);
if let HitResultPriority::BestCase = priority {
// Shift n50 to n100
if self.n300.or(self.n100).or(self.n50).is_none() {
let n = state.n300.min(state.n50 / 4);
state.n300 -= n;
state.n100 += 5 * n;
state.n50 -= 4 * n;
}
}
} else {
let remaining = n_objects.saturating_sub(state.total_hits());
let n300 = n300.unwrap_or(0);
let n100 = n100.unwrap_or(0);
let n50 = n50.unwrap_or(0);
let numerator = n300 * 6 + n100 * 2 + n50;
let acc = if n_objects > 0 {
numerator as f64 / n_objects as f64 / 6.0
} else {
0.0
};
let total_hits = (n300 + n100 + n50 + self.n_misses).min(n_objects) as f64;
let effective_misses =
calculate_effective_misses(&attributes, self.combo, n100, n50, self.n_misses);
OsuPPInner {
mods: self.mods,
combo: self.combo.unwrap_or(attributes.max_combo),
acc,
n300,
n100,
n50,
n_misses: self.n_misses,
total_hits,
effective_miss_count: effective_misses,
attributes,
match priority {
HitResultPriority::BestCase => {
if self.n300.is_none() {
state.n300 = remaining;
} else if self.n100.is_none() {
state.n100 = remaining;
} else if self.n50.is_none() {
state.n50 = remaining;
} else {
state.n300 = remaining;
}
}
HitResultPriority::WorstCase => {
if self.n50.is_none() {
state.n50 = remaining;
} else if self.n100.is_none() {
state.n100 = remaining;
} else if self.n300.is_none() {
state.n300 = remaining;
} else {
state.n50 = remaining;
}
}
}
}
state
}
/// Calculate all performance related values, including pp and stars.
pub fn calculate(mut self) -> OsuPerformanceAttributes {
let attributes = self.attributes.take().unwrap_or_else(|| {
let attrs = self.attributes.take().unwrap_or_else(|| {
let mut calculator = OsuStars::new(self.map).mods(self.mods);
if let Some(passed_objects) = self.passed_objects {
@@ -349,52 +303,60 @@ impl<'map> OsuPP<'map> {
calculator.calculate()
});
self.assert_hitresults(attributes).calculate()
let state = self.generate_hitresults(attrs.max_combo);
let effective_miss_count = calculate_effective_misses(&attrs, &state);
let inner = OsuPpInner {
attrs,
mods: self.mods,
acc: state.accuracy(),
state,
effective_miss_count,
};
inner.calculate()
}
}
struct OsuPPInner {
attributes: OsuDifficultyAttributes,
struct OsuPpInner {
attrs: OsuDifficultyAttributes,
mods: u32,
acc: f64,
combo: usize,
n300: usize,
n100: usize,
n50: usize,
n_misses: usize,
total_hits: f64,
state: OsuScoreState,
effective_miss_count: f64,
}
impl OsuPPInner {
impl OsuPpInner {
fn calculate(mut self) -> OsuPerformanceAttributes {
if self.total_hits.abs() <= f64::EPSILON {
let total_hits = self.state.total_hits();
if total_hits == 0 {
return OsuPerformanceAttributes {
difficulty: self.attributes,
difficulty: self.attrs,
..Default::default()
};
}
let total_hits = total_hits as f64;
let mut multiplier = PERFORMANCE_BASE_MULTIPLIER;
if self.mods.nf() {
multiplier *= (1.0 - 0.02 * self.effective_miss_count).max(0.9);
}
if self.mods.so() && self.total_hits > 0.0 {
multiplier *= 1.0 - (self.attributes.n_spinners as f64 / self.total_hits).powf(0.85);
if self.mods.so() && total_hits > 0.0 {
multiplier *= 1.0 - (self.attrs.n_spinners as f64 / total_hits).powf(0.85);
}
if self.mods.rx() {
// * https://www.desmos.com/calculator/bc9eybdthb
// * we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0
// * this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11)
let (n100_mult, n50_mult) = if self.attributes.od > 0.0 {
let (n100_mult, n50_mult) = if self.attrs.od > 0.0 {
(
1.0 - (self.attributes.od / 13.33).powf(1.8),
1.0 - (self.attributes.od / 13.33).powi(5),
1.0 - (self.attrs.od / 13.33).powf(1.8),
1.0 - (self.attrs.od / 13.33).powi(5),
)
} else {
(1.0, 1.0)
@@ -403,10 +365,10 @@ impl OsuPPInner {
// * As we're adding Oks and Mehs to an approximated number of combo breaks the result can be
// * higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it.
self.effective_miss_count = (self.effective_miss_count
+ self.n100 as f64
+ self.state.n100 as f64
+ n100_mult
+ self.n50 as f64 * n50_mult)
.min(self.total_hits);
+ self.state.n50 as f64 * n50_mult)
.min(total_hits);
}
let aim_value = self.compute_aim_value();
@@ -422,7 +384,7 @@ impl OsuPPInner {
* multiplier;
OsuPerformanceAttributes {
difficulty: self.attributes,
difficulty: self.attrs,
pp_acc: acc_value,
pp_aim: aim_value,
pp_flashlight: flashlight_value,
@@ -433,12 +395,13 @@ impl OsuPPInner {
}
fn compute_aim_value(&self) -> f64 {
let mut aim_value =
(5.0 * (self.attributes.aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let mut aim_value = (5.0 * (self.attrs.aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let total_hits = self.total_hits();
let len_bonus = 0.95
+ 0.4 * (self.total_hits / 2000.0).min(1.0)
+ (self.total_hits > 2000.0) as u8 as f64 * (self.total_hits / 2000.0).log10() * 0.5;
+ 0.4 * (total_hits / 2000.0).min(1.0)
+ (total_hits > 2000.0) as u8 as f64 * (total_hits / 2000.0).log10() * 0.5;
aim_value *= len_bonus;
@@ -446,7 +409,7 @@ impl OsuPPInner {
// * Default a 3% reduction for any # of misses.
if self.effective_miss_count > 0.0 {
aim_value *= 0.97
* (1.0 - (self.effective_miss_count / self.total_hits).powf(0.775))
* (1.0 - (self.effective_miss_count / total_hits).powf(0.775))
.powf(self.effective_miss_count);
}
@@ -454,10 +417,10 @@ impl OsuPPInner {
let ar_factor = if self.mods.rx() {
0.0
} else if self.attributes.ar > 10.33 {
0.3 * (self.attributes.ar - 10.33)
} else if self.attributes.ar < 8.0 {
0.05 * (8.0 - self.attributes.ar)
} else if self.attrs.ar > 10.33 {
0.3 * (self.attrs.ar - 10.33)
} else if self.attrs.ar < 8.0 {
0.05 * (8.0 - self.attrs.ar)
} else {
0.0
};
@@ -467,27 +430,27 @@ impl OsuPPInner {
if self.mods.hd() {
// * We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR.
aim_value *= 1.0 + 0.04 * (12.0 - self.attributes.ar);
aim_value *= 1.0 + 0.04 * (12.0 - self.attrs.ar);
}
// * We assume 15% of sliders in a map are difficult since there's no way to tell from the performance calculator.
let estimate_diff_sliders = self.attributes.n_sliders as f64 * 0.15;
let estimate_diff_sliders = self.attrs.n_sliders as f64 * 0.15;
if self.attributes.n_sliders > 0 {
let estimate_slider_ends_dropped = ((self.n100 + self.n50 + self.n_misses)
.min(self.attributes.max_combo - self.combo)
as f64)
.clamp(0.0, estimate_diff_sliders);
let slider_nerf_factor = (1.0 - self.attributes.slider_factor)
if self.attrs.n_sliders > 0 {
let estimate_slider_ends_dropped =
((self.state.n100 + self.state.n50 + self.state.n_misses)
.min(self.attrs.max_combo - self.state.max_combo) as f64)
.clamp(0.0, estimate_diff_sliders);
let slider_nerf_factor = (1.0 - self.attrs.slider_factor)
* (1.0 - estimate_slider_ends_dropped / estimate_diff_sliders).powi(3)
+ self.attributes.slider_factor;
+ self.attrs.slider_factor;
aim_value *= slider_nerf_factor;
}
aim_value *= self.acc;
// * It is important to consider accuracy difficulty when scaling with accuracy.
aim_value *= 0.98 + self.attributes.od * self.attributes.od / 2500.0;
aim_value *= 0.98 + self.attrs.od * self.attrs.od / 2500.0;
aim_value
}
@@ -498,11 +461,13 @@ impl OsuPPInner {
}
let mut speed_value =
(5.0 * (self.attributes.speed / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
(5.0 * (self.attrs.speed / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let total_hits = self.total_hits();
let len_bonus = 0.95
+ 0.4 * (self.total_hits / 2000.0).min(1.0)
+ (self.total_hits > 2000.0) as u8 as f64 * (self.total_hits / 2000.0).log10() * 0.5;
+ 0.4 * (total_hits / 2000.0).min(1.0)
+ (total_hits > 2000.0) as u8 as f64 * (total_hits / 2000.0).log10() * 0.5;
speed_value *= len_bonus;
@@ -510,14 +475,14 @@ impl OsuPPInner {
// * Default a 3% reduction for any # of misses.
if self.effective_miss_count > 0.0 {
speed_value *= 0.97
* (1.0 - (self.effective_miss_count / self.total_hits).powf(0.775))
* (1.0 - (self.effective_miss_count / total_hits).powf(0.775))
.powf(self.effective_miss_count.powf(0.875));
}
speed_value *= self.get_combo_scaling_factor();
let ar_factor = if self.attributes.ar > 10.33 {
0.3 * (self.attributes.ar - 10.33)
let ar_factor = if self.attrs.ar > 10.33 {
0.3 * (self.attrs.ar - 10.33)
} else {
0.0
};
@@ -528,33 +493,34 @@ impl OsuPPInner {
if self.mods.hd() {
// * We want to give more reward for lower AR when it comes to aim and HD.
// * This nerfs high AR and buffs lower AR.
speed_value *= 1.0 + 0.04 * (12.0 - self.attributes.ar);
speed_value *= 1.0 + 0.04 * (12.0 - self.attrs.ar);
}
// * Calculate accuracy assuming the worst case scenario
let relevant_total_diff = self.total_hits - self.attributes.speed_note_count;
let relevant_n300 = (self.n300 as f64 - relevant_total_diff).max(0.0);
let relevant_n100 =
(self.n100 as f64 - (relevant_total_diff - self.n300 as f64).max(0.0)).max(0.0);
let relevant_n50 = (self.n50 as f64
- (relevant_total_diff - (self.n300 + self.n100) as f64).max(0.0))
let relevant_total_diff = total_hits - self.attrs.speed_note_count;
let relevant_n300 = (self.state.n300 as f64 - relevant_total_diff).max(0.0);
let relevant_n100 = (self.state.n100 as f64
- (relevant_total_diff - self.state.n300 as f64).max(0.0))
.max(0.0);
let relevant_n50 = (self.state.n50 as f64
- (relevant_total_diff - (self.state.n300 + self.state.n100) as f64).max(0.0))
.max(0.0);
let relevant_acc = if self.attributes.speed_note_count.abs() <= f64::EPSILON {
let relevant_acc = if self.attrs.speed_note_count.abs() <= f64::EPSILON {
0.0
} else {
(relevant_n300 * 6.0 + relevant_n100 * 2.0 + relevant_n50)
/ (self.attributes.speed_note_count * 6.0)
/ (self.attrs.speed_note_count * 6.0)
};
// * Scale the speed value with accuracy and OD.
speed_value *= (0.95 + self.attributes.od * self.attributes.od / 750.0)
* ((self.acc + relevant_acc) / 2.0).powf((14.5 - (self.attributes.od).max(8.0)) / 2.0);
speed_value *= (0.95 + self.attrs.od * self.attrs.od / 750.0)
* ((self.acc + relevant_acc) / 2.0).powf((14.5 - (self.attrs.od).max(8.0)) / 2.0);
// * Scale the speed value with # of 50s to punish doubletapping.
speed_value *= 0.99_f64.powf(
(self.n50 as f64 >= self.total_hits / 500.0) as u8 as f64
* (self.n50 as f64 - self.total_hits / 500.0),
(self.state.n50 as f64 >= total_hits / 500.0) as u8 as f64
* (self.state.n50 as f64 - total_hits / 500.0),
);
speed_value
@@ -567,12 +533,12 @@ impl OsuPPInner {
// * This percentage only considers HitCircles of any value - in this part
// * of the calculation we focus on hitting the timing hit window.
let amount_hit_objects_with_acc = self.attributes.n_circles;
let amount_hit_objects_with_acc = self.attrs.n_circles;
let mut better_acc_percentage = if amount_hit_objects_with_acc > 0 {
((self.n300 - (self.total_hits as usize - amount_hit_objects_with_acc)) * 6
+ self.n100 * 2
+ self.n50) as f64
((self.state.n300 - (self.state.total_hits() - amount_hit_objects_with_acc)) * 6
+ self.state.n100 * 2
+ self.state.n50) as f64
/ (amount_hit_objects_with_acc * 6) as f64
} else {
0.0
@@ -585,8 +551,7 @@ impl OsuPPInner {
// * Lots of arbitrary values from testing.
// * Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution.
let mut acc_value =
1.52163_f64.powf(self.attributes.od) * better_acc_percentage.powi(24) * 2.83;
let mut acc_value = 1.52163_f64.powf(self.attrs.od) * better_acc_percentage.powi(24) * 2.83;
// * Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
acc_value *= (amount_hit_objects_with_acc as f64 / 1000.0)
@@ -610,12 +575,14 @@ impl OsuPPInner {
return 0.0;
}
let mut flashlight_value = self.attributes.flashlight * self.attributes.flashlight * 25.0;
let mut flashlight_value = self.attrs.flashlight * self.attrs.flashlight * 25.0;
let total_hits = self.total_hits();
// * Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
if self.effective_miss_count > 0.0 {
flashlight_value *= 0.97
* (1.0 - (self.effective_miss_count / self.total_hits).powf(0.775))
* (1.0 - (self.effective_miss_count / total_hits).powf(0.775))
.powf(self.effective_miss_count.powf(0.875));
}
@@ -623,53 +590,48 @@ impl OsuPPInner {
// * Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
flashlight_value *= 0.7
+ 0.1 * (self.total_hits / 200.0).min(1.0)
+ (self.total_hits > 200.0) as u8 as f64
* 0.2
* ((self.total_hits - 200.0) / 200.0).min(1.0);
+ 0.1 * (total_hits / 200.0).min(1.0)
+ (total_hits > 200.0) as u8 as f64 * 0.2 * ((total_hits - 200.0) / 200.0).min(1.0);
// * Scale the flashlight value with accuracy _slightly_.
flashlight_value *= 0.5 + self.acc / 2.0;
// * It is important to also consider accuracy difficulty when doing that.
flashlight_value *= 0.98 + self.attributes.od * self.attributes.od / 2500.0;
flashlight_value *= 0.98 + self.attrs.od * self.attrs.od / 2500.0;
flashlight_value
}
fn get_combo_scaling_factor(&self) -> f64 {
if self.attributes.max_combo == 0 {
if self.attrs.max_combo == 0 {
1.0
} else {
((self.combo as f64).powf(0.8) / (self.attributes.max_combo as f64).powf(0.8)).min(1.0)
((self.state.max_combo as f64).powf(0.8) / (self.attrs.max_combo as f64).powf(0.8))
.min(1.0)
}
}
fn total_hits(&self) -> f64 {
self.state.total_hits() as f64
}
}
fn calculate_effective_misses(
attrs: &OsuDifficultyAttributes,
combo: Option<usize>,
n100: usize,
n50: usize,
n_misses: usize,
) -> f64 {
fn calculate_effective_misses(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> f64 {
// * Guess the number of misses + slider breaks from combo
let mut combo_based_miss_count = 0.0;
if attrs.n_sliders > 0 {
let full_combo_threshold = attrs.max_combo as f64 - 0.1 * attrs.n_sliders as f64;
if let Some(score_max_combo) = combo
.map(|combo| combo as f64)
.filter(|&combo| combo < full_combo_threshold)
{
combo_based_miss_count = full_combo_threshold / score_max_combo.max(1.0);
if (state.max_combo as f64) < full_combo_threshold {
combo_based_miss_count = full_combo_threshold / (state.max_combo as f64).max(1.0);
}
}
// * Clamp miss count to maximum amount of possible breaks
combo_based_miss_count = combo_based_miss_count.min((n100 + n50 + n_misses) as f64);
combo_based_miss_count =
combo_based_miss_count.min((state.n100 + state.n50 + state.n_misses) as f64);
combo_based_miss_count.max(n_misses as f64)
combo_based_miss_count.max(state.n_misses as f64)
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
@@ -783,21 +745,20 @@ mod test {
#[test]
fn osu_missing_objects() {
let map = Beatmap::default();
let attributes = OsuDifficultyAttributes::default();
let total_objects = 1234;
let n300 = 1000;
let n100 = 200;
let n50 = 30;
let calculator = OsuPP::new(&map)
let state = OsuPP::new(&map)
.passed_objects(total_objects)
.n300(n300)
.n100(n100)
.n50(n50)
.assert_hitresults(attributes);
.generate_hitresults(n300 + n100 + n50 + 50);
let n_objects = calculator.n300 + calculator.n100 + calculator.n50;
let n_objects = state.total_hits();
assert_eq!(
total_objects, n_objects,
+16
View File
@@ -254,6 +254,22 @@ impl<'map> AnyPP<'map> {
}
}
/// While generating remaining hitresults, decide how they should be distributed.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum HitResultPriority {
/// Prioritize good hitresults over bad ones
BestCase,
/// Prioritize bad hitresults over good ones
WorstCase,
}
impl Default for HitResultPriority {
#[inline]
fn default() -> Self {
Self::BestCase
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait AttributeProvider {
/// Provide the actual difficulty attributes.
+5 -5
View File
@@ -48,7 +48,7 @@ pub struct TaikoPP<'map> {
pub(crate) n300: Option<usize>,
pub(crate) n100: Option<usize>,
pub(crate) n_misses: usize,
pub(crate) n_misses: Option<usize>,
}
impl<'map> TaikoPP<'map> {
@@ -61,7 +61,7 @@ impl<'map> TaikoPP<'map> {
mods: 0,
combo: None,
acc: 1.0,
n_misses: 0,
n_misses: None,
passed_objects: None,
clock_rate: None,
n300: None,
@@ -118,7 +118,7 @@ impl<'map> TaikoPP<'map> {
/// Specify the amount of misses of the play.
#[inline]
pub fn n_misses(mut self, n_misses: usize) -> Self {
self.n_misses = n_misses.min(self.map.n_circles as usize);
self.n_misses = Some(n_misses.min(self.map.n_circles as usize));
self
}
@@ -168,7 +168,7 @@ impl<'map> TaikoPP<'map> {
self.combo = Some(max_combo);
self.n300 = Some(n300);
self.n100 = Some(n100);
self.n_misses = n_misses;
self.n_misses = Some(n_misses);
self
}
@@ -196,7 +196,7 @@ impl<'map> TaikoPP<'map> {
fn assert_hitresults(&'map self, attributes: TaikoDifficultyAttributes) -> TaikoPPInner<'map> {
let total_result_count = attributes.max_combo();
let misses = self.n_misses;
let misses = self.n_misses.unwrap_or(0);
let (n300, n100) = match (self.n300, self.n100) {
(Some(n300), Some(n100)) => {