wip osu pp update
This commit is contained in:
@@ -33,13 +33,52 @@ impl Default for TimingPoint {
|
||||
/// [`TimingPoint`] that depends on a previous one.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct DifficultyPoint {
|
||||
/// The start time for the current speed multiplier
|
||||
/// The time at which the control point takes effect.
|
||||
pub time: f64,
|
||||
/// The speed multiplier until the next timing point
|
||||
pub speed_multiplier: f64,
|
||||
/// The slider velocity at this control point.
|
||||
pub slider_vel: f64,
|
||||
/// Whether the section between this and the
|
||||
/// next timing points is a kiai section
|
||||
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 {
|
||||
@@ -52,8 +91,10 @@ impl Default for DifficultyPoint {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
time: 0.0,
|
||||
speed_multiplier: 1.0,
|
||||
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]
|
||||
fn control_point_iter() {
|
||||
let map = Beatmap {
|
||||
timing_points: vec![
|
||||
TimingPoint {
|
||||
time: 1.0,
|
||||
beat_len: 10.0,
|
||||
kiai: false,
|
||||
},
|
||||
TimingPoint {
|
||||
time: 3.0,
|
||||
beat_len: 10.0,
|
||||
kiai: false,
|
||||
},
|
||||
TimingPoint {
|
||||
time: 4.0,
|
||||
beat_len: 10.0,
|
||||
kiai: false,
|
||||
},
|
||||
],
|
||||
difficulty_points: vec![
|
||||
DifficultyPoint {
|
||||
time: 2.0,
|
||||
speed_multiplier: 10.0,
|
||||
kiai: false,
|
||||
},
|
||||
DifficultyPoint {
|
||||
time: 5.0,
|
||||
speed_multiplier: 10.0,
|
||||
kiai: false,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let mut map = Beatmap::default();
|
||||
|
||||
map.timing_points.push(TimingPoint {
|
||||
time: 1.0,
|
||||
beat_len: 10.0,
|
||||
kiai: false,
|
||||
});
|
||||
|
||||
map.timing_points.push(TimingPoint {
|
||||
time: 3.0,
|
||||
beat_len: 10.0,
|
||||
kiai: false,
|
||||
});
|
||||
|
||||
map.timing_points.push(TimingPoint {
|
||||
time: 4.0,
|
||||
beat_len: 10.0,
|
||||
kiai: false,
|
||||
});
|
||||
|
||||
map.difficulty_points
|
||||
.push(DifficultyPoint::new(2.0, 10.0, 10.0, false));
|
||||
map.difficulty_points
|
||||
.push(DifficultyPoint::new(5.0, 10.0, 10.0, false));
|
||||
|
||||
let mut iter = ControlPointIter::new(&map);
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
|
||||
// ! BUG: Since `LegacyDifficultyControlPoint` are not considered while parsing,
|
||||
// ! 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 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 difficulty_point = self.difficulty_point_at(*start_time).unwrap_or_default();
|
||||
|
||||
// ! BUG: Since `LegacyDifficultyControlPoint` are not considered while parsing,
|
||||
// ! this value can be slightly off due to float arithmetics.
|
||||
let mut beat_len = timing_point.beat_len / difficulty_point.speed_multiplier;
|
||||
let mut beat_len = timing_point.beat_len * difficulty_point.bpm_mult;
|
||||
|
||||
let slider_scoring_point_dist =
|
||||
OSU_BASE_SCORING_DIST as f64 * self.slider_mult / self.tick_rate;
|
||||
|
||||
+4
-2
@@ -7,6 +7,7 @@ pub use self::{
|
||||
breaks::Break,
|
||||
control_points::{ControlPoint, ControlPointIter, DifficultyPoint, TimingPoint},
|
||||
mode::GameMode,
|
||||
sorted_vec::SortedVec,
|
||||
};
|
||||
|
||||
mod attributes;
|
||||
@@ -14,6 +15,7 @@ mod breaks;
|
||||
mod control_points;
|
||||
mod converts;
|
||||
mod mode;
|
||||
mod sorted_vec;
|
||||
|
||||
/// The main beatmap struct containing all data relevant
|
||||
/// for difficulty and performance calculation
|
||||
@@ -50,10 +52,10 @@ pub struct Beatmap {
|
||||
pub sounds: Vec<u8>,
|
||||
|
||||
/// 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.
|
||||
pub difficulty_points: Vec<DifficultyPoint>,
|
||||
pub difficulty_points: SortedVec<DifficultyPoint>,
|
||||
|
||||
/// The stack leniency that is used to calculate
|
||||
/// 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,
|
||||
};
|
||||
|
||||
use super::{catch_object::CatchObject, slider_state::SliderState, CatchDifficultyAttributes};
|
||||
use super::{catch_object::CatchObject, CatchDifficultyAttributes};
|
||||
|
||||
const LEGACY_LAST_TICK_OFFSET: f64 = 36.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_time: f64,
|
||||
pub(crate) map: &'a Beatmap,
|
||||
pub(crate) slider_state: SliderState<'a>,
|
||||
pub(crate) ticks: Vec<(Pos2, f64)>,
|
||||
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_time = h.start_time;
|
||||
|
||||
// Responsible for timing point values
|
||||
params.slider_state.update(h.start_time);
|
||||
let timing_point = params.map.timing_point_at(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 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
|
||||
let curve = Curve::new(control_points, *pixel_len, &mut params.curve_bufs);
|
||||
|
||||
let velocity = (BASE_SCORING_DISTANCE
|
||||
* params.map.slider_mult
|
||||
* 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;
|
||||
let total_duration = span_count * curve.dist() / vel;
|
||||
let span_duration = total_duration / span_count;
|
||||
|
||||
// * 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
|
||||
@@ -88,12 +85,13 @@ impl FruitOrJuice {
|
||||
|
||||
let len = curve.dist().min(max_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 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)];
|
||||
|
||||
@@ -112,7 +110,7 @@ impl FruitOrJuice {
|
||||
params.attributes.n_tiny_droplets += tiny_droplet_count(
|
||||
h.start_time,
|
||||
time_add,
|
||||
duration,
|
||||
total_duration,
|
||||
span_count as usize,
|
||||
¶ms.ticks,
|
||||
);
|
||||
@@ -160,7 +158,7 @@ impl FruitOrJuice {
|
||||
// Slider tail
|
||||
let progress = (*repeats % 2 == 0) as u8 as f64;
|
||||
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;
|
||||
params.attributes.n_fruits += new_fruits;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use std::{iter, slice::Iter};
|
||||
|
||||
use crate::{
|
||||
catch::{
|
||||
difficulty_object::DifficultyObject, slider_state::SliderState, SECTION_LENGTH,
|
||||
STAR_SCALING_FACTOR,
|
||||
},
|
||||
catch::{difficulty_object::DifficultyObject, SECTION_LENGTH, STAR_SCALING_FACTOR},
|
||||
curve::CurveBuffers,
|
||||
parse::{HitObject, Pos2},
|
||||
Beatmap, Mods,
|
||||
@@ -179,7 +176,6 @@ impl<'map> CatchObjectIter<'map> {
|
||||
last_pos: None,
|
||||
last_time: 0.0,
|
||||
map,
|
||||
slider_state: SliderState::new(map),
|
||||
ticks: Vec::new(),
|
||||
with_hr: mods.hr(),
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ mod gradual_difficulty;
|
||||
mod gradual_performance;
|
||||
mod movement;
|
||||
mod pp;
|
||||
mod slider_state;
|
||||
|
||||
use catch_object::CatchObject;
|
||||
use difficulty_object::DifficultyObject;
|
||||
@@ -14,7 +13,6 @@ pub use gradual_difficulty::*;
|
||||
pub use gradual_performance::*;
|
||||
use movement::Movement;
|
||||
pub use pp::*;
|
||||
use slider_state::SliderState;
|
||||
|
||||
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_time: 0.0,
|
||||
map,
|
||||
slider_state: SliderState::new(map),
|
||||
ticks: Vec::new(), // using the same buffer for all sliders
|
||||
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};
|
||||
|
||||
@@ -58,7 +58,7 @@ pub(crate) struct Curve {
|
||||
impl Curve {
|
||||
pub(crate) fn new(
|
||||
points: &[PathControlPoint],
|
||||
expected_len: f64,
|
||||
expected_len: Option<f64>,
|
||||
bufs: &mut CurveBuffers,
|
||||
) -> Self {
|
||||
let mut path = Self::calculate_path(points, bufs);
|
||||
@@ -153,7 +153,7 @@ impl Curve {
|
||||
fn calculate_length(
|
||||
points: &[PathControlPoint],
|
||||
path: &mut Vec<Pos2>,
|
||||
expected_len: f64,
|
||||
expected_len: Option<f64>,
|
||||
) -> Vec<f64> {
|
||||
let mut calculated_len = 0.0;
|
||||
let mut cumulative_len = Vec::with_capacity(path.len());
|
||||
@@ -167,7 +167,7 @@ impl Curve {
|
||||
|
||||
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
|
||||
let condition_opt = points
|
||||
.len()
|
||||
@@ -490,8 +490,8 @@ impl Curve {
|
||||
|
||||
let radius = d_a.length();
|
||||
|
||||
let theta_start = d_a.y.atan2(d_a.x);
|
||||
let mut theta_end = d_c.y.atan2(d_c.x);
|
||||
let theta_start = (d_a.y as f64).atan2(d_a.x as f64);
|
||||
let mut theta_end = (d_c.y as f64).atan2(d_c.x as f64);
|
||||
|
||||
while theta_end < theta_start {
|
||||
theta_end += 2.0 * PI;
|
||||
@@ -515,8 +515,8 @@ impl Curve {
|
||||
}
|
||||
|
||||
Some(CircularArcProperties {
|
||||
theta_start: theta_start as f64,
|
||||
theta_range: theta_range as f64,
|
||||
theta_start,
|
||||
theta_range,
|
||||
direction,
|
||||
radius,
|
||||
centre,
|
||||
|
||||
+2
-2
@@ -36,7 +36,7 @@ use crate::{
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum GradualDifficultyAttributes<'map> {
|
||||
/// Gradual osu!catch difficulty attributes.
|
||||
@@ -268,7 +268,7 @@ impl From<ScoreState> for TaikoScoreState {
|
||||
/// // attempting to process further objects will return `None`.
|
||||
/// assert!(gradual_perf.process_next_object(state).is_none());
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum GradualPerformanceAttributes<'map> {
|
||||
/// Gradual osu!catch performance attributes.
|
||||
|
||||
+15
@@ -513,3 +513,18 @@ impl From<taiko::TaikoPerformanceAttributes> for PerformanceAttributes {
|
||||
|
||||
#[cfg(all(feature = "async_tokio", feature = "async_std"))]
|
||||
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> {
|
||||
self.queue
|
||||
.iter()
|
||||
|
||||
+255
-219
@@ -3,248 +3,284 @@ use crate::{
|
||||
parse::Pos2,
|
||||
};
|
||||
|
||||
use super::{OsuObject, ScalingFactor, NORMALIZED_RADIUS};
|
||||
use super::{osu_object::NestedObject, OsuObject, ScalingFactor};
|
||||
|
||||
const MIN_DELTA_TIME: f64 = 25.0;
|
||||
const MAXIMUM_SLIDER_RADIUS: f32 = NORMALIZED_RADIUS * 2.4;
|
||||
const ASSUMED_SLIDER_RADIUS: f32 = NORMALIZED_RADIUS * 1.8;
|
||||
|
||||
pub(crate) struct DifficultyObject<'h> {
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct OsuDifficultyObject<'h> {
|
||||
pub(crate) start_time: f64,
|
||||
pub(crate) delta_time: f64,
|
||||
pub(crate) base: &'h OsuObject,
|
||||
pub(crate) clock_rate: f64,
|
||||
|
||||
pub(crate) delta: f64,
|
||||
pub(crate) strain_time: f64,
|
||||
|
||||
pub(crate) angle: Option<f64>,
|
||||
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,
|
||||
pub(crate) dists: Distances,
|
||||
pub(crate) idx: usize,
|
||||
}
|
||||
|
||||
impl<'h> DifficultyObject<'h> {
|
||||
pub(super) fn new(
|
||||
impl<'h> OsuDifficultyObject<'h> {
|
||||
pub(crate) const MIN_DELTA_TIME: u32 = 25;
|
||||
|
||||
pub(crate) fn new(
|
||||
base: &'h OsuObject,
|
||||
prev: &mut OsuObject,
|
||||
prev_prev: Option<&OsuObject>,
|
||||
scaling_factor: &ScalingFactor,
|
||||
last: &'h OsuObject,
|
||||
clock_rate: f64,
|
||||
idx: usize,
|
||||
dists: Distances,
|
||||
) -> 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
|
||||
let strain_time = delta.max(MIN_DELTA_TIME);
|
||||
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
||||
let strain_time = delta_time.max(Self::MIN_DELTA_TIME as f64);
|
||||
|
||||
// * We don't need to calculate either angle or distances
|
||||
// * 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);
|
||||
// TODO: remove
|
||||
// println!(
|
||||
// "movement_dist={} | movement_time={}",
|
||||
// movement_dist, movement_time
|
||||
// "[{}] lazy_jump_dist={} | lazy_travel_dist={} | \
|
||||
// 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 {
|
||||
start_time,
|
||||
delta_time,
|
||||
base,
|
||||
clock_rate,
|
||||
delta,
|
||||
strain_time,
|
||||
jump_dist,
|
||||
angle,
|
||||
movement_dist,
|
||||
movement_time,
|
||||
travel_dist,
|
||||
travel_time,
|
||||
dists,
|
||||
idx,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_slider_cursor_pos(
|
||||
prev: &mut OsuObject,
|
||||
stack_offset: Pos2,
|
||||
scaling_factor: f64,
|
||||
clock_rate: f64,
|
||||
) -> (f64, f64) {
|
||||
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)
|
||||
}
|
||||
pub(crate) fn opacity_at(&self, time: f64, hidden: bool) -> f64 {
|
||||
if time > self.base.start_time {
|
||||
// * Consider a hitobject as being invisible when its start time is passed.
|
||||
// * In reality the hitobject will be visible beyond its start time up until its hittable window has passed,
|
||||
// * but this is an approximation and such a case is unlikely to be hit where this function is used.
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_movement_values(
|
||||
prev: &OsuObject,
|
||||
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);
|
||||
let fade_in_start_time = self.base.start_time - self.base.time_preempt;
|
||||
let fade_in_duration = self.base.time_fade_in;
|
||||
|
||||
// * Jump distance from the slider tail to the next object,
|
||||
// * as opposed to the lazy position of JumpDistance.
|
||||
let tail_jump_dist = (*end_pos - base_pos).length() * scaling_factor;
|
||||
if hidden {
|
||||
// * Taken from OsuModHidden.
|
||||
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,
|
||||
// * the player will normally follow through the slider,
|
||||
// * such that they're not jumping from the lazy position but
|
||||
// * rather from very close to (or the end of) the slider.
|
||||
// * 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.
|
||||
// * JumpDistance is the leniency distance beyond the assumed_slider_radius.
|
||||
// * tailJumpDistance is maximum_slider_radius since
|
||||
// * the full distance of radial leniency is still possible.
|
||||
let movement_dist = (jump_dist
|
||||
- (MAXIMUM_SLIDER_RADIUS - ASSUMED_SLIDER_RADIUS) as f64)
|
||||
.min((tail_jump_dist - MAXIMUM_SLIDER_RADIUS) as f64)
|
||||
.max(0.0);
|
||||
|
||||
(movement_dist, movement_time)
|
||||
}
|
||||
(((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0))
|
||||
.min(1.0 - ((time - fade_out_start_time) / fade_out_duration).clamp(0.0, 1.0))
|
||||
} else {
|
||||
((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Distances {
|
||||
pub(crate) lazy_jump_dist: f64,
|
||||
pub(crate) lazy_travel_dist: f32,
|
||||
pub(crate) min_jump_dist: f64,
|
||||
pub(crate) min_jump_time: f64,
|
||||
pub(crate) travel_dist: f64,
|
||||
pub(crate) travel_time: f64,
|
||||
pub(crate) angle: Option<f64>,
|
||||
}
|
||||
|
||||
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 crate::{
|
||||
curve::CurveBuffers, osu::difficulty_object::DifficultyObject, parse::Pos2, Beatmap, Mods,
|
||||
use std::{
|
||||
fmt::{Debug, Formatter, Result as FmtResult},
|
||||
mem,
|
||||
vec::IntoIter,
|
||||
};
|
||||
|
||||
use crate::{curve::CurveBuffers, Beatmap, Mods};
|
||||
|
||||
use super::{
|
||||
calculate_star_rating, old_stacking,
|
||||
create_skills,
|
||||
difficulty_object::{Distances, OsuDifficultyObject},
|
||||
old_stacking,
|
||||
osu_object::{ObjectParameters, OsuObject, OsuObjectKind},
|
||||
scaling_factor::ScalingFactor,
|
||||
skill::{Skill, Skills},
|
||||
slider_state::SliderState,
|
||||
stacking, OsuDifficultyAttributes, DIFFICULTY_MULTIPLIER, SECTION_LEN,
|
||||
skills::{Aim, Flashlight, Skill, Speed},
|
||||
stacking, OsuDifficultyAttributes, DIFFICULTY_MULTIPLIER, PERFORMANCE_BASE_MULTIPLIER,
|
||||
};
|
||||
|
||||
/// Gradually calculate the difficulty attributes of an osu!standard map.
|
||||
@@ -43,39 +46,47 @@ use super::{
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OsuGradualDifficultyAttributes {
|
||||
pub(crate) idx: usize,
|
||||
mods: u32,
|
||||
attributes: OsuDifficultyAttributes,
|
||||
clock_rate: f64,
|
||||
hit_objects: OsuObjectIter,
|
||||
skills: Skills,
|
||||
prev_prev: Option<OsuObject>,
|
||||
prev: OsuObject,
|
||||
curr_section_end: f64,
|
||||
strain_peak_buf: Vec<f64>,
|
||||
hit_objects: Vec<OsuObject>,
|
||||
diff_objects: Vec<OsuDifficultyObject<'static>>,
|
||||
skills: [Box<dyn Skill>; 4],
|
||||
hit_window: 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 {
|
||||
/// Create a new difficulty attributes iterator for osu!standard maps.
|
||||
pub fn new(map: &Beatmap, mods: u32) -> Self {
|
||||
let map_attributes = map.attributes().mods(mods).build();
|
||||
let hit_window = map_attributes.hit_windows.od;
|
||||
let time_preempt = map_attributes.hit_windows.ar;
|
||||
let clock_rate = mods.clock_rate();
|
||||
let map_attrs = map.attributes().mods(mods).build();
|
||||
let scaling_factor = ScalingFactor::new(map_attrs.cs);
|
||||
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 {
|
||||
ar: map_attributes.ar,
|
||||
hp: map_attributes.hp,
|
||||
od: map_attributes.od,
|
||||
let mut attrs = OsuDifficultyAttributes {
|
||||
ar: map_attrs.ar,
|
||||
hp: map_attrs.hp,
|
||||
od: map_attrs.od,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut params = ObjectParameters {
|
||||
map,
|
||||
attributes: &mut attributes,
|
||||
slider_state: SliderState::new(map),
|
||||
attributes: &mut attrs,
|
||||
ticks: Vec::new(),
|
||||
curve_bufs: CurveBuffers::default(),
|
||||
};
|
||||
@@ -88,10 +99,10 @@ impl OsuGradualDifficultyAttributes {
|
||||
let mut hit_objects = Vec::with_capacity(map.hit_objects.len());
|
||||
hit_objects.extend(hit_objects_iter);
|
||||
|
||||
attributes.n_circles = 0;
|
||||
attributes.n_sliders = 0;
|
||||
attributes.n_spinners = 0;
|
||||
attributes.max_combo = 0;
|
||||
attrs.n_circles = 0;
|
||||
attrs.n_sliders = 0;
|
||||
attrs.n_spinners = 0;
|
||||
attrs.max_combo = 0;
|
||||
|
||||
let stack_threshold = time_preempt * map.stack_leniency as f64;
|
||||
|
||||
@@ -101,162 +112,200 @@ impl OsuGradualDifficultyAttributes {
|
||||
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 {
|
||||
hit_objects: hit_objects.into_iter(),
|
||||
scaling_factor,
|
||||
h
|
||||
});
|
||||
|
||||
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 {
|
||||
time: 0.0,
|
||||
pos: Pos2::zero(),
|
||||
stack_height: 0.0,
|
||||
kind: OsuObjectKind::Circle,
|
||||
};
|
||||
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
|
||||
if let OsuObjectKind::Slider {
|
||||
lazy_travel_time,
|
||||
lazy_end_pos,
|
||||
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 {
|
||||
idx: 0,
|
||||
attributes,
|
||||
clock_rate: map_attributes.clock_rate,
|
||||
mods,
|
||||
attributes: attrs,
|
||||
diff_objects: extend_lifetime(diff_objects),
|
||||
hit_objects,
|
||||
skills,
|
||||
curr_section_end: 0.0,
|
||||
prev_prev,
|
||||
prev,
|
||||
strain_peak_buf: Vec::new(),
|
||||
hit_window,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
type Item = OsuDifficultyAttributes;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let curr = self.hit_objects.next()?;
|
||||
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,
|
||||
};
|
||||
|
||||
let curr = self.diff_objects.get(self.idx)?;
|
||||
self.idx += 1;
|
||||
|
||||
if self.idx == 1 {
|
||||
self.prev = curr;
|
||||
self.curr_section_end =
|
||||
(self.prev.time / self.clock_rate / SECTION_LEN).ceil() * SECTION_LEN;
|
||||
|
||||
return Some(self.attributes.clone());
|
||||
for skill in self.skills.iter_mut() {
|
||||
skill.process(curr, &self.diff_objects, self.hit_window);
|
||||
}
|
||||
|
||||
let h = DifficultyObject::new(
|
||||
&curr,
|
||||
&mut self.prev,
|
||||
self.prev_prev.as_ref(),
|
||||
&self.hit_objects.scaling_factor,
|
||||
self.clock_rate,
|
||||
);
|
||||
let mut attrs = self.attributes.clone();
|
||||
|
||||
let base_time = h.base.time / self.clock_rate;
|
||||
attrs.max_combo += 1;
|
||||
|
||||
if self.idx == 2 {
|
||||
while base_time > self.curr_section_end {
|
||||
self.skills.start_new_section_from(self.curr_section_end);
|
||||
self.curr_section_end += SECTION_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;
|
||||
match &curr.base.kind {
|
||||
OsuObjectKind::Circle => attrs.n_circles += 1,
|
||||
OsuObjectKind::Slider { nested_objects, .. } => {
|
||||
attrs.n_sliders += 1;
|
||||
attrs.max_combo += nested_objects.len();
|
||||
}
|
||||
OsuObjectKind::Spinner { .. } => attrs.n_spinners += 1,
|
||||
}
|
||||
|
||||
self.skills.process(&h);
|
||||
self.prev_prev = Some(mem::replace(&mut self.prev, curr));
|
||||
let [aim, aim_no_sliders, speed, flashlight] = &self.skills;
|
||||
|
||||
let missing = self.skills.aim().strain_peaks.len() + 1 - self.strain_peak_buf.len();
|
||||
self.strain_peak_buf.extend(iter::repeat(0.0).take(missing));
|
||||
let mut aim = aim.as_any().downcast_ref::<Aim>().unwrap().clone();
|
||||
|
||||
let aim_rating = {
|
||||
let aim = self.skills.aim();
|
||||
self.strain_peak_buf[..aim.strain_peaks.len()].copy_from_slice(&aim.strain_peaks);
|
||||
let mut aim_no_sliders = aim_no_sliders
|
||||
.as_any()
|
||||
.downcast_ref::<Aim>()
|
||||
.unwrap()
|
||||
.clone();
|
||||
|
||||
if let Some(last) = self.strain_peak_buf.last_mut() {
|
||||
*last = aim.curr_section_peak;
|
||||
}
|
||||
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
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 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
|
||||
} else {
|
||||
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 {
|
||||
self.strain_peak_buf[..speed.strain_peaks.len()].copy_from_slice(&speed.strain_peaks);
|
||||
if self.mods.rx() {
|
||||
aim_rating *= 0.9;
|
||||
speed_rating = 0.0;
|
||||
flashlight_rating *= 0.7;
|
||||
}
|
||||
|
||||
if let Some(last) = self.strain_peak_buf.last_mut() {
|
||||
*last = speed.curr_section_peak;
|
||||
}
|
||||
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;
|
||||
|
||||
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 {
|
||||
0.0
|
||||
};
|
||||
|
||||
let flashlight_rating = if let Some(flashlight) = flashlight {
|
||||
self.strain_peak_buf[..flashlight.strain_peaks.len()]
|
||||
.copy_from_slice(&flashlight.strain_peaks);
|
||||
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 let Some(last) = self.strain_peak_buf.last_mut() {
|
||||
*last = flashlight.curr_section_peak;
|
||||
}
|
||||
|
||||
Skill::difficulty_value(&mut self.strain_peak_buf, flashlight).sqrt()
|
||||
* DIFFICULTY_MULTIPLIER
|
||||
let star_rating = if base_performance > 0.00001 {
|
||||
PERFORMANCE_BASE_MULTIPLIER.cbrt()
|
||||
* 0.027
|
||||
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
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;
|
||||
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())
|
||||
Some(attrs)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
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`.
|
||||
/// assert!(gradual_perf.process_next_object(state).is_none());
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Debug)]
|
||||
pub struct OsuGradualPerformanceAttributes<'map> {
|
||||
difficulty: OsuGradualDifficultyAttributes,
|
||||
performance: OsuPP<'map>,
|
||||
|
||||
+136
-150
@@ -4,30 +4,26 @@ mod gradual_performance;
|
||||
mod osu_object;
|
||||
mod pp;
|
||||
mod scaling_factor;
|
||||
mod skill;
|
||||
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;
|
||||
mod skills;
|
||||
|
||||
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 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;
|
||||
// * 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.
|
||||
///
|
||||
@@ -113,60 +109,76 @@ impl<'map> OsuStars<'map> {
|
||||
/// Calculate all difficulty related values, including stars.
|
||||
#[inline]
|
||||
pub fn calculate(self) -> OsuDifficultyAttributes {
|
||||
let (mut skills, mut attributes) = calculate_skills(self);
|
||||
let mods = self.mods;
|
||||
|
||||
let aim_rating = {
|
||||
let aim = skills.aim();
|
||||
let mut aim_strains = mem::take(&mut aim.strain_peaks);
|
||||
let (skills, mut attrs) = calculate_skills(self);
|
||||
|
||||
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 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
|
||||
} else {
|
||||
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 {
|
||||
let mut speed_strains = mem::take(&mut speed.strain_peaks);
|
||||
if mods.rx() {
|
||||
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 {
|
||||
0.0
|
||||
};
|
||||
|
||||
let flashlight_rating = if let Some(flashlight) = flashlight {
|
||||
let mut flashlight_strains = mem::take(&mut flashlight.strain_peaks);
|
||||
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);
|
||||
|
||||
Skill::difficulty_value(&mut flashlight_strains, flashlight).sqrt()
|
||||
* DIFFICULTY_MULTIPLIER
|
||||
let star_rating = if base_performance > 0.00001 {
|
||||
PERFORMANCE_BASE_MULTIPLIER.cbrt()
|
||||
* 0.027
|
||||
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let star_rating = if attributes.max_combo == 0 {
|
||||
0.0
|
||||
} else {
|
||||
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;
|
||||
|
||||
attributes.aim_strain = aim_rating;
|
||||
attributes.speed_strain = speed_rating;
|
||||
attributes.flashlight_rating = flashlight_rating;
|
||||
attributes.slider_factor = slider_factor;
|
||||
attributes.stars = star_rating;
|
||||
|
||||
attributes
|
||||
attrs
|
||||
}
|
||||
|
||||
/// Calculate the skill strains.
|
||||
@@ -175,32 +187,21 @@ impl<'map> OsuStars<'map> {
|
||||
#[inline]
|
||||
pub fn strains(self) -> OsuStrains {
|
||||
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 (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),
|
||||
);
|
||||
let [mut aim, mut aim_no_sliders, mut speed, mut flashlight] = skills;
|
||||
|
||||
OsuStrains {
|
||||
section_len: SECTION_LEN * clock_rate,
|
||||
aim: mem::take(&mut skills.aim().strain_peaks),
|
||||
aim_no_sliders: mem::take(&mut skills.aim_no_sliders().strain_peaks),
|
||||
speed,
|
||||
flashlight,
|
||||
aim: aim.take_strain_peaks(),
|
||||
aim_no_sliders: aim_no_sliders.take_strain_peaks(),
|
||||
speed: speed.take_strain_peaks(),
|
||||
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.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OsuStrains {
|
||||
@@ -225,36 +226,7 @@ impl OsuStrains {
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_star_rating(aim_rating: f64, speed_rating: f64, flashlight_rating: f64) -> f64 {
|
||||
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) {
|
||||
fn calculate_skills(params: OsuStars<'_>) -> ([Box<dyn Skill>; 4], OsuDifficultyAttributes) {
|
||||
let OsuStars {
|
||||
map,
|
||||
mods,
|
||||
@@ -269,7 +241,7 @@ fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
|
||||
let scaling_factor = ScalingFactor::new(map_attributes.cs);
|
||||
let hr = mods.hr();
|
||||
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 {
|
||||
ar: map_attributes.ar,
|
||||
@@ -281,7 +253,6 @@ fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
|
||||
let mut params = ObjectParameters {
|
||||
map,
|
||||
attributes: &mut attributes,
|
||||
slider_state: SliderState::new(map),
|
||||
ticks: Vec::new(),
|
||||
curve_bufs: CurveBuffers::default(),
|
||||
};
|
||||
@@ -303,67 +274,70 @@ fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
|
||||
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);
|
||||
h.pos += stack_offset;
|
||||
|
||||
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()) {
|
||||
(Some(prev), Some(curr)) => (prev, curr),
|
||||
(Some(_), None) | (None, None) => return (skills, attributes),
|
||||
(None, Some(_)) => unreachable!(),
|
||||
let last = match hit_objects.next() {
|
||||
Some(prev) => prev,
|
||||
None => return (skills, attributes),
|
||||
};
|
||||
|
||||
let mut prev_prev = None;
|
||||
let mut last_last = None;
|
||||
|
||||
// First object has no predecessor and thus no strain, handle distinctly
|
||||
let mut curr_section_end = (prev.time / clock_rate / SECTION_LEN).ceil() * SECTION_LEN;
|
||||
|
||||
// Handle second object separately to remove later if-branching
|
||||
let h = DifficultyObject::new(
|
||||
&curr,
|
||||
&mut prev,
|
||||
prev_prev.as_ref(),
|
||||
&scaling_factor,
|
||||
clock_rate,
|
||||
);
|
||||
|
||||
let base_time = h.base.time / clock_rate;
|
||||
|
||||
while base_time > curr_section_end {
|
||||
skills.start_new_section_from(curr_section_end);
|
||||
curr_section_end += SECTION_LEN;
|
||||
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
|
||||
if let OsuObjectKind::Slider {
|
||||
lazy_travel_time,
|
||||
lazy_end_pos,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
skills.process(&h);
|
||||
prev_prev = Some(mem::replace(&mut prev, curr));
|
||||
let mut last = &*last;
|
||||
let mut diff_objects = Vec::with_capacity(hit_objects.len().saturating_sub(2));
|
||||
|
||||
// Handle all other objects
|
||||
for curr in hit_objects {
|
||||
let h = DifficultyObject::new(
|
||||
&curr,
|
||||
&mut prev,
|
||||
prev_prev.as_ref(),
|
||||
&scaling_factor,
|
||||
for (i, curr) in hit_objects.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 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 {
|
||||
skills.save_peak_and_start_new_section(curr_section_end);
|
||||
curr_section_end += SECTION_LEN;
|
||||
}
|
||||
|
||||
skills.process(&h);
|
||||
prev_prev = Some(mem::replace(&mut prev, curr));
|
||||
last_last = Some(last);
|
||||
last = &*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)
|
||||
}
|
||||
@@ -406,7 +380,8 @@ fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
|
||||
|
||||
if hit_objects[n].is_spinner() {
|
||||
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.
|
||||
}
|
||||
@@ -466,7 +441,9 @@ fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
|
||||
|
||||
if hit_objects[n].is_spinner() {
|
||||
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.
|
||||
}
|
||||
|
||||
@@ -495,7 +472,7 @@ fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
|
||||
let mut slider_stack = 0.0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -511,21 +488,28 @@ fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
fn lerp(start: f64, end: f64, percent: f64) -> f64 {
|
||||
start + (end - start) * percent
|
||||
fn create_skills(mods: u32, radius: f32) -> [Box<dyn Skill>; 4] {
|
||||
[
|
||||
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.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct OsuDifficultyAttributes {
|
||||
/// The aim portion of the total strain.
|
||||
pub aim_strain: f64,
|
||||
pub aim: f64,
|
||||
/// The speed portion of the total strain.
|
||||
pub speed_strain: f64,
|
||||
pub speed: f64,
|
||||
/// 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
|
||||
pub slider_factor: f64,
|
||||
/// The number of clickable objects weighted by difficulty.
|
||||
pub speed_note_count: f64,
|
||||
/// The approach rate.
|
||||
pub ar: f64,
|
||||
/// The overall difficulty
|
||||
@@ -567,6 +551,8 @@ pub struct OsuPerformanceAttributes {
|
||||
pub pp_flashlight: f64,
|
||||
/// The speed portion of the final pp.
|
||||
pub pp_speed: f64,
|
||||
/// Misses including an approximated amount of slider breaks
|
||||
pub effective_miss_count: f64,
|
||||
}
|
||||
|
||||
impl OsuPerformanceAttributes {
|
||||
|
||||
+194
-109
@@ -1,8 +1,9 @@
|
||||
use std::{cmp::Ordering, convert::identity};
|
||||
|
||||
use super::{slider_state::SliderState, OsuDifficultyAttributes};
|
||||
use super::OsuDifficultyAttributes;
|
||||
|
||||
use crate::{
|
||||
beatmap::DifficultyPoint,
|
||||
curve::{Curve, CurveBuffers},
|
||||
parse::{HitObject, HitObjectKind, Pos2},
|
||||
Beatmap,
|
||||
@@ -13,9 +14,11 @@ const BASE_SCORING_DISTANCE: f64 = 100.0;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct OsuObject {
|
||||
pub(crate) time: f64,
|
||||
pub(crate) start_time: f64,
|
||||
pub(crate) pos: Pos2,
|
||||
pub(crate) stack_height: f32,
|
||||
pub(crate) time_preempt: f64,
|
||||
pub(crate) time_fade_in: f64,
|
||||
pub(crate) kind: OsuObjectKind,
|
||||
}
|
||||
|
||||
@@ -25,6 +28,7 @@ pub(crate) enum OsuObjectKind {
|
||||
Slider {
|
||||
end_time: f64,
|
||||
end_pos: Pos2,
|
||||
lazy_travel_time: f64,
|
||||
lazy_end_pos: Pos2,
|
||||
nested_objects: Vec<NestedObject>,
|
||||
},
|
||||
@@ -36,7 +40,7 @@ pub(crate) enum OsuObjectKind {
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct NestedObject {
|
||||
pub(crate) pos: Pos2,
|
||||
pub(crate) time: f64,
|
||||
pub(crate) start_time: f64,
|
||||
pub(crate) kind: NestedObjectKind,
|
||||
}
|
||||
|
||||
@@ -51,18 +55,19 @@ pub(crate) struct ObjectParameters<'a> {
|
||||
pub(crate) map: &'a Beatmap,
|
||||
pub(crate) attributes: &'a mut OsuDifficultyAttributes,
|
||||
pub(crate) ticks: Vec<(Pos2, f64)>,
|
||||
pub(crate) slider_state: SliderState<'a>,
|
||||
pub(crate) curve_bufs: CurveBuffers,
|
||||
}
|
||||
|
||||
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> {
|
||||
let ObjectParameters {
|
||||
map,
|
||||
attributes,
|
||||
ticks,
|
||||
slider_state,
|
||||
curve_bufs,
|
||||
} = params;
|
||||
|
||||
@@ -77,10 +82,27 @@ impl OsuObject {
|
||||
HitObjectKind::Circle => {
|
||||
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 {
|
||||
time: h.start_time,
|
||||
start_time: h.start_time,
|
||||
pos,
|
||||
stack_height: 0.0,
|
||||
time_preempt,
|
||||
time_fade_in,
|
||||
kind: OsuObjectKind::Circle,
|
||||
}
|
||||
}
|
||||
@@ -92,30 +114,43 @@ impl OsuObject {
|
||||
} => {
|
||||
attributes.n_sliders += 1;
|
||||
|
||||
// Responsible for timing point values
|
||||
slider_state.update(h.start_time);
|
||||
let timing_point = map.timing_point_at(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.
|
||||
// * this results in more (or less) ticks being generated in <v8 maps for the same time duration.
|
||||
if map.version >= 8 {
|
||||
tick_dist /=
|
||||
(100.0 / slider_state.slider_velocity).max(10.0).min(1000.0) / 100.0;
|
||||
}
|
||||
let tick_dist_mult = if map.version < 8 {
|
||||
let first_slider_vel = map
|
||||
.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
|
||||
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
|
||||
|
||||
let velocity =
|
||||
(BASE_SCORING_DISTANCE * map.slider_mult * slider_state.slider_velocity)
|
||||
/ 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;
|
||||
let end_time = h.start_time + span_count * curve.dist() / vel;
|
||||
let total_duration = end_time - h.start_time;
|
||||
let span_duration = total_duration / span_count;
|
||||
|
||||
// * 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
|
||||
@@ -124,101 +159,131 @@ impl OsuObject {
|
||||
|
||||
let len = curve.dist().min(max_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;
|
||||
|
||||
// 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.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
|
||||
while curr_dist < len - min_dist_from_end {
|
||||
let progress = curr_dist / len;
|
||||
let mut nested_objects = if tick_dist != 0.0 {
|
||||
ticks.reserve((len / tick_dist) as usize);
|
||||
let mut nested_objects =
|
||||
Vec::with_capacity((len * span_count / tick_dist) as usize);
|
||||
|
||||
let curr_time = h.start_time + progress * span_duration;
|
||||
let mut curr_pos = h.pos + curve.position_at(progress);
|
||||
// Ticks of the first span
|
||||
while curr_dist < len - min_dist_from_end {
|
||||
let progress = curr_dist / len;
|
||||
|
||||
if hr {
|
||||
curr_pos.y = 384.0 - curr_pos.y;
|
||||
}
|
||||
let curr_time = h.start_time + progress * span_duration;
|
||||
let mut curr_pos = h.pos + curve.position_at(progress);
|
||||
|
||||
let tick = NestedObject {
|
||||
pos: curr_pos,
|
||||
time: curr_time,
|
||||
kind: NestedObjectKind::Tick,
|
||||
};
|
||||
if hr {
|
||||
curr_pos.y = 384.0 - curr_pos.y;
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
let tick = NestedObject {
|
||||
pos: curr_pos,
|
||||
start_time: curr_time,
|
||||
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
|
||||
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);
|
||||
|
||||
let progress = (*repeats % 2 == 0) as u8 as f64;
|
||||
@@ -233,18 +298,18 @@ impl OsuObject {
|
||||
// * if this is to change, we should revisit this.
|
||||
let legacy_last_tick = NestedObject {
|
||||
pos: end_pos,
|
||||
time: final_span_end_time,
|
||||
start_time: final_span_end_time,
|
||||
kind: NestedObjectKind::Tail,
|
||||
};
|
||||
|
||||
// On very short buzz sliders it can happen that the
|
||||
// legacy last tick is not the last object time-wise
|
||||
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
|
||||
.binary_search_by(|nested| {
|
||||
nested
|
||||
.time
|
||||
.start_time
|
||||
.partial_cmp(&final_span_end_time)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
})
|
||||
@@ -257,7 +322,11 @@ impl OsuObject {
|
||||
|
||||
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;
|
||||
|
||||
if end_time_min % 2.0 >= 1.0 {
|
||||
@@ -274,13 +343,16 @@ impl OsuObject {
|
||||
}
|
||||
|
||||
Self {
|
||||
time: h.start_time,
|
||||
start_time: h.start_time,
|
||||
pos,
|
||||
stack_height: 0.0,
|
||||
time_preempt: head_time_preempt,
|
||||
time_fade_in: head_time_fade_in,
|
||||
kind: OsuObjectKind::Slider {
|
||||
end_time,
|
||||
end_pos,
|
||||
lazy_end_pos,
|
||||
lazy_travel_time,
|
||||
nested_objects,
|
||||
},
|
||||
}
|
||||
@@ -289,9 +361,11 @@ impl OsuObject {
|
||||
attributes.n_spinners += 1;
|
||||
|
||||
Self {
|
||||
time: h.start_time,
|
||||
start_time: h.start_time,
|
||||
pos,
|
||||
stack_height: 0.0,
|
||||
time_preempt: Self::TIME_PREEMPT,
|
||||
time_fade_in: Self::TIME_FADE_IN,
|
||||
kind: OsuObjectKind::Spinner {
|
||||
end_time: *end_time,
|
||||
},
|
||||
@@ -306,7 +380,7 @@ impl OsuObject {
|
||||
#[inline]
|
||||
pub(crate) fn end_time(&self) -> f64 {
|
||||
match &self.kind {
|
||||
OsuObjectKind::Circle => self.time,
|
||||
OsuObjectKind::Circle => self.start_time,
|
||||
OsuObjectKind::Slider { end_time, .. } => *end_time,
|
||||
OsuObjectKind::Spinner { end_time } => *end_time,
|
||||
}
|
||||
@@ -343,3 +417,14 @@ impl OsuObject {
|
||||
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::{
|
||||
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 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 {
|
||||
attributes,
|
||||
mods: self.mods,
|
||||
combo: self.combo,
|
||||
combo: self.combo.unwrap_or(attributes.max_combo),
|
||||
acc,
|
||||
n300,
|
||||
n100,
|
||||
n50,
|
||||
n_misses: self.n_misses,
|
||||
total_hits,
|
||||
effective_misses,
|
||||
effective_miss_count: effective_misses,
|
||||
attributes,
|
||||
}
|
||||
} else {
|
||||
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 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 {
|
||||
attributes,
|
||||
mods: self.mods,
|
||||
combo: self.combo,
|
||||
combo: self.combo.unwrap_or(attributes.max_combo),
|
||||
acc,
|
||||
n300,
|
||||
n100,
|
||||
n50,
|
||||
n_misses: self.n_misses,
|
||||
total_hits,
|
||||
effective_misses,
|
||||
effective_miss_count: effective_misses,
|
||||
attributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,60 +357,69 @@ struct OsuPPInner {
|
||||
attributes: OsuDifficultyAttributes,
|
||||
mods: u32,
|
||||
acc: f64,
|
||||
combo: Option<usize>,
|
||||
combo: usize,
|
||||
|
||||
n300: usize,
|
||||
n100: usize,
|
||||
n50: usize,
|
||||
n_misses: usize,
|
||||
|
||||
total_hits: f64,
|
||||
effective_misses: usize,
|
||||
effective_miss_count: f64,
|
||||
}
|
||||
|
||||
impl OsuPPInner {
|
||||
fn calculate(mut self) -> OsuPerformanceAttributes {
|
||||
let (aim_value, speed_value, acc_value, flashlight_value, pp) =
|
||||
if self.total_hits.abs() <= f64::EPSILON {
|
||||
(0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
} else {
|
||||
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)
|
||||
if self.total_hits.abs() <= f64::EPSILON {
|
||||
return OsuPerformanceAttributes {
|
||||
difficulty: self.attributes,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
difficulty: self.attributes,
|
||||
@@ -415,130 +428,133 @@ impl OsuPPInner {
|
||||
pp_flashlight: flashlight_value,
|
||||
pp_speed: speed_value,
|
||||
pp,
|
||||
effective_miss_count: self.effective_miss_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_aim_value(&self) -> f64 {
|
||||
let attributes = &self.attributes;
|
||||
let total_hits = self.total_hits;
|
||||
let mut aim_value =
|
||||
(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
|
||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
||||
+ (total_hits > 2000.0) as u8 as f64 * 0.5 * (total_hits / 2000.0).log10();
|
||||
+ 0.4 * (self.total_hits / 2000.0).min(1.0)
|
||||
+ (self.total_hits > 2000.0) as u8 as f64 * (self.total_hits / 2000.0).log10() * 0.5;
|
||||
|
||||
aim_value *= len_bonus;
|
||||
|
||||
// Penalize misses
|
||||
let effective_misses = self.effective_misses as i32;
|
||||
if effective_misses > 0 {
|
||||
// * Penalize misses by assessing # of misses relative to the total # of objects.
|
||||
// * Default a 3% reduction for any # of misses.
|
||||
if self.effective_miss_count > 0.0 {
|
||||
aim_value *= 0.97
|
||||
* (1.0 - (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
|
||||
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);
|
||||
}
|
||||
aim_value *= self.get_combo_scaling_factor();
|
||||
|
||||
// AR bonus
|
||||
let ar_factor = if attributes.ar > 10.33 {
|
||||
0.3 * (attributes.ar - 10.33)
|
||||
} else if attributes.ar < 8.0 {
|
||||
0.1 * (8.0 - attributes.ar)
|
||||
let ar_factor = if self.mods.rx() {
|
||||
0.0
|
||||
} else if self.attributes.ar > 10.33 {
|
||||
0.3 * (self.attributes.ar - 10.33)
|
||||
} else if self.attributes.ar < 8.0 {
|
||||
0.05 * (8.0 - self.attributes.ar)
|
||||
} else {
|
||||
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() {
|
||||
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.
|
||||
let estimate_difficult_sliders = attributes.n_sliders as f64 * 0.15;
|
||||
// * We assume 15% of sliders in a map are difficult since there's no way to tell from the performance calculator.
|
||||
let estimate_diff_sliders = self.attributes.n_sliders as f64 * 0.15;
|
||||
|
||||
let non_300s = self.total_hits - self.n300 as f64;
|
||||
let missing_combo = attributes.max_combo - self.combo.unwrap_or(attributes.max_combo);
|
||||
|
||||
let estimate_slider_ends_dropped = non_300s
|
||||
.min(missing_combo as f64)
|
||||
.clamp(0.0, estimate_difficult_sliders);
|
||||
|
||||
let base = 1.0 - estimate_slider_ends_dropped / estimate_difficult_sliders;
|
||||
let slider_nerf_factor =
|
||||
(1.0 - attributes.slider_factor) * base * base * base + attributes.slider_factor;
|
||||
if self.attributes.n_sliders > 0 {
|
||||
let estimate_slider_ends_dropped = ((self.n100 + self.n50 + self.n_misses)
|
||||
.min(self.attributes.max_combo - self.combo)
|
||||
as f64)
|
||||
.clamp(0.0, estimate_diff_sliders);
|
||||
let slider_nerf_factor = (1.0 - self.attributes.slider_factor)
|
||||
* (1.0 - estimate_slider_ends_dropped / estimate_diff_sliders).powi(3)
|
||||
+ self.attributes.slider_factor;
|
||||
|
||||
aim_value *= slider_nerf_factor;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn compute_speed_value(&self) -> f64 {
|
||||
let attributes = &self.attributes;
|
||||
let total_hits = self.total_hits;
|
||||
if self.mods.rx() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
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
|
||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
||||
+ (total_hits > 2000.0) as u8 as f64 * 0.5 * (total_hits / 2000.0).log10();
|
||||
+ 0.4 * (self.total_hits / 2000.0).min(1.0)
|
||||
+ (self.total_hits > 2000.0) as u8 as f64 * (self.total_hits / 2000.0).log10() * 0.5;
|
||||
|
||||
speed_value *= len_bonus;
|
||||
|
||||
// Penalize misses
|
||||
let effective_misses = self.effective_misses as f64;
|
||||
if effective_misses > 0.0 {
|
||||
// * Penalize misses by assessing # of misses relative to the total # of objects.
|
||||
// * Default a 3% reduction for any # of misses.
|
||||
if self.effective_miss_count > 0.0 {
|
||||
speed_value *= 0.97
|
||||
* (1.0 - (effective_misses / total_hits).powf(0.775))
|
||||
.powf(effective_misses.powf(0.875));
|
||||
* (1.0 - (self.effective_miss_count / self.total_hits).powf(0.775))
|
||||
.powf(self.effective_miss_count.powf(0.875));
|
||||
}
|
||||
|
||||
// Combo scaling
|
||||
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);
|
||||
}
|
||||
speed_value *= self.get_combo_scaling_factor();
|
||||
|
||||
// AR bonus
|
||||
let ar_factor = if attributes.ar > 10.33 {
|
||||
0.3 * (attributes.ar - 10.33)
|
||||
let ar_factor = if self.attributes.ar > 10.33 {
|
||||
0.3 * (self.attributes.ar - 10.33)
|
||||
} else {
|
||||
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() {
|
||||
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
|
||||
let od_factor = 0.95 + attributes.od * attributes.od / 750.0;
|
||||
let acc_factor = self.acc.powf((14.5 - attributes.od.max(8.0)) / 2.0);
|
||||
speed_value *= od_factor * acc_factor;
|
||||
// * Calculate accuracy assuming the worst case scenario
|
||||
let relevant_total_diff = self.total_hits - self.attributes.speed_note_count;
|
||||
let relevant_n300 = (self.n300 as f64 - relevant_total_diff).max(0.0);
|
||||
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
|
||||
speed_value *= 0.98_f64.powf(
|
||||
(self.n50 as f64 >= total_hits / 500.0) as u8 as f64
|
||||
* (self.n50 as f64 - total_hits / 500.0),
|
||||
let relevant_acc = if self.attributes.speed_note_count.abs() <= f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(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
|
||||
@@ -549,28 +565,39 @@ impl OsuPPInner {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let attributes = &self.attributes;
|
||||
let total_hits = self.total_hits;
|
||||
let n_circles = attributes.n_circles as f64;
|
||||
let n300 = self.n300 as f64;
|
||||
let n100 = self.n100 as f64;
|
||||
let n50 = self.n50 as f64;
|
||||
// * This percentage only considers HitCircles of any value - in this part
|
||||
// * of the calculation we focus on hitting the timing hit window.
|
||||
let amount_hit_objects_with_acc = self.attributes.n_circles;
|
||||
|
||||
let better_acc_percentage = (n_circles > 0.0) as u8 as f64
|
||||
* (((n300 - (total_hits - n_circles)) * 6.0 + n100 * 2.0 + n50) / (n_circles * 6.0))
|
||||
.max(0.0);
|
||||
let mut better_acc_percentage = if amount_hit_objects_with_acc > 0 {
|
||||
((self.n300 - (self.total_hits as usize - amount_hit_objects_with_acc)) * 6
|
||||
+ 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
|
||||
acc_value *= ((n_circles as f64 / 1000.0).powf(0.3)).min(1.15);
|
||||
// * Lots of arbitrary values from testing.
|
||||
// * Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution.
|
||||
let mut acc_value =
|
||||
1.52163_f64.powf(self.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() {
|
||||
acc_value *= 1.08;
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
acc_value *= 1.02;
|
||||
}
|
||||
@@ -583,76 +610,66 @@ impl OsuPPInner {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let attributes = &self.attributes;
|
||||
let total_hits = self.total_hits;
|
||||
let mut flashlight_value = self.attributes.flashlight * self.attributes.flashlight * 25.0;
|
||||
|
||||
// TD penalty
|
||||
let raw_flashlight = if self.mods.td() {
|
||||
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 {
|
||||
// * Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
|
||||
if self.effective_miss_count > 0.0 {
|
||||
flashlight_value *= 0.97
|
||||
* (1.0 - (effective_misses / total_hits).powf(0.775))
|
||||
.powf(effective_misses.powf(0.875));
|
||||
* (1.0 - (self.effective_miss_count / self.total_hits).powf(0.775))
|
||||
.powf(self.effective_miss_count.powf(0.875));
|
||||
}
|
||||
|
||||
// Combo scaling
|
||||
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);
|
||||
}
|
||||
flashlight_value *= self.get_combo_scaling_factor();
|
||||
|
||||
// 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
|
||||
+ 0.1 * (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));
|
||||
+ 0.1 * (self.total_hits / 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;
|
||||
|
||||
// It is important to also consider accuracy difficulty when doing that
|
||||
flashlight_value *= 0.98 + attributes.od * attributes.od / 2500.0;
|
||||
// * 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
|
||||
}
|
||||
|
||||
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(
|
||||
attributes: &OsuDifficultyAttributes,
|
||||
attrs: &OsuDifficultyAttributes,
|
||||
combo: Option<usize>,
|
||||
n100: usize,
|
||||
n50: usize,
|
||||
n_misses: usize,
|
||||
total_hits: f64,
|
||||
) -> usize {
|
||||
) -> f64 {
|
||||
// * 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 {
|
||||
let full_combo_threshold = attributes.max_combo as f64 - 0.1 * attributes.n_sliders as f64;
|
||||
if attrs.n_sliders > 0 {
|
||||
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(combo) = f64_combo.filter(|&c| c < full_combo_threshold) {
|
||||
combo_based_misses = full_combo_threshold / combo.max(1.0);
|
||||
if let Some(score_max_combo) = combo
|
||||
.map(|combo| combo as f64)
|
||||
.filter(|&combo| combo < full_combo_threshold)
|
||||
{
|
||||
combo_based_miss_count = full_combo_threshold / score_max_combo.max(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// * Clamp misscount since it's derived from combo and can be
|
||||
// * higher than total hits and that breaks some calculations
|
||||
combo_based_misses = combo_based_misses.min(total_hits);
|
||||
// * Clamp miss count to maximum amount of possible breaks
|
||||
combo_based_miss_count = combo_based_miss_count.min((n100 + n50 + n_misses) as f64);
|
||||
|
||||
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.
|
||||
|
||||
@@ -6,9 +6,8 @@ const OBJECT_RADIUS: f32 = 64.0;
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub(crate) struct ScalingFactor {
|
||||
adjusted_factor: f32,
|
||||
factor: f32,
|
||||
radius: f32,
|
||||
pub(crate) factor: f32,
|
||||
pub(crate) radius: f32,
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
@@ -19,32 +18,19 @@ impl ScalingFactor {
|
||||
let radius = OBJECT_RADIUS * scale;
|
||||
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)
|
||||
} else {
|
||||
factor
|
||||
};
|
||||
|
||||
Self {
|
||||
adjusted_factor,
|
||||
factor,
|
||||
radius,
|
||||
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 {
|
||||
Pos2::new(stack_height * self.scale)
|
||||
}
|
||||
|
||||
+1
-1
@@ -131,7 +131,7 @@ impl Skill {
|
||||
pub(crate) fn process(&mut self, curr: &DifficultyObject<'_>) {
|
||||
self.kind.pre_process();
|
||||
self.curr_section_peak = self.strain_value_at(curr).max(self.curr_section_peak);
|
||||
self.prev_time = Some(curr.base.time / curr.clock_rate);
|
||||
self.prev_time = Some(curr.base.start_time / curr.clock_rate);
|
||||
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::{
|
||||
error::Error as StdError,
|
||||
fmt,
|
||||
io::Error as IoError,
|
||||
num::{ParseFloatError, ParseIntError},
|
||||
};
|
||||
use std::{error::Error as StdError, fmt, io::Error as IoError, num::ParseFloatError};
|
||||
|
||||
/// `Result<_, ParseError>`
|
||||
pub type ParseResult<T> = Result<T, ParseError>;
|
||||
@@ -22,14 +17,10 @@ pub enum ParseError {
|
||||
InvalidCurvePoints,
|
||||
/// Expected a decimal number, got something else.
|
||||
InvalidDecimalNumber,
|
||||
/// Expected an integer, got something else.
|
||||
InvalidInteger,
|
||||
/// Failed to parse game mode.
|
||||
InvalidMode,
|
||||
/// Expected an additional field.
|
||||
MissingField(&'static str),
|
||||
/// Reject maps with too many repeat points.
|
||||
TooManyRepeats,
|
||||
/// Failed to recognized specified type for hitobjects.
|
||||
UnknownHitObjectKind,
|
||||
}
|
||||
@@ -43,11 +34,9 @@ impl fmt::Display for ParseError {
|
||||
}
|
||||
Self::BadLine => f.write_str("line not in `Key:Value` pattern"),
|
||||
Self::InvalidCurvePoints => f.write_str("invalid curve point"),
|
||||
Self::InvalidInteger => f.write_str("invalid integer"),
|
||||
Self::InvalidDecimalNumber => f.write_str("invalid float number"),
|
||||
Self::InvalidMode => f.write_str("invalid mode"),
|
||||
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"),
|
||||
}
|
||||
}
|
||||
@@ -60,11 +49,9 @@ impl StdError for ParseError {
|
||||
Self::IncorrectFileHeader => None,
|
||||
Self::BadLine => None,
|
||||
Self::InvalidCurvePoints => None,
|
||||
Self::InvalidInteger => None,
|
||||
Self::InvalidDecimalNumber => None,
|
||||
Self::InvalidMode => None,
|
||||
Self::MissingField(_) => None,
|
||||
Self::TooManyRepeats => 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 {
|
||||
fn from(_: ParseFloatError) -> Self {
|
||||
Self::InvalidDecimalNumber
|
||||
|
||||
@@ -69,7 +69,7 @@ pub enum HitObjectKind {
|
||||
/// A full slider object.
|
||||
Slider {
|
||||
/// Total length of the slider in pixels.
|
||||
pixel_len: f64,
|
||||
pixel_len: Option<f64>,
|
||||
/// The amount of repeat points of the slider.
|
||||
repeats: usize,
|
||||
/// The control points of the slider.
|
||||
|
||||
+198
-96
@@ -14,7 +14,10 @@ pub use slider_parsing::*;
|
||||
use reader::FileReader;
|
||||
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")))]
|
||||
use std::{fs::File, io::Read};
|
||||
@@ -44,18 +47,33 @@ impl<T> OptionExt<T> for Option<T> {
|
||||
}
|
||||
}
|
||||
|
||||
trait FloatExt: Sized {
|
||||
fn validate(self) -> Result<Self, ParseError>;
|
||||
}
|
||||
trait InRange: Sized + Copy + Neg<Output = Self> + PartialOrd {
|
||||
const LIMIT: Self;
|
||||
|
||||
impl FloatExt for f64 {
|
||||
fn validate(self) -> Result<Self, ParseError> {
|
||||
self.is_finite()
|
||||
.then(|| self)
|
||||
.ok_or(ParseError::InvalidDecimalNumber)
|
||||
fn is_in_range(&self) -> bool {
|
||||
(-Self::LIMIT..=Self::LIMIT).contains(self)
|
||||
}
|
||||
|
||||
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 {
|
||||
($map:ident, $func:ident, $reader:ident, $section:ident) => {{
|
||||
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
|
||||
@@ -201,14 +219,11 @@ macro_rules! parse_events_body {
|
||||
|
||||
macro_rules! parse_timingpoints_body {
|
||||
($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 pending_diff_points_time = 0.0;
|
||||
let mut pending_diff_point = None;
|
||||
|
||||
while next_line!($reader)? != 0 {
|
||||
if let Some(bytes) = $reader.get_section() {
|
||||
*$section = Section::from_bytes(bytes);
|
||||
@@ -219,65 +234,125 @@ macro_rules! parse_timingpoints_body {
|
||||
let line = $reader.get_line()?;
|
||||
let mut split = line.split(',');
|
||||
|
||||
let time = split
|
||||
let time: f64 = split
|
||||
.next()
|
||||
.next_field("timing point time")?
|
||||
.trim()
|
||||
.parse::<f64>()?
|
||||
.validate()?;
|
||||
.parse()?;
|
||||
|
||||
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 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 beat_len = beat_len.clamp(6.0, 60_000.0);
|
||||
let mut timing_change = true;
|
||||
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 {
|
||||
time,
|
||||
beat_len,
|
||||
beat_len: beat_len.clamp(6.0, 60_000.0),
|
||||
kiai,
|
||||
};
|
||||
|
||||
$self.timing_points.push(point);
|
||||
}
|
||||
|
||||
if time < prev_time {
|
||||
unsorted_timings = true;
|
||||
} else {
|
||||
prev_time = time;
|
||||
}
|
||||
// * If beatLength is NaN, speedMultiplier should still be 1
|
||||
// * because all comparisons against NaN are false.
|
||||
let speed_multiplier = if beat_len < 0.0 {
|
||||
(100.0 / -beat_len)
|
||||
} else {
|
||||
let speed_multiplier = if beat_len < 0.0 {
|
||||
(-100.0 / beat_len).clamp(0.1, 10.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
1.0
|
||||
};
|
||||
|
||||
let point = DifficultyPoint {
|
||||
time,
|
||||
speed_multiplier,
|
||||
kiai,
|
||||
};
|
||||
|
||||
$self.difficulty_points.push(point);
|
||||
|
||||
if time < prev_diff {
|
||||
unsorted_difficulties = true;
|
||||
} else {
|
||||
prev_diff = time;
|
||||
if time != pending_diff_points_time {
|
||||
if let Some(point) = pending_diff_point.take() {
|
||||
$self.difficulty_points.push_if_not_redundant(point);
|
||||
}
|
||||
}
|
||||
|
||||
pending_diff_point = Some(DifficultyPoint::new(time, beat_len, speed_multiplier, kiai));
|
||||
pending_diff_points_time = time;
|
||||
}
|
||||
|
||||
if unsorted_timings {
|
||||
sort_unstable(&mut $self.timing_points);
|
||||
}
|
||||
|
||||
if unsorted_difficulties {
|
||||
sort_unstable(&mut $self.difficulty_points);
|
||||
}
|
||||
$self.timing_points.dedup_by_key(|point| point.time);
|
||||
$self.difficulty_points.dedup_by_key(|point| point.time);
|
||||
|
||||
Ok(empty)
|
||||
}};
|
||||
@@ -308,24 +383,36 @@ macro_rules! parse_hitobjects_body {
|
||||
let line = $reader.get_line()?;
|
||||
let mut split = line.split(',');
|
||||
|
||||
let pos = Pos2 {
|
||||
x: split.next().next_field("x pos")?.parse()?,
|
||||
y: split.next().next_field("y pos")?.parse()?,
|
||||
};
|
||||
let x: f32 = split.next().next_field("x pos")?.parse()?;
|
||||
let y: f32 = split.next().next_field("y pos")?.parse()?;
|
||||
|
||||
let time = split
|
||||
.next()
|
||||
.next_field("hitobject time")?
|
||||
.trim()
|
||||
.parse::<f64>()?
|
||||
.validate()?;
|
||||
if !(x.is_in_custom_range(MAX_COORDINATE_VALUE as f32)
|
||||
&& y.is_in_custom_range(MAX_COORDINATE_VALUE as f32))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
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 {
|
||||
unsorted = true;
|
||||
}
|
||||
|
||||
let kind: u8 = split.next().next_field("hitobject kind")?.parse()?;
|
||||
let sound = split.next().map(str::parse).transpose()?.unwrap_or(0);
|
||||
let kind: u8 = match split.next().next_field("hitobject kind")?.parse() {
|
||||
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 {
|
||||
$self.n_circles += 1;
|
||||
@@ -337,15 +424,13 @@ macro_rules! parse_hitobjects_body {
|
||||
let mut control_points = Vec::new();
|
||||
|
||||
let control_point_iter = split.next().next_field("control points")?.split('|');
|
||||
let mut repeats: usize = split.next().next_field("repeats")?.parse()?;
|
||||
|
||||
if repeats > 9000 {
|
||||
return Err(ParseError::TooManyRepeats);
|
||||
}
|
||||
|
||||
// * osu-stable treated the first span of the slider
|
||||
// * as a repeat, but no repeats are happening
|
||||
repeats = repeats.saturating_sub(1);
|
||||
let repeats = match split.next().next_field("repeats")?.parse::<usize>() {
|
||||
// * 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),
|
||||
Ok(_) | Err(_) => continue,
|
||||
};
|
||||
|
||||
let mut start_idx = 0;
|
||||
let mut end_idx = 0;
|
||||
@@ -402,25 +487,25 @@ macro_rules! parse_hitobjects_body {
|
||||
if control_points.is_empty() {
|
||||
HitObjectKind::Circle
|
||||
} else {
|
||||
let pixel_len = split
|
||||
.next()
|
||||
.next_field("pixel len")?
|
||||
.parse::<f64>()?
|
||||
.max(0.0)
|
||||
.min(MAX_COORDINATE_VALUE);
|
||||
let pixel_len = match split.next().map(str::parse::<f64>) {
|
||||
Some(Ok(len)) if len.is_in_custom_range(MAX_COORDINATE_VALUE as f64) => {
|
||||
(len != 0.0).then_some(len)
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => None,
|
||||
};
|
||||
|
||||
let edge_sounds_opt = split.next().map(|sounds| {
|
||||
sounds
|
||||
.split('|')
|
||||
.take(repeats + 2)
|
||||
.map(parse_custom_sound)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.collect()
|
||||
});
|
||||
|
||||
let edge_sounds = match edge_sounds_opt {
|
||||
None => Vec::new(),
|
||||
Some(Ok(sounds)) => sounds,
|
||||
Some(Err(err)) => return Err(err),
|
||||
Some(sounds) => sounds,
|
||||
};
|
||||
|
||||
HitObjectKind::Slider {
|
||||
@@ -432,18 +517,27 @@ macro_rules! parse_hitobjects_body {
|
||||
}
|
||||
} else if kind & Self::SPINNER_FLAG > 0 {
|
||||
$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 }
|
||||
} else if kind & Self::HOLD_FLAG > 0 {
|
||||
$self.n_sliders += 1;
|
||||
let mut end = time;
|
||||
|
||||
if let Some(next) = split.next() {
|
||||
end = end.max(next.split(':').next().next_field("hold endtime")?.parse()?);
|
||||
}
|
||||
let end_time = match split
|
||||
.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 {
|
||||
return Err(ParseError::UnknownHitObjectKind);
|
||||
};
|
||||
@@ -453,6 +547,7 @@ macro_rules! parse_hitobjects_body {
|
||||
start_time: time,
|
||||
kind,
|
||||
});
|
||||
|
||||
$self.sounds.push(sound);
|
||||
|
||||
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
|
||||
fn parse_custom_sound(sound: &str) -> ParseResult<u8> {
|
||||
sound.bytes().try_fold(0_u8, |sound, byte| match byte {
|
||||
b'0'..=b'9' => Ok(sound.wrapping_mul(10).wrapping_add((byte & 0xF) as u8)),
|
||||
_ => Err(ParseError::InvalidInteger),
|
||||
})
|
||||
// Required for maps with slider edge sound values above 255 e.g. map /b/80799
|
||||
fn parse_custom_sound(sound: &str) -> u8 {
|
||||
fn fold_str(sound: &str) -> ControlFlow<u8, u8> {
|
||||
sound.bytes().try_fold(0_u8, |sound, byte| match byte {
|
||||
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 {
|
||||
@@ -539,8 +643,6 @@ mod slider_parsing {
|
||||
|
||||
use super::Pos2;
|
||||
|
||||
pub(super) const MAX_COORDINATE_VALUE: f64 = 131_072.0;
|
||||
|
||||
pub(super) fn convert_points(
|
||||
points: &[&str],
|
||||
end_point: Option<&str>,
|
||||
|
||||
+8
-4
@@ -32,13 +32,13 @@ impl Pos2 {
|
||||
/// Return the position's length.
|
||||
#[inline]
|
||||
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.
|
||||
#[inline]
|
||||
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.
|
||||
@@ -49,8 +49,12 @@ impl Pos2 {
|
||||
|
||||
/// Normalize the coordinates with respect to the vector's length.
|
||||
#[inline]
|
||||
pub fn normalize(self) -> Pos2 {
|
||||
self / self.length()
|
||||
pub fn normalize(mut self) -> Pos2 {
|
||||
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| {
|
||||
self.buf[idx..]
|
||||
.starts_with(b"osu file format v")
|
||||
.then(|| idx + 17)
|
||||
.then_some(idx + 17)
|
||||
})
|
||||
.map(|idx| {
|
||||
let mut n = 0;
|
||||
@@ -235,7 +235,7 @@ impl<R> FileReader<R> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.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);
|
||||
|
||||
self.buf.truncate(len);
|
||||
|
||||
+10
-13
@@ -5,23 +5,20 @@ mod gradual_performance;
|
||||
mod pp;
|
||||
mod rim;
|
||||
mod skills;
|
||||
mod stamina_cheese;
|
||||
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 self::colours::ColourDifficultyPreprocessor;
|
||||
use self::difficulty_object::{MonoIndex, ObjectLists, TaikoDifficultyObject};
|
||||
use self::skills::{Peaks, PeaksDifficultyValues, PeaksRaw, Skill};
|
||||
pub use self::{gradual_difficulty::*, gradual_performance::*, pp::*};
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -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