removed no_leniency feature

This commit is contained in:
MaxOhn
2021-11-12 12:48:35 +01:00
parent d703f55b3c
commit 09943c9ea2
20 changed files with 543 additions and 1724 deletions
+2 -5
View File
@@ -1,10 +1,7 @@
## Upcoming
- [BREAKING] The crate features for osu!standard have been adjusted:
- `no_leniency` has been removed
- `no_sliders_no_leniency` has been renamed to `osu_fast`
- `all_included` has been renamed to `osu_precise` and now serves as default
Additionally, instead of importing through `rosu_pp::osu::{version}`, you now have to import through `rosu_pp::osu`
- [BREAKING] With the importance of sliders for osu!standard, the `no_sliders_no_leniency` feature became too inaccurate. Additionally, since considering sliders now inherently drags performance down a little more, the difference between `no_leniency` and `all_included` became too small. Hence, the three osu features `no_sliders_no_leniency`, `no_leniency`, and `all_included` were removed. When the `osu` feature is enabled, it will now essentially use `all_included` under the hood.
Additionally, instead of importing through `rosu_pp::osu::{version}`, you now have to import through `rosu_pp::osu`.
- [BREAKING] Instead of returning `PpResult`, performance calculations now return `PerformanceAttributes` depending on the mode.
- [BREAKING] Instead of returning `StarResult`, difficulty calculations now return `DifficultyAttributes` depending on the mode.
- [BREAKING] Various fields and methods now include `f64` instead of `f32` to stay true to osu!'s original code
+2 -4
View File
@@ -11,11 +11,10 @@ description = "osu! difficulty and pp calculation for all modes"
keywords = ["osu", "pp", "stars", "async"]
[features]
default = ["osu_precise", "taiko", "fruits", "mania"]
default = ["osu", "taiko", "fruits", "mania"]
# game modes
osu_fast = ["osu"]
osu_precise = ["osu", "sliders"]
osu = ["sliders"]
taiko = []
fruits = ["sliders"]
mania = []
@@ -25,7 +24,6 @@ async_std = ["async-std"]
async_tokio = ["tokio"]
# auxiliary, no need to set yourself
osu = []
sliders = []
[dependencies.async-std]
+3 -11
View File
@@ -88,27 +88,19 @@ let result = map.pp()
println!("PP: {}", result.pp());
```
### osu!standard versions
- `osu_precise`: Both stack leniency & slider paths are considered so that the difficulty and pp calculation immitates osu! as close as possible. Pro: Very accurate values; Con: Less performant.
- `osu_fast` (i.e. [oppai](https://github.com/Francesco149/oppai-ng)): Fully ignoring sliders aswell as the positional offset caused by stack leniency. This means the stacked position and travel distance of notes is completely omitted which results in notable inaccuracies but is also considerably faster than `osu_precise`.
- **Note**: If the `fruits` feature is enabled, sliders will be parsed regardless, resulting in a reduced performance advantage of `osu_fast`. Hence, it is only recommended to use `osu_fast` if `fruits` is not enabled.
### Features
| Flag | Description |
|-----|-----|
| `default` | Enable all modes and choose the `osu_precise` version for osu!standard. |
| `default` | Enable all modes. |
| `osu` | Enable osu!standard. |
| `taiko` | Enable osu!taiko. |
| `fruits` | Enable osu!ctb. |
| `mania` | Enable osu!mania. |
| `osu_fast` | When calculating difficulty attributes in osu!standard, ignore stack leniency and sliders. Great performance but less precision values. |
| `osu_precise` | When calculating difficulty attributes in osu!standard, consider both stack leniency and sliders. Great precision but significantly worse performance than `osu_fast`. |
| `async_tokio` | Beatmap parsing will be async through [tokio](https://github.com/tokio-rs/tokio) |
| `async_std` | Beatmap parsing will be async through [async-std](https://github.com/async-rs/async-std) |
### Benchmarks (TODO, update w.r.t new feature flags)
### Benchmarks (TODO, update w.r.t removed feature flags)
Comparing the PP calculation speed between [osu-perf](https://gitlab.com/JackRedstonia/osu-perf/) (alternative rust pp calculculation crate), an [oppai-ng](https://github.com/Francesco149/oppai-ng) rust binding, and rosu-pp's `no_sliders_no_leniency`:
+3 -10
View File
@@ -38,18 +38,11 @@ impl<'p> ControlPointIter<'p> {
}
pub(crate) enum ControlPoint {
Timing {
time: f64,
#[allow(dead_code)] // not used in `osu_fast` feature
beat_len: f64,
},
Difficulty {
time: f64,
slider_velocity: f64,
},
Timing { time: f64, beat_len: f64 },
Difficulty { time: f64, slider_velocity: f64 },
}
#[cfg(any(feature = "osu_precise", feature = "fruits"))]
#[cfg(any(feature = "osu", feature = "fruits"))]
impl ControlPoint {
#[inline]
pub(crate) fn time(&self) -> f64 {
+2 -21
View File
@@ -91,23 +91,15 @@
//! println!("PP: {}", result.pp());
//! ```
//!
//! ## osu!standard versions
//!
//! - `osu_precise`: Both stack leniency & slider paths are considered so that the difficulty and pp calculation immitates osu! as close as possible. Pro: Very accurate values; Con: Less performant.
//! - `osu_fast` (i.e. [oppai](https://github.com/Francesco149/oppai-ng)): Fully ignoring sliders aswell as the positional offset caused by stack leniency. This means the stacked position and travel distance of notes is completely omitted which results in notable inaccuracies but is also considerably faster than `osu_precise`.
//!
//! **Note**: If the `fruits` feature is enabled, sliders will be parsed regardless, resulting in a reduced performance advantage of `osu_fast`. Hence, it is only recommended to use `osu_fast` if `fruits` is not enabled.
//!
//! ## Features
//!
//! | Flag | Description |
//! |-----|-----|
//! | `default` | Enable all modes and choose the `osu_precise` version for osu!standard. |
//! | `default` | Enable all modes. |
//! | `osu` | Enable osu!standard. |
//! | `taiko` | Enable osu!taiko. |
//! | `fruits` | Enable osu!ctb. |
//! | `mania` | Enable osu!mania. |
//! | `osu_fast` | When calculating difficulty attributes in osu!standard, ignore stack leniency and sliders. Great performance but less precise values. |
//! | `osu_precise` | When calculating difficulty attributes in osu!standard, consider both stack leniency and sliders. Great precision but significantly worse performance than `osu_fast`. |
//! | `async_tokio` | Beatmap parsing will be async through [tokio](https://github.com/tokio-rs/tokio) |
//! | `async_std` | Beatmap parsing will be async through [async-std](https://github.com/async-rs/async-std) |
//!
@@ -397,16 +389,5 @@ fn difficulty_range(val: f64, max: f64, avg: f64, min: f64) -> f64 {
)))]
compile_error!("At least one of the features `osu`, `taiko`, `fruits`, `mania` must be enabled");
#[cfg(all(
feature = "osu",
not(any(feature = "osu_precise", feature = "osu_fast"))
))]
compile_error!(
"Since the `osu` feature is enabled, either `osu_precise` or `osu_fast` must be enabled aswell"
);
#[cfg(any(all(feature = "osu_precise", feature = "osu_fast"),))]
compile_error!("Only one of the features `osu_precise` and `osu_fast` should be enabled");
#[cfg(all(feature = "async_tokio", feature = "async_std"))]
compile_error!("Only one of the features `async_tokio` and `async_std` should be enabled");
@@ -1,5 +1,5 @@
use crate::{
osu::precise::osu_object::{NestedObjectKind, OsuObjectKind},
osu::osu_object::{NestedObjectKind, OsuObjectKind},
parse::Pos2,
};
-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>,
scaling_factor: f32,
) -> Self {
let delta = base.time - prev.time;
let travel_dist = prev.travel_dist();
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects
let strain_time = delta.max(25.0);
// We don't need to calculate either angle or distance
// when one of the last->curr objects is a spinner
let (jump_dist, angle) = if base.is_spinner() || prev.is_spinner() {
(0.0, None)
} else {
let jump_dist = ((base.pos - prev.end_pos()) * scaling_factor).length();
let angle = prev_prev.map(|prev_prev| {
let v1 = prev_prev.end_pos() - prev.pos;
let v2 = base.pos - prev.pos;
let dot = v1.dot(v2);
let det = v1.x * v2.y - v1.y * v2.x;
det.atan2(dot).abs()
});
(jump_dist, angle)
};
Self {
base,
prev: prev_vals,
jump_dist,
travel_dist,
angle,
delta,
strain_time,
}
}
}
-324
View File
@@ -1,324 +0,0 @@
//! In addtion to not considering the positional offset caused by stack leniency, slider paths are also ignored.
//! This means stacked positions aswell as the travel distance of notes is completely omitted which
//! will cause notable inaccuracies.
//! The advantage is that it's considerably faster than `osu_precise`.
#![cfg(feature = "osu_fast")]
use std::mem;
use self::osu_object::ObjectParameters;
use super::DifficultyAttributes;
mod difficulty_object;
mod osu_object;
mod skill;
mod skill_kind;
mod slider_state;
use difficulty_object::DifficultyObject;
use osu_object::OsuObject;
use skill::Skill;
use skill_kind::SkillKind;
use slider_state::SliderState;
use crate::{Beatmap, Mods, Strains};
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.
///
/// Sliders are considered as regular hitcircles and stack leniency is ignored.
/// Still decently accurate results but definitely less precise than `osu_precise`.
/// However, this version is considerably faster.
///
/// In case of a partial play, e.g. a fail, one can specify the amount of passed objects.
pub fn stars(
map: &Beatmap,
mods: impl Mods,
passed_objects: Option<usize>,
) -> DifficultyAttributes {
let take = passed_objects.unwrap_or_else(|| map.hit_objects.len());
let attributes = map.attributes().mods(mods);
let hit_window = super::difficulty_range_od(attributes.od) / attributes.clock_rate;
let od = (80.0 - hit_window) / 6.0;
if take < 2 {
return DifficultyAttributes {
ar: attributes.ar,
hp: attributes.hp,
od,
..Default::default()
};
}
let radius = OBJECT_RADIUS * (1.0 - 0.7 * (attributes.cs - 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 params = ObjectParameters {
map,
radius,
clock_rate: attributes.clock_rate,
max_combo: 0,
slider_state: SliderState::new(map),
};
let mut hit_objects = map
.hit_objects
.iter()
.take(take)
.filter_map(|h| OsuObject::new(h, &mut params));
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::new(SkillKind::Aim));
skills.push(Skill::new(SkillKind::speed(hit_window)));
if fl {
skills.push(Skill::new(SkillKind::flashlight(scaling_factor)));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
let mut prev_vals = None;
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
// 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, scaling_factor);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.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, scaling_factor);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.save_current_peak();
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
}
for skill in skills.iter_mut() {
skill.save_current_peak();
}
let aim_rating = skills[0].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_rating = if mods.rx() {
0.0
} else {
skills[1].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER
};
let flashlight_rating = skills.get_mut(2).map_or(0.0, |skill| {
skill.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER
});
let base_aim_performance = {
let base = 5.0 * (aim_rating / 0.0675).max(1.0) - 4.0;
base * base * base / 100_000.0
};
let base_speed_performance = {
let base = 5.0 * (speed_rating / 0.0675).max(1.0) - 4.0;
base * base * base / 100_000.0
};
let base_flashlight_performance = if fl {
flashlight_rating * flashlight_rating * 25.0
} else {
0.0
};
let base_performance = (base_aim_performance.powf(1.1)
+ base_speed_performance.powf(1.1)
+ base_flashlight_performance.powf(1.1))
.powf(1.0 / 1.1);
let star_rating = if base_performance > 0.00001 {
1.12_f32.cbrt()
* 0.027
* ((100_000.0 / (1.0_f32 / 1.1).exp2() * base_performance).cbrt() + 4.0)
} else {
0.0
};
DifficultyAttributes {
stars: star_rating,
ar: attributes.ar,
hp: attributes.hp,
od,
speed_strain: speed_rating,
aim_strain: aim_rating,
flashlight_rating,
max_combo: params.max_combo,
n_circles: map.n_circles as usize,
n_spinners: map.n_spinners as usize,
n_sliders: map.n_sliders as usize,
}
}
/// Essentially the same as the `stars` function but instead of
/// evaluating the final strains, it just returns them as is.
///
/// Suitable to plot the difficulty of a map over time.
pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
let attributes = map.attributes().mods(mods);
let hit_window = super::difficulty_range_od(attributes.od) / attributes.clock_rate;
if map.hit_objects.len() < 2 {
return Strains::default();
}
let radius = OBJECT_RADIUS * (1.0 - 0.7 * (attributes.cs - 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 params = ObjectParameters {
map,
radius,
clock_rate: attributes.clock_rate,
max_combo: 0,
slider_state: SliderState::new(map),
};
let mut hit_objects = map
.hit_objects
.iter()
.filter_map(|h| OsuObject::new(h, &mut params));
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::new(SkillKind::Aim));
skills.push(Skill::new(SkillKind::speed(hit_window)));
if fl {
skills.push(Skill::new(SkillKind::flashlight(scaling_factor)));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
let mut prev_vals = None;
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
// 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, scaling_factor);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.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, scaling_factor);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.save_current_peak();
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
}
for skill in skills.iter_mut() {
skill.save_current_peak();
}
let mut speed_strains = skills.pop().unwrap().strain_peaks;
let mut aim_strains = skills.pop().unwrap().strain_peaks;
let strains = if let Some(mut flashlight_strains) = skills.pop().map(|s| s.strain_peaks) {
mem::swap(&mut speed_strains, &mut aim_strains);
mem::swap(&mut aim_strains, &mut flashlight_strains);
aim_strains
.into_iter()
.zip(speed_strains)
.zip(flashlight_strains)
.map(|((aim, speed), flashlight)| aim + speed + flashlight)
.collect()
} else {
aim_strains
.into_iter()
.zip(speed_strains)
.map(|(aim, speed)| aim + speed)
.collect()
};
Strains {
section_length: SECTION_LEN,
strains,
}
}
-172
View File
@@ -1,172 +0,0 @@
use crate::{
parse::{HitObject, HitObjectKind, Pos2},
Beatmap,
};
use super::slider_state::SliderState;
pub(crate) struct OsuObject {
pub(crate) pos: Pos2,
pub(crate) time: f32,
kind: OsuObjectKind,
}
pub(crate) enum OsuObjectKind {
Circle,
Slider { end_pos: Pos2, travel_dist: f32 },
Spinner,
}
pub(crate) struct ObjectParameters<'a> {
pub(crate) map: &'a Beatmap,
pub(crate) radius: f32,
pub(crate) clock_rate: f32,
pub(crate) max_combo: usize,
pub(crate) slider_state: SliderState<'a>,
}
impl OsuObject {
pub(crate) fn new(h: &HitObject, params: &mut ObjectParameters<'_>) -> Option<Self> {
let time = h.start_time / params.clock_rate;
let obj = match &h.kind {
HitObjectKind::Circle => {
params.max_combo += 1;
Self::circle(h.pos, time)
}
#[cfg(feature = "sliders")]
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
} => {
let span_count = *repeats + 1;
params.max_combo += params.slider_state.count_ticks(
h.start_time,
*pixel_len,
span_count,
params.map,
);
match control_points.last() {
Some(point) => {
let follow_circle_radius = params.radius * 3.0;
let travel_dist = Self::approximate_travel_dist(
follow_circle_radius,
span_count as f32,
point.pos,
);
let mut end_pos = h.pos;
if repeats % 2 == 0 && *pixel_len > follow_circle_radius {
end_pos += point.pos
}
Self {
pos: h.pos,
time,
kind: OsuObjectKind::Slider {
end_pos,
travel_dist,
},
}
}
None => Self::circle(h.pos, time),
}
}
#[cfg(not(feature = "sliders"))]
HitObjectKind::Slider {
pixel_len,
span_count,
last_control_point,
} => {
params.max_combo += params.slider_state.count_ticks(
h.start_time,
*pixel_len,
*span_count,
params.map,
);
let follow_circle_radius = params.radius * 3.0;
let travel_dist = Self::approximate_travel_dist(
follow_circle_radius,
*span_count as f32,
*last_control_point - h.pos,
);
let end_pos = if span_count % 2 == 1 && *pixel_len > follow_circle_radius {
*last_control_point
} else {
h.pos
};
Self {
pos: h.pos,
time,
kind: OsuObjectKind::Slider {
end_pos,
travel_dist,
},
}
}
HitObjectKind::Spinner { .. } => {
params.max_combo += 1;
Self {
pos: h.pos,
time,
kind: OsuObjectKind::Spinner,
}
}
HitObjectKind::Hold { .. } => return None,
};
Some(obj)
}
fn circle(pos: Pos2, time: f32) -> Self {
Self {
pos,
time,
kind: OsuObjectKind::Circle,
}
}
pub(crate) fn is_slider(&self) -> bool {
matches!(self.kind, OsuObjectKind::Slider { .. })
}
pub(crate) fn is_spinner(&self) -> bool {
matches!(self.kind, OsuObjectKind::Spinner)
}
pub(crate) fn end_pos(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner => self.pos,
OsuObjectKind::Slider { end_pos, .. } => *end_pos,
}
}
pub(crate) fn travel_dist(&self) -> f32 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner => 0.0,
OsuObjectKind::Slider { travel_dist, .. } => *travel_dist,
}
}
// Approximating lower bound for lazy travel distance
fn approximate_travel_dist(
follow_circle_radius: f32,
span_count: f32,
last_control_point: Pos2,
) -> f32 {
let lazy_end_point_dist = follow_circle_radius * span_count;
let dist = last_control_point.length();
(dist * span_count - lazy_end_point_dist).max(0.0)
}
}
-109
View File
@@ -1,109 +0,0 @@
use crate::math_util;
use super::{skill_kind::calculate_speed_rhythm_bonus, DifficultyObject, SkillKind};
use std::cmp::Ordering;
const REDUCED_STRAIN_BASELINE: f32 = 0.75;
pub(crate) struct Skill {
curr_strain: f32,
curr_section_peak: f32,
kind: SkillKind,
pub(crate) strain_peaks: Vec<f32>,
prev_time: Option<f32>,
}
impl Skill {
#[inline]
pub(crate) fn new(kind: SkillKind) -> Self {
Self {
curr_strain: 1.0,
curr_section_peak: 0.0,
kind,
strain_peaks: Vec::with_capacity(128),
prev_time: None,
}
}
#[inline]
pub(crate) fn process(&mut self, curr: &DifficultyObject<'_>) {
self.kind.pre_process();
self.curr_section_peak = self.strain_value_at(curr).max(self.curr_section_peak);
self.prev_time = Some(curr.base.time);
self.kind.post_process(curr);
}
#[inline]
pub(crate) fn save_current_peak(&mut self) {
self.strain_peaks.push(self.curr_section_peak);
}
#[inline]
pub(crate) fn start_new_section_from(&mut self, time: f32) {
// The maximum strain of the new section is not zero by default
self.curr_section_peak = self.calculate_initial_strain(time);
}
pub(crate) fn difficulty_value(&mut self) -> f32 {
let mut difficulty = 0.0;
let mut weight = 1.0;
let decay_weight = self.kind.decay_weight();
let (reduced_section_count, difficulty_multiplier) = self.kind.difficulty_values();
let reduced_section_count_f32 = reduced_section_count as f32;
self.strain_peaks
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
let peaks = self.strain_peaks.iter_mut();
for (i, strain) in peaks.take(reduced_section_count).enumerate() {
let clamped = (i as f32 / reduced_section_count_f32).clamp(0.0, 1.0);
let scale = (math_util::lerp(1.0, 10.0, clamped)).log10();
*strain *= math_util::lerp(REDUCED_STRAIN_BASELINE, 1.0, scale);
}
self.strain_peaks
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
for &strain in &self.strain_peaks {
difficulty += strain * weight;
weight *= decay_weight;
}
difficulty * difficulty_multiplier
}
pub(crate) fn calculate_initial_strain(&self, time: f32) -> f32 {
let prev_time = self.prev_time.unwrap_or(0.0);
let decayed_strain = self.curr_strain * self.kind.strain_decay(time - prev_time);
match &self.kind {
SkillKind::Aim | SkillKind::Flashlight { .. } => decayed_strain,
SkillKind::Speed { curr_rhythm, .. } => curr_rhythm * decayed_strain,
}
}
pub(crate) fn strain_value_at(&mut self, curr: &DifficultyObject<'_>) -> f32 {
self.curr_strain *= self.kind.strain_decay(curr.delta);
self.curr_strain += self.kind.strain_value_of(curr) * self.kind.skill_multiplier();
match &mut self.kind {
SkillKind::Aim | SkillKind::Flashlight { .. } => self.curr_strain,
SkillKind::Speed {
curr_rhythm,
history,
hit_window,
} => {
*curr_rhythm = calculate_speed_rhythm_bonus(curr, history, *hit_window);
self.curr_strain * *curr_rhythm
}
}
}
}
-396
View File
@@ -1,396 +0,0 @@
use std::{collections::VecDeque, f32::consts::PI, iter};
use crate::{math_util, parse::Pos2};
use super::DifficultyObject;
const SINGLE_SPACING_TRESHOLD: f32 = 125.0;
const MIN_SPEED_BONUS: f32 = 75.0;
const SPEED_BALANCING_FACTOR: f32 = 40.0;
const TIMING_THRESHOLD: f32 = 107.0;
const AIM_SKILL_MULTIPLIER: f32 = 26.25;
const AIM_STRAIN_DECAY_BASE: f32 = 0.15;
const AIM_DECAY_WEIGHT: f32 = 0.9;
const AIM_DIFFICULTY_MULTIPLIER: f32 = 1.06;
const AIM_REDUCED_SECTION_COUNT: usize = 10;
const AIM_ANGLE_BONUS_BEGIN: f32 = std::f32::consts::FRAC_PI_3;
const SPEED_SKILL_MULTIPLIER: f32 = 1375.0;
const SPEED_STRAIN_DECAY_BASE: f32 = 0.3;
const SPEED_DECAY_WEIGHT: f32 = 0.9;
const SPEED_DIFFICULTY_MULTIPLIER: f32 = 1.04;
const SPEED_REDUCED_SECTION_COUNT: usize = 5;
const SPEED_HISTORY_LENGTH: usize = 32;
const SPEED_HISTORY_TIME_MAX: f32 = 5000.0;
const SPEED_RHYTHM_MULTIPLIER: f32 = 0.75;
const FLASHLIGHT_SKILL_MULTIPLIER: f32 = 0.15;
const FLASHLIGHT_STRAIN_DECAY_BASE: f32 = 0.15;
const FLASHLIGHT_DECAY_WEIGHT: f32 = 1.0;
const FLASHLIGHT_DIFFICULTY_MULTIPLIER: f32 = 1.06;
const FLASHLIGHT_REDUCED_SECTION_COUNT: usize = 10;
const FLASHLIGHT_HISTORY_LENGTH: usize = 10;
pub(crate) struct FlashlightHistoryEntry {
end_pos: Pos2,
is_spinner: bool,
jump_dist: f32,
strain_time: f32,
}
impl From<&DifficultyObject<'_>> for FlashlightHistoryEntry {
fn from(h: &DifficultyObject<'_>) -> Self {
Self {
end_pos: h.base.end_pos(),
is_spinner: h.base.is_spinner(),
jump_dist: h.jump_dist,
strain_time: h.strain_time,
}
}
}
pub(crate) struct SpeedHistoryEntry {
is_slider: bool,
start_time: f32,
strain_time: f32,
}
impl From<&DifficultyObject<'_>> for SpeedHistoryEntry {
fn from(h: &DifficultyObject<'_>) -> Self {
Self {
is_slider: h.base.is_slider(),
start_time: h.base.time,
strain_time: h.strain_time,
}
}
}
pub(crate) enum SkillKind {
Aim,
Flashlight {
history: VecDeque<FlashlightHistoryEntry>,
scaling_factor: f32,
},
Speed {
curr_rhythm: f32,
history: VecDeque<SpeedHistoryEntry>,
hit_window: f32,
},
}
impl SkillKind {
pub(crate) fn flashlight(scaling_factor: f32) -> Self {
Self::Flashlight {
history: VecDeque::with_capacity(FLASHLIGHT_HISTORY_LENGTH),
scaling_factor,
}
}
pub(crate) fn speed(hit_window: f32) -> Self {
Self::Speed {
curr_rhythm: 1.0,
history: VecDeque::with_capacity(SPEED_HISTORY_LENGTH),
hit_window,
}
}
pub(crate) fn pre_process(&mut self) {
match self {
Self::Aim => {}
Self::Flashlight { history, .. } => history.truncate(FLASHLIGHT_HISTORY_LENGTH),
Self::Speed { history, .. } => history.truncate(SPEED_HISTORY_LENGTH),
}
}
pub(crate) fn post_process(&mut self, current: &DifficultyObject<'_>) {
match self {
Self::Aim => {}
Self::Flashlight { history, .. } => history.push_front(current.into()),
Self::Speed { history, .. } => history.push_front(current.into()),
}
}
pub(crate) fn strain_value_of(&self, curr: &DifficultyObject<'_>) -> f32 {
match self {
Self::Aim => {
if curr.base.is_spinner() {
return 0.0;
}
let mut aim_strain = 0.0;
if let Some((prev_jump_dist, prev_strain_time)) = curr.prev {
if let Some(angle) = curr.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)
* (curr.jump_dist - scale).max(0.0))
.sqrt();
aim_strain = 1.4 * apply_diminishing_exp(angle_bonus.max(0.0))
/ (TIMING_THRESHOLD).max(prev_strain_time)
}
}
let jump_dist_exp = apply_diminishing_exp(curr.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();
(aim_strain + dist_exp / (curr.strain_time).max(TIMING_THRESHOLD))
.max(dist_exp / curr.strain_time)
}
Self::Flashlight {
history,
scaling_factor,
} => {
if curr.base.is_spinner() {
return 0.0;
}
let mut small_dist_nerf = 1.0;
let mut result = 0.0;
let mut cumulative_strain_time = 0.0;
let mut history = history.iter();
if let Some(prev) = history.next() {
// Handle first entry distinctly for slight optimization
if !prev.is_spinner {
let jump_dist = (curr.base.pos - prev.end_pos).length();
cumulative_strain_time += prev.strain_time;
// * We want to nerf objects that can be easily seen within the Flashlight circle radius
small_dist_nerf = (jump_dist / 75.0).min(1.0);
// * We also want to nerf stacks so that only the first object of the stack is accounted for
let stack_nerf = ((prev.jump_dist / scaling_factor) / 25.0).min(1.0);
result += stack_nerf * scaling_factor * jump_dist / cumulative_strain_time;
}
let factors = iter::successors(Some(0.8), |s| Some(s * 0.8));
for (factor, prev) in factors.zip(history) {
if !prev.is_spinner {
let jump_dist = (curr.base.pos - prev.end_pos).length();
cumulative_strain_time += prev.strain_time;
// * We also want to nerf stacks so that only the first object of the stack is accounted for
let stack_nerf = ((prev.jump_dist / scaling_factor) / 25.0).min(1.0);
result += factor * stack_nerf * scaling_factor * jump_dist
/ cumulative_strain_time;
}
}
}
result *= small_dist_nerf;
result * result
}
Self::Speed {
history,
hit_window,
..
} => {
if curr.base.is_spinner() {
return 0.0;
}
let mut strain_time = curr.strain_time;
let hit_window_full = hit_window * 2.0;
let speed_window_ratio = strain_time / hit_window_full;
let prev = history.front();
// * Aim to nerf cheesy rhythms (very fast consecutive doubles with large delta times between)
if let Some(prev) =
prev.filter(|p| strain_time < hit_window_full && p.strain_time > strain_time)
{
strain_time =
math_util::lerp(prev.strain_time, strain_time, speed_window_ratio);
}
// * Cap delta time to the OD 300 hit window
// * 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly,
// * whilst 0.92 limits the effect of the cap
strain_time /= (strain_time / hit_window_full / 0.93).clamp(0.92, 1.0);
// * Derive speed bonus for calculation
let mut speed_bonus = 1.0;
if strain_time < MIN_SPEED_BONUS {
let base = (MIN_SPEED_BONUS - strain_time) / SPEED_BALANCING_FACTOR;
speed_bonus = 1.0 + 0.75 * base * base;
}
let dist = SINGLE_SPACING_TRESHOLD.min(curr.travel_dist + curr.jump_dist);
(speed_bonus + speed_bonus * (dist / SINGLE_SPACING_TRESHOLD).powf(3.5))
/ strain_time
}
}
}
#[inline]
pub(crate) fn difficulty_values(&self) -> (usize, f32) {
match self {
Self::Aim => (AIM_REDUCED_SECTION_COUNT, AIM_DIFFICULTY_MULTIPLIER),
Self::Flashlight { .. } => (
FLASHLIGHT_REDUCED_SECTION_COUNT,
FLASHLIGHT_DIFFICULTY_MULTIPLIER,
),
Self::Speed { .. } => (SPEED_REDUCED_SECTION_COUNT, SPEED_DIFFICULTY_MULTIPLIER),
}
}
#[inline]
pub(crate) fn skill_multiplier(&self) -> f32 {
match self {
SkillKind::Aim => AIM_SKILL_MULTIPLIER,
SkillKind::Flashlight { .. } => FLASHLIGHT_SKILL_MULTIPLIER,
SkillKind::Speed { .. } => SPEED_SKILL_MULTIPLIER,
}
}
#[inline]
pub(crate) fn strain_decay_base(&self) -> f32 {
match self {
SkillKind::Aim => AIM_STRAIN_DECAY_BASE,
SkillKind::Flashlight { .. } => FLASHLIGHT_STRAIN_DECAY_BASE,
SkillKind::Speed { .. } => SPEED_STRAIN_DECAY_BASE,
}
}
#[inline]
pub(crate) fn decay_weight(&self) -> f32 {
match self {
SkillKind::Aim => AIM_DECAY_WEIGHT,
SkillKind::Flashlight { .. } => FLASHLIGHT_DECAY_WEIGHT,
SkillKind::Speed { .. } => SPEED_DECAY_WEIGHT,
}
}
#[inline]
pub(crate) fn strain_decay(&self, ms: f32) -> f32 {
self.strain_decay_base().powf(ms / 1000.0)
}
}
pub(crate) fn calculate_speed_rhythm_bonus(
current: &DifficultyObject<'_>,
history: &VecDeque<SpeedHistoryEntry>,
hit_window: f32,
) -> f32 {
if current.base.is_spinner() {
return 0.0;
}
let mut prev_island_size = 0;
let mut rhythm_complexity_sum = 0.0;
let mut island_size = 1;
let mut first_delta_switch = false;
let adjusted_hit_window = hit_window * 0.6;
let history_len = history.len() as f32;
// * Store the ratio of the current start of an island to buff for tighter rhythms
let mut start_ratio = 0.0;
let currs = history.iter();
let prevs = history.iter().skip(1);
let lasts = history.iter().skip(2);
for (((prev, curr), last), i) in prevs.zip(currs).zip(lasts).rev().zip(2..) {
let mut curr_historical_decay =
(SPEED_HISTORY_TIME_MAX - (current.base.time - curr.start_time)).max(0.0)
/ SPEED_HISTORY_TIME_MAX;
if curr_historical_decay.abs() > f32::EPSILON {
// * Either we're limited by time or limited by object count
curr_historical_decay = curr_historical_decay.min(i as f32 / history_len);
let curr_delta = curr.strain_time;
let prev_delta = prev.strain_time;
let last_delta = last.strain_time;
// * Fancy function to calculate rhythm bonuses
let base = (PI / (prev_delta.min(curr_delta) / prev_delta.max(curr_delta))).sin();
let curr_ratio = 1.0 + 6.0 * (base * base).min(0.5);
let lower_penalty = ((prev_delta - curr_delta).abs() - adjusted_hit_window).max(0.0);
let window_penalty = (lower_penalty / adjusted_hit_window).min(1.0);
let mut effective_ratio = window_penalty * curr_ratio;
if first_delta_switch {
if !(prev_delta > 1.25 * curr_delta || prev_delta * 1.25 < curr_delta) {
if island_size < 7 {
island_size += 1;
}
} else {
if curr.is_slider {
// * bpm change is into slider, this is easy acc window
effective_ratio *= 0.125;
}
if prev.is_slider {
// * bpm change was from a slider, this is easier typically than circle -> circle
effective_ratio *= 0.25;
}
if prev_island_size == island_size {
// * repeated island size (ex: triplet -> triplet)
effective_ratio *= 0.25;
}
if prev_island_size % 2 == island_size % 2 {
// * repeated island polarity (2 -> 4, 3 -> 5)
effective_ratio *= 0.5;
}
if last_delta > prev_delta + 10.0 && prev_delta > curr_delta + 10.0 {
// * previous increase happened a note ago, 1/1 -> 1/2-1/4, don't want to buff this
effective_ratio *= 0.125;
}
rhythm_complexity_sum += (effective_ratio * start_ratio).sqrt()
* curr_historical_decay
* ((4 + island_size) as f32).sqrt()
* ((4 + prev_island_size) as f32).sqrt()
/ 4.0;
start_ratio = effective_ratio;
prev_island_size = island_size;
island_size = 1;
// * we're slowing down, stop counting
if prev_delta * 1.25 < curr_delta {
// * if we're speeding up, this stays true and we keep counting island size
first_delta_switch = false;
}
}
} else if prev_delta > 1.25 * curr_delta {
// * we want to be speeding up
// * begin counting island until we change speed again
first_delta_switch = true;
start_ratio = effective_ratio;
island_size = 1;
}
}
}
// * produces multiplier that can be applied to strain. range [1, infinity) (not really though)
(4.0 + rhythm_complexity_sum * SPEED_RHYTHM_MULTIPLIER).sqrt() / 2.0
}
#[inline]
fn apply_diminishing_exp(val: f32) -> f32 {
val.powf(0.99)
}
-57
View File
@@ -1,57 +0,0 @@
use std::f32::NEG_INFINITY;
use crate::{Beatmap, ControlPoint, ControlPointIter};
pub(crate) struct SliderState<'p> {
control_points: ControlPointIter<'p>,
next_time: f32,
px_per_beat: f32,
prev_sv: f32,
}
impl<'p> SliderState<'p> {
#[inline]
pub(crate) fn new(map: &'p Beatmap) -> Self {
Self {
control_points: ControlPointIter::new(map),
next_time: NEG_INFINITY,
px_per_beat: 1.0,
prev_sv: 1.0,
}
}
pub(crate) fn count_ticks(
&mut self,
time: f32,
pixel_len: f32,
span_count: usize,
map: &Beatmap,
) -> usize {
while time >= self.next_time {
self.px_per_beat = map.slider_mult * 100.0 * self.prev_sv;
match self.control_points.next() {
Some(ControlPoint::Timing { time, .. }) => {
self.next_time = time;
self.prev_sv = 1.0;
}
Some(ControlPoint::Difficulty {
time,
slider_velocity,
}) => {
self.next_time = time;
self.prev_sv = slider_velocity;
}
None => break,
}
}
let spans = span_count as f32;
let beats = pixel_len * spans / self.px_per_beat;
let ticks = ((beats - 0.1) / spans * map.tick_rate).ceil() as usize;
ticks
.checked_sub(1)
.map_or(0, |ticks| ticks * span_count + span_count + 1)
}
}
+525 -10
View File
@@ -1,22 +1,537 @@
#![cfg(feature = "osu")]
mod difficulty_object;
mod osu_object;
mod pp;
mod scaling_factor;
mod skill;
mod skill_kind;
mod slider_state;
use difficulty_object::DifficultyObject;
use osu_object::{ObjectParameters, OsuObject};
pub use pp::*;
use scaling_factor::ScalingFactor;
use skill::Skill;
use skill_kind::SkillKind;
use slider_state::SliderState;
#[cfg(feature = "osu_precise")]
#[cfg_attr(docsrs, doc(cfg(feature = "osu_precise")))]
mod precise;
use std::mem;
#[cfg(feature = "osu_precise")]
pub use precise::*;
use crate::{curve::CurveBuffers, Beatmap, Mods, Strains};
#[cfg(feature = "osu_fast")]
#[cfg_attr(docsrs, doc(cfg(feature = "osu_fast")))]
mod fast;
const SECTION_LEN: f64 = 400.0;
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
const NORMALIZED_RADIUS: f32 = 50.0; // * diameter of 100; easier mental maths.
const STACK_DISTANCE: f32 = 3.0;
#[cfg(feature = "osu_fast")]
pub use fast::*;
/// Star calculation for osu!standard maps.
///
/// Slider paths aswell as stack leniency are considered.
/// Both of these drag the performance down but in turn the values are much more accurate
///
/// In case of a partial play, e.g. a fail, one can specify the amount of passed objects.
pub fn stars(
map: &Beatmap,
mods: impl Mods,
passed_objects: Option<usize>,
) -> DifficultyAttributes {
let take = passed_objects.unwrap_or_else(|| map.hit_objects.len());
let map_attributes = map.attributes().mods(mods);
let hit_window = difficulty_range_od(map_attributes.od) / map_attributes.clock_rate;
let od = (80.0 - hit_window) / 6.0;
if take < 2 {
return DifficultyAttributes {
ar: map_attributes.ar,
hp: map_attributes.hp,
od,
..Default::default()
};
}
let mut raw_ar = map.ar as f64;
let hr = mods.hr();
if hr {
raw_ar = (raw_ar * 1.4).min(10.0);
} else if mods.ez() {
raw_ar *= 0.5;
}
let time_preempt = difficulty_range_ar(raw_ar);
let scaling_factor = ScalingFactor::new(map_attributes.cs);
let mut params = ObjectParameters {
map,
max_combo: 0,
slider_state: SliderState::new(map),
ticks: Vec::new(),
curve_bufs: CurveBuffers::default(),
};
let hit_objects_iter = map
.hit_objects
.iter()
.take(take)
.filter_map(|h| OsuObject::new(h, hr, &mut params));
let mut hit_objects = Vec::with_capacity(take);
hit_objects.extend(hit_objects_iter);
let stack_threshold = time_preempt * map.stack_leniency as f64;
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
} else {
old_stacking(&mut hit_objects, stack_threshold);
}
// let scale_factor = (scaling_factor.scale * -6.4) as f32;
let mut hit_objects = hit_objects.into_iter().map(|mut h| {
// let stack_offset = Pos2::new(h.stack_height * scale_factor);
let stack_offset = scaling_factor.stack_offset(h.stack_height);
h.pos += stack_offset;
h.time /= map_attributes.clock_rate;
h
});
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::aim());
skills.push(Skill::speed(hit_window));
if fl {
// NOTE: Instead of having `NORMALIZED_RADIUS` as dividend, it still uses 52.0.
skills.push(Skill::flashlight(52.0 / scaling_factor.radius() as f64));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
// Handle second object separately to remove later if-branching
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.save_current_peak();
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
}
for skill in skills.iter_mut() {
skill.save_current_peak();
}
let aim_rating = skills[0].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_rating = if mods.rx() {
0.0
} else {
skills[1].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER
};
let flashlight_rating = skills.get_mut(2).map_or(0.0, |skill| {
skill.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER
});
let base_aim_performance = {
let base = 5.0 * (aim_rating / 0.0675).max(1.0) - 4.0;
base * base * base / 100_000.0
};
let base_speed_performance = {
let base = 5.0 * (speed_rating / 0.0675).max(1.0) - 4.0;
base * base * base / 100_000.0
};
let base_flashlight_performance = if fl {
flashlight_rating * flashlight_rating * 25.0
} else {
0.0
};
let base_performance = (base_aim_performance.powf(1.1)
+ base_speed_performance.powf(1.1)
+ base_flashlight_performance.powf(1.1))
.powf(1.0 / 1.1);
let star_rating = if base_performance > 0.00001 {
1.12_f64.cbrt()
* 0.027
* ((100_000.0 / (1.0_f64 / 1.1).exp2() * base_performance).cbrt() + 4.0)
} else {
0.0
};
DifficultyAttributes {
ar: map_attributes.ar,
hp: map_attributes.hp,
od,
aim_strain: aim_rating,
speed_strain: speed_rating,
flashlight_rating,
n_circles: map.n_circles as usize,
n_sliders: map.n_sliders as usize,
n_spinners: map.n_spinners as usize,
stars: star_rating,
max_combo: params.max_combo,
}
}
/// Essentially the same as the `stars` function but instead of
/// evaluating the final strains, it just returns them as is.
///
/// Suitable to plot the difficulty of a map over time.
pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
let map_attributes = map.attributes().mods(mods);
let hit_window = difficulty_range_od(map_attributes.od) / map_attributes.clock_rate;
if map.hit_objects.len() < 2 {
return Strains::default();
}
let mut raw_ar = map.ar as f64;
let hr = mods.hr();
if hr {
raw_ar *= 1.4;
} else if mods.ez() {
raw_ar *= 0.5;
}
let time_preempt = difficulty_range_ar(raw_ar);
let scaling_factor = ScalingFactor::new(map_attributes.cs);
let mut params = ObjectParameters {
map,
max_combo: 0,
slider_state: SliderState::new(map),
ticks: Vec::new(),
curve_bufs: CurveBuffers::default(),
};
let hit_objects_iter = map
.hit_objects
.iter()
.filter_map(|h| OsuObject::new(h, hr, &mut params));
let mut hit_objects = Vec::with_capacity(map.hit_objects.len());
hit_objects.extend(hit_objects_iter);
let stack_threshold = time_preempt * map.stack_leniency as f64;
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
} else {
old_stacking(&mut hit_objects, stack_threshold);
}
// let scale_factor = (scaling_factor.scale * -6.4) as f32;
let mut hit_objects = hit_objects.into_iter().map(|mut h| {
// let stack_offset = Pos2::new(h.stack_height * scale_factor);
let stack_offset = scaling_factor.stack_offset(h.stack_height);
h.pos += stack_offset;
h.time /= map_attributes.clock_rate;
h
});
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::aim());
skills.push(Skill::speed(hit_window));
if fl {
// NOTE: Instead of having `NORMALIZED_RADIUS` as dividend, it still uses 52.0.
skills.push(Skill::flashlight(52.0 / scaling_factor.radius() as f64));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
// Handle second object separately to remove later if-branching
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.save_current_peak();
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
}
for skill in skills.iter_mut() {
skill.save_current_peak();
}
let mut speed_strains = skills.pop().unwrap().strain_peaks;
let mut aim_strains = skills.pop().unwrap().strain_peaks;
let strains = if let Some(mut flashlight_strains) = skills.pop().map(|s| s.strain_peaks) {
mem::swap(&mut speed_strains, &mut aim_strains);
mem::swap(&mut aim_strains, &mut flashlight_strains);
aim_strains
.into_iter()
.zip(speed_strains)
.zip(flashlight_strains)
.map(|((aim, speed), flashlight)| aim + speed + flashlight)
.collect()
} else {
aim_strains
.into_iter()
.zip(speed_strains)
.map(|(aim, speed)| aim + speed)
.collect()
};
Strains {
section_length: SECTION_LEN,
strains,
}
}
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
let mut extended_start_idx = 0;
let extended_end_idx = hit_objects.len() - 1;
// 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.abs() > 0.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;
} else if hit_objects[obj_i_idx].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.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.0;
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.0;
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;
} else if hit_objects[obj_i_idx].time - hit_objects[n].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.0;
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.0 && !hit_objects[i].is_slider() {
continue;
}
let mut start_time = hit_objects[i].end_time();
let end_pos = hit_objects[i].end_pos();
let mut slider_stack = 0.0;
for j in i + 1..hit_objects.len() {
if hit_objects[j].time - stack_threshold > start_time {
break;
}
if hit_objects[j].pos.distance(hit_objects[i].pos) < STACK_DISTANCE {
hit_objects[i].stack_height += 1.0;
start_time = hit_objects[j].end_time();
} else if hit_objects[j].pos.distance(end_pos) < STACK_DISTANCE {
slider_stack += 1.0;
hit_objects[j].stack_height -= slider_stack;
start_time = hit_objects[j].end_time();
}
}
}
}
#[inline]
fn difficulty_range_ar(ar: f64) -> f64 {
crate::difficulty_range(ar, 450.0, 1200.0, 1800.0)
}
/// Various data created through the star calculation.
/// This data is necessary to calculate PP.
-538
View File
@@ -1,538 +0,0 @@
//! Every aspect of osu!'s pp calculation is being used.
//! This should result in the most accurate values but with
//! drawback of being slower than `osu_fast`.
#![cfg(feature = "osu_precise")]
use std::mem;
mod difficulty_object;
mod osu_object;
mod scaling_factor;
mod skill;
mod skill_kind;
mod slider_state;
use difficulty_object::DifficultyObject;
use osu_object::{ObjectParameters, OsuObject};
use scaling_factor::ScalingFactor;
use skill::Skill;
use skill_kind::SkillKind;
use slider_state::SliderState;
use crate::{curve::CurveBuffers, Beatmap, Mods, Strains};
use super::DifficultyAttributes;
const SECTION_LEN: f64 = 400.0;
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
const NORMALIZED_RADIUS: f32 = 50.0; // * diameter of 100; easier mental maths.
const STACK_DISTANCE: f32 = 3.0;
/// Star calculation for osu!standard maps.
///
/// Slider paths aswell as stack leniency are considered.
/// Both of these drag the performance down but in turn the values are much more accurate
///
/// In case of a partial play, e.g. a fail, one can specify the amount of passed objects.
pub fn stars(
map: &Beatmap,
mods: impl Mods,
passed_objects: Option<usize>,
) -> DifficultyAttributes {
let take = passed_objects.unwrap_or_else(|| map.hit_objects.len());
let map_attributes = map.attributes().mods(mods);
let hit_window = super::difficulty_range_od(map_attributes.od) / map_attributes.clock_rate;
let od = (80.0 - hit_window) / 6.0;
if take < 2 {
return DifficultyAttributes {
ar: map_attributes.ar,
hp: map_attributes.hp,
od,
..Default::default()
};
}
let mut raw_ar = map.ar as f64;
let hr = mods.hr();
if hr {
raw_ar = (raw_ar * 1.4).min(10.0);
} else if mods.ez() {
raw_ar *= 0.5;
}
let time_preempt = difficulty_range_ar(raw_ar);
let scaling_factor = ScalingFactor::new(map_attributes.cs);
let mut params = ObjectParameters {
map,
max_combo: 0,
slider_state: SliderState::new(map),
ticks: Vec::new(),
curve_bufs: CurveBuffers::default(),
};
let hit_objects_iter = map
.hit_objects
.iter()
.take(take)
.filter_map(|h| OsuObject::new(h, hr, &mut params));
let mut hit_objects = Vec::with_capacity(take);
hit_objects.extend(hit_objects_iter);
let stack_threshold = time_preempt * map.stack_leniency as f64;
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
} else {
old_stacking(&mut hit_objects, stack_threshold);
}
// let scale_factor = (scaling_factor.scale * -6.4) as f32;
let mut hit_objects = hit_objects.into_iter().map(|mut h| {
// let stack_offset = Pos2::new(h.stack_height * scale_factor);
let stack_offset = scaling_factor.stack_offset(h.stack_height);
h.pos += stack_offset;
h.time /= map_attributes.clock_rate;
h
});
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::aim());
skills.push(Skill::speed(hit_window));
if fl {
// NOTE: Instead of having `NORMALIZED_RADIUS` as dividend, it still uses 52.0.
skills.push(Skill::flashlight(52.0 / scaling_factor.radius() as f64));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
// Handle second object separately to remove later if-branching
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.save_current_peak();
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
}
for skill in skills.iter_mut() {
skill.save_current_peak();
}
let aim_rating = skills[0].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_rating = if mods.rx() {
0.0
} else {
skills[1].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER
};
let flashlight_rating = skills.get_mut(2).map_or(0.0, |skill| {
skill.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER
});
let base_aim_performance = {
let base = 5.0 * (aim_rating / 0.0675).max(1.0) - 4.0;
base * base * base / 100_000.0
};
let base_speed_performance = {
let base = 5.0 * (speed_rating / 0.0675).max(1.0) - 4.0;
base * base * base / 100_000.0
};
let base_flashlight_performance = if fl {
flashlight_rating * flashlight_rating * 25.0
} else {
0.0
};
let base_performance = (base_aim_performance.powf(1.1)
+ base_speed_performance.powf(1.1)
+ base_flashlight_performance.powf(1.1))
.powf(1.0 / 1.1);
let star_rating = if base_performance > 0.00001 {
1.12_f64.cbrt()
* 0.027
* ((100_000.0 / (1.0_f64 / 1.1).exp2() * base_performance).cbrt() + 4.0)
} else {
0.0
};
DifficultyAttributes {
ar: map_attributes.ar,
hp: map_attributes.hp,
od,
aim_strain: aim_rating,
speed_strain: speed_rating,
flashlight_rating,
n_circles: map.n_circles as usize,
n_sliders: map.n_sliders as usize,
n_spinners: map.n_spinners as usize,
stars: star_rating,
max_combo: params.max_combo,
}
}
/// Essentially the same as the `stars` function but instead of
/// evaluating the final strains, it just returns them as is.
///
/// Suitable to plot the difficulty of a map over time.
pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
let map_attributes = map.attributes().mods(mods);
let hit_window = super::difficulty_range_od(map_attributes.od) / map_attributes.clock_rate;
if map.hit_objects.len() < 2 {
return Strains::default();
}
let mut raw_ar = map.ar as f64;
let hr = mods.hr();
if hr {
raw_ar *= 1.4;
} else if mods.ez() {
raw_ar *= 0.5;
}
let time_preempt = difficulty_range_ar(raw_ar);
let scaling_factor = ScalingFactor::new(map_attributes.cs);
let mut params = ObjectParameters {
map,
max_combo: 0,
slider_state: SliderState::new(map),
ticks: Vec::new(),
curve_bufs: CurveBuffers::default(),
};
let hit_objects_iter = map
.hit_objects
.iter()
.filter_map(|h| OsuObject::new(h, hr, &mut params));
let mut hit_objects = Vec::with_capacity(map.hit_objects.len());
hit_objects.extend(hit_objects_iter);
let stack_threshold = time_preempt * map.stack_leniency as f64;
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
} else {
old_stacking(&mut hit_objects, stack_threshold);
}
// let scale_factor = (scaling_factor.scale * -6.4) as f32;
let mut hit_objects = hit_objects.into_iter().map(|mut h| {
// let stack_offset = Pos2::new(h.stack_height * scale_factor);
let stack_offset = scaling_factor.stack_offset(h.stack_height);
h.pos += stack_offset;
h.time /= map_attributes.clock_rate;
h
});
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::aim());
skills.push(Skill::speed(hit_window));
if fl {
// NOTE: Instead of having `NORMALIZED_RADIUS` as dividend, it still uses 52.0.
skills.push(Skill::flashlight(52.0 / scaling_factor.radius() as f64));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
// Handle second object separately to remove later if-branching
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.save_current_peak();
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
}
for skill in skills.iter_mut() {
skill.save_current_peak();
}
let mut speed_strains = skills.pop().unwrap().strain_peaks;
let mut aim_strains = skills.pop().unwrap().strain_peaks;
let strains = if let Some(mut flashlight_strains) = skills.pop().map(|s| s.strain_peaks) {
mem::swap(&mut speed_strains, &mut aim_strains);
mem::swap(&mut aim_strains, &mut flashlight_strains);
aim_strains
.into_iter()
.zip(speed_strains)
.zip(flashlight_strains)
.map(|((aim, speed), flashlight)| aim + speed + flashlight)
.collect()
} else {
aim_strains
.into_iter()
.zip(speed_strains)
.map(|(aim, speed)| aim + speed)
.collect()
};
Strains {
section_length: SECTION_LEN,
strains,
}
}
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
let mut extended_start_idx = 0;
let extended_end_idx = hit_objects.len() - 1;
// 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.abs() > 0.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;
} else if hit_objects[obj_i_idx].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.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.0;
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.0;
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;
} else if hit_objects[obj_i_idx].time - hit_objects[n].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.0;
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.0 && !hit_objects[i].is_slider() {
continue;
}
let mut start_time = hit_objects[i].end_time();
let end_pos = hit_objects[i].end_pos();
let mut slider_stack = 0.0;
for j in i + 1..hit_objects.len() {
if hit_objects[j].time - stack_threshold > start_time {
break;
}
if hit_objects[j].pos.distance(hit_objects[i].pos) < STACK_DISTANCE {
hit_objects[i].stack_height += 1.0;
start_time = hit_objects[j].end_time();
} else if hit_objects[j].pos.distance(end_pos) < STACK_DISTANCE {
slider_stack += 1.0;
hit_objects[j].stack_height -= slider_stack;
start_time = hit_objects[j].end_time();
}
}
}
}
#[inline]
fn difficulty_range_ar(ar: f64) -> f64 {
crate::difficulty_range(ar, 450.0, 1200.0, 1800.0)
}
+5 -5
View File
@@ -108,7 +108,7 @@ macro_rules! parse_general_body {
let mut mode = None;
let mut empty = true;
#[cfg(all(feature = "osu", feature = "osu_precise"))]
#[cfg(feature = "osu")]
let mut stack_leniency = None;
while read_line!($reader, $buf)? != 0 {
@@ -133,7 +133,7 @@ macro_rules! parse_general_body {
};
}
#[cfg(all(feature = "osu", feature = "osu_precise"))]
#[cfg(feature = "osu")]
if key == "StackLeniency" {
stack_leniency = Some(value.parse()?);
}
@@ -163,7 +163,7 @@ macro_rules! parse_general_body {
return Err(ParseError::UnincludedMode(GameMode::MNA));
}
#[cfg(all(feature = "osu", feature = "osu_precise"))]
#[cfg(feature = "osu")]
{
$self.stack_leniency = stack_leniency.unwrap_or(0.7);
}
@@ -743,7 +743,7 @@ pub struct Beatmap {
#[cfg(any(feature = "osu", feature = "fruits"))]
pub difficulty_points: Vec<DifficultyPoint>,
#[cfg(all(feature = "osu", feature = "osu_precise"))]
#[cfg(feature = "osu")]
pub stack_leniency: f32,
}
@@ -1062,7 +1062,7 @@ mod tests {
#[cfg(any(feature = "osu", feature = "fruits"))]
{
#[cfg(feature = "osu_precise")]
#[cfg(feature = "osu")]
println!("stack_leniency: {}", map.stack_leniency);
println!("timing_points: {}", map.timing_points.len());