gradual calc for osu

This commit is contained in:
MaxOhn
2024-02-22 16:59:45 +01:00
parent f4c18fa53e
commit 1e35e0fcd7
18 changed files with 850 additions and 1015 deletions
+2 -2
View File
@@ -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,
+7 -5
View File
@@ -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(),
}
}
+8 -4
View File
@@ -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
View File
@@ -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()
+331
View File
@@ -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
View File
@@ -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()
}
}
+10 -2
View File
@@ -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 -3
View File
@@ -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
}
+14 -3
View File
@@ -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
}
+50
View File
@@ -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,
}
}
}
+14 -3
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
use crate::any::difficulty::skills::StrainSkill;
#[derive(Default)]
#[derive(Clone, Default)]
pub struct OsuStrainSkill {
pub inner: StrainSkill,
}
+2 -1
View File
@@ -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,
};
-833
View File
@@ -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)
}
+196
View File
@@ -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
View File
@@ -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);
+9 -5
View File
@@ -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,
}
}
+4 -18
View File
@@ -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,
}
}
}