gradual calc for osu
This commit is contained in:
@@ -5,12 +5,12 @@ pub fn strain_decay(ms: f64, strain_decay_base: f64) -> f64 {
|
||||
/// Wrapper around a difficulty skill that carries a list of all difficulty
|
||||
/// objects.
|
||||
pub struct Skill<'a, S: ISkill> {
|
||||
pub inner: S,
|
||||
pub inner: &'a mut S,
|
||||
pub diff_objects: &'a S::DifficultyObjects<'a>,
|
||||
}
|
||||
|
||||
impl<'a, S: ISkill> Skill<'a, S> {
|
||||
pub const fn new(skill: S, diff_objects: &'a S::DifficultyObjects<'a>) -> Self {
|
||||
pub fn new(skill: &'a mut S, diff_objects: &'a S::DifficultyObjects<'a>) -> Self {
|
||||
Self {
|
||||
inner: skill,
|
||||
diff_objects,
|
||||
|
||||
@@ -57,7 +57,7 @@ impl DifficultyValues {
|
||||
let mut attrs = CatchDifficultyAttributesBuilder::new(attrs, take);
|
||||
|
||||
let hr = difficulty.get_mods().hr();
|
||||
let movement = Movement::new(clock_rate);
|
||||
let mut movement = Movement::new(clock_rate);
|
||||
|
||||
let palpable_objects = convert_objects(converted, &mut attrs, hr, map_attrs.cs as f32);
|
||||
let mut palpable_objects_iter = palpable_objects.iter().take(take);
|
||||
@@ -90,14 +90,16 @@ impl DifficultyValues {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut movement = Skill::new(movement, &diff_objects);
|
||||
{
|
||||
let mut movement = Skill::new(&mut movement, &diff_objects);
|
||||
|
||||
for curr in diff_objects.iter() {
|
||||
movement.process(curr);
|
||||
for curr in diff_objects.iter() {
|
||||
movement.process(curr);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
movement: movement.inner,
|
||||
movement,
|
||||
attrs: attrs.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,14 +75,18 @@ impl DifficultyValues {
|
||||
let mut diff_objects = Vec::with_capacity(n_diff_objects);
|
||||
diff_objects.extend(diff_objects_iter);
|
||||
|
||||
let mut strain = Skill::new(Strain::new(total_columns as usize), &diff_objects);
|
||||
let mut strain = Strain::new(total_columns as usize);
|
||||
|
||||
for curr in diff_objects.iter() {
|
||||
strain.process(curr);
|
||||
{
|
||||
let mut strain = Skill::new(&mut strain, &diff_objects);
|
||||
|
||||
for curr in diff_objects.iter() {
|
||||
strain.process(curr);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
strain: strain.inner,
|
||||
strain,
|
||||
max_combo: params.into_max_combo(),
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -32,12 +32,12 @@ pub fn convert_objects(
|
||||
time_preempt: f64,
|
||||
mut take: usize,
|
||||
attrs: &mut OsuDifficultyAttributes,
|
||||
) -> Vec<OsuObject> {
|
||||
) -> Box<[OsuObject]> {
|
||||
let mut curve_bufs = CurveBuffers::default();
|
||||
// mean=5.16 | median=4
|
||||
let mut ticks_buf = Vec::new();
|
||||
|
||||
let mut osu_objects: Vec<_> = converted
|
||||
let mut osu_objects: Box<[_]> = converted
|
||||
.map
|
||||
.hit_objects
|
||||
.iter()
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
use std::{
|
||||
fmt::{Debug, Formatter, Result as FmtResult},
|
||||
mem,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
any::difficulty::skills::Skill,
|
||||
osu::{
|
||||
convert::convert_objects,
|
||||
object::{OsuObject, OsuObjectKind},
|
||||
OsuBeatmap,
|
||||
},
|
||||
util::mods::Mods,
|
||||
ModeDifficulty,
|
||||
};
|
||||
|
||||
use self::osu_objects::OsuObjects;
|
||||
|
||||
use super::{
|
||||
object::OsuDifficultyObject, skills::OsuSkills, DifficultyValues, OsuDifficultyAttributes,
|
||||
OsuDifficultySetup,
|
||||
};
|
||||
|
||||
/// Gradually calculate the difficulty attributes of an osu!standard map.
|
||||
///
|
||||
/// Note that this struct implements [`Iterator`].
|
||||
/// On every call of [`Iterator::next`], the map's next hit object will
|
||||
/// be processed and the [`OsuDifficultyAttributes`] will be updated and
|
||||
/// returned.
|
||||
///
|
||||
/// If you want to calculate performance attributes, use
|
||||
/// [`OsuGradualPerformance`] instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, ModeDifficulty};
|
||||
/// use rosu_pp::osu::{Osu, OsuGradualDifficulty};
|
||||
///
|
||||
/// let converted = Beatmap::from_path("./resources/2785319.osu")
|
||||
/// .unwrap()
|
||||
/// .unchecked_into_converted::<Osu>()
|
||||
/// .unwrap();
|
||||
///
|
||||
/// let difficulty = ModeDifficulty::new().mods(64); // DT
|
||||
/// let mut iter = OsuGradualDifficulty::new(&difficulty, &converted);
|
||||
///
|
||||
/// // the difficulty of the map after the first hit object
|
||||
/// let attrs1 = iter.next();
|
||||
/// // ... after the second hit object
|
||||
/// let attrs2 = iter.next();
|
||||
///
|
||||
/// // Remaining hit objects
|
||||
/// for difficulty in iter {
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// [`OsuGradualPerformance`]: crate::osu::OsuGradualPerformance
|
||||
pub struct OsuGradualDifficulty {
|
||||
pub(crate) idx: usize,
|
||||
mods: u32,
|
||||
attrs: OsuDifficultyAttributes,
|
||||
skills: OsuSkills,
|
||||
// Lifetimes actually depend on `osu_objects` so this type is
|
||||
// self-referential. This field must be treated with great caution, moving
|
||||
// `osu_objects` will immediately invalidate `diff_objects`.
|
||||
diff_objects: Box<[OsuDifficultyObject<'static>]>,
|
||||
osu_objects: OsuObjects,
|
||||
// Additional safety measure that this type can't be cloned as it would
|
||||
// invalidate `diff_objects`.
|
||||
_not_clonable: NotClonable,
|
||||
}
|
||||
|
||||
struct NotClonable;
|
||||
|
||||
impl Debug for OsuGradualDifficulty {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
f.debug_struct("OsuGradualDifficulty")
|
||||
.field("idx", &self.idx)
|
||||
.field("mods", &self.mods)
|
||||
.field("attrs", &self.attrs)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuGradualDifficulty {
|
||||
/// Create a new difficulty attributes iterator for osu!standard maps.
|
||||
pub fn new(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> Self {
|
||||
let mods = difficulty.get_mods();
|
||||
|
||||
let OsuDifficultySetup {
|
||||
scaling_factor,
|
||||
map_attrs,
|
||||
mut attrs,
|
||||
time_preempt,
|
||||
} = OsuDifficultySetup::new(&difficulty, converted);
|
||||
|
||||
let osu_objects = convert_objects(
|
||||
converted,
|
||||
&scaling_factor,
|
||||
mods.hr(),
|
||||
time_preempt,
|
||||
converted.map.hit_objects.len(),
|
||||
&mut attrs,
|
||||
);
|
||||
|
||||
attrs.n_circles = 0;
|
||||
attrs.n_sliders = 0;
|
||||
attrs.n_spinners = 0;
|
||||
attrs.max_combo = 0;
|
||||
|
||||
if let Some(h) = osu_objects.first() {
|
||||
Self::increment_combo(h, &mut attrs);
|
||||
}
|
||||
|
||||
let mut osu_objects = OsuObjects::new(osu_objects);
|
||||
|
||||
let diff_objects = DifficultyValues::create_difficulty_objects(
|
||||
difficulty,
|
||||
&scaling_factor,
|
||||
osu_objects.iter_mut(),
|
||||
);
|
||||
|
||||
let skills = OsuSkills::new(mods, &scaling_factor, &map_attrs, time_preempt);
|
||||
let diff_objects = extend_lifetime(diff_objects.into_boxed_slice());
|
||||
|
||||
Self {
|
||||
idx: 0,
|
||||
mods,
|
||||
attrs,
|
||||
skills,
|
||||
diff_objects,
|
||||
osu_objects,
|
||||
_not_clonable: NotClonable,
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_combo(h: &OsuObject, attrs: &mut OsuDifficultyAttributes) {
|
||||
attrs.max_combo += 1;
|
||||
|
||||
match &h.kind {
|
||||
OsuObjectKind::Circle => attrs.n_circles += 1,
|
||||
OsuObjectKind::Slider(slider) => {
|
||||
attrs.n_sliders += 1;
|
||||
attrs.max_combo += slider.nested_objects.len() as u32;
|
||||
}
|
||||
OsuObjectKind::Spinner { .. } => attrs.n_spinners += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extend_lifetime(
|
||||
diff_objects: Box<[OsuDifficultyObject<'_>]>,
|
||||
) -> Box<[OsuDifficultyObject<'static>]> {
|
||||
// SAFETY: Owned values of the references will be contained in the same
|
||||
// struct (same lifetime). Also, the only mutable access wraps them in
|
||||
// `Pin` to ensure that they won't move.
|
||||
unsafe { mem::transmute(diff_objects) }
|
||||
}
|
||||
|
||||
impl Iterator for OsuGradualDifficulty {
|
||||
type Item = OsuDifficultyAttributes;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
// The first difficulty object belongs to the second note since each
|
||||
// difficulty object requires the current and the last note. Hence, if
|
||||
// we're still on the first object, we don't have a difficulty object
|
||||
// yet and just skip processing.
|
||||
if self.idx > 0 {
|
||||
let curr = self.diff_objects.get(self.idx - 1)?;
|
||||
|
||||
Skill::new(&mut self.skills.aim, &self.diff_objects).process(curr);
|
||||
Skill::new(&mut self.skills.aim_no_sliders, &self.diff_objects).process(curr);
|
||||
Skill::new(&mut self.skills.speed, &self.diff_objects).process(curr);
|
||||
Skill::new(&mut self.skills.flashlight, &self.diff_objects).process(curr);
|
||||
|
||||
Self::increment_combo(curr.base, &mut self.attrs);
|
||||
} else if self.osu_objects.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.idx += 1;
|
||||
|
||||
let mut attrs = self.attrs.clone();
|
||||
|
||||
let aim_difficulty_value = self.skills.aim.as_difficulty_value();
|
||||
let aim_no_sliders_difficulty_value = self.skills.aim_no_sliders.as_difficulty_value();
|
||||
let speed_relevant_note_count = self.skills.speed.relevant_note_count();
|
||||
let speed_difficulty_value = self.skills.speed.as_difficulty_value();
|
||||
let flashlight_difficulty_value = self.skills.flashlight.as_difficulty_value();
|
||||
|
||||
DifficultyValues::eval(
|
||||
&mut attrs,
|
||||
self.mods,
|
||||
aim_difficulty_value,
|
||||
aim_no_sliders_difficulty_value,
|
||||
speed_difficulty_value,
|
||||
speed_relevant_note_count,
|
||||
flashlight_difficulty_value,
|
||||
);
|
||||
|
||||
Some(attrs)
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = self.len();
|
||||
|
||||
(len, Some(len))
|
||||
}
|
||||
|
||||
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||
let skip_iter = self.diff_objects.iter().skip(self.idx.saturating_sub(1));
|
||||
|
||||
let mut take = n.min(self.len().saturating_sub(1));
|
||||
|
||||
// The first note has no difficulty object
|
||||
if self.idx == 0 && take > 0 {
|
||||
take -= 1;
|
||||
self.idx += 1;
|
||||
}
|
||||
|
||||
let mut aim = Skill::new(&mut self.skills.aim, &self.diff_objects);
|
||||
let mut aim_no_sliders = Skill::new(&mut self.skills.aim_no_sliders, &self.diff_objects);
|
||||
let mut speed = Skill::new(&mut self.skills.speed, &self.diff_objects);
|
||||
let mut flashlight = Skill::new(&mut self.skills.flashlight, &self.diff_objects);
|
||||
|
||||
for curr in skip_iter.take(take) {
|
||||
aim.process(curr);
|
||||
aim_no_sliders.process(curr);
|
||||
speed.process(curr);
|
||||
flashlight.process(curr);
|
||||
|
||||
Self::increment_combo(curr.base, &mut self.attrs);
|
||||
self.idx += 1;
|
||||
}
|
||||
|
||||
self.next()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExactSizeIterator for OsuGradualDifficulty {
|
||||
fn len(&self) -> usize {
|
||||
self.diff_objects.len() + 1 - self.idx
|
||||
}
|
||||
}
|
||||
|
||||
mod osu_objects {
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::osu::object::OsuObject;
|
||||
|
||||
/// Wrapper to ensure that the data will not be moved
|
||||
pub(super) struct OsuObjects {
|
||||
objects: Box<[OsuObject]>,
|
||||
}
|
||||
|
||||
impl OsuObjects {
|
||||
pub(super) fn new(objects: Box<[OsuObject]>) -> Self {
|
||||
Self { objects }
|
||||
}
|
||||
|
||||
pub(super) const fn is_empty(&self) -> bool {
|
||||
self.objects.is_empty()
|
||||
}
|
||||
|
||||
pub(super) fn iter_mut(&mut self) -> impl ExactSizeIterator<Item = Pin<&mut OsuObject>> {
|
||||
self.objects.iter_mut().map(Pin::new)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use crate::{osu::Osu, Beatmap};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty() {
|
||||
let converted = Beatmap::from_bytes(&[])
|
||||
.unwrap()
|
||||
.unchecked_into_converted::<Osu>();
|
||||
|
||||
let difficulty = ModeDifficulty::new();
|
||||
let mut gradual = OsuGradualDifficulty::new(&difficulty, &converted);
|
||||
|
||||
assert!(gradual.next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_and_nth() {
|
||||
let converted = Beatmap::from_path("./resources/2785319.osu")
|
||||
.unwrap()
|
||||
.unchecked_into_converted::<Osu>();
|
||||
|
||||
let difficulty = ModeDifficulty::new();
|
||||
|
||||
let mut gradual = OsuGradualDifficulty::new(&difficulty, &converted);
|
||||
let mut gradual_2nd = OsuGradualDifficulty::new(&difficulty, &converted);
|
||||
let mut gradual_3rd = OsuGradualDifficulty::new(&difficulty, &converted);
|
||||
|
||||
let hit_objects_len = converted.map.hit_objects.len();
|
||||
|
||||
for i in 1.. {
|
||||
let Some(next_gradual) = gradual.next() else {
|
||||
assert_eq!(i, hit_objects_len + 1);
|
||||
assert!(gradual_2nd.last().is_some() || hit_objects_len % 2 == 0);
|
||||
assert!(gradual_3rd.last().is_some() || hit_objects_len % 3 == 0);
|
||||
break;
|
||||
};
|
||||
|
||||
if i % 2 == 0 {
|
||||
let next_gradual_2nd = gradual_2nd.nth(1).unwrap();
|
||||
assert_eq!(next_gradual, next_gradual_2nd);
|
||||
}
|
||||
|
||||
if i % 3 == 0 {
|
||||
let next_gradual_3rd = gradual_3rd.nth(2).unwrap();
|
||||
assert_eq!(next_gradual, next_gradual_3rd);
|
||||
}
|
||||
|
||||
let expected = ModeDifficulty::new()
|
||||
.passed_objects(i as u32)
|
||||
.calculate(&converted);
|
||||
|
||||
assert_eq!(next_gradual, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
-128
@@ -1,7 +1,8 @@
|
||||
use std::cmp;
|
||||
use std::{cmp, pin::Pin};
|
||||
|
||||
use crate::{
|
||||
any::difficulty::{skills::Skill, ModeDifficulty},
|
||||
model::beatmap::BeatmapAttributes,
|
||||
osu::{
|
||||
convert::convert_objects,
|
||||
difficulty::{object::OsuDifficultyObject, scaling_factor::ScalingFactor},
|
||||
@@ -11,13 +12,14 @@ use crate::{
|
||||
util::mods::Mods,
|
||||
};
|
||||
|
||||
use self::skills::{aim::Aim, flashlight::Flashlight, speed::Speed};
|
||||
use self::skills::OsuSkills;
|
||||
|
||||
use super::{attributes::OsuDifficultyAttributes, convert::OsuBeatmap};
|
||||
|
||||
pub mod gradual;
|
||||
mod object;
|
||||
pub mod scaling_factor;
|
||||
mod skills;
|
||||
pub mod skills;
|
||||
|
||||
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
|
||||
|
||||
@@ -29,84 +31,47 @@ pub fn difficulty(
|
||||
converted: &OsuBeatmap<'_>,
|
||||
) -> OsuDifficultyAttributes {
|
||||
let DifficultyValues {
|
||||
aim,
|
||||
aim_no_sliders,
|
||||
speed,
|
||||
flashlight,
|
||||
skills:
|
||||
OsuSkills {
|
||||
aim,
|
||||
aim_no_sliders,
|
||||
speed,
|
||||
flashlight,
|
||||
},
|
||||
mut attrs,
|
||||
} = DifficultyValues::calculate(difficulty, converted);
|
||||
|
||||
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let aim_rating_no_sliders = aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let speed_notes = speed.relevant_note_count();
|
||||
let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let slider_factor = if aim_rating > 0.0 {
|
||||
aim_rating_no_sliders / aim_rating
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let aim_difficulty_value = aim.difficulty_value();
|
||||
let aim_no_sliders_difficulty_value = aim_no_sliders.difficulty_value();
|
||||
let speed_relevant_note_count = speed.relevant_note_count();
|
||||
let speed_difficulty_value = speed.difficulty_value();
|
||||
let flashlight_difficulty_value = flashlight.difficulty_value();
|
||||
|
||||
let mods = difficulty.get_mods();
|
||||
|
||||
if mods.td() {
|
||||
aim_rating = aim_rating.powf(0.8);
|
||||
flashlight_rating = flashlight_rating.powf(0.8);
|
||||
}
|
||||
|
||||
if mods.rx() {
|
||||
aim_rating *= 0.9;
|
||||
speed_rating = 0.0;
|
||||
flashlight_rating *= 0.7;
|
||||
}
|
||||
|
||||
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
let base_speed_performance = (5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
let base_flashlight_performance = if mods.fl() {
|
||||
flashlight_rating.powi(2) * 25.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let base_performance = ((base_aim_performance).powf(1.1)
|
||||
+ (base_speed_performance).powf(1.1)
|
||||
+ (base_flashlight_performance).powf(1.1))
|
||||
.powf(1.0 / 1.1);
|
||||
|
||||
let star_rating = if base_performance > 0.00001 {
|
||||
PERFORMANCE_BASE_MULTIPLIER.cbrt()
|
||||
* 0.027
|
||||
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
attrs.aim = aim_rating;
|
||||
attrs.speed = speed_rating;
|
||||
attrs.flashlight = flashlight_rating;
|
||||
attrs.slider_factor = slider_factor;
|
||||
attrs.stars = star_rating;
|
||||
attrs.speed_note_count = speed_notes;
|
||||
DifficultyValues::eval(
|
||||
&mut attrs,
|
||||
mods,
|
||||
aim_difficulty_value,
|
||||
aim_no_sliders_difficulty_value,
|
||||
speed_difficulty_value,
|
||||
speed_relevant_note_count,
|
||||
flashlight_difficulty_value,
|
||||
);
|
||||
|
||||
attrs
|
||||
}
|
||||
|
||||
pub struct DifficultyValues {
|
||||
pub aim: Aim,
|
||||
pub aim_no_sliders: Aim,
|
||||
pub speed: Speed,
|
||||
pub flashlight: Flashlight,
|
||||
pub attrs: OsuDifficultyAttributes,
|
||||
pub struct OsuDifficultySetup {
|
||||
scaling_factor: ScalingFactor,
|
||||
map_attrs: BeatmapAttributes,
|
||||
attrs: OsuDifficultyAttributes,
|
||||
time_preempt: f64,
|
||||
}
|
||||
|
||||
impl DifficultyValues {
|
||||
pub fn calculate(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> Self {
|
||||
impl OsuDifficultySetup {
|
||||
pub fn new(difficulty: &ModeDifficulty, converted: &OsuBeatmap) -> Self {
|
||||
let mods = difficulty.get_mods();
|
||||
let take = difficulty.get_passed_objects();
|
||||
let clock_rate = difficulty.get_clock_rate();
|
||||
|
||||
let map_attrs = converted
|
||||
@@ -116,95 +81,177 @@ impl DifficultyValues {
|
||||
.build();
|
||||
|
||||
let scaling_factor = ScalingFactor::new(map_attrs.cs);
|
||||
let hr = mods.hr();
|
||||
let hit_window = 2.0 * map_attrs.hit_windows.od;
|
||||
let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32);
|
||||
|
||||
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
|
||||
// * which uniformly speeds up all animations game wide regardless of AR.
|
||||
// * This uniform speedup is hard to match 1:1, however we can at least make
|
||||
// * AR>10 (via mods) feel good by extending the upper linear function above.
|
||||
// * Note that this doesn't exactly match the AR>10 visuals as they're
|
||||
// * classically known, but it feels good.
|
||||
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
|
||||
// * become smaller leading to hitcircles not fully fading in.
|
||||
let time_fade_in = if mods.hd() {
|
||||
time_preempt * HD_FADE_IN_DURATION_MULTIPLIER
|
||||
} else {
|
||||
400.0 * (time_preempt / OsuObject::PREEMPT_MIN).min(1.0)
|
||||
};
|
||||
|
||||
let mut attrs = OsuDifficultyAttributes {
|
||||
let attrs = OsuDifficultyAttributes {
|
||||
ar: map_attrs.ar,
|
||||
hp: map_attrs.hp,
|
||||
od: map_attrs.od,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32);
|
||||
|
||||
Self {
|
||||
scaling_factor,
|
||||
map_attrs,
|
||||
attrs,
|
||||
time_preempt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DifficultyValues {
|
||||
pub skills: OsuSkills,
|
||||
pub attrs: OsuDifficultyAttributes,
|
||||
}
|
||||
|
||||
impl DifficultyValues {
|
||||
pub fn calculate(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> Self {
|
||||
let mods = difficulty.get_mods();
|
||||
let take = difficulty.get_passed_objects();
|
||||
|
||||
let OsuDifficultySetup {
|
||||
scaling_factor,
|
||||
map_attrs,
|
||||
mut attrs,
|
||||
time_preempt,
|
||||
} = OsuDifficultySetup::new(difficulty, converted);
|
||||
|
||||
let mut osu_objects = convert_objects(
|
||||
converted,
|
||||
&scaling_factor,
|
||||
hr,
|
||||
mods.hr(),
|
||||
time_preempt,
|
||||
take,
|
||||
&mut attrs,
|
||||
);
|
||||
|
||||
let mut osu_objects_iter = osu_objects
|
||||
.iter_mut()
|
||||
.map(|h| OsuDifficultyObject::compute_slider_cursor_pos(h, scaling_factor.radius));
|
||||
let osu_object_iter = osu_objects.iter_mut().map(Pin::new);
|
||||
|
||||
let aim = Aim::new(true);
|
||||
let aim_no_sliders = Aim::new(false);
|
||||
let speed = Speed::new(hit_window);
|
||||
let flashlight = Flashlight::new(mods, scaling_factor.radius, time_preempt, time_fade_in);
|
||||
let diff_objects =
|
||||
Self::create_difficulty_objects(difficulty, &scaling_factor, osu_object_iter);
|
||||
|
||||
let mut skills = OsuSkills::new(mods, &scaling_factor, &map_attrs, time_preempt);
|
||||
|
||||
{
|
||||
let mut aim = Skill::new(&mut skills.aim, &diff_objects);
|
||||
let mut aim_no_sliders = Skill::new(&mut skills.aim_no_sliders, &diff_objects);
|
||||
let mut speed = Skill::new(&mut skills.speed, &diff_objects);
|
||||
let mut flashlight = Skill::new(&mut skills.flashlight, &diff_objects);
|
||||
|
||||
// The first hit object has no difficulty object
|
||||
let take_diff_objects =
|
||||
cmp::min(converted.map.hit_objects.len(), take).saturating_sub(1);
|
||||
|
||||
for hit_object in diff_objects.iter().take(take_diff_objects) {
|
||||
aim.process(hit_object);
|
||||
aim_no_sliders.process(hit_object);
|
||||
speed.process(hit_object);
|
||||
flashlight.process(hit_object);
|
||||
}
|
||||
}
|
||||
|
||||
Self { skills, attrs }
|
||||
}
|
||||
|
||||
/// Process the difficulty values and store the results in `attrs`.
|
||||
pub fn eval(
|
||||
attrs: &mut OsuDifficultyAttributes,
|
||||
mods: u32,
|
||||
aim_difficulty_value: f64,
|
||||
aim_no_sliders_difficulty_value: f64,
|
||||
speed_difficulty_value: f64,
|
||||
speed_relevant_note_count: f64,
|
||||
flashlight_difficulty_value: f64,
|
||||
) {
|
||||
let mut aim_rating = aim_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let aim_rating_no_sliders = aim_no_sliders_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let mut speed_rating = speed_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let mut flashlight_rating = flashlight_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let slider_factor = if aim_rating > 0.0 {
|
||||
aim_rating_no_sliders / aim_rating
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
if mods.td() {
|
||||
aim_rating = aim_rating.powf(0.8);
|
||||
flashlight_rating = flashlight_rating.powf(0.8);
|
||||
}
|
||||
|
||||
if mods.rx() {
|
||||
aim_rating *= 0.9;
|
||||
speed_rating = 0.0;
|
||||
flashlight_rating *= 0.7;
|
||||
}
|
||||
|
||||
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
let base_speed_performance =
|
||||
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
let base_flashlight_performance = if mods.fl() {
|
||||
flashlight_rating.powi(2) * 25.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let base_performance = ((base_aim_performance).powf(1.1)
|
||||
+ (base_speed_performance).powf(1.1)
|
||||
+ (base_flashlight_performance).powf(1.1))
|
||||
.powf(1.0 / 1.1);
|
||||
|
||||
let star_rating = if base_performance > 0.00001 {
|
||||
PERFORMANCE_BASE_MULTIPLIER.cbrt()
|
||||
* 0.027
|
||||
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
attrs.aim = aim_rating;
|
||||
attrs.speed = speed_rating;
|
||||
attrs.flashlight = flashlight_rating;
|
||||
attrs.slider_factor = slider_factor;
|
||||
attrs.stars = star_rating;
|
||||
attrs.speed_note_count = speed_relevant_note_count;
|
||||
}
|
||||
|
||||
pub fn create_difficulty_objects<'a>(
|
||||
difficulty: &ModeDifficulty,
|
||||
scaling_factor: &ScalingFactor,
|
||||
osu_objects: impl Iterator<Item = Pin<&'a mut OsuObject>>,
|
||||
) -> Vec<OsuDifficultyObject<'a>> {
|
||||
let take = difficulty.get_passed_objects();
|
||||
let clock_rate = difficulty.get_clock_rate();
|
||||
|
||||
let mut osu_objects_iter = osu_objects
|
||||
.map(|h| OsuDifficultyObject::compute_slider_cursor_pos(h, scaling_factor.radius))
|
||||
.map(Pin::into_ref);
|
||||
|
||||
let Some(mut last) = osu_objects_iter.next().filter(|_| take > 0) else {
|
||||
return Self {
|
||||
aim,
|
||||
aim_no_sliders,
|
||||
speed,
|
||||
flashlight,
|
||||
attrs,
|
||||
};
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut last_last = None;
|
||||
|
||||
let diff_objects: Vec<_> = osu_objects_iter
|
||||
osu_objects_iter
|
||||
.enumerate()
|
||||
.map(|(idx, h)| {
|
||||
let diff_object =
|
||||
OsuDifficultyObject::new(h, last, last_last, clock_rate, idx, &scaling_factor);
|
||||
let diff_object = OsuDifficultyObject::new(
|
||||
h.get_ref(),
|
||||
last.get_ref(),
|
||||
last_last.as_deref(),
|
||||
clock_rate,
|
||||
idx,
|
||||
&scaling_factor,
|
||||
);
|
||||
|
||||
last_last = Some(last);
|
||||
last = h;
|
||||
|
||||
diff_object
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut aim = Skill::new(aim, &diff_objects);
|
||||
let mut aim_no_sliders = Skill::new(aim_no_sliders, &diff_objects);
|
||||
let mut speed = Skill::new(speed, &diff_objects);
|
||||
let mut flashlight = Skill::new(flashlight, &diff_objects);
|
||||
|
||||
// The first hit object has no difficulty object
|
||||
let take_diff_objects = cmp::min(converted.map.hit_objects.len(), take) - 1;
|
||||
|
||||
for hit_object in diff_objects.iter().take(take_diff_objects) {
|
||||
aim.process(hit_object);
|
||||
aim_no_sliders.process(hit_object);
|
||||
speed.process(hit_object);
|
||||
flashlight.process(hit_object);
|
||||
}
|
||||
|
||||
Self {
|
||||
aim: aim.inner,
|
||||
aim_no_sliders: aim_no_sliders.inner,
|
||||
speed: speed.inner,
|
||||
flashlight: flashlight.inner,
|
||||
attrs,
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::pin::Pin;
|
||||
|
||||
use rosu_map::util::Pos;
|
||||
|
||||
use crate::{
|
||||
@@ -148,14 +150,20 @@ impl<'a> OsuDifficultyObject<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_slider_cursor_pos(h: &mut OsuObject, radius: f64) -> &OsuObject {
|
||||
/// The [`Pin<&mut OsuObject>`](std::pin::Pin) denotes that the object will
|
||||
/// be mutated but not moved.
|
||||
pub fn compute_slider_cursor_pos(
|
||||
mut h: Pin<&mut OsuObject>,
|
||||
radius: f64,
|
||||
) -> Pin<&mut OsuObject> {
|
||||
let pos = h.pos;
|
||||
let stack_offset = h.stack_offset;
|
||||
|
||||
let OsuObjectKind::Slider(ref mut slider) = h.kind else {
|
||||
return h;
|
||||
};
|
||||
|
||||
let mut curr_cursor_pos = h.pos + stack_offset;
|
||||
let mut curr_cursor_pos = pos + stack_offset;
|
||||
let scaling_factor = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS) / radius;
|
||||
|
||||
for (curr_movement_obj, i) in slider.nested_objects.iter().zip(1..) {
|
||||
|
||||
@@ -14,6 +14,7 @@ use super::strain::OsuStrainSkill;
|
||||
const SKILL_MULTIPLIER: f64 = 23.55;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.15;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Aim {
|
||||
with_sliders: bool,
|
||||
curr_strain: f64,
|
||||
@@ -34,7 +35,17 @@ impl Aim {
|
||||
}
|
||||
|
||||
pub fn difficulty_value(self) -> f64 {
|
||||
self.inner.difficulty_value(
|
||||
Self::static_difficulty_value(self.inner)
|
||||
}
|
||||
|
||||
/// Use [`difficulty_value`] instead whenever possible because
|
||||
/// [`as_difficulty_value`] clones internally.
|
||||
pub fn as_difficulty_value(&self) -> f64 {
|
||||
Self::static_difficulty_value(self.inner.clone())
|
||||
}
|
||||
|
||||
fn static_difficulty_value(skill: OsuStrainSkill) -> f64 {
|
||||
skill.difficulty_value(
|
||||
OsuStrainSkill::REDUCED_SECTION_COUNT,
|
||||
OsuStrainSkill::REDUCED_STRAIN_BASELINE,
|
||||
OsuStrainSkill::DECAY_WEIGHT,
|
||||
@@ -56,7 +67,7 @@ impl<'a> Skill<'a, Aim> {
|
||||
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
|
||||
}
|
||||
|
||||
const fn curr_section_peak(&self) -> f64 {
|
||||
fn curr_section_peak(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
@@ -64,7 +75,7 @@ impl<'a> Skill<'a, Aim> {
|
||||
&mut self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
const fn curr_section_end(&self) -> f64 {
|
||||
fn curr_section_end(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,18 @@ impl Flashlight {
|
||||
}
|
||||
|
||||
pub fn difficulty_value(self) -> f64 {
|
||||
self.get_curr_strain_peaks().into_iter().sum::<f64>() * OsuStrainSkill::DIFFICULTY_MULTIPLER
|
||||
Self::static_difficulty_value(self.inner)
|
||||
}
|
||||
|
||||
/// Use [`difficulty_value`] instead whenever possible because
|
||||
/// [`as_difficulty_value`] clones internally.
|
||||
pub fn as_difficulty_value(&self) -> f64 {
|
||||
Self::static_difficulty_value(self.inner.clone())
|
||||
}
|
||||
|
||||
fn static_difficulty_value(skill: StrainSkill) -> f64 {
|
||||
skill.get_curr_strain_peaks().into_iter().sum::<f64>()
|
||||
* OsuStrainSkill::DIFFICULTY_MULTIPLER
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +64,7 @@ impl<'a> Skill<'a, Flashlight> {
|
||||
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
|
||||
}
|
||||
|
||||
const fn curr_section_peak(&self) -> f64 {
|
||||
fn curr_section_peak(&self) -> f64 {
|
||||
self.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
@@ -61,7 +72,7 @@ impl<'a> Skill<'a, Flashlight> {
|
||||
&mut self.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
const fn curr_section_end(&self) -> f64 {
|
||||
fn curr_section_end(&self) -> f64 {
|
||||
self.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,54 @@
|
||||
use crate::{model::beatmap::BeatmapAttributes, osu::object::OsuObject, util::mods::Mods};
|
||||
|
||||
use self::{aim::Aim, flashlight::Flashlight, speed::Speed};
|
||||
|
||||
use super::{scaling_factor::ScalingFactor, HD_FADE_IN_DURATION_MULTIPLIER};
|
||||
|
||||
pub mod aim;
|
||||
pub mod flashlight;
|
||||
pub mod speed;
|
||||
pub mod strain;
|
||||
|
||||
pub struct OsuSkills {
|
||||
pub aim: Aim,
|
||||
pub aim_no_sliders: Aim,
|
||||
pub speed: Speed,
|
||||
pub flashlight: Flashlight,
|
||||
}
|
||||
|
||||
impl OsuSkills {
|
||||
pub fn new(
|
||||
mods: u32,
|
||||
scaling_factor: &ScalingFactor,
|
||||
map_attrs: &BeatmapAttributes,
|
||||
time_preempt: f64,
|
||||
) -> Self {
|
||||
let hit_window = 2.0 * map_attrs.hit_windows.od;
|
||||
|
||||
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
|
||||
// * which uniformly speeds up all animations game wide regardless of AR.
|
||||
// * This uniform speedup is hard to match 1:1, however we can at least make
|
||||
// * AR>10 (via mods) feel good by extending the upper linear function above.
|
||||
// * Note that this doesn't exactly match the AR>10 visuals as they're
|
||||
// * classically known, but it feels good.
|
||||
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
|
||||
// * become smaller leading to hitcircles not fully fading in.
|
||||
let time_fade_in = if mods.hd() {
|
||||
time_preempt * HD_FADE_IN_DURATION_MULTIPLIER
|
||||
} else {
|
||||
400.0 * (time_preempt / OsuObject::PREEMPT_MIN).min(1.0)
|
||||
};
|
||||
|
||||
let aim = Aim::new(true);
|
||||
let aim_no_sliders = Aim::new(false);
|
||||
let speed = Speed::new(hit_window);
|
||||
let flashlight = Flashlight::new(mods, scaling_factor.radius, time_preempt, time_fade_in);
|
||||
|
||||
Self {
|
||||
aim,
|
||||
aim_no_sliders,
|
||||
speed,
|
||||
flashlight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const STRAIN_DECAY_BASE: f64 = 0.3;
|
||||
const DIFFICULTY_MULTIPLER: f64 = 1.04;
|
||||
const REDUCED_SECTION_COUNT: usize = 5;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Speed {
|
||||
curr_strain: f64,
|
||||
curr_rhythm: f64,
|
||||
@@ -41,7 +42,17 @@ impl Speed {
|
||||
}
|
||||
|
||||
pub fn difficulty_value(self) -> f64 {
|
||||
self.inner.difficulty_value(
|
||||
Self::static_difficulty_value(self.inner)
|
||||
}
|
||||
|
||||
/// Use [`difficulty_value`] instead whenever possible because
|
||||
/// [`as_difficulty_value`] clones internally.
|
||||
pub fn as_difficulty_value(&self) -> f64 {
|
||||
Self::static_difficulty_value(self.inner.clone())
|
||||
}
|
||||
|
||||
fn static_difficulty_value(skill: OsuStrainSkill) -> f64 {
|
||||
skill.difficulty_value(
|
||||
REDUCED_SECTION_COUNT,
|
||||
OsuStrainSkill::REDUCED_STRAIN_BASELINE,
|
||||
OsuStrainSkill::DECAY_WEIGHT,
|
||||
@@ -77,7 +88,7 @@ impl<'a> Skill<'a, Speed> {
|
||||
* strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
|
||||
}
|
||||
|
||||
const fn curr_section_peak(&self) -> f64 {
|
||||
fn curr_section_peak(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
@@ -85,7 +96,7 @@ impl<'a> Skill<'a, Speed> {
|
||||
&mut self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
const fn curr_section_end(&self) -> f64 {
|
||||
fn curr_section_end(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::any::difficulty::skills::StrainSkill;
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct OsuStrainSkill {
|
||||
pub inner: StrainSkill,
|
||||
}
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@ use crate::{
|
||||
pub use self::{
|
||||
attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes},
|
||||
convert::OsuBeatmap,
|
||||
performance::OsuPerformance,
|
||||
difficulty::gradual::OsuGradualDifficulty,
|
||||
performance::{gradual::OsuGradualPerformance, OsuPerformance},
|
||||
score_state::OsuScoreState,
|
||||
strains::OsuStrains,
|
||||
};
|
||||
|
||||
@@ -1,833 +0,0 @@
|
||||
use std::cmp;
|
||||
|
||||
use rosu_map::section::general::GameMode;
|
||||
|
||||
use crate::{
|
||||
any::ModeDifficulty,
|
||||
any::{HitResultPriority, ModeAttributeProvider, Performance},
|
||||
catch::CatchPerformance,
|
||||
mania::ManiaPerformance,
|
||||
taiko::TaikoPerformance,
|
||||
util::{float_ext::FloatExt, map_or_attrs::MapOrAttrs, mods::Mods},
|
||||
};
|
||||
|
||||
use super::{
|
||||
attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes},
|
||||
convert::OsuBeatmap,
|
||||
score_state::OsuScoreState,
|
||||
Osu,
|
||||
};
|
||||
|
||||
/// Performance calculator on osu!standard maps.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[must_use]
|
||||
pub struct OsuPerformance<'map> {
|
||||
pub(crate) map_or_attrs: MapOrAttrs<'map, Osu>,
|
||||
pub(crate) mods: u32,
|
||||
pub(crate) acc: Option<f64>,
|
||||
pub(crate) combo: Option<u32>,
|
||||
|
||||
pub(crate) n300: Option<u32>,
|
||||
pub(crate) n100: Option<u32>,
|
||||
pub(crate) n50: Option<u32>,
|
||||
pub(crate) n_misses: Option<u32>,
|
||||
pub(crate) passed_objects: Option<u32>,
|
||||
pub(crate) clock_rate: Option<f64>,
|
||||
pub(crate) hitresult_priority: HitResultPriority,
|
||||
}
|
||||
|
||||
impl<'map> OsuPerformance<'map> {
|
||||
/// Create a new performance calculator for osu!standard maps.
|
||||
pub fn new(map: OsuBeatmap<'map>) -> Self {
|
||||
map.into()
|
||||
}
|
||||
|
||||
/// Attempt to convert the map to the specified mode.
|
||||
///
|
||||
/// Returns `None` if the internal beatmap was already replaced with
|
||||
/// [`OsuDifficultyAttributes`], i.e. if [`OsuPerformance::attributes`] or
|
||||
/// [`OsuPerformance::generate_state`] was called.
|
||||
///
|
||||
/// If the given mode should be ignored in case the internal beatmap was
|
||||
/// replaced, use [`mode_or_ignore`] instead.
|
||||
///
|
||||
/// [`mode_or_ignore`]: Self::mode_or_ignore
|
||||
pub fn try_mode(self, mode: GameMode) -> Option<Performance<'map>> {
|
||||
match mode {
|
||||
GameMode::Osu => Some(Performance::Osu(self)),
|
||||
GameMode::Taiko => TaikoPerformance::try_from(self)
|
||||
.map(Performance::Taiko)
|
||||
.ok(),
|
||||
GameMode::Catch => CatchPerformance::try_from(self)
|
||||
.map(Performance::Catch)
|
||||
.ok(),
|
||||
GameMode::Mania => ManiaPerformance::try_from(self)
|
||||
.map(Performance::Mania)
|
||||
.ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to convert the map to the specified mode.
|
||||
///
|
||||
/// If the internal beatmap was already replaced with difficulty
|
||||
/// attributes, the map won't be modified.
|
||||
///
|
||||
/// To see whether the internal beatmap was replaced, use [`try_mode`]
|
||||
/// instead.
|
||||
///
|
||||
/// [`try_mode`]: Self::try_mode
|
||||
pub fn mode_or_ignore(self, mode: GameMode) -> Performance<'map> {
|
||||
match mode {
|
||||
GameMode::Osu => Performance::Osu(self),
|
||||
GameMode::Taiko => {
|
||||
TaikoPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Taiko)
|
||||
}
|
||||
GameMode::Catch => {
|
||||
CatchPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Catch)
|
||||
}
|
||||
GameMode::Mania => {
|
||||
ManiaPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Mania)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provide the result of a previous difficulty or performance calculation.
|
||||
/// If you already calculated the attributes for the current map-mod combination,
|
||||
/// be sure to put them in here so that they don't have to be recalculated.
|
||||
pub fn attributes(mut self, attributes: impl ModeAttributeProvider<Osu>) -> Self {
|
||||
if let Some(attrs) = attributes.attributes() {
|
||||
self.map_or_attrs = MapOrAttrs::Attrs(attrs);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify mods through their bit values.
|
||||
///
|
||||
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
|
||||
pub const fn mods(mut self, mods: u32) -> Self {
|
||||
self.mods = mods;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the max combo of the play.
|
||||
pub const fn combo(mut self, combo: u32) -> Self {
|
||||
self.combo = Some(combo);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify how hitresults should be generated.
|
||||
///
|
||||
/// Defauls to [`HitResultPriority::BestCase`].
|
||||
pub const fn hitresult_priority(mut self, priority: HitResultPriority) -> Self {
|
||||
self.hitresult_priority = priority;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 300s of a play.
|
||||
pub const fn n300(mut self, n300: u32) -> Self {
|
||||
self.n300 = Some(n300);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 100s of a play.
|
||||
pub const fn n100(mut self, n100: u32) -> Self {
|
||||
self.n100 = Some(n100);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 50s of a play.
|
||||
pub const fn n50(mut self, n50: u32) -> Self {
|
||||
self.n50 = Some(n50);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of misses of a play.
|
||||
pub const fn n_misses(mut self, n_misses: u32) -> Self {
|
||||
self.n_misses = Some(n_misses);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
#[cfg_attr(
|
||||
feature = "gradual",
|
||||
doc = "If you want to calculate the performance after every few objects, instead of
|
||||
using [`OsuPP`] multiple times with different `passed_objects`, you should use
|
||||
[`OsuGradualPerformanceAttributes`](crate::osu::OsuGradualPerformance)."
|
||||
)]
|
||||
pub const fn passed_objects(mut self, passed_objects: u32) -> Self {
|
||||
self.passed_objects = Some(passed_objects);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Provide parameters through an [`OsuScoreState`].
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub const fn state(mut self, state: OsuScoreState) -> Self {
|
||||
let OsuScoreState {
|
||||
max_combo,
|
||||
n300,
|
||||
n100,
|
||||
n50,
|
||||
n_misses,
|
||||
} = state;
|
||||
|
||||
self.combo = Some(max_combo);
|
||||
self.n300 = Some(n300);
|
||||
self.n100 = Some(n100);
|
||||
self.n50 = Some(n50);
|
||||
self.n_misses = Some(n_misses);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the accuracy of a play between `0.0` and `100.0`.
|
||||
/// This will be used to generate matching hitresults.
|
||||
pub fn accuracy(mut self, acc: f64) -> Self {
|
||||
self.acc = Some(acc / 100.0);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Create the [`OsuScoreState`] that will be used for performance calculation.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn generate_state(&mut self) -> OsuScoreState {
|
||||
let attrs = match self.map_or_attrs {
|
||||
MapOrAttrs::Map(ref map) => {
|
||||
let attrs = self.generate_attributes(map);
|
||||
|
||||
self.map_or_attrs.attrs_or_insert(attrs)
|
||||
}
|
||||
MapOrAttrs::Attrs(ref attrs) => attrs,
|
||||
};
|
||||
|
||||
let max_combo = attrs.max_combo;
|
||||
let n_objects = self.passed_objects.unwrap_or(attrs.n_objects());
|
||||
let priority = self.hitresult_priority;
|
||||
|
||||
let n_misses = self.n_misses.map_or(0, |n| n.min(n_objects));
|
||||
let n_remaining = n_objects - n_misses;
|
||||
|
||||
let mut n300 = self.n300.map_or(0, |n| n.min(n_remaining));
|
||||
let mut n100 = self.n100.map_or(0, |n| n.min(n_remaining));
|
||||
let mut n50 = self.n50.map_or(0, |n| n.min(n_remaining));
|
||||
|
||||
if let Some(acc) = self.acc {
|
||||
let target_total = acc * f64::from(6 * n_objects);
|
||||
|
||||
match (self.n300, self.n100, self.n50) {
|
||||
(Some(_), Some(_), Some(_)) => {
|
||||
let remaining = n_objects.saturating_sub(n300 + n100 + n50 + n_misses);
|
||||
|
||||
match priority {
|
||||
HitResultPriority::BestCase => n300 += remaining,
|
||||
HitResultPriority::WorstCase => n50 += remaining,
|
||||
}
|
||||
}
|
||||
(Some(_), Some(_), None) => n50 = n_objects.saturating_sub(n300 + n100 + n_misses),
|
||||
(Some(_), None, Some(_)) => n100 = n_objects.saturating_sub(n300 + n50 + n_misses),
|
||||
(None, Some(_), Some(_)) => n300 = n_objects.saturating_sub(n100 + n50 + n_misses),
|
||||
(Some(_), None, None) => {
|
||||
let mut best_dist = f64::MAX;
|
||||
|
||||
n300 = n300.min(n_remaining);
|
||||
let n_remaining = n_remaining - n300;
|
||||
|
||||
let raw_n100 = target_total - f64::from(n_remaining + 6 * n300);
|
||||
let min_n100 = n_remaining.min(raw_n100.floor() as u32);
|
||||
let max_n100 = n_remaining.min(raw_n100.ceil() as u32);
|
||||
|
||||
for new100 in min_n100..=max_n100 {
|
||||
let new50 = n_remaining - new100;
|
||||
let dist = (acc - accuracy(n300, new100, new50, n_misses)).abs();
|
||||
|
||||
if dist < best_dist {
|
||||
best_dist = dist;
|
||||
n100 = new100;
|
||||
n50 = new50;
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, Some(_), None) => {
|
||||
let mut best_dist = f64::MAX;
|
||||
|
||||
n100 = n100.min(n_remaining);
|
||||
let n_remaining = n_remaining - n100;
|
||||
|
||||
let raw_n300 = (target_total - f64::from(n_remaining + 2 * n100)) / 5.0;
|
||||
let min_n300 = n_remaining.min(raw_n300.floor() as u32);
|
||||
let max_n300 = n_remaining.min(raw_n300.ceil() as u32);
|
||||
|
||||
for new300 in min_n300..=max_n300 {
|
||||
let new50 = n_remaining - new300;
|
||||
let curr_dist = (acc - accuracy(new300, n100, new50, n_misses)).abs();
|
||||
|
||||
if curr_dist < best_dist {
|
||||
best_dist = curr_dist;
|
||||
n300 = new300;
|
||||
n50 = new50;
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None, Some(_)) => {
|
||||
let mut best_dist = f64::MAX;
|
||||
|
||||
n50 = n50.min(n_remaining);
|
||||
let n_remaining = n_remaining - n50;
|
||||
|
||||
let raw_n300 = (target_total + f64::from(2 * n_misses + n50)
|
||||
- f64::from(2 * n_objects))
|
||||
/ 4.0;
|
||||
|
||||
let min_n300 = n_remaining.min(raw_n300.floor() as u32);
|
||||
let max_n300 = n_remaining.min(raw_n300.ceil() as u32);
|
||||
|
||||
for new300 in min_n300..=max_n300 {
|
||||
let new100 = n_remaining - new300;
|
||||
let curr_dist = (acc - accuracy(new300, new100, n50, n_misses)).abs();
|
||||
|
||||
if curr_dist < best_dist {
|
||||
best_dist = curr_dist;
|
||||
n300 = new300;
|
||||
n100 = new100;
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None, None) => {
|
||||
let mut best_dist = f64::MAX;
|
||||
|
||||
let raw_n300 = (target_total - f64::from(n_remaining)) / 5.0;
|
||||
let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32);
|
||||
let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32);
|
||||
|
||||
for new300 in min_n300..=max_n300 {
|
||||
let raw_n100 = target_total - f64::from(n_remaining + 5 * new300);
|
||||
let min_n100 = cmp::min(raw_n100.floor() as u32, n_remaining - new300);
|
||||
let max_n100 = cmp::min(raw_n100.ceil() as u32, n_remaining - new300);
|
||||
|
||||
for new100 in min_n100..=max_n100 {
|
||||
let new50 = n_remaining - new300 - new100;
|
||||
let curr_dist = (acc - accuracy(new300, new100, new50, n_misses)).abs();
|
||||
|
||||
if curr_dist < best_dist {
|
||||
best_dist = curr_dist;
|
||||
n300 = new300;
|
||||
n100 = new100;
|
||||
n50 = new50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match priority {
|
||||
HitResultPriority::BestCase => {
|
||||
// Shift n50 to n100 by sacrificing n300
|
||||
let n = n300.min(n50 / 4);
|
||||
n300 -= n;
|
||||
n100 += 5 * n;
|
||||
n50 -= 4 * n;
|
||||
}
|
||||
HitResultPriority::WorstCase => {
|
||||
// Shift n100 to n50 by gaining n300
|
||||
let n = n100 / 5;
|
||||
n300 += n;
|
||||
n100 -= 5 * n;
|
||||
n50 += 4 * n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let remaining = n_objects.saturating_sub(n300 + n100 + n50 + n_misses);
|
||||
|
||||
match priority {
|
||||
HitResultPriority::BestCase => match (self.n300, self.n100, self.n50) {
|
||||
(None, ..) => n300 = remaining,
|
||||
(_, None, _) => n100 = remaining,
|
||||
(.., None) => n50 = remaining,
|
||||
_ => n300 += remaining,
|
||||
},
|
||||
HitResultPriority::WorstCase => match (self.n50, self.n100, self.n300) {
|
||||
(None, ..) => n50 = remaining,
|
||||
(_, None, _) => n100 = remaining,
|
||||
(.., None) => n300 = remaining,
|
||||
_ => n50 += remaining,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let max_possible_combo = max_combo.saturating_sub(n_misses);
|
||||
|
||||
let max_combo = self
|
||||
.combo
|
||||
.map_or(max_possible_combo, |combo| combo.min(max_possible_combo));
|
||||
|
||||
OsuScoreState {
|
||||
max_combo,
|
||||
n300,
|
||||
n100,
|
||||
n50,
|
||||
n_misses,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate all performance related values, including pp and stars.
|
||||
pub fn calculate(mut self) -> OsuPerformanceAttributes {
|
||||
let state = self.generate_state();
|
||||
|
||||
let attrs = match self.map_or_attrs {
|
||||
MapOrAttrs::Map(ref map) => self.generate_attributes(map),
|
||||
MapOrAttrs::Attrs(attrs) => attrs,
|
||||
};
|
||||
|
||||
let effective_miss_count = calculate_effective_misses(&attrs, &state);
|
||||
|
||||
let inner = OsuPerformanceInner {
|
||||
attrs,
|
||||
mods: self.mods,
|
||||
acc: state.accuracy(),
|
||||
state,
|
||||
effective_miss_count,
|
||||
};
|
||||
|
||||
inner.calculate()
|
||||
}
|
||||
|
||||
fn generate_attributes(&self, map: &OsuBeatmap<'_>) -> OsuDifficultyAttributes {
|
||||
let mut calculator = ModeDifficulty::new();
|
||||
|
||||
if let Some(passed_objects) = self.passed_objects {
|
||||
calculator.passed_objects(passed_objects);
|
||||
}
|
||||
|
||||
if let Some(clock_rate) = self.clock_rate {
|
||||
calculator.clock_rate(clock_rate);
|
||||
}
|
||||
|
||||
calculator.mods(self.mods).calculate(map)
|
||||
}
|
||||
|
||||
/// Try to create [`OsuPerformance`] through a [`ModeAttributeProvider`].
|
||||
///
|
||||
/// If you already calculated the attributes for the current map-mod
|
||||
/// combination, the [`OsuBeatmap`] is no longer necessary to calculate
|
||||
/// performance attributes so this method can be used instead of
|
||||
/// [`OsuPerformance::new`].
|
||||
///
|
||||
/// Returns `None` only if the [`ModeAttributeProvider`] did not contain
|
||||
/// attributes for osu e.g. if it's [`DifficultyAttributes::Taiko`].
|
||||
///
|
||||
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
|
||||
pub fn try_from_attributes(attributes: impl ModeAttributeProvider<Osu>) -> Option<Self> {
|
||||
attributes.attributes().map(Self::from)
|
||||
}
|
||||
|
||||
/// Create [`OsuPerformance`] through a [`ModeAttributeProvider`].
|
||||
///
|
||||
/// If you already calculated the attributes for the current map-mod
|
||||
/// combination, the [`OsuBeatmap`] is no longer necessary to calculate
|
||||
/// performance attributes so this method can be used instead of
|
||||
/// [`OsuPerformance::new`].
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the [`ModeAttributeProvider`] did not contain attributes for
|
||||
/// osu e.g. if it's [`DifficultyAttributes::Taiko`].
|
||||
///
|
||||
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
|
||||
pub fn unchecked_from_attributes(attributes: impl ModeAttributeProvider<Osu>) -> Self {
|
||||
Self::try_from_attributes(attributes).expect("invalid osu attributes")
|
||||
}
|
||||
}
|
||||
|
||||
impl<'map> From<OsuBeatmap<'map>> for OsuPerformance<'map> {
|
||||
fn from(map: OsuBeatmap<'map>) -> Self {
|
||||
Self {
|
||||
map_or_attrs: MapOrAttrs::Map(map),
|
||||
mods: 0,
|
||||
acc: None,
|
||||
combo: None,
|
||||
|
||||
n300: None,
|
||||
n100: None,
|
||||
n50: None,
|
||||
n_misses: None,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
hitresult_priority: HitResultPriority::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OsuDifficultyAttributes> for OsuPerformance<'_> {
|
||||
fn from(attrs: OsuDifficultyAttributes) -> Self {
|
||||
Self {
|
||||
map_or_attrs: MapOrAttrs::Attrs(attrs),
|
||||
mods: 0,
|
||||
acc: None,
|
||||
combo: None,
|
||||
|
||||
n300: None,
|
||||
n100: None,
|
||||
n50: None,
|
||||
n_misses: None,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
hitresult_priority: HitResultPriority::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OsuPerformanceAttributes> for OsuPerformance<'_> {
|
||||
fn from(attrs: OsuPerformanceAttributes) -> Self {
|
||||
attrs.difficulty.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14;
|
||||
|
||||
struct OsuPerformanceInner {
|
||||
attrs: OsuDifficultyAttributes,
|
||||
mods: u32,
|
||||
acc: f64,
|
||||
state: OsuScoreState,
|
||||
effective_miss_count: f64,
|
||||
}
|
||||
|
||||
impl OsuPerformanceInner {
|
||||
fn calculate(mut self) -> OsuPerformanceAttributes {
|
||||
let total_hits = self.state.total_hits();
|
||||
|
||||
if total_hits == 0 {
|
||||
return OsuPerformanceAttributes {
|
||||
difficulty: self.attrs,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let total_hits = f64::from(total_hits);
|
||||
|
||||
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() && total_hits > 0.0 {
|
||||
multiplier *= 1.0 - (f64::from(self.attrs.n_spinners) / 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.attrs.od > 0.0 {
|
||||
(
|
||||
1.0 - (self.attrs.od / 13.33).powf(1.8),
|
||||
1.0 - (self.attrs.od / 13.33).powi(5),
|
||||
)
|
||||
} else {
|
||||
(1.0, 1.0)
|
||||
};
|
||||
|
||||
// * 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
|
||||
+ f64::from(self.state.n100)
|
||||
+ n100_mult
|
||||
+ f64::from(self.state.n50) * n50_mult)
|
||||
.min(total_hits);
|
||||
}
|
||||
|
||||
let aim_value = self.compute_aim_value();
|
||||
let speed_value = self.compute_speed_value();
|
||||
let acc_value = self.compute_accuracy_value();
|
||||
let flashlight_value = self.compute_flashlight_value();
|
||||
|
||||
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: self.effective_miss_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_aim_value(&self) -> f64 {
|
||||
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 * (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;
|
||||
|
||||
// * 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 {
|
||||
aim_value *= 0.97
|
||||
* (1.0 - (self.effective_miss_count / total_hits).powf(0.775))
|
||||
.powf(self.effective_miss_count);
|
||||
}
|
||||
|
||||
aim_value *= self.get_combo_scaling_factor();
|
||||
|
||||
let ar_factor = if self.mods.rx() {
|
||||
0.0
|
||||
} 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
|
||||
};
|
||||
|
||||
// * Buff for longer maps with high AR.
|
||||
aim_value *= 1.0 + ar_factor * len_bonus;
|
||||
|
||||
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.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 = f64::from(self.attrs.n_sliders) * 0.15;
|
||||
|
||||
if self.attrs.n_sliders > 0 {
|
||||
let estimate_slider_ends_dropped = f64::from(
|
||||
(self.state.n100 + self.state.n50 + self.state.n_misses)
|
||||
.min(self.attrs.max_combo.saturating_sub(self.state.max_combo)),
|
||||
)
|
||||
.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.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.attrs.od.powi(2) / 2500.0;
|
||||
|
||||
aim_value
|
||||
}
|
||||
|
||||
fn compute_speed_value(&self) -> f64 {
|
||||
if self.mods.rx() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut speed_value =
|
||||
(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 * (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;
|
||||
|
||||
// * 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 {
|
||||
speed_value *= 0.97
|
||||
* (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.attrs.ar > 10.33 {
|
||||
0.3 * (self.attrs.ar - 10.33)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// * Buff for longer maps with high AR.
|
||||
speed_value *= 1.0 + ar_factor * len_bonus;
|
||||
|
||||
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.attrs.ar);
|
||||
}
|
||||
|
||||
// * Calculate accuracy assuming the worst case scenario
|
||||
let relevant_total_diff = total_hits - self.attrs.speed_note_count;
|
||||
let relevant_n300 = (f64::from(self.state.n300) - relevant_total_diff).max(0.0);
|
||||
let relevant_n100 = (f64::from(self.state.n100)
|
||||
- (relevant_total_diff - f64::from(self.state.n300)).max(0.0))
|
||||
.max(0.0);
|
||||
let relevant_n50 = (f64::from(self.state.n50)
|
||||
- (relevant_total_diff - f64::from(self.state.n300 + self.state.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)
|
||||
};
|
||||
|
||||
// * Scale the speed value with accuracy and OD.
|
||||
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(
|
||||
f64::from(u8::from(f64::from(self.state.n50) >= total_hits / 500.0))
|
||||
* (f64::from(self.state.n50) - total_hits / 500.0),
|
||||
);
|
||||
|
||||
speed_value
|
||||
}
|
||||
|
||||
fn compute_accuracy_value(&self) -> f64 {
|
||||
if self.mods.rx() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// * 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.attrs.n_circles;
|
||||
|
||||
let better_acc_percentage = if amount_hit_objects_with_acc > 0 {
|
||||
let sub = self.state.total_hits() - amount_hit_objects_with_acc;
|
||||
|
||||
// * It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points.
|
||||
if self.state.n300 < sub {
|
||||
0.0
|
||||
} else {
|
||||
f64::from((self.state.n300 - sub) * 6 + self.state.n100 * 2 + self.state.n50)
|
||||
/ f64::from(amount_hit_objects_with_acc * 6)
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// * 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.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 *= (f64::from(amount_hit_objects_with_acc) / 1000.0)
|
||||
.powf(0.3)
|
||||
.min(1.15);
|
||||
|
||||
// * Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given.
|
||||
if self.mods.hd() {
|
||||
acc_value *= 1.08;
|
||||
}
|
||||
|
||||
if self.mods.fl() {
|
||||
acc_value *= 1.02;
|
||||
}
|
||||
|
||||
acc_value
|
||||
}
|
||||
|
||||
fn compute_flashlight_value(&self) -> f64 {
|
||||
if !self.mods.fl() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut flashlight_value = self.attrs.flashlight.powi(2) * 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 / total_hits).powf(0.775))
|
||||
.powf(self.effective_miss_count.powf(0.875));
|
||||
}
|
||||
|
||||
flashlight_value *= self.get_combo_scaling_factor();
|
||||
|
||||
// * Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
|
||||
flashlight_value *= 0.7
|
||||
+ 0.1 * (total_hits / 200.0).min(1.0)
|
||||
+ f64::from(u8::from(total_hits > 200.0))
|
||||
* 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.attrs.od.powi(2) / 2500.0;
|
||||
|
||||
flashlight_value
|
||||
}
|
||||
|
||||
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.total_hits() as 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 = f64::from(attrs.max_combo) - 0.1 * f64::from(attrs.n_sliders);
|
||||
|
||||
if f64::from(state.max_combo) < full_combo_threshold {
|
||||
combo_based_miss_count = full_combo_threshold / f64::from(state.max_combo).max(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// * Clamp miss count to maximum amount of possible breaks
|
||||
combo_based_miss_count =
|
||||
combo_based_miss_count.min(f64::from(state.n100 + state.n50 + state.n_misses));
|
||||
|
||||
combo_based_miss_count.max(f64::from(state.n_misses))
|
||||
}
|
||||
|
||||
fn accuracy(n300: u32, n100: u32, n50: u32, n_misses: u32) -> f64 {
|
||||
if n300 + n100 + n50 + n_misses == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let numerator = 6 * n300 + 2 * n100 + n50;
|
||||
let denominator = 6 * (n300 + n100 + n50 + n_misses);
|
||||
|
||||
f64::from(numerator) / f64::from(denominator)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use crate::{
|
||||
osu::{OsuBeatmap, OsuGradualDifficulty},
|
||||
ModeDifficulty,
|
||||
};
|
||||
|
||||
use super::{OsuPerformance, OsuPerformanceAttributes, OsuScoreState};
|
||||
|
||||
/// Gradually calculate the performance attributes of an osu!standard map.
|
||||
///
|
||||
/// After each hit object you can call [`next`]
|
||||
/// and it will return the resulting current [`OsuPerformanceAttributes`].
|
||||
/// To process multiple objects at once, use [`nth`] instead.
|
||||
///
|
||||
/// Both methods require an [`OsuScoreState`] that contains the current
|
||||
/// hitresults as well as the maximum combo so far.
|
||||
///
|
||||
/// If you only want to calculate difficulty attributes use
|
||||
/// [`OsuGradualDifficulty`] instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, ModeDifficulty};
|
||||
/// use rosu_pp::osu::{Osu, OsuGradualPerformance, OsuScoreState};
|
||||
///
|
||||
/// let converted = Beatmap::from_path("./resources/2785319.osu")
|
||||
/// .unwrap()
|
||||
/// .unchecked_into_converted::<Osu>()
|
||||
/// .unwrap();
|
||||
///
|
||||
/// let difficulty = ModeDifficulty::new().mods(64); // DT
|
||||
/// let mut gradual_perf = OsuGradualPerformance::new(&difficulty, converted);
|
||||
/// let mut state = OsuScoreState::new(); // empty state, everything is on 0.
|
||||
///
|
||||
/// // The first 10 hits are 300s and there are no sliders for additional combo
|
||||
/// for _ in 0..10 {
|
||||
/// state.n300 += 1;
|
||||
/// state.max_combo += 1;
|
||||
///
|
||||
/// let performance = gradual_perf.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// }
|
||||
///
|
||||
/// // Then comes a miss. Note that state's max combo won't be incremented for
|
||||
/// // the next few objects because the combo is reset.
|
||||
/// state.n_misses += 1;
|
||||
/// let performance = gradual_perf.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
///
|
||||
/// // The next 10 objects will be a mixture of 300s, 100s, and 50s.
|
||||
/// // Notice how all 10 objects will be processed in one go.
|
||||
/// state.n300 += 2;
|
||||
/// state.n100 += 7;
|
||||
/// state.n50 += 1;
|
||||
/// // The `nth` method takes a zero-based value.
|
||||
/// let performance = gradual_perf.nth(state.clone(), 9).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
///
|
||||
/// // Now comes another 300. Note that the max combo gets incremented again.
|
||||
/// state.n300 += 1;
|
||||
/// state.max_combo += 1;
|
||||
/// let performance = gradual_perf.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
///
|
||||
/// // Skip to the end
|
||||
/// # /*
|
||||
/// state.max_combo = ...
|
||||
/// state.n300 = ...
|
||||
/// state.n100 = ...
|
||||
/// state.n50 = ...
|
||||
/// state.n_misses = ...
|
||||
/// # */
|
||||
/// let final_performance = gradual_perf.nth(state.clone(), usize::MAX).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
///
|
||||
/// // Once the final performance has been calculated, attempting to process
|
||||
/// // further objects will return `None`.
|
||||
/// assert!(gradual_perf.next(state).is_none());
|
||||
/// ```
|
||||
///
|
||||
/// [`next`]: OsuGradualPerformance::next
|
||||
/// [`nth`]: OsuGradualPerformance::nth
|
||||
#[derive(Debug)]
|
||||
pub struct OsuGradualPerformance<'map> {
|
||||
difficulty: OsuGradualDifficulty,
|
||||
performance: OsuPerformance<'map>,
|
||||
}
|
||||
|
||||
impl<'map> OsuGradualPerformance<'map> {
|
||||
/// Create a new gradual performance calculator for osu!standard maps.
|
||||
pub fn new(difficulty: &ModeDifficulty, converted: OsuBeatmap<'map>) -> Self {
|
||||
let mods = difficulty.get_mods();
|
||||
let clock_rate = difficulty.get_clock_rate();
|
||||
let difficulty = OsuGradualDifficulty::new(difficulty, &converted);
|
||||
|
||||
let performance = OsuPerformance::new(converted)
|
||||
.mods(mods)
|
||||
.clock_rate(clock_rate)
|
||||
.passed_objects(0);
|
||||
|
||||
Self {
|
||||
difficulty,
|
||||
performance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process the next hit object and calculate the performance attributes
|
||||
/// for the resulting score state.
|
||||
pub fn next(&mut self, state: OsuScoreState) -> Option<OsuPerformanceAttributes> {
|
||||
self.nth(state, 0)
|
||||
}
|
||||
|
||||
/// Process all remaining hit objects and calculate the final performance
|
||||
/// attributes.
|
||||
pub fn last(&mut self, state: OsuScoreState) -> Option<OsuPerformanceAttributes> {
|
||||
self.nth(state, usize::MAX)
|
||||
}
|
||||
|
||||
/// Process everything up to the next `n`th hitobject and calculate the
|
||||
/// performance attributes for the resulting score state.
|
||||
///
|
||||
/// Note that the count is zero-indexed, so `n=0` will process 1 object,
|
||||
/// `n=1` will process 2, and so on.
|
||||
pub fn nth(&mut self, state: OsuScoreState, n: usize) -> Option<OsuPerformanceAttributes> {
|
||||
let difficulty = self.difficulty.nth(n)?;
|
||||
|
||||
let performance = self
|
||||
.performance
|
||||
.clone()
|
||||
.attributes(difficulty)
|
||||
.state(state)
|
||||
.passed_objects(self.difficulty.idx as u32)
|
||||
.calculate();
|
||||
|
||||
Some(performance)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{osu::Osu, Beatmap};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn next_and_nth() {
|
||||
let converted = Beatmap::from_path("./resources/2785319.osu")
|
||||
.unwrap()
|
||||
.unchecked_into_converted::<Osu>();
|
||||
|
||||
let mods = 88; // HDHRDT
|
||||
|
||||
let difficulty = ModeDifficulty::new().mods(mods);
|
||||
|
||||
let mut gradual = OsuGradualPerformance::new(&difficulty, converted.as_owned());
|
||||
let mut gradual_2nd = OsuGradualPerformance::new(&difficulty, converted.as_owned());
|
||||
let mut gradual_3rd = OsuGradualPerformance::new(&difficulty, converted.as_owned());
|
||||
|
||||
let mut state = OsuScoreState::default();
|
||||
|
||||
let hit_objects_len = converted.map.hit_objects.len();
|
||||
|
||||
for i in 1.. {
|
||||
state.n_misses += 1;
|
||||
|
||||
let Some(next_gradual) = gradual.next(state.clone()) else {
|
||||
assert_eq!(i, hit_objects_len + 1);
|
||||
assert!(gradual_2nd.last(state.clone()).is_some() || hit_objects_len % 2 == 0);
|
||||
assert!(gradual_3rd.last(state.clone()).is_some() || hit_objects_len % 3 == 0);
|
||||
break;
|
||||
};
|
||||
|
||||
if i % 2 == 0 {
|
||||
let next_gradual_2nd = gradual_2nd.nth(state.clone(), 1).unwrap();
|
||||
assert_eq!(next_gradual, next_gradual_2nd);
|
||||
}
|
||||
|
||||
if i % 3 == 0 {
|
||||
let next_gradual_3rd = gradual_3rd.nth(state.clone(), 2).unwrap();
|
||||
assert_eq!(next_gradual, next_gradual_3rd);
|
||||
}
|
||||
|
||||
let mut regular_calc = OsuPerformance::new(converted.as_owned())
|
||||
.mods(mods)
|
||||
.passed_objects(i as u32)
|
||||
.state(state.clone());
|
||||
|
||||
let regular_state = regular_calc.generate_state();
|
||||
assert_eq!(state, regular_state);
|
||||
|
||||
let expected = regular_calc.calculate();
|
||||
|
||||
assert_eq!(next_gradual, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-5
@@ -1,6 +1,9 @@
|
||||
use crate::any::ModeDifficulty;
|
||||
|
||||
use super::{convert::OsuBeatmap, difficulty::DifficultyValues};
|
||||
use super::{
|
||||
convert::OsuBeatmap,
|
||||
difficulty::{skills::OsuSkills, DifficultyValues},
|
||||
};
|
||||
|
||||
/// The result of calculating the strains on a osu! map.
|
||||
///
|
||||
@@ -24,10 +27,13 @@ impl OsuStrains {
|
||||
|
||||
pub fn strains(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> OsuStrains {
|
||||
let DifficultyValues {
|
||||
aim,
|
||||
aim_no_sliders,
|
||||
speed,
|
||||
flashlight,
|
||||
skills:
|
||||
OsuSkills {
|
||||
aim,
|
||||
aim_no_sliders,
|
||||
speed,
|
||||
flashlight,
|
||||
},
|
||||
attrs: _,
|
||||
} = DifficultyValues::calculate(difficulty, converted);
|
||||
|
||||
|
||||
@@ -130,17 +130,21 @@ impl DifficultyValues {
|
||||
|
||||
ColorDifficultyPreprocessor::process_and_assign(&diff_objects);
|
||||
|
||||
let mut peaks = PeaksSkill::new(&diff_objects);
|
||||
|
||||
// The first two hit objects have no difficulty object
|
||||
n_diff_objects -= 2;
|
||||
|
||||
for hit_object in diff_objects.iter().take(n_diff_objects) {
|
||||
peaks.process(&hit_object.borrow());
|
||||
let mut peaks = Peaks::new();
|
||||
|
||||
{
|
||||
let mut peaks = PeaksSkill::new(&mut peaks, &diff_objects);
|
||||
|
||||
for hit_object in diff_objects.iter().take(n_diff_objects) {
|
||||
peaks.process(&hit_object.borrow());
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
peaks: peaks.into_inner(),
|
||||
peaks,
|
||||
max_combo: max_combo as u32,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,12 +89,6 @@ impl Peaks {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Peaks {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PeaksSkill<'a> {
|
||||
pub color: Skill<'a, Color>,
|
||||
pub rhythm: Skill<'a, Rhythm>,
|
||||
@@ -102,11 +96,11 @@ pub struct PeaksSkill<'a> {
|
||||
}
|
||||
|
||||
impl<'a> PeaksSkill<'a> {
|
||||
pub fn new(diff_objects: &'a TaikoDifficultyObjects) -> Self {
|
||||
pub fn new(peaks: &'a mut Peaks, diff_objects: &'a TaikoDifficultyObjects) -> Self {
|
||||
Self {
|
||||
color: Skill::new(Color::default(), diff_objects),
|
||||
rhythm: Skill::new(Rhythm::default(), diff_objects),
|
||||
stamina: Skill::new(Stamina::default(), diff_objects),
|
||||
color: Skill::new(&mut peaks.color, diff_objects),
|
||||
rhythm: Skill::new(&mut peaks.rhythm, diff_objects),
|
||||
stamina: Skill::new(&mut peaks.stamina, diff_objects),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,12 +109,4 @@ impl<'a> PeaksSkill<'a> {
|
||||
self.color.process(curr);
|
||||
self.stamina.process(curr);
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> Peaks {
|
||||
Peaks {
|
||||
color: self.color.inner,
|
||||
rhythm: self.rhythm.inner,
|
||||
stamina: self.stamina.inner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user