diff --git a/src/any/difficulty/mod.rs b/src/any/difficulty/mod.rs index 4782991b..aa7afc3e 100644 --- a/src/any/difficulty/mod.rs +++ b/src/any/difficulty/mod.rs @@ -25,6 +25,7 @@ pub mod gradual; pub mod inspect; pub mod object; pub mod skills; +pub mod skills_new; use crate::model::mode::IGameMode; diff --git a/src/any/difficulty/skills_new/harmonic_skill.rs b/src/any/difficulty/skills_new/harmonic_skill.rs new file mode 100644 index 00000000..6ad43bf0 --- /dev/null +++ b/src/any/difficulty/skills_new/harmonic_skill.rs @@ -0,0 +1,82 @@ +use crate::{ + any::difficulty::skills_new::skill::Skill, + util::traits::{IEnumerable, IOrderedEnumerable}, +}; + +pub trait HarmonicSkill: Skill { + const HARMONIC_SCALE: f64 = 1.0; + const DECAY_EXPONENT: f64 = 0.9; + + fn object_difficulty_of<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64; + + fn process_internal<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64 { + self.object_difficulty_of(curr, objects) + } + + #[expect(unused_mut, reason = "staying in-sync with lazer")] + fn get_transformed_difficulties(&self, mut difficulties: Vec) -> Vec { + difficulties + } + + fn into_transformed_difficulties(self) -> Vec; + + /// Returns `(difficulty_value, object_weight_sum)`. + fn difficulty_value(transformed_object_difficulties: Vec) -> (f64, f64); + + #[expect(dead_code, reason = "staying in-sync with existing skills")] + fn into_difficulty_value(self) -> f64; + + /// Returns `(difficulty_value, object_weight_sum)`. + fn cloned_difficulty_value(&self) -> (f64, f64); + + fn count_top_weighted_object_difficulties( + &self, + difficulty_value: f64, + object_weight_sum: f64, + ) -> f64; + + fn difficulty_to_performance(difficulty: f64) -> f64 { + 4.0 * difficulty.powf(3.0) + } +} + +pub fn harmonic_skill_difficulty_value( + transformed_object_difficulties: &[f64], + harmonic_scale: f64, + decay_exponent: f64, +) -> (f64, f64) { + let mut difficulty = 0.0; + let mut object_weight_sum = 0.0; + + if transformed_object_difficulties.is_empty() { + return (difficulty, object_weight_sum); + } + + // * Objects with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // * These objects will not contribute to the difficulty. + for (index, obj) in transformed_object_difficulties + .to_vec() + .cs_order_descending() + .cs_where(|v| *v > 0.0) + .iter() + .enumerate() + { + // * Use a harmonic sum that considers each object of the map according to a predefined weight. + let weight = (1.0 + (harmonic_scale / (1 + index) as f64)) + / ((index as f64).powf(decay_exponent) + 1.0 + (harmonic_scale / (1 + index) as f64)); + + object_weight_sum += weight; + + difficulty += obj * weight; + } + + (difficulty, object_weight_sum) +} diff --git a/src/any/difficulty/skills_new/mod.rs b/src/any/difficulty/skills_new/mod.rs new file mode 100644 index 00000000..62660ac2 --- /dev/null +++ b/src/any/difficulty/skills_new/mod.rs @@ -0,0 +1,66 @@ +use crate::util::{difficulty::logistic, float_ext::FloatExt, hint::unlikely}; + +pub mod harmonic_skill; +pub mod skill; +pub mod strain_decay_skill; +pub mod strain_skill; +pub mod variable_length_strain_skill; + +pub fn count_top_weighted_object_difficulties( + difficulty_value: f64, + object_difficulties: &[f64], + object_weight_sum: f64, + midpoint_offset: f64, + multiplier: f64, + max_value: Option, +) -> f64 { + if object_difficulties.is_empty() || FloatExt::eq(object_weight_sum, 0.0) { + return 0.0; + } + + // * What would the top difficulty be if all object difficulties were identical + let consistent_top_obj = difficulty_value / object_weight_sum; + + if FloatExt::eq(consistent_top_obj, 0.0) { + return 0.0; + } + + object_difficulties + .iter() + .map(|s| { + logistic( + s / consistent_top_obj, + midpoint_offset, + multiplier, + max_value, + ) + }) + .sum() +} + +pub fn count_top_weighted_strains( + object_difficulties: &[f64], + difficulty_value: f64, + decay_weight: f64, +) -> f64 { + if unlikely(object_difficulties.is_empty()) { + return 0.0; + } + + // * What would the top strain be if all strain values were identical + let consistent_top_strain = difficulty_value * (1.0 - decay_weight); + + if unlikely(FloatExt::eq(consistent_top_strain, 0.0)) { + return object_difficulties.len() as f64; + } + + // * Use a weighted sum of all strains. Constants are arbitrary and give nice values + object_difficulties + .iter() + .map(|s| logistic(s / consistent_top_strain, 0.88, 10.0, Some(1.1))) + .sum() +} + +pub fn strain_decay_base(ms: f64, strain_decay_base: f64) -> f64 { + f64::powf(strain_decay_base, ms / 1000.0) +} diff --git a/src/any/difficulty/skills_new/skill.rs b/src/any/difficulty/skills_new/skill.rs new file mode 100644 index 00000000..8885cd4c --- /dev/null +++ b/src/any/difficulty/skills_new/skill.rs @@ -0,0 +1,12 @@ +pub trait Skill: Sized { + type DifficultyObject<'a>; + type DifficultyObjects<'a>: ?Sized; + + fn process<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ); + + fn get_object_difficulties(&self) -> &[f64]; +} \ No newline at end of file diff --git a/src/any/difficulty/skills_new/strain_decay_skill.rs b/src/any/difficulty/skills_new/strain_decay_skill.rs new file mode 100644 index 00000000..345e8805 --- /dev/null +++ b/src/any/difficulty/skills_new/strain_decay_skill.rs @@ -0,0 +1,14 @@ +use crate::any::difficulty::skills_new::strain_skill::NewStrainSkill; + +pub trait NewStrainDecaySkill: NewStrainSkill { + const SKILL_MULTIPLIER: f64 = 0.0; + const STRAIN_DECAY_BASE: f64 = 0.0; + + fn strain_value_of<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64; + + fn strain_decay(ms: f64) -> f64; +} diff --git a/src/any/difficulty/skills_new/strain_skill.rs b/src/any/difficulty/skills_new/strain_skill.rs new file mode 100644 index 00000000..1d746ea7 --- /dev/null +++ b/src/any/difficulty/skills_new/strain_skill.rs @@ -0,0 +1,73 @@ +use crate::{ + any::difficulty::skills_new::skill::Skill, + util::traits::{IEnumerable, IOrderedEnumerable}, +}; + +pub trait NewStrainSkill: Skill { + const DECAY_WEIGHT: f64 = 0.9; + const SECTION_LENGTH: i32 = 400; + + fn process_internal<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64; + + #[expect(dead_code, reason = "used by process_internal")] + fn strain_value_at<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64; + + fn count_top_weighted_strains(&self, difficulty_value: f64) -> f64; + + fn save_current_peak(&mut self); + + fn start_new_section_from<'a>( + &mut self, + time: f64, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ); + + #[expect(dead_code, reason = "used by start_new_section_from")] + fn calculate_initial_strain<'a>( + &self, + time: f64, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64; + + fn into_current_strain_peaks(self) -> Vec; + + fn get_current_strain_peaks(mut strain_peaks: Vec, current_section_peak: f64) -> Vec { + strain_peaks.push(current_section_peak); + + strain_peaks + } + + fn difficulty_value(current_strain_peaks: Vec) -> f64; + + fn into_difficulty_value(self) -> f64; + + fn cloned_difficulty_value(&self) -> f64; +} + +pub fn strain_skill_difficulty_value(current_strain_peaks: Vec, decay_weight: f64) -> 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 peaks = current_strain_peaks.cs_where(|&p| p > 0.0); + + // * Difficulty is the weighted sum of the highest strains from every section. + // * We're sorting from highest to lowest strain. + for strain in peaks.cs_order_descending() { + difficulty += strain * weight; + weight *= decay_weight; + } + + difficulty +} diff --git a/src/any/difficulty/skills_new/variable_length_strain_skill.rs b/src/any/difficulty/skills_new/variable_length_strain_skill.rs new file mode 100644 index 00000000..06ed37c7 --- /dev/null +++ b/src/any/difficulty/skills_new/variable_length_strain_skill.rs @@ -0,0 +1,111 @@ +use crate::{ + any::difficulty::skills_new::skill::Skill, + util::traits::{IEnumerable, IOrderedEnumerable}, +}; + +pub trait VariableLengthStrainSkill: Skill { + const DECAY_WEIGHT: f64 = 0.9; + const MAX_SECTION_LENGTH: f64 = 400.0; + const MAX_STORED_LENGTH: f64 = 11.0 / (1.0 - Self::DECAY_WEIGHT); + + fn process_internal<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64; + + #[expect(dead_code, reason = "used by process_internal")] + fn strain_value_at<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64; + + fn backfill_peaks<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ); + + fn save_current_peak(&mut self, section_length: f64); + + fn start_new_section_from<'a>( + &mut self, + time: f64, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ); + + #[expect(dead_code, reason = "used by start_new_section_from")] + fn calculate_initial_strain<'a>( + &self, + time: f64, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64; + + fn into_current_strain_peaks(self) -> Vec; + + fn get_current_strain_peaks( + mut strain_peaks: Vec, + current_section_peak: f64, + current_section_begin: f64, + current_section_end: f64, + ) -> Vec { + let final_peak = StrainPeak::new( + current_section_peak, + current_section_end - current_section_begin, + ); + if strain_peaks.last().is_some_and(|peak| *peak != final_peak) { + strain_peaks.cs_add_in_place(final_peak); + } + + strain_peaks + } + + fn count_top_weighted_strains(&self, difficulty_value: f64) -> f64; +} + +#[derive(Debug, Clone, Copy)] +pub struct StrainPeak { + pub value: f64, + pub section_length: f64, +} + +impl StrainPeak { + pub const fn new(value: f64, section_length: f64) -> Self { + Self { + value, + section_length: section_length.round_ties_even(), + } + } +} + +impl PartialEq for StrainPeak { + fn eq(&self, other: &Self) -> bool { + self.value == other.value && self.section_length == other.section_length + } +} + +impl Eq for StrainPeak {} + +impl Ord for StrainPeak { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // * Reverse sort, highest is first. + other.value.total_cmp(&self.value) + } +} + +impl PartialOrd for StrainPeak { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl IOrderedEnumerable for Vec { + fn cs_order_descending(mut self) -> Self { + self.sort_by(StrainPeak::cmp); + + self + } +} diff --git a/src/model/mods.rs b/src/model/mods.rs index 32d0c450..0bc7b163 100644 --- a/src/model/mods.rs +++ b/src/model/mods.rs @@ -277,6 +277,16 @@ impl GameMods { }) .flatten() } + + /// Whether Hidden is active and configured to fade the whole object + /// (not just the approach circle). + /// + /// `OnlyFadeApproachCircles` is a lazer-only setting that defaults to + /// `false`, so legacy/intermode mods (which cannot carry that setting) + /// are treated the same as an explicit `false`. + pub(crate) fn hd_full_fade(&self) -> bool { + self.hd() && self.hd_only_fade_approach_circles() != Some(true) + } } macro_rules! impl_has_mod { diff --git a/src/osu/attributes.rs b/src/osu/attributes.rs index 34c735b0..4b6babe8 100644 --- a/src/osu/attributes.rs +++ b/src/osu/attributes.rs @@ -11,6 +11,8 @@ pub struct OsuDifficultyAttributes { pub speed: f64, /// The difficulty of the flashlight skill. pub flashlight: f64, + /// The difficulty of the reading skill. + pub reading: f64, /// The ratio of the aim strain with and without considering sliders pub slider_factor: f64, /// Describes how much of aim's difficult strain count is contributed to by sliders @@ -19,6 +21,8 @@ pub struct OsuDifficultyAttributes { pub speed_top_weighted_slider_factor: f64, /// The number of clickable objects weighted by difficulty. pub speed_note_count: f64, + /// The number of clickable objects weighted by difficulty. + pub reading_difficult_note_count: f64, /// Weighted sum of aim strains. pub aim_difficult_strain_count: f64, /// Weighted sum of speed strains. @@ -97,6 +101,8 @@ pub struct OsuPerformanceAttributes { pub pp_flashlight: f64, /// The speed portion of the final pp. pub pp_speed: f64, + /// The reading portion of the final pp. + pub pp_reading: f64, /// Misses including an approximated amount of slider breaks pub effective_miss_count: f64, /// Approximated unstable-rate diff --git a/src/osu/difficulty/evaluators/aim.rs b/src/osu/difficulty/evaluators/aim.rs deleted file mode 100644 index e306d400..00000000 --- a/src/osu/difficulty/evaluators/aim.rs +++ /dev/null @@ -1,228 +0,0 @@ -use crate::{ - any::difficulty::object::IDifficultyObject, - osu::difficulty::object::OsuDifficultyObject, - util::{ - difficulty::{milliseconds_to_bpm, reverse_lerp, smootherstep, smoothstep}, - float_ext::FloatExt, - }, -}; - -pub struct AimEvaluator; - -impl AimEvaluator { - const WIDE_ANGLE_MULTIPLIER: f64 = 1.5; - const ACUTE_ANGLE_MULTIPLIER: f64 = 2.55; - const SLIDER_MULTIPLIER: f64 = 1.35; - const VELOCITY_CHANGE_MULTIPLIER: f64 = 0.75; - const WIGGLE_MULTIPLIER: f64 = 1.02; - - #[expect(clippy::too_many_lines, reason = "staying in-sync with lazer")] - pub fn evaluate_diff_of<'a>( - curr: &'a OsuDifficultyObject<'a>, - diff_objects: &'a [OsuDifficultyObject<'a>], - with_slider_travel_dist: bool, - ) -> f64 { - let osu_curr_obj = curr; - - let Some((osu_last_last_obj, osu_last_obj)) = curr - .previous(1, diff_objects) - .zip(curr.previous(0, diff_objects)) - .filter(|(_, last)| !(curr.base.is_spinner() || last.base.is_spinner())) - else { - return 0.0; - }; - - #[expect(clippy::items_after_statements, reason = "staying in-sync with lazer")] - const RADIUS: i32 = OsuDifficultyObject::NORMALIZED_RADIUS; - #[expect(clippy::items_after_statements, reason = "staying in-sync with lazer")] - const DIAMETER: i32 = OsuDifficultyObject::NORMALIZED_DIAMETER; - - // * 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.lazy_jump_dist / osu_curr_obj.adjusted_delta_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_slider_travel_dist { - // * calculate the slider velocity from slider head to slider end. - let travel_vel = osu_last_obj.travel_dist / osu_last_obj.travel_time; - // * calculate the movement velocity from slider end to current object - let movement_vel = osu_curr_obj.min_jump_dist / osu_curr_obj.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.lazy_jump_dist / osu_last_obj.adjusted_delta_time; - - if osu_last_last_obj.base.is_slider() && with_slider_travel_dist { - let travel_vel = osu_last_last_obj.travel_dist / osu_last_last_obj.travel_time; - let movement_vel = osu_last_obj.min_jump_dist / osu_last_obj.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; - let mut wiggle_bonus = 0.0; - - // * Start strain with regular velocity. - let mut aim_strain = curr_vel; - - if let Some((curr_angle, last_angle)) = osu_curr_obj.angle.zip(osu_last_obj.angle) { - // * Rewarding angles, take the smaller velocity as base. - let angle_bonus = curr_vel.min(prev_vel); - - // * If rhythms are the same. - if osu_curr_obj - .adjusted_delta_time - .max(osu_last_obj.adjusted_delta_time) - < 1.25 - * osu_curr_obj - .adjusted_delta_time - .min(osu_last_obj.adjusted_delta_time) - { - acute_angle_bonus = Self::calc_acute_angle_bonus(curr_angle); - - // * Penalize angle repetition. - acute_angle_bonus *= 0.08 - + 0.92 - * (1.0 - - f64::min( - acute_angle_bonus, - f64::powf(Self::calc_acute_angle_bonus(last_angle), 3.0), - )); - - // * Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter - acute_angle_bonus *= angle_bonus - * smootherstep( - milliseconds_to_bpm(osu_curr_obj.adjusted_delta_time, Some(2)), - 300.0, - 400.0, - ) - * smootherstep( - osu_curr_obj.lazy_jump_dist, - f64::from(DIAMETER), - f64::from(DIAMETER * 2), - ); - } - - wide_angle_bonus = Self::calc_wide_angle_bonus(curr_angle); - - // * Penalize angle repetition. - wide_angle_bonus *= 1.0 - - f64::min( - wide_angle_bonus, - f64::powf(Self::calc_wide_angle_bonus(last_angle), 3.0), - ); - - // * Apply full wide angle bonus for distance more than one diameter - wide_angle_bonus *= - angle_bonus * smootherstep(osu_curr_obj.lazy_jump_dist, 0.0, f64::from(DIAMETER)); - - // * Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle - // * https://www.desmos.com/calculator/dp0v0nvowc - wiggle_bonus = angle_bonus - * smootherstep( - osu_curr_obj.lazy_jump_dist, - f64::from(RADIUS), - f64::from(DIAMETER), - ) - * f64::powf( - reverse_lerp( - osu_curr_obj.lazy_jump_dist, - f64::from(DIAMETER * 3), - f64::from(DIAMETER), - ), - 1.8, - ) - * smootherstep(curr_angle, f64::to_radians(110.0), f64::to_radians(60.0)) - * smootherstep( - osu_last_obj.lazy_jump_dist, - f64::from(RADIUS), - f64::from(DIAMETER), - ) - * f64::powf( - reverse_lerp( - osu_last_obj.lazy_jump_dist, - f64::from(DIAMETER * 3), - f64::from(DIAMETER), - ), - 1.8, - ) - * smootherstep(last_angle, f64::to_radians(110.0), f64::to_radians(60.0)); - - if let Some(osu_last_2_obj) = curr.previous(2, diff_objects) { - let distance = - (osu_last_2_obj.base.stacked_pos() - osu_last_obj.base.stacked_pos()).length(); - - if distance < 1.0 { - wide_angle_bonus *= 1.0 - 0.35 * f64::from(1.0 - distance); - } - } - } - - if prev_vel.max(curr_vel).not_eq(0.0) { - // * 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.lazy_jump_dist + osu_last_last_obj.travel_dist) - / osu_last_obj.adjusted_delta_time; - curr_vel = (osu_curr_obj.lazy_jump_dist + osu_last_obj.travel_dist) - / osu_curr_obj.adjusted_delta_time; - - // * Scale with ratio of difference compared to 0.5 * max dist. - let dist_ratio = smoothstep( - (prev_vel - curr_vel).abs() / prev_vel.max(curr_vel), - 0.0, - 1.0, - ); - - // * Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. - let overlap_vel_buff = (f64::from(DIAMETER) * 1.25 - / osu_curr_obj - .adjusted_delta_time - .min(osu_last_obj.adjusted_delta_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.adjusted_delta_time) - .min(osu_last_obj.adjusted_delta_time) - / (osu_curr_obj.adjusted_delta_time).max(osu_last_obj.adjusted_delta_time); - vel_change_bonus *= bonus_base.powf(2.0); - } - - if osu_last_obj.base.is_slider() { - // * Reward sliders based on velocity. - slider_bonus = osu_last_obj.travel_dist / osu_last_obj.travel_time; - } - - aim_strain += wiggle_bonus * Self::WIGGLE_MULTIPLIER; - aim_strain += vel_change_bonus * Self::VELOCITY_CHANGE_MULTIPLIER; - - // * Add in acute angle bonus or wide angle bonus, whichever is larger. - aim_strain += (acute_angle_bonus * Self::ACUTE_ANGLE_MULTIPLIER) - .max(wide_angle_bonus * Self::WIDE_ANGLE_MULTIPLIER); - - aim_strain *= osu_curr_obj.small_circle_bonus; - - // * Add in additional slider velocity bonus. - if with_slider_travel_dist { - aim_strain += slider_bonus * Self::SLIDER_MULTIPLIER; - } - - aim_strain - } - - const fn calc_wide_angle_bonus(angle: f64) -> f64 { - smoothstep(angle, f64::to_radians(40.0), f64::to_radians(140.0)) - } - - const fn calc_acute_angle_bonus(angle: f64) -> f64 { - smoothstep(angle, f64::to_radians(140.0), f64::to_radians(40.0)) - } -} diff --git a/src/osu/difficulty/evaluators/aim/agility.rs b/src/osu/difficulty/evaluators/aim/agility.rs new file mode 100644 index 00000000..e9390b09 --- /dev/null +++ b/src/osu/difficulty/evaluators/aim/agility.rs @@ -0,0 +1,39 @@ +use crate::{ + any::difficulty::object::IDifficultyObject, osu::difficulty::object::OsuDifficultyObject, +}; + +pub struct AgilityEvaluator; + +impl AgilityEvaluator { + // * 1.2 circles distance between centers + const DISTANCE_CAP: f64 = (OsuDifficultyObject::NORMALIZED_DIAMETER as f64) * 1.2; + + pub fn evaluate_diff_of<'a>( + curr: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + ) -> f64 { + if curr.base.is_spinner() { + return 0.0; + } + + let osu_curr_obj = curr; + let osu_prev_obj = curr.previous(0, diff_objects); + + let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.lazy_travel_dist); + let dist = travel_dist + osu_curr_obj.lazy_jump_dist; + + let dist_scaled = dist.min(Self::DISTANCE_CAP) / Self::DISTANCE_CAP; + + let mut agility_diff = dist_scaled * 1000.0 / osu_curr_obj.adjusted_delta_time; + + agility_diff *= osu_curr_obj.small_circle_bonus.powf(1.5); + + agility_diff *= Self::high_bpm_bonus(osu_curr_obj.adjusted_delta_time); + + agility_diff + } + + fn high_bpm_bonus(ms: f64) -> f64 { + 1.0 / (1.0 - f64::powf(0.2, ms / 1000.0)) + } +} diff --git a/src/osu/difficulty/evaluators/aim/flow_aim.rs b/src/osu/difficulty/evaluators/aim/flow_aim.rs new file mode 100644 index 00000000..ba59b255 --- /dev/null +++ b/src/osu/difficulty/evaluators/aim/flow_aim.rs @@ -0,0 +1,163 @@ +use rosu_map::util::Pos; + +use crate::{ + any::difficulty::object::IDifficultyObject, + osu::difficulty::{evaluators::aim::snap_aim::SnapAimEvaluator, object::OsuDifficultyObject}, + util::{ + difficulty::{smootherstep, smoothstep}, + float_ext::FloatExt, + }, +}; + +pub struct FlowAimEvaluator; + +impl FlowAimEvaluator { + const VELOCITY_CHANGE_MULTIPLIER: f64 = 0.52; + + pub fn evaluate_diff_of<'a>( + curr: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + with_slider_travel_dist: bool, + obj_radius: f64, + ) -> f64 { + let osu_curr_obj = curr; + + let Some(osu_last_obj) = curr + .previous(0, diff_objects) + .filter(|last| curr.idx > 1 && !(curr.base.is_spinner() || last.base.is_spinner())) + else { + return 0.0; + }; + + let curr_dist = if with_slider_travel_dist { + osu_curr_obj.lazy_jump_dist + } else { + osu_curr_obj.jump_dist + }; + let prev_dist = if with_slider_travel_dist { + osu_last_obj.lazy_jump_dist + } else { + osu_last_obj.jump_dist + }; + + let mut curr_vel = curr_dist / osu_curr_obj.adjusted_delta_time; + + if osu_last_obj.base.is_slider() && with_slider_travel_dist { + // * If the last object is a slider, then we extend the travel velocity through the slider into the current object. + let slider_dist = osu_last_obj.lazy_travel_dist + osu_curr_obj.lazy_jump_dist; + curr_vel = curr_vel.max(slider_dist / osu_curr_obj.adjusted_delta_time); + } + + let prev_vel = prev_dist / osu_last_obj.adjusted_delta_time; + + let mut flow_diff = curr_vel; + + // * Apply high circle size bonus to the base velocity. + // * We use reduced CS bonus here because the bonus was made for an evaluator with a different d/t scaling. + flow_diff *= osu_curr_obj.small_circle_bonus.sqrt(); + + // * Rhythm changes are harder to flow. + flow_diff *= 1.0 + + f64::min( + 0.25, + f64::powf( + (osu_curr_obj + .adjusted_delta_time + .max(osu_last_obj.adjusted_delta_time) + - osu_curr_obj + .adjusted_delta_time + .min(osu_last_obj.adjusted_delta_time)) + / 50.0, + 4.0, + ), + ); + + if let (Some(curr_angle), Some(last_angle)) = (osu_curr_obj.angle, osu_last_obj.angle) { + let angle_diff = (curr_angle - last_angle).abs(); + let angle_diff_adjusted = (angle_diff / 2.0).sin() * 180.0; + let angular_vel = angle_diff_adjusted / (osu_curr_obj.adjusted_delta_time * 0.1); + + // * Low angular velocity flow (angles are consistent) is easier to follow than erratic flow. + flow_diff *= 0.8 + (angular_vel / 270.0).sqrt(); + } + + // * If all three notes are overlapping - don't reward bonuses as you don't have to do additional movement. + let mut overlapped_notes_weight = 1.0; + + // NOTE: Source does not null check osuLastLastObj + // instead current.Index > 2 is checked. + if curr.idx > 2 + && let Some(osu_last_last_obj) = curr.previous(1, diff_objects) + { + overlapped_notes_weight = 1.0 + - Self::calc_overlap_factor(osu_curr_obj, osu_last_obj, obj_radius) + * Self::calc_overlap_factor(osu_curr_obj, osu_last_last_obj, obj_radius) + * Self::calc_overlap_factor(osu_last_obj, osu_last_last_obj, obj_radius); + } + + if let Some(curr_angle) = osu_curr_obj.angle { + // * Acute angles are also hard to flow. + flow_diff += curr_vel + * SnapAimEvaluator::calc_angle_acuteness(curr_angle) + * overlapped_notes_weight; + } + + if prev_vel.max(curr_vel).not_eq(0.0) { + if with_slider_travel_dist { + curr_vel = curr_dist / osu_curr_obj.adjusted_delta_time; + } + + // * Scale with ratio of difference compared to 0.5 * max dist. + let dist_ratio = smoothstep( + (prev_vel - curr_vel).abs() / prev_vel.max(curr_vel), + 0.0, + 1.0, + ); + + // * Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. + let overlap_vel_buff = f64::min( + f64::from(OsuDifficultyObject::NORMALIZED_DIAMETER) * 1.25 + / osu_curr_obj + .adjusted_delta_time + .min(osu_last_obj.adjusted_delta_time), + (prev_vel - curr_vel).abs(), + ); + + flow_diff += overlap_vel_buff + * dist_ratio + * overlapped_notes_weight + * Self::VELOCITY_CHANGE_MULTIPLIER; + } + + if osu_curr_obj.base.is_slider() && with_slider_travel_dist { + // * Include slider velocity to make velocity more consistent with snap. + flow_diff += osu_curr_obj.travel_dist / osu_curr_obj.travel_time; + } + + // * Final velocity is being raised to a power because flow difficulty scales harder with + // * both high distance and time, and we want to account for that. + flow_diff = flow_diff.powf(1.45); + + // * Reduce difficulty for low spacing since spacing below radius is always to be flowed + flow_diff + * smootherstep( + curr_dist, + 0.0, + f64::from(OsuDifficultyObject::NORMALIZED_RADIUS), + ) + } + + fn calc_overlap_factor<'a>( + first: &'a OsuDifficultyObject<'a>, + second: &'a OsuDifficultyObject<'a>, + obj_radius: f64, + ) -> f64 { + let dist = Pos::distance(&first.base.stacked_pos(), second.base.stacked_pos()); + + f64::clamp( + 1.0 - ((f64::from(dist) - obj_radius).max(0.0) / obj_radius).powf(2.0), + 0.0, + 1.0, + ) + } +} diff --git a/src/osu/difficulty/evaluators/aim/mod.rs b/src/osu/difficulty/evaluators/aim/mod.rs new file mode 100644 index 00000000..17307ee1 --- /dev/null +++ b/src/osu/difficulty/evaluators/aim/mod.rs @@ -0,0 +1,3 @@ +pub mod agility; +pub mod flow_aim; +pub mod snap_aim; diff --git a/src/osu/difficulty/evaluators/aim/snap_aim.rs b/src/osu/difficulty/evaluators/aim/snap_aim.rs new file mode 100644 index 00000000..14aaa583 --- /dev/null +++ b/src/osu/difficulty/evaluators/aim/snap_aim.rs @@ -0,0 +1,304 @@ +use crate::{ + any::difficulty::object::IDifficultyObject, + osu::difficulty::object::OsuDifficultyObject, + util::{ + difficulty::{milliseconds_to_bpm, reverse_lerp, smootherstep, smoothstep}, + float_ext::FloatExt, + }, +}; + +pub struct SnapAimEvaluator; + +impl SnapAimEvaluator { + const WIDE_ANGLE_MULTIPLIER: f64 = 9.67; + const ACUTE_ANGLE_MULTIPLIER: f64 = 2.41; + const SLIDER_MULTIPLIER: f64 = 1.5; + const VELOCITY_CHANGE_MULTIPLIER: f64 = 0.9; + const WIGGLE_MULTIPLIER: f64 = 1.02; + + const WIDE_ANGLE_TIME_SCALE: f64 = 1.45; + + const REPETITION_NOTE_LIMIT: usize = 6; + const REPETITION_MAX_NERF: f64 = 0.15; + const REPETITION_MAX_VECTOR_INFLUENCE: f64 = 0.5; + + #[expect(clippy::too_many_lines, reason = "staying in-sync with lazer")] + pub fn evaluate_diff_of<'a>( + curr: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + with_slider_travel_dist: bool, + ) -> f64 { + let osu_curr_obj = curr; + + let Some(osu_last_obj) = curr + .previous(0, diff_objects) + .filter(|last| curr.idx > 1 && !(curr.base.is_spinner() || last.base.is_spinner())) + else { + return 0.0; + }; + + #[expect(clippy::items_after_statements, reason = "staying in-sync with lazer")] + const RADIUS: i32 = OsuDifficultyObject::NORMALIZED_RADIUS; + #[expect(clippy::items_after_statements, reason = "staying in-sync with lazer")] + const DIAMETER: i32 = OsuDifficultyObject::NORMALIZED_DIAMETER; + + // * Calculate the velocity to the current hitobject, which starts + // * with a base distance / time assuming the last object is a hitcircle. + let curr_dist = if with_slider_travel_dist { + osu_curr_obj.lazy_jump_dist + } else { + osu_curr_obj.jump_dist + }; + let mut curr_vel = curr_dist / osu_curr_obj.adjusted_delta_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_slider_travel_dist { + let slider_distance = osu_last_obj.lazy_travel_dist + osu_curr_obj.lazy_jump_dist; + curr_vel = curr_vel.max(slider_distance / osu_curr_obj.adjusted_delta_time); + } + + let prev_dist = if with_slider_travel_dist { + osu_last_obj.lazy_jump_dist + } else { + osu_last_obj.jump_dist + }; + let prev_vel = prev_dist / osu_last_obj.adjusted_delta_time; + + // * Start difficulty with regular velocity. + let mut snap_diff = curr_vel; + + // * Penalize angle repetition. + snap_diff *= Self::vector_angle_repetition(osu_curr_obj, osu_last_obj, diff_objects); + + if let (Some(curr_angle), Some(last_angle)) = (osu_curr_obj.angle, osu_last_obj.angle) { + // * Rewarding angles, take the smaller velocity as base. + let vel_influence = curr_vel.min(prev_vel); + let mut acute_angle_bonus = 0.0; + + // * If rhythms are the same. + if osu_curr_obj + .adjusted_delta_time + .max(osu_last_obj.adjusted_delta_time) + < 1.25 + * osu_curr_obj + .adjusted_delta_time + .min(osu_last_obj.adjusted_delta_time) + { + acute_angle_bonus = Self::calc_angle_acuteness(curr_angle); + + // * Penalize angle repetition. It is important to do it _before_ + // * multiplying by anything because we compare raw acuteness here. + acute_angle_bonus *= 0.08 + + 0.92 + * (1.0 + - f64::min( + acute_angle_bonus, + f64::powf(Self::calc_angle_acuteness(last_angle), 3.0), + )); + + // * Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter. + acute_angle_bonus *= vel_influence + * smootherstep( + milliseconds_to_bpm(osu_curr_obj.adjusted_delta_time, Some(2)), + 300.0, + 400.0, + ) + * smootherstep(curr_dist, 0.0, f64::from(DIAMETER * 2)); + } + + let mut wide_angle_bonus = Self::calc_angle_wideness(curr_angle); + + // * Penalize angle repetition. It is important to do it _before_ + // * multiplying by anything because we compare raw wideness here. + wide_angle_bonus *= 0.25 + + 0.75 + * (1.0 + - f64::min( + wide_angle_bonus, + f64::powf(Self::calc_angle_wideness(last_angle), 3.0), + )); + + // * Rescaling velocity for the wide angle bonus + let mut wide_angle_curr_vel = curr_dist + / osu_curr_obj + .adjusted_delta_time + .powf(Self::WIDE_ANGLE_TIME_SCALE); + let wide_angle_prev_vel = prev_dist + / osu_last_obj + .adjusted_delta_time + .powf(Self::WIDE_ANGLE_TIME_SCALE); + + if osu_last_obj.base.is_slider() && with_slider_travel_dist { + let slider_dist = osu_last_obj.lazy_travel_dist + osu_curr_obj.lazy_jump_dist; + wide_angle_curr_vel = f64::max( + wide_angle_curr_vel, + slider_dist + / f64::powf( + osu_curr_obj.adjusted_delta_time, + Self::WIDE_ANGLE_TIME_SCALE, + ), + ); + } + + wide_angle_bonus *= wide_angle_curr_vel.min(wide_angle_prev_vel); + + if let Some(osu_last_2_obj) = curr.previous(2, diff_objects) { + // * If objects just go back and forth through a middle point - don't give as much wide bonus. + // * Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, + // * so any object's angle's center point is always the previous object. + let dist = + (osu_last_2_obj.base.stacked_pos() - osu_last_obj.base.stacked_pos()).length(); + + if dist < 1.0 { + wide_angle_bonus *= 1.0 - 0.55 * (1.0 - f64::from(dist)); + } + } + + // * Add in acute angle bonus or wide angle bonus, whichever is larger. + snap_diff += f64::max( + acute_angle_bonus * Self::ACUTE_ANGLE_MULTIPLIER, + wide_angle_bonus * Self::WIDE_ANGLE_MULTIPLIER, + ); + + // * Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle + // * https://www.desmos.com/calculator/dp0v0nvowc + let wiggle_bonus = vel_influence + * smootherstep(curr_dist, f64::from(RADIUS), f64::from(DIAMETER)) + * f64::powf( + reverse_lerp(curr_dist, f64::from(DIAMETER * 3), f64::from(DIAMETER)), + 1.8, + ) + * smootherstep(curr_angle, f64::to_radians(110.0), f64::to_radians(60.0)) + * smootherstep(prev_dist, f64::from(RADIUS), f64::from(DIAMETER)) + * f64::powf( + reverse_lerp(prev_dist, f64::from(DIAMETER * 3), f64::from(DIAMETER)), + 1.8, + ) + * smootherstep(last_angle, f64::to_radians(110.0), f64::to_radians(60.0)); + + snap_diff += wiggle_bonus * Self::WIGGLE_MULTIPLIER; + } + + if prev_vel.max(curr_vel).not_eq(0.0) { + if with_slider_travel_dist { + // * We want to use just the object jump without slider velocity when awarding differences + curr_vel = curr_dist / osu_curr_obj.adjusted_delta_time; + } + + // * Scale with ratio of difference compared to 0.5 * max dist. + let dist_ratio = smoothstep( + (prev_vel - curr_vel).abs() / prev_vel.max(curr_vel), + 0.0, + 1.0, + ); + + // * Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. + let overlap_vel_buff = (f64::from(DIAMETER) * 1.25 + / osu_curr_obj + .adjusted_delta_time + .min(osu_last_obj.adjusted_delta_time)) + .min((prev_vel - curr_vel).abs()); + + let mut vel_change_bonus = overlap_vel_buff * dist_ratio; + + // * Penalize for rhythm changes. + vel_change_bonus *= f64::powf( + osu_curr_obj + .adjusted_delta_time + .min(osu_last_obj.adjusted_delta_time) + / osu_curr_obj + .adjusted_delta_time + .max(osu_last_obj.adjusted_delta_time), + 2.0, + ); + + snap_diff += vel_change_bonus * Self::VELOCITY_CHANGE_MULTIPLIER; + } + + // * Reward sliders based on velocity. + if osu_curr_obj.base.is_slider() && with_slider_travel_dist { + let slider_bonus = osu_curr_obj.travel_dist / osu_curr_obj.travel_time; + snap_diff += if slider_bonus < 1.0 { + slider_bonus + } else { + slider_bonus.powf(0.75) + } * Self::SLIDER_MULTIPLIER; + } + + // * Apply high circle size bonus + snap_diff *= osu_curr_obj.small_circle_bonus; + + snap_diff *= Self::high_bpm_bonus(osu_curr_obj.adjusted_delta_time); + + snap_diff + } + + fn high_bpm_bonus(ms: f64) -> f64 { + 1.0 / (1.0 - f64::powf(0.03, (ms / 1000.0).powf(0.65))) + } + + fn vector_angle_repetition<'a>( + curr: &'a OsuDifficultyObject<'a>, + prev: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + ) -> f64 { + let (Some(curr_angle), Some(prev_angle)) = (curr.angle, prev.angle) else { + return 1.0; + }; + + let mut const_angle_count: f64 = 0.0; + for i in 0..Self::REPETITION_NOTE_LIMIT { + let Some(prev_obj) = curr.previous(i, diff_objects) else { + break; + }; + + // * Only consider vectors in the same jump section, + // * stopping to change rhythm ruins momentum + if curr.adjusted_delta_time.max(prev_obj.adjusted_delta_time) + > 1.1 * curr.adjusted_delta_time.min(prev_obj.adjusted_delta_time) + { + break; + } + + if let (Some(curr_vec_angle), Some(prev_vec_angle)) = ( + curr.normalized_vector_angle, + prev_obj.normalized_vector_angle, + ) { + let angle_diff = (curr_vec_angle - prev_vec_angle).abs(); + // * Refer to this desmos for tuning, constants need to be precise + // * so that values stay within the range of 0 and 1. + // * https://www.desmos.com/calculator/a8jesv5sv2 + const_angle_count += (8.0 * f64::to_radians(11.25).min(angle_diff)).cos(); + } + } + + let vec_repetition = (0.5 / const_angle_count).min(1.0).powf(2.0); + let stack_factor = smootherstep( + curr.lazy_jump_dist, + 0.0, + f64::from(OsuDifficultyObject::NORMALIZED_DIAMETER), + ); + let angle_diff_adjusted = + (2.0 * f64::to_radians(45.0).min((curr_angle - prev_angle).abs() * stack_factor)).cos(); + let base_nerf = 1.0 + - Self::REPETITION_MAX_NERF + * Self::calc_angle_acuteness(prev_angle) + * angle_diff_adjusted; + + (base_nerf + + (1.0 - base_nerf) + * vec_repetition + * Self::REPETITION_MAX_VECTOR_INFLUENCE + * stack_factor) + .powf(2.0) + } + + const fn calc_angle_wideness(angle: f64) -> f64 { + smoothstep(angle, f64::to_radians(40.0), f64::to_radians(140.0)) + } + + pub const fn calc_angle_acuteness(angle: f64) -> f64 { + smoothstep(angle, f64::to_radians(140.0), f64::to_radians(40.0)) + } +} diff --git a/src/osu/difficulty/evaluators/flashlight.rs b/src/osu/difficulty/evaluators/flashlight.rs index 92308f05..1dab5fec 100644 --- a/src/osu/difficulty/evaluators/flashlight.rs +++ b/src/osu/difficulty/evaluators/flashlight.rs @@ -1,6 +1,7 @@ use std::cmp; use crate::{ + GameMods, any::difficulty::object::IDifficultyObject, osu::{difficulty::object::OsuDifficultyObject, object::OsuObjectKind}, }; @@ -32,7 +33,7 @@ impl FlashlightEvaluator { &self, curr: &'a OsuDifficultyObject<'a>, diff_objects: &'a [OsuDifficultyObject<'a>], - hidden: bool, + mods: &GameMods, ) -> f64 { if curr.base.is_spinner() { return 0.0; @@ -44,7 +45,7 @@ impl FlashlightEvaluator { let mut small_dist_nerf = 1.0; let mut cumulative_strain_time = 0.0; - let mut result = 0.0; + let mut flashlight_difficulty = 0.0; let mut last_obj = osu_curr; @@ -55,11 +56,10 @@ impl FlashlightEvaluator { let Some(curr_obj) = curr.previous(i, diff_objects) else { break; }; + let curr_hit_obj = curr_obj.base; cumulative_strain_time += last_obj.adjusted_delta_time; - let curr_hit_obj = curr_obj.base; - if !curr_obj.base.is_spinner() { let jump_dist = f64::from( (osu_hit_obj.stacked_pos() - curr_hit_obj.stacked_end_pos()).length(), @@ -79,15 +79,18 @@ impl FlashlightEvaluator { * (1.0 - osu_curr.opacity_at( curr_hit_obj.start_time, - hidden, + mods.hd_full_fade(), self.time_preempt, self.time_fade_in, )); - result += stack_nerf * opacity_bonus * self.scaling_factor * jump_dist - / cumulative_strain_time; + flashlight_difficulty += + stack_nerf * opacity_bonus * self.scaling_factor * jump_dist + / cumulative_strain_time; - if let Some((curr_obj_angle, osu_curr_angle)) = curr_obj.angle.zip(osu_curr.angle) { + if let (Some(curr_obj_angle), Some(osu_curr_angle)) = + (curr_obj.angle, osu_curr.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); @@ -98,15 +101,15 @@ impl FlashlightEvaluator { last_obj = curr_obj; } - result = (small_dist_nerf * result).powf(2.0); + flashlight_difficulty = (small_dist_nerf * flashlight_difficulty).powf(2.0); // * Additional bonus for Hidden due to there being no approach circles. - if hidden { - result *= 1.0 + Self::HIDDEN_BONUS; + if mods.hd() { + flashlight_difficulty *= 1.0 + Self::HIDDEN_BONUS; } // * Nerf patterns with repeated angles. - result *= Self::MIN_ANGLE_MULTIPLIER + flashlight_difficulty *= Self::MIN_ANGLE_MULTIPLIER + (1.0 - Self::MIN_ANGLE_MULTIPLIER) / (angle_repeat_count + 1.0); let mut slider_bonus = 0.0; @@ -131,8 +134,8 @@ impl FlashlightEvaluator { } } - result += slider_bonus * Self::SLIDER_MULTIPLIER; + flashlight_difficulty += slider_bonus * Self::SLIDER_MULTIPLIER; - result + flashlight_difficulty } } diff --git a/src/osu/difficulty/evaluators/mod.rs b/src/osu/difficulty/evaluators/mod.rs index 3a6d1f51..cbb49cdf 100644 --- a/src/osu/difficulty/evaluators/mod.rs +++ b/src/osu/difficulty/evaluators/mod.rs @@ -1,9 +1,11 @@ pub use self::{ - aim::AimEvaluator, flashlight::FlashlightEvaluator, rhythm::RhythmEvaluator, - speed::SpeedEvaluator, + aim::{agility::AgilityEvaluator, flow_aim::FlowAimEvaluator, snap_aim::SnapAimEvaluator}, + flashlight::FlashlightEvaluator, + reading::ReadingEvaluator, + speed::{rhythm::RhythmEvaluator, speed::SpeedEvaluator}, }; mod aim; mod flashlight; -mod rhythm; +mod reading; mod speed; diff --git a/src/osu/difficulty/evaluators/reading.rs b/src/osu/difficulty/evaluators/reading.rs new file mode 100644 index 00000000..b606db14 --- /dev/null +++ b/src/osu/difficulty/evaluators/reading.rs @@ -0,0 +1,362 @@ +use core::f64; + +use crate::{ + any::difficulty::object::IDifficultyObject, + osu::difficulty::object::OsuDifficultyObject, + util::{ + difficulty::{norm, reverse_lerp, smootherstep}, + float_ext::FloatExt, + }, +}; + +pub struct ReadingEvaluator { + // Maps from OsuDifficultyHitObject.Preempt (clock rate adjusted OsuHitObject.TimePreempt) + preempt: f64, + // Maps from OsuHitObject.TimePreempt + time_preempt: f64, + // Maps from OsuHitObject.TimeFadeIn + time_fade_in: f64, +} + +impl ReadingEvaluator { + const READING_WINDOW_SIZE: f64 = 3000.0; // * 3 seconds + const DISTANCE_INFLUENCE_THRESHOLD: f64 = + (OsuDifficultyObject::NORMALIZED_DIAMETER as f64) * 1.5; + + const DENSITY_MULTIPLIER: f64 = 2.4; + const DESNITY_DIFFICULTY_BASE: f64 = 2.5; + + const PREEMPT_BALANCING_FACTOR: f64 = 14_0000.0; + const PREEMPT_STARTING_POINT: f64 = 500.0; // * AR 9.66 in milliseconds + + const HIDDEN_MULTIPLIER: f64 = 0.28; + + const MINIMUM_ANGLE_RELEVANCY_TIME: f64 = 2000.0; // * 2 seconds + const MAXIMUM_ANGLE_RELEVANCY_TIME: f64 = 200.0; + + pub const fn new(preempt: f64, time_preempt: f64, time_fade_in: f64) -> Self { + Self { + preempt, + time_preempt, + time_fade_in, + } + } + + pub fn evaluate_diff_of<'a>( + &self, + curr: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + hidden: bool, + ) -> f64 { + if curr.base.is_spinner() || curr.idx == 0 { + return 0.0; + } + + let curr_obj = curr; + let next_obj = curr.next(0, diff_objects); + + // * Only allow velocity to buff + let velocity = f64::max(1.0, curr_obj.lazy_jump_dist / curr_obj.adjusted_delta_time); + + let curr_visible_obj_density = + self.retrieve_current_visible_object_density(curr_obj, diff_objects); + let past_obj_difficulty_influence = + self.get_past_obj_difficulty_influence(curr_obj, diff_objects); + + let constant_angle_nerf_factor = + Self::get_constant_angle_nerf_factor(curr_obj, diff_objects); + + let note_density_difficulty = Self::calc_density_difficulty( + next_obj, + velocity, + constant_angle_nerf_factor, + past_obj_difficulty_influence, + curr_visible_obj_density, + ); + + let hidden_difficulty = if hidden { + self.calc_hidden_difficulty( + curr_obj, + diff_objects, + past_obj_difficulty_influence, + curr_visible_obj_density, + velocity, + constant_angle_nerf_factor, + ) + } else { + 0.0 + }; + + let preempt_difficulty = + Self::calc_preempt_difficulty(velocity, constant_angle_nerf_factor, self.preempt); + + let mut reading_difficulty = norm( + 1.5, + [ + preempt_difficulty, + hidden_difficulty, + note_density_difficulty, + ], + ); + + // Having less time to process information is harder + reading_difficulty *= Self::high_bpm_bonus(curr_obj.adjusted_delta_time); + + reading_difficulty + } + + fn calc_density_difficulty<'a>( + next_obj: Option<&'a OsuDifficultyObject<'a>>, + velocity: f64, + constant_angle_nerf_factor: f64, + past_obj_difficulty_influence: f64, + curr_visible_obj_density: f64, + ) -> f64 { + // * Consider future densities too because it can make the path the cursor takes less clear + let mut fut_obj_difficulty_influence = curr_visible_obj_density.sqrt(); + + if let Some(next_obj) = next_obj { + // * Reduce difficulty if movement to next object is small + fut_obj_difficulty_influence *= smootherstep( + next_obj.lazy_jump_dist, + 15.0, + Self::DISTANCE_INFLUENCE_THRESHOLD, + ); + } + + // * Value higher note densities exponentially + let mut note_density_difficulty = + (past_obj_difficulty_influence + fut_obj_difficulty_influence).powf(1.7) + * 0.4 + * constant_angle_nerf_factor + * velocity; + + // * Award only denser than average maps. + note_density_difficulty = + (note_density_difficulty - Self::DESNITY_DIFFICULTY_BASE).max(0.0); + + // Apply a soft cap to general density reading to account for partial memorization + note_density_difficulty.powf(0.45) * Self::DENSITY_MULTIPLIER + } + + fn calc_preempt_difficulty( + velocity: f64, + constant_angle_nerf_factor: f64, + preempt: f64, + ) -> f64 { + // * Arbitrary curve for the base value preempt difficulty should have as approach rate increases. + // * https://www.desmos.com/calculator/c175335a71 + ((Self::PREEMPT_STARTING_POINT - preempt + (preempt - Self::PREEMPT_STARTING_POINT).abs()) + / 2.0) + .powf(2.5) + / Self::PREEMPT_BALANCING_FACTOR + * constant_angle_nerf_factor + * velocity + } + + fn calc_hidden_difficulty<'a>( + &self, + curr_obj: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + past_obj_difficulty_influence: f64, + curr_visible_obj_density: f64, + velocity: f64, + constant_angle_nerf_factor: f64, + ) -> f64 { + // * Higher preempt means that time spent invisible is higher too, we want to reward that + let preempt_factor = self.preempt.powf(2.2) * 0.01; + + // * Account for both past and current densities + let density_factor = + (curr_visible_obj_density + past_obj_difficulty_influence).powf(3.3) * 3.0; + + let mut hidden_difficulty = + (preempt_factor + density_factor) * constant_angle_nerf_factor * velocity * 0.01; + + // * Apply a soft cap to general HD reading to account for partial memorization + hidden_difficulty = hidden_difficulty.powf(0.4) * Self::HIDDEN_MULTIPLIER; + + // * Buff perfect stacks only if current note is completely invisible at the time you click the previous note. + if let Some(prev_obj) = curr_obj.previous(0, diff_objects) + && FloatExt::eq(curr_obj.lazy_jump_dist, 0.0) + && FloatExt::eq( + curr_obj.opacity_at( + prev_obj.base.start_time, + true, + self.time_preempt, + self.time_fade_in, + ), + 0.0, + ) + && prev_obj.start_time > curr_obj.start_time - self.preempt + { + // * Perfect stacks are harder the less time between notes + hidden_difficulty += + Self::HIDDEN_MULTIPLIER * 2500.0 / curr_obj.adjusted_delta_time.powf(1.5); + } + + hidden_difficulty + } + + fn get_past_obj_difficulty_influence<'a>( + &self, + curr_obj: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + ) -> f64 { + diff_objects + .iter() + // Note: This achieves the same as retrievePastVisibleObjects + .filter(|d| { + d.idx < curr_obj.idx + && curr_obj.start_time - d.start_time <= Self::READING_WINDOW_SIZE + && d.start_time >= curr_obj.start_time - self.preempt + }) + .fold(0.0, |past_obj_difficulty_influence, loop_obj| { + let mut loop_difficulty = curr_obj.opacity_at( + loop_obj.base.start_time, + false, + self.time_preempt, + self.time_fade_in, + ); + + // * When aiming an object small distances mean previous objects may be cheesed, so it doesn't matter whether they were arranged confusingly. + loop_difficulty *= smootherstep( + loop_obj.lazy_jump_dist, + 15.0, + Self::DISTANCE_INFLUENCE_THRESHOLD, + ); + + // * Account less for objects close to the max reading window + let delta_time = curr_obj.start_time - loop_obj.start_time; + let time_nerf_factor = Self::get_time_nerf_factor(delta_time); + + loop_difficulty *= time_nerf_factor; + past_obj_difficulty_influence + loop_difficulty + }) + } + + // * Returns the density of objects visible at the point in time the current object needs to be clicked capped by the reading window. + fn retrieve_current_visible_object_density<'a>( + &self, + curr_obj: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + ) -> f64 { + let mut forwards_idx = 0; + let mut visible_object_count = 0.0; + + while let Some(hit_obj) = curr_obj.next(forwards_idx, diff_objects).filter(|next| { + next.start_time - curr_obj.start_time <= Self::READING_WINDOW_SIZE + // * Object not visible at the time current object needs to be clicked. + && curr_obj.start_time >= next.start_time - self.preempt + }) { + let delta_time = hit_obj.start_time - curr_obj.start_time; + let time_nerf_factor = Self::get_time_nerf_factor(delta_time); + + visible_object_count += hit_obj.opacity_at( + curr_obj.base.start_time, + false, + self.time_preempt, + self.time_fade_in, + ) * time_nerf_factor; + + forwards_idx += 1; + } + + visible_object_count + } + + // * Returns a factor of how often the current object's angle has been repeated in a certain time frame. + // * It does this by checking the difference in angle between current and past objects and sums them based on a range of similarity. + // * https://www.desmos.com/calculator/eb057a4822 + fn get_constant_angle_nerf_factor<'a>( + curr_obj: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + ) -> f64 { + let mut constant_angle_count = 0.0; + let mut backwards_idx = 0; + let mut curr_time_gap = 0.0; + + let mut loop_obj_prev0 = curr_obj; + let mut loop_obj_prev1: Option<&OsuDifficultyObject<'a>> = None; + let mut loop_obj_prev2: Option<&OsuDifficultyObject<'a>> = None; + + while let Some(loop_obj) = curr_obj + .previous(backwards_idx, diff_objects) + .filter(|_| curr_time_gap < Self::MINIMUM_ANGLE_RELEVANCY_TIME) + { + // * Account less for objects that are close to the time limit. + let long_interval_factor = 1.0 + - reverse_lerp( + loop_obj.adjusted_delta_time, + Self::MAXIMUM_ANGLE_RELEVANCY_TIME, + Self::MINIMUM_ANGLE_RELEVANCY_TIME, + ); + + if let (Some(loop_obj_angle), Some(curr_obj_angle)) = (loop_obj.angle, curr_obj.angle) { + let angle_diff = (curr_obj_angle - loop_obj_angle).abs(); + let mut angle_diff_alternating = f64::consts::PI; + + if let ( + Some(loop_obj_prev0_angle), + Some(loop_obj_prev1_angle), + Some(loop_obj_prev2_angle), + ) = ( + loop_obj_prev0.angle, + loop_obj_prev1.and_then(|o| o.angle), + loop_obj_prev2.and_then(|o| o.angle), + ) { + angle_diff_alternating = (loop_obj_prev1_angle - loop_obj_angle).abs(); + angle_diff_alternating += (loop_obj_prev2_angle - loop_obj_prev0_angle).abs(); + + let mut weight = 1.0; + + // * Be sure that one of the angles is very sharp, when other is wide + weight *= reverse_lerp( + loop_obj_angle.min(loop_obj_prev0_angle) * 180.0 / f64::consts::PI, + 20.0, + 5.0, + ); + weight *= reverse_lerp( + loop_obj_angle.max(loop_obj_prev0_angle) * 180.0 / f64::consts::PI, + 60.0, + 120.0, + ); + + // * Lerp between max angle difference and rescaled alternating difference, with more harsh scaling compared to normal difference + angle_diff_alternating = + f64::lerp(f64::consts::PI, 0.1 * angle_diff_alternating, weight); + } + + let stack_factor = smootherstep( + loop_obj.lazy_jump_dist, + 0.0, + f64::from(OsuDifficultyObject::NORMALIZED_RADIUS), + ); + + constant_angle_count += (3.0 + * (f64::to_radians(30.0) + .min((angle_diff).min(angle_diff_alternating) * stack_factor))) + .cos() + * long_interval_factor; + } + + curr_time_gap = curr_obj.start_time - loop_obj.start_time; + backwards_idx += 1; + + loop_obj_prev2 = loop_obj_prev1; + loop_obj_prev1 = Some(loop_obj_prev0); + loop_obj_prev0 = loop_obj; + } + + (2.0 / constant_angle_count).clamp(0.2, 1.0) + } + + // * Returns a nerfing factor for when objects are very distant in time, affecting reading less. + const fn get_time_nerf_factor(delta_time: f64) -> f64 { + (2.0 - delta_time / (Self::READING_WINDOW_SIZE / 2.0)).clamp(0.0, 1.0) + } + + fn high_bpm_bonus(ms: f64) -> f64 { + 1.0 / (1.0 - f64::powf(0.8, ms / 1000.0)) + } +} diff --git a/src/osu/difficulty/evaluators/rhythm.rs b/src/osu/difficulty/evaluators/rhythm.rs deleted file mode 100644 index 101d4c9e..00000000 --- a/src/osu/difficulty/evaluators/rhythm.rs +++ /dev/null @@ -1,272 +0,0 @@ -use std::cmp; - -use crate::{ - any::difficulty::object::IDifficultyObject, - osu::difficulty::object::OsuDifficultyObject, - util::difficulty::{logistic, smoothstep_bell_curve}, -}; - -pub struct RhythmEvaluator; - -impl RhythmEvaluator { - const HISTORY_TIME_MAX: u32 = 5 * 1000; // 5 seconds - const HISTORY_OBJECTS_MAX: usize = 32; - const RHYTHM_OVERALL_MULTIPLIER: f64 = 1.0; - const RHYTHM_RATIO_MULTIPLIER: f64 = 15.0; - - #[expect(clippy::too_many_lines, reason = "staying in-sync with lazer")] - pub fn evaluate_diff_of<'a>( - curr: &'a OsuDifficultyObject<'a>, - diff_objects: &'a [OsuDifficultyObject<'a>], - hit_window: f64, - ) -> f64 { - if curr.base.is_spinner() { - return 0.0; - } - - let mut rhythm_complexity_sum = 0.0; - - let delta_difference_eps = hit_window * 0.3; - - let mut island = RhythmIsland::new(delta_difference_eps); - let mut prev_island = RhythmIsland::new(delta_difference_eps); - - // * we can't use dictionary here because we need to compare island with a tolerance - // * which is impossible to pass into the hash comparer - let mut island_counts = Vec::::new(); - - // * 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 = cmp::min(curr.idx, Self::HISTORY_OBJECTS_MAX); - - let mut rhythm_start = 0; - - while curr - .previous(rhythm_start, diff_objects) - .filter(|prev| { - rhythm_start + 2 < historical_note_count - && curr.start_time - prev.start_time < f64::from(Self::HISTORY_TIME_MAX) - }) - .is_some() - { - rhythm_start += 1; - } - - if let Some((mut prev_obj, mut last_obj)) = curr - .previous(rhythm_start, diff_objects) - .zip(curr.previous(rhythm_start + 1, diff_objects)) - { - // * we go from the furthest object back to the current one - for i in (1..=rhythm_start).rev() { - let Some(curr_obj) = curr.previous(i - 1, diff_objects) else { - break; - }; - - // * scales note 0 to 1 from history to now - let time_decay = (f64::from(Self::HISTORY_TIME_MAX) - - (curr.start_time - curr_obj.start_time)) - / f64::from(Self::HISTORY_TIME_MAX); - let note_decay = (historical_note_count - i) as f64 / historical_note_count as f64; - - // * either we're limited by time or limited by object count. - let curr_historical_decay = note_decay.min(time_decay); - - // * Use custom cap value to ensure that at this point delta time is actually zero - let curr_delta = curr_obj.delta_time.max(1e-7); - let prev_delta = prev_obj.delta_time.max(1e-7); - let last_delta = last_obj.delta_time.max(1e-7); - - // * calculate how much current delta difference deserves a rhythm bonus - // * this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) - let delta_difference = prev_delta.max(curr_delta) / prev_delta.min(curr_delta); - - // * Take only the fractional part of the value since we're only interested in punishing multiples - let delta_difference_fraction = delta_difference - delta_difference.trunc(); - - let curr_ratio = 1.0 - + Self::RHYTHM_RATIO_MULTIPLIER - * smoothstep_bell_curve(delta_difference_fraction, 0.5, 0.5).min(0.5); - - // * reduce ratio bonus if delta difference is too big - let difference_multiplier = (2.0 - delta_difference / 8.0).clamp(0.0, 1.0); - - let window_penalty = (((prev_delta - curr_delta).abs() - delta_difference_eps) - .max(0.0) - / delta_difference_eps) - .min(1.0); - - let mut effective_ratio = window_penalty * curr_ratio * difference_multiplier; - - if first_delta_switch { - if (prev_delta - curr_delta).abs() < delta_difference_eps { - // * island is still progressing - island.add_delta(curr_delta as i32); - } 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 - // * unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders - if prev_obj.base.is_slider() { - effective_ratio *= 0.3; - } - - // * repeated island polarity (2 -> 4, 3 -> 5) - if island.is_similar_polarity(&prev_island) { - 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 + delta_difference_eps - && prev_delta > curr_delta + delta_difference_eps - { - effective_ratio *= 0.125; - } - - // * repeated island size (ex: triplet -> triplet) - // * TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation - if prev_island.delta_count == island.delta_count { - effective_ratio *= 0.5; - } - - if let Some(island_count) = island_counts - .iter_mut() - .find(|entry| entry.island == island) - .filter(|entry| !entry.island.is_default()) - { - // * only add island to island counts if they're going one after another - if prev_island == island { - island_count.count += 1; - } - - // * repeated island (ex: triplet -> triplet) - let power = logistic(f64::from(island.delta), 58.33, 0.24, Some(2.75)); - effective_ratio *= (3.0 / island_count.count as f64) - .min((island_count.count as f64).recip().powf(power)); - } else { - island_counts.push(IslandCount { island, count: 1 }); - } - - // * scale down the difficulty if the object is doubletappable - let doubletapness = prev_obj.get_doubletapness(Some(curr_obj), hit_window); - effective_ratio *= 1.0 - doubletapness * 0.75; - - rhythm_complexity_sum += - (effective_ratio * start_ratio).sqrt() * curr_historical_decay; - - start_ratio = effective_ratio; - - prev_island = island; - - // * we're slowing down, stop counting - if prev_delta + delta_difference_eps < curr_delta { - // * if we're speeding up, this stays true and we keep counting island size. - first_delta_switch = false; - } - - island = - RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps); - } - } else if prev_delta > curr_delta + delta_difference_eps { - // * we're speeding up. - // * Begin counting island until we change speed again. - first_delta_switch = true; - - // * bpm change is into slider, this is easy acc window - if curr_obj.base.is_slider() { - effective_ratio *= 0.6; - } - - // * bpm change was from a slider, this is easier typically than circle -> circle - // * unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders - if prev_obj.base.is_slider() { - effective_ratio *= 0.6; - } - - start_ratio = effective_ratio; - - island = RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps); - } - - last_obj = prev_obj; - prev_obj = curr_obj; - } - } - - // * produces multiplier that can be applied to strain. range [1, infinity) (not really though) - let mut rhythm_difficulty = - (4.0 + rhythm_complexity_sum * Self::RHYTHM_OVERALL_MULTIPLIER).sqrt() / 2.0; - rhythm_difficulty *= 1.0 - curr.get_doubletapness(curr.next(0, diff_objects), hit_window); - - rhythm_difficulty - } -} - -#[derive(Copy, Clone)] -struct RhythmIsland { - delta_difference_eps: f64, - delta: i32, - delta_count: i32, -} - -const MIN_DELTA_TIME: i32 = 25; - -// Compile-time check in case `OsuDifficultyObject::MIN_DELTA_TIME` changes -// but we forget to update this value. -const _: [(); 0 - !{ MIN_DELTA_TIME - OsuDifficultyObject::MIN_DELTA_TIME as i32 == 0 } as usize] = - []; - -impl RhythmIsland { - const fn new(delta_difference_eps: f64) -> Self { - Self { - delta_difference_eps, - delta: 0, - delta_count: 0, - } - } - - fn new_with_delta(delta: i32, delta_difference_eps: f64) -> Self { - Self { - delta_difference_eps, - delta: cmp::max(delta, MIN_DELTA_TIME), - delta_count: 1, - } - } - - fn add_delta(&mut self, delta: i32) { - if self.delta == i32::MAX { - self.delta = cmp::max(delta, MIN_DELTA_TIME); - } - - self.delta_count += 1; - } - - const fn is_similar_polarity(&self, other: &Self) -> bool { - // * TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple) - // * naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation - self.delta_count % 2 == other.delta_count % 2 - } - - fn is_default(&self) -> bool { - self.delta_difference_eps.abs() < f64::EPSILON - && self.delta == i32::MAX - && self.delta_count == 0 - } -} - -impl PartialEq for RhythmIsland { - fn eq(&self, other: &Self) -> bool { - f64::from((self.delta - other.delta).abs()) < self.delta_difference_eps - && self.delta_count == other.delta_count - } -} - -struct IslandCount { - island: RhythmIsland, - count: usize, -} diff --git a/src/osu/difficulty/evaluators/speed.rs b/src/osu/difficulty/evaluators/speed.rs deleted file mode 100644 index 961d2dd5..00000000 --- a/src/osu/difficulty/evaluators/speed.rs +++ /dev/null @@ -1,73 +0,0 @@ -use crate::{ - any::difficulty::object::IDifficultyObject, - osu::difficulty::object::OsuDifficultyObject, - util::difficulty::{bpm_to_milliseconds, milliseconds_to_bpm}, -}; - -pub struct SpeedEvaluator; - -impl SpeedEvaluator { - const SINGLE_SPACING_THRESHOLD: f64 = OsuDifficultyObject::NORMALIZED_DIAMETER as f64 * 1.25; // 1.25 circlers distance between centers - const MIN_SPEED_BONUS: f64 = 200.0; // 200 BPM 1/4th - const SPEED_BALANCING_FACTOR: f64 = 40.0; - const DIST_MULTIPLIER: f64 = 0.8; - - pub fn evaluate_diff_of<'a>( - curr: &'a OsuDifficultyObject<'a>, - diff_objects: &'a [OsuDifficultyObject<'a>], - hit_window: f64, - autopilot: bool, - ) -> f64 { - if curr.base.is_spinner() { - return 0.0; - } - - // * derive strainTime for calculation - let osu_curr_obj = curr; - let osu_prev_obj = curr.previous(0, diff_objects); - let osu_next_obj = curr.next(0, diff_objects); - - let mut strain_time = curr.adjusted_delta_time; - // Note: Technically `osu_next_obj` is never `None` but instead the - // default value. This could maybe invalidate the `get_doubletapness` - // result. - let doubletapness = 1.0 - osu_curr_obj.get_doubletapness(osu_next_obj, hit_window); - - // * 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); - - let speed_bonus = if milliseconds_to_bpm(strain_time, None) > Self::MIN_SPEED_BONUS { - // * Add additional scaling bonus for streams/bursts higher than 200bpm - let base = (bpm_to_milliseconds(Self::MIN_SPEED_BONUS, None) - strain_time) - / Self::SPEED_BALANCING_FACTOR; - - 0.75 * base.powf(2.0) - } else { - // * speedBonus will be 0.0 for BPM < 200 - 0.0 - }; - - let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.travel_dist); - let mut dist = travel_dist + osu_curr_obj.min_jump_dist; - - // * Cap distance at single_spacing_threshold - dist = Self::SINGLE_SPACING_THRESHOLD.min(dist); - - // * Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold - let mut dist_bonus = - (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.95) * Self::DIST_MULTIPLIER; - - dist_bonus *= osu_curr_obj.small_circle_bonus.sqrt(); - - if autopilot { - dist_bonus = 0.0; - } - - // * Base difficulty with all bonuses - let difficulty = (1.0 + speed_bonus + dist_bonus) * 1000.0 / strain_time; - - // * Apply penalty if there's doubletappable doubles - difficulty * doubletapness - } -} diff --git a/src/osu/difficulty/evaluators/speed/mod.rs b/src/osu/difficulty/evaluators/speed/mod.rs new file mode 100644 index 00000000..08588fc1 --- /dev/null +++ b/src/osu/difficulty/evaluators/speed/mod.rs @@ -0,0 +1,3 @@ +pub mod rhythm; +#[expect(clippy::module_inception, reason = "what else should I name it?")] +pub mod speed; diff --git a/src/osu/difficulty/evaluators/speed/rhythm.rs b/src/osu/difficulty/evaluators/speed/rhythm.rs new file mode 100644 index 00000000..d5a9a97e --- /dev/null +++ b/src/osu/difficulty/evaluators/speed/rhythm.rs @@ -0,0 +1,276 @@ +use crate::{ + any::difficulty::object::IDifficultyObject, + osu::difficulty::object::OsuDifficultyObject, + util::difficulty::{logistic, reverse_lerp, smoothstep_bell_curve}, +}; + +pub struct RhythmEvaluator; + +impl RhythmEvaluator { + const HISTORY_TIME_MAX: u32 = 5 * 1000; // 5 seconds + const HISTORY_OBJECTS_MAX: usize = 32; + const RHYTHM_OVERALL_MULTIPLIER: f64 = 0.95; + const RHYTHM_RATIO_DIFF_MULTIPLIER: f64 = 26.0; + const DELTA_MIN_VALUE: f64 = 1e-7; + + #[expect(clippy::too_many_lines, reason = "staying in-sync with lazer")] + pub fn evaluate_diff_of<'a>( + curr: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + hit_window: f64, + ) -> f64 { + if curr.base.is_spinner() { + return 0.0; + } + + let mut rhythm_complexity_sum = 0.0; + let delta_difference_eps = hit_window * 0.3; + + let mut island = RhythmIsland::new(i32::MAX); + let mut prev_island = RhythmIsland::new(i32::MAX); + let mut islands = Vec::::new(); + + // * Store the difficulty of the current start of an island to buff for tighter rhythms. + let mut start_difficulty = 0.0; + let mut first_delta_switch = false; + let historical_note_count = std::cmp::min(curr.idx, Self::HISTORY_OBJECTS_MAX); + let mut rhythm_start = 0; + + while curr + .previous(rhythm_start, diff_objects) + .as_ref() + .is_some_and(|prev| { + rhythm_start + 2 < historical_note_count + && curr.start_time - prev.start_time < f64::from(Self::HISTORY_TIME_MAX) + }) + { + rhythm_start += 1; + } + + if let Some((mut prev_obj, mut prev_prev_obj)) = curr + .previous(rhythm_start, diff_objects) + .zip(curr.previous(rhythm_start + 1, diff_objects)) + { + // * We go from the furthest object back to the current one. + for i in (1..=rhythm_start).rev() { + let Some(curr_obj) = curr.previous(i - 1, diff_objects) else { + break; + }; + + if curr_obj.base.is_spinner() { + continue; + } + + // * Scales note 0 to 1 from history to now + let time_decay = (f64::from(Self::HISTORY_TIME_MAX) + - (curr.start_time - curr_obj.start_time)) + / f64::from(Self::HISTORY_TIME_MAX); + let note_decay = (historical_note_count - i) as f64 / historical_note_count as f64; + + let curr_historical_decay = note_decay.min(time_decay); + + // * Use custom cap value to ensure that at this point delta time is actually zero. + let curr_delta = curr_obj.delta_time.max(Self::DELTA_MIN_VALUE); + let prev_delta = prev_obj.delta_time.max(Self::DELTA_MIN_VALUE); + let delta_difference = (prev_delta - curr_delta).abs(); + + // * Make sure to always have the current island initialised - if we don't do it here it will only initialise on the next rhythm change. + if island.delta == i32::MAX { + island = RhythmIsland::new(curr_delta as i32); + } + + // * Calculate how much current delta difference deserves a rhythm bonus. + // * this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200). + let delta_difference_ratio = + prev_delta.max(curr_delta) / prev_delta.min(curr_delta); + + // * reduce ratio bonus if delta difference is too big + let difference_multiplier = (2.0 - delta_difference_ratio / 8.0).clamp(0.0, 1.0); + + let window_penalty = ((delta_difference - delta_difference_eps) + / delta_difference_eps) + .clamp(0.0, 1.0); + + let mut effective_difficulty = Self::get_effective_diff(delta_difference_ratio) + * window_penalty + * difference_multiplier; + + // * If previous object is a slider it might be easier to tap since you don't have to do a whole tapping motion + // * while a full deltatime might end up some weird ratio the "unpress->tap" motion might be simple + // * for example a slider-circle-circle pattern should be evaluated as a regular triple and not as a single->double + if prev_obj.base.is_slider() { + let slider_lazy_end_delta = curr_obj.min_jump_time; + let slider_lazy_delta_diff_ratio = slider_lazy_end_delta.max(curr_delta) + / slider_lazy_end_delta.min(curr_delta); + + let slider_real_end_delta = curr_obj.last_obj_end_delta_time; + let slider_real_delta_diff_ratio = slider_real_end_delta.max(curr_delta) + / slider_real_end_delta.min(curr_delta); + + let slider_effective_difficulty = f64::min( + Self::get_effective_diff(slider_lazy_delta_diff_ratio), + Self::get_effective_diff(slider_real_delta_diff_ratio), + ); + effective_difficulty = slider_effective_difficulty.min(effective_difficulty); + } + + if delta_difference < delta_difference_eps { + // * Island is still progressing + island.add_delta(curr_delta as i32); + } + + if first_delta_switch && delta_difference > delta_difference_eps { + // * bpm change is into slider, this is easy acc window + if curr_obj.base.is_slider() { + effective_difficulty *= 0.5; + } + + // * repeated island polarity (2 -> 4, 3 -> 5) + if island.is_similar_polarity(&prev_island, delta_difference_eps) { + effective_difficulty *= 0.5; + } + + // * previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + if prev_prev_obj.delta_time.max(Self::DELTA_MIN_VALUE) + > prev_delta + delta_difference_eps + && prev_delta > curr_delta + delta_difference_eps + { + effective_difficulty *= 0.125; + } + + // * repeated island size (ex: triplet -> triplet) + // * TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation + if prev_island.delta_count == island.delta_count { + effective_difficulty *= 0.5; + } + + // isSpeedingUp + if prev_delta > curr_delta + delta_difference_eps { + effective_difficulty *= 0.65; + } + + let mut found = false; + + if let Some(existing_island) = islands + .iter_mut() + .find(|i| i.almost_eq(&island, delta_difference_eps)) + { + // * only increase island occurrences if they're going one after another + if prev_island.almost_eq(&island, delta_difference_eps) { + existing_island.occurrences += 1; + } + + // * repeated island (ex: triplet -> triplet) + let power = logistic(f64::from(island.delta), 58.33, 0.24, Some(2.75)); + effective_difficulty *= f64::min( + 3.0 / f64::from(existing_island.occurrences), + (1.0 / f64::from(existing_island.occurrences)).powf(power), + ); + + found = true; + } + + if !found && island.delta_count > 0 { + islands.push(island); + } + + // * scale down the difficulty if the object is double-tappable + effective_difficulty *= 1.0 + - prev_obj.calc_double_tap_feasibility(Some(curr_obj), hit_window) * 0.75; + + if island.delta_count > 1 { + rhythm_complexity_sum += (effective_difficulty * start_difficulty).sqrt() + * curr_historical_decay; + } else { + // * constant difficulty for single-note islands + rhythm_complexity_sum += 0.7 * curr_historical_decay; + } + + start_difficulty = effective_difficulty; + + // * we're slowing down, stop counting + if prev_delta + delta_difference_eps < curr_delta { + // * if we're speeding up, this stays true and we keep counting island size. + first_delta_switch = false; + } + + prev_island = island; + island = RhythmIsland::new(curr_delta as i32); + } else if prev_delta > curr_delta + delta_difference_eps { + // * we're speeding up + // * Begin counting island until we change speed again. + first_delta_switch = true; + + // * bpm change is into slider, this is easy acc window + if curr_obj.base.is_slider() { + effective_difficulty *= 0.6; + } + + // * bpm change was from a slider, this is easier typically than circle -> circle + // * unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders + if prev_obj.base.is_slider() { + effective_difficulty *= 0.6; + } + + start_difficulty = effective_difficulty; + island = RhythmIsland::new(curr_delta as i32); + } + + prev_prev_obj = prev_obj; + prev_obj = curr_obj; + } + } + + // * If the current island is long we don't want the sum to have as big of an effect + rhythm_complexity_sum *= reverse_lerp(f64::from(island.delta_count), 22.0, 3.0); + + (4.0 + rhythm_complexity_sum * Self::RHYTHM_OVERALL_MULTIPLIER).sqrt() / 2.0 + } + + fn get_effective_diff(delta_diff_ratio: f64) -> f64 { + // * Take only the fractional part of the value since we're only interested in punishing multiples + let delta_diff_fraction = delta_diff_ratio - delta_diff_ratio.trunc(); + 1.0 + Self::RHYTHM_RATIO_DIFF_MULTIPLIER + * f64::min(0.5, smoothstep_bell_curve(delta_diff_fraction)) + } +} + +#[derive(Copy, Clone)] +struct RhythmIsland { + delta: i32, + delta_count: i32, + occurrences: i32, +} + +impl RhythmIsland { + pub fn new(delta: i32) -> Self { + Self { + delta: std::cmp::max(delta, OsuDifficultyObject::MIN_DELTA_TIME as i32), + delta_count: 1, + occurrences: 1, + } + } + + fn add_delta(&mut self, delta: i32) { + if self.delta == i32::MAX { + self.delta = std::cmp::max(delta, OsuDifficultyObject::MIN_DELTA_TIME as i32); + } + + self.delta_count += 1; + } + + fn is_similar_polarity(&self, other: &Self, epsilon: f64) -> bool { + // * Single delta islands shouldn't be compared. + if self.delta_count <= 1 || other.delta_count <= 1 { + false + } else { + f64::from((self.delta - other.delta).abs()) < epsilon + && self.delta_count % 2 == other.delta_count % 2 + } + } + + fn almost_eq(&self, other: &Self, epsilon: f64) -> bool { + f64::from((self.delta - other.delta).abs()) < epsilon + && self.delta_count == other.delta_count + } +} diff --git a/src/osu/difficulty/evaluators/speed/speed.rs b/src/osu/difficulty/evaluators/speed/speed.rs new file mode 100644 index 00000000..dabc24e5 --- /dev/null +++ b/src/osu/difficulty/evaluators/speed/speed.rs @@ -0,0 +1,55 @@ +use crate::{ + any::difficulty::object::IDifficultyObject, + osu::difficulty::object::OsuDifficultyObject, + util::difficulty::{bpm_to_milliseconds, milliseconds_to_bpm}, +}; + +pub struct SpeedEvaluator; + +impl SpeedEvaluator { + const MIN_SPEED_BONUS: f64 = 200.0; // 200 BPM 1/4th + const SPEED_BALANCING_FACTOR: f64 = 40.0; + + pub fn evaluate_diff_of<'a>( + curr: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + hit_window: f64, + ) -> f64 { + if curr.base.is_spinner() { + return 0.0; + } + + let osu_curr_obj = curr; + + let mut strain_time = osu_curr_obj.adjusted_delta_time; + let double_tap_feasibility = 1.0 + - osu_curr_obj + .calc_double_tap_feasibility(osu_curr_obj.next(0, diff_objects), hit_window); + + // * 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); + + let speed_bonus = if milliseconds_to_bpm(strain_time, None) > Self::MIN_SPEED_BONUS { + // * Add additional scaling bonus for streams/bursts higher than 200bpm + 0.75 * ((bpm_to_milliseconds(Self::MIN_SPEED_BONUS, None) - strain_time) + / Self::SPEED_BALANCING_FACTOR) + .powf(2.0) + } else { + // * speedBonus will be 0.0 for BPM < 200 + 0.0 + }; + + // * Base difficulty with all bonuses + let mut speed_difficulty = (1.0 + speed_bonus) * 1000.0 / strain_time; + + speed_difficulty *= Self::high_bpm_bonus(osu_curr_obj.adjusted_delta_time); + + // * Apply penalty if there's doubletappable doubles + speed_difficulty * double_tap_feasibility + } + + fn high_bpm_bonus(ms: f64) -> f64 { + 1.0 / (1.0 - f64::powf(0.3, ms / 1000.0)) + } +} diff --git a/src/osu/difficulty/gradual.rs b/src/osu/difficulty/gradual.rs index 20f4fce1..6d4f54c4 100644 --- a/src/osu/difficulty/gradual.rs +++ b/src/osu/difficulty/gradual.rs @@ -1,10 +1,11 @@ +use crate::any::difficulty::skills_new::skill::Skill; use std::{cmp, mem}; use rosu_map::section::general::GameMode; use crate::{ Beatmap, Difficulty, - any::{CalculateError, difficulty::skills::StrainSkill}, + any::CalculateError, model::mode::ConvertError, osu::{ convert::convert_objects, @@ -137,6 +138,8 @@ fn new(difficulty: Difficulty, map: &Beatmap) -> OsuGradualDifficulty { OsuGradualDifficulty::increment_combo(h, &mut attrs); } + let total_hit_objects = osu_objects.len(); + let mut osu_objects = OsuObjects::new(osu_objects); let diff_objects = DifficultyValues::create_difficulty_objects( @@ -146,8 +149,16 @@ fn new(difficulty: Difficulty, map: &Beatmap) -> OsuGradualDifficulty { ); let great_hit_window = map_attrs.hit_windows().od_great.unwrap_or(0.0); + let clock_rate = difficulty.get_clock_rate(); - let skills = OsuSkills::new(mods, &scaling_factor, great_hit_window, time_preempt); + let skills = OsuSkills::new( + mods, + &scaling_factor, + great_hit_window, + time_preempt, + clock_rate, + total_hit_objects, + ); let diff_objects = extend_lifetime(diff_objects.into_boxed_slice()); let score_simulator = GradualLegacyScoreSimulator::new(map, map_attrs); @@ -329,8 +340,8 @@ mod tests { for i in 1.. { let Some(next_gradual) = gradual.next() else { assert_eq!(i, hit_objects_len + 1); - assert!(gradual_2nd.last().is_some() || hit_objects_len % 2 == 0); - assert!(gradual_3rd.last().is_some() || hit_objects_len % 3 == 0); + assert!(gradual_2nd.last().is_some() || hit_objects_len.is_multiple_of(2)); + assert!(gradual_3rd.last().is_some() || hit_objects_len.is_multiple_of(3)); break; }; diff --git a/src/osu/difficulty/mod.rs b/src/osu/difficulty/mod.rs index d38b5ad5..f975c15f 100644 --- a/src/osu/difficulty/mod.rs +++ b/src/osu/difficulty/mod.rs @@ -1,26 +1,32 @@ use std::{cmp, pin::Pin}; use rosu_map::section::general::GameMode; -use skills::{aim::Aim, flashlight::Flashlight, speed::Speed, strain::OsuStrainSkill}; +use skills::{aim::Aim, flashlight::Flashlight, speed::Speed}; use crate::{ Beatmap, any::{ CalculateError, - difficulty::{Difficulty, skills::StrainSkill}, + difficulty::{ + Difficulty, + skills_new::{ + harmonic_skill::HarmonicSkill, + variable_length_strain_skill::VariableLengthStrainSkill, + }, + }, }, model::{beatmap::BeatmapAttributes, mode::ConvertError, mods::GameMods}, osu::{ convert::{convert_objects, prepare_map}, difficulty::{ - object::OsuDifficultyObject, rating::OsuRatingCalculator, - scaling_factor::ScalingFactor, skills::strain::count_top_weighted_sliders, + object::OsuDifficultyObject, scaling_factor::ScalingFactor, skills::reading::Reading, }, legacy_score_simulator::OsuLegacyScoreSimulator, object::OsuObject, - performance::PERFORMANCE_BASE_MULTIPLIER, + performance::{PERFORMANCE_BASE_MULTIPLIER, PERFORMANCE_NORM_EXPONENT}, utils::legacy_score::NestedScorePerObject, }, + util::difficulty::norm, }; use self::skills::OsuSkills; @@ -30,12 +36,9 @@ use super::attributes::OsuDifficultyAttributes; mod evaluators; pub mod gradual; mod object; -pub mod rating; pub mod scaling_factor; pub mod skills; -const STAR_RATING_MULTIPLIER: f64 = 0.0265; - const HD_FADE_IN_DURATION_MULTIPLIER: f64 = 0.4; const HD_FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3; @@ -115,7 +118,7 @@ impl OsuDifficultySetup { ..Default::default() }; - let time_preempt = f64::from((hit_windows.ar.unwrap_or(0.0) * clock_rate) as f32); + let time_preempt = f64::from((hit_windows.ar.unwrap_or(0.0) * clock_rate).trunc() as f32); Self { scaling_factor, @@ -159,11 +162,21 @@ impl DifficultyValues { Self::create_difficulty_objects(difficulty, &scaling_factor, osu_object_iter); let great_hit_window = map_attrs.hit_windows().od_great.unwrap_or(0.0); + let clock_rate = difficulty.get_clock_rate(); - let mut skills = OsuSkills::new(mods, &scaling_factor, great_hit_window, time_preempt); + let take_hit_objects = cmp::min(map.hit_objects.len(), take); // The first hit object has no difficulty object - let take_diff_objects = cmp::min(map.hit_objects.len(), take).saturating_sub(1); + let take_diff_objects = take_hit_objects.saturating_sub(1); + + let mut skills = OsuSkills::new( + mods, + &scaling_factor, + great_hit_window, + time_preempt, + clock_rate, + take_hit_objects, + ); for hit_object in diff_objects.iter().take(take_diff_objects) { skills.process(hit_object, &diff_objects); @@ -183,21 +196,29 @@ impl DifficultyValues { aim_no_sliders, speed, flashlight, + reading, } = skills; let aim_difficulty_value = aim.cloned_difficulty_value(); - - let aim_difficult_strain_count = aim.count_top_weighted_strains(aim_difficulty_value); - - let difficult_sliders = aim.get_difficult_sliders(); - let aim_no_sliders_difficulty_value = aim_no_sliders.cloned_difficulty_value(); + let (speed_difficulty_value, speed_object_weight_sum) = speed.cloned_difficulty_value(); + let (reading_difficulty_value, reading_object_weight_sum) = + reading.cloned_difficulty_value(); - let aim_no_sliders_top_weighted_slider_count = count_top_weighted_sliders( - aim_no_sliders.slider_strains(), - aim_no_sliders_difficulty_value, + let aim_difficult_strain_count = aim.count_top_weighted_strains(aim_difficulty_value); + let speed_difficult_strain_count = speed.count_top_weighted_object_difficulties( + speed_difficulty_value, + speed_object_weight_sum, + ); + let reading_difficult_note_count = reading.count_top_weighted_object_difficulties( + reading_difficulty_value, + reading_object_weight_sum, ); + let speed_notes = speed.relevant_object_count(); + + let aim_no_sliders_top_weighted_slider_count = + aim_no_sliders.count_top_weighted_sliders(aim_no_sliders_difficulty_value); let aim_no_sliders_difficult_strain_count = aim_no_sliders.count_top_weighted_strains(aim_no_sliders_difficulty_value); @@ -205,53 +226,48 @@ impl DifficultyValues { / (aim_no_sliders_difficult_strain_count - aim_no_sliders_top_weighted_slider_count) .max(1.0); - let slider_factor = if aim_difficulty_value > 0.0 { - OsuRatingCalculator::calculate_difficulty_rating(aim_no_sliders_difficulty_value) - / OsuRatingCalculator::calculate_difficulty_rating(aim_difficulty_value) - } else { - 1.0 - }; - - let speed_difficulty_value = speed.cloned_difficulty_value(); let speed_top_weighted_slider_count = - count_top_weighted_sliders(speed.slider_strains(), speed_difficulty_value); - - let speed_difficult_strain_count = speed.count_top_weighted_strains(speed_difficulty_value); - + speed.count_top_weighted_sliders(speed_difficulty_value, speed_object_weight_sum); let speed_top_weighted_slider_factor = speed_top_weighted_slider_count / (speed_difficult_strain_count - speed_top_weighted_slider_count).max(1.0); - let mechanical_difficulty_rating = - calculate_mechanical_difficulty_rating(aim_difficulty_value, speed_difficulty_value); + let difficult_sliders = aim.get_difficult_sliders(); - let osu_rating_calculator = OsuRatingCalculator::new( - mods, - attrs.n_objects(), - attrs.ar, - attrs.od(), - mechanical_difficulty_rating, - slider_factor, - ); + let aim_rating = calculate_aim_difficulty_rating(aim_difficulty_value); + let aim_no_sliders_rating = + calculate_aim_difficulty_rating(aim_no_sliders_difficulty_value); - let aim_rating = osu_rating_calculator.compute_aim_rating(aim_difficulty_value); - let speed_rating = osu_rating_calculator.compute_speed_rating(speed_difficulty_value); + let slider_factor = if aim_difficulty_value > 0.0 { + aim_no_sliders_rating / aim_rating + } else { + 1.0 + }; + + let speed_rating = calculate_difficulty_rating(speed_difficulty_value); + let reading_rating = calculate_difficulty_rating(reading_difficulty_value); let flashlight_rating = if mods.fl() { let flashlight_difficulty_value = flashlight.cloned_difficulty_value(); - - osu_rating_calculator.compute_flashlight_rating(flashlight_difficulty_value) + calculate_difficulty_rating(flashlight_difficulty_value) } else { 0.0 }; let base_aim_performance = Aim::difficulty_to_performance(aim_rating); let base_speed_performance = Speed::difficulty_to_performance(speed_rating); + let base_reading_performance = Reading::difficulty_to_performance(reading_rating); let base_flashlight_performance = Flashlight::difficulty_to_performance(flashlight_rating); - - let base_performance = ((base_aim_performance).powf(1.1) - + (base_speed_performance).powf(1.1) - + (base_flashlight_performance).powf(1.1)) - .powf(1.0 / 1.1); + let base_cognition_performance = + sum_cognition_difficulty(base_reading_performance, base_flashlight_performance); + + let base_performance = norm( + PERFORMANCE_NORM_EXPONENT, + [ + base_aim_performance, + base_speed_performance, + base_cognition_performance, + ], + ); let star_rating = calculate_star_rating(base_performance); @@ -259,13 +275,15 @@ impl DifficultyValues { attrs.aim_difficult_slider_count = difficult_sliders; attrs.speed = speed_rating; attrs.flashlight = flashlight_rating; + attrs.reading = reading_rating; attrs.slider_factor = slider_factor; attrs.aim_top_weighted_slider_factor = aim_top_weighted_slider_factor; attrs.speed_top_weighted_slider_factor = speed_top_weighted_slider_factor; attrs.aim_difficult_strain_count = aim_difficult_strain_count; attrs.speed_difficult_strain_count = speed_difficult_strain_count; attrs.stars = star_rating; - attrs.speed_note_count = speed.relevant_note_count(); + attrs.speed_note_count = speed_notes; + attrs.reading_difficult_note_count = reading_difficult_note_count; } pub fn create_difficulty_objects<'a>( @@ -316,28 +334,30 @@ impl DifficultyValues { } } -fn calculate_mechanical_difficulty_rating( - aim_difficulty_value: f64, - speed_difficulty_value: f64, -) -> f64 { - let aim_value = Aim::difficulty_to_performance( - OsuRatingCalculator::calculate_difficulty_rating(aim_difficulty_value), - ); - let speed_value = Speed::difficulty_to_performance( - OsuRatingCalculator::calculate_difficulty_rating(speed_difficulty_value), - ); +pub fn sum_cognition_difficulty(reading: f64, flashlight: f64) -> f64 { + if reading <= 0.0 { + return flashlight; + } - let total_value = (aim_value.powf(1.1) + speed_value.powf(1.1)).powf(1.0 / 1.1); + if flashlight <= 0.0 { + return reading; + } - calculate_star_rating(total_value) + // * Nerf flashlight value in cognition sum when reading is greater than flashlight + norm( + PERFORMANCE_NORM_EXPONENT, + [reading, flashlight * (flashlight / reading).clamp(0.25, 1.0)], + ) } -fn calculate_star_rating(base_performance: f64) -> f64 { - if base_performance <= 0.00001 { - return 0.0; - } +fn calculate_aim_difficulty_rating(difficulty_value: f64) -> f64 { + difficulty_value.powf(0.63) * 0.02275 +} - PERFORMANCE_BASE_MULTIPLIER.cbrt() - * STAR_RATING_MULTIPLIER - * ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0) +fn calculate_difficulty_rating(difficulty_value: f64) -> f64 { + difficulty_value.sqrt() * 0.0675 +} + +fn calculate_star_rating(base_performance: f64) -> f64 { + f64::cbrt(base_performance * PERFORMANCE_BASE_MULTIPLIER) } diff --git a/src/osu/difficulty/object.rs b/src/osu/difficulty/object.rs index 7cddf0de..82a2a670 100644 --- a/src/osu/difficulty/object.rs +++ b/src/osu/difficulty/object.rs @@ -5,6 +5,7 @@ use rosu_map::util::Pos; use crate::{ any::difficulty::object::{HasStartTime, IDifficultyObject}, osu::object::{OsuObject, OsuObjectKind}, + util::difficulty::reverse_lerp, }; use super::{HD_FADE_OUT_DURATION_MULTIPLIER, scaling_factor::ScalingFactor}; @@ -12,10 +13,19 @@ use super::{HD_FADE_OUT_DURATION_MULTIPLIER, scaling_factor::ScalingFactor}; pub struct OsuDifficultyObject<'a> { pub idx: usize, pub base: &'a OsuObject, + + /// Start time (clock rate adjusted) pub start_time: f64, + /// End time (clock rate adjusted) + pub end_time: f64, + /// Amount of time elapsed between the last and current [`OsuObject`] (clock rate adjusted) pub delta_time: f64, - + /// [`delta_time`] capped to [`MIN_DELTA_TIME`] pub adjusted_delta_time: f64, + /// Amount of time elapsed between last and current [`OsuDifficultyObject`] capped to [`MIN_DELTA_TIME`] + pub last_obj_end_delta_time: f64, + + pub jump_dist: f64, pub lazy_jump_dist: f64, pub min_jump_dist: f64, pub min_jump_time: f64, @@ -24,7 +34,9 @@ pub struct OsuDifficultyObject<'a> { pub lazy_end_pos: Option, pub lazy_travel_dist: f64, pub lazy_travel_time: f64, + pub angle: Option, + pub normalized_vector_angle: Option, pub small_circle_bonus: f64, } @@ -48,16 +60,27 @@ impl<'a> OsuDifficultyObject<'a> { ) -> Self { let delta_time = (hit_object.start_time - last_object.start_time) / clock_rate; let start_time = hit_object.start_time / clock_rate; + let end_time = hit_object.end_time() / clock_rate; - let strain_time = delta_time.max(Self::MIN_DELTA_TIME); - let small_circle_bonus = (1.0 + (30.0 - scaling_factor.radius) / 40.0).max(1.0); + // * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. + let adjusted_delta_time = delta_time.max(Self::MIN_DELTA_TIME); + let last_obj_end_delta_time = if let Some(last) = last_diff_obj { + (start_time - last.end_time).max(Self::MIN_DELTA_TIME) + } else { + adjusted_delta_time + }; + + let small_circle_bonus = (1.0 + (30.0 - scaling_factor.radius) / 70.0).max(1.0); let mut this = Self { idx, base: hit_object, start_time, + end_time, delta_time, - adjusted_delta_time: strain_time, + adjusted_delta_time, + last_obj_end_delta_time, + jump_dist: 0.0, lazy_jump_dist: 0.0, min_jump_dist: 0.0, min_jump_time: 0.0, @@ -67,6 +90,7 @@ impl<'a> OsuDifficultyObject<'a> { lazy_travel_dist: 0.0, lazy_travel_time: 0.0, angle: None, + normalized_vector_angle: None, small_circle_bonus, }; @@ -91,9 +115,19 @@ impl<'a> OsuDifficultyObject<'a> { } let fade_in_start_time = self.base.start_time - time_preempt; - let fade_in_duration = time_fade_in; + + // * Equal to `OsuHitObject.TimeFadeIn` minus any adjustments from the HD mod. + let fade_in_duration = 400.0 * (time_preempt / OsuObject::PREEMPT_MIN).min(1.0); if hidden { + // Sliders retain their default `TimeFadeIn` under HD. + // Only non-slider objects get the HD-adjusted fade in duration. + let time_fade_in = if self.base.is_slider() { + fade_in_duration + } else { + time_fade_in + }; + // * Taken from OsuModHidden. let fade_out_start_time = self.base.start_time - time_preempt + time_fade_in; let fade_out_duration = time_preempt * HD_FADE_OUT_DURATION_MULTIPLIER; @@ -105,7 +139,7 @@ impl<'a> OsuDifficultyObject<'a> { } } - pub fn get_doubletapness(&self, next: Option<&Self>, hit_window: f64) -> f64 { + pub fn calc_double_tap_feasibility(&self, next: Option<&Self>, hit_window: f64) -> f64 { let Some(next) = next else { return 0.0 }; let hit_window = if self.base.is_spinner() { @@ -117,10 +151,19 @@ impl<'a> OsuDifficultyObject<'a> { let curr_delta_time = self.delta_time.max(1.0); let next_delta_time = next.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 = (curr_delta_time / hit_window).min(1.0).powf(2.0); + let window_ratio = (curr_delta_time / hit_window).min(1.0).powf(5.0); - 1.0 - (speed_ratio).powf(1.0 - window_ratio) + // * Can't doubletap if circles don't intersect + let distance_factor = reverse_lerp( + self.lazy_jump_dist, + f64::from(Self::NORMALIZED_DIAMETER), + f64::from(Self::NORMALIZED_RADIUS), + ) + .powf(2.0); + + 1.0 - speed_ratio.powf(distance_factor * (1.0 - window_ratio)) } fn set_distances( @@ -132,29 +175,33 @@ impl<'a> OsuDifficultyObject<'a> { scaling_factor: &ScalingFactor, ) { if let OsuObjectKind::Slider(ref slider) = self.base.kind { - self.travel_dist = self.lazy_travel_dist - * ((1.0 + slider.repeat_count() as f64 / 2.5).powf(1.0 / 2.5)); + // * Bonus for repeat sliders until a better per nested object strain system can be achieved. + self.travel_dist = + self.lazy_travel_dist * (slider.repeat_count() as f64).powf(0.3).max(1.0); self.travel_time = (self.lazy_travel_time / clock_rate).max(OsuDifficultyObject::MIN_DELTA_TIME); } + self.min_jump_time = self.adjusted_delta_time; + if self.base.is_spinner() || last_object.is_spinner() { return; } let scaling_factor = scaling_factor.factor; - let last_cursor_pos = if let Some(last_diff_obj) = last_diff_obj { + let mut last_cursor_pos = if let Some(last_diff_obj) = last_diff_obj { Self::get_end_cursor_pos(last_diff_obj) } else { last_object.stacked_pos() }; - self.lazy_jump_dist = f64::from( - (self.base.stacked_pos() * scaling_factor - last_cursor_pos * scaling_factor).length(), + self.jump_dist = f64::from( + (last_object.stacked_pos() - self.base.stacked_pos()).length() * scaling_factor, ); - self.min_jump_time = self.adjusted_delta_time; + self.lazy_jump_dist = + f64::from((self.base.stacked_pos() - last_cursor_pos).length() * scaling_factor); self.min_jump_dist = self.lazy_jump_dist; let Some(last_diff_obj) = last_diff_obj else { @@ -181,20 +228,19 @@ impl<'a> OsuDifficultyObject<'a> { self.min_jump_dist = ((self.lazy_jump_dist - diff).min(min)).max(0.0); } - let Some(last_last_diff_obj) = last_last_diff_obj else { - return; - }; + if let Some(last_last_diff_obj) = last_last_diff_obj.filter(|ob| !ob.base.is_spinner()) { + if last_object.is_slider() && last_diff_obj.travel_dist > 0.0 { + last_cursor_pos = last_object.stacked_pos(); + } - if !last_last_diff_obj.base.is_spinner() { let last_last_cursor_pos = Self::get_end_cursor_pos(last_last_diff_obj); - let v1 = last_last_cursor_pos - last_object.stacked_pos(); - let v2 = self.base.stacked_pos() - last_cursor_pos; + let angle = self.calculate_angle(last_cursor_pos, last_last_cursor_pos); + let slider_angle = self.calculate_slider_angle(last_diff_obj, last_last_cursor_pos); + let v = self.base.stacked_pos() - last_cursor_pos; - let dot = v1.dot(v2); - let det = v1.x * v2.y - v1.y * v2.x; - - self.angle = Some((f64::from(det).atan2(f64::from(dot))).abs()); + self.normalized_vector_angle = Some(f64::from(v.y).abs().atan2(f64::from(v.x).abs())); + self.angle = Some(angle.min(slider_angle)); } } @@ -212,7 +258,7 @@ impl<'a> OsuDifficultyObject<'a> { let pos = self.base.pos; let stack_offset = self.base.stack_offset; let start_time = self.base.start_time; - let duration = slider.end_time - start_time; + let duration = self.base.duration(); let mut nested_objects = Cow::Borrowed(slider.nested_objects.as_slice()); @@ -253,9 +299,12 @@ impl<'a> OsuDifficultyObject<'a> { end_time_min %= 1.0; } + // * temporary lazy end position until a real result can be derived. let mut lazy_end_pos = pos + stack_offset + slider.path.position_at(end_time_min); let mut curr_cursor_pos = pos + stack_offset; + + // * lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used. let scaling_factor = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS) / radius; for (curr_movement_obj, i) in nested_objects.iter().zip(1..) { @@ -264,6 +313,10 @@ impl<'a> OsuDifficultyObject<'a> { let mut required_movement = f64::from(OsuDifficultyObject::ASSUMED_SLIDER_RADIUS); 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() { @@ -290,6 +343,41 @@ impl<'a> OsuDifficultyObject<'a> { self.lazy_end_pos = Some(lazy_end_pos); } + fn calculate_slider_angle( + &self, + last_diff_obj: &OsuDifficultyObject, + last_last_pos: Pos, + ) -> f64 { + let last_pos = Self::get_end_cursor_pos(last_diff_obj); + + let last_last_pos = if let OsuObjectKind::Slider(ref last_slider) = last_diff_obj.base.kind + && last_diff_obj.travel_dist > 0.0 + { + let m = last_slider.nested_objects.len(); + + if m >= 2 { + last_slider.nested_objects[m - 2].pos + last_diff_obj.base.stack_offset + } else { + // C#'s NestedHitObjects[^2] would resolve to the head circle here — + // which isn't present in `nested_objects`, so fall back to the base object. + last_diff_obj.base.stacked_pos() + } + } else { + last_last_pos + }; + + self.calculate_angle(last_pos, last_last_pos) + } + + fn calculate_angle(&self, last_pos: Pos, last_last_pos: Pos) -> f64 { + let v1 = last_last_pos - last_pos; + let v2 = self.base.stacked_pos() - last_pos; + let dot = v1.dot(v2); + let det = v1.x * v2.y - v1.y * v2.x; + + f64::from(det).atan2(f64::from(dot)).abs() + } + const fn get_end_cursor_pos(hit_object: &OsuDifficultyObject) -> Pos { if let Some(lazy_end_pos) = hit_object.lazy_end_pos { lazy_end_pos diff --git a/src/osu/difficulty/rating.rs b/src/osu/difficulty/rating.rs deleted file mode 100644 index da9bdd4a..00000000 --- a/src/osu/difficulty/rating.rs +++ /dev/null @@ -1,265 +0,0 @@ -use std::convert::identity; - -use crate::{ - GameMods, - util::{difficulty::reverse_lerp, float_ext::FloatExt}, -}; - -pub struct OsuRatingCalculator<'mods> { - mods: &'mods GameMods, - total_hits: u32, - approach_rate: f64, - overall_difficulty: f64, - mechanical_difficulty_rating: f64, - slider_factor: f64, -} - -const DIFFICULTY_MULTIPLIER: f64 = 0.0675; - -impl<'mods> OsuRatingCalculator<'mods> { - pub const fn new( - mods: &'mods GameMods, - total_hits: u32, - approach_rate: f64, - overall_difficulty: f64, - mechanical_difficulty_rating: f64, - slider_factor: f64, - ) -> Self { - Self { - mods, - total_hits, - approach_rate, - overall_difficulty, - mechanical_difficulty_rating, - slider_factor, - } - } -} - -impl OsuRatingCalculator<'_> { - pub fn compute_aim_rating(&self, aim_difficulty_value: f64) -> f64 { - if self.mods.ap() { - return 0.0; - } - - let mut aim_rating = Self::calculate_difficulty_rating(aim_difficulty_value); - - if self.mods.td() { - aim_rating = aim_rating.powf(0.8); - } - - if self.mods.rx() { - aim_rating *= 0.9; - } - - if let Some(magnetised_strength) = self.mods.attraction_strength() { - aim_rating *= 1.0 - magnetised_strength; - } - - let mut rating_multiplier = 1.0; - - let ar_length_bonus = 0.95 - + 0.4 * (f64::from(self.total_hits) / 2000.0).min(1.0) - + f64::from(u8::from(self.total_hits > 2000)) - * (f64::from(self.total_hits) / 2000.0).log10() - * 0.5; - - let ar_factor = if self.mods.rx() { - 0.0 - } else if self.approach_rate > 10.33 { - 0.3 * (self.approach_rate - 10.33) - } else if self.approach_rate < 8.0 { - 0.05 * (8.0 - self.approach_rate) - } else { - 0.0 - }; - - // * Buff for longer maps with high AR. - rating_multiplier += ar_factor * ar_length_bonus; - - if self.mods.hd() { - let visibility_factor = Self::calculate_aim_visibility_factor( - self.mechanical_difficulty_rating, - self.approach_rate, - ); - - rating_multiplier += Self::calculate_visibility_bonus( - self.mods, - self.approach_rate, - Some(visibility_factor), - Some(self.slider_factor), - ); - } - - // * It is important to consider accuracy difficulty when scaling with accuracy. - rating_multiplier *= 0.98 + self.overall_difficulty.max(0.0).powf(2.0) / 2500.0; - - aim_rating * rating_multiplier.cbrt() - } - - pub fn compute_speed_rating(&self, speed_difficulty_value: f64) -> f64 { - if self.mods.rx() { - return 0.0; - } - - let mut speed_rating = Self::calculate_difficulty_rating(speed_difficulty_value); - - if self.mods.ap() { - speed_rating *= 0.5; - } - - if let Some(magnetised_strength) = self.mods.attraction_strength() { - // * reduce speed rating because of the speed distance scaling, with maximum reduction being 0.7x - speed_rating *= 1.0 - magnetised_strength * 0.3; - } - - let mut rating_multiplier = 1.0; - - let ar_length_bonus = 0.95 - + 0.4 * (f64::from(self.total_hits) / 2000.0).min(1.0) - + f64::from(u8::from(self.total_hits > 2000)) - * (f64::from(self.total_hits) / 2000.0).log10() - * 0.5; - - let ar_factor = if self.mods.ap() { - 0.0 - } else if self.approach_rate > 10.33 { - 0.3 * (self.approach_rate - 10.33) - } else { - 0.0 - }; - - // * Buff for longer maps with high AR. - rating_multiplier += ar_factor * ar_length_bonus; - - if self.mods.hd() { - let visibility_factor = Self::calculate_speed_visibility_factor( - self.mechanical_difficulty_rating, - self.approach_rate, - ); - - rating_multiplier += Self::calculate_visibility_bonus( - self.mods, - self.approach_rate, - Some(visibility_factor), - None, - ); - } - - rating_multiplier *= 0.95 + self.overall_difficulty.max(0.0).powf(2.0) / 750.0; - - speed_rating * rating_multiplier.cbrt() - } - - pub fn compute_flashlight_rating(&self, flashlight_difficulty_value: f64) -> f64 { - if !self.mods.fl() { - return 0.0; - } - - let mut flashlight_rating = Self::calculate_difficulty_rating(flashlight_difficulty_value); - - if self.mods.td() { - flashlight_rating = flashlight_rating.powf(0.8); - } - - if self.mods.rx() { - flashlight_rating *= 0.7; - } else if self.mods.ap() { - flashlight_rating *= 0.4; - } - - if let Some(magnetised_strength) = self.mods.attraction_strength() { - flashlight_rating *= 1.0 - magnetised_strength; - } - - if let Some(deflate_initial_scale) = self.mods.deflate_start_scale() { - flashlight_rating *= reverse_lerp(deflate_initial_scale, 11.0, 1.0).clamp(0.1, 1.0); - } - - let mut rating_multiplier = 1.0; - - // * Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. - rating_multiplier *= 0.7 - + 0.1 * (f64::from(self.total_hits) / 200.0).min(1.0) - + f64::from(u8::from(self.total_hits > 200)) - * 0.2 - * (f64::from(self.total_hits.saturating_sub(200)) / 200.0).min(1.0); - - // * It is important to consider accuracy difficulty when scaling with accuracy. - rating_multiplier *= 0.98 + self.overall_difficulty.max(0.0).powf(2.0) / 2500.0; - - flashlight_rating * rating_multiplier.sqrt() - } - - pub fn calculate_visibility_bonus( - mods: &GameMods, - approach_rate: f64, - visibility_factor: Option, - slider_factor: Option, - ) -> f64 { - // * NOTE: TC's effect is only noticeable in performance calculations until lazer mods are accounted for server-side. - let is_always_partially_visible = - mods.hd_only_fade_approach_circles().is_some_and(identity) || mods.tc(); - - // * Start from normal curve, rewarding lower AR up to AR7 - let mut reading_bonus = 0.04 * (12.0 - approach_rate.max(7.0)); - - reading_bonus *= visibility_factor.unwrap_or(1.0); - - // * We want to reward slideraim on low AR less - let slider_visibility_factor = slider_factor.unwrap_or(1.0).powf(3.0); - - // * For AR up to 0 - reduce reward for very low ARs when object is visible - if approach_rate < 7.0 { - let factor = if is_always_partially_visible { - 0.03 - } else { - 0.045 - }; - - reading_bonus += factor * (7.0 - approach_rate.max(0.0)) * slider_visibility_factor; - } - - // * Starting from AR0 - cap values so they won't grow to infinity - if approach_rate < 0.0 { - let factor = if is_always_partially_visible { - 0.075 - } else { - 0.1 - }; - - reading_bonus += - factor * (1.0 - 1.5_f64.powf(approach_rate)) * slider_visibility_factor; - } - - reading_bonus - } - - pub fn calculate_difficulty_rating(difficulty_value: f64) -> f64 { - difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER - } - - fn calculate_aim_visibility_factor( - mechanical_difficulty_rating: f64, - approach_rate: f64, - ) -> f64 { - const AR_FACTOR_END_POINT: f64 = 11.5; - - let mechanical_difficulty_factor = reverse_lerp(mechanical_difficulty_rating, 5.0, 10.0); - let ar_factor_starting_point = FloatExt::lerp(9.0, 10.33, mechanical_difficulty_factor); - - reverse_lerp(approach_rate, AR_FACTOR_END_POINT, ar_factor_starting_point) - } - - fn calculate_speed_visibility_factor( - mechanical_difficulty_rating: f64, - approach_rate: f64, - ) -> f64 { - const AR_FACTOR_END_POINT: f64 = 11.5; - - let mechanical_difficulty_factor = reverse_lerp(mechanical_difficulty_rating, 5.0, 10.0); - let ar_factor_starting_point = FloatExt::lerp(10.0, 10.33, mechanical_difficulty_factor); - - reverse_lerp(approach_rate, AR_FACTOR_END_POINT, ar_factor_starting_point) - } -} diff --git a/src/osu/difficulty/skills/aim.rs b/src/osu/difficulty/skills/aim.rs index 8bf71452..92bd82de 100644 --- a/src/osu/difficulty/skills/aim.rs +++ b/src/osu/difficulty/skills/aim.rs @@ -1,48 +1,74 @@ +use core::f64; + use crate::{ + GameMods, any::difficulty::{ object::{HasStartTime, IDifficultyObject}, - skills::{StrainSkill, strain_decay}, + skills_new::{ + strain_decay_base, + variable_length_strain_skill::{StrainPeak, VariableLengthStrainSkill}, + }, + }, + osu::difficulty::{ + evaluators::{AgilityEvaluator, FlowAimEvaluator, SnapAimEvaluator}, + object::OsuDifficultyObject, + }, + util::{ + difficulty::{lerp, logistic, logistic_exp, norm}, + float_ext::FloatExt, + traits::{IEnumerable, IOrderedEnumerable}, }, - osu::difficulty::{evaluators::AimEvaluator, object::OsuDifficultyObject}, - util::float_ext::FloatExt, }; -use super::strain::OsuStrainSkill; - -define_skill! { - #[derive(Clone)] - pub struct Aim: StrainSkill => [OsuDifficultyObject<'a>][OsuDifficultyObject<'a>] { - include_sliders: bool, +define_new_skill! { + pub struct Aim: VariableLengthStrainSkill => [OsuDifficultyObject<'a>][OsuDifficultyObject<'a>] { current_strain: f64 = 0.0, slider_strains: Vec = Vec::with_capacity(64), + mods: GameMods, + include_sliders: bool, + overall_difficulty: f64, + obj_radius: f64, } } impl Aim { - const SKILL_MULTIPLIER: f64 = 26.0; - const STRAIN_DECAY_BASE: f64 = 0.15; + const SKILL_MULTIPLIER_SNAP: f64 = 70.9; + const SKILL_MULTIPLIER_AGILITY: f64 = 2.35; + const SKILL_MULTIPLIER_FLOW: f64 = 242.0; + + const SKILL_MULTIPLIER_TOTAL: f64 = 1.12; + const COMBINED_SNAP_NORM_EXPONENT: f64 = 1.2; + + fn strain_decay(ms: f64) -> f64 { + strain_decay_base(ms, 0.2) + } - fn calculate_initial_strain( + fn calculate_initial_strain<'a>( &mut self, time: f64, - curr: &OsuDifficultyObject<'_>, - objects: &[OsuDifficultyObject<'_>], + curr: &OsuDifficultyObject<'a>, + objects: &[OsuDifficultyObject<'a>], ) -> f64 { let prev_start_time = curr .previous(0, objects) .map_or(0.0, HasStartTime::start_time); - self.current_strain * strain_decay(time - prev_start_time, Self::STRAIN_DECAY_BASE) + self.current_strain * Self::strain_decay(time - prev_start_time) } - fn strain_value_at( + fn strain_value_at<'a>( &mut self, - curr: &OsuDifficultyObject<'_>, - objects: &[OsuDifficultyObject<'_>], + curr: &OsuDifficultyObject<'a>, + objects: &[OsuDifficultyObject<'a>], ) -> f64 { - self.current_strain *= strain_decay(curr.delta_time, Self::STRAIN_DECAY_BASE); - self.current_strain += AimEvaluator::evaluate_diff_of(curr, objects, self.include_sliders) - * Self::SKILL_MULTIPLIER; + if self.mods.ap() { + return 0.0; + } + + let decay = Self::strain_decay(curr.adjusted_delta_time); + + self.current_strain *= decay; + self.current_strain += self.calculate_adjusted_difficulty(curr, objects) * (1.0 - decay); if curr.base.is_slider() { self.slider_strains.push(self.current_strain); @@ -51,6 +77,96 @@ impl Aim { self.current_strain } + fn calculate_adjusted_difficulty<'a>( + &self, + curr: &OsuDifficultyObject<'a>, + objects: &[OsuDifficultyObject<'a>], + ) -> f64 { + let snap_difficulty = + SnapAimEvaluator::evaluate_diff_of(curr, objects, self.include_sliders) + * Self::SKILL_MULTIPLIER_SNAP; + let agility_difficulty = + AgilityEvaluator::evaluate_diff_of(curr, objects) * Self::SKILL_MULTIPLIER_AGILITY; + let flow_difficulty = FlowAimEvaluator::evaluate_diff_of( + curr, + objects, + self.include_sliders, + self.obj_radius, + ) * Self::SKILL_MULTIPLIER_FLOW; + + let mut total_difficulty = + self.calculate_total_value(snap_difficulty, agility_difficulty, flow_difficulty); + + if let Some(attraction_strength) = self.mods.attraction_strength() { + total_difficulty *= 1.0 - attraction_strength; + } + + total_difficulty *= 0.985 + self.overall_difficulty.max(0.0).powf(2.0) / 4000.0; + + total_difficulty + } + + fn calculate_total_value( + &self, + snap_difficulty: f64, + agility_difficulty: f64, + flow_difficulty: f64, + ) -> f64 { + let mut snap_difficulty_new = snap_difficulty; + let mut flow_difficulty_new = flow_difficulty; + + // * We compare flow to combined snap and agility because snap by itself doesn't have enough difficulty to be above flow on streams + // * Agility on the other hand is supposed to measure the rate of cursor velocity changes while snapping + // * So snapping every circle on a stream requires an enormous amount of agility at which point it's easier to flow + let mut combined_snap_difficulty = norm( + Self::COMBINED_SNAP_NORM_EXPONENT, + [snap_difficulty_new, agility_difficulty], + ); + + let p_snap = + Self::calculate_snap_flow_probability(flow_difficulty / combined_snap_difficulty); + let p_flow = 1.0 - p_snap; + + if self.mods.td() { + // * we don't adjust agility here since agility represents TD difficulty in a decent enough way + snap_difficulty_new = snap_difficulty_new.powf(0.89); + combined_snap_difficulty = norm( + Self::COMBINED_SNAP_NORM_EXPONENT, + [snap_difficulty_new, agility_difficulty], + ); + } + + if self.mods.rx() { + combined_snap_difficulty *= 0.75; + flow_difficulty_new *= 0.6; + } + + let total_difficulty = combined_snap_difficulty * p_snap + flow_difficulty_new * p_flow; + + total_difficulty * Self::SKILL_MULTIPLIER_TOTAL + } + + fn calculate_snap_flow_probability(ratio: f64) -> f64 { + // * A function that turns the ratio of snap : flow into the probability of snapping/flowing + // * It has the constraints: + // * P(snap) + P(flow) = 1 (the object is always either snapped or flowed) + // * P(snap) = f(snap/flow), P(flow) = f(flow/snap) (ie snap and flow are symmetric and reversible) + // * Therefore: f(x) + f(1/x) = 1 + // * 0 <= f(x) <= 1 (cannot have negative or greater than 100% probability of snapping or flowing) + // * This logistic function is a solution, which fits nicely with the general idea of interpolation and provides a tuneable constant + const K: f64 = 7.27; + + if FloatExt::eq(ratio, 0.0) { + return 0.0; + } + + if ratio.is_nan() { + return 1.0; + } + + logistic_exp(-K * ratio.log(f64::consts::E), None) + } + pub fn get_difficult_sliders(&self) -> f64 { if self.slider_strains.is_empty() { return 0.0; @@ -65,24 +181,129 @@ impl Aim { self.slider_strains .iter() .copied() - .map(|strain| 1.0 / (1.0 + f64::exp(-(strain / max_slider_strain * 12.0 - 6.0)))) + .map(|strain| logistic(strain / max_slider_strain, 0.5, 12.0, None)) .sum() } - pub fn slider_strains(&self) -> &[f64] { - &self.slider_strains + pub fn count_top_weighted_sliders(&self, difficulty_value: f64) -> f64 { + if self.slider_strains.is_empty() { + return 0.0; + } + + // * What would the top strain be if all strain values were identical + let consistent_top_strain = difficulty_value / 10.0; + + super::count_top_weighted_sliders(&self.slider_strains, consistent_top_strain) } - // From `OsuStrainSkill`; native rather than trait function so that it has - // priority over `StrainSkill::difficulty_value` - fn difficulty_value(current_strain_peaks: Vec) -> f64 { - super::strain::difficulty_value( - current_strain_peaks, - Self::REDUCED_SECTION_COUNT, - Self::REDUCED_STRAIN_BASELINE, + pub fn difficulty_value( + current_strain_peaks: Vec, + current_section_peak: f64, + current_section_begin: f64, + current_section_end: f64, + ) -> f64 { + aim_difficulty_value( + Self::get_reduced_strain_peaks(Self::get_current_strain_peaks( + current_strain_peaks, + current_section_peak, + current_section_begin, + current_section_end, + )), + Self::MAX_SECTION_LENGTH, Self::DECAY_WEIGHT, ) } + + pub fn cloned_difficulty_value(&self) -> f64 { + Self::difficulty_value( + self.skill_strain_peaks.clone(), + self.skill_current_section_peak, + self.skill_current_section_begin, + self.skill_current_section_end, + ) + } + + fn get_reduced_strain_peaks(current_strain_peaks: Vec) -> Vec { + const REDUCED_SECTION_TIME: f64 = 4000.0; + const REDUCED_STRAIN_BASELINE: f64 = 0.727; + const CHUNK_SIZE: f64 = 20.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 strains = current_strain_peaks.cs_where(|p| p.value > 0.0); + + let mut time = 0.0; + let mut skip_count = 0; + + // * We are reducing the highest strains first to account for extreme difficulty spikes + // * Strains are split into 20ms chunks to try to mitigate inconsistencies caused by reducing strains + while strains.len() > skip_count && time < REDUCED_SECTION_TIME { + let strain = strains[skip_count]; + + let mut added_time = 0.0; + while added_time < strain.section_length { + let scale = lerp( + 1.0, + 10.0, + ((time + added_time) / REDUCED_SECTION_TIME).clamp(0.0, 1.0), + ) + .log10(); + + // * intentionally add at end and sort afterwards, should be cheaper. + strains.push(StrainPeak::new( + strain.value * lerp(REDUCED_STRAIN_BASELINE, 1.0, scale), + CHUNK_SIZE.min(strain.section_length - added_time), + )); + + added_time += CHUNK_SIZE; + } + + time += strain.section_length; + skip_count += 1; + } + + strains.split_off(skip_count).cs_order_descending() + } + + pub fn difficulty_to_performance(difficulty: f64) -> f64 { + 4.0 * difficulty.powf(3.0) + } } -impl OsuStrainSkill for Aim {} +fn aim_difficulty_value( + reduced_strain_peaks: Vec, + max_section_length: f64, + decay_weight: f64, +) -> f64 { + let mut difficulty = 0.0; + let mut time = 0.0; + + // * Difficulty is a continuous weighted sum of the sorted strains + for strain in reduced_strain_peaks { + /* Weighting function can be thought of as: + b + ∫ DecayWeight^x dx + a + where a = startTime and b = endTime + + Technically, the function below has been slightly modified from the equation above. + The real function would be + double weight = DiffUtils.Pow(DecayWeight, startTime) - DiffUtils.Pow(DecayWeight, endTime); + ... + return difficulty / Math.Log(1 / DecayWeight); + E.g. for a DecayWeight of 0.9, we're multiplying by 10 instead of 9.49122... + + This change makes it so that a map composed solely of MaxSectionLength chunks will have the exact same value when summed in this class and StrainSkill. + Doing this ensures the relationship between strain values and difficulty values remains the same between the two classes. + */ + let start_time = time; + let end_time = time + strain.section_length / max_section_length; + + let weight = decay_weight.powf(start_time) - decay_weight.powf(end_time); + + difficulty += strain.value * weight; + time = end_time; + } + + difficulty / (1.0 - decay_weight) +} diff --git a/src/osu/difficulty/skills/flashlight.rs b/src/osu/difficulty/skills/flashlight.rs index 9ca244e0..af01bdf6 100644 --- a/src/osu/difficulty/skills/flashlight.rs +++ b/src/osu/difficulty/skills/flashlight.rs @@ -2,70 +2,142 @@ use crate::{ GameMods, any::difficulty::{ object::{HasStartTime, IDifficultyObject}, - skills::strain_decay, + skills_new::{strain_decay_base, strain_skill::NewStrainSkill}, }, osu::difficulty::{evaluators::FlashlightEvaluator, object::OsuDifficultyObject}, - util::traits::IEnumerable, + util::difficulty::reverse_lerp, }; -define_skill! { - pub struct Flashlight: StrainSkill => [OsuDifficultyObject<'a>][OsuDifficultyObject<'a>] { +define_new_skill! { + pub struct Flashlight: NewStrainSkill => [OsuDifficultyObject<'a>][OsuDifficultyObject<'a>] { current_strain: f64, - has_hidden_mod: bool, + total_objects: i32, + overall_difficulty: f64, + mods: GameMods, evaluator: FlashlightEvaluator, } - pub fn new(mods: &GameMods, radius: f64, time_preempt: f64, time_fade_in: f64) -> Self { + pub fn new(mods: &GameMods, total_objects: i32, overall_difficulty: f64, radius: f64, time_preempt: f64, time_fade_in: f64) -> Self { let scaling_factor = 52.0 / radius; Self { current_strain: 0.0, - has_hidden_mod: mods.hd(), + total_objects: total_objects, + mods: mods.clone(), + overall_difficulty: overall_difficulty, evaluator: FlashlightEvaluator::new(scaling_factor, time_preempt, time_fade_in), } } } impl Flashlight { - const SKILL_MULTIPLIER: f64 = 0.05512; - const STRAIN_DECAY_BASE: f64 = 0.15; + const SKILL_MULTIPLIER: f64 = 0.058; - fn calculate_initial_strain( - &mut self, + fn strain_decay(ms: f64) -> f64 { + strain_decay_base(ms, 0.15) + } + + fn calculate_initial_strain<'a>( + &self, time: f64, - curr: &OsuDifficultyObject<'_>, - objects: &[OsuDifficultyObject<'_>], + curr: &OsuDifficultyObject<'a>, + objects: &[OsuDifficultyObject<'a>], ) -> f64 { let prev_start_time = curr .previous(0, objects) .map_or(0.0, HasStartTime::start_time); - self.current_strain * strain_decay(time - prev_start_time, Self::STRAIN_DECAY_BASE) + self.current_strain * Self::strain_decay(time - prev_start_time) } - fn strain_value_at( + fn strain_value_at<'a>( &mut self, + curr: &OsuDifficultyObject<'a>, + objects: &[OsuDifficultyObject<'a>], + ) -> f64 { + if !self.mods.fl() { + return 0.0; + } + + self.current_strain *= Self::strain_decay(curr.delta_time); + self.current_strain += + self.calculate_adjusted_difficulty(curr, objects) * Self::SKILL_MULTIPLIER; + + self.current_strain + } + + fn calculate_adjusted_difficulty( + &self, curr: &OsuDifficultyObject<'_>, objects: &[OsuDifficultyObject<'_>], ) -> f64 { - self.current_strain *= strain_decay(curr.delta_time, Self::STRAIN_DECAY_BASE); - self.current_strain += self - .evaluator - .evaluate_diff_of(curr, objects, self.has_hidden_mod) - * Self::SKILL_MULTIPLIER; + let mut difficulty = self.evaluator.evaluate_diff_of(curr, objects, &self.mods); - self.current_strain + if self.mods.td() { + difficulty = difficulty.powf(0.9); + } + + if let Some(attraction_strength) = self.mods.attraction_strength() { + difficulty *= 1.0 - attraction_strength; + } + + if let Some(start_scale) = self.mods.deflate_start_scale() { + difficulty *= reverse_lerp(start_scale, 11.0, 1.0).clamp(0.1, 1.0); + } + + if self.mods.rx() { + difficulty *= 0.7; + } + + if self.mods.ap() { + difficulty *= 0.4; + } + + difficulty *= 0.985 + self.overall_difficulty.max(0.0).powf(2.0) / 4000.0; + + difficulty + } + + // NOTE: + // Flashlight is (currently) the only skill needing to override `StrainSkill.difficulty_value(current_strain_peaks) -> f64` + // and requires `self.total_objects`. Since the static method isn't ever used as far as I can tell, it can remain default for now. + // As long as `into_difficulty_value` and `cloned_difficulty_value` are correct it should not affect elsewhere. + + #[expect(dead_code, reason = "overwrites macro impl")] + fn into_difficulty_value(self) -> f64 { + flashlight_difficulty_value( + Self::get_current_strain_peaks( + self.skill_strain_peaks, + self.skill_current_section_peak, + ), + self.total_objects, + ) } - #[expect( - clippy::needless_pass_by_value, - reason = "function definition needs to stay in-sync with `StrainSkill::difficulty_value`" - )] - fn difficulty_value(current_strain_peaks: Vec) -> f64 { - current_strain_peaks.cs_sum() + pub fn cloned_difficulty_value(&self) -> f64 { + flashlight_difficulty_value( + Self::get_current_strain_peaks( + self.skill_strain_peaks.clone(), + self.skill_current_section_peak, + ), + self.total_objects, + ) } pub fn difficulty_to_performance(difficulty: f64) -> f64 { - 25.0 * f64::powf(difficulty, 2.0) + 25.0 * difficulty.powf(2.0) } } + +fn flashlight_difficulty_value(current_strain_peaks: Vec, total_objects: i32) -> f64 { + let sum: f64 = current_strain_peaks.into_iter().sum(); + + // * Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. + sum * (0.7 + + 0.1 * (f64::from(total_objects) / 200.0).min(1.0) + + if total_objects > 200 { + 0.2 * (f64::from(total_objects - 200) / 200.0).min(1.0) + } else { + 0.0 + }) +} diff --git a/src/osu/difficulty/skills/mod.rs b/src/osu/difficulty/skills/mod.rs index c338b4d2..dfeb3242 100644 --- a/src/osu/difficulty/skills/mod.rs +++ b/src/osu/difficulty/skills/mod.rs @@ -1,6 +1,9 @@ -use crate::{any::difficulty::skills::StrainSkill, model::mods::GameMods, osu::object::OsuObject}; +use crate::any::difficulty::skills_new::skill::Skill; +use crate::util::difficulty::logistic; +use crate::util::float_ext::FloatExt; +use crate::{model::mods::GameMods, osu::object::OsuObject}; -use self::{aim::Aim, flashlight::Flashlight, speed::Speed}; +use self::{aim::Aim, flashlight::Flashlight, reading::Reading, speed::Speed}; use super::{ HD_FADE_IN_DURATION_MULTIPLIER, object::OsuDifficultyObject, scaling_factor::ScalingFactor, @@ -8,14 +11,15 @@ use super::{ pub mod aim; pub mod flashlight; +pub mod reading; pub mod speed; -pub mod strain; pub struct OsuSkills { pub aim: Aim, pub aim_no_sliders: Aim, pub speed: Speed, pub flashlight: Flashlight, + pub reading: Reading, } impl OsuSkills { @@ -24,6 +28,8 @@ impl OsuSkills { scaling_factor: &ScalingFactor, great_hit_window: f64, time_preempt: f64, + clock_rate: f64, + total_objects: usize, ) -> Self { let hit_window = 2.0 * great_hit_window; @@ -40,17 +46,44 @@ impl OsuSkills { } else { 400.0 * (time_preempt / OsuObject::PREEMPT_MIN).min(1.0) }; + let overall_difficulty = (79.5 - hit_window / 2.0) / 6.0; + let preempt = time_preempt / clock_rate; - let aim = Aim::new(true); - let aim_no_sliders = Aim::new(false); - let speed = Speed::new(hit_window, mods.ap()); - let flashlight = Flashlight::new(mods, scaling_factor.radius, time_preempt, time_fade_in); + let aim = Aim::new( + mods.clone(), + true, + overall_difficulty, + scaling_factor.radius, + ); + let aim_no_sliders = Aim::new( + mods.clone(), + false, + overall_difficulty, + scaling_factor.radius, + ); + let speed = Speed::new(mods.clone(), hit_window); + let flashlight = Flashlight::new( + mods, + total_objects as i32, + overall_difficulty, + scaling_factor.radius, + time_preempt, + time_fade_in, + ); + let reading = Reading::new( + mods, + preempt, + time_preempt, + time_fade_in, + overall_difficulty, + ); Self { aim, aim_no_sliders, speed, flashlight, + reading, } } @@ -59,5 +92,17 @@ impl OsuSkills { self.aim_no_sliders.process(curr, objects); self.speed.process(curr, objects); self.flashlight.process(curr, objects); + self.reading.process(curr, objects); } } + +fn count_top_weighted_sliders(slider_strains: &[f64], consistent_top_strain: f64) -> f64 { + if FloatExt::eq(consistent_top_strain, 0.0) { + return 0.0; + } + + slider_strains + .iter() + .map(|s| logistic(*s / consistent_top_strain, 0.88, 10.0, Some(1.1))) + .sum() +} diff --git a/src/osu/difficulty/skills/reading.rs b/src/osu/difficulty/skills/reading.rs new file mode 100644 index 00000000..84190175 --- /dev/null +++ b/src/osu/difficulty/skills/reading.rs @@ -0,0 +1,127 @@ +use crate::{ + GameMods, + any::difficulty::skills_new::{count_top_weighted_object_difficulties, strain_decay_base}, + osu::difficulty::{evaluators::ReadingEvaluator, object::OsuDifficultyObject}, + util::difficulty::lerp, +}; + +define_new_skill! { + pub struct Reading: HarmonicSkill => [OsuDifficultyObject<'a>][OsuDifficultyObject<'a>] { + current_strain: f64 = 0.0, + reduced_note_count: usize = 0, + reduced_duration: Option = None, + mods: GameMods, + evaluator: ReadingEvaluator, + overall_difficulty: f64, + } + + pub fn new(mods: &GameMods, preempt: f64, time_preempt: f64, time_fade_in: f64, overall_difficulty: f64) -> Self { + Self { + current_strain: 0.0, + reduced_note_count: 0, + reduced_duration: None, + mods: mods.clone(), + evaluator: ReadingEvaluator::new(preempt, time_preempt, time_fade_in), + overall_difficulty: overall_difficulty, + } + } +} + +impl Reading { + pub const SKILL_MULTIPLIER: f64 = 2.5; + pub const REDUCED_DIFFICULTY_DURATION: i32 = 60 * 1000; + pub const REDUCED_DIFFICULTY_BASE_LINE: f64 = 0.0; + + fn strain_decay(ms: f64) -> f64 { + strain_decay_base(ms, 0.8) + } + + fn object_difficulty_of<'a>( + &mut self, + curr: &'a OsuDifficultyObject<'a>, + objects: &'a [OsuDifficultyObject<'a>], + ) -> f64 { + let decay = Self::strain_decay(curr.delta_time); + + self.current_strain *= decay; + self.current_strain += self.calculate_adjusted_difficulty(curr, objects) + * (1.0 - decay) + * Self::SKILL_MULTIPLIER; + + // * This currently operates under the assumption that `ObjectDifficultyOf` is called once per object, and in order. + // * Under that assumption, we can trust that `current.StartTime` refers to the start time of the first object in the case that `reducedDuration` is yet to be set. + let reduced_duration = self + .reduced_duration + .get_or_insert(curr.start_time + f64::from(Self::REDUCED_DIFFICULTY_DURATION)); + + // * This relies on the same assumption, as calling in order means that we can safely increase the note count until we reach the first object after the reduced duration. + if curr.start_time <= *reduced_duration { + self.reduced_note_count += 1; + } + + self.current_strain + } + + fn calculate_adjusted_difficulty<'a>( + &mut self, + curr: &'a OsuDifficultyObject<'a>, + objects: &'a [OsuDifficultyObject<'a>], + ) -> f64 { + let mut difficulty = self + .evaluator + .evaluate_diff_of(curr, objects, self.mods.hd_full_fade()); + + if self.mods.td() { + difficulty = difficulty.powf(0.89); + } + + if let Some(magnetised_strength) = self.mods.attraction_strength() { + difficulty *= 1.0 - magnetised_strength; + } + + if self.mods.rx() { + difficulty *= 0.4; + } + + if self.mods.ap() { + difficulty *= 0.1; + } + + difficulty *= 0.825 + self.overall_difficulty.max(0.0).powf(2.2) / 1125.0; + + difficulty + } + + fn get_transformed_difficulties(&self, mut difficulties: Vec) -> Vec { + difficulties.retain(|v| *v > 0.0); + + let count = std::cmp::min(difficulties.len(), self.reduced_note_count); + for (i, difficulty) in difficulties.iter_mut().take(count).enumerate() { + let scale = lerp( + 1.0, + 10.0, + (i as f64 / self.reduced_note_count as f64).clamp(0.0, 1.0), + ) + .log10(); + + *difficulty *= lerp(Self::REDUCED_DIFFICULTY_BASE_LINE, 1.0, scale); + } + + difficulties + } + + pub fn count_top_weighted_object_difficulties( + &self, + difficulty_value: f64, + object_weight_sum: f64, + ) -> f64 { + count_top_weighted_object_difficulties( + difficulty_value, + &self.skill_object_difficulties, + object_weight_sum, + 1.15, + 5.0, + Some(1.1), + ) + } +} diff --git a/src/osu/difficulty/skills/speed.rs b/src/osu/difficulty/skills/speed.rs index 43655222..fb59516a 100644 --- a/src/osu/difficulty/skills/speed.rs +++ b/src/osu/difficulty/skills/speed.rs @@ -1,61 +1,50 @@ use crate::{ - any::difficulty::{ - object::{HasStartTime, IDifficultyObject}, - skills::{StrainSkill, strain_decay}, - }, + GameMods, + any::difficulty::skills_new::strain_decay_base, osu::difficulty::{ evaluators::{RhythmEvaluator, SpeedEvaluator}, object::OsuDifficultyObject, }, + util::{difficulty::logistic, float_ext::FloatExt}, }; -use super::strain::OsuStrainSkill; - -define_skill! { +define_new_skill! { #[derive(Clone)] - pub struct Speed: StrainSkill => [OsuDifficultyObject<'a>][OsuDifficultyObject<'a>] { + pub struct Speed: HarmonicSkill => [OsuDifficultyObject<'a>][OsuDifficultyObject<'a>] { + slider_strains: Vec = Vec::with_capacity(64), current_strain: f64 = 0.0, - current_rhythm: f64 = 0.0, + mods: GameMods, hit_window: f64, - has_autopilot_mod: bool, - slider_strains: Vec = Vec::with_capacity(64), } } impl Speed { - const SKILL_MULTIPLIER: f64 = 1.47; - const STRAIN_DECAY_BASE: f64 = 0.3; - const REDUCED_SECTION_COUNT: usize = 5; - - fn calculate_initial_strain( - &mut self, - time: f64, - curr: &OsuDifficultyObject<'_>, - objects: &[OsuDifficultyObject<'_>], - ) -> f64 { - let prev_start_time = curr - .previous(0, objects) - .map_or(0.0, HasStartTime::start_time); + const SKILL_MULTIPLIER: f64 = 1.16; + const HARMONIC_SCALE: f64 = 20.0; - (self.current_strain * self.current_rhythm) - * strain_decay(time - prev_start_time, Self::STRAIN_DECAY_BASE) + fn strain_decay(ms: f64) -> f64 { + strain_decay_base(ms, 0.3) } - fn strain_value_at( + fn object_difficulty_of<'a>( &mut self, - curr: &OsuDifficultyObject<'_>, - objects: &[OsuDifficultyObject<'_>], + curr: &'a OsuDifficultyObject<'a>, + objects: &'a [OsuDifficultyObject<'a>], ) -> f64 { - self.current_strain *= strain_decay(curr.adjusted_delta_time, Self::STRAIN_DECAY_BASE); - self.current_strain += SpeedEvaluator::evaluate_diff_of( - curr, - objects, - self.hit_window, - self.has_autopilot_mod, - ) * Self::SKILL_MULTIPLIER; - self.current_rhythm = RhythmEvaluator::evaluate_diff_of(curr, objects, self.hit_window); + if self.mods.rx() { + return 0.0; + } + + let decay = Self::strain_decay(curr.adjusted_delta_time); + + self.current_strain *= decay; + self.current_strain += self.calculate_adjusted_difficulty(curr, objects) + * (1.0 - decay) + * Self::SKILL_MULTIPLIER; + + let curr_rhythm = RhythmEvaluator::evaluate_diff_of(curr, objects, self.hit_window); - let total_strain = self.current_strain * self.current_rhythm; + let total_strain = self.current_strain * curr_rhythm; if curr.base.is_slider() { self.slider_strains.push(total_strain); @@ -64,35 +53,49 @@ impl Speed { total_strain } - pub fn relevant_note_count(&self) -> f64 { - self.strain_skill_object_strains + fn calculate_adjusted_difficulty<'a>( + &mut self, + curr: &'a OsuDifficultyObject<'a>, + objects: &'a [OsuDifficultyObject<'a>], + ) -> f64 { + let mut difficulty = SpeedEvaluator::evaluate_diff_of(curr, objects, self.hit_window); + + if self.mods.ap() { + difficulty *= 0.5; + } + + difficulty + } + + pub fn relevant_object_count(&self) -> f64 { + if self.skill_object_difficulties.is_empty() { + return 0.0; + } + + let max_strain = self + .skill_object_difficulties .iter() .copied() - .max_by(f64::total_cmp) - .filter(|&n| n > 0.0) - .map_or(0.0, |max_strain| { - self.strain_skill_object_strains - .iter() - .fold(0.0, |sum, strain| { - sum + (1.0 + f64::exp(-(strain / max_strain * 12.0 - 6.0))).recip() - }) - }) - } + .fold(0.0, f64::max); - pub fn slider_strains(&self) -> &[f64] { - &self.slider_strains + if FloatExt::eq(max_strain, 0.0) { + return 0.0; + } + + self.skill_object_difficulties + .iter() + .map(|s| logistic(s / max_strain, 0.5, 12.0, None)) + .sum() } - // From `OsuStrainSkill`; native rather than trait function so that it has - // priority over `StrainSkill::difficulty_value` - fn difficulty_value(current_strain_peaks: Vec) -> f64 { - super::strain::difficulty_value( - current_strain_peaks, - Self::REDUCED_SECTION_COUNT, - Self::REDUCED_STRAIN_BASELINE, - Self::DECAY_WEIGHT, - ) + pub fn count_top_weighted_sliders(&self, difficulty_value: f64, object_weight_sum: f64) -> f64 { + if self.slider_strains.is_empty() || FloatExt::eq(object_weight_sum, 0.0) { + return 0.0; + } + + // * What would the top note be if all note values were identical + let consistent_top_object = difficulty_value / object_weight_sum; + + super::count_top_weighted_sliders(&self.slider_strains, consistent_top_object) } } - -impl OsuStrainSkill for Speed {} diff --git a/src/osu/difficulty/skills/strain.rs b/src/osu/difficulty/skills/strain.rs deleted file mode 100644 index 265944d5..00000000 --- a/src/osu/difficulty/skills/strain.rs +++ /dev/null @@ -1,69 +0,0 @@ -use crate::util::{ - difficulty::logistic, - float_ext::FloatExt, - traits::{IEnumerable, IOrderedEnumerable}, -}; - -pub trait OsuStrainSkill { - const REDUCED_SECTION_COUNT: usize = 10; - const REDUCED_STRAIN_BASELINE: f64 = 0.75; - - fn difficulty_to_performance(difficulty: f64) -> f64 { - difficulty_to_performance(difficulty) - } -} - -pub fn difficulty_value( - current_strain_peaks: Vec, - reduced_section_count: usize, - reduced_strain_baseline: f64, - decay_weight: f64, -) -> 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 peaks = current_strain_peaks.cs_where(|&p| p > 0.0); - - let mut strains = peaks.cs_order_descending(); - - for (i, strain) in strains.iter_mut().take(reduced_section_count).enumerate() { - let clamped = f64::from((i as f32 / reduced_section_count as f32).clamp(0.0, 1.0)); - let scale = f64::log10(lerp(1.0, 10.0, clamped)); - *strain *= lerp(reduced_strain_baseline, 1.0, scale); - } - - for strain in strains.cs_order_descending() { - difficulty += strain * weight; - weight *= decay_weight; - } - - difficulty -} - -pub fn count_top_weighted_sliders(slider_strains: &[f64], difficulty_value: f64) -> f64 { - if slider_strains.is_empty() { - return 0.0; - } - - // * What would the top strain be if all strain values were identical - let consistent_top_strain = difficulty_value / 10.0; - - if FloatExt::eq(consistent_top_strain, 0.0) { - return 0.0; - } - - slider_strains - .iter() - .map(|s| logistic(*s / consistent_top_strain, 0.88, 10.0, Some(1.1))) - .sum() -} - -pub fn difficulty_to_performance(difficulty: f64) -> f64 { - f64::powf(5.0 * f64::max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100_000.0 -} - -const fn lerp(start: f64, end: f64, amount: f64) -> f64 { - start + (end - start) * amount -} diff --git a/src/osu/legacy_score_miss_calc.rs b/src/osu/legacy_score_miss_calc.rs index ae681791..22e3dbc9 100644 --- a/src/osu/legacy_score_miss_calc.rs +++ b/src/osu/legacy_score_miss_calc.rs @@ -137,9 +137,17 @@ impl<'a> OsuLegacyScoreMissCalculator<'a> { let mut miss_count = 0.0; + // * If sliders in the map are hard - it's likely for player to drop sliderends + // * If map has easy sliders - it's more likely for player to sliderbreak + let likely_missed_sliderend_portion = + 0.04 + 0.06 * self.attrs.aim_top_weighted_slider_factor.min(1.0).powf(2.0); + // * Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it - // * In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map - let full_combo_threshold = f64::from(attrs.max_combo) - 0.1 * f64::from(attrs.n_sliders); + // * In classic scores we can't know the amount of dropped sliders so we estimate it + let full_combo_threshold = f64::from(attrs.max_combo) + - (4.0 + + likely_missed_sliderend_portion + * f64::from(attrs.n_sliders).min(f64::from(attrs.n_sliders))); if f64::from(state.max_combo) < full_combo_threshold { miss_count = (full_combo_threshold / f64::from(state.max_combo).max(1.0)).powf(2.5); diff --git a/src/osu/object.rs b/src/osu/object.rs index 0ed98ee8..ff8e4243 100644 --- a/src/osu/object.rs +++ b/src/osu/object.rs @@ -95,7 +95,7 @@ impl OsuObject { } } - pub fn end_time(&self) -> f64 { + pub const fn end_time(&self) -> f64 { match self.kind { OsuObjectKind::Circle => self.start_time, OsuObjectKind::Slider(ref slider) => slider.end_time, @@ -103,6 +103,10 @@ impl OsuObject { } } + pub const fn duration(&self) -> f64 { + self.end_time() - self.start_time + } + pub const fn stacked_pos(&self) -> Pos { // Performed manually for const-ness // self.pos + self.stack_offset diff --git a/src/osu/performance/calculator.rs b/src/osu/performance/calculator.rs index e0d05946..2819a9ed 100644 --- a/src/osu/performance/calculator.rs +++ b/src/osu/performance/calculator.rs @@ -2,22 +2,24 @@ use std::{cmp, f64::consts::PI}; use crate::{ GameMods, + any::difficulty::skills_new::harmonic_skill::HarmonicSkill, osu::{ OsuDifficultyAttributes, OsuPerformanceAttributes, OsuScoreState, difficulty::{ - rating::OsuRatingCalculator, - skills::{aim::Aim, flashlight::Flashlight, speed::Speed, strain::OsuStrainSkill}, + skills::{aim::Aim, flashlight::Flashlight, reading::Reading, speed::Speed}, + sum_cognition_difficulty, }, legacy_score_miss_calc::OsuLegacyScoreMissCalculator, }, util::{ - difficulty::{erf, erf_inv, logistic, reverse_lerp, smoothstep}, + difficulty::{erf, erf_inv, logistic, norm, reverse_lerp, smoothstep}, float_ext::FloatExt, }, }; // * This is being adjusted to keep the final pp value scaled around what it used to be when changing things. -pub const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14; +pub const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.12; +pub const PERFORMANCE_NORM_EXPONENT: f64 = 1.1; pub(super) struct OsuPerformanceCalculator<'mods> { attrs: OsuDifficultyAttributes, @@ -65,19 +67,34 @@ impl OsuPerformanceCalculator<'_> { let combo_based_estimated_miss_count = self.calculate_combo_based_estimated_miss_count(); let mut score_based_estimated_miss_count = None; - let mut effective_miss_count = if using_classic_slider_acc - && state.legacy_total_score.is_some() - { - let legacy_score_miss_calc = OsuLegacyScoreMissCalculator::new(state, acc, mods, attrs); + let mut effective_miss_count = + if using_classic_slider_acc && !self.mods.sv2() && state.legacy_total_score.is_some() { + let legacy_score_miss_calc = + OsuLegacyScoreMissCalculator::new(state, acc, mods, attrs); - *score_based_estimated_miss_count.insert(legacy_score_miss_calc.calculate()) - } else { - // * Use combo-based miss count if this isn't a legacy score - combo_based_estimated_miss_count - }; + *score_based_estimated_miss_count.insert(legacy_score_miss_calc.calculate()) + } else { + // * Use combo-based miss count if this isn't a legacy score + combo_based_estimated_miss_count + }; effective_miss_count = effective_miss_count.max(f64::from(state.hitresults.misses)); effective_miss_count = effective_miss_count.min(f64::from(state.hitresults.total_hits())); + effective_miss_count = effective_miss_count.max(0.0); + + let mut aim_estimated_slider_breaks = 0.0; + let mut speed_estimated_slider_breaks = 0.0; + + if effective_miss_count > 0.0 { + aim_estimated_slider_breaks = self.calculate_estimated_slider_breaks( + self.attrs.aim_top_weighted_slider_factor, + effective_miss_count, + ); + speed_estimated_slider_breaks = self.calculate_estimated_slider_breaks( + self.attrs.speed_top_weighted_slider_factor, + effective_miss_count, + ); + } let total_hits = f64::from(total_hits); @@ -116,9 +133,6 @@ impl OsuPerformanceCalculator<'_> { let speed_deviation = self.calculate_speed_deviation(); - let mut aim_estimated_slider_breaks = 0.0; - let mut speed_estimated_slider_breaks = 0.0; - let aim_value = self.compute_aim_value(effective_miss_count, &mut aim_estimated_slider_breaks); let speed_value = self.compute_speed_value( @@ -127,22 +141,24 @@ impl OsuPerformanceCalculator<'_> { &mut speed_estimated_slider_breaks, ); let acc_value = self.compute_accuracy_value(); + + let reading_value = self.compute_reading_value(effective_miss_count); let flashlight_value = self.compute_flashlight_value(effective_miss_count); + let cognition_value = sum_cognition_difficulty(reading_value, 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; + let pp = norm( + PERFORMANCE_NORM_EXPONENT, + [aim_value, speed_value, acc_value, cognition_value], + ) * multiplier; OsuPerformanceAttributes { difficulty: self.attrs, + pp, pp_acc: acc_value, pp_aim: aim_value, pp_flashlight: flashlight_value, pp_speed: speed_value, - pp, + pp_reading: reading_value, effective_miss_count, speed_deviation, combo_based_estimated_miss_count, @@ -203,17 +219,12 @@ impl OsuPerformanceCalculator<'_> { let total_hits = self.total_hits(); let len_bonus = 0.95 - + 0.4 * (total_hits / 2000.0).min(1.0) + + 0.35 * (total_hits / 2000.0).min(1.0) + f64::from(u8::from(total_hits > 2000.0)) * (total_hits / 2000.0).log10() * 0.5; aim_value *= len_bonus; if effective_miss_count > 0.0 { - *aim_estimated_slider_breaks = self.calculate_estimated_slider_breaks( - self.attrs.aim_top_weighted_slider_factor, - effective_miss_count, - ); - let relevant_miss_count = (effective_miss_count + *aim_estimated_slider_breaks) .min(self.total_imperfect_hits() + f64::from(self.n_large_tick_miss())); @@ -231,13 +242,7 @@ impl OsuPerformanceCalculator<'_> { * self.acc.powf(16.0)) * (1.0 - 0.003 * self.attrs.hp * self.attrs.hp); } else if self.mods.tc() { - aim_value *= 1.0 - + OsuRatingCalculator::calculate_visibility_bonus( - self.mods, - self.attrs.ar, - Some(self.attrs.slider_factor), - None, - ); + aim_value *= 1.0 + self.calculate_traceable_bonus(self.attrs.slider_factor); } aim_value *= self.acc; @@ -257,20 +262,7 @@ impl OsuPerformanceCalculator<'_> { let mut speed_value = Speed::difficulty_to_performance(self.attrs.speed); - let total_hits = self.total_hits(); - - let len_bonus = 0.95 - + 0.4 * (total_hits / 2000.0).min(1.0) - + f64::from(u8::from(total_hits > 2000.0)) * (total_hits / 2000.0).log10() * 0.5; - - speed_value *= len_bonus; - if effective_miss_count > 0.0 { - *speed_estimated_slider_breaks = self.calculate_estimated_slider_breaks( - self.attrs.speed_top_weighted_slider_factor, - effective_miss_count, - ); - let relevant_miss_count = (effective_miss_count + *speed_estimated_slider_breaks) .min(self.total_imperfect_hits() + f64::from(self.n_large_tick_miss())); @@ -280,46 +272,24 @@ impl OsuPerformanceCalculator<'_> { ); } - // * TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. if self.mods.bl() { // * Increasing the speed value by object count for Blinds isn't // * ideal, so the minimum buff is given. speed_value *= 1.12; - } else if self.mods.tc() { - speed_value *= 1.0 - + OsuRatingCalculator::calculate_visibility_bonus( - self.mods, - self.attrs.ar, - None, - None, - ); } let speed_high_deviation_mult = self.calculate_speed_high_deviation_nerf(speed_deviation); speed_value *= speed_high_deviation_mult; - // * Calculate accuracy assuming the worst case scenario - let relevant_total_diff = f64::max(0.0, total_hits - self.attrs.speed_note_count); - let hitresults = &self.state.hitresults; - let relevant_n300 = (f64::from(hitresults.n300) - relevant_total_diff).max(0.0); - let relevant_n100 = (f64::from(hitresults.n100) - - (relevant_total_diff - f64::from(hitresults.n300)).max(0.0)) - .max(0.0); - let relevant_n50 = (f64::from(hitresults.n50) - - (relevant_total_diff - f64::from(hitresults.n300 + hitresults.n100)).max(0.0)) - .max(0.0); - - let relevant_acc = if self.attrs.speed_note_count.eq(0.0) { - 0.0 - } else { - (relevant_n300 * 6.0 + relevant_n100 * 2.0 + relevant_n50) - / (self.attrs.speed_note_count * 6.0) - }; + // * An effective hit window is created based on the speed SR. The higher the speed difficulty, the shorter the hit window. + // * For example, a speed SR of 4.0 leads to an effective hit window of 20ms, which is OD 10. + let effective_hit_window = 20.0 * (4.0 / self.attrs.speed).powf(0.35); - let od = self.attrs.od(); + // * Find the proportion of 300s on speed notes assuming the hit window was the effective hit window. + let effective_acc = erf(effective_hit_window / speed_deviation); - // * Scale the speed value with accuracy and OD. - speed_value *= f64::powf((self.acc + relevant_acc) / 2.0, (14.5 - od) / 2.0); + // * Scale speed value by normalized accuracy. + speed_value *= effective_acc.powf(2.0); speed_value } @@ -365,23 +335,21 @@ impl OsuPerformanceCalculator<'_> { 1.52163_f64.powf(self.attrs.od()) * better_acc_percentage.powf(24.0) * 2.83; // * Bonus for many hitcircles - it's harder to keep good accuracy up for longer. - acc_value *= (f64::from(amount_hit_objects_with_acc) / 1000.0) - .powf(0.3) - .min(1.15); + acc_value *= if amount_hit_objects_with_acc < 1000 { + (f64::from(amount_hit_objects_with_acc) / 1000.0).powf(0.3) + } else { + (f64::from(amount_hit_objects_with_acc) / 1000.0).powf(0.1) + }; // * Increasing the accuracy value by object count for Blinds isn't // * ideal, so the minimum buff is given. if self.mods.bl() { acc_value *= 1.14; - } else if self.mods.hd() || self.mods.tc() { + } else if self.mods.tc() { // * Decrease bonus for AR > 10 acc_value *= 1.0 + 0.08 * reverse_lerp(self.attrs.ar, 11.5, 10.0); } - if self.mods.fl() { - acc_value *= 1.02; - } - acc_value } @@ -409,6 +377,22 @@ impl OsuPerformanceCalculator<'_> { flashlight_value } + fn compute_reading_value(&self, effective_miss_count: f64) -> f64 { + let mut reading_value = Reading::difficulty_to_performance(self.attrs.reading); + + if effective_miss_count > 0.0 { + reading_value *= Self::calculate_miss_penalty( + effective_miss_count, + self.attrs.reading_difficult_note_count, + ); + } + + // * Scale the reading value with accuracy _harshly_. + reading_value *= self.acc.powf(3.0); + + reading_value + } + fn calculate_combo_based_estimated_miss_count(&self) -> f64 { let Self { state, @@ -424,10 +408,16 @@ impl OsuPerformanceCalculator<'_> { let mut miss_count = f64::from(state.hitresults.misses); if *using_classic_slider_acc { + // * If sliders in the map are hard - it's likely for player to drop sliderends + // * If map has easy sliders - it's more likely for player to sliderbreak + let likely_missed_sliderend_portion = + 0.04 + 0.06 * attrs.aim_top_weighted_slider_factor.min(1.0).powf(2.0); + // * Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it - // * In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map - let full_combo_threshold = - f64::from(attrs.max_combo) - 0.1 * f64::from(attrs.n_sliders); + // * In classic scores we can't know the amount of dropped sliders so we estimate it + let full_combo_threshold = f64::from(attrs.max_combo) + - (4.0 + likely_missed_sliderend_portion * f64::from(attrs.n_sliders)) + .min(f64::from(attrs.n_sliders)); if f64::from(state.max_combo) < full_combo_threshold { miss_count = full_combo_threshold / f64::from(state.max_combo).max(1.0); @@ -478,22 +468,28 @@ impl OsuPerformanceCalculator<'_> { .. } = self; - if !using_classic_slider_acc || state.hitresults.n100 == 0 { + let non_miss_mistakes = state.hitresults.n100 + state.hitresults.n50; + + if !using_classic_slider_acc || non_miss_mistakes == 0 { return 0.0; } let missed_combo_percent = 1.0 - f64::from(state.max_combo) / f64::from(attrs.max_combo); - let mut estimated_slider_breaks = (effective_miss_count * top_weighted_slider_factor) - .min(f64::from(state.hitresults.n100)); + let mut estimated_slider_breaks = + f64::from(non_miss_mistakes).min(effective_miss_count * top_weighted_slider_factor); - // * Scores with more Oks are more likely to have slider breaks. - let ok_adjustment = ((f64::from(state.hitresults.n100) - estimated_slider_breaks) + 0.5) - / f64::from(state.hitresults.n100); + // * Scores with more Oks and Mehs are more likely to have slider breaks. + // * We add an arbitrary value to both sides of the division to make it more stable on extreme ends. + let non_miss_mistake_adjustment = (f64::from(non_miss_mistakes) - estimated_slider_breaks + + 4.5) + / f64::from(non_miss_mistakes + 4); // * There is a low probability of extra slider breaks on effective miss counts close to 1, as score based calculations are good at indicating if only a single break occurred. estimated_slider_breaks *= smoothstep(effective_miss_count, 1.0, 2.0); - estimated_slider_breaks * ok_adjustment * logistic(missed_combo_percent, 0.33, 15.0, None) + estimated_slider_breaks + * non_miss_mistake_adjustment + * logistic(missed_combo_percent, 0.33, 15.0, None) } fn calculate_speed_deviation(&self) -> Option { @@ -615,11 +611,36 @@ impl OsuPerformanceCalculator<'_> { adjusted_speed_value / speed_value } + fn calculate_traceable_bonus(&self, slider_factor: f64) -> f64 { + // * We want to reward slider aim less, more so at lower AR + let high_ar_slider_visibility_factor = 0.5 + (slider_factor.powf(6.0) / 2.0); + let low_ar_slider_visibility_factor = slider_factor.powf(6.0); + + // * Start from normal curve, rewarding lower AR up to AR7 + let mut traceable_bonus = 0.0275; + traceable_bonus += + 0.025 * (12.0 - self.attrs.ar.max(7.0)) * high_ar_slider_visibility_factor; + + // * For AR up to 0 - reduce reward for very low ARs when object is visible + if self.attrs.ar < 7.0 { + traceable_bonus += + 0.025 * (7.0 - self.attrs.ar.max(0.0)) * low_ar_slider_visibility_factor; + } + + // * Starting from AR0 - cap values so they won't grow to infinity + if self.attrs.ar < 0.0 { + traceable_bonus += + 0.025 * (1.0 - f64::powf(1.5, self.attrs.ar)) * low_ar_slider_visibility_factor; + } + + traceable_bonus + } + // * Miss penalty assumes that a player will miss on the hardest parts of a map, // * so we use the amount of relatively difficult sections to adjust miss penalty // * to make it more punishing on maps with lower amount of hard sections. fn calculate_miss_penalty(miss_count: f64, diff_strain_count: f64) -> f64 { - 0.96 / ((miss_count / (4.0 * diff_strain_count.ln().powf(0.94))) + 1.0) + 0.93 / (miss_count / (4.0 * diff_strain_count.max(1.0).ln()) + 1.0) } fn get_combo_scaling_factor(&self) -> f64 { diff --git a/src/osu/performance/gradual.rs b/src/osu/performance/gradual.rs index 5e990d7a..31f2581f 100644 --- a/src/osu/performance/gradual.rs +++ b/src/osu/performance/gradual.rs @@ -164,8 +164,12 @@ mod tests { let Some(next_gradual) = gradual.next(state.clone()) else { assert_eq!(i, hit_objects_len + 1); - assert!(gradual_2nd.last(state.clone()).is_some() || hit_objects_len % 2 == 0); - assert!(gradual_3rd.last(state.clone()).is_some() || hit_objects_len % 3 == 0); + assert!( + gradual_2nd.last(state.clone()).is_some() || hit_objects_len.is_multiple_of(2) + ); + assert!( + gradual_3rd.last(state.clone()).is_some() || hit_objects_len.is_multiple_of(3) + ); break; }; diff --git a/src/osu/performance/hitresult_generator/closest.rs b/src/osu/performance/hitresult_generator/closest.rs index 624229b0..ece534d3 100644 --- a/src/osu/performance/hitresult_generator/closest.rs +++ b/src/osu/performance/hitresult_generator/closest.rs @@ -284,22 +284,22 @@ mod tests { let n50 = remain - n300 - n100; // Skip if any provided constraints are violated - if let Some(expected_n300) = inspect.n300 { - if n300 != expected_n300 { - continue; - } + if let Some(expected_n300) = inspect.n300 + && n300 != expected_n300 + { + continue; } - if let Some(expected_n100) = inspect.n100 { - if n100 != expected_n100 { - continue; - } + if let Some(expected_n100) = inspect.n100 + && n100 != expected_n100 + { + continue; } - if let Some(expected_n50) = inspect.n50 { - if n50 != expected_n50 { - continue; - } + if let Some(expected_n50) = inspect.n50 + && n50 != expected_n50 + { + continue; } let candidate = OsuHitResults { diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index 6888c652..3b750f1a 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -4,7 +4,10 @@ use rosu_map::section::general::GameMode; use self::calculator::OsuPerformanceCalculator; -pub use self::{calculator::PERFORMANCE_BASE_MULTIPLIER, inspect::InspectOsuPerformance}; +pub use self::{ + calculator::{PERFORMANCE_BASE_MULTIPLIER, PERFORMANCE_NORM_EXPONENT}, + inspect::InspectOsuPerformance, +}; use crate::{ Beatmap, diff --git a/src/osu/strains.rs b/src/osu/strains.rs index 6cfbf24b..d743d122 100644 --- a/src/osu/strains.rs +++ b/src/osu/strains.rs @@ -1,5 +1,10 @@ use crate::{ - Beatmap, Difficulty, any::difficulty::skills::StrainSkill, model::mode::ConvertError, + Beatmap, Difficulty, + any::difficulty::skills_new::{ + harmonic_skill::HarmonicSkill, strain_skill::NewStrainSkill, + variable_length_strain_skill::VariableLengthStrainSkill, + }, + model::mode::ConvertError, osu::convert::prepare_map, }; @@ -18,6 +23,8 @@ pub struct OsuStrains { pub speed: Vec, /// Strain peaks of the flashlight skill. pub flashlight: Vec, + /// Strain peaks of the reading skill. + pub reading: Vec, } impl OsuStrains { @@ -36,14 +43,24 @@ pub fn strains(difficulty: &Difficulty, map: &Beatmap) -> Result>(), + aim_no_sliders: aim_no_sliders + .into_current_strain_peaks() + .iter() + .map(|sp| sp.value) + .collect::>(), + speed: speed.into_transformed_difficulties(), flashlight: flashlight.into_current_strain_peaks(), + reading: reading.into_transformed_difficulties(), }) } diff --git a/src/osu/utils/legacy_score.rs b/src/osu/utils/legacy_score.rs index c98c42ba..94e4bbce 100644 --- a/src/osu/utils/legacy_score.rs +++ b/src/osu/utils/legacy_score.rs @@ -61,8 +61,7 @@ fn calculate_spinner_score(spinner: Spinner) -> f64 { #[derive(Default)] struct InnerNestedScorePerObject { - n_sliders: usize, - n_repeats: usize, + amount_of_big_ticks: usize, amount_of_small_ticks: usize, spinner_score: f64, object_count: usize, @@ -75,8 +74,8 @@ impl InnerNestedScorePerObject { match h.kind { OsuObjectKind::Circle => {} OsuObjectKind::Slider(ref slider) => { - self.n_sliders += 1; - self.n_repeats += slider.repeat_count(); + // * 1 for head, 1 for tail, plus repeats + self.amount_of_big_ticks += 2 + slider.repeat_count(); self.amount_of_small_ticks += slider.tick_count(); } OsuObjectKind::Spinner(spinner) => { @@ -89,13 +88,7 @@ impl InnerNestedScorePerObject { const BIG_TICK_SCORE: f64 = 30.0; const SMALL_TICK_SCORE: f64 = 10.0; - // * 1 for head, 1 for tail - let mut amount_of_big_ticks = self.n_sliders * 2; - - // * Add slider repeats - amount_of_big_ticks += self.n_repeats; - - let slider_score = amount_of_big_ticks as f64 * BIG_TICK_SCORE + let slider_score = self.amount_of_big_ticks as f64 * BIG_TICK_SCORE + self.amount_of_small_ticks as f64 * SMALL_TICK_SCORE; (slider_score + self.spinner_score) / self.object_count as f64 diff --git a/src/util/difficulty.rs b/src/util/difficulty.rs index bba7abab..07f1347d 100644 --- a/src/util/difficulty.rs +++ b/src/util/difficulty.rs @@ -40,17 +40,10 @@ pub fn bell_curve(x: f64, mean: f64, width: f64, multiplier: Option) -> f64 multiplier.unwrap_or(1.0) * f64::exp(E * -(f64::powf(x - mean, 2.0) / f64::powf(width, 2.0))) } -pub fn smoothstep_bell_curve(x: f64, mean: f64, width: f64) -> f64 { - let mut new_x = x; - - new_x -= mean; - new_x = if new_x > 0.0 { - width - new_x - } else { - width + new_x - }; - - smoothstep(new_x, 0.0, width) +pub const fn smoothstep_bell_curve(x: f64) -> f64 { + let mut new_x = 0.5 - (x - 0.5).abs(); + new_x = (new_x * 2.0).clamp(0.0, 1.0); + new_x * new_x * (3.0 - 2.0 * new_x) } pub const fn smoothstep(x: f64, start: f64, end: f64) -> f64 { @@ -65,6 +58,11 @@ pub const fn smootherstep(x: f64, start: f64, end: f64) -> f64 { x * x * x * (x * (6.0 * x - 15.0) + 10.0) } +// osu.Framework.Utils.Interpolation.Lerp +pub const fn lerp(start: f64, end: f64, amount: f64) -> f64 { + start + (end - start) * amount +} + pub const fn reverse_lerp(x: f64, start: f64, end: f64) -> f64 { f64::clamp((x - start) / (end - start), 0.0, 1.0) } diff --git a/src/util/macros_new.rs b/src/util/macros_new.rs new file mode 100644 index 00000000..68a2e48d --- /dev/null +++ b/src/util/macros_new.rs @@ -0,0 +1,634 @@ +macro_rules! define_new_skill { + // Entry point without `new` function + ( + $( #[$meta:meta] )* + $vis:vis struct $skill:ident: $trait:ident => $objects:ty[$object:ty] { + $( $field_name:ident: $field_type:ty $( = $field_default:expr )?, )* + } + ) => { + define_new_skill! { + @$trait $objects[$object] + extend_fields $trait + fields { $( $field_name $field_type $( = $field_default )?, )* } + struct { $( #[$meta] )* $vis $skill } + new { + setup {} + args {} + assigns {} + } + } + }; + + // Entry point with `new` function + ( + $( #[$meta:meta] )* + $vis:vis struct $skill:ident: $trait:ident => $objects:ty[$object:ty] { + $( $field_name:ident: $field_type:ty $( = $field_default:expr )?, )* + } + + $_new_vis:vis fn new( $( $arg_name:ident: $arg_type:ty ),* ) -> Self { + $( $body:tt )* + } + ) => { + define_new_skill! { + @$trait $objects[$object] + extend_fields $trait + fields { $( $field_name $field_type |, )* } + struct { $( #[$meta] )* $vis $skill } + new { + setup_body { $( $body )* } + setup {} + args { $( $arg_name $arg_type, )* } + } + } + }; + + // Processing `new` function: Found the final `Self` return value + ( + @$trait:ident $objects:ty[$object:ty] + extend_fields $extend_fields:ident + fields { $( $fields:tt )* } + struct { $( $struct:tt )* } + new { + setup_body { + Self { $( $assign_name:ident: $assign_expr:expr, )* } // <- + } + setup { $( $setup:tt )* } + args { $( $args:tt )* } + } + ) => { + define_new_skill! { + @$trait $objects[$object] + extend_fields $trait + fields { $( $fields )* } + struct { $( $struct )* } + new { + setup { $( $setup )* } + args { $( $args )* } + assigns { $( $assign_name $assign_expr, )* } // <- + } + } + }; + + // Processing `new` function: Pop next statement from body and continue + ( + @$trait:ident $objects:ty[$object:ty] + extend_fields $extend_fields:ident + fields { $( $fields:tt )* } + struct { $( $struct:tt )* } + new { + setup_body { + $stmt:stmt; // <- + $( $rest:tt )+ + } + setup { $( $setup:tt )* } + args { $( $args:tt )* } + } + ) => { + define_new_skill! { + @$trait $objects[$object] + extend_fields $trait + fields { $( $fields )* } + struct { $( $struct )* } + new { + setup_body { $( $rest )* } // <- + setup { $( $setup )* $stmt } // <- + args { $( $args )* } + } + } + }; + + // Extend `Skill`'s fields + ( + @$trait:ident $objects:ty[$object:ty] + extend_fields Skill // <- + fields { $( $fields:tt )* } + $( $rest:tt )* + ) => { + define_new_skill! { + @$trait $objects[$object] + fields { + $( $fields )* + skill_object_difficulties Vec = Vec::with_capacity(256), // <- + } + $( $rest )* + } + }; + + // Extend `VariableLengthStrainSkill`'s fields + ( + @$trait:ident $objects:ty[$object:ty] + extend_fields VariableLengthStrainSkill // <- + fields { $( $fields:tt )* } + $( $rest:tt )* + ) => { + define_new_skill! { + @$trait $objects[$object] + extend_fields Skill + fields { + $( $fields )* + skill_current_section_peak f64 = 0.0, // <- + skill_current_section_end f64 = 0.0, // <- + skill_current_section_begin f64 = 0.0, // <- + skill_total_length f64 = 0.0, // <- + skill_strain_peaks Vec = Vec::with_capacity(256), // <- + skill_queued_strains Vec<(f64, f64)> = Vec::with_capacity(256), // <- + } + $( $rest )* + } + }; + + // Extend `NewStrainSkill`'s fields + ( + @$trait:ident $objects:ty[$object:ty] + extend_fields NewStrainSkill // <- + fields { $( $fields:tt )* } + $( $rest:tt )* + ) => { + define_new_skill! { + @$trait $objects[$object] + extend_fields Skill + fields { + $( $fields )* + skill_current_section_peak f64 = 0.0, // <- + skill_current_section_end f64 = 0.0, // <- + skill_strain_peaks Vec = Vec::with_capacity(256), // <- + } + $( $rest )* + } + }; + + // Extend `HarmonicSkill`'s fields + ( + @$trait:ident $objects:ty[$object:ty] + extend_fields HarmonicSkill // <- + fields { $( $fields:tt )* } + $( $rest:tt )* + ) => { + define_new_skill! { + @$trait $objects[$object] + extend_fields Skill + fields { + $( $fields )* + } + $( $rest )* + } + }; + + // Parse field without default + ( + @$trait:ident $objects:ty[$object:ty] + fields { + $field_name:ident $field_type:ty, // <- + $( $fields:tt )* + } + struct { $( $struct:tt )* } + new { + setup { $( $setup:tt )* } + args { $( $args:tt )* } + assigns { $( $assigns:tt )* } + } + ) => { + define_new_skill! { + @$trait $objects[$object] + fields { $( $fields )* } + struct { $( $struct )* $field_name $field_type, } // <- + new { + setup { $( $setup )* } + args { $( $args )* $field_name $field_type, } // <- + assigns { $( $assigns )* $field_name, } // <- + } + } + }; + + // Parse field with default + ( + @$trait:ident $objects:ty[$object:ty] + fields { + $field_name:ident $field_type:ty = $field_default:expr, // <- + $( $fields:tt )* + } + struct { $( $struct:tt )* } + new { + setup { $( $setup:tt )* } + args { $( $args:tt )* } + assigns { $( $assigns:tt )* } + } + ) => { + define_new_skill! { + @$trait $objects[$object] + fields { $( $fields )* } + struct { $( $struct )* $field_name $field_type, } // <- + new { + setup { $( $setup )* } + args { $( $args )* } + assigns { $( $assigns )* $field_name $field_default, } // <- + } + } + }; + + // Parse field with but skip for `new` function + ( + @$trait:ident $objects:ty[$object:ty] + fields { + $field_name:ident $field_type:ty |, // <- + $( $fields:tt )* + } + struct { $( $struct:tt )* } + $( $rest:tt )* + ) => { + define_new_skill! { + @$trait $objects[$object] + fields { $( $fields )* } + struct { $( $struct )* $field_name $field_type, } // <- + $( $rest )* + } + }; + + // Final output + ( + @$trait:ident $objects:ty[$object:ty] + fields {} + struct { + $( #[$meta:meta] )* + $vis:vis $name:ident + $( $field_name:ident $field_type:ty, )* + } + new { + setup { $( $setup:stmt )* } + args { $( $arg_name:ident $arg_type:ty, )* } + assigns { $( $assign_name:ident $( $assign_expr:expr )?, )* } + } + ) => { + $( #[$meta] )* + $vis struct $name { + $( $field_name: $field_type, )* + } + + impl $name { + $vis fn new( + $( $arg_name: $arg_type, )* + ) -> Self { + $( $setup )* + + Self { + $( $assign_name $( : $assign_expr )?, )* + } + } + } + + const _: () = { + #[expect(unused_imports, reason = "fine for macros")] + use crate::{ + util::traits::IEnumerable, + any::difficulty::{ + object::{IDifficultyObject, IDifficultyObjects, HasStartTime}, + skills_new::{ + skill::Skill, + strain_skill::NewStrainSkill, + variable_length_strain_skill::VariableLengthStrainSkill, + harmonic_skill::HarmonicSkill, + }, + }, + }; + + define_new_skill!( @impl $trait $name $objects[$object] ); + }; + }; + + // Implement `Skill` trait + ( @impl Skill $name:ident $objects:ty[$object:ty] ) => { + impl Skill for $name { + type DifficultyObject<'a> = $object; + type DifficultyObjects<'a> = $objects; + + fn process<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) { + let difficulty_value = self.process_internal(curr, objects); + self.skill_object_difficulties.push(difficulty_value); + } + + fn get_object_difficulties(&self) -> &[f64] { + &self.skill_object_difficulties + } + } + }; + + // Implement `NewStrainSkill` trait + ( @impl NewStrainSkill $name:ident $objects:ty[$object:ty] ) => { + define_new_skill!( @impl Skill $name $objects[$object] ); + + impl NewStrainSkill for $name { + fn process_internal<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64 { + let section_length = f64::from(Self::SECTION_LENGTH); + + // * The first object doesn't generate a strain, so we begin with an incremented section end + if curr.idx == 0 { + self.skill_current_section_end = + f64::ceil(curr.start_time / section_length) * section_length; + } + + while curr.start_time > self.skill_current_section_end { + self.save_current_peak(); + self.start_new_section_from( + self.skill_current_section_end, + curr, + objects + ); + self.skill_current_section_end += section_length; + } + + let strain = self.strain_value_at(curr, objects); + self.skill_current_section_peak + = f64::max(strain, self.skill_current_section_peak); + + strain + } + + #[expect(unused_variables, reason = "placeholder")] + fn strain_value_at<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64 { + todo!() + } + + fn count_top_weighted_strains(&self, difficulty_value: f64) -> f64 { + crate::any::difficulty::skills_new::count_top_weighted_strains( + &self.skill_object_difficulties, + difficulty_value, + Self::DECAY_WEIGHT, + ) + } + + fn save_current_peak(&mut self) { + self.skill_strain_peaks.push(self.skill_current_section_peak); + } + + fn start_new_section_from<'a>( + &mut self, + time: f64, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) { + self.skill_current_section_peak + = self.calculate_initial_strain(time, curr, objects); + } + + #[expect(unused_variables, reason = "placeholder")] + fn calculate_initial_strain<'a>( + &self, + time: f64, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64 { + todo!() + } + + fn into_current_strain_peaks(self) -> Vec { + Self::get_current_strain_peaks( + self.skill_strain_peaks, + self.skill_current_section_peak, + ) + } + + fn difficulty_value(current_strain_peaks: Vec) -> f64 { + crate::any::difficulty::skills_new::strain_skill::strain_skill_difficulty_value( + current_strain_peaks, + Self::DECAY_WEIGHT, + ) + } + + fn into_difficulty_value(self) -> f64 { + Self::difficulty_value( + Self::get_current_strain_peaks( + self.skill_strain_peaks, + self.skill_current_section_peak, + ) + ) + } + + fn cloned_difficulty_value(&self) -> f64 { + Self::difficulty_value( + Self::get_current_strain_peaks( + self.skill_strain_peaks.clone(), + self.skill_current_section_peak, + ) + ) + } + } + }; + + // Implement `VariableLengthStrainSkill` trait + ( @impl VariableLengthStrainSkill $name:ident $objects:ty[$object:ty] ) => { + define_new_skill!( @impl Skill $name $objects[$object] ); + + impl VariableLengthStrainSkill for $name { + fn process_internal<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64 { + // * If we're on the first object, set up the first section to end `MaxSectionLength` after it. + if curr.idx == 0 { + self.skill_current_section_begin = curr.start_time; + self.skill_current_section_end + = self.skill_current_section_begin + Self::MAX_SECTION_LENGTH; + + // * No work is required for first object after calculating difficulty + self.skill_current_section_peak + = self.strain_value_at(curr, objects); + + return self.skill_current_section_peak; + } + + self.backfill_peaks(curr, objects); + + let current_strain = self.strain_value_at(curr, objects); + + // * If the current strain is larger than the current peak, begin a new peak + // * Otherwise, add the current strain to the queue + if current_strain > self.skill_current_section_peak { + // * Clear the queue since none of the strains inside of it will be contributing to the difficulty. + self.skill_queued_strains.clear(); + + // * End the current section with the new peak + self.save_current_peak(curr.start_time - self.skill_current_section_begin); + + // * Set up the new section to start at the current object with the current strain + self.skill_current_section_begin = curr.start_time; + self.skill_current_section_end = self.skill_current_section_begin + Self::MAX_SECTION_LENGTH; + self.skill_current_section_peak = current_strain; + } else { + // * Empty the queue of smaller elements as they won't be relevant to difficulty + while self.skill_queued_strains.last().filter(|(strain_value, _)| strain_value < ¤t_strain).is_some() { + self.skill_queued_strains.pop(); + } + self.skill_queued_strains.push((current_strain, curr.start_time)); + } + + current_strain + } + + #[expect(unused_variables, reason = "placeholder")] + fn strain_value_at<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64 { + todo!() + } + + fn backfill_peaks<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) { + // * If the current object starts after the current section ends + // * then we want to start a new section without any harsh drop-off. + // * If we have previous strains that influence the current difficulty we will prioritise those first. + // * Otherwise, start with the current object's initial strain. + while curr.start_time > self.skill_current_section_end { + // * Save the current peak, marking the end of the section. + self.save_current_peak(self.skill_current_section_end - self.skill_current_section_begin); + self.skill_current_section_begin = self.skill_current_section_end; + + // * If we have any strains queued, then we will use those until the object falls into the new section. + if !self.skill_queued_strains.is_empty() { + let (strain, start_time) = self.skill_queued_strains.remove(0); + + // * We want the section to end `MaxSectionLength` after the strain we're using as an influence. + // * This effectively means the queued strain will exist in its own section if the gap between the queued strain and current object is large enough. + // * This is required to make sure there's no harsh difficulty difference between 2 sections if there was a large gap. + self.skill_current_section_end = start_time + Self::MAX_SECTION_LENGTH; + self.start_new_section_from(self.skill_current_section_begin, curr, objects); + + // * If the current object's peak was higher, we don't want to override it with a lower strain. + // * Only use the queued strain if it contributes more difficulty. + self.skill_current_section_peak = self.skill_current_section_peak.max(strain); + + // * If the queue is empty then we should start the section from the current object instead. + // * The queue can be empty if we're starting off of the back of a new peak, or if we drained through all the queue + // * and the current object is still later than the section end. + } else { + // * We don't have any prior strains to take as a reference, so end the new section `MaxSectionLength` after it starts. + self.skill_current_section_end = self.skill_current_section_begin + Self::MAX_SECTION_LENGTH; + self.start_new_section_from(self.skill_current_section_begin, curr, objects); + } + } + } + + fn save_current_peak(&mut self, section_length: f64) { + let peak = crate::any::difficulty::skills_new::variable_length_strain_skill::StrainPeak::new(self.skill_current_section_peak, section_length); + + self.skill_strain_peaks.cs_add_in_place(peak); + self.skill_total_length += section_length; + + // * Remove from the back of our strain peaks if there's any which are too deep to contribute to difficulty. + // * `maxStoredLength` dictates for us how many sections will preserve at least 99.999% of the difficulty value. + while self.skill_total_length > Self::MAX_STORED_LENGTH * Self::MAX_SECTION_LENGTH { + let Some(strain_peak) = self.skill_strain_peaks.pop() else { + break; + }; + self.skill_total_length -= strain_peak.section_length; + } + } + + fn start_new_section_from<'a>( + &mut self, + time: f64, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) { + // * 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.skill_current_section_peak = self.calculate_initial_strain(time, curr, objects); + } + + #[expect(unused_variables, reason = "placeholder")] + fn calculate_initial_strain<'a>( + &self, + time: f64, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64 { + todo!() + } + + fn into_current_strain_peaks(self) -> Vec { + self.skill_strain_peaks + } + + fn count_top_weighted_strains(&self, difficulty_value: f64) -> f64 { + crate::any::difficulty::skills_new::count_top_weighted_strains( + &self.skill_object_difficulties, + difficulty_value, + Self::DECAY_WEIGHT, + ) + } + } + }; + + // Implement `HarmonicSkill` trait + ( @impl HarmonicSkill $name:ident $objects:ty[$object:ty] ) => { + define_new_skill!( @impl Skill $name $objects[$object] ); + + impl HarmonicSkill for $name { + fn object_difficulty_of<'a>( + &mut self, + curr: &Self::DifficultyObject<'a>, + objects: &Self::DifficultyObjects<'a>, + ) -> f64 { + self.object_difficulty_of(curr, objects) + } + + fn into_transformed_difficulties(self) -> Vec { + self.get_transformed_difficulties(self.get_object_difficulties().to_vec()) + } + + fn difficulty_value(transformed_object_difficulties: Vec) -> (f64, f64) { + crate::any::difficulty::skills_new::harmonic_skill::harmonic_skill_difficulty_value( + &transformed_object_difficulties, + Self::HARMONIC_SCALE, + Self::DECAY_EXPONENT, + ) + } + + fn into_difficulty_value(self) -> f64 { + Self::difficulty_value( + self.get_transformed_difficulties(self.get_object_difficulties().to_vec()), + ) + .0 + } + + fn cloned_difficulty_value(&self) -> (f64, f64) { + Self::difficulty_value( + self.get_transformed_difficulties(self.skill_object_difficulties.clone()), + ) + } + + fn count_top_weighted_object_difficulties( + &self, + difficulty_value: f64, + object_weight_sum: f64, + ) -> f64 { + crate::any::difficulty::skills_new::count_top_weighted_object_difficulties( + difficulty_value, + &self.skill_object_difficulties, + object_weight_sum, + 0.88, + 10.0, + Some(1.1) + ) + } + } + }; +} diff --git a/src/util/mod.rs b/src/util/mod.rs index 33c6ebb0..397d4fb9 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -9,6 +9,9 @@ pub mod sort; pub mod sync; pub mod traits; +#[macro_use] +mod macros_new; + #[macro_use] mod macros; diff --git a/src/util/traits.rs b/src/util/traits.rs index 1205addc..4b4ecfea 100644 --- a/src/util/traits.rs +++ b/src/util/traits.rs @@ -1,16 +1,21 @@ -use std::iter::Sum; - /// Mimics the C# `IEnumerable` interface. pub trait IEnumerable: Sized { fn cs_where bool>(self, f: F) -> Self; - fn cs_sum(&self) -> S + fn cs_add_in_place(&mut self, item: T) -> usize where - S: for<'a> Sum<&'a T>; + T: Ord; } /// Mimics the C# `IOrderedEnumerable` interface. pub trait IOrderedEnumerable: IEnumerable { + /// Sorts the elements of a sequence in descending order. + /// + /// This method performs a stable sort; that is, if the keys of two elements + /// are equal, the order of the elements is preserved. In contrast, an unstable sort does not + /// preserve the order of elements that have the same key. + /// + /// fn cs_order_descending(self) -> Self; } @@ -24,25 +29,20 @@ impl IEnumerable for Vec { self } - /// Computes the sum of a sequence of numeric values. + /// Adds the given item to the list according to standard sorting rules. Do not use on unsorted lists. /// - /// - fn cs_sum(&self) -> S + /// + fn cs_add_in_place(&mut self, item: T) -> usize where - S: for<'a> Sum<&'a T>, + T: Ord, { - self.iter().sum() + let index = self.binary_search(&item).unwrap_or_else(|i| i); + self.insert(index, item); + index } } impl IOrderedEnumerable for Vec { - /// Sorts the elements of a sequence in descending order. - /// - /// This method performs a stable sort; that is, if the keys of two elements - /// are equal, the order of the elements is preserved. In contrast, an unstable sort does not - /// preserve the order of elements that have the same key. - /// - /// fn cs_order_descending(mut self) -> Self { self.sort_by(|a, b| b.total_cmp(a)); diff --git a/tests/common.rs b/tests/common.rs index 0666b3bc..10c632d6 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -67,8 +67,15 @@ macro_rules! impl_float { ( $( $ty:ty )* ) => { $( impl Float for $ty { + #[cfg(not(target_family = "unix"))] const EPSILON: Self = Self::EPSILON; + // Differing libm implementations can cause values generated on hosts that aren't + // windows based to fall barely outside of the default epsilon values. + // For the purposes of diffcalc, precision to 12 decimal places should suffice. + #[cfg(target_family = "unix")] + const EPSILON: Self = 1e-12; + fn abs(self) -> Self { self.abs() } diff --git a/tests/difficulty.rs b/tests/difficulty.rs index 1911d764..dc480f09 100644 --- a/tests/difficulty.rs +++ b/tests/difficulty.rs @@ -37,10 +37,12 @@ macro_rules! test_cases { aim_difficult_slider_count: $aim_difficult_slider_count:literal, speed: $speed:literal, flashlight: $flashlight:literal, + reading: $reading:literal, slider_factor: $slider_factor:literal, aim_top_weighted_slider_factor: $aim_top_weighted_slider_factor:literal, speed_top_weighted_slider_factor: $speed_top_weighted_slider_factor:literal, speed_note_count: $speed_note_count:literal, + reading_difficult_note_count: $reading_difficult_note_count:literal, aim_difficult_strain_count: $aim_difficult_strain_count:literal, speed_difficult_strain_count: $speed_difficult_strain_count:literal, nested_score_per_object: $nested_score_per_object:literal, @@ -63,10 +65,12 @@ macro_rules! test_cases { aim_difficult_slider_count: $aim_difficult_slider_count, speed: $speed, flashlight: $flashlight, + reading: $reading, slider_factor: $slider_factor, aim_top_weighted_slider_factor: $aim_top_weighted_slider_factor, speed_top_weighted_slider_factor: $speed_top_weighted_slider_factor, speed_note_count: $speed_note_count, + reading_difficult_note_count: $reading_difficult_note_count, aim_difficult_strain_count: $aim_difficult_strain_count, speed_difficult_strain_count: $speed_difficult_strain_count, nested_score_per_object: $nested_score_per_object, @@ -150,24 +154,25 @@ macro_rules! test_cases { #[test] fn basic_osu() { - #[cfg(target_os = "windows")] test_cases! { Osu: OSU { NM => { - aim: 3.021506412510076, - aim_difficult_slider_count: 180.33980678704012, - speed: 2.5263145770639976, + aim: 3.27863857424994, + aim_difficult_slider_count: 192.5269999738169, + speed: 2.4917265153109014, flashlight: 0.0, - slider_factor: 0.9847225384137204, - aim_top_weighted_slider_factor: 1.3996332540321264, - speed_top_weighted_slider_factor: 0.6014562852677632, - speed_note_count: 202.24319351543616, - aim_difficult_strain_count: 108.47555309841259, - speed_difficult_strain_count: 78.39830024782772, + reading: 0.8229208521405954, + slider_factor: 0.9630386892765709, + aim_top_weighted_slider_factor: 1.524202370856421, + speed_top_weighted_slider_factor: 0.536191641231213, + speed_note_count: 183.0639785973236, + reading_difficult_note_count: 34.92251595856365, + aim_difficult_strain_count: 124.69544446818438, + speed_difficult_strain_count: 81.74921671931915, nested_score_per_object: 34.991680532445926, legacy_score_base_multiplier: 5.0, maximum_legacy_combo_score: 15729840.0, - ar: 9.300000190734863, + ar: 9.30000019, great_hit_window: 26.5, ok_hit_window: 68.5, meh_hit_window: 110.5, @@ -176,20 +181,22 @@ fn basic_osu() { n_sliders: 293, n_large_ticks: 15, n_spinners: 1, - stars: 5.740766046562339, + stars: 6.004027372552197, max_combo: 909, }; HD => { - aim: 3.121489829231887, - aim_difficult_slider_count: 180.33980678704012, - speed: 2.614171127905441, + aim: 3.27863857424994, + aim_difficult_slider_count: 192.5269999738169, + speed: 2.4917265153109014, flashlight: 0.0, - slider_factor: 0.9847225384137204, - aim_top_weighted_slider_factor: 1.3996332540321264, - speed_top_weighted_slider_factor: 0.6014562852677632, - speed_note_count: 202.24319351543616, - aim_difficult_strain_count: 108.47555309841259, - speed_difficult_strain_count: 78.39830024782772, + reading: 2.1827264342501156, + slider_factor: 0.963038689276571, + aim_top_weighted_slider_factor: 1.524202370856421, + speed_top_weighted_slider_factor: 0.536191641231213, + speed_note_count: 183.0639785973236, + reading_difficult_note_count: 135.7746987103988, + aim_difficult_strain_count: 124.69544446818438, + speed_difficult_strain_count: 81.74921671931915, nested_score_per_object: 34.991680532445926, legacy_score_base_multiplier: 5.0, maximum_legacy_combo_score: 15729840.0, @@ -202,20 +209,22 @@ fn basic_osu() { n_sliders: 293, n_large_ticks: 15, n_spinners: 1, - stars: 5.934133851244851, + stars: 6.308336834596954, max_combo: 909, }; HR => { - aim: 3.4309052630747257, - aim_difficult_slider_count: 187.20300643263465, - speed: 2.6813963801152716, + aim: 3.799232322249643, + aim_difficult_slider_count: 191.8309507640488, + speed: 2.4917265153109014, flashlight: 0.0, - slider_factor: 0.9748562752795166, - aim_top_weighted_slider_factor: 1.3634873114118244, - speed_top_weighted_slider_factor: 0.6668815233244475, - speed_note_count: 185.01178339020348, - aim_difficult_strain_count: 112.28112750203013, - speed_difficult_strain_count: 74.53251006179151, + reading: 0.9144658746351508, + slider_factor: 0.9475983634088616, + aim_top_weighted_slider_factor: 1.510078933241033, + speed_top_weighted_slider_factor: 0.5361916412312123, + speed_note_count: 183.0639785973236, + reading_difficult_note_count: 38.427842331296304, + aim_difficult_strain_count: 119.62860170592188, + speed_difficult_strain_count: 81.74921671931915, nested_score_per_object: 34.991680532445926, legacy_score_base_multiplier: 5.0, maximum_legacy_combo_score: 15729840.0, @@ -228,20 +237,22 @@ fn basic_osu() { n_sliders: 293, n_large_ticks: 15, n_spinners: 1, - stars: 6.375104448752039, + stars: 6.713673504226164, max_combo: 909, }; DT => { - aim: 4.3662195513104525, - aim_difficult_slider_count: 195.41476682131653, - speed: 3.7793477426295814, + aim: 4.693556954514378, + aim_difficult_slider_count: 207.97415619378023, + speed: 3.674242476685813, flashlight: 0.0, - slider_factor: 0.9787310737204966, - aim_top_weighted_slider_factor: 1.3819099517666353, - speed_top_weighted_slider_factor: 0.6923456235877925, - speed_note_count: 208.98215163620375, - aim_difficult_strain_count: 130.48279566301667, - speed_difficult_strain_count: 93.64469563382437, + reading: 2.0228172499241897, + slider_factor: 0.9674908735064722, + aim_top_weighted_slider_factor: 1.476888448482664, + speed_top_weighted_slider_factor: 0.6387657108874909, + speed_note_count: 211.45478779956713, + reading_difficult_note_count: 190.90786995621218, + aim_difficult_strain_count: 144.31095827870723, + speed_difficult_strain_count: 86.60969041721593, nested_score_per_object: 34.991680532445926, legacy_score_base_multiplier: 5.0, maximum_legacy_combo_score: 15729840.0, @@ -254,20 +265,22 @@ fn basic_osu() { n_sliders: 293, n_large_ticks: 15, n_spinners: 1, - stars: 8.40182116136074, + stars: 8.762958976840826, max_combo: 909, }; FL => { - aim: 3.021506412510076, - aim_difficult_slider_count: 180.33980678704012, - speed: 2.5263145770639976, - flashlight: 2.3005989208967885, - slider_factor: 0.9847225384137204, - aim_top_weighted_slider_factor: 1.3996332540321264, - speed_top_weighted_slider_factor: 0.6014562852677632, - speed_note_count: 202.24319351543616, - aim_difficult_strain_count: 108.47555309841259, - speed_difficult_strain_count: 78.39830024782772, + aim: 3.27863857424994, + aim_difficult_slider_count: 192.5269999738169, + speed: 2.4917265153109014, + flashlight: 2.345608737345168, + reading: 0.8229208521405954, + slider_factor: 0.963038689276571, + aim_top_weighted_slider_factor: 1.524202370856421, + speed_top_weighted_slider_factor: 0.536191641231213, + speed_note_count: 183.0639785973236, + reading_difficult_note_count: 34.92251595856365, + aim_difficult_strain_count: 124.69544446818438, + speed_difficult_strain_count: 81.74921671931915, nested_score_per_object: 34.991680532445926, legacy_score_base_multiplier: 5.0, maximum_legacy_combo_score: 15729840.0, @@ -280,20 +293,22 @@ fn basic_osu() { n_sliders: 293, n_large_ticks: 15, n_spinners: 1, - stars: 6.864308231959398, + stars: 7.036258413665859, max_combo: 909, }; HD EZ => { - aim: 2.9818506123002706, - aim_difficult_slider_count: 173.00759261125233, - speed: 2.511531850339625, + aim: 2.7625488821040367, + aim_difficult_slider_count: 196.89037873007746, + speed: 2.3995360680924946, flashlight: 0.0, - slider_factor: 0.9931728395338801, - aim_top_weighted_slider_factor: 1.4758126955064637, - speed_top_weighted_slider_factor: 0.48777057881852615, - speed_note_count: 211.97339651166865, - aim_difficult_strain_count: 107.45480335487801, - speed_difficult_strain_count: 78.94432491731223, + reading: 3.5538685094268883, + slider_factor: 0.9796909646357417, + aim_top_weighted_slider_factor: 1.562739917685155, + speed_top_weighted_slider_factor: 0.5369556982593612, + speed_note_count: 192.2649456376246, + reading_difficult_note_count: 124.55280093376417, + aim_difficult_strain_count: 129.38126835796155, + speed_difficult_strain_count: 84.40439036292067, nested_score_per_object: 34.991680532445926, legacy_score_base_multiplier: 3.0, maximum_legacy_combo_score: 15729840.0, @@ -306,20 +321,22 @@ fn basic_osu() { n_sliders: 293, n_large_ticks: 15, n_spinners: 1, - stars: 5.680319048111094, + stars: 6.891768477382506, max_combo: 909, }; HD FL => { - aim: 3.121489829231887, - aim_difficult_slider_count: 180.33980678704012, - speed: 2.614171127905441, - flashlight: 2.620335643475851, - slider_factor: 0.9847225384137204, - aim_top_weighted_slider_factor: 1.3996332540321264, - speed_top_weighted_slider_factor: 0.6014562852677632, - speed_note_count: 202.24319351543616, - aim_difficult_strain_count: 108.47555309841259, - speed_difficult_strain_count: 78.39830024782772, + aim: 3.27863857424994, + aim_difficult_slider_count: 192.5269999738169, + speed: 2.4917265153109014, + flashlight: 2.6270894946667935, + reading: 2.1827264342501156, + slider_factor: 0.963038689276571, + aim_top_weighted_slider_factor: 1.524202370856421, + speed_top_weighted_slider_factor: 0.536191641231213, + speed_note_count: 183.0639785973236, + reading_difficult_note_count: 135.7746987103988, + aim_difficult_strain_count: 124.6954444681844, + speed_difficult_strain_count: 81.74921671931915, nested_score_per_object: 34.991680532445926, legacy_score_base_multiplier: 5.0, maximum_legacy_combo_score: 15729840.0, @@ -332,7 +349,7 @@ fn basic_osu() { n_sliders: 293, n_large_ticks: 15, n_spinners: 1, - stars: 7.2736222258399374, + stars: 7.47402836368438, max_combo: 909, }; } @@ -581,10 +598,12 @@ impl AssertEq for OsuDifficultyAttributes { aim_difficult_slider_count, speed, flashlight, + reading, slider_factor, aim_top_weighted_slider_factor, speed_top_weighted_slider_factor, speed_note_count, + reading_difficult_note_count, aim_difficult_strain_count, speed_difficult_strain_count, nested_score_per_object, @@ -610,6 +629,7 @@ impl AssertEq for OsuDifficultyAttributes { ); assert_eq_float(*speed, expected.speed); assert_eq_float(*flashlight, expected.flashlight); + assert_eq_float(*reading, expected.reading); assert_eq_float(*slider_factor, expected.slider_factor); assert_eq_float( *aim_top_weighted_slider_factor, @@ -620,6 +640,10 @@ impl AssertEq for OsuDifficultyAttributes { expected.speed_top_weighted_slider_factor, ); assert_eq_float(*speed_note_count, expected.speed_note_count); + assert_eq_float( + *reading_difficult_note_count, + expected.reading_difficult_note_count, + ); assert_eq_float( *aim_difficult_strain_count, expected.aim_difficult_strain_count, @@ -637,7 +661,7 @@ impl AssertEq for OsuDifficultyAttributes { *maximum_legacy_combo_score, expected.maximum_legacy_combo_score, ); - assert_eq_float(*ar, expected.ar); + assert_eq_float(*ar as f32, expected.ar as f32); assert_eq_float(*great_hit_window, expected.great_hit_window); assert_eq_float(*ok_hit_window, expected.ok_hit_window); assert_eq_float(*meh_hit_window, expected.meh_hit_window); diff --git a/tests/performance.rs b/tests/performance.rs index 81e219cb..7d8f122b 100644 --- a/tests/performance.rs +++ b/tests/performance.rs @@ -34,6 +34,7 @@ macro_rules! test_cases { pp_aim: $pp_aim:expr, pp_flashlight: $pp_flashlight:expr, pp_speed: $pp_speed:expr, + pp_reading: $pp_reading:expr, effective_miss_count: $effective_miss_count:expr, speed_deviation: $speed_deviation:expr, combo_based_estimated_miss_count: $combo_based_estimated_miss_count:expr, @@ -49,6 +50,7 @@ macro_rules! test_cases { pp_aim: $pp_aim, pp_flashlight: $pp_flashlight, pp_speed: $pp_speed, + pp_reading: $pp_reading, effective_miss_count: $effective_miss_count, speed_deviation: $speed_deviation, combo_based_estimated_miss_count: $combo_based_estimated_miss_count, @@ -107,95 +109,101 @@ macro_rules! test_cases { #[test] fn basic_osu() { - #[cfg(target_os = "windows")] test_cases! { Osu: OSU { NM => { - pp: 287.9051448920619, + pp: 316.5901855625614, pp_acc: 98.99847982709288, - pp_aim: 113.66811014707582, + pp_aim: 148.75278891878943, pp_flashlight: 0.0, - pp_speed: 65.7316947411581, + pp_speed: 61.34653468094172, + pp_reading: 2.2291238201795176, effective_miss_count: 0.0, - speed_deviation: Some(11.559405011202584), + speed_deviation: Some(11.70045116819282), combo_based_estimated_miss_count: 0.0, score_based_estimated_miss_count: None, aim_estimated_slider_breaks: 0.0, speed_estimated_slider_breaks: 0.0, }; HD => { - pp: 315.8674097332546, - pp_acc: 106.91835821326032, - pp_aim: 125.5489356876975, + pp: 349.9881115302272, + pp_acc: 98.99847982709288, + pp_aim: 148.75278891878943, pp_flashlight: 0.0, - pp_speed: 72.9912208672784, + pp_speed: 61.34653468094172, + pp_reading: 41.59660781351789, effective_miss_count: 0.0, - speed_deviation: Some(11.559405011202584), + speed_deviation: Some(11.70045116819282), combo_based_estimated_miss_count: 0.0, score_based_estimated_miss_count: None, aim_estimated_slider_breaks: 0.0, speed_estimated_slider_breaks: 0.0, }; EZ HD => { - pp: 200.88128466771315, - pp_acc: 17.33989029835826, - pp_aim: 109.17177789930311, + pp: 330.5430804957736, + pp_acc: 16.05545397996135, + pp_aim: 88.9845069859366, pp_flashlight: 0.0, - pp_speed: 64.55964097206972, + pp_speed: 40.67344998245687, + pp_reading: 179.54117243675987, effective_miss_count: 0.0, - speed_deviation: Some(22.768253044002595), + speed_deviation: Some(23.04067406810845), combo_based_estimated_miss_count: 0.0, score_based_estimated_miss_count: None, aim_estimated_slider_breaks: 0.0, speed_estimated_slider_breaks: 0.0, }; HR => { - pp: 422.8822464661912, + pp: 468.21934604774174, pp_acc: 161.55575439788055, - pp_aim: 167.50210608714042, + pp_aim: 231.45791599856506, pp_flashlight: 0.0, - pp_speed: 78.89335639563441, + pp_speed: 61.86844909389746, + pp_reading: 3.0588804345706087, effective_miss_count: 0.0, - speed_deviation: Some(8.598712200750178), + speed_deviation: Some(8.609766678538842), combo_based_estimated_miss_count: 0.0, score_based_estimated_miss_count: None, aim_estimated_slider_breaks: 0.0, speed_estimated_slider_breaks: 0.0, }; DT => { - pp: 784.2400469306212, + pp: 861.1999380363726, pp_acc: 183.66566616694254, - pp_aim: 348.7917741691343, + pp_aim: 436.40604835642193, pp_flashlight: 0.0, - pp_speed: 224.8868678368528, + pp_speed: 198.35289926936036, + pp_reading: 33.10777055891541, effective_miss_count: 0.0, - speed_deviation: Some(7.6754769185728815), + speed_deviation: Some(7.66444640194172), combo_based_estimated_miss_count: 0.0, score_based_estimated_miss_count: None, aim_estimated_slider_breaks: 0.0, speed_estimated_slider_breaks: 0.0, }; FL => { - pp: 415.9768919360004, - pp_acc: 100.97844942363474, - pp_aim: 113.66811014707582, - pp_flashlight: 132.3188848707867, - pp_speed: 65.7316947411581, + pp: 444.5824653333625, + pp_acc: 98.99847982709288, + pp_aim: 148.75278891878943, + pp_flashlight: 137.54700871774983, + pp_speed: 61.34653468094172, + pp_reading: 2.2291238201795176, effective_miss_count: 0.0, - speed_deviation: Some(11.559405011202584), + speed_deviation: Some(11.70045116819282), combo_based_estimated_miss_count: 0.0, score_based_estimated_miss_count: None, aim_estimated_slider_breaks: 0.0, speed_estimated_slider_breaks: 0.0, }; HD FL => { - pp: 483.7752666636294, - pp_acc: 109.05672537752552, - pp_aim: 125.5489356876975, - pp_flashlight: 171.65397211175005, - pp_speed: 72.9912208672784, + pp: 512.2069389717595, + pp_acc: 98.99847982709288, + pp_aim: 148.75278891878943, + pp_flashlight: 172.5399803247157, + pp_speed: 61.34653468094172, + pp_reading: 41.59660781351789, effective_miss_count: 0.0, - speed_deviation: Some(11.559405011202584), + speed_deviation: Some(11.70045116819282), combo_based_estimated_miss_count: 0.0, score_based_estimated_miss_count: None, aim_estimated_slider_breaks: 0.0, @@ -334,6 +342,7 @@ impl AssertEq for OsuPerformanceAttributes { pp_acc, pp_aim, pp_flashlight, + pp_reading, pp_speed, effective_miss_count, speed_deviation, @@ -347,6 +356,7 @@ impl AssertEq for OsuPerformanceAttributes { assert_eq_float(*pp_acc, expected.pp_acc); assert_eq_float(*pp_aim, expected.pp_aim); assert_eq_float(*pp_flashlight, expected.pp_flashlight); + assert_eq_float(*pp_reading, expected.pp_reading); assert_eq_float(*pp_speed, expected.pp_speed); assert_eq_float(*effective_miss_count, expected.effective_miss_count); assert_eq_option(*speed_deviation, expected.speed_deviation);