wip osu pp update
This commit is contained in:
@@ -33,13 +33,52 @@ impl Default for TimingPoint {
|
|||||||
/// [`TimingPoint`] that depends on a previous one.
|
/// [`TimingPoint`] that depends on a previous one.
|
||||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||||
pub struct DifficultyPoint {
|
pub struct DifficultyPoint {
|
||||||
/// The start time for the current speed multiplier
|
/// The time at which the control point takes effect.
|
||||||
pub time: f64,
|
pub time: f64,
|
||||||
/// The speed multiplier until the next timing point
|
/// The slider velocity at this control point.
|
||||||
pub speed_multiplier: f64,
|
pub slider_vel: f64,
|
||||||
/// Whether the section between this and the
|
/// Whether the section between this and the
|
||||||
/// next timing points is a kiai section
|
/// next timing points is a kiai section
|
||||||
pub kiai: bool,
|
pub kiai: bool,
|
||||||
|
/// Legacy BPM multiplier that introduces floating-point errors for rulesets that depend on it.
|
||||||
|
pub bpm_mult: f64,
|
||||||
|
/// Whether or not slider ticks should be generated at this control point.
|
||||||
|
/// This exists for backwards compatibility with maps that abuse NaN
|
||||||
|
/// slider velocity behavior on osu!stable (e.g. /b/2628991).
|
||||||
|
pub generate_ticks: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DifficultyPoint {
|
||||||
|
/// The default slider velocity for a [`DifficultyPoint`]
|
||||||
|
pub const DEFAULT_SLIDER_VEL: f64 = 1.0;
|
||||||
|
/// The default BPM multipler for a [`DifficultyPoint`]
|
||||||
|
pub const DEFAULT_BPM_MULT: f64 = 1.0;
|
||||||
|
/// The default for generating ticks of a [`DifficultyPoint`]
|
||||||
|
pub const DEFAULT_GENERATE_TICKS: bool = true;
|
||||||
|
|
||||||
|
/// Create a new [`DifficultyPoint`]
|
||||||
|
pub fn new(time: f64, beat_len: f64, speed_multiplier: f64, kiai: bool) -> Self {
|
||||||
|
// * Note: In stable, the division occurs on floats, but with compiler optimisations
|
||||||
|
// * turned on actually seems to occur on doubles via some .NET black magic (possibly inlining?).
|
||||||
|
let bpm_multiplier = if beat_len < 0.0 {
|
||||||
|
((-beat_len) as f32).clamp(10.0, 10_000.0)
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
time,
|
||||||
|
slider_vel: speed_multiplier.clamp(0.1, 10.0),
|
||||||
|
kiai,
|
||||||
|
bpm_mult: bpm_multiplier as f64,
|
||||||
|
generate_ticks: !beat_len.is_nan(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_redundant(&self, existing: &DifficultyPoint) -> bool {
|
||||||
|
(self.slider_vel - existing.slider_vel).abs() <= f64::EPSILON
|
||||||
|
&& self.generate_ticks == existing.generate_ticks
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialOrd for DifficultyPoint {
|
impl PartialOrd for DifficultyPoint {
|
||||||
@@ -52,8 +91,10 @@ impl Default for DifficultyPoint {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
time: 0.0,
|
time: 0.0,
|
||||||
speed_multiplier: 1.0,
|
|
||||||
kiai: false,
|
kiai: false,
|
||||||
|
slider_vel: Self::DEFAULT_SLIDER_VEL,
|
||||||
|
bpm_mult: Self::DEFAULT_BPM_MULT,
|
||||||
|
generate_ticks: Self::DEFAULT_GENERATE_TICKS,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,38 +180,30 @@ mod test {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn control_point_iter() {
|
fn control_point_iter() {
|
||||||
let map = Beatmap {
|
let mut map = Beatmap::default();
|
||||||
timing_points: vec![
|
|
||||||
TimingPoint {
|
map.timing_points.push(TimingPoint {
|
||||||
time: 1.0,
|
time: 1.0,
|
||||||
beat_len: 10.0,
|
beat_len: 10.0,
|
||||||
kiai: false,
|
kiai: false,
|
||||||
},
|
});
|
||||||
TimingPoint {
|
|
||||||
time: 3.0,
|
map.timing_points.push(TimingPoint {
|
||||||
beat_len: 10.0,
|
time: 3.0,
|
||||||
kiai: false,
|
beat_len: 10.0,
|
||||||
},
|
kiai: false,
|
||||||
TimingPoint {
|
});
|
||||||
time: 4.0,
|
|
||||||
beat_len: 10.0,
|
map.timing_points.push(TimingPoint {
|
||||||
kiai: false,
|
time: 4.0,
|
||||||
},
|
beat_len: 10.0,
|
||||||
],
|
kiai: false,
|
||||||
difficulty_points: vec![
|
});
|
||||||
DifficultyPoint {
|
|
||||||
time: 2.0,
|
map.difficulty_points
|
||||||
speed_multiplier: 10.0,
|
.push(DifficultyPoint::new(2.0, 10.0, 10.0, false));
|
||||||
kiai: false,
|
map.difficulty_points
|
||||||
},
|
.push(DifficultyPoint::new(5.0, 10.0, 10.0, false));
|
||||||
DifficultyPoint {
|
|
||||||
time: 5.0,
|
|
||||||
speed_multiplier: 10.0,
|
|
||||||
kiai: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut iter = ControlPointIter::new(&map);
|
let mut iter = ControlPointIter::new(&map);
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
|||||||
|
|
||||||
// ! BUG: Since `LegacyDifficultyControlPoint` are not considered while parsing,
|
// ! BUG: Since `LegacyDifficultyControlPoint` are not considered while parsing,
|
||||||
// ! this value can be slightly off due to float arithmetics.
|
// ! this value can be slightly off due to float arithmetics.
|
||||||
let beat_len = timing_point.beat_len / difficulty_point.speed_multiplier;
|
let beat_len = timing_point.beat_len * difficulty_point.bpm_mult;
|
||||||
|
|
||||||
let span_count = (repeats + 1) as i32;
|
let span_count = (repeats + 1) as i32;
|
||||||
let start_time = hit_object.start_time.round() as i32;
|
let start_time = hit_object.start_time.round() as i32;
|
||||||
|
|||||||
@@ -111,9 +111,7 @@ impl Beatmap {
|
|||||||
let timing_point = self.timing_point_at(*start_time);
|
let timing_point = self.timing_point_at(*start_time);
|
||||||
let difficulty_point = self.difficulty_point_at(*start_time).unwrap_or_default();
|
let difficulty_point = self.difficulty_point_at(*start_time).unwrap_or_default();
|
||||||
|
|
||||||
// ! BUG: Since `LegacyDifficultyControlPoint` are not considered while parsing,
|
let mut beat_len = timing_point.beat_len * difficulty_point.bpm_mult;
|
||||||
// ! this value can be slightly off due to float arithmetics.
|
|
||||||
let mut beat_len = timing_point.beat_len / difficulty_point.speed_multiplier;
|
|
||||||
|
|
||||||
let slider_scoring_point_dist =
|
let slider_scoring_point_dist =
|
||||||
OSU_BASE_SCORING_DIST as f64 * self.slider_mult / self.tick_rate;
|
OSU_BASE_SCORING_DIST as f64 * self.slider_mult / self.tick_rate;
|
||||||
|
|||||||
+4
-2
@@ -7,6 +7,7 @@ pub use self::{
|
|||||||
breaks::Break,
|
breaks::Break,
|
||||||
control_points::{ControlPoint, ControlPointIter, DifficultyPoint, TimingPoint},
|
control_points::{ControlPoint, ControlPointIter, DifficultyPoint, TimingPoint},
|
||||||
mode::GameMode,
|
mode::GameMode,
|
||||||
|
sorted_vec::SortedVec,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod attributes;
|
mod attributes;
|
||||||
@@ -14,6 +15,7 @@ mod breaks;
|
|||||||
mod control_points;
|
mod control_points;
|
||||||
mod converts;
|
mod converts;
|
||||||
mod mode;
|
mod mode;
|
||||||
|
mod sorted_vec;
|
||||||
|
|
||||||
/// The main beatmap struct containing all data relevant
|
/// The main beatmap struct containing all data relevant
|
||||||
/// for difficulty and performance calculation
|
/// for difficulty and performance calculation
|
||||||
@@ -50,10 +52,10 @@ pub struct Beatmap {
|
|||||||
pub sounds: Vec<u8>,
|
pub sounds: Vec<u8>,
|
||||||
|
|
||||||
/// Timing points that indicate a new timing section.
|
/// Timing points that indicate a new timing section.
|
||||||
pub timing_points: Vec<TimingPoint>,
|
pub timing_points: SortedVec<TimingPoint>,
|
||||||
|
|
||||||
/// Timing point for the current timing section.
|
/// Timing point for the current timing section.
|
||||||
pub difficulty_points: Vec<DifficultyPoint>,
|
pub difficulty_points: SortedVec<DifficultyPoint>,
|
||||||
|
|
||||||
/// The stack leniency that is used to calculate
|
/// The stack leniency that is used to calculate
|
||||||
/// the stack offset for stacked positions.
|
/// the stack offset for stacked positions.
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
use std::{
|
||||||
|
cmp::Ordering,
|
||||||
|
convert::identity,
|
||||||
|
fmt::{Debug, Formatter, Result as FmtResult},
|
||||||
|
ops::Deref,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{DifficultyPoint, TimingPoint};
|
||||||
|
|
||||||
|
/// A [`Vec`] whose elements are guaranteed to be in order based on the given comparator.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SortedVec<T> {
|
||||||
|
inner: Vec<T>,
|
||||||
|
cmp: fn(&T, &T) -> Ordering,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> SortedVec<T> {
|
||||||
|
/// If the value is found then [`Result::Ok`] is returned, containing the
|
||||||
|
/// index of the matching element. If there are multiple matches, then any
|
||||||
|
/// one of the matches could be returned.
|
||||||
|
/// If the value is not found then [`Result::Err`] is returned, containing
|
||||||
|
/// the index where a matching element could be inserted while maintaining
|
||||||
|
/// sorted order.
|
||||||
|
pub fn find(&self, value: &T) -> Result<usize, usize> {
|
||||||
|
self.inner
|
||||||
|
.binary_search_by(|probe| (self.cmp)(probe, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn push(&mut self, value: T) {
|
||||||
|
let idx = self.find(&value).map_or_else(identity, identity);
|
||||||
|
|
||||||
|
self.inner.insert(idx, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn dedup_by_key<F, K>(&mut self, mut key: F)
|
||||||
|
where
|
||||||
|
F: FnMut(&mut T) -> K,
|
||||||
|
K: PartialEq,
|
||||||
|
{
|
||||||
|
self.inner.dedup_by(|a, b| key(a) == key(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Deref for SortedVec<T> {
|
||||||
|
type Target = Vec<T>;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Debug> Debug for SortedVec<T> {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||||
|
Debug::fmt(&self.inner, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SortedVec<TimingPoint> {
|
||||||
|
#[inline]
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Vec::new(),
|
||||||
|
cmp: |a, b| a.time.partial_cmp(&b.time).unwrap_or(Ordering::Equal),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SortedVec<DifficultyPoint> {
|
||||||
|
#[inline]
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Vec::new(),
|
||||||
|
cmp: |a, b| a.time.partial_cmp(&b.time).unwrap_or(Ordering::Equal),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SortedVec<DifficultyPoint> {
|
||||||
|
pub(crate) fn push_if_not_redundant(&mut self, value: DifficultyPoint) {
|
||||||
|
let is_redundant = match self.find(&value).map_err(|idx| idx.checked_sub(1)) {
|
||||||
|
Ok(idx) | Err(Some(idx)) => value.is_redundant(&self[idx]),
|
||||||
|
Err(None) => value.is_redundant(&DifficultyPoint::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_redundant {
|
||||||
|
self.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-26
@@ -6,7 +6,7 @@ use crate::{
|
|||||||
Beatmap,
|
Beatmap,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{catch_object::CatchObject, slider_state::SliderState, CatchDifficultyAttributes};
|
use super::{catch_object::CatchObject, CatchDifficultyAttributes};
|
||||||
|
|
||||||
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
|
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
|
||||||
const BASE_SCORING_DISTANCE: f64 = 100.0;
|
const BASE_SCORING_DISTANCE: f64 = 100.0;
|
||||||
@@ -18,7 +18,6 @@ pub(crate) struct FruitParams<'a> {
|
|||||||
pub(crate) last_pos: Option<f32>,
|
pub(crate) last_pos: Option<f32>,
|
||||||
pub(crate) last_time: f64,
|
pub(crate) last_time: f64,
|
||||||
pub(crate) map: &'a Beatmap,
|
pub(crate) map: &'a Beatmap,
|
||||||
pub(crate) slider_state: SliderState<'a>,
|
|
||||||
pub(crate) ticks: Vec<(Pos2, f64)>,
|
pub(crate) ticks: Vec<(Pos2, f64)>,
|
||||||
pub(crate) with_hr: bool,
|
pub(crate) with_hr: bool,
|
||||||
}
|
}
|
||||||
@@ -55,31 +54,29 @@ impl FruitOrJuice {
|
|||||||
params.last_pos = Some(h.pos.x + control_points[control_points.len() - 1].pos.x);
|
params.last_pos = Some(h.pos.x + control_points[control_points.len() - 1].pos.x);
|
||||||
params.last_time = h.start_time;
|
params.last_time = h.start_time;
|
||||||
|
|
||||||
// Responsible for timing point values
|
let timing_point = params.map.timing_point_at(h.start_time);
|
||||||
params.slider_state.update(h.start_time);
|
|
||||||
|
let difficulty_point = params
|
||||||
|
.map
|
||||||
|
.difficulty_point_at(h.start_time)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let vel_factor =
|
||||||
|
BASE_SCORING_DISTANCE * params.map.slider_mult / timing_point.beat_len;
|
||||||
|
let tick_dist_factor =
|
||||||
|
BASE_SCORING_DISTANCE * params.map.slider_mult / params.map.tick_rate;
|
||||||
|
|
||||||
|
let vel = vel_factor * difficulty_point.slider_vel;
|
||||||
|
|
||||||
|
let mut tick_dist = tick_dist_factor * difficulty_point.slider_vel;
|
||||||
|
|
||||||
let span_count = (*repeats + 1) as f64;
|
let span_count = (*repeats + 1) as f64;
|
||||||
|
|
||||||
let mut tick_dist = 100.0 * params.map.slider_mult / params.map.tick_rate;
|
|
||||||
|
|
||||||
if params.map.version >= 8 {
|
|
||||||
tick_dist /= (100.0 / params.slider_state.slider_velocity)
|
|
||||||
.max(10.0)
|
|
||||||
.min(1000.0)
|
|
||||||
/ 100.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the curve w.r.t. the control points
|
// Build the curve w.r.t. the control points
|
||||||
let curve = Curve::new(control_points, *pixel_len, &mut params.curve_bufs);
|
let curve = Curve::new(control_points, *pixel_len, &mut params.curve_bufs);
|
||||||
|
|
||||||
let velocity = (BASE_SCORING_DISTANCE
|
let total_duration = span_count * curve.dist() / vel;
|
||||||
* params.map.slider_mult
|
let span_duration = total_duration / span_count;
|
||||||
* params.slider_state.slider_velocity)
|
|
||||||
/ params.slider_state.beat_len;
|
|
||||||
|
|
||||||
let end_time = h.start_time + span_count * curve.dist() / velocity;
|
|
||||||
let duration = end_time - h.start_time;
|
|
||||||
let span_duration = duration / span_count;
|
|
||||||
|
|
||||||
// * A very lenient maximum length of a slider for ticks to be generated.
|
// * A very lenient maximum length of a slider for ticks to be generated.
|
||||||
// * This exists for edge cases such as /b/1573664 where the beatmap has
|
// * This exists for edge cases such as /b/1573664 where the beatmap has
|
||||||
@@ -88,12 +85,13 @@ impl FruitOrJuice {
|
|||||||
|
|
||||||
let len = curve.dist().min(max_len);
|
let len = curve.dist().min(max_len);
|
||||||
tick_dist = tick_dist.clamp(0.0, len);
|
tick_dist = tick_dist.clamp(0.0, len);
|
||||||
let min_dist_from_end = velocity * 10.0;
|
let min_dist_from_end = vel * 10.0;
|
||||||
|
|
||||||
let mut curr_dist = tick_dist;
|
let mut curr_dist = tick_dist;
|
||||||
let time_add = duration * tick_dist / (*pixel_len * span_count);
|
let pixel_len = pixel_len.unwrap_or(0.0);
|
||||||
|
let time_add = total_duration * tick_dist / (pixel_len * span_count);
|
||||||
|
|
||||||
let target = *pixel_len - tick_dist / 8.0;
|
let target = pixel_len - tick_dist / 8.0;
|
||||||
|
|
||||||
let mut slider_objects = vec![(h.pos, h.start_time)];
|
let mut slider_objects = vec![(h.pos, h.start_time)];
|
||||||
|
|
||||||
@@ -112,7 +110,7 @@ impl FruitOrJuice {
|
|||||||
params.attributes.n_tiny_droplets += tiny_droplet_count(
|
params.attributes.n_tiny_droplets += tiny_droplet_count(
|
||||||
h.start_time,
|
h.start_time,
|
||||||
time_add,
|
time_add,
|
||||||
duration,
|
total_duration,
|
||||||
span_count as usize,
|
span_count as usize,
|
||||||
¶ms.ticks,
|
¶ms.ticks,
|
||||||
);
|
);
|
||||||
@@ -160,7 +158,7 @@ impl FruitOrJuice {
|
|||||||
// Slider tail
|
// Slider tail
|
||||||
let progress = (*repeats % 2 == 0) as u8 as f64;
|
let progress = (*repeats % 2 == 0) as u8 as f64;
|
||||||
let pos = h.pos + curve.position_at(progress);
|
let pos = h.pos + curve.position_at(progress);
|
||||||
slider_objects.push((pos, h.start_time + duration));
|
slider_objects.push((pos, h.start_time + total_duration));
|
||||||
|
|
||||||
let new_fruits = 2 + (tick_dist > 0.0) as usize * *repeats;
|
let new_fruits = 2 + (tick_dist > 0.0) as usize * *repeats;
|
||||||
params.attributes.n_fruits += new_fruits;
|
params.attributes.n_fruits += new_fruits;
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
use std::{iter, slice::Iter};
|
use std::{iter, slice::Iter};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
catch::{
|
catch::{difficulty_object::DifficultyObject, SECTION_LENGTH, STAR_SCALING_FACTOR},
|
||||||
difficulty_object::DifficultyObject, slider_state::SliderState, SECTION_LENGTH,
|
|
||||||
STAR_SCALING_FACTOR,
|
|
||||||
},
|
|
||||||
curve::CurveBuffers,
|
curve::CurveBuffers,
|
||||||
parse::{HitObject, Pos2},
|
parse::{HitObject, Pos2},
|
||||||
Beatmap, Mods,
|
Beatmap, Mods,
|
||||||
@@ -179,7 +176,6 @@ impl<'map> CatchObjectIter<'map> {
|
|||||||
last_pos: None,
|
last_pos: None,
|
||||||
last_time: 0.0,
|
last_time: 0.0,
|
||||||
map,
|
map,
|
||||||
slider_state: SliderState::new(map),
|
|
||||||
ticks: Vec::new(),
|
ticks: Vec::new(),
|
||||||
with_hr: mods.hr(),
|
with_hr: mods.hr(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ mod gradual_difficulty;
|
|||||||
mod gradual_performance;
|
mod gradual_performance;
|
||||||
mod movement;
|
mod movement;
|
||||||
mod pp;
|
mod pp;
|
||||||
mod slider_state;
|
|
||||||
|
|
||||||
use catch_object::CatchObject;
|
use catch_object::CatchObject;
|
||||||
use difficulty_object::DifficultyObject;
|
use difficulty_object::DifficultyObject;
|
||||||
@@ -14,7 +13,6 @@ pub use gradual_difficulty::*;
|
|||||||
pub use gradual_performance::*;
|
pub use gradual_performance::*;
|
||||||
use movement::Movement;
|
use movement::Movement;
|
||||||
pub use pp::*;
|
pub use pp::*;
|
||||||
use slider_state::SliderState;
|
|
||||||
|
|
||||||
use crate::{catch::fruit_or_juice::FruitParams, curve::CurveBuffers, Beatmap, Mods, OsuStars};
|
use crate::{catch::fruit_or_juice::FruitParams, curve::CurveBuffers, Beatmap, Mods, OsuStars};
|
||||||
|
|
||||||
@@ -161,7 +159,6 @@ fn calculate_movement(params: CatchStars<'_>) -> (Movement, CatchDifficultyAttri
|
|||||||
last_pos: None,
|
last_pos: None,
|
||||||
last_time: 0.0,
|
last_time: 0.0,
|
||||||
map,
|
map,
|
||||||
slider_state: SliderState::new(map),
|
|
||||||
ticks: Vec::new(), // using the same buffer for all sliders
|
ticks: Vec::new(), // using the same buffer for all sliders
|
||||||
with_hr: mods.hr(),
|
with_hr: mods.hr(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
use crate::beatmap::{Beatmap, ControlPoint, ControlPointIter};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub(crate) struct SliderState<'p> {
|
|
||||||
control_points: ControlPointIter<'p>,
|
|
||||||
next: Option<ControlPoint>,
|
|
||||||
pub(crate) beat_len: f64,
|
|
||||||
pub(crate) slider_velocity: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'p> SliderState<'p> {
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn new(map: &'p Beatmap) -> Self {
|
|
||||||
let mut control_points = ControlPointIter::new(map);
|
|
||||||
|
|
||||||
let (beat_len, slider_velocity) = match control_points.next() {
|
|
||||||
Some(ControlPoint::Timing(point)) => (point.beat_len, 1.0),
|
|
||||||
Some(ControlPoint::Difficulty(point)) => (1000.0, point.speed_multiplier),
|
|
||||||
None => (1000.0, 1.0),
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
next: control_points.next(),
|
|
||||||
control_points,
|
|
||||||
beat_len,
|
|
||||||
slider_velocity,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn update(&mut self, time: f64) {
|
|
||||||
while let Some(next) = self.next.as_ref().filter(|n| time >= n.time()) {
|
|
||||||
match next {
|
|
||||||
ControlPoint::Timing(point) => {
|
|
||||||
self.beat_len = point.beat_len;
|
|
||||||
self.slider_velocity = 1.0;
|
|
||||||
}
|
|
||||||
ControlPoint::Difficulty(point) => self.slider_velocity = point.speed_multiplier,
|
|
||||||
}
|
|
||||||
|
|
||||||
self.next = self.control_points.next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use crate::beatmap::{Beatmap, DifficultyPoint, TimingPoint};
|
|
||||||
|
|
||||||
use super::SliderState;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn catch_slider_state() {
|
|
||||||
let map = Beatmap {
|
|
||||||
timing_points: vec![
|
|
||||||
TimingPoint {
|
|
||||||
time: 1.0,
|
|
||||||
beat_len: 10.0,
|
|
||||||
kiai: false,
|
|
||||||
},
|
|
||||||
TimingPoint {
|
|
||||||
time: 3.0,
|
|
||||||
beat_len: 20.0,
|
|
||||||
kiai: false,
|
|
||||||
},
|
|
||||||
TimingPoint {
|
|
||||||
time: 4.0,
|
|
||||||
beat_len: 30.0,
|
|
||||||
kiai: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
difficulty_points: vec![
|
|
||||||
DifficultyPoint {
|
|
||||||
time: 2.0,
|
|
||||||
speed_multiplier: 15.0,
|
|
||||||
kiai: false,
|
|
||||||
},
|
|
||||||
DifficultyPoint {
|
|
||||||
time: 5.0,
|
|
||||||
speed_multiplier: 45.0,
|
|
||||||
kiai: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut state = SliderState::new(&map);
|
|
||||||
|
|
||||||
state.update(2.0);
|
|
||||||
assert!((state.beat_len - 10.0).abs() <= f64::EPSILON);
|
|
||||||
|
|
||||||
state.update(3.0);
|
|
||||||
assert!((state.beat_len - 20.0).abs() <= f64::EPSILON);
|
|
||||||
assert!((state.slider_velocity - 1.0).abs() <= f64::EPSILON);
|
|
||||||
|
|
||||||
state.update(5.0);
|
|
||||||
assert!((state.beat_len - 30.0).abs() <= f64::EPSILON);
|
|
||||||
assert!((state.slider_velocity - 45.0).abs() <= f64::EPSILON);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+8
-8
@@ -1,4 +1,4 @@
|
|||||||
use std::{borrow::Cow, cmp::Ordering, convert::identity, f32::consts::PI, iter};
|
use std::{borrow::Cow, cmp::Ordering, convert::identity, f64::consts::PI, iter};
|
||||||
|
|
||||||
use crate::parse::{PathControlPoint, PathType, Pos2};
|
use crate::parse::{PathControlPoint, PathType, Pos2};
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ pub(crate) struct Curve {
|
|||||||
impl Curve {
|
impl Curve {
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
points: &[PathControlPoint],
|
points: &[PathControlPoint],
|
||||||
expected_len: f64,
|
expected_len: Option<f64>,
|
||||||
bufs: &mut CurveBuffers,
|
bufs: &mut CurveBuffers,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut path = Self::calculate_path(points, bufs);
|
let mut path = Self::calculate_path(points, bufs);
|
||||||
@@ -153,7 +153,7 @@ impl Curve {
|
|||||||
fn calculate_length(
|
fn calculate_length(
|
||||||
points: &[PathControlPoint],
|
points: &[PathControlPoint],
|
||||||
path: &mut Vec<Pos2>,
|
path: &mut Vec<Pos2>,
|
||||||
expected_len: f64,
|
expected_len: Option<f64>,
|
||||||
) -> Vec<f64> {
|
) -> Vec<f64> {
|
||||||
let mut calculated_len = 0.0;
|
let mut calculated_len = 0.0;
|
||||||
let mut cumulative_len = Vec::with_capacity(path.len());
|
let mut cumulative_len = Vec::with_capacity(path.len());
|
||||||
@@ -167,7 +167,7 @@ impl Curve {
|
|||||||
|
|
||||||
cumulative_len.extend(length_iter);
|
cumulative_len.extend(length_iter);
|
||||||
|
|
||||||
if (expected_len - calculated_len).abs() > f64::EPSILON {
|
if let Some(expected_len) = expected_len.filter(|&len| calculated_len != len) {
|
||||||
// * In osu-stable, if the last two control points of a slider are equal, extension is not performed
|
// * In osu-stable, if the last two control points of a slider are equal, extension is not performed
|
||||||
let condition_opt = points
|
let condition_opt = points
|
||||||
.len()
|
.len()
|
||||||
@@ -490,8 +490,8 @@ impl Curve {
|
|||||||
|
|
||||||
let radius = d_a.length();
|
let radius = d_a.length();
|
||||||
|
|
||||||
let theta_start = d_a.y.atan2(d_a.x);
|
let theta_start = (d_a.y as f64).atan2(d_a.x as f64);
|
||||||
let mut theta_end = d_c.y.atan2(d_c.x);
|
let mut theta_end = (d_c.y as f64).atan2(d_c.x as f64);
|
||||||
|
|
||||||
while theta_end < theta_start {
|
while theta_end < theta_start {
|
||||||
theta_end += 2.0 * PI;
|
theta_end += 2.0 * PI;
|
||||||
@@ -515,8 +515,8 @@ impl Curve {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Some(CircularArcProperties {
|
Some(CircularArcProperties {
|
||||||
theta_start: theta_start as f64,
|
theta_start,
|
||||||
theta_range: theta_range as f64,
|
theta_range,
|
||||||
direction,
|
direction,
|
||||||
radius,
|
radius,
|
||||||
centre,
|
centre,
|
||||||
|
|||||||
+2
-2
@@ -36,7 +36,7 @@ use crate::{
|
|||||||
/// // ...
|
/// // ...
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Debug)]
|
||||||
#[allow(clippy::large_enum_variant)]
|
#[allow(clippy::large_enum_variant)]
|
||||||
pub enum GradualDifficultyAttributes<'map> {
|
pub enum GradualDifficultyAttributes<'map> {
|
||||||
/// Gradual osu!catch difficulty attributes.
|
/// Gradual osu!catch difficulty attributes.
|
||||||
@@ -268,7 +268,7 @@ impl From<ScoreState> for TaikoScoreState {
|
|||||||
/// // attempting to process further objects will return `None`.
|
/// // attempting to process further objects will return `None`.
|
||||||
/// assert!(gradual_perf.process_next_object(state).is_none());
|
/// assert!(gradual_perf.process_next_object(state).is_none());
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Debug)]
|
||||||
#[allow(clippy::large_enum_variant)]
|
#[allow(clippy::large_enum_variant)]
|
||||||
pub enum GradualPerformanceAttributes<'map> {
|
pub enum GradualPerformanceAttributes<'map> {
|
||||||
/// Gradual osu!catch performance attributes.
|
/// Gradual osu!catch performance attributes.
|
||||||
|
|||||||
+15
@@ -513,3 +513,18 @@ impl From<taiko::TaikoPerformanceAttributes> for PerformanceAttributes {
|
|||||||
|
|
||||||
#[cfg(all(feature = "async_tokio", feature = "async_std"))]
|
#[cfg(all(feature = "async_tokio", feature = "async_std"))]
|
||||||
compile_error!("Only one of the features `async_tokio` and `async_std` should be enabled");
|
compile_error!("Only one of the features `async_tokio` and `async_std` should be enabled");
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{Beatmap, OsuPP};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom() {
|
||||||
|
let path = "F:\\osu!\\beatmaps\\2536330.osu";
|
||||||
|
let map = Beatmap::from_path(path).unwrap();
|
||||||
|
|
||||||
|
let attrs = OsuPP::new(&map).calculate();
|
||||||
|
|
||||||
|
println!("{:#?}", attrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,10 +70,6 @@ impl<T, const N: usize> LimitedQueue<T, N> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn full(&self) -> bool {
|
|
||||||
self.len == N
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn iter(&self) -> LimitedQueueIter<'_, T> {
|
pub(crate) fn iter(&self) -> LimitedQueueIter<'_, T> {
|
||||||
self.queue
|
self.queue
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+255
-219
@@ -3,248 +3,284 @@ use crate::{
|
|||||||
parse::Pos2,
|
parse::Pos2,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{OsuObject, ScalingFactor, NORMALIZED_RADIUS};
|
use super::{osu_object::NestedObject, OsuObject, ScalingFactor};
|
||||||
|
|
||||||
const MIN_DELTA_TIME: f64 = 25.0;
|
#[derive(Clone, Debug)]
|
||||||
const MAXIMUM_SLIDER_RADIUS: f32 = NORMALIZED_RADIUS * 2.4;
|
pub(crate) struct OsuDifficultyObject<'h> {
|
||||||
const ASSUMED_SLIDER_RADIUS: f32 = NORMALIZED_RADIUS * 1.8;
|
pub(crate) start_time: f64,
|
||||||
|
pub(crate) delta_time: f64,
|
||||||
pub(crate) struct DifficultyObject<'h> {
|
|
||||||
pub(crate) base: &'h OsuObject,
|
pub(crate) base: &'h OsuObject,
|
||||||
pub(crate) clock_rate: f64,
|
|
||||||
|
|
||||||
pub(crate) delta: f64,
|
|
||||||
pub(crate) strain_time: f64,
|
pub(crate) strain_time: f64,
|
||||||
|
pub(crate) dists: Distances,
|
||||||
pub(crate) angle: Option<f64>,
|
pub(crate) idx: usize,
|
||||||
pub(crate) jump_dist: f64,
|
|
||||||
|
|
||||||
pub(crate) movement_dist: f64,
|
|
||||||
pub(crate) movement_time: f64,
|
|
||||||
|
|
||||||
pub(crate) travel_dist: f64,
|
|
||||||
pub(crate) travel_time: f64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'h> DifficultyObject<'h> {
|
impl<'h> OsuDifficultyObject<'h> {
|
||||||
pub(super) fn new(
|
pub(crate) const MIN_DELTA_TIME: u32 = 25;
|
||||||
|
|
||||||
|
pub(crate) fn new(
|
||||||
base: &'h OsuObject,
|
base: &'h OsuObject,
|
||||||
prev: &mut OsuObject,
|
last: &'h OsuObject,
|
||||||
prev_prev: Option<&OsuObject>,
|
|
||||||
scaling_factor: &ScalingFactor,
|
|
||||||
clock_rate: f64,
|
clock_rate: f64,
|
||||||
|
idx: usize,
|
||||||
|
dists: Distances,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let delta = (base.time - prev.time) / clock_rate;
|
let start_time = base.start_time / clock_rate;
|
||||||
|
let delta_time = (base.start_time - last.start_time) / clock_rate;
|
||||||
|
|
||||||
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects
|
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
||||||
let strain_time = delta.max(MIN_DELTA_TIME);
|
let strain_time = delta_time.max(Self::MIN_DELTA_TIME as f64);
|
||||||
|
|
||||||
// * We don't need to calculate either angle or distances
|
// TODO: remove
|
||||||
// * when one of the last->curr objects is a spinner
|
|
||||||
let (travel_dist, travel_time, movement_dist, movement_time, jump_dist, angle) =
|
|
||||||
if base.is_spinner() || prev.is_spinner() {
|
|
||||||
(0.0, 0.0, 0.0, 0.0, 0.0, None)
|
|
||||||
} else {
|
|
||||||
let prev_stack_offset = scaling_factor.stack_offset(prev.stack_height);
|
|
||||||
|
|
||||||
// Important to call `Self::compute_slider_cursor_pos` before using `prev.lazy_end_pos`
|
|
||||||
// because the lazy end position is being calculated in that function
|
|
||||||
let (travel_dist, travel_time) = Self::compute_slider_cursor_pos(
|
|
||||||
prev,
|
|
||||||
prev_stack_offset,
|
|
||||||
scaling_factor.raw(),
|
|
||||||
clock_rate,
|
|
||||||
);
|
|
||||||
|
|
||||||
let prev_cursor_pos = prev.lazy_end_pos(prev_stack_offset);
|
|
||||||
|
|
||||||
let jump_dist =
|
|
||||||
((base.pos - prev_cursor_pos) * scaling_factor.adjusted()).length() as f64;
|
|
||||||
|
|
||||||
let angle =
|
|
||||||
prev_prev
|
|
||||||
.filter(|prev_prev| !prev_prev.is_spinner())
|
|
||||||
.map(|prev_prev| {
|
|
||||||
let prev_prev_cursor_pos = prev_prev
|
|
||||||
.lazy_end_pos(scaling_factor.stack_offset(prev_prev.stack_height));
|
|
||||||
|
|
||||||
let v1 = prev_prev_cursor_pos - prev.pos;
|
|
||||||
let v2 = base.pos - prev_cursor_pos;
|
|
||||||
|
|
||||||
let dot = (v1.dot(v2)) as f64;
|
|
||||||
let det = (v1.x * v2.y - v1.y * v2.x) as f64;
|
|
||||||
|
|
||||||
det.atan2(dot).abs()
|
|
||||||
});
|
|
||||||
|
|
||||||
let (movement_dist, movement_time) = Self::compute_movement_values(
|
|
||||||
prev,
|
|
||||||
base.pos,
|
|
||||||
jump_dist,
|
|
||||||
strain_time,
|
|
||||||
travel_time,
|
|
||||||
scaling_factor.adjusted(),
|
|
||||||
);
|
|
||||||
|
|
||||||
(
|
|
||||||
travel_dist,
|
|
||||||
travel_time,
|
|
||||||
movement_dist,
|
|
||||||
movement_time,
|
|
||||||
jump_dist,
|
|
||||||
angle,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
// ? Common values to debug
|
|
||||||
// println!("travel_dist={} | travel_time={}", travel_dist, travel_time);
|
|
||||||
// println!(
|
// println!(
|
||||||
// "movement_dist={} | movement_time={}",
|
// "[{}] lazy_jump_dist={} | lazy_travel_dist={} | \
|
||||||
// movement_dist, movement_time
|
// min_jump_dist={} | min_jump_time={} \
|
||||||
|
// | travel_dist={} | travel_time={} | angle={:?}",
|
||||||
|
// base.start_time,
|
||||||
|
// dists.lazy_jump_dist,
|
||||||
|
// dists.lazy_travel_dist,
|
||||||
|
// dists.min_jump_dist,
|
||||||
|
// dists.min_jump_time,
|
||||||
|
// dists.travel_dist,
|
||||||
|
// dists.travel_time,
|
||||||
|
// dists.angle,
|
||||||
// );
|
// );
|
||||||
// println!(
|
|
||||||
// "jump_dist={} | strain_time={} | angle={:?}",
|
|
||||||
// jump_dist, strain_time, angle
|
|
||||||
// );
|
|
||||||
// println!("--");
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
start_time,
|
||||||
|
delta_time,
|
||||||
base,
|
base,
|
||||||
clock_rate,
|
|
||||||
delta,
|
|
||||||
strain_time,
|
strain_time,
|
||||||
jump_dist,
|
dists,
|
||||||
angle,
|
idx,
|
||||||
movement_dist,
|
|
||||||
movement_time,
|
|
||||||
travel_dist,
|
|
||||||
travel_time,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compute_slider_cursor_pos(
|
pub(crate) fn opacity_at(&self, time: f64, hidden: bool) -> f64 {
|
||||||
prev: &mut OsuObject,
|
if time > self.base.start_time {
|
||||||
stack_offset: Pos2,
|
// * Consider a hitobject as being invisible when its start time is passed.
|
||||||
scaling_factor: f64,
|
// * In reality the hitobject will be visible beyond its start time up until its hittable window has passed,
|
||||||
clock_rate: f64,
|
// * but this is an approximation and such a case is unlikely to be hit where this function is used.
|
||||||
) -> (f64, f64) {
|
return 0.0;
|
||||||
match &mut prev.kind {
|
|
||||||
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => (0.0, 0.0),
|
|
||||||
OsuObjectKind::Slider {
|
|
||||||
lazy_end_pos,
|
|
||||||
nested_objects,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let mut travel_dist = 0.0;
|
|
||||||
let pos = prev.pos - stack_offset; // stack offset is ignored everywhere
|
|
||||||
let mut curr_cursor_pos = pos;
|
|
||||||
|
|
||||||
let last_idx = nested_objects.len() - 1;
|
|
||||||
|
|
||||||
for (i, nested) in nested_objects.iter_mut().enumerate() {
|
|
||||||
let mut curr_movement = nested.pos - curr_cursor_pos;
|
|
||||||
let mut curr_movement_len = scaling_factor * curr_movement.length() as f64;
|
|
||||||
|
|
||||||
// * Amount of movement required so that the cursor position needs to be updated.
|
|
||||||
let mut required_movement = ASSUMED_SLIDER_RADIUS as f64;
|
|
||||||
|
|
||||||
if i == last_idx {
|
|
||||||
// * The end of a slider has special aim rules due
|
|
||||||
// * to the relaxed time constraint on position.
|
|
||||||
// * There is both a lazy end position as well as the actual end slider position.
|
|
||||||
// * We assume the player takes the simpler movement.
|
|
||||||
// * For sliders that are circular, the lazy end position
|
|
||||||
// * may actually be farther away than the sliders true end.
|
|
||||||
// * This code is designed to prevent buffing situations
|
|
||||||
// * where lazy end is actually a less efficient movement.
|
|
||||||
let lazy_movement = *lazy_end_pos - curr_cursor_pos;
|
|
||||||
|
|
||||||
if lazy_movement.length() < curr_movement.length() {
|
|
||||||
curr_movement = lazy_movement;
|
|
||||||
}
|
|
||||||
|
|
||||||
curr_movement_len = scaling_factor * curr_movement.length() as f64;
|
|
||||||
} else if let NestedObjectKind::Repeat = nested.kind {
|
|
||||||
// * For a slider repeat, assume a tighter movement
|
|
||||||
// * threshold to better assess repeat sliders.
|
|
||||||
required_movement = NORMALIZED_RADIUS as f64;
|
|
||||||
}
|
|
||||||
|
|
||||||
if curr_movement_len > required_movement {
|
|
||||||
// * this finds the positional delta from the required
|
|
||||||
// * radius and the current position, and updates the
|
|
||||||
// * currCursorPosition accordingly, as well as rewarding distance.
|
|
||||||
curr_cursor_pos += curr_movement
|
|
||||||
* ((curr_movement_len - required_movement) / curr_movement_len) as f32;
|
|
||||||
|
|
||||||
curr_movement_len *=
|
|
||||||
(curr_movement_len - required_movement) / curr_movement_len;
|
|
||||||
|
|
||||||
travel_dist += curr_movement_len;
|
|
||||||
}
|
|
||||||
|
|
||||||
if i == last_idx {
|
|
||||||
*lazy_end_pos = curr_cursor_pos;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let repeats = nested_objects
|
|
||||||
.iter()
|
|
||||||
.filter(|nested| matches!(nested.kind, NestedObjectKind::Repeat))
|
|
||||||
.count();
|
|
||||||
|
|
||||||
// * Bonus for repeat sliders until a better per
|
|
||||||
// * nested object strain system can be achieved.
|
|
||||||
travel_dist *= (1.0 + repeats as f64 / 2.5).powf(1.0 / 2.5);
|
|
||||||
let prev_time = prev.time;
|
|
||||||
|
|
||||||
let lazy_travel_time = nested_objects
|
|
||||||
.last()
|
|
||||||
.map_or(0.0, |nested| nested.time - prev_time);
|
|
||||||
|
|
||||||
let travel_time = MIN_DELTA_TIME.max(lazy_travel_time / clock_rate);
|
|
||||||
|
|
||||||
(travel_dist, travel_time)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_movement_values(
|
let fade_in_start_time = self.base.start_time - self.base.time_preempt;
|
||||||
prev: &OsuObject,
|
let fade_in_duration = self.base.time_fade_in;
|
||||||
base_pos: Pos2,
|
|
||||||
jump_dist: f64,
|
|
||||||
strain_time: f64,
|
|
||||||
travel_time: f64,
|
|
||||||
scaling_factor: f32,
|
|
||||||
) -> (f64, f64) {
|
|
||||||
match &prev.kind {
|
|
||||||
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => (jump_dist, strain_time),
|
|
||||||
OsuObjectKind::Slider { end_pos, .. } => {
|
|
||||||
let movement_time = MIN_DELTA_TIME.max(strain_time - travel_time);
|
|
||||||
|
|
||||||
// * Jump distance from the slider tail to the next object,
|
if hidden {
|
||||||
// * as opposed to the lazy position of JumpDistance.
|
// * Taken from OsuModHidden.
|
||||||
let tail_jump_dist = (*end_pos - base_pos).length() * scaling_factor;
|
let fade_out_start_time =
|
||||||
|
self.base.start_time - self.base.time_preempt + self.base.time_fade_in;
|
||||||
|
const FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3;
|
||||||
|
let fade_out_duration = self.base.time_preempt * FADE_OUT_DURATION_MULTIPLIER;
|
||||||
|
|
||||||
// * For hitobjects which continue in the direction of the slider,
|
(((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0))
|
||||||
// * the player will normally follow through the slider,
|
.min(1.0 - ((time - fade_out_start_time) / fade_out_duration).clamp(0.0, 1.0))
|
||||||
// * such that they're not jumping from the lazy position but
|
} else {
|
||||||
// * rather from very close to (or the end of) the slider.
|
((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0)
|
||||||
// * In such cases, a leniency is applied by also considering the
|
}
|
||||||
// * jump distance from the tail of the slider,
|
}
|
||||||
// * and taking the minimum jump distance.
|
}
|
||||||
// * Additional distance is removed based on position of jump
|
|
||||||
// * relative to slider follow circle radius.
|
#[derive(Clone, Debug, Default)]
|
||||||
// * JumpDistance is the leniency distance beyond the assumed_slider_radius.
|
pub(crate) struct Distances {
|
||||||
// * tailJumpDistance is maximum_slider_radius since
|
pub(crate) lazy_jump_dist: f64,
|
||||||
// * the full distance of radial leniency is still possible.
|
pub(crate) lazy_travel_dist: f32,
|
||||||
let movement_dist = (jump_dist
|
pub(crate) min_jump_dist: f64,
|
||||||
- (MAXIMUM_SLIDER_RADIUS - ASSUMED_SLIDER_RADIUS) as f64)
|
pub(crate) min_jump_time: f64,
|
||||||
.min((tail_jump_dist - MAXIMUM_SLIDER_RADIUS) as f64)
|
pub(crate) travel_dist: f64,
|
||||||
.max(0.0);
|
pub(crate) travel_time: f64,
|
||||||
|
pub(crate) angle: Option<f64>,
|
||||||
(movement_dist, movement_time)
|
}
|
||||||
}
|
|
||||||
|
impl Distances {
|
||||||
|
pub(crate) const NORMALISED_RADIUS: f32 = 50.0;
|
||||||
|
|
||||||
|
const MAXIMUM_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 2.4;
|
||||||
|
const ASSUMED_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 1.8;
|
||||||
|
|
||||||
|
pub(crate) fn new(
|
||||||
|
base: &mut OsuObject,
|
||||||
|
last: &OsuObject,
|
||||||
|
last_last: Option<&OsuObject>,
|
||||||
|
clock_rate: f64,
|
||||||
|
strain_time: f64,
|
||||||
|
scaling_factor_: &ScalingFactor,
|
||||||
|
) -> Self {
|
||||||
|
let mut this = if let OsuObjectKind::Slider {
|
||||||
|
lazy_end_pos,
|
||||||
|
lazy_travel_time,
|
||||||
|
nested_objects,
|
||||||
|
..
|
||||||
|
} = &mut base.kind
|
||||||
|
{
|
||||||
|
let lazy_travel_dist = Self::compute_slider_cursor_pos(
|
||||||
|
base.pos,
|
||||||
|
base.start_time,
|
||||||
|
lazy_end_pos,
|
||||||
|
lazy_travel_time,
|
||||||
|
nested_objects,
|
||||||
|
scaling_factor_,
|
||||||
|
);
|
||||||
|
|
||||||
|
let repeat_count = nested_objects.iter().fold(0, |repeats, nested| {
|
||||||
|
repeats + matches!(nested.kind, NestedObjectKind::Repeat) as usize
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
// * Bonus for repeat sliders until a better per nested object strain system can be achieved.
|
||||||
|
travel_dist: (lazy_travel_dist
|
||||||
|
* (1.0 + repeat_count as f64 / 2.5).powf(1.0 / 2.5) as f32)
|
||||||
|
as f64,
|
||||||
|
travel_time: lazy_travel_time.max(OsuDifficultyObject::MIN_DELTA_TIME as f64),
|
||||||
|
lazy_travel_dist,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Self::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// * We don't need to calculate either angle or distance when
|
||||||
|
// * one of the last->curr objects is a spinner
|
||||||
|
if base.is_spinner() || last.is_spinner() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// * We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
||||||
|
let scaling_factor = scaling_factor_.factor;
|
||||||
|
|
||||||
|
let last_cursor_pos = Self::get_end_cursor_pos(last, scaling_factor_);
|
||||||
|
|
||||||
|
this.lazy_jump_dist =
|
||||||
|
(base.pos * scaling_factor - last_cursor_pos * scaling_factor).length() as f64;
|
||||||
|
this.min_jump_time = strain_time;
|
||||||
|
this.min_jump_dist = this.lazy_jump_dist;
|
||||||
|
|
||||||
|
if let OsuObjectKind::Slider {
|
||||||
|
end_pos,
|
||||||
|
lazy_travel_time,
|
||||||
|
..
|
||||||
|
} = &last.kind
|
||||||
|
{
|
||||||
|
let last_travel_dist =
|
||||||
|
(lazy_travel_time / clock_rate).max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
|
||||||
|
this.min_jump_time =
|
||||||
|
(strain_time - last_travel_dist).max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
|
||||||
|
|
||||||
|
// * There are two types of slider-to-object patterns to consider in order
|
||||||
|
// * to better approximate the real movement a player will take to jump between the hitobjects.
|
||||||
|
// *
|
||||||
|
// * 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
|
||||||
|
// *
|
||||||
|
// * <======o==> ← slider
|
||||||
|
// * | ← most natural jump path
|
||||||
|
// * o ← a follow-up hitcircle
|
||||||
|
// *
|
||||||
|
// * In this case the most natural jump path is approximated by LazyJumpDistance.
|
||||||
|
// *
|
||||||
|
// * 2. The flow pattern, where players follow through the slider to its
|
||||||
|
// * visual extent into the next hitobject.
|
||||||
|
// *
|
||||||
|
// * <======o==>---o
|
||||||
|
// * ↑
|
||||||
|
// * most natural jump path
|
||||||
|
// *
|
||||||
|
// * In this case the most natural jump path is better approximated by a new distance
|
||||||
|
// * called "tailJumpDistance" - the distance between the slider's tail and the next hitobject.
|
||||||
|
// *
|
||||||
|
// * Thus, the player is assumed to jump the minimum of these two distances in all cases.
|
||||||
|
|
||||||
|
let tail_jump_dist = (*end_pos - base.pos).length() * scaling_factor;
|
||||||
|
|
||||||
|
this.min_jump_dist = ((this.lazy_jump_dist
|
||||||
|
- (Self::MAXIMUM_SLIDER_RADIUS - Self::ASSUMED_SLIDER_RADIUS) as f64)
|
||||||
|
.min((tail_jump_dist - Self::MAXIMUM_SLIDER_RADIUS) as f64))
|
||||||
|
.max(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(last_last) = last_last.filter(|obj| !obj.is_spinner()) {
|
||||||
|
let last_last_cursor_pos = Self::get_end_cursor_pos(last_last, scaling_factor_);
|
||||||
|
|
||||||
|
let v1 = last_last_cursor_pos - last.pos;
|
||||||
|
let v2 = base.pos - last_cursor_pos;
|
||||||
|
|
||||||
|
let dot = v1.dot(v2) as f64;
|
||||||
|
let det = (v1.x * v2.y - v1.y * v2.x) as f64;
|
||||||
|
|
||||||
|
this.angle = Some(det.atan2(dot).abs());
|
||||||
|
}
|
||||||
|
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn compute_slider_cursor_pos(
|
||||||
|
stacked_pos: Pos2,
|
||||||
|
start_time: f64,
|
||||||
|
lazy_end_pos: &mut Pos2,
|
||||||
|
lazy_travel_time: &mut f64,
|
||||||
|
nested_objects: &[NestedObject],
|
||||||
|
scaling_factor_: &ScalingFactor,
|
||||||
|
) -> f32 {
|
||||||
|
let mut curr_cursor_pos = stacked_pos;
|
||||||
|
let scaling_factor = Self::NORMALISED_RADIUS as f64 / scaling_factor_.radius as f64;
|
||||||
|
|
||||||
|
let mut lazy_travel_dist: f32 = 0.0;
|
||||||
|
|
||||||
|
for (curr_movement_obj, i) in nested_objects.iter().zip(1..) {
|
||||||
|
let mut curr_movement = curr_movement_obj.pos - curr_cursor_pos;
|
||||||
|
let mut curr_movement_len = scaling_factor * curr_movement.length() as f64;
|
||||||
|
|
||||||
|
// * Amount of movement required so that the cursor position needs to be updated.
|
||||||
|
let mut required_movement = Self::ASSUMED_SLIDER_RADIUS as f64;
|
||||||
|
|
||||||
|
if i == nested_objects.len() {
|
||||||
|
// * The end of a slider has special aim rules due
|
||||||
|
// * to the relaxed time constraint on position.
|
||||||
|
// * There is both a lazy end position as well as the actual end slider position.
|
||||||
|
// * We assume the player takes the simpler movement.
|
||||||
|
// * For sliders that are circular, the lazy end position
|
||||||
|
// * may actually be farther away than the sliders true end.
|
||||||
|
// * This code is designed to prevent buffing situations
|
||||||
|
// * where lazy end is actually a less efficient movement.
|
||||||
|
let lazy_movement = *lazy_end_pos - curr_cursor_pos;
|
||||||
|
|
||||||
|
if lazy_movement.length() < curr_movement.length() {
|
||||||
|
curr_movement = lazy_movement;
|
||||||
|
}
|
||||||
|
|
||||||
|
curr_movement_len = scaling_factor * curr_movement.length() as f64;
|
||||||
|
} else if let NestedObjectKind::Repeat = curr_movement_obj.kind {
|
||||||
|
// * For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
|
||||||
|
required_movement = Self::NORMALISED_RADIUS as f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
if curr_movement_len > required_movement {
|
||||||
|
// * this finds the positional delta from the required radius and the current position, and updates the currCursorPosition accordingly, as well as rewarding distance.
|
||||||
|
curr_cursor_pos += curr_movement
|
||||||
|
* ((curr_movement_len - required_movement) / curr_movement_len) as f32;
|
||||||
|
curr_movement_len *= (curr_movement_len - required_movement) / curr_movement_len;
|
||||||
|
lazy_travel_dist += curr_movement_len as f32;
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == nested_objects.len() {
|
||||||
|
*lazy_end_pos = curr_cursor_pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*lazy_travel_time = nested_objects
|
||||||
|
.last()
|
||||||
|
.map_or(0.0, |nested| nested.start_time - start_time);
|
||||||
|
|
||||||
|
lazy_travel_dist
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_end_cursor_pos(hit_object: &OsuObject, scaling_factor: &ScalingFactor) -> Pos2 {
|
||||||
|
if hit_object.is_slider() {
|
||||||
|
let stack_offset = scaling_factor.stack_offset(hit_object.stack_height);
|
||||||
|
|
||||||
|
hit_object.lazy_end_pos(stack_offset)
|
||||||
|
} else {
|
||||||
|
hit_object.pos
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+181
-132
@@ -1,16 +1,19 @@
|
|||||||
use std::{iter, mem, vec::IntoIter};
|
use std::{
|
||||||
|
fmt::{Debug, Formatter, Result as FmtResult},
|
||||||
use crate::{
|
mem,
|
||||||
curve::CurveBuffers, osu::difficulty_object::DifficultyObject, parse::Pos2, Beatmap, Mods,
|
vec::IntoIter,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::{curve::CurveBuffers, Beatmap, Mods};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
calculate_star_rating, old_stacking,
|
create_skills,
|
||||||
|
difficulty_object::{Distances, OsuDifficultyObject},
|
||||||
|
old_stacking,
|
||||||
osu_object::{ObjectParameters, OsuObject, OsuObjectKind},
|
osu_object::{ObjectParameters, OsuObject, OsuObjectKind},
|
||||||
scaling_factor::ScalingFactor,
|
scaling_factor::ScalingFactor,
|
||||||
skill::{Skill, Skills},
|
skills::{Aim, Flashlight, Skill, Speed},
|
||||||
slider_state::SliderState,
|
stacking, OsuDifficultyAttributes, DIFFICULTY_MULTIPLIER, PERFORMANCE_BASE_MULTIPLIER,
|
||||||
stacking, OsuDifficultyAttributes, DIFFICULTY_MULTIPLIER, SECTION_LEN,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Gradually calculate the difficulty attributes of an osu!standard map.
|
/// Gradually calculate the difficulty attributes of an osu!standard map.
|
||||||
@@ -43,39 +46,47 @@ use super::{
|
|||||||
/// // ...
|
/// // ...
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct OsuGradualDifficultyAttributes {
|
pub struct OsuGradualDifficultyAttributes {
|
||||||
pub(crate) idx: usize,
|
pub(crate) idx: usize,
|
||||||
|
mods: u32,
|
||||||
attributes: OsuDifficultyAttributes,
|
attributes: OsuDifficultyAttributes,
|
||||||
clock_rate: f64,
|
hit_objects: Vec<OsuObject>,
|
||||||
hit_objects: OsuObjectIter,
|
diff_objects: Vec<OsuDifficultyObject<'static>>,
|
||||||
skills: Skills,
|
skills: [Box<dyn Skill>; 4],
|
||||||
prev_prev: Option<OsuObject>,
|
hit_window: f64,
|
||||||
prev: OsuObject,
|
}
|
||||||
curr_section_end: f64,
|
|
||||||
strain_peak_buf: Vec<f64>,
|
impl Debug for OsuGradualDifficultyAttributes {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||||
|
f.debug_struct("OsuGradualDifficultyAttributes")
|
||||||
|
.field("idx", &self.idx)
|
||||||
|
.field("attributes", &self.attributes)
|
||||||
|
.field("hit_objects", &self.hit_objects)
|
||||||
|
.field("skills", &"<cannot be displayed>")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OsuGradualDifficultyAttributes {
|
impl OsuGradualDifficultyAttributes {
|
||||||
/// Create a new difficulty attributes iterator for osu!standard maps.
|
/// Create a new difficulty attributes iterator for osu!standard maps.
|
||||||
pub fn new(map: &Beatmap, mods: u32) -> Self {
|
pub fn new(map: &Beatmap, mods: u32) -> Self {
|
||||||
let map_attributes = map.attributes().mods(mods).build();
|
let clock_rate = mods.clock_rate();
|
||||||
let hit_window = map_attributes.hit_windows.od;
|
let map_attrs = map.attributes().mods(mods).build();
|
||||||
let time_preempt = map_attributes.hit_windows.ar;
|
let scaling_factor = ScalingFactor::new(map_attrs.cs);
|
||||||
let hr = mods.hr();
|
let hr = mods.hr();
|
||||||
let scaling_factor = ScalingFactor::new(map_attributes.cs);
|
let time_preempt = map_attrs.hit_windows.ar;
|
||||||
|
let hit_window = 2.0 * map_attrs.hit_windows.od;
|
||||||
|
|
||||||
let mut attributes = OsuDifficultyAttributes {
|
let mut attrs = OsuDifficultyAttributes {
|
||||||
ar: map_attributes.ar,
|
ar: map_attrs.ar,
|
||||||
hp: map_attributes.hp,
|
hp: map_attrs.hp,
|
||||||
od: map_attributes.od,
|
od: map_attrs.od,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut params = ObjectParameters {
|
let mut params = ObjectParameters {
|
||||||
map,
|
map,
|
||||||
attributes: &mut attributes,
|
attributes: &mut attrs,
|
||||||
slider_state: SliderState::new(map),
|
|
||||||
ticks: Vec::new(),
|
ticks: Vec::new(),
|
||||||
curve_bufs: CurveBuffers::default(),
|
curve_bufs: CurveBuffers::default(),
|
||||||
};
|
};
|
||||||
@@ -88,10 +99,10 @@ impl OsuGradualDifficultyAttributes {
|
|||||||
let mut hit_objects = Vec::with_capacity(map.hit_objects.len());
|
let mut hit_objects = Vec::with_capacity(map.hit_objects.len());
|
||||||
hit_objects.extend(hit_objects_iter);
|
hit_objects.extend(hit_objects_iter);
|
||||||
|
|
||||||
attributes.n_circles = 0;
|
attrs.n_circles = 0;
|
||||||
attributes.n_sliders = 0;
|
attrs.n_sliders = 0;
|
||||||
attributes.n_spinners = 0;
|
attrs.n_spinners = 0;
|
||||||
attributes.max_combo = 0;
|
attrs.max_combo = 0;
|
||||||
|
|
||||||
let stack_threshold = time_preempt * map.stack_leniency as f64;
|
let stack_threshold = time_preempt * map.stack_leniency as f64;
|
||||||
|
|
||||||
@@ -101,162 +112,200 @@ impl OsuGradualDifficultyAttributes {
|
|||||||
old_stacking(&mut hit_objects, stack_threshold);
|
old_stacking(&mut hit_objects, stack_threshold);
|
||||||
}
|
}
|
||||||
|
|
||||||
let skills = Skills::new(hit_window, mods.rx(), scaling_factor.radius(), mods.fl());
|
let mut hit_objects_iter = hit_objects.iter_mut().map(|h| {
|
||||||
|
let stack_offset = scaling_factor.stack_offset(h.stack_height);
|
||||||
|
h.pos += stack_offset;
|
||||||
|
|
||||||
let hit_objects = OsuObjectIter {
|
h
|
||||||
hit_objects: hit_objects.into_iter(),
|
});
|
||||||
scaling_factor,
|
|
||||||
|
let skills = create_skills(mods, scaling_factor.radius);
|
||||||
|
|
||||||
|
let last = match hit_objects_iter.next() {
|
||||||
|
Some(prev) => prev,
|
||||||
|
None => {
|
||||||
|
return Self {
|
||||||
|
idx: 0,
|
||||||
|
mods,
|
||||||
|
attributes: attrs,
|
||||||
|
hit_objects: Vec::new(),
|
||||||
|
diff_objects: Vec::new(),
|
||||||
|
skills,
|
||||||
|
hit_window,
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let prev_prev = None;
|
let mut last_last = None;
|
||||||
|
|
||||||
let prev = OsuObject {
|
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
|
||||||
time: 0.0,
|
if let OsuObjectKind::Slider {
|
||||||
pos: Pos2::zero(),
|
lazy_travel_time,
|
||||||
stack_height: 0.0,
|
lazy_end_pos,
|
||||||
kind: OsuObjectKind::Circle,
|
nested_objects,
|
||||||
};
|
..
|
||||||
|
} = &mut last.kind
|
||||||
|
{
|
||||||
|
Distances::compute_slider_cursor_pos(
|
||||||
|
last.pos,
|
||||||
|
last.start_time,
|
||||||
|
lazy_end_pos,
|
||||||
|
lazy_travel_time,
|
||||||
|
nested_objects,
|
||||||
|
&scaling_factor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last = &*last;
|
||||||
|
let mut diff_objects = Vec::with_capacity(map.hit_objects.len().saturating_sub(2));
|
||||||
|
|
||||||
|
for (i, curr) in hit_objects_iter.enumerate() {
|
||||||
|
let delta_time = (curr.start_time - last.start_time) / clock_rate;
|
||||||
|
|
||||||
|
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
||||||
|
let strain_time = delta_time.max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
|
||||||
|
|
||||||
|
let dists = Distances::new(
|
||||||
|
curr,
|
||||||
|
last,
|
||||||
|
last_last,
|
||||||
|
clock_rate,
|
||||||
|
strain_time,
|
||||||
|
&scaling_factor,
|
||||||
|
);
|
||||||
|
|
||||||
|
let diff_obj = OsuDifficultyObject::new(curr, last, clock_rate, i, dists);
|
||||||
|
diff_objects.push(diff_obj);
|
||||||
|
|
||||||
|
last_last = Some(last);
|
||||||
|
last = &*curr;
|
||||||
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
idx: 0,
|
idx: 0,
|
||||||
attributes,
|
mods,
|
||||||
clock_rate: map_attributes.clock_rate,
|
attributes: attrs,
|
||||||
|
diff_objects: extend_lifetime(diff_objects),
|
||||||
hit_objects,
|
hit_objects,
|
||||||
skills,
|
skills,
|
||||||
curr_section_end: 0.0,
|
hit_window,
|
||||||
prev_prev,
|
|
||||||
prev,
|
|
||||||
strain_peak_buf: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extend_lifetime(
|
||||||
|
diff_objects: Vec<OsuDifficultyObject<'_>>,
|
||||||
|
) -> Vec<OsuDifficultyObject<'static>> {
|
||||||
|
// SAFETY: Owned values of the references will be contained
|
||||||
|
// in the same struct and hence live just as long as this vec.
|
||||||
|
unsafe { mem::transmute(diff_objects) }
|
||||||
|
}
|
||||||
|
|
||||||
impl Iterator for OsuGradualDifficultyAttributes {
|
impl Iterator for OsuGradualDifficultyAttributes {
|
||||||
type Item = OsuDifficultyAttributes;
|
type Item = OsuDifficultyAttributes;
|
||||||
|
|
||||||
fn next(&mut self) -> Option<Self::Item> {
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
let curr = self.hit_objects.next()?;
|
let curr = self.diff_objects.get(self.idx)?;
|
||||||
self.attributes.max_combo += 1;
|
|
||||||
|
|
||||||
match &curr.kind {
|
|
||||||
OsuObjectKind::Circle => self.attributes.n_circles += 1,
|
|
||||||
OsuObjectKind::Slider { nested_objects, .. } => {
|
|
||||||
self.attributes.max_combo += nested_objects.len();
|
|
||||||
self.attributes.n_sliders += 1
|
|
||||||
}
|
|
||||||
OsuObjectKind::Spinner { .. } => self.attributes.n_spinners += 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.idx += 1;
|
self.idx += 1;
|
||||||
|
|
||||||
if self.idx == 1 {
|
for skill in self.skills.iter_mut() {
|
||||||
self.prev = curr;
|
skill.process(curr, &self.diff_objects, self.hit_window);
|
||||||
self.curr_section_end =
|
|
||||||
(self.prev.time / self.clock_rate / SECTION_LEN).ceil() * SECTION_LEN;
|
|
||||||
|
|
||||||
return Some(self.attributes.clone());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let h = DifficultyObject::new(
|
let mut attrs = self.attributes.clone();
|
||||||
&curr,
|
|
||||||
&mut self.prev,
|
|
||||||
self.prev_prev.as_ref(),
|
|
||||||
&self.hit_objects.scaling_factor,
|
|
||||||
self.clock_rate,
|
|
||||||
);
|
|
||||||
|
|
||||||
let base_time = h.base.time / self.clock_rate;
|
attrs.max_combo += 1;
|
||||||
|
|
||||||
if self.idx == 2 {
|
match &curr.base.kind {
|
||||||
while base_time > self.curr_section_end {
|
OsuObjectKind::Circle => attrs.n_circles += 1,
|
||||||
self.skills.start_new_section_from(self.curr_section_end);
|
OsuObjectKind::Slider { nested_objects, .. } => {
|
||||||
self.curr_section_end += SECTION_LEN;
|
attrs.n_sliders += 1;
|
||||||
}
|
attrs.max_combo += nested_objects.len();
|
||||||
} else {
|
|
||||||
while base_time > self.curr_section_end {
|
|
||||||
self.skills
|
|
||||||
.save_peak_and_start_new_section(self.curr_section_end);
|
|
||||||
self.curr_section_end += SECTION_LEN;
|
|
||||||
}
|
}
|
||||||
|
OsuObjectKind::Spinner { .. } => attrs.n_spinners += 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.skills.process(&h);
|
let [aim, aim_no_sliders, speed, flashlight] = &self.skills;
|
||||||
self.prev_prev = Some(mem::replace(&mut self.prev, curr));
|
|
||||||
|
|
||||||
let missing = self.skills.aim().strain_peaks.len() + 1 - self.strain_peak_buf.len();
|
let mut aim = aim.as_any().downcast_ref::<Aim>().unwrap().clone();
|
||||||
self.strain_peak_buf.extend(iter::repeat(0.0).take(missing));
|
|
||||||
|
|
||||||
let aim_rating = {
|
let mut aim_no_sliders = aim_no_sliders
|
||||||
let aim = self.skills.aim();
|
.as_any()
|
||||||
self.strain_peak_buf[..aim.strain_peaks.len()].copy_from_slice(&aim.strain_peaks);
|
.downcast_ref::<Aim>()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
|
||||||
if let Some(last) = self.strain_peak_buf.last_mut() {
|
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
*last = aim.curr_section_peak;
|
let aim_rating_no_sliders =
|
||||||
}
|
aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
|
|
||||||
Skill::difficulty_value(&mut self.strain_peak_buf, aim).sqrt() * DIFFICULTY_MULTIPLIER
|
let mut speed = speed.as_any().downcast_ref::<Speed>().unwrap().clone();
|
||||||
};
|
let speed_notes = speed.relevant_note_count();
|
||||||
|
let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
|
|
||||||
|
let mut flashlight = flashlight
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<Flashlight>()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
|
|
||||||
let slider_factor = if aim_rating > 0.0 {
|
let slider_factor = if aim_rating > 0.0 {
|
||||||
let aim_no_sliders = self.skills.aim_no_sliders();
|
|
||||||
self.strain_peak_buf[..aim_no_sliders.strain_peaks.len()]
|
|
||||||
.copy_from_slice(&aim_no_sliders.strain_peaks);
|
|
||||||
|
|
||||||
if let Some(last) = self.strain_peak_buf.last_mut() {
|
|
||||||
*last = aim_no_sliders.curr_section_peak;
|
|
||||||
}
|
|
||||||
|
|
||||||
let aim_rating_no_sliders =
|
|
||||||
Skill::difficulty_value(&mut self.strain_peak_buf, aim_no_sliders).sqrt()
|
|
||||||
* DIFFICULTY_MULTIPLIER;
|
|
||||||
|
|
||||||
aim_rating_no_sliders / aim_rating
|
aim_rating_no_sliders / aim_rating
|
||||||
} else {
|
} else {
|
||||||
1.0
|
1.0
|
||||||
};
|
};
|
||||||
|
|
||||||
let (speed, flashlight) = self.skills.speed_flashlight();
|
if self.mods.td() {
|
||||||
|
aim_rating = aim_rating.powf(0.8);
|
||||||
|
flashlight_rating = flashlight_rating.powf(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
let speed_rating = if let Some(speed) = speed {
|
if self.mods.rx() {
|
||||||
self.strain_peak_buf[..speed.strain_peaks.len()].copy_from_slice(&speed.strain_peaks);
|
aim_rating *= 0.9;
|
||||||
|
speed_rating = 0.0;
|
||||||
|
flashlight_rating *= 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(last) = self.strain_peak_buf.last_mut() {
|
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||||
*last = speed.curr_section_peak;
|
let base_speed_performance =
|
||||||
}
|
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||||
|
|
||||||
Skill::difficulty_value(&mut self.strain_peak_buf, speed).sqrt() * DIFFICULTY_MULTIPLIER
|
let base_flashlight_performance = if self.mods.fl() {
|
||||||
|
flashlight_rating * flashlight_rating * 25.0
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
|
|
||||||
let flashlight_rating = if let Some(flashlight) = flashlight {
|
let base_performance = ((base_aim_performance).powf(1.1)
|
||||||
self.strain_peak_buf[..flashlight.strain_peaks.len()]
|
+ (base_speed_performance).powf(1.1)
|
||||||
.copy_from_slice(&flashlight.strain_peaks);
|
+ (base_flashlight_performance).powf(1.1))
|
||||||
|
.powf(1.0 / 1.1);
|
||||||
|
|
||||||
if let Some(last) = self.strain_peak_buf.last_mut() {
|
let star_rating = if base_performance > 0.00001 {
|
||||||
*last = flashlight.curr_section_peak;
|
PERFORMANCE_BASE_MULTIPLIER.cbrt()
|
||||||
}
|
* 0.027
|
||||||
|
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
|
||||||
Skill::difficulty_value(&mut self.strain_peak_buf, flashlight).sqrt()
|
|
||||||
* DIFFICULTY_MULTIPLIER
|
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
|
|
||||||
let star_rating = calculate_star_rating(aim_rating, speed_rating, flashlight_rating);
|
attrs.aim = aim_rating;
|
||||||
|
attrs.speed = speed_rating;
|
||||||
|
attrs.flashlight = flashlight_rating;
|
||||||
|
attrs.slider_factor = slider_factor;
|
||||||
|
attrs.stars = star_rating;
|
||||||
|
attrs.speed_note_count = speed_notes;
|
||||||
|
|
||||||
self.attributes.aim_strain = aim_rating;
|
Some(attrs)
|
||||||
self.attributes.speed_strain = speed_rating;
|
|
||||||
self.attributes.flashlight_rating = flashlight_rating;
|
|
||||||
self.attributes.slider_factor = slider_factor;
|
|
||||||
self.attributes.stars = star_rating;
|
|
||||||
|
|
||||||
Some(self.attributes.clone())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||||
self.hit_objects.size_hint()
|
let len = self.hit_objects.len() - self.idx;
|
||||||
|
|
||||||
|
(len, Some(len))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ impl OsuScoreState {
|
|||||||
/// // attempting to process further objects will return `None`.
|
/// // attempting to process further objects will return `None`.
|
||||||
/// assert!(gradual_perf.process_next_object(state).is_none());
|
/// assert!(gradual_perf.process_next_object(state).is_none());
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Debug)]
|
||||||
pub struct OsuGradualPerformanceAttributes<'map> {
|
pub struct OsuGradualPerformanceAttributes<'map> {
|
||||||
difficulty: OsuGradualDifficultyAttributes,
|
difficulty: OsuGradualDifficultyAttributes,
|
||||||
performance: OsuPP<'map>,
|
performance: OsuPP<'map>,
|
||||||
|
|||||||
+136
-150
@@ -4,30 +4,26 @@ mod gradual_performance;
|
|||||||
mod osu_object;
|
mod osu_object;
|
||||||
mod pp;
|
mod pp;
|
||||||
mod scaling_factor;
|
mod scaling_factor;
|
||||||
mod skill;
|
mod skills;
|
||||||
mod skill_kind;
|
|
||||||
mod slider_state;
|
|
||||||
|
|
||||||
use std::mem;
|
|
||||||
|
|
||||||
use difficulty_object::DifficultyObject;
|
|
||||||
pub use gradual_difficulty::*;
|
|
||||||
pub use gradual_performance::*;
|
|
||||||
use osu_object::{ObjectParameters, OsuObject};
|
|
||||||
pub use pp::*;
|
|
||||||
use scaling_factor::ScalingFactor;
|
|
||||||
use skill::Skill;
|
|
||||||
use skill_kind::SkillKind;
|
|
||||||
use slider_state::SliderState;
|
|
||||||
|
|
||||||
use crate::{curve::CurveBuffers, AnyStars, Beatmap, GameMode, Mods};
|
use crate::{curve::CurveBuffers, AnyStars, Beatmap, GameMode, Mods};
|
||||||
|
|
||||||
use self::skill::Skills;
|
use self::{
|
||||||
|
difficulty_object::{Distances, OsuDifficultyObject},
|
||||||
|
osu_object::{ObjectParameters, OsuObject, OsuObjectKind},
|
||||||
|
scaling_factor::ScalingFactor,
|
||||||
|
skills::{Aim, Flashlight, Skill, Speed},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use self::{gradual_difficulty::*, gradual_performance::*, pp::*};
|
||||||
|
|
||||||
const SECTION_LEN: f64 = 400.0;
|
const SECTION_LEN: f64 = 400.0;
|
||||||
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
|
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
|
||||||
const NORMALIZED_RADIUS: f32 = 50.0; // * diameter of 100; easier mental maths.
|
// * Change radius to 50 to make 100 the diameter. Easier for mental maths.
|
||||||
|
const NORMALIZED_RADIUS: f32 = 50.0;
|
||||||
const STACK_DISTANCE: f32 = 3.0;
|
const STACK_DISTANCE: f32 = 3.0;
|
||||||
|
// * This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
|
||||||
|
const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14;
|
||||||
|
|
||||||
/// Difficulty calculator on osu!standard maps.
|
/// Difficulty calculator on osu!standard maps.
|
||||||
///
|
///
|
||||||
@@ -113,60 +109,76 @@ impl<'map> OsuStars<'map> {
|
|||||||
/// Calculate all difficulty related values, including stars.
|
/// Calculate all difficulty related values, including stars.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn calculate(self) -> OsuDifficultyAttributes {
|
pub fn calculate(self) -> OsuDifficultyAttributes {
|
||||||
let (mut skills, mut attributes) = calculate_skills(self);
|
let mods = self.mods;
|
||||||
|
|
||||||
let aim_rating = {
|
let (skills, mut attrs) = calculate_skills(self);
|
||||||
let aim = skills.aim();
|
|
||||||
let mut aim_strains = mem::take(&mut aim.strain_peaks);
|
|
||||||
|
|
||||||
Skill::difficulty_value(&mut aim_strains, aim).sqrt() * DIFFICULTY_MULTIPLIER
|
let [mut aim, mut aim_no_sliders, mut speed, mut flashlight] = skills;
|
||||||
};
|
|
||||||
|
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
|
let aim_rating_no_sliders =
|
||||||
|
aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
|
|
||||||
|
let (mut speed_rating, speed_notes) =
|
||||||
|
if let Some(speed) = speed.as_any_mut().downcast_mut::<Speed>() {
|
||||||
|
let notes = speed.relevant_note_count();
|
||||||
|
let rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
|
|
||||||
|
(rating, notes)
|
||||||
|
} else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
|
|
||||||
let slider_factor = if aim_rating > 0.0 {
|
let slider_factor = if aim_rating > 0.0 {
|
||||||
let aim_no_sliders = skills.aim_no_sliders();
|
|
||||||
|
|
||||||
let mut aim_strains_no_sliders = mem::take(&mut aim_no_sliders.strain_peaks);
|
|
||||||
let aim_rating_no_sliders =
|
|
||||||
Skill::difficulty_value(&mut aim_strains_no_sliders, aim_no_sliders).sqrt()
|
|
||||||
* DIFFICULTY_MULTIPLIER;
|
|
||||||
|
|
||||||
aim_rating_no_sliders / aim_rating
|
aim_rating_no_sliders / aim_rating
|
||||||
} else {
|
} else {
|
||||||
1.0
|
1.0
|
||||||
};
|
};
|
||||||
|
|
||||||
let (speed, flashlight) = skills.speed_flashlight();
|
if mods.td() {
|
||||||
|
aim_rating = aim_rating.powf(0.8);
|
||||||
|
flashlight_rating = flashlight_rating.powf(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
let speed_rating = if let Some(speed) = speed {
|
if mods.rx() {
|
||||||
let mut speed_strains = mem::take(&mut speed.strain_peaks);
|
aim_rating *= 0.9;
|
||||||
|
speed_rating = 0.0;
|
||||||
|
flashlight_rating *= 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
Skill::difficulty_value(&mut speed_strains, speed).sqrt() * DIFFICULTY_MULTIPLIER
|
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||||
|
let base_speed_performance =
|
||||||
|
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||||
|
|
||||||
|
let base_flashlight_performance = if mods.fl() {
|
||||||
|
flashlight_rating * flashlight_rating * 25.0
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
|
|
||||||
let flashlight_rating = if let Some(flashlight) = flashlight {
|
let base_performance = ((base_aim_performance).powf(1.1)
|
||||||
let mut flashlight_strains = mem::take(&mut flashlight.strain_peaks);
|
+ (base_speed_performance).powf(1.1)
|
||||||
|
+ (base_flashlight_performance).powf(1.1))
|
||||||
|
.powf(1.0 / 1.1);
|
||||||
|
|
||||||
Skill::difficulty_value(&mut flashlight_strains, flashlight).sqrt()
|
let star_rating = if base_performance > 0.00001 {
|
||||||
* DIFFICULTY_MULTIPLIER
|
PERFORMANCE_BASE_MULTIPLIER.cbrt()
|
||||||
|
* 0.027
|
||||||
|
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
|
|
||||||
let star_rating = if attributes.max_combo == 0 {
|
attrs.aim = aim_rating;
|
||||||
0.0
|
attrs.speed = speed_rating;
|
||||||
} else {
|
attrs.flashlight = flashlight_rating;
|
||||||
calculate_star_rating(aim_rating, speed_rating, flashlight_rating)
|
attrs.slider_factor = slider_factor;
|
||||||
};
|
attrs.stars = star_rating;
|
||||||
|
attrs.speed_note_count = speed_notes;
|
||||||
|
|
||||||
attributes.aim_strain = aim_rating;
|
attrs
|
||||||
attributes.speed_strain = speed_rating;
|
|
||||||
attributes.flashlight_rating = flashlight_rating;
|
|
||||||
attributes.slider_factor = slider_factor;
|
|
||||||
attributes.stars = star_rating;
|
|
||||||
|
|
||||||
attributes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate the skill strains.
|
/// Calculate the skill strains.
|
||||||
@@ -175,32 +187,21 @@ impl<'map> OsuStars<'map> {
|
|||||||
#[inline]
|
#[inline]
|
||||||
pub fn strains(self) -> OsuStrains {
|
pub fn strains(self) -> OsuStrains {
|
||||||
let clock_rate = self.clock_rate.unwrap_or_else(|| self.mods.clock_rate());
|
let clock_rate = self.clock_rate.unwrap_or_else(|| self.mods.clock_rate());
|
||||||
let (mut skills, _) = calculate_skills(self);
|
let (skills, _) = calculate_skills(self);
|
||||||
|
|
||||||
let len = skills.aim().strain_peaks.len();
|
let [mut aim, mut aim_no_sliders, mut speed, mut flashlight] = skills;
|
||||||
let (speed, flashlight) = skills.speed_flashlight();
|
|
||||||
|
|
||||||
let speed = speed.map_or_else(
|
|
||||||
|| vec![0.0; len],
|
|
||||||
|skill| mem::take(&mut skill.strain_peaks),
|
|
||||||
);
|
|
||||||
|
|
||||||
let flashlight = flashlight.map_or_else(
|
|
||||||
|| vec![0.0; len],
|
|
||||||
|skill| mem::take(&mut skill.strain_peaks),
|
|
||||||
);
|
|
||||||
|
|
||||||
OsuStrains {
|
OsuStrains {
|
||||||
section_len: SECTION_LEN * clock_rate,
|
section_len: SECTION_LEN * clock_rate,
|
||||||
aim: mem::take(&mut skills.aim().strain_peaks),
|
aim: aim.take_strain_peaks(),
|
||||||
aim_no_sliders: mem::take(&mut skills.aim_no_sliders().strain_peaks),
|
aim_no_sliders: aim_no_sliders.take_strain_peaks(),
|
||||||
speed,
|
speed: speed.take_strain_peaks(),
|
||||||
flashlight,
|
flashlight: flashlight.take_strain_peaks(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The result of calculating the strains on a osu!taiko map.
|
/// The result of calculating the strains on a osu! map.
|
||||||
/// Suitable to plot the difficulty of a map over time.
|
/// Suitable to plot the difficulty of a map over time.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct OsuStrains {
|
pub struct OsuStrains {
|
||||||
@@ -225,36 +226,7 @@ impl OsuStrains {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_star_rating(aim_rating: f64, speed_rating: f64, flashlight_rating: f64) -> f64 {
|
fn calculate_skills(params: OsuStars<'_>) -> ([Box<dyn Skill>; 4], OsuDifficultyAttributes) {
|
||||||
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 = flashlight_rating * flashlight_rating * 25.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);
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
|
|
||||||
let OsuStars {
|
let OsuStars {
|
||||||
map,
|
map,
|
||||||
mods,
|
mods,
|
||||||
@@ -269,7 +241,7 @@ fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
|
|||||||
let scaling_factor = ScalingFactor::new(map_attributes.cs);
|
let scaling_factor = ScalingFactor::new(map_attributes.cs);
|
||||||
let hr = mods.hr();
|
let hr = mods.hr();
|
||||||
let time_preempt = map_attributes.hit_windows.ar;
|
let time_preempt = map_attributes.hit_windows.ar;
|
||||||
let hit_window = map_attributes.hit_windows.od;
|
let hit_window = 2.0 * map_attributes.hit_windows.od;
|
||||||
|
|
||||||
let mut attributes = OsuDifficultyAttributes {
|
let mut attributes = OsuDifficultyAttributes {
|
||||||
ar: map_attributes.ar,
|
ar: map_attributes.ar,
|
||||||
@@ -281,7 +253,6 @@ fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
|
|||||||
let mut params = ObjectParameters {
|
let mut params = ObjectParameters {
|
||||||
map,
|
map,
|
||||||
attributes: &mut attributes,
|
attributes: &mut attributes,
|
||||||
slider_state: SliderState::new(map),
|
|
||||||
ticks: Vec::new(),
|
ticks: Vec::new(),
|
||||||
curve_bufs: CurveBuffers::default(),
|
curve_bufs: CurveBuffers::default(),
|
||||||
};
|
};
|
||||||
@@ -303,67 +274,70 @@ fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
|
|||||||
old_stacking(&mut hit_objects, stack_threshold);
|
old_stacking(&mut hit_objects, stack_threshold);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut hit_objects = hit_objects.into_iter().map(|mut h| {
|
let mut hit_objects = hit_objects.iter_mut().map(|h| {
|
||||||
let stack_offset = scaling_factor.stack_offset(h.stack_height);
|
let stack_offset = scaling_factor.stack_offset(h.stack_height);
|
||||||
h.pos += stack_offset;
|
h.pos += stack_offset;
|
||||||
|
|
||||||
h
|
h
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut skills = Skills::new(hit_window, mods.rx(), scaling_factor.radius(), mods.fl());
|
let mut skills = create_skills(mods, scaling_factor.radius);
|
||||||
|
|
||||||
let (mut prev, curr) = match (hit_objects.next(), hit_objects.next()) {
|
let last = match hit_objects.next() {
|
||||||
(Some(prev), Some(curr)) => (prev, curr),
|
Some(prev) => prev,
|
||||||
(Some(_), None) | (None, None) => return (skills, attributes),
|
None => return (skills, attributes),
|
||||||
(None, Some(_)) => unreachable!(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut prev_prev = None;
|
let mut last_last = None;
|
||||||
|
|
||||||
// First object has no predecessor and thus no strain, handle distinctly
|
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
|
||||||
let mut curr_section_end = (prev.time / clock_rate / SECTION_LEN).ceil() * SECTION_LEN;
|
if let OsuObjectKind::Slider {
|
||||||
|
lazy_travel_time,
|
||||||
// Handle second object separately to remove later if-branching
|
lazy_end_pos,
|
||||||
let h = DifficultyObject::new(
|
nested_objects,
|
||||||
&curr,
|
..
|
||||||
&mut prev,
|
} = &mut last.kind
|
||||||
prev_prev.as_ref(),
|
{
|
||||||
&scaling_factor,
|
Distances::compute_slider_cursor_pos(
|
||||||
clock_rate,
|
last.pos,
|
||||||
);
|
last.start_time,
|
||||||
|
lazy_end_pos,
|
||||||
let base_time = h.base.time / clock_rate;
|
lazy_travel_time,
|
||||||
|
nested_objects,
|
||||||
while base_time > curr_section_end {
|
&scaling_factor,
|
||||||
skills.start_new_section_from(curr_section_end);
|
);
|
||||||
curr_section_end += SECTION_LEN;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
skills.process(&h);
|
let mut last = &*last;
|
||||||
prev_prev = Some(mem::replace(&mut prev, curr));
|
let mut diff_objects = Vec::with_capacity(hit_objects.len().saturating_sub(2));
|
||||||
|
|
||||||
// Handle all other objects
|
for (i, curr) in hit_objects.enumerate() {
|
||||||
for curr in hit_objects {
|
let delta_time = (curr.start_time - last.start_time) / clock_rate;
|
||||||
let h = DifficultyObject::new(
|
|
||||||
&curr,
|
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
||||||
&mut prev,
|
let strain_time = delta_time.max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
|
||||||
prev_prev.as_ref(),
|
|
||||||
&scaling_factor,
|
let dists = Distances::new(
|
||||||
|
curr,
|
||||||
|
last,
|
||||||
|
last_last,
|
||||||
clock_rate,
|
clock_rate,
|
||||||
|
strain_time,
|
||||||
|
&scaling_factor,
|
||||||
);
|
);
|
||||||
|
|
||||||
let base_time = h.base.time / clock_rate;
|
let diff_obj = OsuDifficultyObject::new(curr, last, clock_rate, i, dists);
|
||||||
|
diff_objects.push(diff_obj);
|
||||||
|
|
||||||
while base_time > curr_section_end {
|
last_last = Some(last);
|
||||||
skills.save_peak_and_start_new_section(curr_section_end);
|
last = &*curr;
|
||||||
curr_section_end += SECTION_LEN;
|
|
||||||
}
|
|
||||||
|
|
||||||
skills.process(&h);
|
|
||||||
prev_prev = Some(mem::replace(&mut prev, curr));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
skills.save_current_peak();
|
for curr in diff_objects.iter() {
|
||||||
|
for skill in skills.iter_mut() {
|
||||||
|
skill.process(curr, &diff_objects, hit_window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
(skills, attributes)
|
(skills, attributes)
|
||||||
}
|
}
|
||||||
@@ -406,7 +380,8 @@ fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
|
|||||||
|
|
||||||
if hit_objects[n].is_spinner() {
|
if hit_objects[n].is_spinner() {
|
||||||
continue;
|
continue;
|
||||||
} else if hit_objects[obj_i_idx].time - hit_objects[n].end_time() > stack_threshold
|
} else if hit_objects[obj_i_idx].start_time - hit_objects[n].end_time()
|
||||||
|
> stack_threshold
|
||||||
{
|
{
|
||||||
break; // * We are no longer within stacking range of the previous object.
|
break; // * We are no longer within stacking range of the previous object.
|
||||||
}
|
}
|
||||||
@@ -466,7 +441,9 @@ fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
|
|||||||
|
|
||||||
if hit_objects[n].is_spinner() {
|
if hit_objects[n].is_spinner() {
|
||||||
continue;
|
continue;
|
||||||
} else if hit_objects[obj_i_idx].time - hit_objects[n].time > stack_threshold {
|
} else if hit_objects[obj_i_idx].start_time - hit_objects[n].start_time
|
||||||
|
> stack_threshold
|
||||||
|
{
|
||||||
break; // * We are no longer within stacking range of the previous object.
|
break; // * We are no longer within stacking range of the previous object.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +472,7 @@ fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
|
|||||||
let mut slider_stack = 0.0;
|
let mut slider_stack = 0.0;
|
||||||
|
|
||||||
for j in i + 1..hit_objects.len() {
|
for j in i + 1..hit_objects.len() {
|
||||||
if hit_objects[j].time - stack_threshold > start_time {
|
if hit_objects[j].start_time - stack_threshold > start_time {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,21 +488,28 @@ fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lerp(start: f64, end: f64, percent: f64) -> f64 {
|
fn create_skills(mods: u32, radius: f32) -> [Box<dyn Skill>; 4] {
|
||||||
start + (end - start) * percent
|
[
|
||||||
|
Box::new(Aim::new(true)) as Box<dyn Skill>,
|
||||||
|
Box::new(Aim::new(false)) as Box<dyn Skill>,
|
||||||
|
Box::new(Speed::new()) as Box<dyn Skill>,
|
||||||
|
Box::new(Flashlight::new(mods, radius)) as Box<dyn Skill>,
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The result of a difficulty calculation on an osu!standard map.
|
/// The result of a difficulty calculation on an osu!standard map.
|
||||||
#[derive(Clone, Debug, Default, PartialEq)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
pub struct OsuDifficultyAttributes {
|
pub struct OsuDifficultyAttributes {
|
||||||
/// The aim portion of the total strain.
|
/// The aim portion of the total strain.
|
||||||
pub aim_strain: f64,
|
pub aim: f64,
|
||||||
/// The speed portion of the total strain.
|
/// The speed portion of the total strain.
|
||||||
pub speed_strain: f64,
|
pub speed: f64,
|
||||||
/// The flashlight portion of the total strain.
|
/// The flashlight portion of the total strain.
|
||||||
pub flashlight_rating: f64,
|
pub flashlight: f64,
|
||||||
/// The ratio of the aim strain with and without considering sliders
|
/// The ratio of the aim strain with and without considering sliders
|
||||||
pub slider_factor: f64,
|
pub slider_factor: f64,
|
||||||
|
/// The number of clickable objects weighted by difficulty.
|
||||||
|
pub speed_note_count: f64,
|
||||||
/// The approach rate.
|
/// The approach rate.
|
||||||
pub ar: f64,
|
pub ar: f64,
|
||||||
/// The overall difficulty
|
/// The overall difficulty
|
||||||
@@ -567,6 +551,8 @@ pub struct OsuPerformanceAttributes {
|
|||||||
pub pp_flashlight: f64,
|
pub pp_flashlight: f64,
|
||||||
/// The speed portion of the final pp.
|
/// The speed portion of the final pp.
|
||||||
pub pp_speed: f64,
|
pub pp_speed: f64,
|
||||||
|
/// Misses including an approximated amount of slider breaks
|
||||||
|
pub effective_miss_count: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OsuPerformanceAttributes {
|
impl OsuPerformanceAttributes {
|
||||||
|
|||||||
+194
-109
@@ -1,8 +1,9 @@
|
|||||||
use std::{cmp::Ordering, convert::identity};
|
use std::{cmp::Ordering, convert::identity};
|
||||||
|
|
||||||
use super::{slider_state::SliderState, OsuDifficultyAttributes};
|
use super::OsuDifficultyAttributes;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
beatmap::DifficultyPoint,
|
||||||
curve::{Curve, CurveBuffers},
|
curve::{Curve, CurveBuffers},
|
||||||
parse::{HitObject, HitObjectKind, Pos2},
|
parse::{HitObject, HitObjectKind, Pos2},
|
||||||
Beatmap,
|
Beatmap,
|
||||||
@@ -13,9 +14,11 @@ const BASE_SCORING_DISTANCE: f64 = 100.0;
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct OsuObject {
|
pub(crate) struct OsuObject {
|
||||||
pub(crate) time: f64,
|
pub(crate) start_time: f64,
|
||||||
pub(crate) pos: Pos2,
|
pub(crate) pos: Pos2,
|
||||||
pub(crate) stack_height: f32,
|
pub(crate) stack_height: f32,
|
||||||
|
pub(crate) time_preempt: f64,
|
||||||
|
pub(crate) time_fade_in: f64,
|
||||||
pub(crate) kind: OsuObjectKind,
|
pub(crate) kind: OsuObjectKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +28,7 @@ pub(crate) enum OsuObjectKind {
|
|||||||
Slider {
|
Slider {
|
||||||
end_time: f64,
|
end_time: f64,
|
||||||
end_pos: Pos2,
|
end_pos: Pos2,
|
||||||
|
lazy_travel_time: f64,
|
||||||
lazy_end_pos: Pos2,
|
lazy_end_pos: Pos2,
|
||||||
nested_objects: Vec<NestedObject>,
|
nested_objects: Vec<NestedObject>,
|
||||||
},
|
},
|
||||||
@@ -36,7 +40,7 @@ pub(crate) enum OsuObjectKind {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct NestedObject {
|
pub(crate) struct NestedObject {
|
||||||
pub(crate) pos: Pos2,
|
pub(crate) pos: Pos2,
|
||||||
pub(crate) time: f64,
|
pub(crate) start_time: f64,
|
||||||
pub(crate) kind: NestedObjectKind,
|
pub(crate) kind: NestedObjectKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,18 +55,19 @@ pub(crate) struct ObjectParameters<'a> {
|
|||||||
pub(crate) map: &'a Beatmap,
|
pub(crate) map: &'a Beatmap,
|
||||||
pub(crate) attributes: &'a mut OsuDifficultyAttributes,
|
pub(crate) attributes: &'a mut OsuDifficultyAttributes,
|
||||||
pub(crate) ticks: Vec<(Pos2, f64)>,
|
pub(crate) ticks: Vec<(Pos2, f64)>,
|
||||||
pub(crate) slider_state: SliderState<'a>,
|
|
||||||
pub(crate) curve_bufs: CurveBuffers,
|
pub(crate) curve_bufs: CurveBuffers,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OsuObject {
|
impl OsuObject {
|
||||||
#[allow(clippy::too_many_arguments)]
|
const PREEMPT_MIN: f64 = 450.0;
|
||||||
|
const TIME_PREEMPT: f64 = 600.0;
|
||||||
|
const TIME_FADE_IN: f64 = 400.0;
|
||||||
|
|
||||||
pub(crate) fn new(h: &HitObject, hr: bool, params: &mut ObjectParameters<'_>) -> Option<Self> {
|
pub(crate) fn new(h: &HitObject, hr: bool, params: &mut ObjectParameters<'_>) -> Option<Self> {
|
||||||
let ObjectParameters {
|
let ObjectParameters {
|
||||||
map,
|
map,
|
||||||
attributes,
|
attributes,
|
||||||
ticks,
|
ticks,
|
||||||
slider_state,
|
|
||||||
curve_bufs,
|
curve_bufs,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
@@ -77,10 +82,27 @@ impl OsuObject {
|
|||||||
HitObjectKind::Circle => {
|
HitObjectKind::Circle => {
|
||||||
attributes.n_circles += 1;
|
attributes.n_circles += 1;
|
||||||
|
|
||||||
|
// TODO: check if ar needs to be adjusted
|
||||||
|
let tmp_preempt =
|
||||||
|
difficulty_range(map.ar as f64, 1800.0, 1200.0, Self::PREEMPT_MIN) as f32;
|
||||||
|
let time_preempt = tmp_preempt as f64;
|
||||||
|
|
||||||
|
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
|
||||||
|
// * which uniformly speeds up all animations game wide regardless of AR.
|
||||||
|
// * This uniform speedup is hard to match 1:1, however we can at least make
|
||||||
|
// * AR>10 (via mods) feel good by extending the upper linear function above.
|
||||||
|
// * Note that this doesn't exactly match the AR>10 visuals as they're
|
||||||
|
// * classically known, but it feels good.
|
||||||
|
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
|
||||||
|
// * become smaller leading to hitcircles not fully fading in.
|
||||||
|
let time_fade_in = 400.0 * (time_preempt / Self::PREEMPT_MIN).min(1.0);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
time: h.start_time,
|
start_time: h.start_time,
|
||||||
pos,
|
pos,
|
||||||
stack_height: 0.0,
|
stack_height: 0.0,
|
||||||
|
time_preempt,
|
||||||
|
time_fade_in,
|
||||||
kind: OsuObjectKind::Circle,
|
kind: OsuObjectKind::Circle,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,30 +114,43 @@ impl OsuObject {
|
|||||||
} => {
|
} => {
|
||||||
attributes.n_sliders += 1;
|
attributes.n_sliders += 1;
|
||||||
|
|
||||||
// Responsible for timing point values
|
let timing_point = map.timing_point_at(h.start_time);
|
||||||
slider_state.update(h.start_time);
|
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
|
||||||
|
|
||||||
let span_count = (*repeats + 1) as f64;
|
let scoring_dist =
|
||||||
|
BASE_SCORING_DISTANCE * map.slider_mult * difficulty_point.slider_vel;
|
||||||
|
|
||||||
let mut tick_dist = 100.0 * map.slider_mult / map.tick_rate;
|
let vel = scoring_dist / timing_point.beat_len;
|
||||||
|
|
||||||
// * prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
|
// * prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
|
||||||
// * this results in more (or less) ticks being generated in <v8 maps for the same time duration.
|
// * this results in more (or less) ticks being generated in <v8 maps for the same time duration.
|
||||||
if map.version >= 8 {
|
let tick_dist_mult = if map.version < 8 {
|
||||||
tick_dist /=
|
let first_slider_vel = map
|
||||||
(100.0 / slider_state.slider_velocity).max(10.0).min(1000.0) / 100.0;
|
.difficulty_points
|
||||||
}
|
.first()
|
||||||
|
.map_or(DifficultyPoint::DEFAULT_SLIDER_VEL, |point| {
|
||||||
|
point.slider_vel
|
||||||
|
});
|
||||||
|
|
||||||
|
first_slider_vel.recip()
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tick_dist = if difficulty_point.generate_ticks {
|
||||||
|
scoring_dist / map.tick_rate * tick_dist_mult
|
||||||
|
} else {
|
||||||
|
f64::INFINITY
|
||||||
|
};
|
||||||
|
|
||||||
|
let span_count = (*repeats + 1) as f64;
|
||||||
|
|
||||||
// Build the curve w.r.t. the control points
|
// Build the curve w.r.t. the control points
|
||||||
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
|
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
|
||||||
|
|
||||||
let velocity =
|
let end_time = h.start_time + span_count * curve.dist() / vel;
|
||||||
(BASE_SCORING_DISTANCE * map.slider_mult * slider_state.slider_velocity)
|
let total_duration = end_time - h.start_time;
|
||||||
/ slider_state.beat_len;
|
let span_duration = total_duration / span_count;
|
||||||
|
|
||||||
let end_time = h.start_time + span_count * curve.dist() / velocity;
|
|
||||||
let duration = end_time - h.start_time;
|
|
||||||
let span_duration = duration / span_count;
|
|
||||||
|
|
||||||
// * A very lenient maximum length of a slider for ticks to be generated.
|
// * A very lenient maximum length of a slider for ticks to be generated.
|
||||||
// * This exists for edge cases such as /b/1573664 where the beatmap has
|
// * This exists for edge cases such as /b/1573664 where the beatmap has
|
||||||
@@ -124,101 +159,131 @@ impl OsuObject {
|
|||||||
|
|
||||||
let len = curve.dist().min(max_len);
|
let len = curve.dist().min(max_len);
|
||||||
tick_dist = tick_dist.clamp(0.0, len);
|
tick_dist = tick_dist.clamp(0.0, len);
|
||||||
let min_dist_from_end = velocity * 10.0;
|
let min_dist_from_end = vel * 10.0;
|
||||||
|
|
||||||
let mut curr_dist = tick_dist;
|
let mut curr_dist = tick_dist;
|
||||||
|
|
||||||
|
// TODO: check if ar needs to be adjusted
|
||||||
|
let tmp_preempt =
|
||||||
|
difficulty_range(map.ar as f64, 1800.0, 1200.0, Self::PREEMPT_MIN) as f32;
|
||||||
|
let head_time_preempt = tmp_preempt as f64;
|
||||||
|
|
||||||
|
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
|
||||||
|
// * which uniformly speeds up all animations game wide regardless of AR.
|
||||||
|
// * This uniform speedup is hard to match 1:1, however we can at least make
|
||||||
|
// * AR>10 (via mods) feel good by extending the upper linear function above.
|
||||||
|
// * Note that this doesn't exactly match the AR>10 visuals as they're
|
||||||
|
// * classically known, but it feels good.
|
||||||
|
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
|
||||||
|
// * become smaller leading to hitcircles not fully fading in.
|
||||||
|
let head_time_fade_in = 400.0 * (head_time_preempt / Self::PREEMPT_MIN).min(1.0);
|
||||||
|
|
||||||
ticks.clear();
|
ticks.clear();
|
||||||
ticks.reserve((len / tick_dist) as usize);
|
|
||||||
let mut nested_objects =
|
|
||||||
Vec::with_capacity((len * span_count / tick_dist) as usize);
|
|
||||||
|
|
||||||
// Ticks of the first span
|
let mut nested_objects = if tick_dist != 0.0 {
|
||||||
while curr_dist < len - min_dist_from_end {
|
ticks.reserve((len / tick_dist) as usize);
|
||||||
let progress = curr_dist / len;
|
let mut nested_objects =
|
||||||
|
Vec::with_capacity((len * span_count / tick_dist) as usize);
|
||||||
|
|
||||||
let curr_time = h.start_time + progress * span_duration;
|
// Ticks of the first span
|
||||||
let mut curr_pos = h.pos + curve.position_at(progress);
|
while curr_dist < len - min_dist_from_end {
|
||||||
|
let progress = curr_dist / len;
|
||||||
|
|
||||||
if hr {
|
let curr_time = h.start_time + progress * span_duration;
|
||||||
curr_pos.y = 384.0 - curr_pos.y;
|
let mut curr_pos = h.pos + curve.position_at(progress);
|
||||||
}
|
|
||||||
|
|
||||||
let tick = NestedObject {
|
if hr {
|
||||||
pos: curr_pos,
|
curr_pos.y = 384.0 - curr_pos.y;
|
||||||
time: curr_time,
|
}
|
||||||
kind: NestedObjectKind::Tick,
|
|
||||||
};
|
|
||||||
|
|
||||||
nested_objects.push(tick);
|
let tick = NestedObject {
|
||||||
ticks.push((curr_pos, curr_time));
|
pos: curr_pos,
|
||||||
|
start_time: curr_time,
|
||||||
curr_dist += tick_dist;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other spans
|
|
||||||
for span_idx in 1..=*repeats {
|
|
||||||
let progress = (span_idx % 2 == 1) as u8 as f64;
|
|
||||||
let span_idx_f64 = span_idx as f64;
|
|
||||||
|
|
||||||
// Repeat point
|
|
||||||
let curr_time = h.start_time + span_duration * span_idx_f64;
|
|
||||||
let mut curr_pos = h.pos + curve.position_at(progress);
|
|
||||||
|
|
||||||
if hr {
|
|
||||||
curr_pos.y = 384.0 - curr_pos.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
let repeat = NestedObject {
|
|
||||||
pos: curr_pos,
|
|
||||||
time: curr_time,
|
|
||||||
kind: NestedObjectKind::Repeat,
|
|
||||||
};
|
|
||||||
|
|
||||||
nested_objects.push(repeat);
|
|
||||||
|
|
||||||
// Ticks
|
|
||||||
if span_idx & 1 == 1 {
|
|
||||||
// S-------->R | Span 0
|
|
||||||
// 2 4 6 8 | => span_duration = 8
|
|
||||||
// R<--------- | Span 1
|
|
||||||
// 16 14 12 10 | => offset = 1 * span_duration
|
|
||||||
// --------->R | Span 2
|
|
||||||
// 18 20 22 24 | => not reverse; simple case
|
|
||||||
// T<--------- | Span 3
|
|
||||||
// 32 30 28 26 | => offset = 3 * span_duration
|
|
||||||
//
|
|
||||||
// n = offset + tick
|
|
||||||
// 26 = 24 + 2
|
|
||||||
// 28 = 24 + 4
|
|
||||||
// 30 = 24 + 6
|
|
||||||
// 32 = 24 + 8
|
|
||||||
|
|
||||||
let offset = span_idx_f64 * span_duration;
|
|
||||||
|
|
||||||
let tick_iter = ticks.iter().rev().zip(ticks.iter()).map(
|
|
||||||
|((rev_pos, _), (_, time))| NestedObject {
|
|
||||||
pos: *rev_pos,
|
|
||||||
time: offset + time,
|
|
||||||
kind: NestedObjectKind::Tick,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
nested_objects.extend(tick_iter);
|
|
||||||
} else {
|
|
||||||
let tick_iter = ticks.iter().map(|(pos, time)| NestedObject {
|
|
||||||
pos: *pos,
|
|
||||||
time: time + span_duration * span_idx_f64,
|
|
||||||
kind: NestedObjectKind::Tick,
|
kind: NestedObjectKind::Tick,
|
||||||
});
|
};
|
||||||
|
|
||||||
nested_objects.extend(tick_iter);
|
nested_objects.push(tick);
|
||||||
|
ticks.push((curr_pos, curr_time));
|
||||||
|
|
||||||
|
curr_dist += tick_dist;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Other spans
|
||||||
|
for span_idx in 1..=*repeats {
|
||||||
|
let progress = (span_idx % 2 == 1) as u8 as f64;
|
||||||
|
let span_idx_f64 = span_idx as f64;
|
||||||
|
|
||||||
|
// Repeat point
|
||||||
|
let curr_time = h.start_time + span_duration * span_idx_f64;
|
||||||
|
let mut curr_pos = h.pos + curve.position_at(progress);
|
||||||
|
|
||||||
|
if hr {
|
||||||
|
curr_pos.y = 384.0 - curr_pos.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
let repeat = NestedObject {
|
||||||
|
pos: curr_pos,
|
||||||
|
start_time: curr_time,
|
||||||
|
kind: NestedObjectKind::Repeat,
|
||||||
|
};
|
||||||
|
|
||||||
|
nested_objects.push(repeat);
|
||||||
|
|
||||||
|
// Ticks
|
||||||
|
if span_idx & 1 == 1 {
|
||||||
|
// S-------->R | Span 0
|
||||||
|
// 2 4 6 8 | => span_duration = 8
|
||||||
|
// R<--------- | Span 1
|
||||||
|
// 16 14 12 10 | => offset = 1 * span_duration
|
||||||
|
// --------->R | Span 2
|
||||||
|
// 18 20 22 24 | => not reverse; simple case
|
||||||
|
// T<--------- | Span 3
|
||||||
|
// 32 30 28 26 | => offset = 3 * span_duration
|
||||||
|
//
|
||||||
|
// n = offset + tick
|
||||||
|
// 26 = 24 + 2
|
||||||
|
// 28 = 24 + 4
|
||||||
|
// 30 = 24 + 6
|
||||||
|
// 32 = 24 + 8
|
||||||
|
|
||||||
|
let offset = span_idx_f64 * span_duration;
|
||||||
|
|
||||||
|
let tick_iter = ticks.iter().rev().zip(ticks.iter()).map(
|
||||||
|
|((rev_pos, _), (_, time))| {
|
||||||
|
let start_time = offset + time;
|
||||||
|
|
||||||
|
NestedObject {
|
||||||
|
pos: *rev_pos,
|
||||||
|
start_time,
|
||||||
|
kind: NestedObjectKind::Tick,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
nested_objects.extend(tick_iter);
|
||||||
|
} else {
|
||||||
|
let tick_iter = ticks.iter().map(|(pos, time)| {
|
||||||
|
let start_time = time + span_duration * span_idx_f64;
|
||||||
|
|
||||||
|
NestedObject {
|
||||||
|
pos: *pos,
|
||||||
|
start_time,
|
||||||
|
kind: NestedObjectKind::Tick,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nested_objects.extend(tick_iter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nested_objects
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
// Slider tail
|
// Slider tail
|
||||||
let final_span_start_time = h.start_time + *repeats as f64 * span_duration;
|
let final_span_start_time = h.start_time + *repeats as f64 * span_duration;
|
||||||
let final_span_end_time = (h.start_time + duration / 2.0)
|
let final_span_end_time = (h.start_time + total_duration / 2.0)
|
||||||
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
|
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
|
||||||
|
|
||||||
let progress = (*repeats % 2 == 0) as u8 as f64;
|
let progress = (*repeats % 2 == 0) as u8 as f64;
|
||||||
@@ -233,18 +298,18 @@ impl OsuObject {
|
|||||||
// * if this is to change, we should revisit this.
|
// * if this is to change, we should revisit this.
|
||||||
let legacy_last_tick = NestedObject {
|
let legacy_last_tick = NestedObject {
|
||||||
pos: end_pos,
|
pos: end_pos,
|
||||||
time: final_span_end_time,
|
start_time: final_span_end_time,
|
||||||
kind: NestedObjectKind::Tail,
|
kind: NestedObjectKind::Tail,
|
||||||
};
|
};
|
||||||
|
|
||||||
// On very short buzz sliders it can happen that the
|
// On very short buzz sliders it can happen that the
|
||||||
// legacy last tick is not the last object time-wise
|
// legacy last tick is not the last object time-wise
|
||||||
match nested_objects.last() {
|
match nested_objects.last() {
|
||||||
Some(last) if last.time > final_span_end_time => {
|
Some(last) if last.start_time > final_span_end_time => {
|
||||||
let idx = nested_objects
|
let idx = nested_objects
|
||||||
.binary_search_by(|nested| {
|
.binary_search_by(|nested| {
|
||||||
nested
|
nested
|
||||||
.time
|
.start_time
|
||||||
.partial_cmp(&final_span_end_time)
|
.partial_cmp(&final_span_end_time)
|
||||||
.unwrap_or(Ordering::Equal)
|
.unwrap_or(Ordering::Equal)
|
||||||
})
|
})
|
||||||
@@ -257,7 +322,11 @@ impl OsuObject {
|
|||||||
|
|
||||||
attributes.max_combo += nested_objects.len();
|
attributes.max_combo += nested_objects.len();
|
||||||
|
|
||||||
let lazy_travel_time = final_span_end_time - h.start_time;
|
let last_time = nested_objects
|
||||||
|
.last()
|
||||||
|
.map_or(final_span_end_time, |nested| nested.start_time);
|
||||||
|
|
||||||
|
let lazy_travel_time = last_time - h.start_time;
|
||||||
let mut end_time_min = lazy_travel_time / span_duration;
|
let mut end_time_min = lazy_travel_time / span_duration;
|
||||||
|
|
||||||
if end_time_min % 2.0 >= 1.0 {
|
if end_time_min % 2.0 >= 1.0 {
|
||||||
@@ -274,13 +343,16 @@ impl OsuObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
time: h.start_time,
|
start_time: h.start_time,
|
||||||
pos,
|
pos,
|
||||||
stack_height: 0.0,
|
stack_height: 0.0,
|
||||||
|
time_preempt: head_time_preempt,
|
||||||
|
time_fade_in: head_time_fade_in,
|
||||||
kind: OsuObjectKind::Slider {
|
kind: OsuObjectKind::Slider {
|
||||||
end_time,
|
end_time,
|
||||||
end_pos,
|
end_pos,
|
||||||
lazy_end_pos,
|
lazy_end_pos,
|
||||||
|
lazy_travel_time,
|
||||||
nested_objects,
|
nested_objects,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -289,9 +361,11 @@ impl OsuObject {
|
|||||||
attributes.n_spinners += 1;
|
attributes.n_spinners += 1;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
time: h.start_time,
|
start_time: h.start_time,
|
||||||
pos,
|
pos,
|
||||||
stack_height: 0.0,
|
stack_height: 0.0,
|
||||||
|
time_preempt: Self::TIME_PREEMPT,
|
||||||
|
time_fade_in: Self::TIME_FADE_IN,
|
||||||
kind: OsuObjectKind::Spinner {
|
kind: OsuObjectKind::Spinner {
|
||||||
end_time: *end_time,
|
end_time: *end_time,
|
||||||
},
|
},
|
||||||
@@ -306,7 +380,7 @@ impl OsuObject {
|
|||||||
#[inline]
|
#[inline]
|
||||||
pub(crate) fn end_time(&self) -> f64 {
|
pub(crate) fn end_time(&self) -> f64 {
|
||||||
match &self.kind {
|
match &self.kind {
|
||||||
OsuObjectKind::Circle => self.time,
|
OsuObjectKind::Circle => self.start_time,
|
||||||
OsuObjectKind::Slider { end_time, .. } => *end_time,
|
OsuObjectKind::Slider { end_time, .. } => *end_time,
|
||||||
OsuObjectKind::Spinner { end_time } => *end_time,
|
OsuObjectKind::Spinner { end_time } => *end_time,
|
||||||
}
|
}
|
||||||
@@ -343,3 +417,14 @@ impl OsuObject {
|
|||||||
matches!(self.kind, OsuObjectKind::Spinner { .. })
|
matches!(self.kind, OsuObjectKind::Spinner { .. })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: cleanup
|
||||||
|
fn difficulty_range(difficulty: f64, min: f64, mid: f64, max: f64) -> f64 {
|
||||||
|
if difficulty > 5.0 {
|
||||||
|
mid + (max - mid) * (difficulty - 5.0) / 5.0
|
||||||
|
} else if difficulty < 5.0 {
|
||||||
|
mid - (mid - min) * (5.0 - difficulty) / 5.0
|
||||||
|
} else {
|
||||||
|
mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+204
-187
@@ -1,4 +1,6 @@
|
|||||||
use super::{OsuDifficultyAttributes, OsuPerformanceAttributes, OsuScoreState};
|
use super::{
|
||||||
|
OsuDifficultyAttributes, OsuPerformanceAttributes, OsuScoreState, PERFORMANCE_BASE_MULTIPLIER,
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
AnyPP, Beatmap, DifficultyAttributes, GameMode, Mods, OsuStars, PerformanceAttributes,
|
AnyPP, Beatmap, DifficultyAttributes, GameMode, Mods, OsuStars, PerformanceAttributes,
|
||||||
};
|
};
|
||||||
@@ -262,18 +264,19 @@ impl<'map> OsuPP<'map> {
|
|||||||
let total_hits = (n300 + n100 + n50 + self.n_misses).min(n_objects) as f64;
|
let total_hits = (n300 + n100 + n50 + self.n_misses).min(n_objects) as f64;
|
||||||
|
|
||||||
let effective_misses =
|
let effective_misses =
|
||||||
calculate_effective_misses(&attributes, self.combo, self.n_misses, total_hits);
|
calculate_effective_misses(&attributes, self.combo, n100, n50, self.n_misses);
|
||||||
|
|
||||||
OsuPPInner {
|
OsuPPInner {
|
||||||
attributes,
|
|
||||||
mods: self.mods,
|
mods: self.mods,
|
||||||
combo: self.combo,
|
combo: self.combo.unwrap_or(attributes.max_combo),
|
||||||
acc,
|
acc,
|
||||||
n300,
|
n300,
|
||||||
n100,
|
n100,
|
||||||
n50,
|
n50,
|
||||||
|
n_misses: self.n_misses,
|
||||||
total_hits,
|
total_hits,
|
||||||
effective_misses,
|
effective_miss_count: effective_misses,
|
||||||
|
attributes,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
|
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
|
||||||
@@ -313,18 +316,19 @@ impl<'map> OsuPP<'map> {
|
|||||||
let total_hits = (n300 + n100 + n50 + self.n_misses).min(n_objects) as f64;
|
let total_hits = (n300 + n100 + n50 + self.n_misses).min(n_objects) as f64;
|
||||||
|
|
||||||
let effective_misses =
|
let effective_misses =
|
||||||
calculate_effective_misses(&attributes, self.combo, self.n_misses, total_hits);
|
calculate_effective_misses(&attributes, self.combo, n100, n50, self.n_misses);
|
||||||
|
|
||||||
OsuPPInner {
|
OsuPPInner {
|
||||||
attributes,
|
|
||||||
mods: self.mods,
|
mods: self.mods,
|
||||||
combo: self.combo,
|
combo: self.combo.unwrap_or(attributes.max_combo),
|
||||||
acc,
|
acc,
|
||||||
n300,
|
n300,
|
||||||
n100,
|
n100,
|
||||||
n50,
|
n50,
|
||||||
|
n_misses: self.n_misses,
|
||||||
total_hits,
|
total_hits,
|
||||||
effective_misses,
|
effective_miss_count: effective_misses,
|
||||||
|
attributes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,60 +357,69 @@ struct OsuPPInner {
|
|||||||
attributes: OsuDifficultyAttributes,
|
attributes: OsuDifficultyAttributes,
|
||||||
mods: u32,
|
mods: u32,
|
||||||
acc: f64,
|
acc: f64,
|
||||||
combo: Option<usize>,
|
combo: usize,
|
||||||
|
|
||||||
n300: usize,
|
n300: usize,
|
||||||
n100: usize,
|
n100: usize,
|
||||||
n50: usize,
|
n50: usize,
|
||||||
|
n_misses: usize,
|
||||||
|
|
||||||
total_hits: f64,
|
total_hits: f64,
|
||||||
effective_misses: usize,
|
effective_miss_count: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OsuPPInner {
|
impl OsuPPInner {
|
||||||
fn calculate(mut self) -> OsuPerformanceAttributes {
|
fn calculate(mut self) -> OsuPerformanceAttributes {
|
||||||
let (aim_value, speed_value, acc_value, flashlight_value, pp) =
|
if self.total_hits.abs() <= f64::EPSILON {
|
||||||
if self.total_hits.abs() <= f64::EPSILON {
|
return OsuPerformanceAttributes {
|
||||||
(0.0, 0.0, 0.0, 0.0, 0.0)
|
difficulty: self.attributes,
|
||||||
} else {
|
..Default::default()
|
||||||
let mut multiplier = 1.12;
|
|
||||||
|
|
||||||
// NF penalty
|
|
||||||
if self.mods.nf() {
|
|
||||||
multiplier *= (1.0 - 0.02 * (self.effective_misses as f64)).max(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SO penalty
|
|
||||||
if self.mods.so() {
|
|
||||||
let n_spinners = self.attributes.n_spinners;
|
|
||||||
multiplier *= 1.0 - (n_spinners as f64 / self.total_hits).powf(0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Relax penalty
|
|
||||||
if self.mods.rx() {
|
|
||||||
// * As we're adding 100s and 50s to an approximated number of combo breaks\
|
|
||||||
// * the result can be higher than total hits in specific scenarios
|
|
||||||
// * (which breaks some calculations) so we need to clamp it.
|
|
||||||
self.effective_misses = (self.effective_misses + self.n100 + self.n50)
|
|
||||||
.min(self.total_hits as usize);
|
|
||||||
|
|
||||||
multiplier *= 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
let aim_value = self.compute_aim_value();
|
|
||||||
let speed_value = self.compute_speed_value();
|
|
||||||
let acc_value = self.compute_accuracy_value();
|
|
||||||
let flashlight_value = self.compute_flashlight_value();
|
|
||||||
|
|
||||||
let pp = (aim_value.powf(1.1)
|
|
||||||
+ speed_value.powf(1.1)
|
|
||||||
+ acc_value.powf(1.1)
|
|
||||||
+ flashlight_value.powf(1.1))
|
|
||||||
.powf(1.0 / 1.1)
|
|
||||||
* multiplier;
|
|
||||||
|
|
||||||
(aim_value, speed_value, acc_value, flashlight_value, pp)
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut multiplier = PERFORMANCE_BASE_MULTIPLIER;
|
||||||
|
|
||||||
|
if self.mods.nf() {
|
||||||
|
multiplier *= (1.0 - 0.02 * self.effective_miss_count).max(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.mods.so() && self.total_hits > 0.0 {
|
||||||
|
multiplier *= 1.0 - (self.attributes.n_spinners as f64 / self.total_hits).powf(0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.mods.rx() {
|
||||||
|
// * https://www.desmos.com/calculator/bc9eybdthb
|
||||||
|
// * we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0
|
||||||
|
// * this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11)
|
||||||
|
let (n100_mult, n50_mult) = if self.attributes.od > 0.0 {
|
||||||
|
(
|
||||||
|
1.0 - (self.attributes.od / 13.33).powf(1.8),
|
||||||
|
1.0 - (self.attributes.od / 13.33).powi(5),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(1.0, 1.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
// * As we're adding Oks and Mehs to an approximated number of combo breaks the result can be
|
||||||
|
// * higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it.
|
||||||
|
self.effective_miss_count = (self.effective_miss_count
|
||||||
|
+ self.n100 as f64
|
||||||
|
+ n100_mult
|
||||||
|
+ self.n50 as f64 * n50_mult)
|
||||||
|
.min(self.total_hits);
|
||||||
|
}
|
||||||
|
|
||||||
|
let aim_value = self.compute_aim_value();
|
||||||
|
let speed_value = self.compute_speed_value();
|
||||||
|
let acc_value = self.compute_accuracy_value();
|
||||||
|
let flashlight_value = self.compute_flashlight_value();
|
||||||
|
|
||||||
|
let pp = (aim_value.powf(1.1)
|
||||||
|
+ speed_value.powf(1.1)
|
||||||
|
+ acc_value.powf(1.1)
|
||||||
|
+ flashlight_value.powf(1.1))
|
||||||
|
.powf(1.0 / 1.1)
|
||||||
|
* multiplier;
|
||||||
|
|
||||||
OsuPerformanceAttributes {
|
OsuPerformanceAttributes {
|
||||||
difficulty: self.attributes,
|
difficulty: self.attributes,
|
||||||
@@ -415,130 +428,133 @@ impl OsuPPInner {
|
|||||||
pp_flashlight: flashlight_value,
|
pp_flashlight: flashlight_value,
|
||||||
pp_speed: speed_value,
|
pp_speed: speed_value,
|
||||||
pp,
|
pp,
|
||||||
|
effective_miss_count: self.effective_miss_count,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compute_aim_value(&self) -> f64 {
|
fn compute_aim_value(&self) -> f64 {
|
||||||
let attributes = &self.attributes;
|
let mut aim_value =
|
||||||
let total_hits = self.total_hits;
|
(5.0 * (self.attributes.aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||||
|
|
||||||
// TD penalty
|
|
||||||
let raw_aim = if self.mods.td() {
|
|
||||||
attributes.aim_strain.powf(0.8)
|
|
||||||
} else {
|
|
||||||
attributes.aim_strain
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut aim_value = (5.0 * (raw_aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
|
||||||
|
|
||||||
// Longer maps are worth more
|
|
||||||
let len_bonus = 0.95
|
let len_bonus = 0.95
|
||||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
+ 0.4 * (self.total_hits / 2000.0).min(1.0)
|
||||||
+ (total_hits > 2000.0) as u8 as f64 * 0.5 * (total_hits / 2000.0).log10();
|
+ (self.total_hits > 2000.0) as u8 as f64 * (self.total_hits / 2000.0).log10() * 0.5;
|
||||||
|
|
||||||
aim_value *= len_bonus;
|
aim_value *= len_bonus;
|
||||||
|
|
||||||
// Penalize misses
|
// * Penalize misses by assessing # of misses relative to the total # of objects.
|
||||||
let effective_misses = self.effective_misses as i32;
|
// * Default a 3% reduction for any # of misses.
|
||||||
if effective_misses > 0 {
|
if self.effective_miss_count > 0.0 {
|
||||||
aim_value *= 0.97
|
aim_value *= 0.97
|
||||||
* (1.0 - (effective_misses as f64 / total_hits).powf(0.775)).powi(effective_misses);
|
* (1.0 - (self.effective_miss_count / self.total_hits).powf(0.775))
|
||||||
|
.powf(self.effective_miss_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combo scaling
|
aim_value *= self.get_combo_scaling_factor();
|
||||||
if let Some(combo) = self.combo.filter(|_| attributes.max_combo > 0) {
|
|
||||||
aim_value *= ((combo as f64 / attributes.max_combo as f64).powf(0.8)).min(1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// AR bonus
|
let ar_factor = if self.mods.rx() {
|
||||||
let ar_factor = if attributes.ar > 10.33 {
|
0.0
|
||||||
0.3 * (attributes.ar - 10.33)
|
} else if self.attributes.ar > 10.33 {
|
||||||
} else if attributes.ar < 8.0 {
|
0.3 * (self.attributes.ar - 10.33)
|
||||||
0.1 * (8.0 - attributes.ar)
|
} else if self.attributes.ar < 8.0 {
|
||||||
|
0.05 * (8.0 - self.attributes.ar)
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
|
|
||||||
aim_value *= 1.0 + ar_factor * len_bonus; // * Buff for longer maps with high AR.
|
// * Buff for longer maps with high AR.
|
||||||
|
aim_value *= 1.0 + ar_factor * len_bonus;
|
||||||
|
|
||||||
// HD bonus (this would include the Blinds mod but it's currently not representable)
|
|
||||||
if self.mods.hd() {
|
if self.mods.hd() {
|
||||||
aim_value *= 1.0 + 0.04 * (12.0 - attributes.ar);
|
// * We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR.
|
||||||
|
aim_value *= 1.0 + 0.04 * (12.0 - self.attributes.ar);
|
||||||
}
|
}
|
||||||
|
|
||||||
if attributes.n_sliders > 0 {
|
// * We assume 15% of sliders in a map are difficult since there's no way to tell from the performance calculator.
|
||||||
// * We assume 15% of sliders in a map are difficult since
|
let estimate_diff_sliders = self.attributes.n_sliders as f64 * 0.15;
|
||||||
// * there's no way to tell from the performance calculator.
|
|
||||||
let estimate_difficult_sliders = attributes.n_sliders as f64 * 0.15;
|
|
||||||
|
|
||||||
let non_300s = self.total_hits - self.n300 as f64;
|
if self.attributes.n_sliders > 0 {
|
||||||
let missing_combo = attributes.max_combo - self.combo.unwrap_or(attributes.max_combo);
|
let estimate_slider_ends_dropped = ((self.n100 + self.n50 + self.n_misses)
|
||||||
|
.min(self.attributes.max_combo - self.combo)
|
||||||
let estimate_slider_ends_dropped = non_300s
|
as f64)
|
||||||
.min(missing_combo as f64)
|
.clamp(0.0, estimate_diff_sliders);
|
||||||
.clamp(0.0, estimate_difficult_sliders);
|
let slider_nerf_factor = (1.0 - self.attributes.slider_factor)
|
||||||
|
* (1.0 - estimate_slider_ends_dropped / estimate_diff_sliders).powi(3)
|
||||||
let base = 1.0 - estimate_slider_ends_dropped / estimate_difficult_sliders;
|
+ self.attributes.slider_factor;
|
||||||
let slider_nerf_factor =
|
|
||||||
(1.0 - attributes.slider_factor) * base * base * base + attributes.slider_factor;
|
|
||||||
|
|
||||||
aim_value *= slider_nerf_factor;
|
aim_value *= slider_nerf_factor;
|
||||||
}
|
}
|
||||||
|
|
||||||
aim_value *= self.acc;
|
aim_value *= self.acc;
|
||||||
aim_value *= 0.98 + attributes.od * attributes.od / 2500.0;
|
// * It is important to consider accuracy difficulty when scaling with accuracy.
|
||||||
|
aim_value *= 0.98 + self.attributes.od * self.attributes.od / 2500.0;
|
||||||
|
|
||||||
aim_value
|
aim_value
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compute_speed_value(&self) -> f64 {
|
fn compute_speed_value(&self) -> f64 {
|
||||||
let attributes = &self.attributes;
|
if self.mods.rx() {
|
||||||
let total_hits = self.total_hits;
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
let mut speed_value =
|
let mut speed_value =
|
||||||
(5.0 * (attributes.speed_strain / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
(5.0 * (self.attributes.speed / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||||
|
|
||||||
// Longer maps are worth more
|
|
||||||
let len_bonus = 0.95
|
let len_bonus = 0.95
|
||||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
+ 0.4 * (self.total_hits / 2000.0).min(1.0)
|
||||||
+ (total_hits > 2000.0) as u8 as f64 * 0.5 * (total_hits / 2000.0).log10();
|
+ (self.total_hits > 2000.0) as u8 as f64 * (self.total_hits / 2000.0).log10() * 0.5;
|
||||||
|
|
||||||
speed_value *= len_bonus;
|
speed_value *= len_bonus;
|
||||||
|
|
||||||
// Penalize misses
|
// * Penalize misses by assessing # of misses relative to the total # of objects.
|
||||||
let effective_misses = self.effective_misses as f64;
|
// * Default a 3% reduction for any # of misses.
|
||||||
if effective_misses > 0.0 {
|
if self.effective_miss_count > 0.0 {
|
||||||
speed_value *= 0.97
|
speed_value *= 0.97
|
||||||
* (1.0 - (effective_misses / total_hits).powf(0.775))
|
* (1.0 - (self.effective_miss_count / self.total_hits).powf(0.775))
|
||||||
.powf(effective_misses.powf(0.875));
|
.powf(self.effective_miss_count.powf(0.875));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combo scaling
|
speed_value *= self.get_combo_scaling_factor();
|
||||||
if let Some(combo) = self.combo.filter(|_| attributes.max_combo > 0) {
|
|
||||||
speed_value *= ((combo as f64 / attributes.max_combo as f64).powf(0.8)).min(1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// AR bonus
|
let ar_factor = if self.attributes.ar > 10.33 {
|
||||||
let ar_factor = if attributes.ar > 10.33 {
|
0.3 * (self.attributes.ar - 10.33)
|
||||||
0.3 * (attributes.ar - 10.33)
|
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
|
|
||||||
speed_value *= 1.0 + ar_factor * len_bonus; // * Buff for longer maps with high AR.
|
// * Buff for longer maps with high AR.
|
||||||
|
speed_value *= 1.0 + ar_factor * len_bonus;
|
||||||
|
|
||||||
// HD bonus (this would include the Blinds mod but it's currently not representable)
|
|
||||||
if self.mods.hd() {
|
if self.mods.hd() {
|
||||||
speed_value *= 1.0 + 0.04 * (12.0 - attributes.ar);
|
// * We want to give more reward for lower AR when it comes to aim and HD.
|
||||||
|
// * This nerfs high AR and buffs lower AR.
|
||||||
|
speed_value *= 1.0 + 0.04 * (12.0 - self.attributes.ar);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scaling the speed value with accuracy and OD
|
// * Calculate accuracy assuming the worst case scenario
|
||||||
let od_factor = 0.95 + attributes.od * attributes.od / 750.0;
|
let relevant_total_diff = self.total_hits - self.attributes.speed_note_count;
|
||||||
let acc_factor = self.acc.powf((14.5 - attributes.od.max(8.0)) / 2.0);
|
let relevant_n300 = (self.n300 as f64 - relevant_total_diff).max(0.0);
|
||||||
speed_value *= od_factor * acc_factor;
|
let relevant_n100 =
|
||||||
|
(self.n100 as f64 - (relevant_total_diff - self.n300 as f64).max(0.0)).max(0.0);
|
||||||
|
let relevant_n50 = (self.n50 as f64
|
||||||
|
- (relevant_total_diff - (self.n300 + self.n100) as f64).max(0.0))
|
||||||
|
.max(0.0);
|
||||||
|
|
||||||
// Penalize n50s
|
let relevant_acc = if self.attributes.speed_note_count.abs() <= f64::EPSILON {
|
||||||
speed_value *= 0.98_f64.powf(
|
0.0
|
||||||
(self.n50 as f64 >= total_hits / 500.0) as u8 as f64
|
} else {
|
||||||
* (self.n50 as f64 - total_hits / 500.0),
|
(relevant_n300 * 6.0 + relevant_n100 * 2.0 + relevant_n50)
|
||||||
|
/ (self.attributes.speed_note_count * 6.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
// * Scale the speed value with accuracy and OD.
|
||||||
|
speed_value *= (0.95 + self.attributes.od * self.attributes.od / 750.0)
|
||||||
|
* ((self.acc + relevant_acc) / 2.0).powf((14.5 - (self.attributes.od).max(8.0)) / 2.0);
|
||||||
|
|
||||||
|
// * Scale the speed value with # of 50s to punish doubletapping.
|
||||||
|
speed_value *= 0.99_f64.powf(
|
||||||
|
(self.n50 as f64 >= self.total_hits / 500.0) as u8 as f64
|
||||||
|
* (self.n50 as f64 - self.total_hits / 500.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
speed_value
|
speed_value
|
||||||
@@ -549,28 +565,39 @@ impl OsuPPInner {
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let attributes = &self.attributes;
|
// * This percentage only considers HitCircles of any value - in this part
|
||||||
let total_hits = self.total_hits;
|
// * of the calculation we focus on hitting the timing hit window.
|
||||||
let n_circles = attributes.n_circles as f64;
|
let amount_hit_objects_with_acc = self.attributes.n_circles;
|
||||||
let n300 = self.n300 as f64;
|
|
||||||
let n100 = self.n100 as f64;
|
|
||||||
let n50 = self.n50 as f64;
|
|
||||||
|
|
||||||
let better_acc_percentage = (n_circles > 0.0) as u8 as f64
|
let mut better_acc_percentage = if amount_hit_objects_with_acc > 0 {
|
||||||
* (((n300 - (total_hits - n_circles)) * 6.0 + n100 * 2.0 + n50) / (n_circles * 6.0))
|
((self.n300 - (self.total_hits as usize - amount_hit_objects_with_acc)) * 6
|
||||||
.max(0.0);
|
+ self.n100 * 2
|
||||||
|
+ self.n50) as f64
|
||||||
|
/ (amount_hit_objects_with_acc * 6) as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
let mut acc_value = 1.52163_f64.powf(attributes.od) * better_acc_percentage.powi(24) * 2.83;
|
// * It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points.
|
||||||
|
if better_acc_percentage < 0.0 {
|
||||||
|
better_acc_percentage = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
// Bonus for many hitcircles
|
// * Lots of arbitrary values from testing.
|
||||||
acc_value *= ((n_circles as f64 / 1000.0).powf(0.3)).min(1.15);
|
// * Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution.
|
||||||
|
let mut acc_value =
|
||||||
|
1.52163_f64.powf(self.attributes.od) * better_acc_percentage.powi(24) * 2.83;
|
||||||
|
|
||||||
// HD bonus (this would include the Blinds mod but it's currently not representable)
|
// * Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
|
||||||
|
acc_value *= (amount_hit_objects_with_acc as f64 / 1000.0)
|
||||||
|
.powf(0.3)
|
||||||
|
.min(1.15);
|
||||||
|
|
||||||
|
// * Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given.
|
||||||
if self.mods.hd() {
|
if self.mods.hd() {
|
||||||
acc_value *= 1.08;
|
acc_value *= 1.08;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FL bonus
|
|
||||||
if self.mods.fl() {
|
if self.mods.fl() {
|
||||||
acc_value *= 1.02;
|
acc_value *= 1.02;
|
||||||
}
|
}
|
||||||
@@ -583,76 +610,66 @@ impl OsuPPInner {
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let attributes = &self.attributes;
|
let mut flashlight_value = self.attributes.flashlight * self.attributes.flashlight * 25.0;
|
||||||
let total_hits = self.total_hits;
|
|
||||||
|
|
||||||
// TD penalty
|
// * Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
|
||||||
let raw_flashlight = if self.mods.td() {
|
if self.effective_miss_count > 0.0 {
|
||||||
attributes.flashlight_rating.powf(0.8)
|
|
||||||
} else {
|
|
||||||
attributes.flashlight_rating
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut flashlight_value = raw_flashlight * raw_flashlight * 25.0;
|
|
||||||
|
|
||||||
// Add an additional bonus for HDFL
|
|
||||||
if self.mods.hd() {
|
|
||||||
flashlight_value *= 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Penalize misses by assessing # of misses relative to the total # of objects.
|
|
||||||
// Default a 3% reduction for any # of misses
|
|
||||||
let effective_misses = self.effective_misses as f64;
|
|
||||||
if effective_misses > 0.0 {
|
|
||||||
flashlight_value *= 0.97
|
flashlight_value *= 0.97
|
||||||
* (1.0 - (effective_misses / total_hits).powf(0.775))
|
* (1.0 - (self.effective_miss_count / self.total_hits).powf(0.775))
|
||||||
.powf(effective_misses.powf(0.875));
|
.powf(self.effective_miss_count.powf(0.875));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combo scaling
|
flashlight_value *= self.get_combo_scaling_factor();
|
||||||
if let Some(combo) = self.combo.filter(|_| attributes.max_combo > 0) {
|
|
||||||
flashlight_value *= ((combo as f64 / attributes.max_combo as f64).powf(0.8)).min(1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius
|
// * Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
|
||||||
flashlight_value *= 0.7
|
flashlight_value *= 0.7
|
||||||
+ 0.1 * (total_hits / 200.0).min(1.0)
|
+ 0.1 * (self.total_hits / 200.0).min(1.0)
|
||||||
+ (total_hits > 200.0) as u8 as f64 * (0.2 * ((total_hits - 200.0) / 200.0).min(1.0));
|
+ (self.total_hits > 200.0) as u8 as f64
|
||||||
|
* 0.2
|
||||||
|
* ((self.total_hits - 200.0) / 200.0).min(1.0);
|
||||||
|
|
||||||
// Scale the aim value with accuracy _slightly_
|
// * Scale the flashlight value with accuracy _slightly_.
|
||||||
flashlight_value *= 0.5 + self.acc / 2.0;
|
flashlight_value *= 0.5 + self.acc / 2.0;
|
||||||
|
// * It is important to also consider accuracy difficulty when doing that.
|
||||||
// It is important to also consider accuracy difficulty when doing that
|
flashlight_value *= 0.98 + self.attributes.od * self.attributes.od / 2500.0;
|
||||||
flashlight_value *= 0.98 + attributes.od * attributes.od / 2500.0;
|
|
||||||
|
|
||||||
flashlight_value
|
flashlight_value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_combo_scaling_factor(&self) -> f64 {
|
||||||
|
if self.attributes.max_combo == 0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
((self.combo as f64).powf(0.8) / (self.attributes.max_combo as f64).powf(0.8)).min(1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_effective_misses(
|
fn calculate_effective_misses(
|
||||||
attributes: &OsuDifficultyAttributes,
|
attrs: &OsuDifficultyAttributes,
|
||||||
combo: Option<usize>,
|
combo: Option<usize>,
|
||||||
|
n100: usize,
|
||||||
|
n50: usize,
|
||||||
n_misses: usize,
|
n_misses: usize,
|
||||||
total_hits: f64,
|
) -> f64 {
|
||||||
) -> usize {
|
|
||||||
// * Guess the number of misses + slider breaks from combo
|
// * Guess the number of misses + slider breaks from combo
|
||||||
let mut combo_based_misses: f64 = 0.0;
|
let mut combo_based_miss_count = 0.0;
|
||||||
|
|
||||||
if attributes.n_sliders > 0 {
|
if attrs.n_sliders > 0 {
|
||||||
let full_combo_threshold = attributes.max_combo as f64 - 0.1 * attributes.n_sliders as f64;
|
let full_combo_threshold = attrs.max_combo as f64 - 0.1 * attrs.n_sliders as f64;
|
||||||
|
|
||||||
let f64_combo = combo.map(|c| c as f64);
|
if let Some(score_max_combo) = combo
|
||||||
|
.map(|combo| combo as f64)
|
||||||
if let Some(combo) = f64_combo.filter(|&c| c < full_combo_threshold) {
|
.filter(|&combo| combo < full_combo_threshold)
|
||||||
combo_based_misses = full_combo_threshold / combo.max(1.0);
|
{
|
||||||
|
combo_based_miss_count = full_combo_threshold / score_max_combo.max(1.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Clamp misscount since it's derived from combo and can be
|
// * Clamp miss count to maximum amount of possible breaks
|
||||||
// * higher than total hits and that breaks some calculations
|
combo_based_miss_count = combo_based_miss_count.min((n100 + n50 + n_misses) as f64);
|
||||||
combo_based_misses = combo_based_misses.min(total_hits);
|
|
||||||
|
|
||||||
n_misses.max(combo_based_misses.floor() as usize)
|
combo_based_miss_count.max(n_misses as f64)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
|
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ const OBJECT_RADIUS: f32 = 64.0;
|
|||||||
|
|
||||||
#[derive(Copy, Clone, Debug)]
|
#[derive(Copy, Clone, Debug)]
|
||||||
pub(crate) struct ScalingFactor {
|
pub(crate) struct ScalingFactor {
|
||||||
adjusted_factor: f32,
|
pub(crate) factor: f32,
|
||||||
factor: f32,
|
pub(crate) radius: f32,
|
||||||
radius: f32,
|
|
||||||
scale: f32,
|
scale: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,32 +18,19 @@ impl ScalingFactor {
|
|||||||
let radius = OBJECT_RADIUS * scale;
|
let radius = OBJECT_RADIUS * scale;
|
||||||
let factor = NORMALIZED_RADIUS / radius;
|
let factor = NORMALIZED_RADIUS / radius;
|
||||||
|
|
||||||
let adjusted_factor = if radius < 30.0 {
|
let factor = if radius < 30.0 {
|
||||||
factor * (1.0 + (30.0 - radius).min(5.0) / 50.0)
|
factor * (1.0 + (30.0 - radius).min(5.0) / 50.0)
|
||||||
} else {
|
} else {
|
||||||
factor
|
factor
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
adjusted_factor,
|
|
||||||
factor,
|
factor,
|
||||||
radius,
|
radius,
|
||||||
scale: scale * -6.4,
|
scale: scale * -6.4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn raw(&self) -> f64 {
|
|
||||||
self.factor as f64
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn adjusted(&self) -> f32 {
|
|
||||||
self.adjusted_factor
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn radius(&self) -> f32 {
|
|
||||||
self.radius
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn stack_offset(&self, stack_height: f32) -> Pos2 {
|
pub(crate) fn stack_offset(&self, stack_height: f32) -> Pos2 {
|
||||||
Pos2::new(stack_height * self.scale)
|
Pos2::new(stack_height * self.scale)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -131,7 +131,7 @@ impl Skill {
|
|||||||
pub(crate) fn process(&mut self, curr: &DifficultyObject<'_>) {
|
pub(crate) fn process(&mut self, curr: &DifficultyObject<'_>) {
|
||||||
self.kind.pre_process();
|
self.kind.pre_process();
|
||||||
self.curr_section_peak = self.strain_value_at(curr).max(self.curr_section_peak);
|
self.curr_section_peak = self.strain_value_at(curr).max(self.curr_section_peak);
|
||||||
self.prev_time = Some(curr.base.time / curr.clock_rate);
|
self.prev_time = Some(curr.base.start_time / curr.clock_rate);
|
||||||
self.kind.post_process(curr);
|
self.kind.post_process(curr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,580 +0,0 @@
|
|||||||
use std::{
|
|
||||||
collections::VecDeque,
|
|
||||||
f64::consts::{FRAC_PI_2, PI},
|
|
||||||
fmt, iter,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::parse::Pos2;
|
|
||||||
|
|
||||||
use super::{lerp, DifficultyObject};
|
|
||||||
|
|
||||||
const SINGLE_SPACING_TRESHOLD: f64 = 125.0;
|
|
||||||
|
|
||||||
const SPEED_BALANCING_FACTOR: f64 = 40.0;
|
|
||||||
|
|
||||||
const AIM_SKILL_MULTIPLIER: f64 = 23.25;
|
|
||||||
const AIM_STRAIN_DECAY_BASE: f64 = 0.15;
|
|
||||||
const AIM_DECAY_WEIGHT: f64 = 0.9;
|
|
||||||
const AIM_DIFFICULTY_MULTIPLIER: f64 = 1.06;
|
|
||||||
const AIM_REDUCED_SECTION_COUNT: usize = 10;
|
|
||||||
|
|
||||||
const AIM_HISTORY_LENGTH: usize = 2;
|
|
||||||
const AIM_WIDE_ANGLE_MULTIPLIER: f64 = 1.5;
|
|
||||||
const AIM_ACUTE_ANGLE_MULTIPLIER: f64 = 2.0;
|
|
||||||
const AIM_SLIDER_MULTIPLIER: f64 = 1.5;
|
|
||||||
const AIM_VELOCITY_CHANGE_MULTIPLIER: f64 = 0.75;
|
|
||||||
|
|
||||||
const SPEED_SKILL_MULTIPLIER: f64 = 1375.0;
|
|
||||||
const SPEED_STRAIN_DECAY_BASE: f64 = 0.3;
|
|
||||||
const SPEED_DECAY_WEIGHT: f64 = 0.9;
|
|
||||||
const SPEED_DIFFICULTY_MULTIPLIER: f64 = 1.04;
|
|
||||||
const SPEED_REDUCED_SECTION_COUNT: usize = 5;
|
|
||||||
|
|
||||||
const SPEED_HISTORY_LENGTH: usize = 32;
|
|
||||||
const SPEED_RHYTHM_MULTIPLIER: f64 = 0.75;
|
|
||||||
const SPEED_HISTORY_TIME_MAX: f64 = 5000.0; // * 5 seconds of calculate_speed_rhythm_bonus max
|
|
||||||
const MIN_SPEED_BONUS: f64 = 75.0; // * ~200BPM
|
|
||||||
|
|
||||||
const FLASHLIGHT_SKILL_MULTIPLIER: f64 = 0.15;
|
|
||||||
const FLASHLIGHT_STRAIN_DECAY_BASE: f64 = 0.15;
|
|
||||||
const FLASHLIGHT_DECAY_WEIGHT: f64 = 1.0;
|
|
||||||
const FLASHLIGHT_DIFFICULTY_MULTIPLIER: f64 = 1.06;
|
|
||||||
const FLASHLIGHT_REDUCED_SECTION_COUNT: usize = 10;
|
|
||||||
|
|
||||||
const FLASHLIGHT_HISTORY_LENGTH: usize = 10;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub(crate) struct AimHistoryEntry {
|
|
||||||
angle: Option<f64>,
|
|
||||||
is_slider: bool,
|
|
||||||
is_spinner: bool,
|
|
||||||
strain_time: f64,
|
|
||||||
jump_dist: f64,
|
|
||||||
movement_dist: f64,
|
|
||||||
movement_time: f64,
|
|
||||||
travel_dist: f64,
|
|
||||||
travel_time: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&DifficultyObject<'_>> for AimHistoryEntry {
|
|
||||||
fn from(h: &DifficultyObject<'_>) -> Self {
|
|
||||||
Self {
|
|
||||||
angle: h.angle,
|
|
||||||
is_slider: h.base.is_slider(),
|
|
||||||
is_spinner: h.base.is_spinner(),
|
|
||||||
strain_time: h.strain_time,
|
|
||||||
jump_dist: h.jump_dist,
|
|
||||||
movement_dist: h.movement_dist,
|
|
||||||
movement_time: h.movement_time,
|
|
||||||
travel_dist: h.travel_dist,
|
|
||||||
travel_time: h.travel_time,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub(crate) struct FlashlightHistoryEntry {
|
|
||||||
end_pos: Pos2,
|
|
||||||
is_spinner: bool,
|
|
||||||
jump_dist: f64,
|
|
||||||
strain_time: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub(crate) struct SpeedHistoryEntry {
|
|
||||||
is_slider: bool,
|
|
||||||
start_time: f64,
|
|
||||||
strain_time: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&DifficultyObject<'_>> for SpeedHistoryEntry {
|
|
||||||
fn from(h: &DifficultyObject<'_>) -> Self {
|
|
||||||
Self {
|
|
||||||
is_slider: h.base.is_slider(),
|
|
||||||
start_time: h.base.time / h.clock_rate,
|
|
||||||
strain_time: h.strain_time,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub(crate) enum SkillKind {
|
|
||||||
Aim {
|
|
||||||
history: VecDeque<AimHistoryEntry>,
|
|
||||||
with_sliders: bool,
|
|
||||||
},
|
|
||||||
Flashlight {
|
|
||||||
history: VecDeque<FlashlightHistoryEntry>,
|
|
||||||
scaling_factor: f64,
|
|
||||||
},
|
|
||||||
Speed {
|
|
||||||
curr_rhythm: f64,
|
|
||||||
history: VecDeque<SpeedHistoryEntry>,
|
|
||||||
hit_window: f64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SkillKind {
|
|
||||||
pub(crate) fn aim(with_sliders: bool) -> Self {
|
|
||||||
Self::Aim {
|
|
||||||
history: VecDeque::with_capacity(AIM_HISTORY_LENGTH + 1),
|
|
||||||
with_sliders,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn flashlight(scaling_factor: f64) -> Self {
|
|
||||||
Self::Flashlight {
|
|
||||||
history: VecDeque::with_capacity(FLASHLIGHT_HISTORY_LENGTH + 1),
|
|
||||||
scaling_factor,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn speed(hit_window: f64) -> Self {
|
|
||||||
Self::Speed {
|
|
||||||
curr_rhythm: 1.0,
|
|
||||||
history: VecDeque::with_capacity(SPEED_HISTORY_LENGTH + 1),
|
|
||||||
hit_window,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn pre_process(&mut self) {
|
|
||||||
match self {
|
|
||||||
Self::Aim { history, .. } => history.truncate(AIM_HISTORY_LENGTH),
|
|
||||||
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 { history, .. } => history.push_front(current.into()),
|
|
||||||
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<'_>) -> f64 {
|
|
||||||
match self {
|
|
||||||
Self::Aim {
|
|
||||||
history,
|
|
||||||
with_sliders,
|
|
||||||
} => {
|
|
||||||
if curr.base.is_spinner() || history.len() < 2 || history[0].is_spinner {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let prev = &history[0];
|
|
||||||
let prev_prev = &history[1];
|
|
||||||
|
|
||||||
// * Calculate the velocity to the current hitobject,
|
|
||||||
// * which starts with a base distance / time assuming the last object is a hitcircle.
|
|
||||||
let mut curr_velocity = curr.jump_dist / curr.strain_time;
|
|
||||||
|
|
||||||
// * But if the last object is a slider, then we extend the
|
|
||||||
// * travel velocity through the slider into the current object.
|
|
||||||
if prev.is_slider && *with_sliders {
|
|
||||||
// * calculate the movement velocity from slider end to current object
|
|
||||||
let movement_velocity = curr.movement_dist / curr.movement_time;
|
|
||||||
|
|
||||||
// * calculate the slider velocity from slider head to slider end.
|
|
||||||
let travel_velocity = curr.travel_dist / curr.travel_time;
|
|
||||||
|
|
||||||
// * take the larger total combined velocity.
|
|
||||||
curr_velocity = curr_velocity.max(movement_velocity + travel_velocity);
|
|
||||||
}
|
|
||||||
|
|
||||||
// * As above, do the same for the previous hitobject.
|
|
||||||
let mut prev_velocity = prev.jump_dist / prev.strain_time;
|
|
||||||
|
|
||||||
if prev_prev.is_slider && *with_sliders {
|
|
||||||
let movement_velocity = prev.movement_dist / prev.movement_time;
|
|
||||||
let travel_velocity = prev.travel_dist / prev.travel_time;
|
|
||||||
prev_velocity = prev_velocity.max(movement_velocity + travel_velocity);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut wide_angle_bonus = 0.0;
|
|
||||||
let mut acute_angle_bonus = 0.0;
|
|
||||||
let mut slider_bonus = 0.0;
|
|
||||||
let mut velocity_change_bonus = 0.0;
|
|
||||||
|
|
||||||
// * Start strain with regular velocity
|
|
||||||
let mut aim_strain = curr_velocity;
|
|
||||||
|
|
||||||
// * If rhythms are the same.
|
|
||||||
if curr.strain_time.max(prev.strain_time)
|
|
||||||
< 1.25 * curr.strain_time.min(prev.strain_time)
|
|
||||||
{
|
|
||||||
if let (Some(curr_angle), Some(prev_angle), Some(prev_prev_angle)) =
|
|
||||||
(curr.angle, prev.angle, prev_prev.angle)
|
|
||||||
{
|
|
||||||
// * Rewarding angles, take the smaller velocity as base.
|
|
||||||
let angle_bonus = curr_velocity.min(prev_velocity);
|
|
||||||
|
|
||||||
wide_angle_bonus = calculate_wide_angle_bonus(curr_angle);
|
|
||||||
|
|
||||||
// * Only bufff delta_time exceeding 300 bpm 1/2.
|
|
||||||
if curr.strain_time <= 100.0 {
|
|
||||||
let curr_bonus = calculate_acute_angle_bonus(curr_angle);
|
|
||||||
|
|
||||||
// * Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
|
|
||||||
let prev_bonus = calculate_acute_angle_bonus(prev_angle);
|
|
||||||
|
|
||||||
// * The maximum velocity we buff is equal to 125 / strainTime
|
|
||||||
let angle_bonus = angle_bonus.min(125.0 / curr.strain_time);
|
|
||||||
|
|
||||||
// * scale buff from 150 bpm 1/4 to 200 bpm 1/4
|
|
||||||
let base1 =
|
|
||||||
(FRAC_PI_2 * ((100.0 - curr.strain_time) / 25.0).min(1.0)).sin();
|
|
||||||
|
|
||||||
// * Buff distance exceeding 50 (radius) up to 100 (diameter).
|
|
||||||
let base2 = (FRAC_PI_2 * (curr.jump_dist.clamp(50.0, 100.0) - 50.0)
|
|
||||||
/ 50.0)
|
|
||||||
.sin();
|
|
||||||
|
|
||||||
acute_angle_bonus = curr_bonus
|
|
||||||
* prev_bonus
|
|
||||||
* angle_bonus
|
|
||||||
* base1
|
|
||||||
* base1
|
|
||||||
* base2
|
|
||||||
* base2
|
|
||||||
}
|
|
||||||
|
|
||||||
// * Penalize wide angles if they're repeated,
|
|
||||||
// * reducing the penalty as the lastAngle gets more acute.
|
|
||||||
let base = calculate_wide_angle_bonus(prev_angle);
|
|
||||||
wide_angle_bonus *=
|
|
||||||
angle_bonus * (1.0 - wide_angle_bonus.min(base * base * base));
|
|
||||||
|
|
||||||
// * Penalize acute angles if they're repeated,
|
|
||||||
// * reducing the penalty as the lastLastAngle gets more obtuse.
|
|
||||||
let base = calculate_acute_angle_bonus(prev_prev_angle);
|
|
||||||
acute_angle_bonus *=
|
|
||||||
0.5 + 0.5 * (1.0 - acute_angle_bonus.min(base * base * base));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if prev_velocity.max(curr_velocity).abs() > f64::EPSILON {
|
|
||||||
// * We want to use the average velocity over the whole object when
|
|
||||||
// * awarding differences, not the individual jump and slider path velocities.
|
|
||||||
prev_velocity = (prev.jump_dist + prev.travel_dist) / prev.strain_time;
|
|
||||||
curr_velocity = (curr.jump_dist + curr.travel_dist) / curr.strain_time;
|
|
||||||
|
|
||||||
let velocity_diff = (prev_velocity - curr_velocity).abs();
|
|
||||||
|
|
||||||
// * Scale with ratio of difference compared to 0.5 * max dist.
|
|
||||||
let base = (FRAC_PI_2 * velocity_diff / prev_velocity.max(curr_velocity)).sin();
|
|
||||||
let dist_ratio = base * base;
|
|
||||||
|
|
||||||
// * Reward for % distance up to 125 / strainTime
|
|
||||||
// * for overlaps where velocity is still changing.
|
|
||||||
let overlap_velocity_buff =
|
|
||||||
velocity_diff.min(125.0 / curr.strain_time.min(prev.strain_time));
|
|
||||||
|
|
||||||
// * Reward for % distance slowed down compared to previous,
|
|
||||||
// * paying attention to not award overlap
|
|
||||||
let base =
|
|
||||||
(FRAC_PI_2 * (curr.jump_dist.min(prev.jump_dist) / 100.0).min(1.0)).sin();
|
|
||||||
let non_overlap_velocity_buff = velocity_diff * base * base;
|
|
||||||
|
|
||||||
// * Choose the largest bonus, multiplied by ratio.
|
|
||||||
velocity_change_bonus =
|
|
||||||
overlap_velocity_buff.max(non_overlap_velocity_buff) * dist_ratio;
|
|
||||||
|
|
||||||
// * Penalize for rhythm changes.
|
|
||||||
let base = curr.strain_time.min(prev.strain_time)
|
|
||||||
/ curr.strain_time.max(prev.strain_time);
|
|
||||||
velocity_change_bonus *= base * base;
|
|
||||||
}
|
|
||||||
|
|
||||||
if curr.travel_time.abs() > f64::EPSILON {
|
|
||||||
// * Reward sliders based on velocity
|
|
||||||
slider_bonus = curr.travel_dist / curr.travel_time;
|
|
||||||
}
|
|
||||||
|
|
||||||
// * Add in acute angle bonus or wide angle bonus + velocity change bonus,
|
|
||||||
// * whichever is larger
|
|
||||||
aim_strain += (acute_angle_bonus * AIM_ACUTE_ANGLE_MULTIPLIER).max(
|
|
||||||
wide_angle_bonus * AIM_WIDE_ANGLE_MULTIPLIER
|
|
||||||
+ velocity_change_bonus * AIM_VELOCITY_CHANGE_MULTIPLIER,
|
|
||||||
);
|
|
||||||
|
|
||||||
// * Add in additional slider velocity bonus.
|
|
||||||
if *with_sliders {
|
|
||||||
aim_strain += slider_bonus * AIM_SLIDER_MULTIPLIER;
|
|
||||||
}
|
|
||||||
|
|
||||||
aim_strain
|
|
||||||
}
|
|
||||||
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() as f64;
|
|
||||||
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() as f64;
|
|
||||||
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 = 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, f64) {
|
|
||||||
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) -> f64 {
|
|
||||||
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) -> f64 {
|
|
||||||
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) -> f64 {
|
|
||||||
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: f64) -> f64 {
|
|
||||||
self.strain_decay_base().powf(ms / 1000.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn calculate_speed_rhythm_bonus(
|
|
||||||
current: &DifficultyObject<'_>,
|
|
||||||
history: &VecDeque<SpeedHistoryEntry>,
|
|
||||||
hit_window: f64,
|
|
||||||
) -> f64 {
|
|
||||||
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 f64;
|
|
||||||
|
|
||||||
// * 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 / current.clock_rate - curr.start_time))
|
|
||||||
.max(0.0)
|
|
||||||
/ SPEED_HISTORY_TIME_MAX;
|
|
||||||
|
|
||||||
if curr_historical_decay.abs() > f64::EPSILON {
|
|
||||||
// * Either we're limited by time or limited by object count
|
|
||||||
curr_historical_decay = curr_historical_decay.min(i as f64 / 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 f64).sqrt()
|
|
||||||
* ((4 + prev_island_size) as f64).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
|
|
||||||
}
|
|
||||||
|
|
||||||
fn calculate_wide_angle_bonus(angle: f64) -> f64 {
|
|
||||||
let base = (3.0 / 4.0 * ((PI / 6.0).max(angle).min(5.0 / 6.0 * PI) - PI / 6.0)).sin();
|
|
||||||
|
|
||||||
base * base
|
|
||||||
}
|
|
||||||
|
|
||||||
fn calculate_acute_angle_bonus(angle: f64) -> f64 {
|
|
||||||
1.0 - calculate_wide_angle_bonus(angle)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for SkillKind {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Aim { .. } => f.debug_struct("Aim").finish(),
|
|
||||||
Self::Flashlight { .. } => f.debug_struct("Flashlight").finish(),
|
|
||||||
Self::Speed { .. } => f.debug_struct("Speed").finish(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
use std::{
|
||||||
|
any::Any,
|
||||||
|
f64::consts::{FRAC_PI_2, FRAC_PI_6, PI},
|
||||||
|
mem,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::osu::difficulty_object::OsuDifficultyObject;
|
||||||
|
|
||||||
|
use super::{previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct Aim {
|
||||||
|
curr_strain: f64,
|
||||||
|
curr_section_peak: f64,
|
||||||
|
curr_section_end: f64,
|
||||||
|
strain_peaks: Vec<f64>,
|
||||||
|
with_sliders: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Aim {
|
||||||
|
const SKILL_MULTIPLIER: f64 = 23.55;
|
||||||
|
const STRAIN_DECAY_BASE: f64 = 0.15;
|
||||||
|
|
||||||
|
pub(crate) fn new(with_sliders: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
curr_strain: 0.0,
|
||||||
|
curr_section_peak: 0.0,
|
||||||
|
curr_section_end: 0.0,
|
||||||
|
strain_peaks: Vec::new(),
|
||||||
|
with_sliders,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strain_decay(ms: f64) -> f64 {
|
||||||
|
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Skill for Aim {
|
||||||
|
fn process(
|
||||||
|
&mut self,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
hit_window: f64,
|
||||||
|
) {
|
||||||
|
<Self as StrainSkill>::process(self, curr, diff_objects, hit_window)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn difficulty_value(&mut self) -> f64 {
|
||||||
|
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any(&self) -> &dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_strain_peaks(&mut self) -> Vec<f64> {
|
||||||
|
mem::take(&mut self.strain_peaks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StrainSkill for Aim {
|
||||||
|
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
|
||||||
|
&mut self.strain_peaks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||||
|
&mut self.curr_section_peak
|
||||||
|
}
|
||||||
|
|
||||||
|
fn curr_section_end(&mut self) -> &mut f64 {
|
||||||
|
&mut self.curr_section_end
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strain_value_at(
|
||||||
|
&mut self,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
_hit_window: f64,
|
||||||
|
) -> f64 {
|
||||||
|
self.curr_strain *= Self::strain_decay(curr.delta_time);
|
||||||
|
self.curr_strain += AimEvaluator::evaluate_diff_of(curr, diff_objects, self.with_sliders)
|
||||||
|
* Self::SKILL_MULTIPLIER;
|
||||||
|
|
||||||
|
self.curr_strain
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_initial_strain(
|
||||||
|
&self,
|
||||||
|
time: f64,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
) -> f64 {
|
||||||
|
self.curr_strain * Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn difficulty_value(&mut self) -> f64 {
|
||||||
|
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OsuStrainSkill for Aim {}
|
||||||
|
|
||||||
|
struct AimEvaluator;
|
||||||
|
|
||||||
|
impl AimEvaluator {
|
||||||
|
const WIDE_ANGLE_MULTIPLIER: f64 = 1.5;
|
||||||
|
const ACUTE_ANGLE_MULTIPLIER: f64 = 1.95;
|
||||||
|
const SLIDER_MULTIPLIER: f64 = 1.35;
|
||||||
|
const VELOCITY_CHANGE_MULTIPLIER: f64 = 0.75;
|
||||||
|
|
||||||
|
fn evaluate_diff_of(
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
with_sliders: bool,
|
||||||
|
) -> f64 {
|
||||||
|
let osu_curr_obj = curr;
|
||||||
|
|
||||||
|
let (osu_last_last_obj, osu_last_obj) = if let Some(tuple) =
|
||||||
|
previous(diff_objects, curr.idx, 1)
|
||||||
|
.zip(previous(diff_objects, curr.idx, 0))
|
||||||
|
.filter(|(_, last)| !(curr.base.is_spinner() || last.base.is_spinner()))
|
||||||
|
{
|
||||||
|
tuple
|
||||||
|
} else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// * Calculate the velocity to the current hitobject, which starts
|
||||||
|
// * with a base distance / time assuming the last object is a hitcircle.
|
||||||
|
let mut curr_vel = osu_curr_obj.dists.lazy_jump_dist / osu_curr_obj.strain_time;
|
||||||
|
|
||||||
|
// * But if the last object is a slider, then we extend the travel
|
||||||
|
// * velocity through the slider into the current object.
|
||||||
|
if osu_last_obj.base.is_slider() && with_sliders {
|
||||||
|
// * calculate the slider velocity from slider head to slider end.
|
||||||
|
let travel_vel = osu_last_obj.dists.travel_dist / osu_last_obj.dists.travel_time;
|
||||||
|
// * calculate the movement velocity from slider end to current object
|
||||||
|
let movement_vel = osu_curr_obj.dists.min_jump_dist / osu_curr_obj.dists.min_jump_time;
|
||||||
|
|
||||||
|
// * take the larger total combined velocity.
|
||||||
|
curr_vel = curr_vel.max(movement_vel + travel_vel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// * As above, do the same for the previous hitobject.
|
||||||
|
let mut prev_vel = osu_last_obj.dists.lazy_jump_dist / osu_last_obj.strain_time;
|
||||||
|
|
||||||
|
if osu_last_last_obj.base.is_slider() && with_sliders {
|
||||||
|
let travel_vel =
|
||||||
|
osu_last_last_obj.dists.travel_dist / osu_last_last_obj.dists.travel_time;
|
||||||
|
let movement_vel = osu_last_obj.dists.min_jump_dist / osu_last_obj.dists.min_jump_time;
|
||||||
|
|
||||||
|
prev_vel = prev_vel.max(movement_vel + travel_vel);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut wide_angle_bonus = 0.0;
|
||||||
|
let mut acute_angle_bonus = 0.0;
|
||||||
|
let mut slider_bonus = 0.0;
|
||||||
|
let mut vel_change_bonus = 0.0;
|
||||||
|
|
||||||
|
// * Start strain with regular velocity.
|
||||||
|
let mut aim_strain = curr_vel;
|
||||||
|
|
||||||
|
// * If rhythms are the same.
|
||||||
|
if osu_curr_obj.strain_time.max(osu_last_obj.strain_time)
|
||||||
|
< 1.25 * osu_curr_obj.strain_time.min(osu_last_obj.strain_time)
|
||||||
|
{
|
||||||
|
if let Some(((curr_angle, last_angle), last_last_angle)) = osu_curr_obj
|
||||||
|
.dists
|
||||||
|
.angle
|
||||||
|
.zip(osu_last_obj.dists.angle)
|
||||||
|
.zip(osu_last_last_obj.dists.angle)
|
||||||
|
{
|
||||||
|
// * Rewarding angles, take the smaller velocity as base.
|
||||||
|
let angle_bonus = curr_vel.min(prev_vel);
|
||||||
|
|
||||||
|
wide_angle_bonus = Self::calc_wide_angle_bonus(curr_angle);
|
||||||
|
acute_angle_bonus = Self::calc_acute_angle_bonus(curr_angle);
|
||||||
|
|
||||||
|
// * Only buff deltaTime exceeding 300 bpm 1/2.
|
||||||
|
if osu_curr_obj.strain_time > 100.0 {
|
||||||
|
acute_angle_bonus = 0.0;
|
||||||
|
} else {
|
||||||
|
let base1 =
|
||||||
|
(FRAC_PI_2 * ((100.0 - osu_curr_obj.strain_time) / 25.0).min(1.0)).sin();
|
||||||
|
|
||||||
|
let base2 = (FRAC_PI_2
|
||||||
|
* ((osu_curr_obj.dists.lazy_jump_dist).clamp(50.0, 100.0) - 50.0)
|
||||||
|
/ 50.0)
|
||||||
|
.sin();
|
||||||
|
|
||||||
|
// * Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
|
||||||
|
acute_angle_bonus *= Self::calc_acute_angle_bonus(last_angle)
|
||||||
|
// * The maximum velocity we buff is equal to 125 / strainTime
|
||||||
|
* angle_bonus.min(125.0 / osu_curr_obj.strain_time)
|
||||||
|
// * scale buff from 150 bpm 1/4 to 200 bpm 1/4
|
||||||
|
* base1
|
||||||
|
* base1
|
||||||
|
// * Buff distance exceeding 50 (radius) up to 100 (diameter).
|
||||||
|
* base2
|
||||||
|
* base2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute.
|
||||||
|
wide_angle_bonus *= angle_bonus
|
||||||
|
* (1.0 - wide_angle_bonus.min(Self::calc_wide_angle_bonus(last_angle).powi(3)));
|
||||||
|
// * Penalize acute angles if they're repeated, reducing the penalty as the lastLastAngle gets more obtuse.
|
||||||
|
acute_angle_bonus *= 0.5
|
||||||
|
+ 0.5
|
||||||
|
* (1.0
|
||||||
|
- acute_angle_bonus
|
||||||
|
.min(Self::calc_acute_angle_bonus(last_last_angle).powi(3)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if prev_vel.max(curr_vel).abs() > f64::EPSILON {
|
||||||
|
// * We want to use the average velocity over the whole object when awarding
|
||||||
|
// * differences, not the individual jump and slider path velocities.
|
||||||
|
prev_vel = (osu_last_obj.dists.lazy_jump_dist + osu_last_last_obj.dists.travel_dist)
|
||||||
|
/ osu_last_obj.strain_time;
|
||||||
|
curr_vel = (osu_curr_obj.dists.lazy_jump_dist + osu_last_obj.dists.travel_dist)
|
||||||
|
/ osu_curr_obj.strain_time;
|
||||||
|
|
||||||
|
// * Scale with ratio of difference compared to 0.5 * max dist.
|
||||||
|
let dist_ratio_base =
|
||||||
|
(FRAC_PI_2 * (prev_vel - curr_vel).abs() / prev_vel.max(curr_vel)).sin();
|
||||||
|
let dist_ratio = dist_ratio_base * dist_ratio_base;
|
||||||
|
|
||||||
|
// * Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
|
||||||
|
let overlap_vel_buff = (125.0 / osu_curr_obj.strain_time.min(osu_last_obj.strain_time))
|
||||||
|
.min((prev_vel - curr_vel).abs());
|
||||||
|
|
||||||
|
vel_change_bonus = overlap_vel_buff * dist_ratio;
|
||||||
|
|
||||||
|
// * Penalize for rhythm changes.
|
||||||
|
let bonus_base = (osu_curr_obj.strain_time).min(osu_last_obj.strain_time)
|
||||||
|
/ (osu_curr_obj.strain_time).max(osu_last_obj.strain_time);
|
||||||
|
vel_change_bonus *= bonus_base * bonus_base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if osu_last_obj.base.is_slider() {
|
||||||
|
// * Reward sliders based on velocity.
|
||||||
|
slider_bonus = osu_last_obj.dists.travel_dist / osu_last_obj.dists.travel_time
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
|
||||||
|
aim_strain += (acute_angle_bonus * Self::ACUTE_ANGLE_MULTIPLIER).max(
|
||||||
|
wide_angle_bonus * Self::WIDE_ANGLE_MULTIPLIER
|
||||||
|
+ vel_change_bonus * Self::VELOCITY_CHANGE_MULTIPLIER,
|
||||||
|
);
|
||||||
|
|
||||||
|
// * Add in additional slider velocity bonus.
|
||||||
|
if with_sliders {
|
||||||
|
aim_strain += slider_bonus * Self::SLIDER_MULTIPLIER;
|
||||||
|
}
|
||||||
|
|
||||||
|
aim_strain
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calc_wide_angle_bonus(angle: f64) -> f64 {
|
||||||
|
let base = (3.0 / 4.0 * ((5.0 / 6.0 * PI).min(angle.max(FRAC_PI_6)) - FRAC_PI_6)).sin();
|
||||||
|
|
||||||
|
base * base
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calc_acute_angle_bonus(angle: f64) -> f64 {
|
||||||
|
1.0 - Self::calc_wide_angle_bonus(angle)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
use std::{any::Any, mem};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
osu::{
|
||||||
|
difficulty_object::OsuDifficultyObject,
|
||||||
|
osu_object::{NestedObjectKind, OsuObjectKind},
|
||||||
|
},
|
||||||
|
Mods,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct Flashlight {
|
||||||
|
curr_strain: f64,
|
||||||
|
curr_section_peak: f64,
|
||||||
|
curr_section_end: f64,
|
||||||
|
strain_peaks: Vec<f64>,
|
||||||
|
has_hidden_mod: bool,
|
||||||
|
scaling_factor: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Flashlight {
|
||||||
|
const SKILL_MULTIPLIER: f64 = 0.052;
|
||||||
|
const STRAIN_DECAY_BASE: f64 = 0.15;
|
||||||
|
|
||||||
|
pub(crate) fn new(mods: u32, radius: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
curr_strain: 0.0,
|
||||||
|
curr_section_peak: 0.0,
|
||||||
|
curr_section_end: 0.0,
|
||||||
|
strain_peaks: Vec::new(),
|
||||||
|
has_hidden_mod: mods.hd(),
|
||||||
|
scaling_factor: 52.0 / radius as f64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strain_decay(ms: f64) -> f64 {
|
||||||
|
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Skill for Flashlight {
|
||||||
|
fn process(
|
||||||
|
&mut self,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
hit_window: f64,
|
||||||
|
) {
|
||||||
|
<Self as StrainSkill>::process(self, curr, diff_objects, hit_window)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn difficulty_value(&mut self) -> f64 {
|
||||||
|
<Self as StrainSkill>::difficulty_value(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any(&self) -> &dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_strain_peaks(&mut self) -> Vec<f64> {
|
||||||
|
mem::take(&mut self.strain_peaks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StrainSkill for Flashlight {
|
||||||
|
const DECAY_WEIGHT: f64 = 0.9;
|
||||||
|
|
||||||
|
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
|
||||||
|
&mut self.strain_peaks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||||
|
&mut self.curr_section_peak
|
||||||
|
}
|
||||||
|
|
||||||
|
fn curr_section_end(&mut self) -> &mut f64 {
|
||||||
|
&mut self.curr_section_end
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strain_value_at(
|
||||||
|
&mut self,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
_hit_window: f64,
|
||||||
|
) -> f64 {
|
||||||
|
self.curr_strain *= Self::strain_decay(curr.delta_time);
|
||||||
|
self.curr_strain += FlashlightEvaluator::evaluate_diff_of(
|
||||||
|
curr,
|
||||||
|
diff_objects,
|
||||||
|
self.has_hidden_mod,
|
||||||
|
self.scaling_factor,
|
||||||
|
) * Self::SKILL_MULTIPLIER;
|
||||||
|
|
||||||
|
self.curr_strain
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_initial_strain(
|
||||||
|
&self,
|
||||||
|
time: f64,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
) -> f64 {
|
||||||
|
self.curr_strain * Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn difficulty_value(&mut self) -> f64 {
|
||||||
|
self.get_curr_strain_peaks().into_iter().sum::<f64>() * Self::DIFFICULTY_MULTIPLER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OsuStrainSkill for Flashlight {}
|
||||||
|
|
||||||
|
struct FlashlightEvaluator;
|
||||||
|
|
||||||
|
impl FlashlightEvaluator {
|
||||||
|
const MAX_OPACITY_BONUS: f64 = 0.4;
|
||||||
|
const HIDDEN_BONUS: f64 = 0.2;
|
||||||
|
|
||||||
|
const MIN_VELOCITY: f64 = 0.5;
|
||||||
|
const SLIDER_MULTIPLIER: f64 = 1.3;
|
||||||
|
|
||||||
|
const MIN_ANGLE_MULTIPLIER: f64 = 0.2;
|
||||||
|
|
||||||
|
fn evaluate_diff_of(
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
hidden: bool,
|
||||||
|
scaling_factor: f64,
|
||||||
|
) -> f64 {
|
||||||
|
if curr.base.is_spinner() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let osu_curr = curr;
|
||||||
|
let osu_hit_obj = curr.base;
|
||||||
|
|
||||||
|
let mut small_dist_nerf = 1.0;
|
||||||
|
let mut cumulative_strain_time = 0.0;
|
||||||
|
|
||||||
|
let mut result = 0.0;
|
||||||
|
|
||||||
|
let mut last_obj = osu_curr;
|
||||||
|
|
||||||
|
let mut angle_repeat_count = 0.0;
|
||||||
|
|
||||||
|
// * This is iterating backwards in time from the current object.
|
||||||
|
for i in 0..curr.idx.min(10) {
|
||||||
|
let curr_obj = if let Some(curr_obj) = previous(diff_objects, curr.idx, i) {
|
||||||
|
curr_obj
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let curr_hit_obj = curr_obj.base;
|
||||||
|
|
||||||
|
if !curr_obj.base.is_spinner() {
|
||||||
|
let jump_dist = (osu_hit_obj.pos - curr_hit_obj.end_pos()).length() as f64;
|
||||||
|
cumulative_strain_time += last_obj.strain_time;
|
||||||
|
|
||||||
|
// * We want to nerf objects that can be easily seen within the Flashlight circle radius.
|
||||||
|
if i == 0 {
|
||||||
|
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 = ((curr_obj.dists.lazy_jump_dist / scaling_factor) / 25.0).min(1.0);
|
||||||
|
|
||||||
|
// * Bonus based on how visible the object is.
|
||||||
|
let opacity_bonus = 1.0
|
||||||
|
+ Self::MAX_OPACITY_BONUS
|
||||||
|
* (1.0 - osu_curr.opacity_at(curr_hit_obj.start_time, hidden));
|
||||||
|
|
||||||
|
result += stack_nerf * opacity_bonus * scaling_factor * jump_dist
|
||||||
|
/ cumulative_strain_time;
|
||||||
|
|
||||||
|
if let Some((curr_obj_angle, osu_curr_angle)) =
|
||||||
|
curr_obj.dists.angle.zip(osu_curr.dists.angle)
|
||||||
|
{
|
||||||
|
// * Objects further back in time should count less for the nerf.
|
||||||
|
if (curr_obj_angle - osu_curr_angle).abs() < 0.02 {
|
||||||
|
angle_repeat_count += (1.0 - 0.1 * i as f64).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
last_obj = curr_obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = small_dist_nerf * result;
|
||||||
|
result = base * base;
|
||||||
|
|
||||||
|
// * Additional bonus for Hidden due to there being no approach circles.
|
||||||
|
if hidden {
|
||||||
|
result *= 1.0 + Self::HIDDEN_BONUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Nerf patterns with repeated angles.
|
||||||
|
result *= Self::MIN_ANGLE_MULTIPLIER
|
||||||
|
+ (1.0 - Self::MIN_ANGLE_MULTIPLIER) / (angle_repeat_count + 1.0);
|
||||||
|
|
||||||
|
let mut slider_bonus = 0.0;
|
||||||
|
|
||||||
|
if let OsuObjectKind::Slider { nested_objects, .. } = &osu_curr.base.kind {
|
||||||
|
// * Invert the scaling factor to determine the true travel distance independent of circle size.
|
||||||
|
let pixel_travel_dist = osu_curr.dists.lazy_travel_dist as f64 / scaling_factor;
|
||||||
|
|
||||||
|
// * Reward sliders based on velocity.
|
||||||
|
slider_bonus = ((pixel_travel_dist / osu_curr.dists.travel_time as f64
|
||||||
|
- Self::MIN_VELOCITY)
|
||||||
|
.max(0.0))
|
||||||
|
.sqrt();
|
||||||
|
|
||||||
|
// * Longer sliders require more memorisation.
|
||||||
|
slider_bonus *= pixel_travel_dist;
|
||||||
|
|
||||||
|
// * Nerf sliders with repeats, as less memorisation is required.
|
||||||
|
let repeat_count = nested_objects.iter().fold(0, |count, nested| {
|
||||||
|
count + matches!(nested.kind, NestedObjectKind::Repeat) as usize
|
||||||
|
});
|
||||||
|
|
||||||
|
if repeat_count > 0 {
|
||||||
|
slider_bonus /= (repeat_count + 1) as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result += slider_bonus * Self::SLIDER_MULTIPLIER;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
mod aim;
|
||||||
|
mod flashlight;
|
||||||
|
mod speed;
|
||||||
|
|
||||||
|
use std::{any::Any, cmp::Ordering, mem};
|
||||||
|
|
||||||
|
pub(crate) use self::{aim::Aim, flashlight::Flashlight, speed::Speed};
|
||||||
|
|
||||||
|
use super::{difficulty_object::OsuDifficultyObject, SECTION_LEN};
|
||||||
|
|
||||||
|
pub(crate) trait Skill {
|
||||||
|
fn process(
|
||||||
|
&mut self,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
hit_window: f64,
|
||||||
|
);
|
||||||
|
fn difficulty_value(&mut self) -> f64;
|
||||||
|
fn as_any(&self) -> &dyn Any;
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||||
|
fn take_strain_peaks(&mut self) -> Vec<f64>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) trait StrainSkill: Skill + Sized {
|
||||||
|
const DECAY_WEIGHT: f64 = 0.9;
|
||||||
|
|
||||||
|
fn strain_peaks_mut(&mut self) -> &mut Vec<f64>;
|
||||||
|
fn curr_section_peak(&mut self) -> &mut f64;
|
||||||
|
fn curr_section_end(&mut self) -> &mut f64;
|
||||||
|
|
||||||
|
fn strain_value_at(
|
||||||
|
&mut self,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
hit_window: f64,
|
||||||
|
) -> f64;
|
||||||
|
|
||||||
|
fn calculate_initial_strain(
|
||||||
|
&self,
|
||||||
|
time: f64,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
) -> f64;
|
||||||
|
|
||||||
|
fn process(
|
||||||
|
&mut self,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
hit_window: f64,
|
||||||
|
) {
|
||||||
|
// * The first object doesn't generate a strain, so we begin with an incremented section end
|
||||||
|
if curr.idx == 0 {
|
||||||
|
let section_len = SECTION_LEN as f64;
|
||||||
|
*self.curr_section_end() = (curr.start_time / section_len).ceil() * section_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
while curr.start_time > *self.curr_section_end() {
|
||||||
|
self.save_curr_peak();
|
||||||
|
|
||||||
|
{
|
||||||
|
let section_end = *self.curr_section_end();
|
||||||
|
self.start_new_section_from(section_end, curr, diff_objects);
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.curr_section_end() += SECTION_LEN as f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.curr_section_peak() = self
|
||||||
|
.strain_value_at(curr, diff_objects, hit_window)
|
||||||
|
.max(*self.curr_section_peak());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_curr_peak(&mut self) {
|
||||||
|
let peak = *self.curr_section_peak();
|
||||||
|
self.strain_peaks_mut().push(peak);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_new_section_from(
|
||||||
|
&mut self,
|
||||||
|
time: f64,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
) {
|
||||||
|
// * The maximum strain of the new section is not zero by default
|
||||||
|
// * This means we need to capture the strain level at the beginning of the new section,
|
||||||
|
// * and use that as the initial peak level.
|
||||||
|
*self.curr_section_peak() = self.calculate_initial_strain(time, curr, diff_objects);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn difficulty_value(&mut self) -> f64;
|
||||||
|
|
||||||
|
fn get_curr_strain_peaks(&mut self) -> Vec<f64> {
|
||||||
|
let curr_peak = *self.curr_section_peak();
|
||||||
|
let mut strain_peaks = mem::take(self.strain_peaks_mut());
|
||||||
|
strain_peaks.push(curr_peak);
|
||||||
|
|
||||||
|
strain_peaks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) trait OsuStrainSkill: StrainSkill + Sized {
|
||||||
|
const REDUCED_SECTION_COUNT: usize = 10;
|
||||||
|
const REDUCED_STRAIN_BASELINE: f64 = 0.75;
|
||||||
|
const DIFFICULTY_MULTIPLER: f64 = 1.06;
|
||||||
|
|
||||||
|
fn difficulty_value(&mut self) -> f64 {
|
||||||
|
let mut difficulty = 0.0;
|
||||||
|
let mut weight = 1.0;
|
||||||
|
|
||||||
|
// * Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
|
||||||
|
// * These sections will not contribute to the difficulty.
|
||||||
|
let mut peaks = self.get_curr_strain_peaks();
|
||||||
|
|
||||||
|
peaks.retain(|&peak| peak > 0.0);
|
||||||
|
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||||
|
|
||||||
|
let peak_iter = peaks.iter_mut().take(Self::REDUCED_SECTION_COUNT);
|
||||||
|
|
||||||
|
fn lerp(start: f64, end: f64, amount: f64) -> f64 {
|
||||||
|
start + (end - start) * amount
|
||||||
|
}
|
||||||
|
|
||||||
|
// * We are reducing the highest strains first to account for extreme difficulty spikes
|
||||||
|
for (i, strain) in peak_iter.enumerate() {
|
||||||
|
let clamped = (i as f32 / Self::REDUCED_SECTION_COUNT as f32).clamp(0.0, 1.0) as f64;
|
||||||
|
let scale = (lerp(1.0, 10.0, clamped)).log10();
|
||||||
|
*strain *= lerp(Self::REDUCED_STRAIN_BASELINE, 1.0, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||||
|
|
||||||
|
// * Difficulty is the weighted sum of the highest strains from every section.
|
||||||
|
// * We're sorting from highest to lowest strain.
|
||||||
|
for strain in peaks {
|
||||||
|
difficulty += strain * weight;
|
||||||
|
weight *= Self::DECAY_WEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
difficulty * Self::DIFFICULTY_MULTIPLER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn previous<'map, 'objects>(
|
||||||
|
diff_objects: &'objects [OsuDifficultyObject<'map>],
|
||||||
|
curr: usize,
|
||||||
|
backwards_idx: usize,
|
||||||
|
) -> Option<&'objects OsuDifficultyObject<'map>> {
|
||||||
|
curr.checked_sub(backwards_idx + 1)
|
||||||
|
.and_then(|idx| diff_objects.get(idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn previous_start_time(
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
curr: usize,
|
||||||
|
backwards_idx: usize,
|
||||||
|
) -> f64 {
|
||||||
|
previous(diff_objects, curr, backwards_idx).map_or(0.0, |h| h.start_time)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next<'map, 'objects>(
|
||||||
|
diff_objects: &'objects [OsuDifficultyObject<'map>],
|
||||||
|
curr: usize,
|
||||||
|
forwards_idx: usize,
|
||||||
|
) -> Option<&'objects OsuDifficultyObject<'map>> {
|
||||||
|
diff_objects.get(curr + (forwards_idx + 1))
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
use std::{any::Any, cmp::Ordering, f64::consts::PI, mem};
|
||||||
|
|
||||||
|
use crate::osu::difficulty_object::OsuDifficultyObject;
|
||||||
|
|
||||||
|
use super::{next, previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct Speed {
|
||||||
|
curr_strain: f64,
|
||||||
|
curr_section_peak: f64,
|
||||||
|
curr_section_end: f64,
|
||||||
|
curr_rhythm: f64,
|
||||||
|
strain_peaks: Vec<f64>,
|
||||||
|
object_strains: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Speed {
|
||||||
|
const SKILL_MULTIPLIER: f64 = 1375.0;
|
||||||
|
const STRAIN_DECAY_BASE: f64 = 0.3;
|
||||||
|
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
curr_strain: 0.0,
|
||||||
|
curr_section_peak: 0.0,
|
||||||
|
curr_section_end: 0.0,
|
||||||
|
curr_rhythm: 0.0,
|
||||||
|
strain_peaks: Vec::new(),
|
||||||
|
object_strains: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strain_decay(ms: f64) -> f64 {
|
||||||
|
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn relevant_note_count(&self) -> f64 {
|
||||||
|
self.object_strains
|
||||||
|
.iter()
|
||||||
|
.max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
|
||||||
|
.copied()
|
||||||
|
.filter(|&n| n > 0.0)
|
||||||
|
.map_or(0.0, |max_strain| {
|
||||||
|
self.object_strains.iter().fold(0.0, |sum, strain| {
|
||||||
|
sum + (1.0 + (-(strain / max_strain * 12.0 - 6.0)).exp()).recip()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Skill for Speed {
|
||||||
|
fn process(
|
||||||
|
&mut self,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
hit_window: f64,
|
||||||
|
) {
|
||||||
|
<Self as StrainSkill>::process(self, curr, diff_objects, hit_window)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn difficulty_value(&mut self) -> f64 {
|
||||||
|
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any(&self) -> &dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_strain_peaks(&mut self) -> Vec<f64> {
|
||||||
|
mem::take(&mut self.strain_peaks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StrainSkill for Speed {
|
||||||
|
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
|
||||||
|
&mut self.strain_peaks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||||
|
&mut self.curr_section_peak
|
||||||
|
}
|
||||||
|
|
||||||
|
fn curr_section_end(&mut self) -> &mut f64 {
|
||||||
|
&mut self.curr_section_end
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strain_value_at(
|
||||||
|
&mut self,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
hit_window: f64,
|
||||||
|
) -> f64 {
|
||||||
|
self.curr_strain *= Self::strain_decay(curr.strain_time);
|
||||||
|
self.curr_strain += SpeedEvaluator::evaluate_diff_of(curr, diff_objects, hit_window)
|
||||||
|
* Self::SKILL_MULTIPLIER;
|
||||||
|
self.curr_rhythm = RhythmEvaluator::evaluate_diff_of(curr, diff_objects, hit_window);
|
||||||
|
|
||||||
|
let total_strain = self.curr_strain * self.curr_rhythm;
|
||||||
|
self.object_strains.push(total_strain);
|
||||||
|
|
||||||
|
total_strain
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_initial_strain(
|
||||||
|
&self,
|
||||||
|
time: f64,
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
) -> f64 {
|
||||||
|
(self.curr_strain * self.curr_rhythm)
|
||||||
|
* Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn difficulty_value(&mut self) -> f64 {
|
||||||
|
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OsuStrainSkill for Speed {
|
||||||
|
const REDUCED_SECTION_COUNT: usize = 5;
|
||||||
|
const DIFFICULTY_MULTIPLER: f64 = 1.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SpeedEvaluator;
|
||||||
|
|
||||||
|
impl SpeedEvaluator {
|
||||||
|
const SINGLE_SPACING_THRESHOLD: f64 = 125.0;
|
||||||
|
const MIN_SPEED_BONUS: f64 = 75.0; // ~200BPM
|
||||||
|
const SPEED_BALANCING_FACTOR: f64 = 40.;
|
||||||
|
|
||||||
|
fn evaluate_diff_of(
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
hit_window: f64,
|
||||||
|
) -> f64 {
|
||||||
|
if curr.base.is_spinner() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// * derive strainTime for calculation
|
||||||
|
let osu_curr_obj = curr;
|
||||||
|
let osu_prev_obj = previous(diff_objects, curr.idx, 0);
|
||||||
|
let osu_next_obj = next(diff_objects, curr.idx, 0);
|
||||||
|
|
||||||
|
let mut strain_time = curr.strain_time;
|
||||||
|
let mut doubletapness = 1.0;
|
||||||
|
|
||||||
|
// * Nerf doubletappable doubles.
|
||||||
|
if let Some(osu_next_obj) = osu_next_obj {
|
||||||
|
let curr_delta_time = osu_curr_obj.delta_time.max(1.0);
|
||||||
|
let next_delta_time = osu_next_obj.delta_time.max(1.0);
|
||||||
|
let delta_diff = (next_delta_time - curr_delta_time).abs();
|
||||||
|
let speed_ratio = curr_delta_time / curr_delta_time.max(delta_diff);
|
||||||
|
let window_ratio_base = (curr_delta_time / hit_window).min(1.0);
|
||||||
|
let window_ratio = window_ratio_base * window_ratio_base;
|
||||||
|
doubletapness = speed_ratio.powf(1.0 - window_ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Cap deltatime to the OD 300 hitwindow.
|
||||||
|
// * 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) / 0.93).clamp(0.92, 1.0);
|
||||||
|
|
||||||
|
// * derive speedBonus for calculation
|
||||||
|
let speed_bonus = if strain_time < Self::MIN_SPEED_BONUS {
|
||||||
|
let base = (Self::MIN_SPEED_BONUS - strain_time) / Self::SPEED_BALANCING_FACTOR;
|
||||||
|
|
||||||
|
1.0 + 0.75 * base * base
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.dists.travel_dist);
|
||||||
|
let dist =
|
||||||
|
Self::SINGLE_SPACING_THRESHOLD.min(travel_dist + osu_curr_obj.dists.min_jump_dist);
|
||||||
|
|
||||||
|
(speed_bonus + speed_bonus * (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.5))
|
||||||
|
* doubletapness
|
||||||
|
/ strain_time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RhythmEvaluator;
|
||||||
|
|
||||||
|
impl RhythmEvaluator {
|
||||||
|
// * 5 seconds of calculatingRhythmBonus max.
|
||||||
|
const HISTORY_TIME_MAX: u32 = 5000;
|
||||||
|
const RHYTHM_MULTIPLIER: f64 = 0.75;
|
||||||
|
|
||||||
|
fn evaluate_diff_of(
|
||||||
|
curr: &OsuDifficultyObject<'_>,
|
||||||
|
diff_objects: &[OsuDifficultyObject<'_>],
|
||||||
|
hit_window: f64,
|
||||||
|
) -> f64 {
|
||||||
|
if curr.base.is_spinner() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut prev_island_size = 0;
|
||||||
|
|
||||||
|
let mut rhythm_complexity_sum = 0.0;
|
||||||
|
let mut island_size = 1;
|
||||||
|
// * store the ratio of the current start of an island to buff for tighter rhythms
|
||||||
|
let mut start_ratio = 0.0;
|
||||||
|
|
||||||
|
let mut first_delta_switch = false;
|
||||||
|
|
||||||
|
let historical_note_count = curr.idx.min(32);
|
||||||
|
|
||||||
|
let mut rhythm_start = 0;
|
||||||
|
|
||||||
|
while previous(diff_objects, curr.idx, rhythm_start)
|
||||||
|
.filter(|prev| {
|
||||||
|
rhythm_start + 2 < historical_note_count
|
||||||
|
&& curr.start_time - prev.start_time < Self::HISTORY_TIME_MAX as f64
|
||||||
|
})
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
rhythm_start += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in (1..=rhythm_start).rev() {
|
||||||
|
let (curr_obj, prev_obj, last_obj) = if let Some(((curr, prev), last)) =
|
||||||
|
previous(diff_objects, curr.idx, i - 1)
|
||||||
|
.zip(previous(diff_objects, curr.idx, i))
|
||||||
|
.zip(previous(diff_objects, curr.idx, i + 1))
|
||||||
|
{
|
||||||
|
(curr, prev, last)
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
// * scales note 0 to 1 from history to now
|
||||||
|
let mut curr_historical_decay = (Self::HISTORY_TIME_MAX as f64
|
||||||
|
- (curr.start_time - curr_obj.start_time))
|
||||||
|
/ Self::HISTORY_TIME_MAX as f64;
|
||||||
|
|
||||||
|
// * either we're limited by time or limited by object count.
|
||||||
|
curr_historical_decay = curr_historical_decay
|
||||||
|
.min((historical_note_count - i) as f64 / historical_note_count as f64);
|
||||||
|
|
||||||
|
let curr_delta = curr_obj.strain_time;
|
||||||
|
let prev_delta = prev_obj.strain_time;
|
||||||
|
let last_delta = last_obj.strain_time;
|
||||||
|
|
||||||
|
// * fancy function to calculate rhythmbonuses.
|
||||||
|
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 mut window_penalty = ((((prev_delta - curr_delta).abs() - hit_window * 0.3)
|
||||||
|
.max(0.0))
|
||||||
|
/ (hit_window * 0.3))
|
||||||
|
.min(1.0);
|
||||||
|
|
||||||
|
println!("window_penalty: prev={prev_delta} | curr={curr_delta} | window={hit_window} => {window_penalty}");
|
||||||
|
|
||||||
|
window_penalty = window_penalty.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 is still progressing, count size.
|
||||||
|
island_size += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// * bpm change is into slider, this is easy acc window
|
||||||
|
if curr_obj.base.is_slider() {
|
||||||
|
effective_ratio *= 0.125;
|
||||||
|
}
|
||||||
|
|
||||||
|
// * bpm change was from a slider, this is easier typically than circle -> circle
|
||||||
|
if prev_obj.base.is_slider() {
|
||||||
|
effective_ratio *= 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
// * repeated island size (ex: triplet -> triplet)
|
||||||
|
if prev_island_size == island_size {
|
||||||
|
effective_ratio *= 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
// * repeated island polartiy (2 -> 4, 3 -> 5)
|
||||||
|
if prev_island_size % 2 == island_size % 2 {
|
||||||
|
effective_ratio *= 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// * previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
|
||||||
|
if last_delta > prev_delta + 10.0 && prev_delta > curr_delta + 10.0 {
|
||||||
|
effective_ratio *= 0.125;
|
||||||
|
}
|
||||||
|
|
||||||
|
rhythm_complexity_sum += (effective_ratio * start_ratio).sqrt()
|
||||||
|
* curr_historical_decay
|
||||||
|
* ((4 + island_size) as f64).sqrt()
|
||||||
|
/ 2.0
|
||||||
|
* ((4 + prev_island_size) as f64).sqrt()
|
||||||
|
/ 2.0;
|
||||||
|
|
||||||
|
start_ratio = effective_ratio;
|
||||||
|
|
||||||
|
// * log the last island size.
|
||||||
|
prev_island_size = island_size;
|
||||||
|
|
||||||
|
// * 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
island_size = 1;
|
||||||
|
}
|
||||||
|
} 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)
|
||||||
|
let res = (4.0 + rhythm_complexity_sum * Self::RHYTHM_MULTIPLIER).sqrt() / 2.0;
|
||||||
|
|
||||||
|
println!("res={res}");
|
||||||
|
|
||||||
|
res
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
use crate::beatmap::{Beatmap, ControlPoint, ControlPointIter};
|
|
||||||
|
|
||||||
pub(crate) struct SliderState<'p> {
|
|
||||||
control_points: ControlPointIter<'p>,
|
|
||||||
next: Option<ControlPoint>,
|
|
||||||
pub(crate) beat_len: f64,
|
|
||||||
pub(crate) slider_velocity: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'p> SliderState<'p> {
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn new(map: &'p Beatmap) -> Self {
|
|
||||||
let mut control_points = ControlPointIter::new(map);
|
|
||||||
|
|
||||||
let (beat_len, slider_velocity) = match control_points.next() {
|
|
||||||
Some(ControlPoint::Timing(point)) => (point.beat_len, 1.0),
|
|
||||||
Some(ControlPoint::Difficulty(point)) => (1000.0, point.speed_multiplier),
|
|
||||||
None => (1000.0, 1.0),
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
next: control_points.next(),
|
|
||||||
control_points,
|
|
||||||
beat_len,
|
|
||||||
slider_velocity,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn update(&mut self, time: f64) {
|
|
||||||
while let Some(next) = self.next.as_ref().filter(|n| time >= n.time()) {
|
|
||||||
match next {
|
|
||||||
ControlPoint::Timing(point) => {
|
|
||||||
self.beat_len = point.beat_len;
|
|
||||||
self.slider_velocity = 1.0;
|
|
||||||
}
|
|
||||||
ControlPoint::Difficulty(point) => self.slider_velocity = point.speed_multiplier,
|
|
||||||
}
|
|
||||||
|
|
||||||
self.next = self.control_points.next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use crate::beatmap::{Beatmap, DifficultyPoint, TimingPoint};
|
|
||||||
|
|
||||||
use super::SliderState;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn osu_slider_state() {
|
|
||||||
let map = Beatmap {
|
|
||||||
timing_points: vec![
|
|
||||||
TimingPoint {
|
|
||||||
time: 1.0,
|
|
||||||
beat_len: 10.0,
|
|
||||||
kiai: false,
|
|
||||||
},
|
|
||||||
TimingPoint {
|
|
||||||
time: 3.0,
|
|
||||||
beat_len: 20.0,
|
|
||||||
kiai: false,
|
|
||||||
},
|
|
||||||
TimingPoint {
|
|
||||||
time: 4.0,
|
|
||||||
beat_len: 30.0,
|
|
||||||
kiai: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
difficulty_points: vec![
|
|
||||||
DifficultyPoint {
|
|
||||||
time: 2.0,
|
|
||||||
speed_multiplier: 15.0,
|
|
||||||
kiai: false,
|
|
||||||
},
|
|
||||||
DifficultyPoint {
|
|
||||||
time: 5.0,
|
|
||||||
speed_multiplier: 45.0,
|
|
||||||
kiai: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut state = SliderState::new(&map);
|
|
||||||
|
|
||||||
state.update(2.0);
|
|
||||||
assert!((state.beat_len - 10.0).abs() <= f64::EPSILON);
|
|
||||||
|
|
||||||
state.update(3.0);
|
|
||||||
assert!((state.beat_len - 20.0).abs() <= f64::EPSILON);
|
|
||||||
assert!((state.slider_velocity - 1.0).abs() <= f64::EPSILON);
|
|
||||||
|
|
||||||
state.update(5.0);
|
|
||||||
assert!((state.beat_len - 30.0).abs() <= f64::EPSILON);
|
|
||||||
assert!((state.slider_velocity - 45.0).abs() <= f64::EPSILON);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-20
@@ -1,9 +1,4 @@
|
|||||||
use std::{
|
use std::{error::Error as StdError, fmt, io::Error as IoError, num::ParseFloatError};
|
||||||
error::Error as StdError,
|
|
||||||
fmt,
|
|
||||||
io::Error as IoError,
|
|
||||||
num::{ParseFloatError, ParseIntError},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// `Result<_, ParseError>`
|
/// `Result<_, ParseError>`
|
||||||
pub type ParseResult<T> = Result<T, ParseError>;
|
pub type ParseResult<T> = Result<T, ParseError>;
|
||||||
@@ -22,14 +17,10 @@ pub enum ParseError {
|
|||||||
InvalidCurvePoints,
|
InvalidCurvePoints,
|
||||||
/// Expected a decimal number, got something else.
|
/// Expected a decimal number, got something else.
|
||||||
InvalidDecimalNumber,
|
InvalidDecimalNumber,
|
||||||
/// Expected an integer, got something else.
|
|
||||||
InvalidInteger,
|
|
||||||
/// Failed to parse game mode.
|
/// Failed to parse game mode.
|
||||||
InvalidMode,
|
InvalidMode,
|
||||||
/// Expected an additional field.
|
/// Expected an additional field.
|
||||||
MissingField(&'static str),
|
MissingField(&'static str),
|
||||||
/// Reject maps with too many repeat points.
|
|
||||||
TooManyRepeats,
|
|
||||||
/// Failed to recognized specified type for hitobjects.
|
/// Failed to recognized specified type for hitobjects.
|
||||||
UnknownHitObjectKind,
|
UnknownHitObjectKind,
|
||||||
}
|
}
|
||||||
@@ -43,11 +34,9 @@ impl fmt::Display for ParseError {
|
|||||||
}
|
}
|
||||||
Self::BadLine => f.write_str("line not in `Key:Value` pattern"),
|
Self::BadLine => f.write_str("line not in `Key:Value` pattern"),
|
||||||
Self::InvalidCurvePoints => f.write_str("invalid curve point"),
|
Self::InvalidCurvePoints => f.write_str("invalid curve point"),
|
||||||
Self::InvalidInteger => f.write_str("invalid integer"),
|
|
||||||
Self::InvalidDecimalNumber => f.write_str("invalid float number"),
|
Self::InvalidDecimalNumber => f.write_str("invalid float number"),
|
||||||
Self::InvalidMode => f.write_str("invalid mode"),
|
Self::InvalidMode => f.write_str("invalid mode"),
|
||||||
Self::MissingField(field) => write!(f, "missing field `{}`", field),
|
Self::MissingField(field) => write!(f, "missing field `{}`", field),
|
||||||
Self::TooManyRepeats => f.write_str("repeat count is way too high"),
|
|
||||||
Self::UnknownHitObjectKind => f.write_str("unsupported hitobject kind"),
|
Self::UnknownHitObjectKind => f.write_str("unsupported hitobject kind"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,11 +49,9 @@ impl StdError for ParseError {
|
|||||||
Self::IncorrectFileHeader => None,
|
Self::IncorrectFileHeader => None,
|
||||||
Self::BadLine => None,
|
Self::BadLine => None,
|
||||||
Self::InvalidCurvePoints => None,
|
Self::InvalidCurvePoints => None,
|
||||||
Self::InvalidInteger => None,
|
|
||||||
Self::InvalidDecimalNumber => None,
|
Self::InvalidDecimalNumber => None,
|
||||||
Self::InvalidMode => None,
|
Self::InvalidMode => None,
|
||||||
Self::MissingField(_) => None,
|
Self::MissingField(_) => None,
|
||||||
Self::TooManyRepeats => None,
|
|
||||||
Self::UnknownHitObjectKind => None,
|
Self::UnknownHitObjectKind => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,12 +63,6 @@ impl From<IoError> for ParseError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ParseIntError> for ParseError {
|
|
||||||
fn from(_: ParseIntError) -> Self {
|
|
||||||
Self::InvalidInteger
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ParseFloatError> for ParseError {
|
impl From<ParseFloatError> for ParseError {
|
||||||
fn from(_: ParseFloatError) -> Self {
|
fn from(_: ParseFloatError) -> Self {
|
||||||
Self::InvalidDecimalNumber
|
Self::InvalidDecimalNumber
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ pub enum HitObjectKind {
|
|||||||
/// A full slider object.
|
/// A full slider object.
|
||||||
Slider {
|
Slider {
|
||||||
/// Total length of the slider in pixels.
|
/// Total length of the slider in pixels.
|
||||||
pixel_len: f64,
|
pixel_len: Option<f64>,
|
||||||
/// The amount of repeat points of the slider.
|
/// The amount of repeat points of the slider.
|
||||||
repeats: usize,
|
repeats: usize,
|
||||||
/// The control points of the slider.
|
/// The control points of the slider.
|
||||||
|
|||||||
+198
-96
@@ -14,7 +14,10 @@ pub use slider_parsing::*;
|
|||||||
use reader::FileReader;
|
use reader::FileReader;
|
||||||
pub(crate) use sort::legacy_sort;
|
pub(crate) use sort::legacy_sort;
|
||||||
|
|
||||||
use std::cmp::Ordering;
|
use std::{
|
||||||
|
cmp::Ordering,
|
||||||
|
ops::{ControlFlow, Neg},
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
|
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
|
||||||
use std::{fs::File, io::Read};
|
use std::{fs::File, io::Read};
|
||||||
@@ -44,18 +47,33 @@ impl<T> OptionExt<T> for Option<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
trait FloatExt: Sized {
|
trait InRange: Sized + Copy + Neg<Output = Self> + PartialOrd {
|
||||||
fn validate(self) -> Result<Self, ParseError>;
|
const LIMIT: Self;
|
||||||
}
|
|
||||||
|
|
||||||
impl FloatExt for f64 {
|
fn is_in_range(&self) -> bool {
|
||||||
fn validate(self) -> Result<Self, ParseError> {
|
(-Self::LIMIT..=Self::LIMIT).contains(self)
|
||||||
self.is_finite()
|
}
|
||||||
.then(|| self)
|
|
||||||
.ok_or(ParseError::InvalidDecimalNumber)
|
fn is_in_custom_range(&self, limit: Self) -> bool {
|
||||||
|
(-limit..=limit).contains(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl InRange for i32 {
|
||||||
|
const LIMIT: Self = i32::MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InRange for f32 {
|
||||||
|
const LIMIT: Self = i32::MAX as f32;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InRange for f64 {
|
||||||
|
const LIMIT: Self = i32::MAX as f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_COORDINATE_VALUE: i32 = 131_072;
|
||||||
|
const KIAI_FLAG: i32 = 1 << 0;
|
||||||
|
|
||||||
macro_rules! section {
|
macro_rules! section {
|
||||||
($map:ident, $func:ident, $reader:ident, $section:ident) => {{
|
($map:ident, $func:ident, $reader:ident, $section:ident) => {{
|
||||||
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
|
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
|
||||||
@@ -201,14 +219,11 @@ macro_rules! parse_events_body {
|
|||||||
|
|
||||||
macro_rules! parse_timingpoints_body {
|
macro_rules! parse_timingpoints_body {
|
||||||
($self:ident, $reader:ident, $section:ident) => {{
|
($self:ident, $reader:ident, $section:ident) => {{
|
||||||
let mut unsorted_timings = false;
|
|
||||||
let mut unsorted_difficulties = false;
|
|
||||||
|
|
||||||
let mut prev_diff = 0.0;
|
|
||||||
let mut prev_time = 0.0;
|
|
||||||
|
|
||||||
let mut empty = true;
|
let mut empty = true;
|
||||||
|
|
||||||
|
let mut pending_diff_points_time = 0.0;
|
||||||
|
let mut pending_diff_point = None;
|
||||||
|
|
||||||
while next_line!($reader)? != 0 {
|
while next_line!($reader)? != 0 {
|
||||||
if let Some(bytes) = $reader.get_section() {
|
if let Some(bytes) = $reader.get_section() {
|
||||||
*$section = Section::from_bytes(bytes);
|
*$section = Section::from_bytes(bytes);
|
||||||
@@ -219,65 +234,125 @@ macro_rules! parse_timingpoints_body {
|
|||||||
let line = $reader.get_line()?;
|
let line = $reader.get_line()?;
|
||||||
let mut split = line.split(',');
|
let mut split = line.split(',');
|
||||||
|
|
||||||
let time = split
|
let time: f64 = split
|
||||||
.next()
|
.next()
|
||||||
.next_field("timing point time")?
|
.next_field("timing point time")?
|
||||||
.trim()
|
.trim()
|
||||||
.parse::<f64>()?
|
.parse()?;
|
||||||
.validate()?;
|
|
||||||
|
|
||||||
|
if !time.is_in_range() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// * beatLength is allowed to be NaN to handle an edge case in which
|
||||||
|
// * some beatmaps use NaN slider velocity to disable slider tick
|
||||||
|
// * generation (see LegacyDifficultyControlPoint).
|
||||||
let beat_len: f64 = split.next().next_field("beat len")?.trim().parse()?;
|
let beat_len: f64 = split.next().next_field("beat len")?.trim().parse()?;
|
||||||
let timing_change = split.nth(4).and_then(|value| value.bytes().next());
|
|
||||||
let effect_flags = split.next().and_then(|value| value.bytes().next());
|
|
||||||
|
|
||||||
let kiai = matches!(effect_flags, Some(b'1'));
|
if !(beat_len.is_in_range() || beat_len.is_nan()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if matches!(timing_change, Some(b'1') | None) {
|
let mut timing_change = true;
|
||||||
let beat_len = beat_len.clamp(6.0, 60_000.0);
|
let mut kiai = false;
|
||||||
|
|
||||||
|
enum Status {
|
||||||
|
Ok,
|
||||||
|
Err,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_remaining<'s, I>(
|
||||||
|
mut split: I,
|
||||||
|
timing_change: &mut bool,
|
||||||
|
kiai: &mut bool,
|
||||||
|
) -> Status
|
||||||
|
where
|
||||||
|
I: Iterator<Item = &'s str>,
|
||||||
|
{
|
||||||
|
match split
|
||||||
|
.next()
|
||||||
|
.filter(|&sig| !sig.starts_with('0'))
|
||||||
|
.map(str::parse::<i32>)
|
||||||
|
{
|
||||||
|
Some(Ok(time_sig)) if !time_sig.is_in_range() || time_sig < 1 => {
|
||||||
|
return Status::Err
|
||||||
|
}
|
||||||
|
Some(Ok(_)) => {}
|
||||||
|
None => return Status::Ok,
|
||||||
|
Some(Err(_)) => return Status::Err,
|
||||||
|
}
|
||||||
|
|
||||||
|
match split.next().map(str::parse::<i32>) {
|
||||||
|
Some(Ok(sample_set)) if !sample_set.is_in_range() => return Status::Err,
|
||||||
|
Some(Ok(_)) => {}
|
||||||
|
None => return Status::Ok,
|
||||||
|
Some(Err(_)) => return Status::Err,
|
||||||
|
}
|
||||||
|
|
||||||
|
match split.next().map(str::parse::<i32>) {
|
||||||
|
Some(Ok(custom_sample)) if !custom_sample.is_in_range() => return Status::Err,
|
||||||
|
Some(Ok(_)) => {}
|
||||||
|
None => return Status::Ok,
|
||||||
|
Some(Err(_)) => return Status::Err,
|
||||||
|
}
|
||||||
|
|
||||||
|
match split.next().map(str::parse::<i32>) {
|
||||||
|
Some(Ok(sample_volume)) if !sample_volume.is_in_range() => return Status::Err,
|
||||||
|
Some(Ok(_)) => {}
|
||||||
|
None => return Status::Ok,
|
||||||
|
Some(Err(_)) => return Status::Err,
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(byte) = split.next().and_then(|value| value.bytes().next()) {
|
||||||
|
*timing_change = byte == b'1';
|
||||||
|
} else {
|
||||||
|
return Status::Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
match split.next().map(str::parse::<i32>) {
|
||||||
|
Some(Ok(effect_flags)) if !effect_flags.is_in_range() => return Status::Err,
|
||||||
|
Some(Ok(effect_flags)) => *kiai = (effect_flags & KIAI_FLAG) > 0,
|
||||||
|
None => return Status::Ok,
|
||||||
|
Some(Err(_)) => return Status::Err,
|
||||||
|
}
|
||||||
|
|
||||||
|
Status::Ok
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Status::Err = parse_remaining(split, &mut timing_change, &mut kiai) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if timing_change {
|
||||||
let point = TimingPoint {
|
let point = TimingPoint {
|
||||||
time,
|
time,
|
||||||
beat_len,
|
beat_len: beat_len.clamp(6.0, 60_000.0),
|
||||||
kiai,
|
kiai,
|
||||||
};
|
};
|
||||||
|
|
||||||
$self.timing_points.push(point);
|
$self.timing_points.push(point);
|
||||||
|
}
|
||||||
|
|
||||||
if time < prev_time {
|
// * If beatLength is NaN, speedMultiplier should still be 1
|
||||||
unsorted_timings = true;
|
// * because all comparisons against NaN are false.
|
||||||
} else {
|
let speed_multiplier = if beat_len < 0.0 {
|
||||||
prev_time = time;
|
(100.0 / -beat_len)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let speed_multiplier = if beat_len < 0.0 {
|
1.0
|
||||||
(-100.0 / beat_len).clamp(0.1, 10.0)
|
};
|
||||||
} else {
|
|
||||||
1.0
|
|
||||||
};
|
|
||||||
|
|
||||||
let point = DifficultyPoint {
|
if time != pending_diff_points_time {
|
||||||
time,
|
if let Some(point) = pending_diff_point.take() {
|
||||||
speed_multiplier,
|
$self.difficulty_points.push_if_not_redundant(point);
|
||||||
kiai,
|
|
||||||
};
|
|
||||||
|
|
||||||
$self.difficulty_points.push(point);
|
|
||||||
|
|
||||||
if time < prev_diff {
|
|
||||||
unsorted_difficulties = true;
|
|
||||||
} else {
|
|
||||||
prev_diff = time;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pending_diff_point = Some(DifficultyPoint::new(time, beat_len, speed_multiplier, kiai));
|
||||||
|
pending_diff_points_time = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
if unsorted_timings {
|
$self.timing_points.dedup_by_key(|point| point.time);
|
||||||
sort_unstable(&mut $self.timing_points);
|
$self.difficulty_points.dedup_by_key(|point| point.time);
|
||||||
}
|
|
||||||
|
|
||||||
if unsorted_difficulties {
|
|
||||||
sort_unstable(&mut $self.difficulty_points);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(empty)
|
Ok(empty)
|
||||||
}};
|
}};
|
||||||
@@ -308,24 +383,36 @@ macro_rules! parse_hitobjects_body {
|
|||||||
let line = $reader.get_line()?;
|
let line = $reader.get_line()?;
|
||||||
let mut split = line.split(',');
|
let mut split = line.split(',');
|
||||||
|
|
||||||
let pos = Pos2 {
|
let x: f32 = split.next().next_field("x pos")?.parse()?;
|
||||||
x: split.next().next_field("x pos")?.parse()?,
|
let y: f32 = split.next().next_field("y pos")?.parse()?;
|
||||||
y: split.next().next_field("y pos")?.parse()?,
|
|
||||||
};
|
|
||||||
|
|
||||||
let time = split
|
if !(x.is_in_custom_range(MAX_COORDINATE_VALUE as f32)
|
||||||
.next()
|
&& y.is_in_custom_range(MAX_COORDINATE_VALUE as f32))
|
||||||
.next_field("hitobject time")?
|
{
|
||||||
.trim()
|
continue;
|
||||||
.parse::<f64>()?
|
}
|
||||||
.validate()?;
|
|
||||||
|
let pos = Pos2 { x, y };
|
||||||
|
|
||||||
|
let time: f64 = split.next().next_field("hitobject time")?.trim().parse()?;
|
||||||
|
|
||||||
|
if !time.is_in_range() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if !$self.hit_objects.is_empty() && time < prev_time {
|
if !$self.hit_objects.is_empty() && time < prev_time {
|
||||||
unsorted = true;
|
unsorted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let kind: u8 = split.next().next_field("hitobject kind")?.parse()?;
|
let kind: u8 = match split.next().next_field("hitobject kind")?.parse() {
|
||||||
let sound = split.next().map(str::parse).transpose()?.unwrap_or(0);
|
Ok(kind) => kind,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sound: u8 = match split.next().next_field("sound")?.parse() {
|
||||||
|
Ok(sound) => sound,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
let kind = if kind & Self::CIRCLE_FLAG > 0 {
|
let kind = if kind & Self::CIRCLE_FLAG > 0 {
|
||||||
$self.n_circles += 1;
|
$self.n_circles += 1;
|
||||||
@@ -337,15 +424,13 @@ macro_rules! parse_hitobjects_body {
|
|||||||
let mut control_points = Vec::new();
|
let mut control_points = Vec::new();
|
||||||
|
|
||||||
let control_point_iter = split.next().next_field("control points")?.split('|');
|
let control_point_iter = split.next().next_field("control points")?.split('|');
|
||||||
let mut repeats: usize = split.next().next_field("repeats")?.parse()?;
|
|
||||||
|
|
||||||
if repeats > 9000 {
|
let repeats = match split.next().next_field("repeats")?.parse::<usize>() {
|
||||||
return Err(ParseError::TooManyRepeats);
|
// * osu-stable treated the first span of the slider
|
||||||
}
|
// * as a repeat, but no repeats are happening
|
||||||
|
Ok(repeats @ 0..=9000) => repeats.saturating_sub(1),
|
||||||
// * osu-stable treated the first span of the slider
|
Ok(_) | Err(_) => continue,
|
||||||
// * as a repeat, but no repeats are happening
|
};
|
||||||
repeats = repeats.saturating_sub(1);
|
|
||||||
|
|
||||||
let mut start_idx = 0;
|
let mut start_idx = 0;
|
||||||
let mut end_idx = 0;
|
let mut end_idx = 0;
|
||||||
@@ -402,25 +487,25 @@ macro_rules! parse_hitobjects_body {
|
|||||||
if control_points.is_empty() {
|
if control_points.is_empty() {
|
||||||
HitObjectKind::Circle
|
HitObjectKind::Circle
|
||||||
} else {
|
} else {
|
||||||
let pixel_len = split
|
let pixel_len = match split.next().map(str::parse::<f64>) {
|
||||||
.next()
|
Some(Ok(len)) if len.is_in_custom_range(MAX_COORDINATE_VALUE as f64) => {
|
||||||
.next_field("pixel len")?
|
(len != 0.0).then_some(len)
|
||||||
.parse::<f64>()?
|
}
|
||||||
.max(0.0)
|
Some(_) => continue,
|
||||||
.min(MAX_COORDINATE_VALUE);
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
let edge_sounds_opt = split.next().map(|sounds| {
|
let edge_sounds_opt = split.next().map(|sounds| {
|
||||||
sounds
|
sounds
|
||||||
.split('|')
|
.split('|')
|
||||||
.take(repeats + 2)
|
.take(repeats + 2)
|
||||||
.map(parse_custom_sound)
|
.map(parse_custom_sound)
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect()
|
||||||
});
|
});
|
||||||
|
|
||||||
let edge_sounds = match edge_sounds_opt {
|
let edge_sounds = match edge_sounds_opt {
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
Some(Ok(sounds)) => sounds,
|
Some(sounds) => sounds,
|
||||||
Some(Err(err)) => return Err(err),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
HitObjectKind::Slider {
|
HitObjectKind::Slider {
|
||||||
@@ -432,18 +517,27 @@ macro_rules! parse_hitobjects_body {
|
|||||||
}
|
}
|
||||||
} else if kind & Self::SPINNER_FLAG > 0 {
|
} else if kind & Self::SPINNER_FLAG > 0 {
|
||||||
$self.n_spinners += 1;
|
$self.n_spinners += 1;
|
||||||
let end_time = split.next().next_field("spinner endtime")?.parse()?;
|
|
||||||
|
let end_time = match split.next().next_field("spinner endtime")?.parse::<f64>() {
|
||||||
|
Ok(end_time) => end_time.max(0.0),
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
HitObjectKind::Spinner { end_time }
|
HitObjectKind::Spinner { end_time }
|
||||||
} else if kind & Self::HOLD_FLAG > 0 {
|
} else if kind & Self::HOLD_FLAG > 0 {
|
||||||
$self.n_sliders += 1;
|
$self.n_sliders += 1;
|
||||||
let mut end = time;
|
|
||||||
|
|
||||||
if let Some(next) = split.next() {
|
let end_time = match split
|
||||||
end = end.max(next.split(':').next().next_field("hold endtime")?.parse()?);
|
.next()
|
||||||
}
|
.and_then(|next| next.split(':').next())
|
||||||
|
.map(str::parse::<f64>)
|
||||||
|
{
|
||||||
|
Some(Ok(time_)) if time_.is_in_range() => time_.max(time),
|
||||||
|
Some(_) => continue,
|
||||||
|
None => time,
|
||||||
|
};
|
||||||
|
|
||||||
HitObjectKind::Hold { end_time: end }
|
HitObjectKind::Hold { end_time }
|
||||||
} else {
|
} else {
|
||||||
return Err(ParseError::UnknownHitObjectKind);
|
return Err(ParseError::UnknownHitObjectKind);
|
||||||
};
|
};
|
||||||
@@ -453,6 +547,7 @@ macro_rules! parse_hitobjects_body {
|
|||||||
start_time: time,
|
start_time: time,
|
||||||
kind,
|
kind,
|
||||||
});
|
});
|
||||||
|
|
||||||
$self.sounds.push(sound);
|
$self.sounds.push(sound);
|
||||||
|
|
||||||
prev_time = time;
|
prev_time = time;
|
||||||
@@ -476,12 +571,21 @@ macro_rules! parse_hitobjects_body {
|
|||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required for maps with slider edge sound values above 255 e.g. map id 80799
|
// Required for maps with slider edge sound values above 255 e.g. map /b/80799
|
||||||
fn parse_custom_sound(sound: &str) -> ParseResult<u8> {
|
fn parse_custom_sound(sound: &str) -> u8 {
|
||||||
sound.bytes().try_fold(0_u8, |sound, byte| match byte {
|
fn fold_str(sound: &str) -> ControlFlow<u8, u8> {
|
||||||
b'0'..=b'9' => Ok(sound.wrapping_mul(10).wrapping_add((byte & 0xF) as u8)),
|
sound.bytes().try_fold(0_u8, |sound, byte| match byte {
|
||||||
_ => Err(ParseError::InvalidInteger),
|
b'0'..=b'9' => {
|
||||||
})
|
ControlFlow::Continue(sound.wrapping_mul(10).wrapping_add((byte & 0xF) as u8))
|
||||||
|
}
|
||||||
|
_ => ControlFlow::Break(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
match fold_str(sound) {
|
||||||
|
ControlFlow::Continue(n) => n,
|
||||||
|
ControlFlow::Break(n) => n,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! parse_body {
|
macro_rules! parse_body {
|
||||||
@@ -539,8 +643,6 @@ mod slider_parsing {
|
|||||||
|
|
||||||
use super::Pos2;
|
use super::Pos2;
|
||||||
|
|
||||||
pub(super) const MAX_COORDINATE_VALUE: f64 = 131_072.0;
|
|
||||||
|
|
||||||
pub(super) fn convert_points(
|
pub(super) fn convert_points(
|
||||||
points: &[&str],
|
points: &[&str],
|
||||||
end_point: Option<&str>,
|
end_point: Option<&str>,
|
||||||
|
|||||||
+8
-4
@@ -32,13 +32,13 @@ impl Pos2 {
|
|||||||
/// Return the position's length.
|
/// Return the position's length.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn length(&self) -> f32 {
|
pub fn length(&self) -> f32 {
|
||||||
self.x.hypot(self.y)
|
((self.x * self.x + self.y * self.y) as f64).sqrt() as f32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the dot product.
|
/// Return the dot product.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn dot(&self, other: Self) -> f32 {
|
pub fn dot(&self, other: Self) -> f32 {
|
||||||
self.x.mul_add(other.x, self.y * other.y)
|
(self.x * other.x) + (self.y * other.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the distance to another position.
|
/// Return the distance to another position.
|
||||||
@@ -49,8 +49,12 @@ impl Pos2 {
|
|||||||
|
|
||||||
/// Normalize the coordinates with respect to the vector's length.
|
/// Normalize the coordinates with respect to the vector's length.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn normalize(self) -> Pos2 {
|
pub fn normalize(mut self) -> Pos2 {
|
||||||
self / self.length()
|
let scale = self.length().recip();
|
||||||
|
self.x *= scale;
|
||||||
|
self.y *= scale;
|
||||||
|
|
||||||
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -138,7 +138,7 @@ impl<R> FileReader<R> {
|
|||||||
.and_then(|idx| {
|
.and_then(|idx| {
|
||||||
self.buf[idx..]
|
self.buf[idx..]
|
||||||
.starts_with(b"osu file format v")
|
.starts_with(b"osu file format v")
|
||||||
.then(|| idx + 17)
|
.then_some(idx + 17)
|
||||||
})
|
})
|
||||||
.map(|idx| {
|
.map(|idx| {
|
||||||
let mut n = 0;
|
let mut n = 0;
|
||||||
@@ -235,7 +235,7 @@ impl<R> FileReader<R> {
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.rev()
|
.rev()
|
||||||
.find_map(|(i, byte)| (!matches!(byte, b' ' | b'\t')).then(|| i + 1))
|
.find_map(|(i, byte)| (!matches!(byte, b' ' | b'\t')).then_some(i + 1))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
self.buf.truncate(len);
|
self.buf.truncate(len);
|
||||||
|
|||||||
+10
-13
@@ -5,23 +5,20 @@ mod gradual_performance;
|
|||||||
mod pp;
|
mod pp;
|
||||||
mod rim;
|
mod rim;
|
||||||
mod skills;
|
mod skills;
|
||||||
mod stamina_cheese;
|
|
||||||
mod taiko_object;
|
mod taiko_object;
|
||||||
|
|
||||||
pub use gradual_difficulty::*;
|
|
||||||
pub use gradual_performance::*;
|
|
||||||
pub use pp::*;
|
|
||||||
use rim::Rim;
|
|
||||||
use taiko_object::IntoTaikoObjectIter;
|
|
||||||
|
|
||||||
use crate::beatmap::BeatmapHitWindows;
|
|
||||||
use crate::{Beatmap, GameMode, Mods, OsuStars};
|
|
||||||
|
|
||||||
use std::{borrow::Cow, cell::RefCell, rc::Rc};
|
use std::{borrow::Cow, cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
use self::colours::ColourDifficultyPreprocessor;
|
pub use self::{gradual_difficulty::*, gradual_performance::*, pp::*};
|
||||||
use self::difficulty_object::{MonoIndex, ObjectLists, TaikoDifficultyObject};
|
|
||||||
use self::skills::{Peaks, PeaksDifficultyValues, PeaksRaw, Skill};
|
use crate::{beatmap::BeatmapHitWindows, Beatmap, GameMode, Mods, OsuStars};
|
||||||
|
|
||||||
|
use self::{
|
||||||
|
colours::ColourDifficultyPreprocessor,
|
||||||
|
difficulty_object::{MonoIndex, ObjectLists, TaikoDifficultyObject},
|
||||||
|
skills::{Peaks, PeaksDifficultyValues, PeaksRaw, Skill},
|
||||||
|
taiko_object::IntoTaikoObjectIter,
|
||||||
|
};
|
||||||
|
|
||||||
const SECTION_LEN: usize = 400;
|
const SECTION_LEN: usize = 400;
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
use super::Rim;
|
|
||||||
use crate::{limited_queue::LimitedQueue, Beatmap};
|
|
||||||
|
|
||||||
const ROLL_MIN_REPETITIONS: usize = 12;
|
|
||||||
const TL_MIN_REPETITIONS: isize = 16;
|
|
||||||
|
|
||||||
pub(crate) trait StaminaCheeseDetector {
|
|
||||||
fn find_cheese(&self) -> Vec<bool>;
|
|
||||||
fn find_rolls<const PATTERN_LEN: usize, const DOUBLE_PATTERN_LEN: usize>(
|
|
||||||
&self,
|
|
||||||
cheese: &mut [bool],
|
|
||||||
);
|
|
||||||
fn find_tl_tap<const PARITY: usize, const IS_RIN: bool>(&self, cheese: &mut [bool]);
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StaminaCheeseDetector for Beatmap {
|
|
||||||
fn find_cheese(&self) -> Vec<bool> {
|
|
||||||
let mut cheese = vec![false; self.hit_objects.len()];
|
|
||||||
|
|
||||||
self.find_rolls::<3, 6>(&mut cheese);
|
|
||||||
self.find_rolls::<4, 8>(&mut cheese);
|
|
||||||
|
|
||||||
self.find_tl_tap::<0, true>(&mut cheese);
|
|
||||||
self.find_tl_tap::<1, true>(&mut cheese);
|
|
||||||
self.find_tl_tap::<0, false>(&mut cheese);
|
|
||||||
self.find_tl_tap::<1, false>(&mut cheese);
|
|
||||||
|
|
||||||
cheese
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_rolls<const PATTERN_LEN: usize, const DOUBLE_PATTERN_LEN: usize>(
|
|
||||||
&self,
|
|
||||||
cheese: &mut [bool],
|
|
||||||
) {
|
|
||||||
let mut history: LimitedQueue<u8, DOUBLE_PATTERN_LEN> = LimitedQueue::new();
|
|
||||||
|
|
||||||
let mut index_before_last_repeat = -1;
|
|
||||||
let mut last_mark_end = 0;
|
|
||||||
|
|
||||||
for (i, &h) in self.sounds.iter().enumerate() {
|
|
||||||
history.push(h);
|
|
||||||
|
|
||||||
if !history.full() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let contains = contains_pattern_repeat::<PATTERN_LEN, DOUBLE_PATTERN_LEN>(&history);
|
|
||||||
|
|
||||||
if !contains {
|
|
||||||
index_before_last_repeat = (i + 1 - history.len()) as isize;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let repeated_len = (i as isize - index_before_last_repeat) as usize;
|
|
||||||
|
|
||||||
if repeated_len < ROLL_MIN_REPETITIONS {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
mark_as_cheese(last_mark_end.max(i + 1 - repeated_len), i, cheese);
|
|
||||||
|
|
||||||
last_mark_end = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_tl_tap<const PARITY: usize, const IS_RIN: bool>(&self, cheese: &mut [bool]) {
|
|
||||||
let mut tl_len = -2;
|
|
||||||
let mut last_mark_end = 0;
|
|
||||||
|
|
||||||
for (i, &sound) in self.sounds.iter().enumerate().skip(PARITY).step_by(2) {
|
|
||||||
if sound.is_rim() == IS_RIN {
|
|
||||||
tl_len += 2;
|
|
||||||
} else {
|
|
||||||
tl_len = -2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if tl_len < TL_MIN_REPETITIONS {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let start = (i as isize + 1 - tl_len).max(last_mark_end as isize);
|
|
||||||
mark_as_cheese(start as usize, i, cheese);
|
|
||||||
|
|
||||||
last_mark_end = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn mark_as_cheese(start: usize, end: usize, cheese: &mut [bool]) {
|
|
||||||
cheese
|
|
||||||
.iter_mut()
|
|
||||||
.take(end + 1)
|
|
||||||
.skip(start)
|
|
||||||
.for_each(|b| *b = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn contains_pattern_repeat<const PATTERN_LEN: usize, const DOUBLE_PATTERN_LEN: usize>(
|
|
||||||
history: &LimitedQueue<u8, DOUBLE_PATTERN_LEN>,
|
|
||||||
) -> bool {
|
|
||||||
for (&curr, &to_compare) in history.iter().zip(history.iter().skip(PATTERN_LEN)) {
|
|
||||||
if curr.is_rim() != to_compare.is_rim() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user