refactor osu_2019 into modern format

This commit is contained in:
James Wilson
2024-11-02 17:18:39 +00:00
parent ca7e57d10f
commit 98b74d337a
26 changed files with 2991 additions and 1246 deletions
+11 -4
View File
@@ -1,8 +1,5 @@
use crate::{
catch::{CatchDifficultyAttributes, CatchPerformanceAttributes},
mania::{ManiaDifficultyAttributes, ManiaPerformanceAttributes},
osu::{OsuDifficultyAttributes, OsuPerformanceAttributes},
taiko::{TaikoDifficultyAttributes, TaikoPerformanceAttributes},
catch::{CatchDifficultyAttributes, CatchPerformanceAttributes}, mania::{ManiaDifficultyAttributes, ManiaPerformanceAttributes}, osu::{OsuDifficultyAttributes, OsuPerformanceAttributes}, osu_2019, taiko::{TaikoDifficultyAttributes, TaikoPerformanceAttributes}
};
use super::performance::{into::IntoPerformance, Performance};
@@ -18,6 +15,8 @@ pub enum DifficultyAttributes {
Catch(CatchDifficultyAttributes),
/// osu!mania difficulty calculation result.
Mania(ManiaDifficultyAttributes),
/// osu!standard (relax) difficulty calculation result.
OsuRelax(osu_2019::OsuDifficultyAttributes),
}
impl DifficultyAttributes {
@@ -28,6 +27,7 @@ impl DifficultyAttributes {
Self::Taiko(attrs) => attrs.stars,
Self::Catch(attrs) => attrs.stars,
Self::Mania(attrs) => attrs.stars,
Self::OsuRelax(attrs) => attrs.stars,
}
}
@@ -38,6 +38,7 @@ impl DifficultyAttributes {
Self::Taiko(attrs) => attrs.max_combo,
Self::Catch(attrs) => attrs.max_combo(),
Self::Mania(attrs) => attrs.max_combo,
Self::OsuRelax(attrs) => attrs.max_combo,
}
}
@@ -58,6 +59,8 @@ pub enum PerformanceAttributes {
Catch(CatchPerformanceAttributes),
/// osu!mania performance calculation result.
Mania(ManiaPerformanceAttributes),
/// osu!standard (relax) performance calculation result.
OsuRelax(osu_2019::OsuPerformanceAttributes),
}
impl PerformanceAttributes {
@@ -68,6 +71,7 @@ impl PerformanceAttributes {
Self::Taiko(attrs) => attrs.pp,
Self::Catch(attrs) => attrs.pp,
Self::Mania(attrs) => attrs.pp,
Self::OsuRelax(attrs) => attrs.pp,
}
}
@@ -78,6 +82,7 @@ impl PerformanceAttributes {
Self::Taiko(attrs) => attrs.stars(),
Self::Catch(attrs) => attrs.stars(),
Self::Mania(attrs) => attrs.stars(),
Self::OsuRelax(attrs) => attrs.difficulty.stars,
}
}
@@ -88,6 +93,7 @@ impl PerformanceAttributes {
Self::Taiko(attrs) => DifficultyAttributes::Taiko(attrs.difficulty.clone()),
Self::Catch(attrs) => DifficultyAttributes::Catch(attrs.difficulty.clone()),
Self::Mania(attrs) => DifficultyAttributes::Mania(attrs.difficulty.clone()),
Self::OsuRelax(attrs) => DifficultyAttributes::OsuRelax(attrs.difficulty.clone()),
}
}
@@ -98,6 +104,7 @@ impl PerformanceAttributes {
Self::Taiko(attrs) => attrs.difficulty.max_combo,
Self::Catch(attrs) => attrs.difficulty.max_combo(),
Self::Mania(attrs) => attrs.difficulty.max_combo,
Self::OsuRelax(attrs) => attrs.difficulty.max_combo,
}
}
+7
View File
@@ -111,6 +111,11 @@ impl_from_mode!(
ManiaDifficultyAttributes,
ManiaPerformanceAttributes
},
osu_2019 {
OsuRelax,
OsuDifficultyAttributes,
OsuPerformanceAttributes
},
);
impl<'a> IntoPerformance<'a> for Beatmap {
@@ -141,6 +146,7 @@ impl<'a> IntoPerformance<'a> for DifficultyAttributes {
Self::Taiko(attrs) => Performance::Taiko(attrs.into()),
Self::Catch(attrs) => Performance::Catch(attrs.into()),
Self::Mania(attrs) => Performance::Mania(attrs.into()),
Self::OsuRelax(attrs) => Performance::OsuRelax(attrs.into()),
}
}
}
@@ -152,6 +158,7 @@ impl<'a> IntoPerformance<'a> for PerformanceAttributes {
Self::Taiko(attrs) => Performance::Taiko(attrs.difficulty.into()),
Self::Catch(attrs) => Performance::Catch(attrs.difficulty.into()),
Self::Mania(attrs) => Performance::Mania(attrs.difficulty.into()),
Self::OsuRelax(attrs) => Performance::OsuRelax(attrs.difficulty.into()),
}
}
}
+23 -2
View File
@@ -20,6 +20,7 @@ pub enum Performance<'map> {
Taiko(TaikoPerformance<'map>),
Catch(CatchPerformance<'map>),
Mania(ManiaPerformance<'map>),
OsuRelax(crate::osu_2019::OsuPerformance<'map>),
}
impl<'map> Performance<'map> {
@@ -56,6 +57,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => PerformanceAttributes::Taiko(t.calculate()),
Self::Catch(f) => PerformanceAttributes::Catch(f.calculate()),
Self::Mania(m) => PerformanceAttributes::Mania(m.calculate()),
Self::OsuRelax(or) => PerformanceAttributes::OsuRelax(or.calculate()),
}
}
@@ -114,6 +116,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => Self::Taiko(t.mods(mods)),
Self::Catch(f) => Self::Catch(f.mods(mods)),
Self::Mania(m) => Self::Mania(m.mods(mods)),
Self::OsuRelax(or) => Self::OsuRelax(or.mods(mods)),
}
}
@@ -124,6 +127,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => Self::Taiko(t.difficulty(difficulty)),
Self::Catch(f) => Self::Catch(f.difficulty(difficulty)),
Self::Mania(m) => Self::Mania(m.difficulty(difficulty)),
Self::OsuRelax(or) => Self::OsuRelax(or.difficulty(difficulty)),
}
}
@@ -140,6 +144,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => Self::Taiko(t.passed_objects(passed_objects)),
Self::Catch(f) => Self::Catch(f.passed_objects(passed_objects)),
Self::Mania(m) => Self::Mania(m.passed_objects(passed_objects)),
Self::OsuRelax(or) => Self::OsuRelax(or.passed_objects(passed_objects)),
}
}
@@ -157,6 +162,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => Self::Taiko(t.clock_rate(clock_rate)),
Self::Catch(f) => Self::Catch(f.clock_rate(clock_rate)),
Self::Mania(m) => Self::Mania(m.clock_rate(clock_rate)),
Self::OsuRelax(or) => Self::OsuRelax(or.clock_rate(clock_rate)),
}
}
@@ -175,6 +181,7 @@ impl<'map> Performance<'map> {
match self {
Self::Osu(o) => Self::Osu(o.ar(ar, with_mods)),
Self::Catch(c) => Self::Catch(c.ar(ar, with_mods)),
Self::OsuRelax(or) => Self::OsuRelax(or.ar(ar, with_mods)),
Self::Taiko(_) | Self::Mania(_) => self,
}
}
@@ -194,6 +201,7 @@ impl<'map> Performance<'map> {
match self {
Self::Osu(o) => Self::Osu(o.cs(cs, with_mods)),
Self::Catch(c) => Self::Catch(c.cs(cs, with_mods)),
Self::OsuRelax(or) => Self::OsuRelax(or.cs(cs, with_mods)),
Self::Taiko(_) | Self::Mania(_) => self,
}
}
@@ -213,6 +221,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => Self::Taiko(t.hp(hp, with_mods)),
Self::Catch(c) => Self::Catch(c.hp(hp, with_mods)),
Self::Mania(m) => Self::Mania(m.hp(hp, with_mods)),
Self::OsuRelax(or) => Self::OsuRelax(or.hp(hp, with_mods)),
}
}
@@ -231,6 +240,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => Self::Taiko(t.od(od, with_mods)),
Self::Catch(c) => Self::Catch(c.od(od, with_mods)),
Self::Mania(m) => Self::Mania(m.od(od, with_mods)),
Self::OsuRelax(or) => Self::OsuRelax(or.od(od, with_mods)),
}
}
@@ -252,6 +262,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => Self::Taiko(t.state(state.into())),
Self::Catch(f) => Self::Catch(f.state(state.into())),
Self::Mania(m) => Self::Mania(m.state(state.into())),
Self::OsuRelax(or) => Self::OsuRelax(or.state(state.into())),
}
}
@@ -262,6 +273,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => Self::Taiko(t.accuracy(acc)),
Self::Catch(f) => Self::Catch(f.accuracy(acc)),
Self::Mania(m) => Self::Mania(m.accuracy(acc)),
Self::OsuRelax(or) => Self::OsuRelax(or.accuracy(acc)),
}
}
@@ -272,6 +284,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => Self::Taiko(t.misses(n_misses)),
Self::Catch(f) => Self::Catch(f.misses(n_misses)),
Self::Mania(m) => Self::Mania(m.misses(n_misses)),
Self::OsuRelax(or) => Self::OsuRelax(or.misses(n_misses)),
}
}
@@ -283,6 +296,7 @@ impl<'map> Performance<'map> {
Self::Osu(o) => Self::Osu(o.combo(combo)),
Self::Taiko(t) => Self::Taiko(t.combo(combo)),
Self::Catch(f) => Self::Catch(f.combo(combo)),
Self::OsuRelax(or) => Self::OsuRelax(or.combo(combo)),
Self::Mania(_) => self,
}
}
@@ -296,6 +310,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => Self::Taiko(t.hitresult_priority(priority)),
Self::Catch(_) => self,
Self::Mania(m) => Self::Mania(m.hitresult_priority(priority)),
Self::OsuRelax(or) => Self::OsuRelax(or.hitresult_priority(priority)),
}
}
@@ -311,6 +326,8 @@ impl<'map> Performance<'map> {
pub fn lazer(self, lazer: bool) -> Self {
if let Self::Osu(osu) = self {
Self::Osu(osu.lazer(lazer))
} else if let Self::OsuRelax(osu_relax) = self {
Self::OsuRelax(osu_relax.lazer(lazer))
} else {
self
}
@@ -323,6 +340,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => Self::Taiko(t.n300(n300)),
Self::Catch(f) => Self::Catch(f.fruits(n300)),
Self::Mania(m) => Self::Mania(m.n300(n300)),
Self::OsuRelax(or) => Self::OsuRelax(or.n300(n300)),
}
}
@@ -333,6 +351,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => Self::Taiko(t.n100(n100)),
Self::Catch(f) => Self::Catch(f.droplets(n100)),
Self::Mania(m) => Self::Mania(m.n100(n100)),
Self::OsuRelax(or) => Self::OsuRelax(or.n100(n100)),
}
}
@@ -345,6 +364,7 @@ impl<'map> Performance<'map> {
Self::Taiko(_) => self,
Self::Catch(f) => Self::Catch(f.tiny_droplets(n50)),
Self::Mania(m) => Self::Mania(m.n50(n50)),
Self::OsuRelax(or) => Self::OsuRelax(or.n50(n50)),
}
}
@@ -354,7 +374,7 @@ impl<'map> Performance<'map> {
/// droplet misses and osu!mania for which it repesents the amount of n200.
pub fn n_katu(self, n_katu: u32) -> Self {
match self {
Self::Osu(_) | Self::Taiko(_) => self,
Self::Osu(_) | Self::Taiko(_) | Self::OsuRelax(_) => self,
Self::Catch(f) => Self::Catch(f.tiny_droplet_misses(n_katu)),
Self::Mania(m) => Self::Mania(m.n200(n_katu)),
}
@@ -366,7 +386,7 @@ impl<'map> Performance<'map> {
/// amount of n320.
pub fn n_geki(self, n_geki: u32) -> Self {
match self {
Self::Osu(_) | Self::Taiko(_) | Self::Catch(_) => self,
Self::Osu(_) | Self::Taiko(_) | Self::Catch(_) | Self::OsuRelax(_) => self,
Self::Mania(m) => Self::Mania(m.n320(n_geki)),
}
}
@@ -378,6 +398,7 @@ impl<'map> Performance<'map> {
Self::Taiko(t) => t.generate_state().into(),
Self::Catch(f) => f.generate_state().into(),
Self::Mania(m) => m.generate_state().into(),
Self::OsuRelax(or) => or.generate_state().into(),
}
}
}
+7 -7
View File
@@ -21,14 +21,14 @@ use self::skills::OsuSkills;
use super::{attributes::OsuDifficultyAttributes, convert::OsuBeatmap};
pub mod gradual;
mod object;
pub mod object;
pub mod scaling_factor;
pub mod skills;
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
const HD_FADE_IN_DURATION_MULTIPLIER: f64 = 0.4;
const HD_FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3;
pub(crate) const HD_FADE_IN_DURATION_MULTIPLIER: f64 = 0.4;
pub(crate) const HD_FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3;
pub fn difficulty(difficulty: &Difficulty, converted: &OsuBeatmap<'_>) -> OsuDifficultyAttributes {
let DifficultyValues {
@@ -64,10 +64,10 @@ pub fn difficulty(difficulty: &Difficulty, converted: &OsuBeatmap<'_>) -> OsuDif
}
pub struct OsuDifficultySetup {
scaling_factor: ScalingFactor,
map_attrs: BeatmapAttributes,
attrs: OsuDifficultyAttributes,
time_preempt: f64,
pub(crate) scaling_factor: ScalingFactor,
pub(crate) map_attrs: BeatmapAttributes,
pub(crate) attrs: OsuDifficultyAttributes,
pub(crate) time_preempt: f64,
}
impl OsuDifficultySetup {
+3 -3
View File
@@ -18,9 +18,9 @@ pub use self::{
};
mod attributes;
mod convert;
mod difficulty;
mod object;
pub(crate) mod convert;
pub(crate) mod difficulty;
pub(crate) mod object;
mod performance;
mod score_state;
mod strains;
+80
View File
@@ -0,0 +1,80 @@
use super::OsuPerformance;
#[derive(Clone, Debug, PartialEq, Default)]
pub struct OsuDifficultyAttributes {
pub aim_strain: f64,
pub speed_strain: f64,
pub ar: f64,
pub od: f64,
pub hp: f64,
pub cs: f64,
pub n_circles: u32,
pub n_sliders: u32,
pub n_spinners: u32,
pub stars: f64,
pub max_combo: u32,
pub aim_difficult_strain_count: f64,
pub speed_difficult_strain_count: f64,
pub beatmap_id: i32,
pub beatmap_creator: String,
pub n_slider_ticks: u32,
}
impl OsuDifficultyAttributes {
/// Return the maximum combo.
pub const fn max_combo(&self) -> u32 {
self.max_combo
}
/// Return the amount of hitobjects.
pub const fn n_objects(&self) -> u32 {
self.n_circles + self.n_sliders + self.n_spinners
}
/// Returns a builder for performance calculation.
pub fn performance<'a>(self) -> OsuPerformance<'a> {
self.into()
}
}
#[derive(Clone, PartialEq, Debug, Default)]
pub struct OsuPerformanceAttributes {
pub difficulty: OsuDifficultyAttributes,
pub pp: f64,
pub pp_acc: f64,
pub pp_aim: f64,
pub pp_speed: f64,
pub effective_miss_count: f64,
}
impl OsuPerformanceAttributes {
/// Return the star value.
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
pub const fn max_combo(&self) -> u32 {
self.difficulty.max_combo
}
/// Return the amount of hitobjects.
pub const fn n_objects(&self) -> u32 {
self.difficulty.n_objects()
}
/// Returns a builder for performance calculation.
pub fn performance<'a>(self) -> OsuPerformance<'a> {
self.difficulty.into()
}
}
impl From<OsuPerformanceAttributes> for OsuDifficultyAttributes {
fn from(attributes: OsuPerformanceAttributes) -> Self {
attributes.difficulty
}
}
+277
View File
@@ -0,0 +1,277 @@
use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers};
use crate::{model::{
beatmap::{Beatmap, Converted},
mode::ConvertStatus,
}, osu::difficulty::scaling_factor::ScalingFactor};
use super::{
attributes::OsuDifficultyAttributes,
object::{NestedSliderObjectKind, OsuObject, OsuObjectKind},
OsuRelax,
};
/// A [`Beatmap`] for [`Osu`] calculations.
pub type OsuRelaxBeatmap<'a> = Converted<'a, OsuRelax>;
pub fn check_convert(map: &Beatmap) -> ConvertStatus {
if map.mode == GameMode::Osu {
ConvertStatus::Noop
} else {
ConvertStatus::Incompatible
}
}
pub fn try_convert(map: &mut Beatmap) -> ConvertStatus {
check_convert(map)
}
pub fn convert_objects(
converted: &OsuRelaxBeatmap<'_>,
scaling_factor: &ScalingFactor,
hr: bool,
time_preempt: f64,
mut take: usize,
attrs: &mut OsuDifficultyAttributes,
) -> Box<[OsuObject]> {
let mut curve_bufs = CurveBuffers::default();
// mean=5.16 | median=4
let mut ticks_buf = Vec::new();
let mut osu_objects: Box<[_]> = converted
.hit_objects
.iter()
.map(|h| OsuObject::new(h, converted, &mut curve_bufs, &mut ticks_buf))
.inspect(|h| {
if take == 0 {
return;
}
take -= 1;
attrs.max_combo += 1;
match h.kind {
OsuObjectKind::Circle => attrs.n_circles += 1,
OsuObjectKind::Slider(ref slider) => {
attrs.n_sliders += 1;
attrs.max_combo += slider.nested_objects.len() as u32;
attrs.n_slider_ticks += slider
.nested_objects
.iter()
.filter(|nested| {
matches!(
nested.kind,
NestedSliderObjectKind::Tick | NestedSliderObjectKind::Repeat
)
})
.count() as u32;
}
OsuObjectKind::Spinner(_) => attrs.n_spinners += 1,
}
})
.collect();
if hr {
osu_objects
.iter_mut()
.for_each(OsuObject::reflect_vertically);
} else {
osu_objects.iter_mut().for_each(OsuObject::finalize_nested);
}
let stack_threshold = time_preempt * f64::from(converted.stack_leniency);
if converted.version >= 6 {
stacking(&mut osu_objects, stack_threshold);
} else {
old_stacking(&mut osu_objects, stack_threshold);
}
for h in osu_objects.iter_mut() {
h.stack_offset = scaling_factor.stack_offset(h.stack_height);
if let OsuObjectKind::Slider(ref mut slider) = h.kind {
slider.lazy_end_pos += h.pos + h.stack_offset;
}
}
osu_objects
}
const STACK_DISTANCE: f32 = 3.0;
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
let mut extended_start_idx = 0;
let Some(extended_end_idx) = hit_objects.len().checked_sub(1) else {
return;
};
// First big `if` in osu!lazer's function can be skipped
for i in (1..=extended_end_idx).rev() {
let mut n = i;
let mut obj_i_idx = i;
// * We should check every note which has not yet got a stack.
// * Consider the case we have two interwound stacks and this will make sense.
// * o <-1 o <-2
// * o <-3 o <-4
// * We first process starting from 4 and handle 2,
// * then we come backwards on the i loop iteration until we reach 3 and handle 1.
// * 2 and 1 will be ignored in the i loop because they already have a stack value.
if hit_objects[obj_i_idx].stack_height != 0 || hit_objects[obj_i_idx].is_spinner() {
continue;
}
// * If this object is a hitcircle, then we enter this "special" case.
// * It either ends with a stack of hitcircles only,
// * or a stack of hitcircles that are underneath a slider.
// * Any other case is handled by the "is_slider" code below this.
if hit_objects[obj_i_idx].is_circle() {
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
}
if hit_objects[obj_i_idx].start_time - hit_objects[n].end_time() > stack_threshold {
break; // * We are no longer within stacking range of the previous object.
}
// * HitObjects before the specified update range haven't been reset yet
if n < extended_start_idx {
hit_objects[n].stack_height = 0;
extended_start_idx = n;
}
// * This is a special case where hticircles are moved DOWN and RIGHT (negative stacking)
// * if they are under the *last* slider in a stacked pattern.
// * o==o <- slider is at original location
// * o <- hitCircle has stack of -1
// * o <- hitCircle has stack of -2
if hit_objects[n].is_slider()
&& hit_objects[n]
.end_pos()
.distance(hit_objects[obj_i_idx].pos)
< STACK_DISTANCE
{
let offset =
hit_objects[obj_i_idx].stack_height - hit_objects[n].stack_height + 1;
for j in n + 1..=i {
// * For each object which was declared under this slider, we will offset
// * it to appear *below* the slider end (rather than above).
if hit_objects[n].end_pos().distance(hit_objects[j].pos) < STACK_DISTANCE {
hit_objects[j].stack_height -= offset;
}
}
// * We have hit a slider. We should restart calculation using this as the new base.
// * Breaking here will mean that the slider still has StackCount of 0,
// * so will be handled in the i-outer-loop.
break;
}
if hit_objects[n].pos.distance(hit_objects[obj_i_idx].pos) < STACK_DISTANCE {
// * Keep processing as if there are no sliders.
// * If we come across a slider, this gets cancelled out.
// * NOTE: Sliders with start positions stacking
// * are a special case that is also handled here.
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1;
obj_i_idx = n;
}
}
} else if hit_objects[obj_i_idx].is_slider() {
// * We have hit the first slider in a possible stack.
// * From this point on, we ALWAYS stack positive regardless.
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
}
if hit_objects[obj_i_idx].start_time - hit_objects[n].start_time > stack_threshold {
break; // * We are no longer within stacking range of the previous object.
}
if hit_objects[n]
.end_pos()
.distance(hit_objects[obj_i_idx].pos)
< STACK_DISTANCE
{
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1;
obj_i_idx = n;
}
}
}
}
}
fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
for i in 0..hit_objects.len() {
if hit_objects[i].stack_height != 0 && !hit_objects[i].is_slider() {
continue;
}
let mut start_time = hit_objects[i].end_time();
let pos2 = {
let h = &hit_objects[i];
match h.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => h.pos,
OsuObjectKind::Slider(ref slider) => {
// We need the path endpos instead of the slider endpos
let repeat_count = slider.repeat_count();
let nested = if repeat_count % 2 == 0 {
slider.tail()
} else {
slider
.nested_objects
.iter()
.find(|nested| matches!(nested.kind, NestedSliderObjectKind::Repeat))
};
nested.map_or(h.pos, |nested| nested.pos)
}
}
};
let mut slider_stack = 0;
for j in i + 1..hit_objects.len() {
if hit_objects[j].start_time - stack_threshold > start_time {
break;
}
// * Note the use of `StartTime` in the code below doesn't match stable's use of `EndTime`.
// * This is because in the stable implementation, `UpdateCalculations` is not called on the inner-loop hitobject (j)
// * and therefore it does not have a correct `EndTime`, but instead the default of `EndTime = StartTime`.
// *
// * Effects of this can be seen on https://osu.ppy.sh/beatmapsets/243#osu/1146 at sliders around 86647 ms, where
// * if we use `EndTime` here it would result in unexpected stacking.
if hit_objects[j].pos.distance(hit_objects[i].pos) < STACK_DISTANCE {
hit_objects[i].stack_height += 1;
start_time = hit_objects[j].start_time;
} else if hit_objects[j].pos.distance(pos2) < STACK_DISTANCE {
slider_stack += 1;
hit_objects[j].stack_height -= slider_stack;
start_time = hit_objects[j].start_time;
}
}
}
}
+232
View File
@@ -0,0 +1,232 @@
use std::{cmp, mem};
use osu_objects::OsuObjects;
use crate::{any::difficulty::skills::Skill, osu_2019::{convert::{convert_objects, OsuRelaxBeatmap}, object::{OsuObject, OsuObjectKind}, OsuDifficultyAttributes}, Difficulty};
use super::{object::OsuDifficultyObject, skills::OsuSkills, DifficultyValues, OsuDifficultySetup};
/// Gradually calculate the difficulty attributes of an osu!standard (relax) 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, Difficulty};
/// use rosu_pp::osu_2019::{OsuRelax, OsuGradualDifficulty};
///
/// let converted = Beatmap::from_path("./resources/2785319.osu")
/// .unwrap()
/// .unchecked_into_converted::<OsuRelax>();
///
/// let difficulty = Difficulty::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,
pub(crate) difficulty: Difficulty,
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 OsuGradualDifficulty {
/// Create a new difficulty attributes iterator for osu!standard (relax) maps.
pub fn new(difficulty: Difficulty, converted: &OsuRelaxBeatmap<'_>) -> 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.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();
let diff_objects = extend_lifetime(diff_objects.into_boxed_slice());
Self {
idx: 0,
difficulty,
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.speed, &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 speed_difficulty_value = self.skills.speed.as_difficulty_value();
DifficultyValues::eval(
&mut attrs,
&aim_difficulty_value,
&speed_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 = cmp::min(n, 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 speed = Skill::new(&mut self.skills.speed, &self.diff_objects);
for curr in skip_iter.take(take) {
aim.process(curr);
speed.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_2019::object::OsuObject;
/// Wrapper to ensure that the data will not be moved
pub(super) struct OsuObjects {
objects: Box<[OsuObject]>,
}
impl OsuObjects {
pub(super) const 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)
}
}
}
+176
View File
@@ -0,0 +1,176 @@
use std::{cmp, pin::Pin};
use object::OsuDifficultyObject;
use skills::{strain::{DifficultyValue, UsedOsuStrainSkills}, OsuSkills};
use crate::{any::difficulty::skills::Skill, model::beatmap::BeatmapAttributes, osu::difficulty::scaling_factor::ScalingFactor, Difficulty};
use super::{attributes::OsuDifficultyAttributes, convert::{convert_objects, OsuRelaxBeatmap}, object::OsuObject};
pub mod skills;
pub mod gradual;
pub mod object;
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
pub fn difficulty(difficulty: &Difficulty, converted: &OsuRelaxBeatmap) -> OsuDifficultyAttributes {
let DifficultyValues {
skills:
OsuSkills {
aim,
speed,
},
mut attrs,
} = DifficultyValues::calculate(difficulty, converted);
let aim_difficulty_value = aim.difficulty_value();
let speed_difficulty_value = speed.difficulty_value();
DifficultyValues::eval(
&mut attrs,
&aim_difficulty_value,
&speed_difficulty_value,
);
attrs
}
pub struct OsuDifficultySetup {
scaling_factor: ScalingFactor,
map_attrs: BeatmapAttributes,
attrs: OsuDifficultyAttributes,
time_preempt: f64,
}
impl OsuDifficultySetup {
pub fn new(difficulty: &Difficulty, beatmap: &OsuRelaxBeatmap) -> Self {
let clock_rate = difficulty.get_clock_rate();
let map_attrs = beatmap.attributes().difficulty(difficulty).build();
let scaling_factor = ScalingFactor::new(map_attrs.cs);
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: &Difficulty, converted: &OsuRelaxBeatmap) -> 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,
mods.hr(),
time_preempt,
take,
&mut attrs,
);
let osu_object_iter = osu_objects.iter_mut().map(Pin::new);
let diff_objects =
Self::create_difficulty_objects(difficulty, &scaling_factor, osu_object_iter);
let mut skills = OsuSkills::new();
{
let mut aim = Skill::new(&mut skills.aim, &diff_objects);
let mut speed = Skill::new(&mut skills.speed, &diff_objects);
// The first hit object has no difficulty object
let take_diff_objects = cmp::min(converted.hit_objects.len(), take).saturating_sub(1);
for hit_object in diff_objects.iter().take(take_diff_objects) {
aim.process(hit_object);
speed.process(hit_object);
}
}
Self { skills, attrs }
}
/// Process the difficulty values and store the results in `attrs`.
pub fn eval(
attrs: &mut OsuDifficultyAttributes,
aim: &UsedOsuStrainSkills<DifficultyValue>,
speed: &UsedOsuStrainSkills<DifficultyValue>,
) {
let aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let aim_difficult_strain_count = aim.count_difficult_strains();
let speed_difficult_strain_count = speed.count_difficult_strains();
let stars = aim_rating + speed_rating + (aim_rating - speed_rating).abs() / 2.0;
attrs.aim_strain = aim_rating;
attrs.speed_strain = speed_rating;
attrs.aim_difficult_strain_count = aim_difficult_strain_count;
attrs.speed_difficult_strain_count = speed_difficult_strain_count;
attrs.stars = stars;
}
pub fn create_difficulty_objects<'a>(
difficulty: &Difficulty,
scaling_factor: &ScalingFactor,
osu_objects: impl ExactSizeIterator<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 Vec::new();
};
let mut last_last = None;
osu_objects_iter
.enumerate()
.map(|(idx, h)| {
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()
}
}
+240
View File
@@ -0,0 +1,240 @@
use std::{borrow::Cow, pin::Pin};
use rosu_map::util::Pos;
use crate::{
any::difficulty::object::IDifficultyObject,
osu_2019::object::{OsuObject, OsuObjectKind, OsuSlider},
};
use crate::osu::difficulty::{scaling_factor::ScalingFactor, HD_FADE_OUT_DURATION_MULTIPLIER};
pub struct OsuDifficultyObject<'a> {
pub idx: usize,
pub base: &'a OsuObject,
pub start_time: f64,
pub delta_time: f64,
pub strain_time: f64,
pub lazy_jump_dist: f64,
pub min_jump_dist: f64,
pub min_jump_time: f64,
pub travel_dist: f64,
pub travel_time: f64,
pub angle: Option<f64>,
}
impl<'a> OsuDifficultyObject<'a> {
pub const NORMALIZED_RADIUS: f32 = 50.0;
pub const MIN_DELTA_TIME: f64 = 25.0;
const MAX_SLIDER_RADIUS: f32 = Self::NORMALIZED_RADIUS * 2.4;
const ASSUMED_SLIDER_RADIUS: f32 = Self::NORMALIZED_RADIUS * 1.8;
pub fn new(
hit_object: &'a OsuObject,
last_object: &'a OsuObject,
last_last_object: Option<&OsuObject>,
clock_rate: f64,
idx: usize,
scaling_factor: &ScalingFactor,
) -> Self {
let delta_time = (hit_object.start_time - last_object.start_time) / clock_rate;
let start_time = hit_object.start_time / clock_rate;
let strain_time = delta_time.max(Self::MIN_DELTA_TIME);
let mut this = Self {
idx,
base: hit_object,
start_time,
delta_time,
strain_time,
lazy_jump_dist: 0.0,
min_jump_dist: 0.0,
min_jump_time: 0.0,
travel_dist: 0.0,
travel_time: 0.0,
angle: None,
};
this.set_distances(last_object, last_last_object, clock_rate, scaling_factor);
this
}
pub fn opacity_at(&self, time: f64, hidden: bool, time_preempt: f64, time_fade_in: f64) -> f64 {
if time > self.base.start_time {
// * Consider a hitobject as being invisible when its start time is passed.
// * In reality the hitobject will be visible beyond its start time up until its hittable window has passed,
// * but this is an approximation and such a case is unlikely to be hit where this function is used.
return 0.0;
}
let fade_in_start_time = self.base.start_time - time_preempt;
let fade_in_duration = time_fade_in;
if hidden {
// * Taken from OsuModHidden.
let fade_out_start_time = self.base.start_time - time_preempt + time_fade_in;
let fade_out_duration = time_preempt * HD_FADE_OUT_DURATION_MULTIPLIER;
(((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0))
.min(1.0 - ((time - fade_out_start_time) / fade_out_duration).clamp(0.0, 1.0))
} else {
((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0)
}
}
pub fn get_doubletapness(&self, next: Option<&Self>, hit_window: f64) -> f64 {
let Some(next) = next else { return 0.0 };
let hit_window = if self.base.is_spinner() {
0.0
} else {
hit_window
};
let curr_delta_time = self.delta_time.max(1.0);
let next_delta_time = next.delta_time.max(1.0);
let delta_diff = (next_delta_time - curr_delta_time).abs();
let speed_ratio = curr_delta_time / curr_delta_time.max(delta_diff);
let window_ratio = (curr_delta_time / hit_window).min(1.0).powf(2.0);
1.0 - (speed_ratio).powf(1.0 - window_ratio)
}
fn set_distances(
&mut self,
last_object: &OsuObject,
last_last_object: Option<&OsuObject>,
clock_rate: f64,
scaling_factor: &ScalingFactor,
) {
if let OsuObjectKind::Slider(ref slider) = self.base.kind {
self.travel_dist = f64::from(
slider.lazy_travel_dist
* ((1.0 + slider.repeat_count() as f64 / 2.5).powf(1.0 / 2.5)) as f32,
);
self.travel_time = (self.base.lazy_travel_time() / clock_rate)
.max(OsuDifficultyObject::MIN_DELTA_TIME);
}
if self.base.is_spinner() || last_object.is_spinner() {
return;
}
let scaling_factor = scaling_factor.factor;
let last_cursor_pos = Self::get_end_cursor_pos(last_object);
self.lazy_jump_dist = f64::from(
(self.base.stacked_pos() * scaling_factor - last_cursor_pos * scaling_factor).length(),
);
self.min_jump_time = self.strain_time;
self.min_jump_dist = self.lazy_jump_dist;
if let OsuObjectKind::Slider(ref last_slider) = last_object.kind {
let last_travel_time = (last_object.lazy_travel_time() / clock_rate)
.max(OsuDifficultyObject::MIN_DELTA_TIME);
self.min_jump_time =
(self.strain_time - last_travel_time).max(OsuDifficultyObject::MIN_DELTA_TIME);
let tail_pos = last_slider.tail().map_or(last_object.pos, |tail| tail.pos);
let stacked_tail_pos = tail_pos + last_object.stack_offset;
let tail_jump_dist =
(stacked_tail_pos - self.base.stacked_pos()).length() * scaling_factor;
let diff = f64::from(
OsuDifficultyObject::MAX_SLIDER_RADIUS - OsuDifficultyObject::ASSUMED_SLIDER_RADIUS,
);
let min = f64::from(tail_jump_dist - OsuDifficultyObject::MAX_SLIDER_RADIUS);
self.min_jump_dist = ((self.lazy_jump_dist - diff).min(min)).max(0.0);
}
if let Some(last_last_object) = last_last_object.filter(|h| !h.is_spinner()) {
let last_last_cursor_pos = Self::get_end_cursor_pos(last_last_object);
let v1 = last_last_cursor_pos - last_object.stacked_pos();
let v2 = self.base.stacked_pos() - last_cursor_pos;
let dot = v1.dot(v2);
let det = v1.x * v2.y - v1.y * v2.x;
self.angle = Some((f64::from(det).atan2(f64::from(dot))).abs());
}
}
/// 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 start_time = h.start_time;
let OsuObjectKind::Slider(ref mut slider) = h.kind else {
return h;
};
let mut nested = Cow::Borrowed(slider.nested_objects.as_slice());
let duration = slider.end_time - start_time;
OsuSlider::lazy_travel_time(start_time, duration, &mut nested);
let nested = nested.as_ref();
let mut curr_cursor_pos = pos + stack_offset;
let scaling_factor = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS) / radius;
for (curr_movement_obj, i) in nested.iter().zip(1..) {
let mut curr_movement = curr_movement_obj.pos + stack_offset - curr_cursor_pos;
let mut curr_movement_len = scaling_factor * f64::from(curr_movement.length());
let mut required_movement = f64::from(OsuDifficultyObject::ASSUMED_SLIDER_RADIUS);
if i == nested.len() {
let lazy_movement = slider.lazy_end_pos - curr_cursor_pos;
if lazy_movement.length() < curr_movement.length() {
curr_movement = lazy_movement;
}
curr_movement_len = scaling_factor * f64::from(curr_movement.length());
} else if curr_movement_obj.is_repeat() {
required_movement = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS);
}
if curr_movement_len > required_movement {
curr_cursor_pos += curr_movement
* ((curr_movement_len - required_movement) / curr_movement_len) as f32;
curr_movement_len *= (curr_movement_len - required_movement) / curr_movement_len;
slider.lazy_travel_dist += curr_movement_len as f32;
}
if i == nested.len() {
slider.lazy_end_pos = curr_cursor_pos;
}
}
h
}
fn get_end_cursor_pos(hit_object: &OsuObject) -> Pos {
if let OsuObjectKind::Slider(ref slider) = hit_object.kind {
// We don't have access to the slider's curve at this point so we
// take the pre-computed value.
slider.lazy_end_pos
} else {
hit_object.stacked_pos()
}
}
}
impl IDifficultyObject for OsuDifficultyObject<'_> {
fn idx(&self) -> usize {
self.idx
}
}
+144
View File
@@ -0,0 +1,144 @@
use crate::{any::difficulty::{object::IDifficultyObject, skills::{strain_decay, ISkill, Skill}}, osu_2019::difficulty::object::OsuDifficultyObject, util::strains_vec::StrainsVec};
use super::strain::{DifficultyValue, OsuStrainSkill, UsedOsuStrainSkills};
const SKILL_MULTIPLIER: f64 = 26.25;
const STRAIN_DECAY_BASE: f64 = 0.15;
#[derive(Clone)]
pub struct Aim {
curr_strain: f64,
inner: OsuStrainSkill,
}
impl Aim {
pub fn new() -> Self {
Self {
curr_strain: 0.0,
inner: OsuStrainSkill::default(),
}
}
pub fn get_curr_strain_peaks(self) -> StrainsVec {
self.inner.get_curr_strain_peaks().strains()
}
pub fn difficulty_value(self) -> UsedOsuStrainSkills<DifficultyValue> {
Self::static_difficulty_value(self.inner)
}
/// Use [`difficulty_value`] instead whenever possible because
/// [`as_difficulty_value`] clones internally.
pub fn as_difficulty_value(&self) -> UsedOsuStrainSkills<DifficultyValue> {
Self::static_difficulty_value(self.inner.clone())
}
fn static_difficulty_value(skill: OsuStrainSkill) -> UsedOsuStrainSkills<DifficultyValue> {
skill.difficulty_value(
OsuStrainSkill::DECAY_WEIGHT,
)
}
}
impl ISkill for Aim {
type DifficultyObjects<'a> = [OsuDifficultyObject<'a>];
}
impl<'a> Skill<'a, Aim> {
fn calculate_initial_strain(&mut self, time: f64, curr: &'a OsuDifficultyObject<'a>) -> f64 {
let prev_start_time = curr
.previous(0, self.diff_objects)
.map_or(0.0, |prev| prev.start_time);
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
}
fn curr_section_peak(&self) -> f64 {
self.inner.inner.inner.curr_section_peak
}
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_peak
}
fn curr_section_end(&self) -> f64 {
self.inner.inner.inner.curr_section_end
}
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_end
}
pub fn process(&mut self, curr: &'a OsuDifficultyObject<'a>) {
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / OsuStrainSkill::SECTION_LEN).ceil()
* OsuStrainSkill::SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.inner.inner.save_curr_peak();
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
self.inner.inner.start_new_section_from(initial_strain);
*self.curr_section_end_mut() += OsuStrainSkill::SECTION_LEN;
}
let strain_value_at = self.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
fn strain_value_at(&mut self, curr: &'a OsuDifficultyObject<'a>) -> f64 {
self.inner.curr_strain *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE);
self.inner.curr_strain +=
AimEvaluator::evaluate_diff_of(curr, self.diff_objects)
* SKILL_MULTIPLIER;
self.inner.inner.object_strains.push(self.inner.curr_strain);
self.inner.curr_strain
}
}
struct AimEvaluator;
impl AimEvaluator {
const ANGLE_BONUS_BEGIN: f64 = std::f64::consts::FRAC_PI_3;
const TIMING_THRESHOLD: f64 = 107.0;
fn evaluate_diff_of<'a>(
curr: &'a OsuDifficultyObject<'a>,
diff_objects: &'a [OsuDifficultyObject<'a>],
) -> f64 {
if curr.base.is_spinner() {
return 0.0;
}
let mut result = 0.0;
if let Some(prev) = curr.previous(0, diff_objects) {
if let Some(angle) = curr.angle.filter(|a| *a > Self::ANGLE_BONUS_BEGIN) {
let scale = 90.0;
let angle_bonus = (((angle - Self::ANGLE_BONUS_BEGIN).sin()).powi(2)
* (prev.lazy_jump_dist - scale).max(0.0)
* (curr.lazy_jump_dist - scale).max(0.0))
.sqrt();
result = 1.5 * apply_diminishing_exp(angle_bonus.max(0.0))
/ (Self::TIMING_THRESHOLD).max(prev.strain_time)
}
}
let jump_dist_exp = apply_diminishing_exp(curr.lazy_jump_dist);
let travel_dist_exp = apply_diminishing_exp(curr.travel_dist);
let dist_exp =
jump_dist_exp + travel_dist_exp + (travel_dist_exp * jump_dist_exp).sqrt();
(result + dist_exp / (curr.strain_time).max(Self::TIMING_THRESHOLD))
.max(dist_exp / curr.strain_time)
}
}
#[inline]
fn apply_diminishing_exp(val: f64) -> f64 {
val.powf(0.99)
}
+23
View File
@@ -0,0 +1,23 @@
use aim::Aim;
use speed::Speed;
pub mod aim;
pub mod speed;
pub mod strain;
pub struct OsuSkills {
pub aim: Aim,
pub speed: Speed,
}
impl OsuSkills {
pub fn new() -> Self {
let aim = Aim::new();
let speed = Speed::new();
Self {
aim,
speed,
}
}
}
+165
View File
@@ -0,0 +1,165 @@
use crate::{any::difficulty::{object::IDifficultyObject, skills::{strain_decay, ISkill, Skill}}, osu_2019::difficulty::object::OsuDifficultyObject, util::strains_vec::StrainsVec};
use super::strain::{DifficultyValue, OsuStrainSkill, UsedOsuStrainSkills};
const SKILL_MULTIPLIER: f64 = 1400.0;
const STRAIN_DECAY_BASE: f64 = 0.3;
#[derive(Clone)]
pub struct Speed {
curr_strain: f64,
inner: OsuStrainSkill,
}
impl Speed {
pub fn new() -> Self {
Self {
curr_strain: 0.0,
inner: OsuStrainSkill::default(),
}
}
pub fn get_curr_strain_peaks(self) -> StrainsVec {
self.inner.get_curr_strain_peaks().strains()
}
pub fn difficulty_value(self) -> UsedOsuStrainSkills<DifficultyValue> {
Self::static_difficulty_value(self.inner)
}
/// Use [`difficulty_value`] instead whenever possible because
/// [`as_difficulty_value`] clones internally.
pub fn as_difficulty_value(&self) -> UsedOsuStrainSkills<DifficultyValue> {
Self::static_difficulty_value(self.inner.clone())
}
fn static_difficulty_value(skill: OsuStrainSkill) -> UsedOsuStrainSkills<DifficultyValue> {
skill.difficulty_value(
OsuStrainSkill::DECAY_WEIGHT,
)
}
pub fn relevant_note_count(&self) -> f64 {
self.inner
.object_strains
.iter()
.copied()
.max_by(f64::total_cmp)
.filter(|&n| n > 0.0)
.map_or(0.0, |max_strain| {
self.inner.object_strains.iter().fold(0.0, |sum, strain| {
sum + (1.0 + (-(strain / max_strain * 12.0 - 6.0)).exp()).recip()
})
})
}
}
impl ISkill for Speed {
type DifficultyObjects<'a> = [OsuDifficultyObject<'a>];
}
impl<'a> Skill<'a, Speed> {
fn calculate_initial_strain(&mut self, time: f64, curr: &'a OsuDifficultyObject<'a>) -> f64 {
let prev_start_time = curr
.previous(0, self.diff_objects)
.map_or(0.0, |prev| prev.start_time);
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
}
fn curr_section_peak(&self) -> f64 {
self.inner.inner.inner.curr_section_peak
}
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_peak
}
fn curr_section_end(&self) -> f64 {
self.inner.inner.inner.curr_section_end
}
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_end
}
pub fn process(&mut self, curr: &'a OsuDifficultyObject<'a>) {
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / OsuStrainSkill::SECTION_LEN).ceil()
* OsuStrainSkill::SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.inner.inner.save_curr_peak();
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
self.inner.inner.start_new_section_from(initial_strain);
*self.curr_section_end_mut() += OsuStrainSkill::SECTION_LEN;
}
let strain_value_at = self.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
fn strain_value_at(&mut self, curr: &'a OsuDifficultyObject<'a>) -> f64 {
self.inner.curr_strain *= strain_decay(curr.strain_time, STRAIN_DECAY_BASE);
self.inner.curr_strain += SpeedEvaluator::evaluate_diff_of(
curr
) * SKILL_MULTIPLIER;
self.inner.inner.object_strains.push(self.inner.curr_strain);
self.inner.curr_strain
}
}
struct SpeedEvaluator;
impl SpeedEvaluator {
const SINGLE_SPACING_TRESHOLD: f64 = 125.0;
const ANGLE_BONUS_BEGIN: f64 = 5.0 * std::f64::consts::FRAC_PI_6;
const PI_OVER_4: f64 = std::f64::consts::FRAC_PI_4;
const PI_OVER_2: f64 = std::f64::consts::FRAC_PI_2;
const MIN_SPEED_BONUS: f64 = 75.0;
const MAX_SPEED_BONUS: f64 = 45.0;
const SPEED_BALANCING_FACTOR: f64 = 40.0;
fn evaluate_diff_of<'a>(curr: &'a OsuDifficultyObject<'a>) -> f64 {
if curr.base.is_spinner() {
return 0.0;
}
let dist = Self::SINGLE_SPACING_TRESHOLD.min(curr.travel_dist + curr.lazy_jump_dist);
let delta_time = Self::MAX_SPEED_BONUS.max(curr.delta_time);
let mut speed_bonus = 1.0;
if delta_time < Self::MIN_SPEED_BONUS {
let exp_base = (Self::MIN_SPEED_BONUS - delta_time) / Self::SPEED_BALANCING_FACTOR;
speed_bonus += exp_base * exp_base;
}
let mut angle_bonus = 1.0;
if let Some(angle) = curr.angle.filter(|a| *a < Self::ANGLE_BONUS_BEGIN) {
let exp_base = (1.5 * (Self::ANGLE_BONUS_BEGIN - angle)).sin();
angle_bonus = 1.0 + exp_base * exp_base / 3.57;
if angle < Self::PI_OVER_2 {
angle_bonus = 1.28;
if dist < 90.0 && angle < Self::PI_OVER_4 {
angle_bonus += (1.0 - angle_bonus) * ((90.0 - dist) / 10.0).min(1.0);
} else if dist < 90.0 {
angle_bonus += (1.0 - angle_bonus)
* ((90.0 - dist) / 10.0).min(1.0)
* ((Self::PI_OVER_2 - angle) / Self::PI_OVER_4).sin();
}
}
}
(1.0 + (speed_bonus - 1.0) * 0.75)
* angle_bonus
* (0.95 + speed_bonus * (dist / Self::SINGLE_SPACING_TRESHOLD).powf(3.5))
/ curr.strain_time
}
}
+105
View File
@@ -0,0 +1,105 @@
use crate::{any::difficulty::skills::StrainSkill, util::strains_vec::StrainsVec};
#[derive(Clone)]
pub struct OsuStrainSkill {
pub object_strains: Vec<f64>,
pub inner: StrainSkill,
}
impl Default for OsuStrainSkill {
fn default() -> Self {
Self {
// mean=406.72 | median=307
object_strains: Vec::with_capacity(256),
inner: StrainSkill::default(),
}
}
}
impl OsuStrainSkill {
pub const REDUCED_SECTION_COUNT: usize = 10;
pub const REDUCED_STRAIN_BASELINE: f64 = 0.75;
pub const DECAY_WEIGHT: f64 = 0.9;
pub const SECTION_LEN: f64 = 400.0;
pub fn save_curr_peak(&mut self) {
self.inner.save_curr_peak();
}
pub fn start_new_section_from(&mut self, initial_strain: f64) {
self.inner.start_new_section_from(initial_strain);
}
pub fn get_curr_strain_peaks(self) -> UsedOsuStrainSkills<StrainsVec> {
UsedOsuStrainSkills {
value: self.inner.get_curr_strain_peaks(),
object_strains: self.object_strains,
}
}
pub fn difficulty_value(
self,
decay_weight: f64,
) -> UsedOsuStrainSkills<DifficultyValue> {
let mut difficulty = 0.0;
let mut weight = 1.0;
let UsedOsuStrainSkills {
value: mut peaks,
object_strains,
} = self.get_curr_strain_peaks();
peaks.sort_desc();
for strain in peaks.iter() {
difficulty += strain * weight;
weight *= decay_weight;
}
UsedOsuStrainSkills {
value: DifficultyValue(difficulty),
object_strains,
}
}
pub fn difficulty_to_performance(difficulty: f64) -> f64 {
(5.0 * (difficulty / 0.0675).max(1.0) - 4.0).powf(3.0) / 100_000.0
}
}
pub struct DifficultyValue(f64);
pub struct UsedOsuStrainSkills<T> {
value: T,
object_strains: Vec<f64>,
}
impl UsedOsuStrainSkills<DifficultyValue> {
pub const fn difficulty_value(&self) -> f64 {
self.value.0
}
pub fn count_difficult_strains(&self) -> f64 {
let DifficultyValue(diff) = self.value;
if diff.abs() < f64::EPSILON {
return 0.0;
}
// * What would the top strain be if all strain values were identical
let consistent_top_strain = diff / 10.0;
// * Use a weighted sum of all strains. Constants are arbitrary and give nice values
self.object_strains
.iter()
.map(|s| 1.1 / (1.0 + (-10.0 * (s / consistent_top_strain - 0.88)).exp()))
.sum()
}
}
impl UsedOsuStrainSkills<StrainsVec> {
pub fn strains(self) -> StrainsVec {
self.value
}
}
-61
View File
@@ -1,61 +0,0 @@
use super::OsuObject;
pub(crate) struct DifficultyObject<'h> {
pub(crate) base: &'h OsuObject,
pub(crate) prev: Option<(f32, f32)>, // (jump_dist, strain_time)
pub(crate) jump_dist: f32,
pub(crate) travel_dist: f32,
pub(crate) angle: Option<f32>,
pub(crate) delta: f32,
pub(crate) strain_time: f32,
}
impl<'h> DifficultyObject<'h> {
pub(crate) fn new(
base: &'h OsuObject,
prev: &OsuObject,
prev_vals: Option<(f32, f32)>, // (jump_dist, strain_time)
prev_prev: Option<OsuObject>,
clock_rate: f32,
scaling_factor: f32,
) -> Self {
let delta = (base.time - prev.time) / clock_rate;
let strain_time = delta.max(50.0);
let pos = base.pos;
let travel_dist = prev.travel_dist.unwrap_or(0.0);
let prev_cursor_pos = prev.end_pos;
let jump_dist = if base.is_spinner() {
0.0
} else {
((pos - prev_cursor_pos) * scaling_factor).length()
};
let angle = prev_prev.map(|prev_prev| {
let prev_prev_cursor_pos = prev_prev.end_pos;
let v1 = prev_prev_cursor_pos - prev.pos;
let v2 = pos - prev_cursor_pos;
let dot = v1.dot(v2);
let det = v1.x * v2.y - v1.y * v2.x;
det.atan2(dot).abs()
});
Self {
base,
prev: prev_vals,
jump_dist,
travel_dist,
angle,
delta,
strain_time,
}
}
}
+62 -11
View File
@@ -1,16 +1,67 @@
mod difficulty_object;
use difficulty_object::DifficultyObject;
use convert::OsuRelaxBeatmap;
use rosu_map::util::Pos;
use strains::OsuStrains;
mod osu_object;
use osu_object::OsuObject;
use crate::{model::mode::{ConvertStatus, IGameMode}, Beatmap, Difficulty};
mod pp;
pub use pp::{OsuAttributeProvider, OsuPP};
mod difficulty;
mod attributes;
mod performance;
mod strains;
mod object;
mod convert;
mod skill;
use skill::Skill;
pub use performance::OsuPerformance;
pub use attributes::OsuPerformanceAttributes;
pub use attributes::OsuDifficultyAttributes;
pub use performance::gradual::OsuGradualPerformance;
pub use difficulty::gradual::OsuGradualDifficulty;
mod skill_kind;
use skill_kind::SkillKind;
const PLAYFIELD_BASE_SIZE: Pos = Pos::new(512.0, 384.0);
pub mod stars;
/// Marker type for [`GameMode::Osu`] with the Relax mod.
///
/// [`GameMode::Osu`]: rosu_map::section::general::GameMode::Osu
pub struct OsuRelax;
impl IGameMode for OsuRelax {
type DifficultyAttributes = OsuDifficultyAttributes;
type Strains = OsuStrains;
type Performance<'map> = OsuPerformance<'map>;
type GradualDifficulty = OsuGradualDifficulty;
type GradualPerformance = OsuGradualPerformance;
fn check_convert(map: &Beatmap) -> ConvertStatus {
convert::check_convert(map)
}
fn try_convert(map: &mut Beatmap) -> ConvertStatus {
convert::try_convert(map)
}
fn difficulty(
difficulty: &Difficulty,
converted: &OsuRelaxBeatmap<'_>,
) -> Self::DifficultyAttributes {
difficulty::difficulty(difficulty, converted)
}
fn strains(difficulty: &Difficulty, converted: &OsuRelaxBeatmap<'_>) -> Self::Strains {
strains::strains(difficulty, converted)
}
fn performance(map: OsuRelaxBeatmap<'_>) -> Self::Performance<'_> {
OsuPerformance::new(map)
}
fn gradual_difficulty(difficulty: Difficulty, map: &OsuRelaxBeatmap<'_>) -> Self::GradualDifficulty {
OsuGradualDifficulty::new(difficulty, map)
}
fn gradual_performance(
difficulty: Difficulty,
map: &OsuRelaxBeatmap<'_>,
) -> Self::GradualPerformance {
OsuGradualPerformance::new(difficulty, map)
}
}
+342
View File
@@ -0,0 +1,342 @@
use std::borrow::Cow;
use rosu_map::{
section::{
general::GameMode,
hit_objects::{CurveBuffers, SliderEvent, SliderEventType, SliderEventsIter},
},
util::Pos,
};
use crate::{
model::{
control_point::{DifficultyPoint, TimingPoint},
hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner},
},
util::{get_precision_adjusted_beat_len, sort},
};
use super::{convert::OsuRelaxBeatmap, PLAYFIELD_BASE_SIZE};
pub struct OsuObject {
pub pos: Pos,
pub start_time: f64,
pub stack_height: i32,
pub stack_offset: Pos,
pub kind: OsuObjectKind,
}
impl OsuObject {
pub const OBJECT_RADIUS: f32 = 64.0;
pub const PREEMPT_MIN: f64 = 450.0;
const BASE_SCORING_DIST: f32 = 100.0;
pub fn new(
h: &HitObject,
converted: &OsuRelaxBeatmap<'_>,
curve_bufs: &mut CurveBuffers,
ticks_buf: &mut Vec<SliderEvent>,
) -> Self {
let kind = match h.kind {
HitObjectKind::Circle => OsuObjectKind::Circle,
HitObjectKind::Slider(ref slider) => {
OsuObjectKind::Slider(OsuSlider::new(h, slider, converted, curve_bufs, ticks_buf))
}
HitObjectKind::Spinner(spinner) => OsuObjectKind::Spinner(spinner),
HitObjectKind::Hold(HoldNote { duration }) => {
OsuObjectKind::Spinner(Spinner { duration })
}
};
Self {
pos: h.pos,
start_time: h.start_time,
stack_height: 0,
stack_offset: Pos::default(),
kind,
}
}
pub fn reflect_vertically(&mut self) {
fn reflect_y(y: &mut f32) {
*y = PLAYFIELD_BASE_SIZE.y - *y;
}
reflect_y(&mut self.pos.y);
if let OsuObjectKind::Slider(ref mut slider) = self.kind {
// Requires `stack_offset` so we can't add `h.pos` just yet
slider.lazy_end_pos.y = -slider.lazy_end_pos.y;
for nested in slider.nested_objects.iter_mut() {
let mut nested_pos = self.pos; // already reflected at this point
nested_pos += Pos::new(nested.pos.x, -nested.pos.y);
nested.pos = nested_pos;
}
}
}
pub fn finalize_nested(&mut self) {
if let OsuObjectKind::Slider(ref mut slider) = self.kind {
for nested in slider.nested_objects.iter_mut() {
nested.pos += self.pos;
}
}
}
pub fn end_time(&self) -> f64 {
match self.kind {
OsuObjectKind::Circle => self.start_time,
OsuObjectKind::Slider(ref slider) => slider.end_time,
OsuObjectKind::Spinner(ref spinner) => self.start_time + spinner.duration,
}
}
pub fn stacked_pos(&self) -> Pos {
self.pos + self.stack_offset
}
pub fn end_pos(&self) -> Pos {
match self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => self.pos,
OsuObjectKind::Slider(ref slider) => {
slider.tail().map_or(Pos::default(), |nested| nested.pos)
}
}
}
pub fn stacked_end_pos(&self) -> Pos {
self.end_pos() + self.stack_offset
}
pub const fn lazy_travel_time(&self) -> f64 {
match self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => 0.0,
OsuObjectKind::Slider(ref slider) => slider.lazy_travel_time,
}
}
pub const fn is_circle(&self) -> bool {
matches!(self.kind, OsuObjectKind::Circle)
}
pub const fn is_slider(&self) -> bool {
matches!(self.kind, OsuObjectKind::Slider { .. })
}
pub const fn is_spinner(&self) -> bool {
matches!(self.kind, OsuObjectKind::Spinner(_))
}
}
pub enum OsuObjectKind {
Circle,
Slider(OsuSlider),
Spinner(Spinner),
}
pub struct OsuSlider {
pub end_time: f64,
pub lazy_end_pos: Pos,
pub lazy_travel_dist: f32,
pub lazy_travel_time: f64,
pub nested_objects: Vec<NestedSliderObject>,
}
impl OsuSlider {
fn new(
h: &HitObject,
slider: &Slider,
converted: &OsuRelaxBeatmap<'_>,
curve_bufs: &mut CurveBuffers,
ticks_buf: &mut Vec<SliderEvent>,
) -> Self {
let start_time = h.start_time;
let slider_multiplier = converted.slider_multiplier;
let slider_tick_rate = converted.slider_tick_rate;
let beat_len = converted
.timing_point_at(start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let (slider_velocity, generate_ticks) = converted.difficulty_point_at(start_time).map_or(
(
DifficultyPoint::DEFAULT_SLIDER_VELOCITY,
DifficultyPoint::DEFAULT_GENERATE_TICKS,
),
|point| (point.slider_velocity, point.generate_ticks),
);
let path = slider.curve(GameMode::Osu, curve_bufs);
let span_count = slider.span_count() as f64;
let velocity = f64::from(OsuObject::BASE_SCORING_DIST) * slider_multiplier
/ get_precision_adjusted_beat_len(slider_velocity, beat_len);
let scoring_dist = velocity * beat_len;
let end_time = start_time + span_count * path.dist() / velocity;
let duration = end_time - start_time;
let span_duration = duration / span_count;
let tick_dist_multiplier = if converted.version < 8 {
slider_velocity.recip()
} else {
1.0
};
let tick_dist = if generate_ticks {
scoring_dist / slider_tick_rate * tick_dist_multiplier
} else {
f64::INFINITY
};
let events = SliderEventsIter::new(
start_time,
span_duration,
velocity,
tick_dist,
path.dist(),
slider.span_count() as i32,
ticks_buf,
);
let span_at = |progress: f64| (progress * span_count) as i32;
let obj_progress_at = |progress: f64| {
let p = progress * span_count % 1.0;
if span_at(progress) % 2 == 1 {
1.0 - p
} else {
p
}
};
let end_path_pos = path.position_at(obj_progress_at(1.0));
let mut nested_objects: Vec<_> = events
.filter_map(|e| {
let obj = match e.kind {
SliderEventType::Tick => NestedSliderObject {
pos: path.position_at(e.path_progress),
start_time: e.time,
kind: NestedSliderObjectKind::Tick,
},
SliderEventType::Repeat => NestedSliderObject {
pos: path.position_at(e.path_progress),
start_time: start_time + f64::from(e.span_idx + 1) * span_duration,
kind: NestedSliderObjectKind::Repeat,
},
SliderEventType::Tail => NestedSliderObject {
pos: end_path_pos, // no `h.pos` yet to keep order of float operations
start_time: e.time,
kind: NestedSliderObjectKind::Tail,
},
SliderEventType::Head | SliderEventType::LastTick => return None,
};
Some(obj)
})
.collect();
sort::csharp(&mut nested_objects, |a, b| {
a.start_time.total_cmp(&b.start_time)
});
let mut nested = Cow::Borrowed(nested_objects.as_slice());
let lazy_travel_time = OsuSlider::lazy_travel_time(start_time, duration, &mut nested);
let mut end_time_min = lazy_travel_time / span_duration;
if end_time_min % 2.0 >= 1.0 {
end_time_min = 1.0 - end_time_min % 1.0;
} else {
end_time_min %= 1.0;
}
let lazy_end_pos = path.position_at(end_time_min);
Self {
end_time,
lazy_end_pos,
lazy_travel_dist: 0.0,
lazy_travel_time,
nested_objects,
}
}
pub fn lazy_travel_time(
start_time: f64,
duration: f64,
nested_objects: &mut Cow<'_, [NestedSliderObject]>,
) -> f64 {
const TAIL_LENIENCY: f64 = -36.0;
let mut tracking_end_time =
(start_time + duration + TAIL_LENIENCY).max(start_time + duration / 2.0);
let last_real_tick = nested_objects
.iter()
.enumerate()
.rfind(|(_, nested)| nested.is_tick());
if let Some((idx, last_real_tick)) =
last_real_tick.filter(|(_, tick)| tick.start_time > tracking_end_time)
{
tracking_end_time = last_real_tick.start_time;
// * When the last tick falls after the tracking end time, we need to re-sort the nested objects
// * based on time. This creates a somewhat weird ordering which is counter to how a user would
// * understand the slider, but allows a zero-diff with known diffcalc output.
// *
// * To reiterate, this is definitely not correct from a difficulty calculation perspective
// * and should be revisited at a later date (likely by replacing this whole code with the commented
// * version above).
nested_objects.to_mut()[idx..].rotate_left(1);
}
tracking_end_time - start_time
}
pub fn repeat_count(&self) -> usize {
self.nested_objects
.iter()
.filter(|nested| matches!(nested.kind, NestedSliderObjectKind::Repeat))
.count()
}
pub fn tail(&self) -> Option<&NestedSliderObject> {
self.nested_objects
.iter()
// The tail is not necessarily the last nested object, e.g. on very
// short and fast buzz sliders (/b/1001757)
.rfind(|nested| matches!(nested.kind, NestedSliderObjectKind::Tail))
}
}
#[derive(Clone, Debug)]
pub struct NestedSliderObject {
pub pos: Pos,
pub start_time: f64,
pub kind: NestedSliderObjectKind,
}
impl NestedSliderObject {
pub const fn is_repeat(&self) -> bool {
matches!(self.kind, NestedSliderObjectKind::Repeat)
}
pub const fn is_tick(&self) -> bool {
matches!(self.kind, NestedSliderObjectKind::Tick)
}
}
#[derive(Copy, Clone, Debug)]
pub enum NestedSliderObjectKind {
Repeat,
Tail,
Tick,
}
-226
View File
@@ -1,226 +0,0 @@
use rosu_map::{
section::{
general::GameMode,
hit_objects::{BorrowedCurve, CurveBuffers},
},
util::Pos,
};
use crate::{
model::{
control_point::{DifficultyPoint, TimingPoint},
hit_object::{HitObject, HitObjectKind, Slider},
},
Beatmap,
};
use super::stars::OsuDifficultyAttributes;
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
const BASE_SCORING_DISTANCE: f64 = 100.0;
pub(crate) struct OsuObject {
pub(crate) time: f32,
pub(crate) pos: Pos,
pub(crate) end_pos: Pos,
// circle: Some(0.0) | slider: Some(_) | spinner: None
pub(crate) travel_dist: Option<f32>,
}
impl OsuObject {
pub(crate) fn new(
h: &HitObject,
map: &Beatmap,
radius: f32,
scaling_factor: f32,
ticks: &mut Vec<f64>,
attrs: &mut OsuDifficultyAttributes,
curve_bufs: &mut CurveBuffers,
) -> Self {
attrs.max_combo += 1; // hitcircle, slider head, or spinner
match &h.kind {
HitObjectKind::Circle => {
attrs.n_circles += 1;
Self {
time: h.start_time as f32,
pos: h.pos,
end_pos: h.pos,
travel_dist: Some(0.0),
}
}
HitObjectKind::Slider(Slider {
expected_dist,
repeats,
control_points,
..
}) => {
attrs.n_sliders += 1;
let beat_len = timing_point_at(&map.timing_points, h.start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let (slider_vel, generate_ticks) =
difficulty_point_at(&map.difficulty_points, h.start_time).map_or(
(
DifficultyPoint::DEFAULT_SLIDER_VELOCITY,
DifficultyPoint::DEFAULT_GENERATE_TICKS,
),
|point| (point.slider_velocity, point.generate_ticks),
);
let scoring_dist = BASE_SCORING_DISTANCE * map.slider_multiplier * slider_vel;
let vel = scoring_dist / beat_len;
// Key values which are computed here
let mut end_pos = h.pos;
let mut travel_dist = 0.0;
let approx_follow_circle_radius = radius * 3.0;
let tick_dist_mult = if map.version < 8 {
slider_vel.recip()
} else {
1.0
};
let mut tick_dist = if generate_ticks {
scoring_dist / map.slider_tick_rate * tick_dist_mult
} else {
f64::INFINITY
};
let span_count = (*repeats + 1) as f64;
// Build the curve w.r.t. the curve points
let curve =
BorrowedCurve::new(GameMode::Osu, control_points, *expected_dist, curve_bufs);
let end_time = h.start_time + span_count * curve.dist() / vel;
let total_duration = end_time - h.start_time;
let span_duration = total_duration / span_count;
// Called on each slider object except for the head.
// Increases combo and adjusts `end_pos` and `travel_dist`
// w.r.t. the object position at the given time on the slider curve.
let mut compute_vertex = |time: f64| {
attrs.max_combo += 1;
let mut progress = (time - h.start_time) / span_duration;
if progress % 2.0 >= 1.0 {
progress = 1.0 - progress % 1.0;
} else {
progress %= 1.0;
}
let curr_pos = h.pos + curve.position_at(progress);
let diff = curr_pos - end_pos;
let mut dist = diff.length();
if dist > approx_follow_circle_radius {
dist -= approx_follow_circle_radius;
end_pos += diff.normalize() * dist;
travel_dist += dist;
}
};
let max_len = 100_000.0;
let len = curve.dist().min(max_len);
tick_dist = tick_dist.clamp(0.0, len);
let min_dist_from_end = vel * 10.0;
let mut curr_dist = tick_dist;
if tick_dist != 0.0 {
ticks.reserve((len / tick_dist) as usize);
// Tick of the first span
while curr_dist < len - min_dist_from_end {
let progress = curr_dist / len;
let curr_time = h.start_time + progress * span_duration;
compute_vertex(curr_time);
ticks.push(curr_time);
curr_dist += tick_dist;
}
// Other spans
for span_idx in 1..=*repeats {
let span_idx_f64 = span_idx as f64;
// Repeat point
let curr_time = h.start_time + span_duration * span_idx_f64;
compute_vertex(curr_time);
let span_offset = span_idx_f64 * span_duration;
// Ticks
if span_idx & 1 == 1 {
let base = h.start_time + h.start_time + span_duration;
for time in ticks.iter().rev() {
compute_vertex(span_offset + base - time);
}
} else {
for time in ticks.iter() {
compute_vertex(span_offset + time);
}
}
}
ticks.clear();
}
// Slider tail
let final_span_start_time = h.start_time + *repeats as f64 * span_duration;
let final_span_end_time = (h.start_time + total_duration / 2.0)
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
compute_vertex(final_span_end_time);
travel_dist *= scaling_factor;
Self {
time: h.start_time as f32,
pos: h.pos,
end_pos,
travel_dist: Some(travel_dist),
}
}
HitObjectKind::Spinner { .. } | HitObjectKind::Hold { .. } => {
attrs.n_spinners += 1;
Self {
time: h.start_time as f32,
pos: h.pos,
end_pos: h.pos,
travel_dist: None,
}
}
}
}
#[inline]
pub(crate) fn is_spinner(&self) -> bool {
self.travel_dist.is_none()
}
}
fn timing_point_at(points: &[TimingPoint], time: f64) -> Option<&TimingPoint> {
let i = points
.binary_search_by(|probe| probe.time.total_cmp(&time))
.unwrap_or_else(|i| i.saturating_sub(1));
points.get(i)
}
fn difficulty_point_at(points: &[DifficultyPoint], time: f64) -> Option<&DifficultyPoint> {
points
.binary_search_by(|probe| probe.time.total_cmp(&time))
.map_or_else(|i| i.checked_sub(1), Some)
.map(|i| &points[i])
}
+129
View File
@@ -0,0 +1,129 @@
use crate::{
osu_2019::{OsuRelaxBeatmap, OsuGradualDifficulty},
Difficulty,
};
use super::{OsuPerformanceAttributes, OsuScoreState};
/// Gradually calculate the performance attributes of an osu!standard (relax) 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, Difficulty};
/// use rosu_pp::osu_2019::{OsuRelax, OsuGradualPerformance, OsuScoreState};
///
/// let converted = Beatmap::from_path("./resources/2785319.osu")
/// .unwrap()
/// .unchecked_into_converted::<OsuRelax>();
///
/// let difficulty = Difficulty::new().mods(64); // DT
/// let mut gradual = 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 attrs = gradual.next(state.clone()).unwrap();
/// println!("PP: {}", attrs.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.misses += 1;
/// let attrs = gradual.next(state.clone()).unwrap();
/// println!("PP: {}", attrs.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 attrs = gradual.nth(state.clone(), 9).unwrap();
/// println!("PP: {}", attrs.pp);
///
/// // Now comes another 300. Note that the max combo gets incremented again.
/// state.n300 += 1;
/// state.max_combo += 1;
/// let attrs = gradual.next(state.clone()).unwrap();
/// println!("PP: {}", attrs.pp);
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n300 = ...
/// state.n100 = ...
/// state.n50 = ...
/// state.misses = ...
/// # */
/// let attrs = gradual.last(state.clone()).unwrap();
/// println!("PP: {}", attrs.pp);
///
/// // Once the final performance has been calculated, attempting to process
/// // further objects will return `None`.
/// assert!(gradual.next(state).is_none());
/// ```
///
/// [`next`]: OsuGradualPerformance::next
/// [`nth`]: OsuGradualPerformance::nth
pub struct OsuGradualPerformance {
difficulty: OsuGradualDifficulty,
}
impl OsuGradualPerformance {
/// Create a new gradual performance calculator for osu!standard (relax) maps.
pub fn new(difficulty: Difficulty, converted: &OsuRelaxBeatmap<'_>) -> Self {
let difficulty = OsuGradualDifficulty::new(difficulty, converted);
Self { difficulty }
}
/// 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 performance = self
.difficulty
.nth(n)?
.performance()
.state(state)
.difficulty(self.difficulty.difficulty.clone())
.passed_objects(self.difficulty.idx as u32)
.calculate();
Some(performance)
}
/// Returns the amount of remaining objects.
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.difficulty.len()
}
}
+923
View File
@@ -0,0 +1,923 @@
use std::cmp;
use crate::{any::{HitResultPriority, IntoModePerformance, IntoPerformance}, osu::OsuScoreState, util::map_or_attrs::MapOrAttrs, Difficulty, GameMods, Performance};
use super::{OsuDifficultyAttributes, OsuPerformanceAttributes, OsuRelax};
pub mod gradual;
/// Performance calculator on osu!standard (relax) maps.
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct OsuPerformance<'map> {
pub(crate) map_or_attrs: MapOrAttrs<'map, OsuRelax>,
pub(crate) difficulty: Difficulty,
pub(crate) acc: Option<f64>,
pub(crate) combo: Option<u32>,
pub(crate) slider_tick_hits: Option<u32>,
pub(crate) slider_end_hits: Option<u32>,
pub(crate) n300: Option<u32>,
pub(crate) n100: Option<u32>,
pub(crate) n50: Option<u32>,
pub(crate) misses: Option<u32>,
pub(crate) hitresult_priority: HitResultPriority,
pub(crate) lazer: Option<bool>,
}
impl<'map> OsuPerformance<'map> {
/// Create a new performance calculator for osu! maps.
///
/// The argument `map_or_attrs` must be either
/// - previously calculated attributes ([`OsuDifficultyAttributes`]
/// or [`OsuPerformanceAttributes`])
/// - a beatmap ([`OsuRelaxBeatmap<'map>`])
///
/// If a map is given, difficulty attributes will need to be calculated
/// internally which is a costly operation. Hence, passing attributes
/// should be prefered.
///
/// However, when passing previously calculated attributes, make sure they
/// have been calculated for the same map and [`Difficulty`] settings.
/// Otherwise, the final attributes will be incorrect.
///
/// [`OsuBeatmap<'map>`]: crate::osu::OsuBeatmap
pub fn new(map_or_attrs: impl IntoModePerformance<'map, OsuRelax>) -> Self {
map_or_attrs.into_performance()
}
/// Try to create a new performance calculator for osu! maps.
///
/// Returns `None` if `map_or_attrs` does not belong to osu! e.g.
/// a [`Converted`], [`DifficultyAttributes`], or [`PerformanceAttributes`]
/// of a different mode.
///
/// See [`OsuPerformance::new`] for more information.
///
/// [`Converted`]: crate::model::beatmap::Converted
/// [`DifficultyAttributes`]: crate::any::DifficultyAttributes
/// [`PerformanceAttributes`]: crate::any::PerformanceAttributes
pub fn try_new(map_or_attrs: impl IntoPerformance<'map>) -> Option<Self> {
if let Performance::OsuRelax(calc) = map_or_attrs.into_performance() {
Some(calc)
} else {
None
}
}
/// Specify mods.
///
/// Accepted types are
/// - `u32`
/// - [`rosu_mods::GameModsLegacy`]
/// - [`rosu_mods::GameMods`]
/// - [`rosu_mods::GameModsIntermode`]
/// - [`&rosu_mods::GameModsIntermode`](rosu_mods::GameModsIntermode)
///
/// See <https://github.com/ppy/osu-api/wiki#mods>
pub fn mods(mut self, mods: impl Into<GameMods>) -> Self {
self.difficulty = self.difficulty.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
}
/// Whether the calculated attributes belong to an osu!lazer or osu!stable
/// score.
///
/// Defaults to lazer.
///
/// This affects internal accuracy calculation because lazer considers
/// slider heads for accuracy whereas stable does not.
pub const fn lazer(mut self, lazer: bool) -> Self {
self.lazer = Some(lazer);
self
}
/// Specify the amount of hit slider ticks.
///
/// Only relevant for osu!lazer.
pub const fn n_slider_ticks(mut self, n_slider_ticks: u32) -> Self {
self.slider_tick_hits = Some(n_slider_ticks);
self
}
/// Specify the amount of hit slider ends.
///
/// Only relevant for osu!lazer.
pub const fn n_slider_ends(mut self, n_slider_ends: u32) -> Self {
self.slider_end_hits = Some(n_slider_ends);
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 misses(mut self, n_misses: u32) -> Self {
self.misses = Some(n_misses);
self
}
/// Use the specified settings of the given [`Difficulty`].
pub fn difficulty(mut self, difficulty: Difficulty) -> Self {
self.difficulty = difficulty;
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
/// If you want to calculate the performance after every few objects,
/// instead of using [`OsuPerformance`] multiple times with different
/// `passed_objects`, you should use [`OsuGradualPerformance`].
///
/// [`OsuGradualPerformance`]: crate::osu::OsuGradualPerformance
pub fn passed_objects(mut self, passed_objects: u32) -> Self {
self.difficulty = self.difficulty.passed_objects(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.
///
/// | Minimum | Maximum |
/// | :-----: | :-----: |
/// | 0.01 | 100 |
pub fn clock_rate(mut self, clock_rate: f64) -> Self {
self.difficulty = self.difficulty.clock_rate(clock_rate);
self
}
/// Override a beatmap's set AR.
///
/// `with_mods` determines if the given value should be used before
/// or after accounting for mods, e.g. on `true` the value will be
/// used as is and on `false` it will be modified based on the mods.
///
/// | Minimum | Maximum |
/// | :-----: | :-----: |
/// | -20 | 20 |
pub fn ar(mut self, ar: f32, with_mods: bool) -> Self {
self.difficulty = self.difficulty.ar(ar, with_mods);
self
}
/// Override a beatmap's set CS.
///
/// `with_mods` determines if the given value should be used before
/// or after accounting for mods, e.g. on `true` the value will be
/// used as is and on `false` it will be modified based on the mods.
///
/// | Minimum | Maximum |
/// | :-----: | :-----: |
/// | -20 | 20 |
pub fn cs(mut self, cs: f32, with_mods: bool) -> Self {
self.difficulty = self.difficulty.cs(cs, with_mods);
self
}
/// Override a beatmap's set HP.
///
/// `with_mods` determines if the given value should be used before
/// or after accounting for mods, e.g. on `true` the value will be
/// used as is and on `false` it will be modified based on the mods.
///
/// | Minimum | Maximum |
/// | :-----: | :-----: |
/// | -20 | 20 |
pub fn hp(mut self, hp: f32, with_mods: bool) -> Self {
self.difficulty = self.difficulty.hp(hp, with_mods);
self
}
/// Override a beatmap's set OD.
///
/// `with_mods` determines if the given value should be used before
/// or after accounting for mods, e.g. on `true` the value will be
/// used as is and on `false` it will be modified based on the mods.
///
/// | Minimum | Maximum |
/// | :-----: | :-----: |
/// | -20 | 20 |
pub fn od(mut self, od: f32, with_mods: bool) -> Self {
self.difficulty = self.difficulty.od(od, with_mods);
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,
slider_tick_hits,
slider_end_hits,
n300,
n100,
n50,
misses,
} = state;
self.combo = Some(max_combo);
self.slider_tick_hits = Some(slider_tick_hits);
self.slider_end_hits = Some(slider_end_hits);
self.n300 = Some(n300);
self.n100 = Some(n100);
self.n50 = Some(n50);
self.misses = Some(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.clamp(0.0, 100.0) / 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.difficulty.with_mode().calculate(map);
self.map_or_attrs.insert_attrs(attrs)
}
MapOrAttrs::Attrs(ref attrs) => attrs,
};
let max_combo = attrs.max_combo;
let n_objects = cmp::min(
self.difficulty.get_passed_objects() as u32,
attrs.n_objects(),
);
let priority = self.hitresult_priority;
let misses = self.misses.map_or(0, |n| cmp::min(n, n_objects));
let n_remaining = n_objects - misses;
let mut n300 = self.n300.map_or(0, |n| cmp::min(n, n_remaining));
let mut n100 = self.n100.map_or(0, |n| cmp::min(n, n_remaining));
let mut n50 = self.n50.map_or(0, |n| cmp::min(n, n_remaining));
let lazer = self.lazer.unwrap_or(true);
let (n_slider_ends, n_slider_ticks, max_slider_ends, max_slider_ticks) = if lazer {
let n_slider_ends = self
.slider_end_hits
.map_or(attrs.n_sliders, |n| cmp::min(n, attrs.n_sliders));
let n_slider_ticks = self
.slider_tick_hits
.map_or(attrs.n_slider_ticks, |n| cmp::min(n, attrs.n_slider_ticks));
(
n_slider_ends,
n_slider_ticks,
attrs.n_sliders,
attrs.n_slider_ticks,
)
} else {
(0, 0, 0, 0)
};
if let Some(acc) = self.acc {
let target_total =
acc * f64::from(30 * n_objects + 15 * max_slider_ends + 3 * max_slider_ticks);
match (self.n300, self.n100, self.n50) {
(Some(_), Some(_), Some(_)) => {
let remaining = n_objects.saturating_sub(n300 + n100 + n50 + misses);
match priority {
HitResultPriority::BestCase => n300 += remaining,
HitResultPriority::WorstCase => n50 += remaining,
}
}
(Some(_), Some(_), None) => n50 = n_objects.saturating_sub(n300 + n100 + misses),
(Some(_), None, Some(_)) => n100 = n_objects.saturating_sub(n300 + n50 + misses),
(None, Some(_), Some(_)) => n300 = n_objects.saturating_sub(n100 + n50 + misses),
(Some(_), None, None) => {
let mut best_dist = f64::MAX;
n300 = cmp::min(n300, n_remaining);
let n_remaining = n_remaining - n300;
let raw_n100 = (target_total
- f64::from(
5 * n_remaining + 30 * n300 + 15 * n_slider_ends + 3 * n_slider_ticks,
))
/ 5.0;
let min_n100 = cmp::min(n_remaining, raw_n100.floor() as u32);
let max_n100 = cmp::min(n_remaining, raw_n100.ceil() as u32);
for new100 in min_n100..=max_n100 {
let new50 = n_remaining - new100;
let dist = (acc
- accuracy(
n_slider_ticks,
n_slider_ends,
n300,
new100,
new50,
misses,
max_slider_ticks,
max_slider_ends,
))
.abs();
if dist < best_dist {
best_dist = dist;
n100 = new100;
n50 = new50;
}
}
}
(None, Some(_), None) => {
let mut best_dist = f64::MAX;
n100 = cmp::min(n100, n_remaining);
let n_remaining = n_remaining - n100;
let raw_n300 = (target_total
- f64::from(
5 * n_remaining + 10 * n100 + 15 * n_slider_ends + 3 * n_slider_ticks,
))
/ 25.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 new50 = n_remaining - new300;
let curr_dist = (acc
- accuracy(
n_slider_ticks,
n_slider_ends,
new300,
n100,
new50,
misses,
max_slider_ticks,
max_slider_ends,
))
.abs();
if curr_dist < best_dist {
best_dist = curr_dist;
n300 = new300;
n50 = new50;
}
}
}
(None, None, Some(_)) => {
let mut best_dist = f64::MAX;
n50 = cmp::min(n50, n_remaining);
let n_remaining = n_remaining - n50;
let raw_n300 = (target_total + f64::from(10 * misses + 5 * n50)
- f64::from(10 * n_objects + 15 * n_slider_ends + 3 * n_slider_ticks))
/ 20.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 new100 = n_remaining - new300;
let curr_dist = (acc
- accuracy(
n_slider_ticks,
n_slider_ends,
new300,
new100,
n50,
misses,
max_slider_ticks,
max_slider_ends,
))
.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(5 * n_remaining + 15 * n_slider_ends + 3 * n_slider_ticks))
/ 25.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(
5 * n_remaining
+ 25 * new300
+ 15 * n_slider_ends
+ 3 * n_slider_ticks,
))
/ 5.0;
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(
n_slider_ticks,
n_slider_ends,
new300,
new100,
new50,
misses,
max_slider_ticks,
max_slider_ends,
))
.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 = cmp::min(n300, 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 + 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(misses);
let max_combo = self.combo.map_or(max_possible_combo, |combo| {
cmp::min(combo, max_possible_combo)
});
self.combo = Some(max_combo);
self.slider_end_hits = Some(n_slider_ends);
self.slider_tick_hits = Some(n_slider_ticks);
self.n300 = Some(n300);
self.n100 = Some(n100);
self.n50 = Some(n50);
self.misses = Some(misses);
OsuScoreState {
max_combo,
slider_tick_hits: n_slider_ticks,
slider_end_hits: n_slider_ends,
n300,
n100,
n50,
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.difficulty.with_mode().calculate(map),
MapOrAttrs::Attrs(attrs) => attrs,
};
let effective_miss_count = calculate_effective_misses(&attrs, &state);
let lazer = self.lazer.unwrap_or(true);
let (n_slider_ends, n_slider_ticks) = if lazer {
(attrs.n_sliders, attrs.n_slider_ticks)
} else {
(0, 0)
};
let inner = OsuPerformanceInner {
attrs,
mods: self.difficulty.get_mods(),
acc: state.accuracy(n_slider_ticks, n_slider_ends),
state,
effective_miss_count,
lazer,
};
inner.calculate()
}
pub(crate) const fn from_map_or_attrs(map_or_attrs: MapOrAttrs<'map, OsuRelax>) -> Self {
Self {
map_or_attrs,
difficulty: Difficulty::new(),
acc: None,
combo: None,
slider_tick_hits: None,
slider_end_hits: None,
n300: None,
n100: None,
n50: None,
misses: None,
hitresult_priority: HitResultPriority::DEFAULT,
lazer: None,
}
}
}
impl<'map, T: IntoModePerformance<'map, OsuRelax>> From<T> for OsuPerformance<'map> {
fn from(into: T) -> Self {
into.into_performance()
}
}
// * This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
pub const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.09;
struct OsuPerformanceInner<'mods> {
attrs: OsuDifficultyAttributes,
mods: &'mods GameMods,
acc: f64,
state: OsuScoreState,
effective_miss_count: f64,
lazer: bool,
}
impl OsuPerformanceInner<'_> {
fn calculate(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;
// SO penalty
if self.mods.so() {
multiplier *= 1.0 - (self.attrs.n_spinners as f64 / total_hits).powf(0.85);
}
let mut aim_value = self.compute_aim_value(total_hits);
let speed_value = self.compute_speed_value(total_hits);
let acc_value = self.compute_accuracy_value(total_hits);
let mut acc_depression = 1.0;
let streams_nerf =
((self.attrs.aim_strain / self.attrs.speed_strain) * 100.0).round() / 100.0;
if streams_nerf < 1.09 {
let acc_factor = (1.0 - self.acc).abs();
acc_depression = (0.86 - acc_factor).max(0.5);
if acc_depression > 0.0 {
aim_value *= acc_depression;
}
}
let nodt_bonus = match !(self.mods.dt() || self.mods.nc() || self.mods.ht()) {
true => 1.02,
false => 1.0,
};
let mut pp = (aim_value.powf(1.185 * nodt_bonus)
+ speed_value.powf(0.83 * acc_depression)
+ acc_value.powf(1.14 * nodt_bonus))
.powf(1.0 / 1.1)
* multiplier;
if self.mods.dt() && self.mods.hr() {
pp *= 1.025;
}
if self.attrs.beatmap_creator == "gwb" || self.attrs.beatmap_creator == "Plasma" {
pp *= 0.9;
}
pp *= match self.attrs.beatmap_id {
// Louder than steel [ok this is epic]
1808605 => 0.85,
// over the top [Above the stars]
1821147 => 0.70,
// Just press F [Parkour's ok this is epic]
1844776 => 0.64,
// Hardware Store [skyapple mode]
1777768 => 0.90,
// Akatsuki compilation [ok this is akatsuki]
1962833 => {
pp *= 0.885;
if self.mods.dt() {
0.83
} else {
1.0
}
}
// Songs Compilation [Marathon]
2403677 => 0.85,
// Songs Compilation [Remembrance]
2174272 => 0.85,
// Apocalypse 1992 [Universal Annihilation]
2382377 => 0.85,
_ => 1.0,
};
OsuPerformanceAttributes {
difficulty: self.attrs,
pp_aim: aim_value,
pp_speed: speed_value,
pp_acc: acc_value,
pp: pp,
effective_miss_count: self.effective_miss_count,
}
}
fn compute_aim_value(&self, total_hits: f64) -> f64 {
// TD penalty
let raw_aim = if self.mods.td() {
self.attrs.aim_strain.powf(0.8)
} else {
self.attrs.aim_strain
};
let mut aim_value = (5.0 * (raw_aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
// Longer maps are worth more
let len_bonus = 0.88
+ 0.4 * (total_hits / 2000.0).min(1.0)
+ (total_hits > 2000.0) as u8 as f64 * 0.5 * (total_hits / 2000.0).log10();
aim_value *= len_bonus;
// Penalize misses
if self.effective_miss_count > 0.0 {
let miss_penalty = self.calculate_miss_penalty(self.effective_miss_count, total_hits);
aim_value *= miss_penalty;
}
// AR bonus
let mut ar_factor = if self.attrs.ar > 10.33 {
0.3 * (self.attrs.ar - 10.33)
} else {
0.0
};
if self.attrs.ar < 8.0 {
ar_factor = 0.025 * (8.0 - self.attrs.ar);
}
aim_value *= 1.0 + ar_factor * len_bonus;
// HD bonus
if self.mods.hd() {
aim_value *= 1.0 + 0.05 * (11.0 - self.attrs.ar) as f64;
}
// FL bonus
if self.mods.fl() {
aim_value *= 1.0
+ 0.3 * (total_hits / 200.0).min(1.0)
+ (total_hits > 200.0) as u8 as f64
* 0.25
* ((total_hits - 200.0) / 300.0).min(1.0)
+ (total_hits > 500.0) as u8 as f64 * (total_hits - 500.0) / 1600.0;
}
// EZ bonus
if self.mods.ez() {
let mut base_buff = 1.08_f64;
if self.attrs.ar <= 8.0 {
base_buff += (7.0 - self.attrs.ar as f64) / 100.0;
}
aim_value *= base_buff;
}
// Scale with accuracy
aim_value *= 0.3 + self.acc / 2.0;
aim_value *= 0.98 + self.attrs.od * self.attrs.od / 2500.0;
aim_value
}
fn compute_speed_value(&self, total_hits: f64) -> f64 {
let mut speed_value =
(5.0 * (self.attrs.speed_strain / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
// Longer maps are worth more
let len_bonus = 0.88
+ 0.4 * (total_hits / 2000.0).min(1.0)
+ (total_hits > 2000.0) as u8 as f64 * 0.5 * (total_hits / 2000.0).log10();
speed_value *= len_bonus;
// Penalize misses
if self.effective_miss_count > 0.0 {
let miss_penalty = self.calculate_miss_penalty(self.effective_miss_count, total_hits);
speed_value *= miss_penalty;
}
// AR bonus
let mut ar_factor = if self.attrs.ar > 10.33 {
0.3 * (self.attrs.ar - 10.33)
} else {
0.0
};
if self.attrs.ar < 8.0 {
ar_factor = 0.025 * (8.0 - self.attrs.ar);
}
speed_value *= 1.0 + ar_factor * len_bonus;
// HD bonus
if self.mods.hd() {
speed_value *= 1.0 + 0.05 * (11.0 - self.attrs.ar) as f64;
}
// Scaling the speed value with accuracy and OD
speed_value *= (0.93 + self.attrs.od * self.attrs.od / 750.0)
* self
.acc
.powf((14.5 - self.attrs.od.max(8.0)) / 2.0);
speed_value *= 0.98_f64.powf(match (self.state.n50 as f64) < total_hits / 500.0 {
true => 0.0,
false => self.state.n50 as f64 - total_hits / 500.0,
});
speed_value
}
fn compute_accuracy_value(&self, total_hits: f64) -> f64 {
let n_circles = self.attrs.n_circles as f64;
let n300 = self.state.n300 as f64;
let n100 = self.state.n100 as f64;
let n50 = self.state.n50 as f64;
let better_acc_percentage = (n_circles > 0.0) as u8 as f64
* (((n300 - (total_hits - n_circles)) * 6.0 + n100 * 2.0 + n50) / (n_circles * 6.0))
.max(0.0);
let mut acc_value =
1.52163_f64.powf(self.attrs.od) * better_acc_percentage.powi(24) * 2.83;
// Bonus for many hitcircles
acc_value *= ((n_circles / 1000.0).powf(0.3)).min(1.15);
// HD bonus
if self.mods.hd() {
acc_value *= 1.08;
}
// FL bonus
if self.mods.fl() {
acc_value *= 1.02;
}
acc_value
}
fn calculate_miss_penalty(&self, effective_miss_count: f64, total_hits: f64) -> f64 {
0.97 * (1.0 - (effective_miss_count / total_hits).powf(0.5))
.powf(1.0 + (effective_miss_count / 1.5))
}
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.misses));
combo_based_miss_count.max(f64::from(state.misses))
}
fn accuracy(
n_slider_ticks: u32,
n_slider_ends: u32,
n300: u32,
n100: u32,
n50: u32,
misses: u32,
max_slider_ticks: u32,
max_slider_ends: u32,
) -> f64 {
if n_slider_ticks + n_slider_ends + n300 + n100 + n50 + misses == 0 {
return 0.0;
}
let numerator = 300 * n300 + 100 * n100 + 50 * n50 + 150 * n_slider_ends + 30 * n_slider_ticks;
let denominator =
300 * (n300 + n100 + n50 + misses) + 150 * max_slider_ends + 30 * max_slider_ticks;
f64::from(numerator) / f64::from(denominator)
}
-547
View File
@@ -1,547 +0,0 @@
use super::stars::{stars, OsuDifficultyAttributes, OsuPerformanceAttributes};
use crate::{Beatmap, GameMods};
/// Calculator for pp on osu!standard maps.
///
/// # Example
///
/// ```
/// # use rosu_pp::{OsuPP, Beatmap};
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
/// let attrs = OsuPP::new(&map)
/// .mods(8 + 64) // HDDT
/// .combo(1234)
/// .misses(1)
/// .accuracy(98.5) // should be set last
/// .calculate();
///
/// println!("PP: {} | Stars: {}", attrs.pp(), attrs.stars());
///
/// let next_result = OsuPP::new(&map)
/// .attributes(attrs) // reusing previous results for performance
/// .mods(8 + 64) // has to be the same to reuse attributes
/// .accuracy(99.5)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
/// ```
#[derive(Clone, Debug)]
pub struct OsuPP<'m> {
map: Option<&'m Beatmap>,
attributes: Option<OsuDifficultyAttributes>,
mods: GameMods,
combo: Option<u32>,
acc: Option<f32>,
n300: Option<u32>,
n100: Option<u32>,
n50: Option<u32>,
n_misses: u32,
}
impl<'m> OsuPP<'m> {
/// Creates a new calculator for the given map.
#[inline]
pub fn from_map(map: &'m Beatmap) -> Self {
Self {
map: Some(map),
attributes: None,
mods: GameMods::default(),
combo: None,
acc: None,
n300: None,
n100: None,
n50: None,
n_misses: 0,
}
}
/// Creates a new calculator for the given attributes.
#[inline]
pub fn from_attributes(attributes: OsuDifficultyAttributes) -> Self {
Self {
map: None,
attributes: Some(attributes),
mods: GameMods::default(),
combo: None,
acc: None,
n300: None,
n100: None,
n50: None,
n_misses: 0,
}
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub fn mods(mut self, mods: impl Into<GameMods>) -> Self {
self.mods = mods.into();
self
}
/// Specify the max combo of the play.
#[inline]
pub fn combo(mut self, combo: u32) -> Self {
self.combo = Some(combo);
self
}
/// Specify the amount of 300s of a play.
#[inline]
pub fn n300(mut self, n300: u32) -> Self {
self.n300 = Some(n300);
self
}
/// Specify the amount of 100s of a play.
#[inline]
pub fn n100(mut self, n100: u32) -> Self {
self.n100 = Some(n100);
self
}
/// Specify the amount of 50s of a play.
#[inline]
pub fn n50(mut self, n50: u32) -> Self {
self.n50 = Some(n50);
self
}
/// Specify the amount of misses of a play.
#[inline]
pub fn misses(mut self, n_misses: u32) -> Self {
self.n_misses = n_misses;
self
}
/// Generate the hit results with respect to the given accuracy between `0` and `100`.
///
/// Be sure to set `misses` beforehand!
pub fn accuracy(mut self, acc: f32) -> Self {
let n_objects = self.n_objects();
let acc = acc / 100.0;
if self.n100.or(self.n50).is_some() {
let mut n100 = self.n100.unwrap_or(0);
let mut n50 = self.n50.unwrap_or(0);
let placed_points = 2 * n100 + n50 + self.n_misses;
let missing_objects = n_objects - n100 - n50 - self.n_misses;
let missing_points =
((6.0 * acc * n_objects as f32).round() as u32).saturating_sub(placed_points);
let mut n300 = missing_objects.min(missing_points / 6);
n50 += missing_objects - n300;
if let Some(orig_n50) = self.n50.filter(|_| self.n100.is_none()) {
// Only n50s were changed, try to load some off again onto n100s
let difference = n50 - orig_n50;
let n = n300.min(difference / 4);
n300 -= n;
n100 += 5 * n;
n50 -= 4 * n;
}
self.n300.replace(n300);
self.n100.replace(n100);
self.n50.replace(n50);
} else {
let misses = self.n_misses.min(n_objects);
let target_total = (acc * n_objects as f32 * 6.0).round() as u32;
let delta = target_total - (n_objects - misses);
let mut n300 = delta / 5;
let mut n100 = delta % 5;
let mut n50 = n_objects - n300 - n100 - misses;
// Sacrifice n300s to transform n50s into n100s
let n = n300.min(n50 / 4);
n300 -= n;
n100 += 5 * n;
n50 -= 4 * n;
self.n300.replace(n300);
self.n100.replace(n100);
self.n50.replace(n50);
}
let acc = (6 * self.n300.unwrap() + 2 * self.n100.unwrap() + self.n50.unwrap()) as f32
/ (6 * n_objects) as f32;
self.acc.replace(acc);
self
}
fn assert_hitresults(&mut self) {
if self.acc.is_none() {
let n_objects = self.n_objects();
let remaining = n_objects
.saturating_sub(self.n300.unwrap_or(0))
.saturating_sub(self.n100.unwrap_or(0))
.saturating_sub(self.n50.unwrap_or(0))
.saturating_sub(self.n_misses);
if remaining > 0 {
if self.n300.is_none() {
self.n300.replace(remaining);
self.n100.get_or_insert(0);
self.n50.get_or_insert(0);
} else if self.n100.is_none() {
self.n100.replace(remaining);
self.n50.get_or_insert(0);
} else if self.n50.is_none() {
self.n50.replace(remaining);
} else {
*self.n300.as_mut().unwrap() += remaining;
}
} else {
self.n300.get_or_insert(0);
self.n100.get_or_insert(0);
self.n50.get_or_insert(0);
}
let numerator = self.n50.unwrap() + self.n100.unwrap() * 2 + self.n300.unwrap() * 6;
self.acc.replace(numerator as f32 / n_objects as f32 / 6.0);
}
}
/// Returns an object which contains the pp and [`DifficultyAttributes`](crate::osu::DifficultyAttributes)
/// containing stars and other attributes.
pub fn calculate(mut self) -> OsuPerformanceAttributes {
if self.attributes.is_none() {
let attributes = stars(self.map.unwrap(), self.mods.clone());
self.attributes.replace(attributes);
}
// Make sure the hitresults and accuracy are set
self.assert_hitresults();
let total_hits = self.total_hits() as f32;
let mut multiplier = 1.09;
let effective_miss_count = self.calculate_effective_miss_count();
// SO penalty
if self.mods.so() {
multiplier *=
1.0 - (self.attributes.as_ref().unwrap().n_spinners as f32 / total_hits).powf(0.85);
}
let mut aim_value = self.compute_aim_value(total_hits, effective_miss_count);
let speed_value = self.compute_speed_value(total_hits, effective_miss_count);
let acc_value = self.compute_accuracy_value(total_hits);
let mut acc_depression = 1.0;
let difficulty = self.attributes.as_ref().unwrap();
let streams_nerf =
((difficulty.aim_strain / difficulty.speed_strain) * 100.0).round() / 100.0;
if streams_nerf < 1.09 {
let acc_factor = (1.0 - self.acc.unwrap()).abs();
acc_depression = (0.86 - acc_factor).max(0.5);
if acc_depression > 0.0 {
aim_value *= acc_depression;
}
}
let nodt_bonus = match !(self.mods.dt() || self.mods.nc() || self.mods.ht()) {
true => 1.02,
false => 1.0,
};
let mut pp = (aim_value.powf(1.185 * nodt_bonus)
+ speed_value.powf(0.83 * acc_depression)
+ acc_value.powf(1.14 * nodt_bonus))
.powf(1.0 / 1.1)
* multiplier;
if self.mods.dt() && self.mods.hr() {
pp *= 1.025;
}
if difficulty.beatmap_creator == "gwb" || difficulty.beatmap_creator == "Plasma" {
pp *= 0.9;
}
pp *= match difficulty.beatmap_id {
// Louder than steel [ok this is epic]
1808605 => 0.85,
// over the top [Above the stars]
1821147 => 0.70,
// Just press F [Parkour's ok this is epic]
1844776 => 0.64,
// Hardware Store [skyapple mode]
1777768 => 0.90,
// Akatsuki compilation [ok this is akatsuki]
1962833 => {
pp *= 0.885;
if self.mods.dt() {
0.83
} else {
1.0
}
}
// Songs Compilation [Marathon]
2403677 => 0.85,
// Songs Compilation [Remembrance]
2174272 => 0.85,
// Apocalypse 1992 [Universal Annihilation]
2382377 => 0.85,
_ => 1.0,
};
OsuPerformanceAttributes {
difficulty: self.attributes.unwrap(),
pp_aim: aim_value as f64,
pp_speed: speed_value as f64,
pp_acc: acc_value as f64,
pp: pp as f64,
effective_miss_count: effective_miss_count as f64,
}
}
fn compute_aim_value(&self, total_hits: f32, effective_miss_count: f32) -> f32 {
let attributes = self.attributes.as_ref().unwrap();
// TD penalty
let raw_aim = if self.mods.td() {
attributes.aim_strain.powf(0.8) as f32
} else {
attributes.aim_strain as f32
};
let mut aim_value = (5.0 * (raw_aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
// Longer maps are worth more
let len_bonus = 0.88
+ 0.4 * (total_hits / 2000.0).min(1.0)
+ (total_hits > 2000.0) as u8 as f32 * 0.5 * (total_hits / 2000.0).log10();
aim_value *= len_bonus;
// Penalize misses
if effective_miss_count > 0.0 {
let miss_penalty = self.calculate_miss_penalty(effective_miss_count);
aim_value *= miss_penalty;
}
// AR bonus
let mut ar_factor = if attributes.ar > 10.33 {
0.3 * (attributes.ar - 10.33)
} else {
0.0
};
if attributes.ar < 8.0 {
ar_factor = 0.025 * (8.0 - attributes.ar);
}
aim_value *= 1.0 + ar_factor as f32 * len_bonus;
// HD bonus
if self.mods.hd() {
aim_value *= 1.0 + 0.05 * (11.0 - attributes.ar) as f32;
}
// FL bonus
if self.mods.fl() {
aim_value *= 1.0
+ 0.3 * (total_hits / 200.0).min(1.0)
+ (total_hits > 200.0) as u8 as f32
* 0.25
* ((total_hits - 200.0) / 300.0).min(1.0)
+ (total_hits > 500.0) as u8 as f32 * (total_hits - 500.0) / 1600.0;
}
// EZ bonus
if self.mods.ez() {
let mut base_buff = 1.08_f32;
if attributes.ar <= 8.0 {
base_buff += (7.0 - attributes.ar as f32) / 100.0;
}
aim_value *= base_buff;
}
// Scale with accuracy
aim_value *= 0.3 + self.acc.unwrap() / 2.0;
aim_value *= 0.98 + attributes.od as f32 * attributes.od as f32 / 2500.0;
aim_value
}
fn compute_speed_value(&self, total_hits: f32, effective_miss_count: f32) -> f32 {
let attributes = self.attributes.as_ref().unwrap();
let mut speed_value =
(5.0 * (attributes.speed_strain as f32 / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
// Longer maps are worth more
let len_bonus = 0.88
+ 0.4 * (total_hits / 2000.0).min(1.0)
+ (total_hits > 2000.0) as u8 as f32 * 0.5 * (total_hits / 2000.0).log10();
speed_value *= len_bonus;
// Penalize misses
if effective_miss_count > 0.0 {
let miss_penalty = self.calculate_miss_penalty(effective_miss_count);
speed_value *= miss_penalty;
}
// AR bonus
if attributes.ar > 10.33 {
let mut ar_factor = if attributes.ar > 10.33 {
0.3 * (attributes.ar - 10.33)
} else {
0.0
};
if attributes.ar < 8.0 {
ar_factor = 0.025 * (8.0 - attributes.ar);
}
speed_value *= 1.0 + ar_factor as f32 * len_bonus;
}
// HD bonus
if self.mods.hd() {
speed_value *= 1.0 + 0.05 * (11.0 - attributes.ar) as f32;
}
// Scaling the speed value with accuracy and OD
speed_value *= (0.93 + attributes.od as f32 * attributes.od as f32 / 750.0)
* self
.acc
.unwrap()
.powf((14.5 - attributes.od.max(8.0) as f32) / 2.0);
speed_value *= 0.98_f32.powf(match (self.n50.unwrap() as f32) < total_hits / 500.0 {
true => 0.0,
false => self.n50.unwrap() as f32 - total_hits / 500.0,
});
speed_value
}
fn compute_accuracy_value(&self, total_hits: f32) -> f32 {
let attributes = self.attributes.as_ref().unwrap();
let n_circles = attributes.n_circles as f32;
let n300 = self.n300.unwrap_or(0) as f32;
let n100 = self.n100.unwrap_or(0) as f32;
let n50 = self.n50.unwrap_or(0) as f32;
let better_acc_percentage = (n_circles > 0.0) as u8 as f32
* (((n300 - (total_hits - n_circles)) * 6.0 + n100 * 2.0 + n50) / (n_circles * 6.0))
.max(0.0);
let mut acc_value =
1.52163_f32.powf(attributes.od as f32) * better_acc_percentage.powi(24) * 2.83;
// Bonus for many hitcircles
acc_value *= ((n_circles as f32 / 1000.0).powf(0.3)).min(1.15);
// HD bonus
if self.mods.hd() {
acc_value *= 1.08;
}
// FL bonus
if self.mods.fl() {
acc_value *= 1.02;
}
acc_value
}
#[inline]
fn total_hits(&self) -> u32 {
let n_objects = self.n_objects();
(self.n300.unwrap_or(0) + self.n100.unwrap_or(0) + self.n50.unwrap_or(0) + self.n_misses)
.min(n_objects)
}
#[inline]
fn calculate_miss_penalty(&self, effective_miss_count: f32) -> f32 {
let total_hits = self.total_hits() as f32;
0.97 * (1.0 - (effective_miss_count / total_hits).powf(0.5))
.powf(1.0 + (effective_miss_count / 1.5))
}
#[inline]
fn calculate_effective_miss_count(&self) -> f32 {
let mut combo_based_miss_count = 0.0;
let attributes = self.attributes.as_ref().unwrap();
let combo = self.combo.unwrap_or(attributes.max_combo as u32) as f32;
let n100 = self.n100.unwrap_or(0) as f32;
let n50 = self.n50.unwrap_or(0) as f32;
if attributes.n_sliders > 0 {
let fc_threshold = attributes.max_combo as f32 - (0.1 * attributes.n_sliders as f32);
if combo < fc_threshold {
combo_based_miss_count = fc_threshold / combo.max(1.0);
}
}
combo_based_miss_count = combo_based_miss_count.min(n100 + n50 + self.n_misses as f32);
combo_based_miss_count.max(self.n_misses as f32)
}
#[inline]
fn n_objects(&self) -> u32 {
match self.attributes.as_ref() {
Some(attributes) => {
(attributes.n_circles + attributes.n_sliders + attributes.n_spinners) as u32
}
None => self.map.unwrap().hit_objects.len() as u32,
}
}
}
/// Provides attributes for an osu! beatmap.
pub trait OsuAttributeProvider {
/// Returns the attributes of the map.
fn attributes(self) -> Option<OsuDifficultyAttributes>;
}
impl OsuAttributeProvider for OsuDifficultyAttributes {
#[inline]
fn attributes(self) -> Option<OsuDifficultyAttributes> {
Some(self)
}
}
impl OsuAttributeProvider for OsuPerformanceAttributes {
#[inline]
fn attributes(self) -> Option<OsuDifficultyAttributes> {
Some(self.difficulty)
}
}
-112
View File
@@ -1,112 +0,0 @@
use super::{DifficultyObject, SkillKind};
use std::cmp::Ordering;
const SPEED_SKILL_MULTIPLIER: f32 = 1400.0;
const SPEED_STRAIN_DECAY_BASE: f32 = 0.3;
const AIM_SKILL_MULTIPLIER: f32 = 26.25;
const AIM_STRAIN_DECAY_BASE: f32 = 0.15;
const DECAY_WEIGHT: f32 = 0.9;
pub(crate) struct Skill {
current_strain: f32,
current_section_peak: f32,
kind: SkillKind,
pub(crate) strain_peaks: Vec<f32>,
prev_time: Option<f32>,
pub(crate) object_strains: Vec<f32>,
}
impl Skill {
#[inline]
pub(crate) fn new(kind: SkillKind) -> Self {
Self {
current_strain: 1.0,
current_section_peak: 1.0,
kind,
strain_peaks: Vec::with_capacity(128),
prev_time: None,
object_strains: Vec::new(),
}
}
#[inline]
pub(crate) fn save_current_peak(&mut self) {
self.strain_peaks.push(self.current_section_peak);
}
#[inline]
pub(crate) fn start_new_section_from(&mut self, time: f32) {
self.current_section_peak = self.peak_strain(time - self.prev_time.unwrap());
}
#[inline]
pub(crate) fn process(&mut self, current: &DifficultyObject<'_>) {
self.current_strain *= self.strain_decay(current.delta);
self.current_strain += self.kind.strain_value_of(current) * self.skill_multiplier();
self.object_strains.push(self.current_strain);
self.current_section_peak = self.current_section_peak.max(self.current_strain);
self.prev_time.replace(current.base.time);
}
pub(crate) fn difficulty_value(&mut self) -> f32 {
let mut difficulty = 0.0;
let mut weight = 1.0;
self.strain_peaks
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
for &strain in self.strain_peaks.iter() {
difficulty += strain * weight;
weight *= DECAY_WEIGHT;
}
difficulty
}
pub(crate) fn count_difficult_strains(&mut self) -> f64 {
let top_strain = self
.object_strains
.iter()
.fold(f64::NEG_INFINITY, |prev, curr| prev.max(*curr as f64));
self.object_strains
.iter()
.map(|strain| (strain / top_strain as f32).powi(4))
.sum::<f32>() as f64
}
#[inline]
fn skill_multiplier(&self) -> f32 {
match self.kind {
SkillKind::Aim => AIM_SKILL_MULTIPLIER,
SkillKind::Speed => SPEED_SKILL_MULTIPLIER,
}
}
#[inline]
fn strain_decay_base(&self) -> f32 {
match self.kind {
SkillKind::Aim => AIM_STRAIN_DECAY_BASE,
SkillKind::Speed => SPEED_STRAIN_DECAY_BASE,
}
}
#[inline]
fn peak_strain(&self, delta_time: f32) -> f32 {
self.current_strain * self.strain_decay(delta_time)
}
#[inline]
fn strain_decay(&self, ms: f32) -> f32 {
self.strain_decay_base().powf(ms / 1000.0)
}
}
-100
View File
@@ -1,100 +0,0 @@
use super::DifficultyObject;
const SINGLE_SPACING_TRESHOLD: f32 = 125.0;
const SPEED_ANGLE_BONUS_BEGIN: f32 = 5.0 * std::f32::consts::FRAC_PI_6;
const PI_OVER_4: f32 = std::f32::consts::FRAC_PI_4;
const PI_OVER_2: f32 = std::f32::consts::FRAC_PI_2;
const MIN_SPEED_BONUS: f32 = 75.0;
const MAX_SPEED_BONUS: f32 = 45.0;
const SPEED_BALANCING_FACTOR: f32 = 40.0;
const AIM_ANGLE_BONUS_BEGIN: f32 = std::f32::consts::FRAC_PI_3;
const TIMING_THRESHOLD: f32 = 107.0;
#[derive(Copy, Clone)]
pub(crate) enum SkillKind {
Aim,
Speed,
}
impl SkillKind {
pub(crate) fn strain_value_of(self, current: &DifficultyObject<'_>) -> f32 {
match self {
Self::Aim => {
if current.base.is_spinner() {
return 0.0;
}
let mut result = 0.0;
if let Some((prev_jump_dist, prev_strain_time)) = current.prev {
if let Some(angle) = current.angle.filter(|a| *a > AIM_ANGLE_BONUS_BEGIN) {
let scale = 90.0;
let angle_bonus = (((angle - AIM_ANGLE_BONUS_BEGIN).sin()).powi(2)
* (prev_jump_dist - scale).max(0.0)
* (current.jump_dist - scale).max(0.0))
.sqrt();
result = 1.5 * apply_diminishing_exp(angle_bonus.max(0.0))
/ (TIMING_THRESHOLD).max(prev_strain_time)
}
}
let jump_dist_exp = apply_diminishing_exp(current.jump_dist);
let travel_dist_exp = apply_diminishing_exp(current.travel_dist);
let dist_exp =
jump_dist_exp + travel_dist_exp + (travel_dist_exp * jump_dist_exp).sqrt();
(result + dist_exp / (current.strain_time).max(TIMING_THRESHOLD))
.max(dist_exp / current.strain_time)
}
Self::Speed => {
if current.base.is_spinner() {
return 0.0;
}
let dist = SINGLE_SPACING_TRESHOLD.min(current.travel_dist + current.jump_dist);
let delta_time = MAX_SPEED_BONUS.max(current.delta);
let mut speed_bonus = 1.0;
if delta_time < MIN_SPEED_BONUS {
let exp_base = (MIN_SPEED_BONUS - delta_time) / SPEED_BALANCING_FACTOR;
speed_bonus += exp_base * exp_base;
}
let mut angle_bonus = 1.0;
if let Some(angle) = current.angle.filter(|a| *a < SPEED_ANGLE_BONUS_BEGIN) {
let exp_base = (1.5 * (SPEED_ANGLE_BONUS_BEGIN - angle)).sin();
angle_bonus = 1.0 + exp_base * exp_base / 3.57;
if angle < PI_OVER_2 {
angle_bonus = 1.28;
if dist < 90.0 && angle < PI_OVER_4 {
angle_bonus += (1.0 - angle_bonus) * ((90.0 - dist) / 10.0).min(1.0);
} else if dist < 90.0 {
angle_bonus += (1.0 - angle_bonus)
* ((90.0 - dist) / 10.0).min(1.0)
* ((PI_OVER_2 - angle) / PI_OVER_4).sin();
}
}
}
(1.0 + (speed_bonus - 1.0) * 0.75)
* angle_bonus
* (0.95 + speed_bonus * (dist / SINGLE_SPACING_TRESHOLD).powf(3.5))
/ current.strain_time
}
}
}
}
#[inline]
fn apply_diminishing_exp(val: f32) -> f32 {
val.powf(0.99)
}
-173
View File
@@ -1,173 +0,0 @@
//! The positional offset of notes created by stack leniency is not considered.
//! This means the jump distance inbetween notes might be slightly off, resulting in small inaccuracies.
//! Since calculating these offsets is relatively expensive though, this version is faster than `all_included`.
use super::{DifficultyObject, OsuObject, Skill, SkillKind};
use crate::{Beatmap, GameMods};
use rosu_map::section::hit_objects::CurveBuffers;
const OBJECT_RADIUS: f32 = 64.0;
const SECTION_LEN: f32 = 400.0;
const DIFFICULTY_MULTIPLIER: f32 = 0.0675;
const NORMALIZED_RADIUS: f32 = 52.0;
/// Star calculation for osu!standard maps.
///
/// Slider paths are considered but stack leniency is ignored.
/// As most maps don't even make use of leniency and even if,
/// it has generally little effect on stars, the results are close to perfect.
/// This version is considerably more efficient than `all_included` since
/// processing stack leniency is relatively expensive.
///
/// In case of a partial play, e.g. a fail, one can specify the amount of passed objects.
pub fn stars(map: &Beatmap, mods: GameMods) -> OsuDifficultyAttributes {
let map_attributes = map.attributes().mods(mods).build();
let mut diff_attributes = OsuDifficultyAttributes {
ar: map_attributes.ar,
od: map_attributes.od,
cs: map_attributes.cs,
beatmap_id: map.beatmap_id,
beatmap_creator: map.creator.clone(),
..Default::default()
};
if map.hit_objects.len() < 2 {
return diff_attributes;
}
let section_len = SECTION_LEN * map_attributes.clock_rate as f32;
let radius = OBJECT_RADIUS * (1.0 - 0.7 * (map_attributes.cs as f32 - 5.0) / 5.0) / 2.0;
let mut scaling_factor = NORMALIZED_RADIUS / radius;
if radius < 30.0 {
let small_circle_bonus = (30.0 - radius).min(5.0) / 50.0;
scaling_factor *= 1.0 + small_circle_bonus;
}
let mut ticks_buf = Vec::new();
let mut curve_bufs = CurveBuffers::default();
let mut hit_objects = map.hit_objects.iter().filter_map(|h| {
Some(OsuObject::new(
h,
map,
radius,
scaling_factor,
&mut ticks_buf,
&mut diff_attributes,
&mut curve_bufs,
))
});
let mut aim = Skill::new(SkillKind::Aim);
let mut speed = Skill::new(SkillKind::Speed);
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end =
(map.hit_objects[0].start_time as f32 / section_len).ceil() * section_len;
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
let mut prev_vals = None;
// Handle second object separately to remove later if-branching
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(
&curr,
&prev,
prev_vals,
prev_prev,
map_attributes.clock_rate as f32,
scaling_factor,
);
while h.base.time as f32 > current_section_end {
current_section_end += section_len;
}
aim.process(&h);
speed.process(&h);
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(
&curr,
&prev,
prev_vals,
prev_prev,
map_attributes.clock_rate as f32,
scaling_factor,
);
while h.base.time as f32 > current_section_end {
aim.save_current_peak();
aim.start_new_section_from(current_section_end);
speed.save_current_peak();
speed.start_new_section_from(current_section_end);
current_section_end += section_len;
}
aim.process(&h);
speed.process(&h);
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
}
aim.save_current_peak();
speed.save_current_peak();
let aim_strain = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_strain = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let aim_difficult_strain_count = aim.count_difficult_strains();
let speed_difficult_strain_count = speed.count_difficult_strains();
let stars = aim_strain + speed_strain + (aim_strain - speed_strain).abs() / 2.0;
diff_attributes.stars = stars as f64;
diff_attributes.speed_strain = speed_strain as f64;
diff_attributes.aim_strain = aim_strain as f64;
diff_attributes.aim_difficult_strain_count = aim_difficult_strain_count;
diff_attributes.speed_difficult_strain_count = speed_difficult_strain_count;
diff_attributes
}
#[derive(Clone, Debug, Default)]
pub struct OsuDifficultyAttributes {
pub aim_strain: f64,
pub speed_strain: f64,
pub ar: f64,
pub od: f64,
pub hp: f64,
pub cs: f64,
pub n_circles: usize,
pub n_sliders: usize,
pub n_spinners: usize,
pub stars: f64,
pub max_combo: usize,
pub aim_difficult_strain_count: f64,
pub speed_difficult_strain_count: f64,
pub beatmap_id: i32,
pub beatmap_creator: String,
}
#[derive(Clone, Debug)]
pub struct OsuPerformanceAttributes {
pub difficulty: OsuDifficultyAttributes,
pub pp: f64,
pub pp_acc: f64,
pub pp_aim: f64,
pub pp_speed: f64,
pub effective_miss_count: f64,
}
+37
View File
@@ -0,0 +1,37 @@
use crate::Difficulty;
use super::{
difficulty::{skills::OsuSkills, DifficultyValues}, OsuRelaxBeatmap,
};
/// The result of calculating the strains on a osu! map.
///
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug, PartialEq)]
pub struct OsuStrains {
/// Strain peaks of the aim skill.
pub aim: Vec<f64>,
/// Strain peaks of the speed skill.
pub speed: Vec<f64>,
}
impl OsuStrains {
/// Time between two strains in ms.
pub const SECTION_LEN: f64 = 400.0;
}
pub fn strains(difficulty: &Difficulty, converted: &OsuRelaxBeatmap<'_>) -> OsuStrains {
let DifficultyValues {
skills:
OsuSkills {
aim,
speed,
},
attrs: _,
} = DifficultyValues::calculate(difficulty, converted);
OsuStrains {
aim: aim.get_curr_strain_peaks().into_vec(),
speed: speed.get_curr_strain_peaks().into_vec(),
}
}
+5
View File
@@ -120,4 +120,9 @@ from_attrs!(
ManiaDifficultyAttributes,
ManiaPerformanceAttributes
},
osu_2019 {
OsuRelax,
OsuDifficultyAttributes,
OsuPerformanceAttributes
},
);