wip osu pp update

This commit is contained in:
MaxOhn
2022-10-07 18:46:17 +02:00
parent fae3a0359c
commit 5eb8dab588
34 changed files with 2415 additions and 1931 deletions
+69 -36
View File
@@ -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;
+1 -3
View File
@@ -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
View File
@@ -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.
+90
View File
@@ -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
View File
@@ -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,
&params.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 -5
View File
@@ -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(),
};
-3
View File
@@ -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(),
};
-100
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
-4
View File
@@ -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
View File
@@ -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
View File
@@ -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))
}
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+3 -17
View File
@@ -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
View File
@@ -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);
}
-580
View File
@@ -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(),
}
}
}
+273
View File
@@ -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)
}
}
+235
View File
@@ -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
}
}
+166
View File
@@ -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))
}
+331
View File
@@ -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
}
}
-99
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
-110
View File
@@ -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
}