f32 -> f64 & osu_precise fixes + update

This commit is contained in:
MaxOhn
2021-11-12 02:44:40 +01:00
parent db474149d8
commit d703f55b3c
36 changed files with 1110 additions and 696 deletions
+17 -17
View File
@@ -22,16 +22,16 @@ pub use fast::*;
/// This data is necessary to calculate PP.
#[derive(Clone, Debug, Default)]
pub struct DifficultyAttributes {
pub aim_strain: f32,
pub speed_strain: f32,
pub flashlight_rating: f32,
pub ar: f32,
pub od: f32,
pub hp: f32,
pub aim_strain: f64,
pub speed_strain: f64,
pub flashlight_rating: f64,
pub ar: f64,
pub od: f64,
pub hp: f64,
pub n_circles: usize,
pub n_sliders: usize,
pub n_spinners: usize,
pub stars: f32,
pub stars: f64,
pub max_combo: usize,
}
@@ -39,29 +39,29 @@ pub struct DifficultyAttributes {
#[derive(Clone, Debug, Default)]
pub struct PerformanceAttributes {
pub attributes: DifficultyAttributes,
pub pp_acc: f32,
pub pp_aim: f32,
pub pp_flashlight: f32,
pub pp_speed: f32,
pub pp: f32,
pub pp_acc: f64,
pub pp_aim: f64,
pub pp_flashlight: f64,
pub pp_speed: f64,
pub pp: f64,
}
impl PerformanceAttributes {
/// Return the star value.
#[inline]
pub fn stars(&self) -> f32 {
pub fn stars(&self) -> f64 {
self.attributes.stars
}
/// Return the performance point value.
#[inline]
pub fn pp(&self) -> f32 {
pub fn pp(&self) -> f64 {
self.pp
}
}
#[inline]
fn difficulty_range_od(od: f32) -> f32 {
fn difficulty_range_od(od: f64) -> f64 {
super::difficulty_range(od, 20.0, 50.0, 80.0)
}
@@ -72,7 +72,7 @@ fn custom_osu() {
use crate::{Beatmap, OsuPP};
let path = "E:Games/osu!/beatmaps/1402167_.osu";
let path = "E:Games/osu!/beatmaps/70090_.osu";
let file = File::open(path).unwrap();
let start = Instant::now();
@@ -94,7 +94,7 @@ fn custom_osu() {
println!("Parsing average: {:?}", accum / iters);
let start = Instant::now();
let result = OsuPP::new(&map).mods(0).calculate();
let result = OsuPP::new(&map).mods(16 + 64).calculate();
let iters = 100;
let accum = start.elapsed();
+58 -59
View File
@@ -35,7 +35,7 @@ pub struct OsuPP<'m> {
attributes: Option<DifficultyAttributes>,
mods: u32,
combo: Option<usize>,
acc: Option<f32>,
acc: Option<f64>,
n300: Option<usize>,
n100: Option<usize>,
@@ -138,7 +138,7 @@ impl<'m> OsuPP<'m> {
///
/// Be sure to set `misses` beforehand!
/// In case of a partial play, be also sure to set `passed_objects` beforehand!
pub fn accuracy(mut self, acc: f32) -> Self {
pub fn accuracy(mut self, acc: f64) -> Self {
let n_objects = self
.passed_objects
.unwrap_or_else(|| self.map.hit_objects.len());
@@ -152,7 +152,7 @@ impl<'m> OsuPP<'m> {
let placed_points = 2 * n100 + n50 + self.n_misses;
let missing_objects = n_objects - n100 - n50 - self.n_misses;
let missing_points =
((6.0 * acc * n_objects as f32).round() as usize).saturating_sub(placed_points);
((6.0 * acc * n_objects as f64).round() as usize).saturating_sub(placed_points);
let mut n300 = missing_objects.min(missing_points / 6);
n50 += missing_objects - n300;
@@ -171,10 +171,10 @@ impl<'m> OsuPP<'m> {
self.n100 = Some(n100);
self.n50 = Some(n50);
acc = (6 * n300 + 2 * n100 + n50) as f32 / (6 * n_objects) as f32;
acc = (6 * n300 + 2 * n100 + n50) as f64 / (6 * n_objects) as f64;
} else {
let misses = self.n_misses.min(n_objects);
let target_total = (acc * n_objects as f32 * 6.0).round() as usize;
let target_total = (acc * n_objects as f64 * 6.0).round() as usize;
let delta = target_total - (n_objects - misses);
let mut n300 = delta / 5;
@@ -191,7 +191,7 @@ impl<'m> OsuPP<'m> {
self.n100 = Some(n100);
self.n50 = Some(n50);
acc = (6 * n300 + 2 * n100 + n50) as f32 / (6 * n_objects) as f32;
acc = (6 * n300 + 2 * n100 + n50) as f64 / (6 * n_objects) as f64;
}
self.acc = Some(acc);
@@ -213,7 +213,7 @@ impl<'m> OsuPP<'m> {
let n100 = n100.unwrap_or(0);
let n50 = n50.unwrap_or(0);
let total_hits = (n300 + n100 + n50 + self.n_misses).min(n_objects) as f32;
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);
@@ -259,9 +259,9 @@ impl<'m> OsuPP<'m> {
let n50 = n50.unwrap_or(0);
let numerator = n300 * 6 + n100 * 2 + n50;
let acc = numerator as f32 / n_objects as f32 / 6.0;
let acc = numerator as f64 / n_objects as f64 / 6.0;
let total_hits = (n300 + n100 + n50 + self.n_misses).min(n_objects) as f32;
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);
@@ -295,13 +295,13 @@ struct OsuPPInner {
attributes: DifficultyAttributes,
mods: u32,
combo: Option<usize>,
acc: f32,
acc: f64,
n300: usize,
n100: usize,
n50: usize,
total_hits: f32,
total_hits: f64,
effective_misses: usize,
}
@@ -311,18 +311,23 @@ impl OsuPPInner {
// NF penalty
if self.mods.nf() {
multiplier *= (1.0 - 0.02 * (self.effective_misses as f32)).max(0.9);
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 f32 / self.total_hits).powf(0.85);
multiplier *= 1.0 - (n_spinners as f64 / self.total_hits).powf(0.85);
}
// Relax penalty
if self.mods.rx() {
self.effective_misses += self.n100 + self.n50;
// * 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;
}
@@ -348,7 +353,7 @@ impl OsuPPInner {
}
}
fn compute_aim_value(&self) -> f32 {
fn compute_aim_value(&self) -> f64 {
let attributes = &self.attributes;
let total_hits = self.total_hits;
@@ -364,48 +369,44 @@ impl OsuPPInner {
// 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 f32 * 0.5 * (total_hits / 2000.0).log10();
+ (total_hits > 2000.0) as u8 as f64 * 0.5 * (total_hits / 2000.0).log10();
aim_value *= len_bonus;
// Penalize misses
let effective_misses = self.effective_misses as i32;
if effective_misses > 0 {
aim_value *= 0.97
* (1.0 - (effective_misses as f32 / total_hits).powf(0.775)).powi(effective_misses);
* (1.0 - (effective_misses as f64 / total_hits).powf(0.775)).powi(effective_misses);
}
// Combo scaling
if let Some(combo) = self.combo.filter(|_| attributes.max_combo > 0) {
aim_value *= ((combo as f32 / attributes.max_combo as f32).powf(0.8)).min(1.0);
aim_value *= ((combo as f64 / attributes.max_combo as f64).powf(0.8)).min(1.0);
}
// AR bonus
let ar_factor = if attributes.ar > 10.33 {
attributes.ar - 10.33
0.3 * (attributes.ar - 10.33)
} else if attributes.ar < 8.0 {
0.025 * (8.0 - attributes.ar)
0.1 * (8.0 - attributes.ar)
} else {
0.0
};
let ar_total_hits_factor = (1.0 + (-(0.007 * (total_hits - 400.0))).exp()).recip();
let ar_bonus = 1.0 + (0.03 + 0.37 * ar_total_hits_factor) * ar_factor;
aim_value *= 1.0 + ar_factor * len_bonus; // * Buff for longer maps with high AR.
// 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);
}
aim_value *= ar_bonus;
// Scale with accuracy
aim_value *= 0.5 + self.acc / 2.0;
aim_value *= self.acc;
aim_value *= 0.98 + attributes.od * attributes.od / 2500.0;
aim_value
}
fn compute_speed_value(&self) -> f32 {
fn compute_speed_value(&self) -> f64 {
let attributes = &self.attributes;
let total_hits = self.total_hits;
@@ -415,11 +416,11 @@ impl OsuPPInner {
// 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 f32 * 0.5 * (total_hits / 2000.0).log10();
+ (total_hits > 2000.0) as u8 as f64 * 0.5 * (total_hits / 2000.0).log10();
speed_value *= len_bonus;
// Penalize misses
let effective_misses = self.effective_misses as f32;
let effective_misses = self.effective_misses as f64;
if effective_misses > 0.0 {
speed_value *= 0.97
* (1.0 - (effective_misses / total_hits).powf(0.775))
@@ -428,19 +429,17 @@ impl OsuPPInner {
// Combo scaling
if let Some(combo) = self.combo.filter(|_| attributes.max_combo > 0) {
speed_value *= ((combo as f32 / attributes.max_combo as f32).powf(0.8)).min(1.0);
speed_value *= ((combo as f64 / attributes.max_combo as f64).powf(0.8)).min(1.0);
}
// AR bonus
let ar_factor = if attributes.ar > 10.33 {
attributes.ar - 10.33
0.3 * (attributes.ar - 10.33)
} else {
0.0
};
let ar_total_hits_factor = (1.0 + (-(0.007 * (total_hits - 400.0))).exp()).recip();
speed_value *= 1.0 + (0.03 + 0.37 * ar_total_hits_factor) * ar_factor;
speed_value *= 1.0 + ar_factor * len_bonus; // * Buff for longer maps with high AR.
// HD bonus (this would include the Blinds mod but it's currently not representable)
if self.mods.hd() {
@@ -453,34 +452,34 @@ impl OsuPPInner {
speed_value *= od_factor * acc_factor;
// Penalize n50s
speed_value *= 0.98_f32.powf(
(self.n50 as f32 >= total_hits / 500.0) as u8 as f32
* (self.n50 as f32 - total_hits / 500.0),
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),
);
speed_value
}
fn compute_accuracy_value(&self) -> f32 {
fn compute_accuracy_value(&self) -> f64 {
if self.mods.rx() {
return 0.0;
}
let attributes = &self.attributes;
let total_hits = self.total_hits;
let n_circles = attributes.n_circles as f32;
let n300 = self.n300 as f32;
let n100 = self.n100 as f32;
let n50 = self.n50 as f32;
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;
let better_acc_percentage = (n_circles > 0.0) as u8 as f32
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 acc_value = 1.52163_f32.powf(attributes.od) * better_acc_percentage.powi(24) * 2.83;
let mut acc_value = 1.52163_f64.powf(attributes.od) * better_acc_percentage.powi(24) * 2.83;
// Bonus for many hitcircles
acc_value *= ((n_circles as f32 / 1000.0).powf(0.3)).min(1.15);
acc_value *= ((n_circles as f64 / 1000.0).powf(0.3)).min(1.15);
// HD bonus (this would include the Blinds mod but it's currently not representable)
if self.mods.hd() {
@@ -495,7 +494,7 @@ impl OsuPPInner {
acc_value
}
fn compute_flashlight_value(&self) -> f32 {
fn compute_flashlight_value(&self) -> f64 {
if !self.mods.fl() {
return 0.0;
}
@@ -519,7 +518,7 @@ impl OsuPPInner {
// 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 f32;
let effective_misses = self.effective_misses as f64;
if effective_misses > 0.0 {
flashlight_value *= 0.97
* (1.0 - (effective_misses / total_hits).powf(0.775))
@@ -528,13 +527,13 @@ impl OsuPPInner {
// Combo scaling
if let Some(combo) = self.combo.filter(|_| attributes.max_combo > 0) {
flashlight_value *= ((combo as f32 / attributes.max_combo as f32).powf(0.8)).min(1.0);
flashlight_value *= ((combo as f64 / attributes.max_combo as f64).powf(0.8)).min(1.0);
}
// Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius
flashlight_value *= 0.7
+ 0.1 * (total_hits / 200.0).min(1.0)
+ (total_hits > 200.0) as u8 as f32 * (0.2 * ((total_hits - 200.0) / 200.0).min(1.0));
+ (total_hits > 200.0) as u8 as f64 * (0.2 * ((total_hits - 200.0) / 200.0).min(1.0));
// Scale the aim value with accuracy _slightly_
flashlight_value *= 0.5 + self.acc / 2.0;
@@ -550,23 +549,23 @@ fn calculate_effective_misses(
attributes: &DifficultyAttributes,
combo: Option<usize>,
n_misses: usize,
total_hits: f32,
total_hits: f64,
) -> usize {
// Guess the number of misses + slider breaks from combo
let mut combo_based_misses: f32 = 0.0;
// * Guess the number of misses + slider breaks from combo
let mut combo_based_misses: f64 = 0.0;
if attributes.n_sliders > 0 {
let full_combo_threshold = attributes.max_combo as f32 - 0.1 * attributes.n_sliders as f32;
let full_combo_threshold = attributes.max_combo as f64 - 0.1 * attributes.n_sliders as f64;
let f32_combo = combo.map(|c| c as f32);
let f64_combo = combo.map(|c| c as f64);
if let Some(combo) = f32_combo.filter(|&c| c < full_combo_threshold) {
if let Some(combo) = f64_combo.filter(|&c| c < full_combo_threshold) {
combo_based_misses = full_combo_threshold / combo.max(1.0);
}
}
// We're clamping misses because since it's derived from combo it
// can be higher than total hits and that breaks some calculations
// * 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);
n_misses.max(combo_based_misses.floor() as usize)
@@ -634,7 +633,7 @@ mod test {
+ 2 * calculator.n100.unwrap_or(0)
+ calculator.n50.unwrap_or(0);
let denominator = 6 * total_objects;
let acc = 100.0 * numerator as f32 / denominator as f32;
let acc = 100.0 * numerator as f64 / denominator as f64;
assert!(
(target_acc - acc).abs() < 1.0,
@@ -668,7 +667,7 @@ mod test {
+ 2 * calculator.n100.unwrap_or(0)
+ calculator.n50.unwrap_or(0);
let denominator = 6 * total_objects;
let acc = 100.0 * numerator as f32 / denominator as f32;
let acc = 100.0 * numerator as f64 / denominator as f64;
assert!(
(target_acc - acc).abs() < 1.0,
+211 -40
View File
@@ -1,67 +1,238 @@
use super::OsuObject;
use crate::{
osu::precise::osu_object::{NestedObjectKind, OsuObjectKind},
parse::Pos2,
};
use super::{OsuObject, ScalingFactor, NORMALIZED_RADIUS};
const MIN_DELTA_TIME: f64 = 25.0;
const MAXIMUM_SLIDER_RADIUS: f32 = NORMALIZED_RADIUS * 2.4;
const ASSUMED_SLIDER_RADIUS: f32 = NORMALIZED_RADIUS * 1.65;
pub(crate) struct DifficultyObject<'h> {
pub(crate) base: &'h OsuObject,
pub(crate) prev: Option<(f32, f32)>, // (jump_dist, strain_time)
pub(crate) jump_dist: f32,
pub(crate) travel_dist: f32,
pub(crate) angle: Option<f32>,
pub(crate) delta: f64,
pub(crate) strain_time: f64,
pub(crate) delta: f32,
pub(crate) strain_time: f32,
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,
}
impl<'h> DifficultyObject<'h> {
pub(crate) fn new(
pub(super) fn new(
base: &'h OsuObject,
prev: &OsuObject,
prev_vals: Option<(f32, f32)>, // (jump_dist, strain_time)
prev_prev: Option<OsuObject>,
scale_factor: f32,
scaling_factor: f32,
prev: &mut OsuObject,
prev_prev: Option<&OsuObject>,
scaling_factor: &ScalingFactor,
clock_rate: f64,
) -> Self {
let delta = base.time - prev.time;
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects
let strain_time = delta.max(25.0);
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects
let strain_time = delta.max(MIN_DELTA_TIME);
let pos = base.pos; // stacked position
let travel_dist = prev.travel_dist();
let prev_cursor_pos = prev.lazy_end_pos(scale_factor);
// * 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);
// We don't need to calculate either angle or distance
// when one of the last->curr objects is a spinner
let (jump_dist, angle) = if base.is_spinner() || prev.is_spinner() {
(0.0, None)
} else {
let jump_dist = ((pos - prev_cursor_pos) * scaling_factor).length();
// 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 angle = prev_prev.map(|prev_prev| {
let prev_prev_cursor_pos = prev_prev.lazy_end_pos(scale_factor);
let prev_cursor_pos = prev.lazy_end_pos(prev_stack_offset);
let v1 = prev_prev_cursor_pos - prev.pos;
let v2 = pos - prev_cursor_pos;
let jump_dist =
((base.pos - prev_cursor_pos) * scaling_factor.adjusted()).length() as f64;
let dot = v1.dot(v2);
let det = v1.x * v2.y - v1.y * v2.x;
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));
det.atan2(dot).abs()
});
let v1 = prev_prev_cursor_pos - prev.pos;
let v2 = base.pos - prev_cursor_pos;
(jump_dist, angle)
};
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,
)
};
Self {
base,
prev: prev_vals,
jump_dist,
travel_dist,
angle,
delta,
strain_time,
jump_dist,
angle,
movement_dist,
movement_time,
travel_dist,
travel_time,
}
}
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_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 / clock_rate - prev_time);
let travel_time = MIN_DELTA_TIME.max(lazy_travel_time);
(travel_dist, travel_time)
}
}
}
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);
// * 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;
// * 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)
}
}
}
}
+50 -79
View File
@@ -8,24 +8,25 @@ use std::mem;
mod difficulty_object;
mod osu_object;
mod scaling_factor;
mod skill;
mod skill_kind;
mod slider_state;
use difficulty_object::DifficultyObject;
use osu_object::{ObjectParameters, OsuObject};
use scaling_factor::ScalingFactor;
use skill::Skill;
use skill_kind::SkillKind;
use slider_state::SliderState;
use crate::{curve::CurveBuffers, parse::Pos2, Beatmap, Mods, Strains};
use crate::{curve::CurveBuffers, Beatmap, Mods, Strains};
use super::DifficultyAttributes;
const OBJECT_RADIUS: f32 = 64.0;
const SECTION_LEN: f32 = 400.0;
const DIFFICULTY_MULTIPLIER: f32 = 0.0675;
const NORMALIZED_RADIUS: f32 = 52.0;
const SECTION_LEN: f64 = 400.0;
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
const NORMALIZED_RADIUS: f32 = 50.0; // * diameter of 100; easier mental maths.
const STACK_DISTANCE: f32 = 3.0;
/// Star calculation for osu!standard maps.
@@ -54,7 +55,7 @@ pub fn stars(
};
}
let mut raw_ar = map.ar;
let mut raw_ar = map.ar as f64;
let hr = mods.hr();
if hr {
@@ -64,19 +65,10 @@ pub fn stars(
}
let time_preempt = difficulty_range_ar(raw_ar);
let scale = (1.0 - 0.7 * (map_attributes.cs - 5.0) / 5.0) / 2.0;
let radius = OBJECT_RADIUS * scale;
let mut scaling_factor = NORMALIZED_RADIUS / radius;
if radius < 30.0 {
let small_circle_bonus = (30.0 - radius).min(5.0) / 50.0;
scaling_factor *= 1.0 + small_circle_bonus;
}
let scaling_factor = ScalingFactor::new(map_attributes.cs);
let mut params = ObjectParameters {
map,
radius,
scaling_factor,
max_combo: 0,
slider_state: SliderState::new(map),
ticks: Vec::new(),
@@ -92,7 +84,7 @@ pub fn stars(
let mut hit_objects = Vec::with_capacity(take);
hit_objects.extend(hit_objects_iter);
let stack_threshold = time_preempt * map.stack_leniency;
let stack_threshold = time_preempt * map.stack_leniency as f64;
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
@@ -100,13 +92,13 @@ pub fn stars(
old_stacking(&mut hit_objects, stack_threshold);
}
let scale_factor = scale * -6.4;
// let scale_factor = (scaling_factor.scale * -6.4) as f32;
let mut hit_objects = hit_objects.into_iter().map(|mut h| {
let stack_offset = h.stack_height * scale_factor;
// let stack_offset = Pos2::new(h.stack_height * scale_factor);
let stack_offset = scaling_factor.stack_offset(h.stack_height);
h.pos += stack_offset;
h.time /= map_attributes.clock_rate;
h.pos += Pos2::new(stack_offset);
h
});
@@ -114,16 +106,16 @@ pub fn stars(
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::new(SkillKind::Aim));
skills.push(Skill::new(SkillKind::speed(hit_window)));
skills.push(Skill::aim());
skills.push(Skill::speed(hit_window));
if fl {
skills.push(Skill::new(SkillKind::flashlight(scaling_factor)));
// NOTE: Instead of having `NORMALIZED_RADIUS` as dividend, it still uses 52.0.
skills.push(Skill::flashlight(52.0 / scaling_factor.radius() as f64));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
let mut prev_vals = None;
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
@@ -132,11 +124,10 @@ pub fn stars(
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(
&curr,
&prev,
prev_vals,
prev_prev,
scale_factor,
scaling_factor,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
@@ -152,18 +143,16 @@ pub fn stars(
}
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(
&curr,
&prev,
prev_vals,
prev_prev,
scale_factor,
scaling_factor,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
@@ -180,7 +169,6 @@ pub fn stars(
}
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
}
@@ -224,9 +212,9 @@ pub fn stars(
.powf(1.0 / 1.1);
let star_rating = if base_performance > 0.00001 {
1.12_f32.cbrt()
1.12_f64.cbrt()
* 0.027
* ((100_000.0 / (1.0_f32 / 1.1).exp2() * base_performance).cbrt() + 4.0)
* ((100_000.0 / (1.0_f64 / 1.1).exp2() * base_performance).cbrt() + 4.0)
} else {
0.0
};
@@ -258,7 +246,7 @@ pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
return Strains::default();
}
let mut raw_ar = map.ar;
let mut raw_ar = map.ar as f64;
let hr = mods.hr();
if hr {
@@ -268,19 +256,10 @@ pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
}
let time_preempt = difficulty_range_ar(raw_ar);
let scale = (1.0 - 0.7 * (map_attributes.cs - 5.0) / 5.0) / 2.0;
let radius = OBJECT_RADIUS * scale;
let mut scaling_factor = NORMALIZED_RADIUS / radius;
if radius < 30.0 {
let small_circle_bonus = (30.0 - radius).min(5.0) / 50.0;
scaling_factor *= 1.0 + small_circle_bonus;
}
let scaling_factor = ScalingFactor::new(map_attributes.cs);
let mut params = ObjectParameters {
map,
radius,
scaling_factor,
max_combo: 0,
slider_state: SliderState::new(map),
ticks: Vec::new(),
@@ -295,7 +274,7 @@ pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
let mut hit_objects = Vec::with_capacity(map.hit_objects.len());
hit_objects.extend(hit_objects_iter);
let stack_threshold = time_preempt * map.stack_leniency;
let stack_threshold = time_preempt * map.stack_leniency as f64;
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
@@ -303,13 +282,13 @@ pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
old_stacking(&mut hit_objects, stack_threshold);
}
let scale_factor = scale * -6.4;
// let scale_factor = (scaling_factor.scale * -6.4) as f32;
let mut hit_objects = hit_objects.into_iter().map(|mut h| {
let stack_offset = h.stack_height * scale_factor;
// let stack_offset = Pos2::new(h.stack_height * scale_factor);
let stack_offset = scaling_factor.stack_offset(h.stack_height);
h.pos += stack_offset;
h.time /= map_attributes.clock_rate;
h.pos += Pos2::new(stack_offset);
h
});
@@ -317,16 +296,16 @@ pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::new(SkillKind::Aim));
skills.push(Skill::new(SkillKind::speed(hit_window)));
skills.push(Skill::aim());
skills.push(Skill::speed(hit_window));
if fl {
skills.push(Skill::new(SkillKind::flashlight(scaling_factor)));
// NOTE: Instead of having `NORMALIZED_RADIUS` as dividend, it still uses 52.0.
skills.push(Skill::flashlight(52.0 / scaling_factor.radius() as f64));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
let mut prev_vals = None;
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
@@ -335,11 +314,10 @@ pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(
&curr,
&prev,
prev_vals,
prev_prev,
scale_factor,
scaling_factor,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
@@ -355,18 +333,16 @@ pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
}
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(
&curr,
&prev,
prev_vals,
prev_prev,
scale_factor,
scaling_factor,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
@@ -383,7 +359,6 @@ pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
}
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
}
@@ -418,7 +393,7 @@ pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
}
}
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f32) {
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
let mut extended_start_idx = 0;
let extended_end_idx = hit_objects.len() - 1;
@@ -529,7 +504,7 @@ fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f32) {
}
}
fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f32) {
fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
for i in 0..hit_objects.len() {
if hit_objects[i].stack_height != 0.0 && !hit_objects[i].is_slider() {
continue;
@@ -557,11 +532,7 @@ fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f32) {
}
}
const OSU_AR_MAX: f32 = 450.0;
const OSU_AR_AVG: f32 = 1200.0;
const OSU_AR_MIN: f32 = 1800.0;
#[inline]
fn difficulty_range_ar(ar: f32) -> f32 {
crate::difficulty_range(ar, OSU_AR_MAX, OSU_AR_AVG, OSU_AR_MIN)
fn difficulty_range_ar(ar: f64) -> f64 {
crate::difficulty_range(ar, 450.0, 1200.0, 1800.0)
}
+130 -86
View File
@@ -6,35 +6,47 @@ use crate::{
Beatmap,
};
const LEGACY_LAST_TICK_OFFSET: f32 = 36.0;
const BASE_SCORING_DISTANCE: f32 = 100.0;
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
const BASE_SCORING_DISTANCE: f64 = 100.0;
pub(crate) struct OsuObject {
pub(crate) time: f32,
pub(crate) time: f64,
pub(crate) pos: Pos2,
pub(crate) stack_height: f32,
kind: OsuObjectKind,
pub(crate) kind: OsuObjectKind,
}
enum OsuObjectKind {
pub(crate) enum OsuObjectKind {
Circle,
Slider {
end_time: f32,
end_time: f64,
end_pos: Pos2,
lazy_end_pos: Pos2,
travel_dist: f32,
nested_objects: Vec<NestedObject>,
},
Spinner {
end_time: f32,
end_time: f64,
},
}
#[derive(Debug)]
pub(crate) struct NestedObject {
pub(crate) pos: Pos2,
pub(crate) time: f64,
pub(crate) kind: NestedObjectKind,
}
#[derive(Copy, Clone, Debug)]
pub(crate) enum NestedObjectKind {
Repeat,
Tail,
Tick,
}
pub(crate) struct ObjectParameters<'a> {
pub(crate) map: &'a Beatmap,
pub(crate) radius: f32,
pub(crate) scaling_factor: f32,
pub(crate) max_combo: usize,
pub(crate) ticks: Vec<f32>,
pub(crate) ticks: Vec<(Pos2, f64)>,
pub(crate) slider_state: SliderState<'a>,
pub(crate) curve_bufs: CurveBuffers,
}
@@ -44,8 +56,6 @@ impl OsuObject {
pub(crate) fn new(h: &HitObject, hr: bool, params: &mut ObjectParameters<'_>) -> Option<Self> {
let ObjectParameters {
map,
radius,
scaling_factor,
max_combo,
ticks,
slider_state,
@@ -71,18 +81,15 @@ impl OsuObject {
repeats,
control_points,
} => {
// Key values which are computed here
let mut lazy_end_pos = pos;
let mut travel_dist = 0.0;
// Responsible for timing point values
slider_state.update(h.start_time);
let span_count = (*repeats + 1) as f32;
let span_count = (*repeats + 1) as f64;
let approx_follow_circle_radius = *radius * 3.0;
let mut tick_dist = 100.0 * map.slider_mult / map.tick_rate;
// * 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;
@@ -99,35 +106,6 @@ impl OsuObject {
let duration = end_time - h.start_time;
let span_duration = duration / span_count;
// Called on each slider object except for the head.
// Increases combo and adjusts `lazy_end_pos` and `travel_dist`
// w.r.t. the object position at the given time on the slider curve.
let mut compute_vertex = |mut progress: f32| {
*max_combo += 1;
if progress % 2.0 >= 1.0 {
progress = 1.0 - progress % 1.0;
} else {
progress %= 1.0;
}
let mut curr_pos = h.pos + curve.position_at(progress);
if hr {
curr_pos.y = 384.0 - curr_pos.y;
}
let diff = curr_pos - lazy_end_pos;
let mut dist = diff.length();
if dist > approx_follow_circle_radius {
// * The cursor would be outside the follow circle, we need to move it
dist -= approx_follow_circle_radius;
lazy_end_pos += diff.normalize() * dist;
travel_dist += dist;
}
};
// * 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
// * been edited by the user, and should never be reached in normal usage.
@@ -141,54 +119,132 @@ impl OsuObject {
ticks.clear();
ticks.reserve((len / tick_dist) as usize);
let mut nested_objects =
Vec::with_capacity((len * span_count / tick_dist) as usize);
// Tick of the first span
// Ticks of the first span
while curr_dist < len - min_dist_from_end {
let progress = curr_dist / len;
compute_vertex(progress);
ticks.push(progress);
let curr_time = h.start_time + progress * span_duration;
let mut curr_pos = h.pos + curve.position_at(progress);
if hr {
curr_pos.y = 384.0 - curr_pos.y;
}
let tick = NestedObject {
pos: curr_pos,
time: curr_time,
kind: NestedObjectKind::Tick,
};
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 f32;
let progress = (span_idx % 2 == 1) as u8 as f64;
let span_idx_f64 = span_idx as f64;
// Reverse tick
compute_vertex(progress);
// Repeat point
let curr_time = h.start_time + span_duration * span_idx_f64;
let mut curr_pos = h.pos + curve.position_at(progress);
// Actual ticks
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 {
ticks
.iter()
.rev()
.for_each(|&tick_progress| compute_vertex(tick_progress + progress));
// 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 {
ticks
.iter()
.for_each(|&tick_progress| compute_vertex(tick_progress + progress));
let tick_iter = ticks.iter().map(|(pos, time)| NestedObject {
pos: *pos,
time: time + span_duration * span_idx_f64,
kind: NestedObjectKind::Tick,
});
nested_objects.extend(tick_iter);
}
}
// Slider tail
let final_span_start_time = h.start_time + *repeats as f32 * span_duration;
let final_span_start_time = h.start_time + *repeats as f64 * span_duration;
let final_span_end_time = (h.start_time + duration / 2.0)
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
let progress = (*repeats % 2 == 1) as u8 as f32;
let final_progress =
(final_span_end_time - final_span_start_time) / span_duration + progress;
compute_vertex(final_progress);
let mut end_pos = h.pos + curve.position_at(1.0 - progress);
travel_dist *= *scaling_factor;
let progress = (*repeats % 2 == 0) as u8 as f64;
let mut end_pos = h.pos + curve.position_at(progress);
if hr {
end_pos.y = 384.0 - end_pos.y;
}
// * we need to use the LegacyLastTick here for compatibility reasons (difficulty).
// * it is *okay* to use this because the TailCircle is not used for any meaningful purpose in gameplay.
// * if this is to change, we should revisit this.
let legacy_last_tick = NestedObject {
pos: end_pos,
time: final_span_end_time,
kind: NestedObjectKind::Tail,
};
nested_objects.push(legacy_last_tick);
*max_combo += nested_objects.len();
let lazy_travel_time = final_span_end_time - h.start_time;
let mut end_time_min = lazy_travel_time / span_duration;
if end_time_min % 2.0 >= 1.0 {
end_time_min = 1.0 - end_time_min % 1.0;
} else {
end_time_min %= 1.0;
}
// * temporary lazy end position until a real result can be derived.
let mut lazy_end_pos = h.pos + curve.position_at(end_time_min);
if hr {
lazy_end_pos.y = 384.0 - lazy_end_pos.y;
}
Self {
time: h.start_time,
pos,
@@ -197,7 +253,7 @@ impl OsuObject {
end_time: final_span_end_time,
end_pos,
lazy_end_pos,
travel_dist,
nested_objects,
},
}
}
@@ -216,15 +272,7 @@ impl OsuObject {
}
#[inline]
pub(crate) fn travel_dist(&self) -> f32 {
match &self.kind {
OsuObjectKind::Slider { travel_dist, .. } => *travel_dist,
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => 0.0,
}
}
#[inline]
pub(crate) fn end_time(&self) -> f32 {
pub(crate) fn end_time(&self) -> f64 {
match &self.kind {
OsuObjectKind::Circle => self.time,
OsuObjectKind::Slider { end_time, .. } => *end_time,
@@ -241,14 +289,10 @@ impl OsuObject {
}
#[inline]
pub(crate) fn lazy_end_pos(&self, scale_factor: f32) -> Pos2 {
pub(crate) fn lazy_end_pos(&self, stack_offset: Pos2) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
OsuObjectKind::Slider { lazy_end_pos, .. } => {
let stack_offset = scale_factor * self.stack_height;
*lazy_end_pos + Pos2::new(stack_offset)
}
OsuObjectKind::Slider { lazy_end_pos, .. } => *lazy_end_pos + stack_offset,
}
}
+50
View File
@@ -0,0 +1,50 @@
use crate::parse::Pos2;
use super::NORMALIZED_RADIUS;
const OBJECT_RADIUS: f32 = 64.0;
pub(crate) struct ScalingFactor {
adjusted_factor: f32,
factor: f32,
radius: f32,
scale: f32,
}
impl ScalingFactor {
pub(crate) fn new(cs: f64) -> Self {
let scale = (1.0 - 0.7 * (cs as f32 - 5.0) / 5.0) / 2.0;
let radius = OBJECT_RADIUS * scale;
let factor = NORMALIZED_RADIUS / radius;
let adjusted_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)
}
}
+30 -15
View File
@@ -4,23 +4,38 @@ use super::{skill_kind::calculate_speed_rhythm_bonus, DifficultyObject, SkillKin
use std::cmp::Ordering;
const REDUCED_STRAIN_BASELINE: f32 = 0.75;
const REDUCED_STRAIN_BASELINE: f64 = 0.75;
pub(crate) struct Skill {
curr_strain: f32,
curr_section_peak: f32,
curr_strain: f64,
curr_section_peak: f64,
kind: SkillKind,
pub(crate) strain_peaks: Vec<f32>,
pub(crate) strain_peaks: Vec<f64>,
prev_time: Option<f32>,
prev_time: Option<f64>,
}
impl Skill {
#[inline]
pub(crate) fn new(kind: SkillKind) -> Self {
pub(crate) fn aim() -> Self {
Self::new(SkillKind::aim())
}
#[inline]
pub(crate) fn flashlight(scaling_factor: f64) -> Self {
Self::new(SkillKind::flashlight(scaling_factor))
}
#[inline]
pub(crate) fn speed(hit_window: f64) -> Self {
Self::new(SkillKind::speed(hit_window))
}
#[inline]
fn new(kind: SkillKind) -> Self {
Self {
curr_strain: 1.0,
curr_strain: 0.0,
curr_section_peak: 0.0,
kind,
@@ -44,18 +59,18 @@ impl Skill {
}
#[inline]
pub(crate) fn start_new_section_from(&mut self, time: f32) {
pub(crate) fn start_new_section_from(&mut self, time: f64) {
// The maximum strain of the new section is not zero by default
self.curr_section_peak = self.calculate_initial_strain(time);
}
pub(crate) fn difficulty_value(&mut self) -> f32 {
pub(crate) fn difficulty_value(&mut self) -> f64 {
let mut difficulty = 0.0;
let mut weight = 1.0;
let decay_weight = self.kind.decay_weight();
let (reduced_section_count, difficulty_multiplier) = self.kind.difficulty_values();
let reduced_section_count_f32 = reduced_section_count as f32;
let reduced_section_count_f64 = reduced_section_count as f64;
self.strain_peaks
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
@@ -63,7 +78,7 @@ impl Skill {
let peaks = self.strain_peaks.iter_mut();
for (i, strain) in peaks.take(reduced_section_count).enumerate() {
let clamped = (i as f32 / reduced_section_count_f32).clamp(0.0, 1.0);
let clamped = (i as f64 / reduced_section_count_f64).clamp(0.0, 1.0);
let scale = (math_util::lerp(1.0, 10.0, clamped)).log10();
*strain *= math_util::lerp(REDUCED_STRAIN_BASELINE, 1.0, scale);
}
@@ -79,22 +94,22 @@ impl Skill {
difficulty * difficulty_multiplier
}
pub(crate) fn calculate_initial_strain(&self, time: f32) -> f32 {
pub(crate) fn calculate_initial_strain(&self, time: f64) -> f64 {
let prev_time = self.prev_time.unwrap_or(0.0);
let decayed_strain = self.curr_strain * self.kind.strain_decay(time - prev_time);
match &self.kind {
SkillKind::Aim | SkillKind::Flashlight { .. } => decayed_strain,
SkillKind::Aim { .. } | SkillKind::Flashlight { .. } => decayed_strain,
SkillKind::Speed { curr_rhythm, .. } => curr_rhythm * decayed_strain,
}
}
pub(crate) fn strain_value_at(&mut self, curr: &DifficultyObject<'_>) -> f32 {
pub(crate) fn strain_value_at(&mut self, curr: &DifficultyObject<'_>) -> f64 {
self.curr_strain *= self.kind.strain_decay(curr.delta);
self.curr_strain += self.kind.strain_value_of(curr) * self.kind.skill_multiplier();
match &mut self.kind {
SkillKind::Aim | SkillKind::Flashlight { .. } => self.curr_strain,
SkillKind::Aim { .. } | SkillKind::Flashlight { .. } => self.curr_strain,
SkillKind::Speed {
curr_rhythm,
history,
+238 -75
View File
@@ -1,47 +1,81 @@
use std::{collections::VecDeque, f32::consts::PI, iter};
use std::{
collections::VecDeque,
f64::consts::{FRAC_PI_2, PI},
iter,
};
use crate::{math_util, parse::Pos2};
use super::DifficultyObject;
const SINGLE_SPACING_TRESHOLD: f32 = 125.0;
const SINGLE_SPACING_TRESHOLD: f64 = 125.0;
const MIN_SPEED_BONUS: f32 = 75.0;
const SPEED_BALANCING_FACTOR: f32 = 40.0;
const SPEED_BALANCING_FACTOR: f64 = 40.0;
const TIMING_THRESHOLD: f32 = 107.0;
const AIM_SKILL_MULTIPLIER: f32 = 26.25;
const AIM_STRAIN_DECAY_BASE: f32 = 0.15;
const AIM_DECAY_WEIGHT: f32 = 0.9;
const AIM_DIFFICULTY_MULTIPLIER: f32 = 1.06;
const AIM_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_ANGLE_BONUS_BEGIN: f32 = std::f32::consts::FRAC_PI_3;
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: f32 = 1375.0;
const SPEED_STRAIN_DECAY_BASE: f32 = 0.3;
const SPEED_DECAY_WEIGHT: f32 = 0.9;
const SPEED_DIFFICULTY_MULTIPLIER: f32 = 1.04;
const SPEED_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_HISTORY_TIME_MAX: f32 = 5000.0;
const SPEED_RHYTHM_MULTIPLIER: f32 = 0.75;
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: f32 = 0.15;
const FLASHLIGHT_STRAIN_DECAY_BASE: f32 = 0.15;
const FLASHLIGHT_DECAY_WEIGHT: f32 = 1.0;
const FLASHLIGHT_DIFFICULTY_MULTIPLIER: f32 = 1.06;
const FLASHLIGHT_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;
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,
}
}
}
pub(crate) struct FlashlightHistoryEntry {
end_pos: Pos2,
is_spinner: bool,
jump_dist: f32,
strain_time: f32,
jump_dist: f64,
strain_time: f64,
}
impl From<&DifficultyObject<'_>> for FlashlightHistoryEntry {
@@ -57,8 +91,8 @@ impl From<&DifficultyObject<'_>> for FlashlightHistoryEntry {
pub(crate) struct SpeedHistoryEntry {
is_slider: bool,
start_time: f32,
strain_time: f32,
start_time: f64,
strain_time: f64,
}
impl From<&DifficultyObject<'_>> for SpeedHistoryEntry {
@@ -72,37 +106,45 @@ impl From<&DifficultyObject<'_>> for SpeedHistoryEntry {
}
pub(crate) enum SkillKind {
Aim,
Aim {
history: VecDeque<AimHistoryEntry>,
},
Flashlight {
history: VecDeque<FlashlightHistoryEntry>,
scaling_factor: f32,
scaling_factor: f64,
},
Speed {
curr_rhythm: f32,
curr_rhythm: f64,
history: VecDeque<SpeedHistoryEntry>,
hit_window: f32,
hit_window: f64,
},
}
impl SkillKind {
pub(crate) fn flashlight(scaling_factor: f32) -> Self {
pub(crate) fn aim() -> Self {
Self::Aim {
history: VecDeque::with_capacity(AIM_HISTORY_LENGTH + 1),
}
}
pub(crate) fn flashlight(scaling_factor: f64) -> Self {
Self::Flashlight {
history: VecDeque::with_capacity(FLASHLIGHT_HISTORY_LENGTH),
history: VecDeque::with_capacity(FLASHLIGHT_HISTORY_LENGTH + 1),
scaling_factor,
}
}
pub(crate) fn speed(hit_window: f32) -> Self {
pub(crate) fn speed(hit_window: f64) -> Self {
Self::Speed {
curr_rhythm: 1.0,
history: VecDeque::with_capacity(SPEED_HISTORY_LENGTH),
history: VecDeque::with_capacity(SPEED_HISTORY_LENGTH + 1),
hit_window,
}
}
pub(crate) fn pre_process(&mut self) {
match self {
Self::Aim => {}
Self::Aim { history } => history.truncate(AIM_HISTORY_LENGTH),
Self::Flashlight { history, .. } => history.truncate(FLASHLIGHT_HISTORY_LENGTH),
Self::Speed { history, .. } => history.truncate(SPEED_HISTORY_LENGTH),
}
@@ -110,43 +152,159 @@ impl SkillKind {
pub(crate) fn post_process(&mut self, current: &DifficultyObject<'_>) {
match self {
Self::Aim => {}
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<'_>) -> f32 {
pub(crate) fn strain_value_of(&self, curr: &DifficultyObject<'_>) -> f64 {
match self {
Self::Aim => {
if curr.base.is_spinner() {
Self::Aim { history } => {
if curr.base.is_spinner() || history.len() < 2 || history[0].is_spinner {
return 0.0;
}
let mut aim_strain = 0.0;
let prev = &history[0];
let prev_prev = &history[1];
if let Some((prev_jump_dist, prev_strain_time)) = curr.prev {
if let Some(angle) = curr.angle.filter(|a| *a > AIM_ANGLE_BONUS_BEGIN) {
let scale = 90.0;
// * 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;
let angle_bonus = (((angle - AIM_ANGLE_BONUS_BEGIN).sin()).powi(2)
* (prev_jump_dist - scale).max(0.0)
* (curr.jump_dist - scale).max(0.0))
.sqrt();
// * 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 {
// * calculate the movement velocity from slider end to current object
let movement_velocity = curr.movement_dist / curr.movement_time;
aim_strain = 1.4 * apply_diminishing_exp(angle_bonus.max(0.0))
/ (TIMING_THRESHOLD).max(prev_strain_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 {
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));
}
}
let jump_dist_exp = apply_diminishing_exp(curr.jump_dist);
let travel_dist_exp = apply_diminishing_exp(curr.travel_dist);
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 dist_exp =
jump_dist_exp + travel_dist_exp + (travel_dist_exp * jump_dist_exp).sqrt();
let velocity_diff = (prev_velocity - curr_velocity).abs();
(aim_strain + dist_exp / (curr.strain_time).max(TIMING_THRESHOLD))
.max(dist_exp / curr.strain_time)
// * 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.
aim_strain += slider_bonus * AIM_SLIDER_MULTIPLIER;
aim_strain
}
Self::Flashlight {
history,
@@ -164,7 +322,7 @@ impl SkillKind {
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();
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
@@ -180,7 +338,7 @@ impl SkillKind {
for (factor, prev) in factors.zip(history) {
if !prev.is_spinner {
let jump_dist = (curr.base.pos - prev.end_pos).length();
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
@@ -240,9 +398,9 @@ impl SkillKind {
}
#[inline]
pub(crate) fn difficulty_values(&self) -> (usize, f32) {
pub(crate) fn difficulty_values(&self) -> (usize, f64) {
match self {
Self::Aim => (AIM_REDUCED_SECTION_COUNT, AIM_DIFFICULTY_MULTIPLIER),
Self::Aim { .. } => (AIM_REDUCED_SECTION_COUNT, AIM_DIFFICULTY_MULTIPLIER),
Self::Flashlight { .. } => (
FLASHLIGHT_REDUCED_SECTION_COUNT,
FLASHLIGHT_DIFFICULTY_MULTIPLIER,
@@ -252,34 +410,34 @@ impl SkillKind {
}
#[inline]
pub(crate) fn skill_multiplier(&self) -> f32 {
pub(crate) fn skill_multiplier(&self) -> f64 {
match self {
SkillKind::Aim => AIM_SKILL_MULTIPLIER,
SkillKind::Aim { .. } => AIM_SKILL_MULTIPLIER,
SkillKind::Flashlight { .. } => FLASHLIGHT_SKILL_MULTIPLIER,
SkillKind::Speed { .. } => SPEED_SKILL_MULTIPLIER,
}
}
#[inline]
pub(crate) fn strain_decay_base(&self) -> f32 {
pub(crate) fn strain_decay_base(&self) -> f64 {
match self {
SkillKind::Aim => AIM_STRAIN_DECAY_BASE,
SkillKind::Aim { .. } => AIM_STRAIN_DECAY_BASE,
SkillKind::Flashlight { .. } => FLASHLIGHT_STRAIN_DECAY_BASE,
SkillKind::Speed { .. } => SPEED_STRAIN_DECAY_BASE,
}
}
#[inline]
pub(crate) fn decay_weight(&self) -> f32 {
pub(crate) fn decay_weight(&self) -> f64 {
match self {
SkillKind::Aim => AIM_DECAY_WEIGHT,
SkillKind::Aim { .. } => AIM_DECAY_WEIGHT,
SkillKind::Flashlight { .. } => FLASHLIGHT_DECAY_WEIGHT,
SkillKind::Speed { .. } => SPEED_DECAY_WEIGHT,
}
}
#[inline]
pub(crate) fn strain_decay(&self, ms: f32) -> f32 {
pub(crate) fn strain_decay(&self, ms: f64) -> f64 {
self.strain_decay_base().powf(ms / 1000.0)
}
}
@@ -287,8 +445,8 @@ impl SkillKind {
pub(crate) fn calculate_speed_rhythm_bonus(
current: &DifficultyObject<'_>,
history: &VecDeque<SpeedHistoryEntry>,
hit_window: f32,
) -> f32 {
hit_window: f64,
) -> f64 {
if current.base.is_spinner() {
return 0.0;
}
@@ -298,7 +456,7 @@ pub(crate) fn calculate_speed_rhythm_bonus(
let mut island_size = 1;
let mut first_delta_switch = false;
let adjusted_hit_window = hit_window * 0.6;
let history_len = history.len() as f32;
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;
@@ -312,9 +470,9 @@ pub(crate) fn calculate_speed_rhythm_bonus(
(SPEED_HISTORY_TIME_MAX - (current.base.time - curr.start_time)).max(0.0)
/ SPEED_HISTORY_TIME_MAX;
if curr_historical_decay.abs() > f32::EPSILON {
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 f32 / history_len);
curr_historical_decay = curr_historical_decay.min(i as f64 / history_len);
let curr_delta = curr.strain_time;
let prev_delta = prev.strain_time;
@@ -362,8 +520,8 @@ pub(crate) fn calculate_speed_rhythm_bonus(
rhythm_complexity_sum += (effective_ratio * start_ratio).sqrt()
* curr_historical_decay
* ((4 + island_size) as f32).sqrt()
* ((4 + prev_island_size) as f32).sqrt()
* ((4 + island_size) as f64).sqrt()
* ((4 + prev_island_size) as f64).sqrt()
/ 4.0;
start_ratio = effective_ratio;
@@ -390,7 +548,12 @@ pub(crate) fn calculate_speed_rhythm_bonus(
(4.0 + rhythm_complexity_sum * SPEED_RHYTHM_MULTIPLIER).sqrt() / 2.0
}
#[inline]
fn apply_diminishing_exp(val: f32) -> f32 {
val.powf(0.99)
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)
}
+8 -8
View File
@@ -3,8 +3,8 @@ use crate::{Beatmap, ControlPoint, ControlPointIter};
pub(crate) struct SliderState<'p> {
control_points: ControlPointIter<'p>,
next: Option<ControlPoint>,
pub(crate) beat_len: f32,
pub(crate) slider_velocity: f32,
pub(crate) beat_len: f64,
pub(crate) slider_velocity: f64,
}
impl<'p> SliderState<'p> {
@@ -29,7 +29,7 @@ impl<'p> SliderState<'p> {
}
#[inline]
pub(crate) fn update(&mut self, time: f32) {
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 { beat_len, .. } => {
@@ -88,14 +88,14 @@ mod test {
let mut state = SliderState::new(&map);
state.update(2.0);
assert!((state.beat_len - 10.0).abs() <= f32::EPSILON);
assert!((state.beat_len - 10.0).abs() <= f64::EPSILON);
state.update(3.0);
assert!((state.beat_len - 20.0).abs() <= f32::EPSILON);
assert!((state.slider_velocity - 1.0).abs() <= f32::EPSILON);
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() <= f32::EPSILON);
assert!((state.slider_velocity - 45.0).abs() <= f32::EPSILON);
assert!((state.beat_len - 30.0).abs() <= f64::EPSILON);
assert!((state.slider_velocity - 45.0).abs() <= f64::EPSILON);
}
}