removed no_leniency feature
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
osu::precise::osu_object::{NestedObjectKind, OsuObjectKind},
|
||||
osu::osu_object::{NestedObjectKind, OsuObjectKind},
|
||||
parse::Pos2,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user