From 3c57b9b4090e2e1127681a02463bb47ed81949be Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 27 Sep 2024 15:46:40 +0200 Subject: [PATCH 01/48] feat: updated osu!standard difficulate calculation --- Cargo.toml | 3 +- README.md | 4 +- src/catch/object/juice_stream.rs | 7 +- src/lib.rs | 4 +- src/mania/convert/mod.rs | 2 +- src/mania/object.rs | 4 +- src/model/hit_object.rs | 13 +- src/model/mods.rs | 1 + src/osu/difficulty/mod.rs | 10 +- src/osu/difficulty/object.rs | 16 +- src/osu/difficulty/scaling_factor.rs | 5 +- src/osu/difficulty/skills/aim.rs | 11 +- src/osu/difficulty/skills/flashlight.rs | 10 +- src/osu/difficulty/skills/speed.rs | 215 +++++++++++++----------- src/osu/difficulty/skills/strain.rs | 8 +- src/osu/object.rs | 116 ++++++++++--- src/osu/performance/mod.rs | 14 +- src/taiko/convert.rs | 2 +- 18 files changed, 277 insertions(+), 168 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1e41427e..abd830cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,8 @@ sync = [] tracing = ["rosu-map/tracing"] [dependencies] -rosu-map = { version = "0.1.1" } +# rosu-map = { version = "0.1.2" } +rosu-map = { git = "https://github.com/MaxOhn/rosu-map", branch = "pp-update" } rosu-mods = { version = "0.1.0" } [dev-dependencies] diff --git a/README.md b/README.md index 741aa511..ff5eaf40 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ with emphasis on a precise translation to Rust for the most [accurate results](# while also providing a significant [boost in performance](#speed). Last commits of the ported code: - - [osu!lazer] : `7342fb7f51b34533a42bffda89c3d6c569cc69ce` (2022-10-11) - - [osu!tools] : `146d5916937161ef65906aa97f85d367035f3712` (2022-10-08) + - [osu!lazer] : `f08134f443b2cf255fd19c8bc3ef517b6a3bb8e3` (2024-09-23) + - [osu!tools] : `51965515eb4355fde0591728ef4d38eee119a964` (2024-09-01) News posts of the latest gamemode updates: - osu: diff --git a/src/catch/object/juice_stream.rs b/src/catch/object/juice_stream.rs index 5b4e870b..c6ef2990 100644 --- a/src/catch/object/juice_stream.rs +++ b/src/catch/object/juice_stream.rs @@ -1,7 +1,8 @@ use std::vec::Drain; -use rosu_map::section::hit_objects::{ - CurveBuffers, PathControlPoint, SliderEvent, SliderEventType, SliderEventsIter, +use rosu_map::section::{ + general::GameMode, + hit_objects::{CurveBuffers, PathControlPoint, SliderEvent, SliderEventType, SliderEventsIter}, }; use crate::{ @@ -41,7 +42,7 @@ impl<'a> JuiceStream<'a> { point.slider_velocity }); - let path = slider.curve(&mut bufs.curve); + let path = slider.curve(GameMode::Catch, &mut bufs.curve); let velocity_factor = JuiceStream::BASE_SCORING_DIST * slider_multiplier / beat_len; let velocity = velocity_factor * slider_velocity; diff --git a/src/lib.rs b/src/lib.rs index 60a28124..dabf5d09 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,8 +5,8 @@ //! while also providing a significant [boost in performance](#speed). //! //! Last commits of the ported code: -//! - [osu!lazer] : `7342fb7f51b34533a42bffda89c3d6c569cc69ce` (2022-10-11) -//! - [osu!tools] : `146d5916937161ef65906aa97f85d367035f3712` (2022-10-08) +//! - [osu!lazer] : `f08134f443b2cf255fd19c8bc3ef517b6a3bb8e3` (2024-09-23) +//! - [osu!tools] : `51965515eb4355fde0591728ef4d38eee119a964` (2024-09-01) //! //! News posts of the latest gamemode updates: //! - osu: diff --git a/src/mania/convert/mod.rs b/src/mania/convert/mod.rs index f358930f..a1e39555 100644 --- a/src/mania/convert/mod.rs +++ b/src/mania/convert/mod.rs @@ -108,7 +108,7 @@ fn convert(map: &mut Beatmap) { last_values.pattern = new_pattern; } HitObjectKind::Slider(ref slider) => { - let curve = slider.curve(&mut curve_bufs); + let curve = slider.curve(GameMode::Mania, &mut curve_bufs); let mut gen = DistanceObjectPatternGenerator::new( &mut random, diff --git a/src/mania/object.rs b/src/mania/object.rs index 03919cb6..591f844b 100644 --- a/src/mania/object.rs +++ b/src/mania/object.rs @@ -1,4 +1,4 @@ -use rosu_map::section::hit_objects::CurveBuffers; +use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers}; use crate::model::{ beatmap::Beatmap, @@ -26,7 +26,7 @@ impl ManiaObject { HitObjectKind::Slider(ref slider) => { const BASE_SCORING_DIST: f32 = 100.0; - let dist = slider.curve(&mut params.curve_bufs).dist(); + let dist = slider.curve(GameMode::Mania, &mut params.curve_bufs).dist(); let beat_len = params .map diff --git a/src/model/hit_object.rs b/src/model/hit_object.rs index 895c8f11..8dc9cbe6 100644 --- a/src/model/hit_object.rs +++ b/src/model/hit_object.rs @@ -1,6 +1,9 @@ use std::cmp::Ordering; -use rosu_map::section::hit_objects::{BorrowedCurve, CurveBuffers}; +use rosu_map::section::{ + general::GameMode, + hit_objects::{BorrowedCurve, CurveBuffers}, +}; pub use rosu_map::{ section::hit_objects::{hit_samples::HitSoundType, PathControlPoint, PathType, SplineType}, @@ -76,8 +79,12 @@ impl Slider { self.repeats + 1 } - pub(crate) fn curve<'a>(&self, bufs: &'a mut CurveBuffers) -> BorrowedCurve<'a> { - BorrowedCurve::new(&self.control_points, self.expected_dist, bufs) + pub(crate) fn curve<'a>( + &self, + mode: GameMode, + bufs: &'a mut CurveBuffers, + ) -> BorrowedCurve<'a> { + BorrowedCurve::new(mode, &self.control_points, self.expected_dist, bufs) } } diff --git a/src/model/mods.rs b/src/model/mods.rs index 115522f0..16370343 100644 --- a/src/model/mods.rs +++ b/src/model/mods.rs @@ -175,6 +175,7 @@ impl_has_mod! { fl: + Flashlight ["Flashlight"], so: + SpunOut ["SpunOut"], bl: - Blinds ["Blinds"], + tc: - Traceable ["Traceable"], } impl Default for GameMods { diff --git a/src/osu/difficulty/mod.rs b/src/osu/difficulty/mod.rs index 94f39f73..c038d6f8 100644 --- a/src/osu/difficulty/mod.rs +++ b/src/osu/difficulty/mod.rs @@ -1,5 +1,7 @@ use std::{cmp, pin::Pin}; +use skills::{flashlight::Flashlight, strain::OsuStrainSkill}; + use crate::{ any::difficulty::{skills::Skill, Difficulty}, model::{beatmap::BeatmapAttributes, mods::GameMods}, @@ -174,13 +176,11 @@ impl DifficultyValues { flashlight_rating *= 0.7; } - let base_aim_performance = - (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powf(3.0) / 100_000.0; - let base_speed_performance = - (5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powf(3.0) / 100_000.0; + let base_aim_performance = OsuStrainSkill::difficulty_to_performance(aim_rating); + let base_speed_performance = OsuStrainSkill::difficulty_to_performance(speed_rating); let base_flashlight_performance = if mods.fl() { - flashlight_rating.powf(2.0) * 25.0 + Flashlight::difficulty_to_performance(flashlight_rating) } else { 0.0 }; diff --git a/src/osu/difficulty/object.rs b/src/osu/difficulty/object.rs index 4c24371c..5557e86c 100644 --- a/src/osu/difficulty/object.rs +++ b/src/osu/difficulty/object.rs @@ -1,10 +1,10 @@ -use std::pin::Pin; +use std::{borrow::Cow, pin::Pin}; use rosu_map::util::Pos; use crate::{ any::difficulty::object::IDifficultyObject, - osu::object::{OsuObject, OsuObjectKind}, + osu::object::{OsuObject, OsuObjectKind, OsuSlider}, }; use super::{scaling_factor::ScalingFactor, HD_FADE_OUT_DURATION_MULTIPLIER}; @@ -158,20 +158,26 @@ impl<'a> OsuDifficultyObject<'a> { ) -> Pin<&mut OsuObject> { let pos = h.pos; let stack_offset = h.stack_offset; + let start_time = h.start_time; let OsuObjectKind::Slider(ref mut slider) = h.kind else { return h; }; + let mut nested = Cow::Borrowed(slider.nested_objects.as_slice()); + let duration = slider.end_time - start_time; + OsuSlider::lazy_travel_time(start_time, duration, &mut nested); + let nested = nested.as_ref(); + let mut curr_cursor_pos = pos + stack_offset; let scaling_factor = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS) / radius; - for (curr_movement_obj, i) in slider.nested_objects.iter().zip(1..) { + for (curr_movement_obj, i) in nested.iter().zip(1..) { let mut curr_movement = curr_movement_obj.pos + stack_offset - curr_cursor_pos; let mut curr_movement_len = scaling_factor * f64::from(curr_movement.length()); let mut required_movement = f64::from(OsuDifficultyObject::ASSUMED_SLIDER_RADIUS); - if i == slider.nested_objects.len() { + if i == nested.len() { let lazy_movement = slider.lazy_end_pos - curr_cursor_pos; if lazy_movement.length() < curr_movement.length() { @@ -190,7 +196,7 @@ impl<'a> OsuDifficultyObject<'a> { slider.lazy_travel_dist += curr_movement_len as f32; } - if i == slider.nested_objects.len() { + if i == nested.len() { slider.lazy_end_pos = curr_cursor_pos; } } diff --git a/src/osu/difficulty/scaling_factor.rs b/src/osu/difficulty/scaling_factor.rs index f964c69b..0b99bdc8 100644 --- a/src/osu/difficulty/scaling_factor.rs +++ b/src/osu/difficulty/scaling_factor.rs @@ -4,6 +4,8 @@ use crate::osu::object::OsuObject; use super::object::OsuDifficultyObject; +const BROKEN_GAMEFIELD_ROUNDING_ALLOWANCE: f32 = 1.00041; + /// Fields around the scaling of hit objects. /// /// osu!lazer stores these in each hit object but since all objects share the @@ -17,7 +19,8 @@ pub struct ScalingFactor { impl ScalingFactor { pub fn new(cs: f64) -> Self { - let scale = (1.0 - 0.7 * (cs as f32 - 5.0) / 5.0) / 2.0; + let scale = + (1.0 - 0.7 * ((cs - 5.0) / 5.0)) as f32 / 2.0 * BROKEN_GAMEFIELD_ROUNDING_ALLOWANCE; let radius = f64::from(OsuObject::OBJECT_RADIUS * scale); let factor = OsuDifficultyObject::NORMALIZED_RADIUS / radius as f32; diff --git a/src/osu/difficulty/skills/aim.rs b/src/osu/difficulty/skills/aim.rs index 2ceff6f6..0aefd1c8 100644 --- a/src/osu/difficulty/skills/aim.rs +++ b/src/osu/difficulty/skills/aim.rs @@ -11,7 +11,7 @@ use crate::{ use super::strain::OsuStrainSkill; -const SKILL_MULTIPLIER: f64 = 23.55; +const SKILL_MULTIPLIER: f64 = 24.963; const STRAIN_DECAY_BASE: f64 = 0.15; #[derive(Clone)] @@ -49,7 +49,6 @@ impl Aim { OsuStrainSkill::REDUCED_SECTION_COUNT, OsuStrainSkill::REDUCED_STRAIN_BASELINE, OsuStrainSkill::DECAY_WEIGHT, - OsuStrainSkill::DIFFICULTY_MULTIPLER, ) } } @@ -121,7 +120,7 @@ impl AimEvaluator { fn evaluate_diff_of<'a>( curr: &'a OsuDifficultyObject<'a>, diff_objects: &'a [OsuDifficultyObject<'a>], - with_sliders: bool, + with_slider_travel_dist: bool, ) -> f64 { let osu_curr_obj = curr; @@ -139,7 +138,7 @@ impl AimEvaluator { // * But if the last object is a slider, then we extend the travel // * velocity through the slider into the current object. - if osu_last_obj.base.is_slider() && with_sliders { + 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 @@ -152,7 +151,7 @@ impl AimEvaluator { // * As above, do the same for the previous hitobject. let mut prev_vel = osu_last_obj.lazy_jump_dist / osu_last_obj.strain_time; - if osu_last_last_obj.base.is_slider() && with_sliders { + 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; @@ -254,7 +253,7 @@ impl AimEvaluator { ); // * Add in additional slider velocity bonus. - if with_sliders { + if with_slider_travel_dist { aim_strain += slider_bonus * Self::SLIDER_MULTIPLIER; } diff --git a/src/osu/difficulty/skills/flashlight.rs b/src/osu/difficulty/skills/flashlight.rs index 4ed01ac6..2678680f 100644 --- a/src/osu/difficulty/skills/flashlight.rs +++ b/src/osu/difficulty/skills/flashlight.rs @@ -10,9 +10,7 @@ use crate::{ util::strains_vec::StrainsVec, }; -use super::strain::OsuStrainSkill; - -const SKILL_MULTIPLIER: f64 = 0.052; +const SKILL_MULTIPLIER: f64 = 0.05512; const STRAIN_DECAY_BASE: f64 = 0.15; pub struct Flashlight { @@ -49,7 +47,11 @@ impl Flashlight { } fn static_difficulty_value(skill: StrainSkill) -> f64 { - skill.get_curr_strain_peaks().sum() * OsuStrainSkill::DIFFICULTY_MULTIPLER + skill.get_curr_strain_peaks().sum() + } + + pub fn difficulty_to_performance(difficulty: f64) -> f64 { + 25.0 * (difficulty).powf(2.0) } } diff --git a/src/osu/difficulty/skills/speed.rs b/src/osu/difficulty/skills/speed.rs index 7b4260e0..030b00bc 100644 --- a/src/osu/difficulty/skills/speed.rs +++ b/src/osu/difficulty/skills/speed.rs @@ -11,10 +11,9 @@ use crate::{ use super::strain::OsuStrainSkill; -const SKILL_MULTIPLIER: f64 = 1375.0; +const SKILL_MULTIPLIER: f64 = 1.430; const STRAIN_DECAY_BASE: f64 = 0.3; -const DIFFICULTY_MULTIPLER: f64 = 1.04; const REDUCED_SECTION_COUNT: usize = 5; #[derive(Clone)] @@ -57,7 +56,6 @@ impl Speed { REDUCED_SECTION_COUNT, OsuStrainSkill::REDUCED_STRAIN_BASELINE, OsuStrainSkill::DECAY_WEIGHT, - DIFFICULTY_MULTIPLER, ) } @@ -140,7 +138,7 @@ impl<'a> Skill<'a, Speed> { struct SpeedEvaluator; impl SpeedEvaluator { - const SINGLE_SPACING_THRESHOLD: f64 = 125.0; + const SINGLE_SPACING_THRESHOLD: f64 = 125.0; // 1.25 circlers distance between centers const MIN_SPEED_BONUS: f64 = 75.0; // ~200BPM const SPEED_BALANCING_FACTOR: f64 = 40.; @@ -175,21 +173,30 @@ impl SpeedEvaluator { // * 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. strain_time /= ((strain_time / hit_window) / 0.93).clamp(0.92, 1.0); - // * derive speedBonus for calculation let speed_bonus = if strain_time < Self::MIN_SPEED_BONUS { + // * Add additional scaling bonus for streams/bursts higher than 200bpm let base = (Self::MIN_SPEED_BONUS - strain_time) / Self::SPEED_BALANCING_FACTOR; 1.0 + 0.75 * base.powf(2.0) } else { + // * speedBonus will be 1.0 for BPM < 200 1.0 }; let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.travel_dist); - let dist = Self::SINGLE_SPACING_THRESHOLD.min(travel_dist + osu_curr_obj.min_jump_dist); + let mut dist = travel_dist + osu_curr_obj.min_jump_dist; - (speed_bonus + speed_bonus * (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.5)) - * doubletapness - / strain_time + // * Cap distance at single_spacing_threshold + dist = Self::SINGLE_SPACING_THRESHOLD.min(dist); + + // * Max distance bonus is 2 at single_spacing_threshold + let dist_bonus = 1.0 + (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.5); + + // * Base difficulty with all bonuses + let difficulty = speed_bonus * dist_bonus * 1000.0 / strain_time; + + // * Apply penalty if there's doubletappable doubles + return difficulty * doubletapness; } } @@ -233,103 +240,107 @@ impl RhythmEvaluator { rhythm_start += 1; } - for i in (1..=rhythm_start).rev() { - let Some(((curr_obj, prev_obj), last_obj)) = curr - .previous(i - 1, diff_objects) - .zip(curr.previous(i, diff_objects)) - .zip(curr.previous(i + 1, diff_objects)) - else { - break; - }; - - // * scales note 0 to 1 from history to now - let mut curr_historical_decay = (f64::from(Self::HISTORY_TIME_MAX) - - (curr.start_time - curr_obj.start_time)) - / f64::from(Self::HISTORY_TIME_MAX); - - // * either we're limited by time or limited by object count. - curr_historical_decay = curr_historical_decay - .min((historical_note_count - i) as f64 / historical_note_count as f64); - - let curr_delta = curr_obj.strain_time; - let prev_delta = prev_obj.strain_time; - let last_delta = last_obj.strain_time; - - // * fancy function to calculate rhythmbonuses. - let base = (PI / (prev_delta.min(curr_delta) / prev_delta.max(curr_delta))).sin(); - let curr_ratio = 1.0 + 6.0 * base.powf(2.0).min(0.5); - - let hit_window = u64::from(!curr_obj.base.is_spinner()) as f64 * hit_window; - - let mut window_penalty = ((((prev_delta - curr_delta).abs() - hit_window * 0.3) - .max(0.0)) - / (hit_window * 0.3)) - .min(1.0); - - window_penalty = window_penalty.min(1.0); - - let mut effective_ratio = window_penalty * curr_ratio; - - if first_delta_switch { - // Keep in-sync with lazer - #[allow(clippy::if_not_else)] - if !(prev_delta > 1.25 * curr_delta || prev_delta * 1.25 < curr_delta) { - if island_size < 7 { - // * island is still progressing, count size. - island_size += 1; - } - } else { - // * bpm change is into slider, this is easy acc window - if curr_obj.base.is_slider() { - effective_ratio *= 0.125; - } - - // * bpm change was from a slider, this is easier typically than circle -> circle - if prev_obj.base.is_slider() { - effective_ratio *= 0.25; - } - - // * repeated island size (ex: triplet -> triplet) - if prev_island_size == island_size { - effective_ratio *= 0.25; - } - - // * repeated island polartiy (2 -> 4, 3 -> 5) - if prev_island_size % 2 == island_size % 2 { - effective_ratio *= 0.5; - } - - // * previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. - if last_delta > prev_delta + 10.0 && prev_delta > curr_delta + 10.0 { - effective_ratio *= 0.125; + if let Some((mut prev_obj, mut last_obj)) = curr + .previous(rhythm_start, diff_objects) + .zip(curr.previous(rhythm_start + 1, diff_objects)) + { + 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 mut curr_historical_decay = (f64::from(Self::HISTORY_TIME_MAX) + - (curr.start_time - curr_obj.start_time)) + / f64::from(Self::HISTORY_TIME_MAX); + + // * either we're limited by time or limited by object count. + curr_historical_decay = curr_historical_decay + .min((historical_note_count - i) as f64 / historical_note_count as f64); + + let curr_delta = curr_obj.strain_time; + let prev_delta = prev_obj.strain_time; + let last_delta = last_obj.strain_time; + + // * fancy function to calculate rhythmbonuses. + let base = (PI / (prev_delta.min(curr_delta) / prev_delta.max(curr_delta))).sin(); + let curr_ratio = 1.0 + 6.0 * base.powf(2.0).min(0.5); + + let hit_window = u64::from(!curr_obj.base.is_spinner()) as f64 * hit_window; + + let mut window_penalty = ((((prev_delta - curr_delta).abs() - hit_window * 0.3) + .max(0.0)) + / (hit_window * 0.3)) + .min(1.0); + + window_penalty = window_penalty.min(1.0); + + let mut effective_ratio = window_penalty * curr_ratio; + + if first_delta_switch { + // Keep in-sync with lazer + #[allow(clippy::if_not_else)] + if !(prev_delta > 1.25 * curr_delta || prev_delta * 1.25 < curr_delta) { + if island_size < 7 { + // * island is still progressing, count size. + island_size += 1; + } + } else { + // * bpm change is into slider, this is easy acc window + if curr_obj.base.is_slider() { + effective_ratio *= 0.125; + } + + // * bpm change was from a slider, this is easier typically than circle -> circle + if prev_obj.base.is_slider() { + effective_ratio *= 0.25; + } + + // * repeated island size (ex: triplet -> triplet) + if prev_island_size == island_size { + effective_ratio *= 0.25; + } + + // * repeated island polartiy (2 -> 4, 3 -> 5) + if prev_island_size % 2 == island_size % 2 { + effective_ratio *= 0.5; + } + + // * previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + if last_delta > prev_delta + 10.0 && prev_delta > curr_delta + 10.0 { + effective_ratio *= 0.125; + } + + rhythm_complexity_sum += (effective_ratio * start_ratio).sqrt() + * curr_historical_decay + * f64::from(4 + island_size).sqrt() + / 2.0 + * f64::from(4 + prev_island_size).sqrt() + / 2.0; + + start_ratio = effective_ratio; + + // * log the last island size. + prev_island_size = island_size; + + // * we're slowing down, stop counting + if prev_delta * 1.25 < curr_delta { + // * if we're speeding up, this stays true and we keep counting island size. + first_delta_switch = false; + } + + island_size = 1; } - - rhythm_complexity_sum += (effective_ratio * start_ratio).sqrt() - * curr_historical_decay - * f64::from(4 + island_size).sqrt() - / 2.0 - * f64::from(4 + prev_island_size).sqrt() - / 2.0; - + } else if prev_delta > 1.25 * curr_delta { + // * we want to be speeding up. + // * Begin counting island until we change speed again. + first_delta_switch = true; start_ratio = effective_ratio; - - // * log the last island size. - prev_island_size = island_size; - - // * we're slowing down, stop counting - if prev_delta * 1.25 < curr_delta { - // * if we're speeding up, this stays true and we keep counting island size. - first_delta_switch = false; - } - island_size = 1; } - } else if prev_delta > 1.25 * curr_delta { - // * we want to be speeding up. - // * Begin counting island until we change speed again. - first_delta_switch = true; - start_ratio = effective_ratio; - island_size = 1; + + last_obj = prev_obj; + prev_obj = curr_obj; } } diff --git a/src/osu/difficulty/skills/strain.rs b/src/osu/difficulty/skills/strain.rs index 48045494..6cfd2b07 100644 --- a/src/osu/difficulty/skills/strain.rs +++ b/src/osu/difficulty/skills/strain.rs @@ -8,7 +8,6 @@ pub struct OsuStrainSkill { impl OsuStrainSkill { pub const REDUCED_SECTION_COUNT: usize = 10; pub const REDUCED_STRAIN_BASELINE: f64 = 0.75; - pub const DIFFICULTY_MULTIPLER: f64 = 1.06; pub const DECAY_WEIGHT: f64 = 0.9; pub const SECTION_LEN: f64 = 400.0; @@ -30,7 +29,6 @@ impl OsuStrainSkill { reduced_section_count: usize, reduced_strain_baseline: f64, decay_weight: f64, - difficulty_multiplier: f64, ) -> f64 { let mut difficulty = 0.0; let mut weight = 1.0; @@ -52,7 +50,11 @@ impl OsuStrainSkill { weight *= decay_weight; } - difficulty * difficulty_multiplier + difficulty + } + + pub fn difficulty_to_performance(difficulty: f64) -> f64 { + (5.0 * (difficulty / 0.0675).max(1.0) - 4.0).powf(3.0) / 100_000.0 } } diff --git a/src/osu/object.rs b/src/osu/object.rs index dd48526f..a189b4c7 100644 --- a/src/osu/object.rs +++ b/src/osu/object.rs @@ -1,5 +1,10 @@ +use std::borrow::Cow; + use rosu_map::{ - section::hit_objects::{CurveBuffers, SliderEvent, SliderEventType, SliderEventsIter}, + section::{ + general::GameMode, + hit_objects::{CurveBuffers, SliderEvent, SliderEventType, SliderEventsIter}, + }, util::Pos, }; @@ -61,6 +66,8 @@ impl OsuObject { reflect_y(&mut self.pos.y); if let OsuObjectKind::Slider(ref mut slider) = self.kind { + let repeat_count = slider.repeat_count(); + // Requires `stack_offset` so we can't add `h.pos` just yet slider.lazy_end_pos.y = -slider.lazy_end_pos.y; @@ -81,6 +88,21 @@ impl OsuObject { reflect_y(&mut nested.pos.y); } + // Same for the last repeat point + for nested in nested_iter.by_ref().rev() { + if let NestedSliderObjectKind::Repeat = nested.kind { + nested.pos = if repeat_count % 2 == 0 { + self.pos + } else { + self.pos + Pos::new(slider.path_end_pos.x, -slider.path_end_pos.y) + }; + + break; + } + + reflect_y(&mut nested.pos.y); + } + for nested in nested_iter { reflect_y(&mut nested.pos.y); } @@ -123,12 +145,7 @@ impl OsuObject { pub fn lazy_travel_time(&self) -> f64 { match self.kind { OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => 0.0, - OsuObjectKind::Slider(ref slider) => slider - .nested_objects - // Here we really want the last nested object which is not - // necessarily the tail - .last() - .map_or(0.0, |nested| nested.start_time - self.start_time), + OsuObjectKind::Slider(ref slider) => slider.lazy_travel_time, } } @@ -155,6 +172,10 @@ pub struct OsuSlider { pub end_time: f64, pub lazy_end_pos: Pos, pub lazy_travel_dist: f32, + pub lazy_travel_time: f64, + // Very annoyingly, this position might be needed solely to update the last + // repeat point's position on HR. + pub path_end_pos: Pos, pub nested_objects: Vec, } @@ -182,13 +203,25 @@ impl OsuSlider { |point| (point.slider_velocity, point.generate_ticks), ); - let path = slider.curve(curve_bufs); + let path = slider.curve(GameMode::Osu, curve_bufs); let span_count = slider.span_count() as f64; - let scoring_dist = - f64::from(OsuObject::BASE_SCORING_DIST) * slider_multiplier * slider_velocity; - let velocity = scoring_dist / beat_len; + fn get_precision_adjusted_beat_len(slider_velocity_multiplier: f64, beat_len: f64) -> f64 { + let slider_velocity_as_beat_len = -100.0 / slider_velocity_multiplier; + + let bpm_multiplier = if slider_velocity_as_beat_len < 0.0 { + f64::from(((-slider_velocity_as_beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0 + } else { + 1.0 + }; + + beat_len * bpm_multiplier + } + + let velocity = f64::from(OsuObject::BASE_SCORING_DIST) * slider_multiplier + / get_precision_adjusted_beat_len(slider_velocity, beat_len); + let scoring_dist = velocity * beat_len; let end_time = start_time + span_count * path.dist() / velocity; @@ -244,12 +277,14 @@ impl OsuSlider { start_time: start_time + f64::from(e.span_idx + 1) * span_duration, kind: NestedSliderObjectKind::Repeat, }, - SliderEventType::LastTick => NestedSliderObject { - pos: end_path_pos, // no `h.pos` yet to keep order of float operations - start_time: e.time, - kind: NestedSliderObjectKind::Tail, - }, - SliderEventType::Head | SliderEventType::Tail => return None, + SliderEventType::Tail => { + NestedSliderObject { + pos: end_path_pos, // no `h.pos` yet to keep order of float operations + start_time: e.time, + kind: NestedSliderObjectKind::Tail, + } + } + SliderEventType::Head | SliderEventType::LastTick => return None, }; Some(obj) @@ -260,9 +295,8 @@ impl OsuSlider { a.start_time.total_cmp(&b.start_time) }); - let lazy_travel_time = nested_objects - .last() - .map_or(0.0, |nested| nested.start_time - h.start_time); + let mut nested = Cow::Borrowed(nested_objects.as_slice()); + let lazy_travel_time = OsuSlider::lazy_travel_time(start_time, duration, &mut nested); let mut end_time_min = lazy_travel_time / span_duration; @@ -273,15 +307,51 @@ impl OsuSlider { } let lazy_end_pos = path.position_at(end_time_min); + let path_end_pos = path.position_at(1.0); Self { end_time, lazy_end_pos, lazy_travel_dist: 0.0, + lazy_travel_time, + path_end_pos, nested_objects, } } + pub fn lazy_travel_time( + start_time: f64, + duration: f64, + nested_objects: &mut Cow<'_, [NestedSliderObject]>, + ) -> f64 { + const TAIL_LENIENCY: f64 = -36.0; + + let mut tracking_end_time = + (start_time + duration + TAIL_LENIENCY).max(start_time + duration / 2.0); + + let last_real_tick = nested_objects + .iter() + .enumerate() + .rfind(|(_, nested)| nested.is_tick()); + + if let Some((idx, last_real_tick)) = + last_real_tick.filter(|(_, tick)| tick.start_time > tracking_end_time) + { + tracking_end_time = last_real_tick.start_time; + + // * When the last tick falls after the tracking end time, we need to re-sort the nested objects + // * based on time. This creates a somewhat weird ordering which is counter to how a user would + // * understand the slider, but allows a zero-diff with known diffcalc output. + // * + // * To reiterate, this is definitely not correct from a difficulty calculation perspective + // * and should be revisited at a later date (likely by replacing this whole code with the commented + // * version above). + nested_objects.to_mut()[idx..].rotate_left(1); + } + + tracking_end_time - start_time + } + pub fn repeat_count(&self) -> usize { self.nested_objects .iter() @@ -306,6 +376,7 @@ impl OsuSlider { } } +#[derive(Clone, Debug)] pub struct NestedSliderObject { pub pos: Pos, pub start_time: f64, @@ -316,8 +387,13 @@ impl NestedSliderObject { pub const fn is_repeat(&self) -> bool { matches!(self.kind, NestedSliderObjectKind::Repeat) } + + pub const fn is_tick(&self) -> bool { + matches!(self.kind, NestedSliderObjectKind::Tick) + } } +#[derive(Copy, Clone, Debug)] pub enum NestedSliderObjectKind { Repeat, Tail, diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index 95ec37b9..499bfb66 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -13,6 +13,7 @@ use crate::{ use super::{ attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes}, + difficulty::skills::{flashlight::Flashlight, strain::OsuStrainSkill}, score_state::OsuScoreState, Osu, }; @@ -613,7 +614,7 @@ impl OsuPerformanceInner<'_> { } fn compute_aim_value(&self) -> f64 { - let mut aim_value = (5.0 * (self.attrs.aim / 0.0675).max(1.0) - 4.0).powf(3.0) / 100_000.0; + let mut aim_value = OsuStrainSkill::difficulty_to_performance(self.attrs.aim); let total_hits = self.total_hits(); @@ -652,7 +653,7 @@ impl OsuPerformanceInner<'_> { * (0.0016 / (1.0 + 2.0 * self.effective_miss_count)) * self.acc.powf(16.0)) * (1.0 - 0.003 * self.attrs.hp * self.attrs.hp); - } else if self.mods.hd() { + } else if self.mods.hd() || self.mods.tc() { // * We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. aim_value *= 1.0 + 0.04 * (12.0 - self.attrs.ar); } @@ -685,8 +686,7 @@ impl OsuPerformanceInner<'_> { return 0.0; } - let mut speed_value = - (5.0 * (self.attrs.speed / 0.0675).max(1.0) - 4.0).powf(3.0) / 100_000.0; + let mut speed_value = OsuStrainSkill::difficulty_to_performance(self.attrs.speed); let total_hits = self.total_hits(); @@ -719,7 +719,7 @@ impl OsuPerformanceInner<'_> { // * 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.hd() { + } else if self.mods.hd() || self.mods.tc() { // * We want to give more reward for lower AR when it comes to aim and HD. // * This nerfs high AR and buffs lower AR. speed_value *= 1.0 + 0.04 * (12.0 - self.attrs.ar); @@ -792,7 +792,7 @@ impl OsuPerformanceInner<'_> { // * ideal, so the minimum buff is given. if self.mods.bl() { acc_value *= 1.14; - } else if self.mods.hd() { + } else if self.mods.hd() || self.mods.tc() { acc_value *= 1.08; } @@ -808,7 +808,7 @@ impl OsuPerformanceInner<'_> { return 0.0; } - let mut flashlight_value = self.attrs.flashlight.powf(2.0) * 25.0; + let mut flashlight_value = Flashlight::difficulty_to_performance(self.attrs.flashlight); let total_hits = self.total_hits(); diff --git a/src/taiko/convert.rs b/src/taiko/convert.rs index f690c2f7..27106f36 100644 --- a/src/taiko/convert.rs +++ b/src/taiko/convert.rs @@ -131,7 +131,7 @@ fn should_convert_slider_to_taiko_hits(map: &Beatmap, params: &mut SliderParams< tick_spacing, } = params; - let curve = slider.curve(bufs); + let curve = slider.curve(GameMode::Taiko, bufs); // * The true distance, accounting for any repeats. This ends up being the drum roll distance later let spans = slider.span_count() as f64; From 64001a3f8fe16899fcf3dec9f3299b33b5d1da8e Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Sat, 28 Sep 2024 19:14:47 +0200 Subject: [PATCH 02/48] feat: updated taiko calc --- src/taiko/difficulty/color/mod.rs | 26 +++++ src/taiko/difficulty/color/mono_streak.rs | 4 + src/taiko/difficulty/color/preprocessor.rs | 28 ++--- src/taiko/difficulty/gradual.rs | 35 ++++-- src/taiko/difficulty/mod.rs | 119 +++++++++++++++------ src/taiko/difficulty/object.rs | 20 +++- src/taiko/difficulty/skills/color.rs | 21 +++- src/taiko/difficulty/skills/mod.rs | 19 +++- src/taiko/difficulty/skills/peaks.rs | 114 -------------------- src/taiko/difficulty/skills/stamina.rs | 39 +++++-- src/taiko/performance/mod.rs | 6 +- src/taiko/strains.rs | 6 +- src/util/sync.rs | 4 + 13 files changed, 249 insertions(+), 192 deletions(-) delete mode 100644 src/taiko/difficulty/skills/peaks.rs diff --git a/src/taiko/difficulty/color/mod.rs b/src/taiko/difficulty/color/mod.rs index 4b138187..348cbec9 100644 --- a/src/taiko/difficulty/color/mod.rs +++ b/src/taiko/difficulty/color/mod.rs @@ -5,6 +5,8 @@ use self::{ repeating_hit_patterns::RepeatingHitPatterns, }; +use super::object::{TaikoDifficultyObject, TaikoDifficultyObjects}; + pub mod alternating_mono_pattern; pub mod mono_streak; pub mod preprocessor; @@ -16,3 +18,27 @@ pub struct TaikoDifficultyColor { pub alternating_mono_pattern: Option>, pub repeating_hit_patterns: Option>, } + +impl TaikoDifficultyColor { + pub fn previous_color_change<'a>( + &self, + hit_objects: &'a TaikoDifficultyObjects, + ) -> Option<&'a RefCount> { + self.mono_streak + .as_ref() + .and_then(Weak::upgrade) + .and_then(|mono| mono.get().first_hit_object()) + .and_then(|h| hit_objects.previous_note(&h.get(), 0)) + } + + pub fn next_color_change<'a>( + &self, + hit_objects: &'a TaikoDifficultyObjects, + ) -> Option<&'a RefCount> { + self.mono_streak + .as_ref() + .and_then(Weak::upgrade) + .and_then(|mono| mono.get().last_hit_object()) + .and_then(|h| hit_objects.next_note(&h.get(), 0)) + } +} diff --git a/src/taiko/difficulty/color/mono_streak.rs b/src/taiko/difficulty/color/mono_streak.rs index 71f9798f..bf63f74f 100644 --- a/src/taiko/difficulty/color/mono_streak.rs +++ b/src/taiko/difficulty/color/mono_streak.rs @@ -35,4 +35,8 @@ impl MonoStreak { pub fn first_hit_object(&self) -> Option> { self.hit_objects.first().and_then(Weak::upgrade) } + + pub fn last_hit_object(&self) -> Option> { + self.hit_objects.last().and_then(Weak::upgrade) + } } diff --git a/src/taiko/difficulty/color/preprocessor.rs b/src/taiko/difficulty/color/preprocessor.rs index 4d7feb02..e2541243 100644 --- a/src/taiko/difficulty/color/preprocessor.rs +++ b/src/taiko/difficulty/color/preprocessor.rs @@ -2,7 +2,7 @@ use std::collections::VecDeque; use crate::{ taiko::difficulty::object::TaikoDifficultyObjects, - util::sync::{Ref, RefCount}, + util::sync::{Ref, RefCount, Weak}, }; use super::{ @@ -17,11 +17,6 @@ impl ColorDifficultyPreprocessor { let hit_patterns = Self::encode(hit_objects); for repeating_hit_pattern in hit_patterns { - if let Some(obj) = repeating_hit_pattern.get().first_hit_object() { - obj.get_mut().color.repeating_hit_patterns = - Some(RefCount::clone(&repeating_hit_pattern)); - } - let mono_patterns = Ref::map(repeating_hit_pattern.get(), |repeating| { repeating.alternating_mono_patterns.as_slice() }); @@ -33,11 +28,6 @@ impl ColorDifficultyPreprocessor { mono_pattern.idx = i; } - if let Some(obj) = mono_pattern.get().first_hit_object() { - obj.get_mut().color.alternating_mono_pattern = - Some(RefCount::downgrade(mono_pattern)); - } - let mono_streaks = Ref::map(mono_pattern.get(), |alternating| { alternating.mono_streaks.as_slice() }); @@ -49,9 +39,19 @@ impl ColorDifficultyPreprocessor { borrowed.idx = j; } - if let Some(obj) = mono_streak.get().first_hit_object() { - obj.get_mut().color.mono_streak = Some(RefCount::downgrade(mono_streak)); - }; + for hit_object in mono_streak + .get() + .hit_objects + .iter() + .filter_map(Weak::upgrade) + { + let mut borrowed = hit_object.get_mut(); + borrowed.color.repeating_hit_patterns = + Some(RefCount::clone(&repeating_hit_pattern)); + borrowed.color.alternating_mono_pattern = + Some(RefCount::downgrade(mono_pattern)); + borrowed.color.mono_streak = Some(RefCount::downgrade(mono_streak)); + } } } } diff --git a/src/taiko/difficulty/gradual.rs b/src/taiko/difficulty/gradual.rs index 9cff07d5..d71dde59 100644 --- a/src/taiko/difficulty/gradual.rs +++ b/src/taiko/difficulty/gradual.rs @@ -1,6 +1,7 @@ use std::{cmp, mem, slice::Iter}; use crate::{ + any::difficulty::skills::Skill, model::{beatmap::HitWindows, hit_object::HitObject}, taiko::TaikoBeatmap, util::sync::RefCount, @@ -8,8 +9,9 @@ use crate::{ }; use super::{ + combined_difficulty_value, object::{TaikoDifficultyObject, TaikoDifficultyObjects}, - skills::peaks::{Peaks, PeaksSkill}, + skills::TaikoSkills, DifficultyValues, TaikoDifficultyAttributes, }; @@ -53,7 +55,7 @@ pub struct TaikoGradualDifficulty { attrs: TaikoDifficultyAttributes, diff_objects: TaikoDifficultyObjects, diff_objects_iter: Iter<'static, RefCount>, - peaks: Peaks, + skills: TaikoSkills, total_hits: usize, first_combos: FirstTwoCombos, } @@ -96,7 +98,7 @@ impl TaikoGradualDifficulty { &mut n_diff_objects, ); - let peaks = Peaks::new(); + let skills = TaikoSkills::new(); let attrs = TaikoDifficultyAttributes { hit_window, @@ -117,7 +119,7 @@ impl TaikoGradualDifficulty { difficulty, diff_objects, diff_objects_iter, - peaks, + skills, attrs, total_hits, first_combos, @@ -144,7 +146,10 @@ impl Iterator for TaikoGradualDifficulty { loop { let curr = self.diff_objects_iter.next()?; let borrowed = curr.get(); - PeaksSkill::new(&mut self.peaks, &self.diff_objects).process(&borrowed); + + Skill::new(&mut self.skills.rhythm, &self.diff_objects).process(&borrowed); + Skill::new(&mut self.skills.color, &self.diff_objects).process(&borrowed); + Skill::new(&mut self.skills.stamina, &self.diff_objects).process(&borrowed); if borrowed.base_hit_type.is_hit() { self.attrs.max_combo += 1; @@ -166,10 +171,14 @@ impl Iterator for TaikoGradualDifficulty { self.idx += 1; - let color = self.peaks.color_difficulty_value(); - let rhythm = self.peaks.rhythm_difficulty_value(); - let stamina = self.peaks.stamina_difficulty_value(); - let combined = self.peaks.clone().difficulty_value(); + let color = self.skills.color.as_difficulty_value(); + let rhythm = self.skills.rhythm.as_difficulty_value(); + let stamina = self.skills.stamina.as_difficulty_value(); + let combined = combined_difficulty_value( + self.skills.color.clone(), + self.skills.rhythm.clone(), + self.skills.stamina.clone(), + ); let mut attrs = self.attrs.clone(); @@ -225,13 +234,17 @@ impl Iterator for TaikoGradualDifficulty { } } - let mut peaks = PeaksSkill::new(&mut self.peaks, &self.diff_objects); + let mut rhythm = Skill::new(&mut self.skills.rhythm, &self.diff_objects); + let mut color = Skill::new(&mut self.skills.color, &self.diff_objects); + let mut stamina = Skill::new(&mut self.skills.stamina, &self.diff_objects); for _ in 0..take { loop { let curr = self.diff_objects_iter.next()?; let borrowed = curr.get(); - peaks.process(&borrowed); + rhythm.process(&borrowed); + color.process(&borrowed); + stamina.process(&borrowed); if borrowed.base_hit_type.is_hit() { self.attrs.max_combo += 1; diff --git a/src/taiko/difficulty/mod.rs b/src/taiko/difficulty/mod.rs index d9a97759..930173b2 100644 --- a/src/taiko/difficulty/mod.rs +++ b/src/taiko/difficulty/mod.rs @@ -1,16 +1,18 @@ +use std::cmp; + use crate::{ + any::difficulty::skills::Skill, taiko::{ difficulty::{ color::preprocessor::ColorDifficultyPreprocessor, object::{TaikoDifficultyObject, TaikoDifficultyObjects}, - skills::peaks::PeaksSkill, }, object::TaikoObject, }, Difficulty, }; -use self::skills::peaks::Peaks; +use self::skills::{color::Color, rhythm::Rhythm, stamina::Stamina, TaikoSkills}; use super::{attributes::TaikoDifficultyAttributes, convert::TaikoBeatmap}; @@ -20,7 +22,10 @@ mod object; mod rhythm; mod skills; -const DIFFICULTY_MULTIPLIER: f64 = 1.35; +const DIFFICULTY_MULTIPLIER: f64 = 0.084_375; +const RHYTHM_SKILL_MULTIPLIER: f64 = 0.2 * DIFFICULTY_MULTIPLIER; +const COLOR_SKILL_MULTIPLIER: f64 = 0.375 * DIFFICULTY_MULTIPLIER; +const STAMINA_SKILL_MULTIPLIER: f64 = 0.375 * DIFFICULTY_MULTIPLIER; pub fn difficulty( difficulty: &Difficulty, @@ -32,7 +37,14 @@ pub fn difficulty( .hit_windows() .od; - let DifficultyValues { peaks, max_combo } = DifficultyValues::calculate(difficulty, converted); + let DifficultyValues { + skills: TaikoSkills { + rhythm, + color, + stamina, + }, + max_combo, + } = DifficultyValues::calculate(difficulty, converted); let mut attrs = TaikoDifficultyAttributes { hit_window, @@ -41,10 +53,10 @@ pub fn difficulty( ..Default::default() }; - let color_rating = peaks.color_difficulty_value(); - let rhythm_rating = peaks.rhythm_difficulty_value(); - let stamina_rating = peaks.stamina_difficulty_value(); - let combined_rating = peaks.difficulty_value(); + let color_rating = color.as_difficulty_value(); + let rhythm_rating = rhythm.as_difficulty_value(); + let stamina_rating = stamina.as_difficulty_value(); + let combined_rating = combined_difficulty_value(color, rhythm, stamina); DifficultyValues::eval( &mut attrs, @@ -57,6 +69,57 @@ pub fn difficulty( attrs } +fn combined_difficulty_value(color: Color, rhythm: Rhythm, stamina: Stamina) -> f64 { + fn norm(p: f64, values: [f64; 2]) -> f64 { + values + .into_iter() + .fold(0.0, |sum, x| sum + x.powf(p)) + .powf(p.recip()) + } + + let color_peaks = color.get_curr_strain_peaks(); + let rhythm_peaks = rhythm.get_curr_strain_peaks(); + let stamina_peaks = stamina.get_curr_strain_peaks(); + + let cap = cmp::min( + cmp::min(color_peaks.len(), rhythm_peaks.len()), + stamina_peaks.len(), + ); + let mut peaks = Vec::with_capacity(cap); + + let iter = color_peaks + .iter() + .zip(rhythm_peaks.iter()) + .zip(stamina_peaks.iter()); + + for ((mut color_peak, mut rhythm_peak), mut stamina_peak) in iter { + color_peak *= COLOR_SKILL_MULTIPLIER; + rhythm_peak *= RHYTHM_SKILL_MULTIPLIER; + stamina_peak *= STAMINA_SKILL_MULTIPLIER; + + let mut peak = norm(1.5, [color_peak, stamina_peak]); + peak = norm(2.0, [peak, rhythm_peak]); + + // * 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. + if peak > 0.0 { + peaks.push(peak); + } + } + + let mut difficulty = 0.0; + let mut weight = 1.0; + + peaks.sort_by(|a, b| b.total_cmp(a)); + + for strain in peaks { + difficulty += strain * weight; + weight *= 0.9; + } + + difficulty +} + fn rescale(stars: f64) -> f64 { if stars < 0.0 { stars @@ -66,7 +129,7 @@ fn rescale(stars: f64) -> f64 { } pub struct DifficultyValues { - pub peaks: Peaks, + pub skills: TaikoSkills, pub max_combo: u32, } @@ -89,17 +152,21 @@ impl DifficultyValues { // The first two hit objects have no difficulty object n_diff_objects = n_diff_objects.saturating_sub(2); - let mut peaks = Peaks::new(); + let mut skills = TaikoSkills::new(); { - let mut peaks = PeaksSkill::new(&mut peaks, &diff_objects); + let mut rhythm = Skill::new(&mut skills.rhythm, &diff_objects); + let mut color = Skill::new(&mut skills.color, &diff_objects); + let mut stamina = Skill::new(&mut skills.stamina, &diff_objects); for hit_object in diff_objects.iter().take(n_diff_objects) { - peaks.process(&hit_object.get()); + rhythm.process(&hit_object.get()); + color.process(&hit_object.get()); + stamina.process(&hit_object.get()); } } - Self { peaks, max_combo } + Self { skills, max_combo } } pub fn eval( @@ -107,26 +174,14 @@ impl DifficultyValues { color_difficulty_value: f64, rhythm_difficulty_value: f64, stamina_difficulty_value: f64, - peaks_difficulty_value: f64, + combined_difficulty_value: f64, ) { - let color_rating = color_difficulty_value * DIFFICULTY_MULTIPLIER; - let rhythm_rating = rhythm_difficulty_value * DIFFICULTY_MULTIPLIER; - let stamina_rating = stamina_difficulty_value * DIFFICULTY_MULTIPLIER; - let combined_rating = peaks_difficulty_value * DIFFICULTY_MULTIPLIER; - - let mut star_rating = rescale(combined_rating * 1.4); - - // * TODO: This is temporary measure as we don't detect abuse of multiple-input - // * playstyles of converts within the current system. - if attrs.is_convert { - star_rating *= 0.925; - - // * For maps with low colour variance and high stamina requirement, - // * multiple inputs are more likely to be abused. - if color_rating < 2.0 && stamina_rating > 8.0 { - star_rating *= 0.8; - } - } + let color_rating = color_difficulty_value * COLOR_SKILL_MULTIPLIER; + let rhythm_rating = rhythm_difficulty_value * RHYTHM_SKILL_MULTIPLIER; + let stamina_rating = stamina_difficulty_value * STAMINA_SKILL_MULTIPLIER; + let combined_rating = combined_difficulty_value; + + let star_rating = rescale(combined_rating * 1.4); attrs.stamina = stamina_rating; attrs.rhythm = rhythm_rating; diff --git a/src/taiko/difficulty/object.rs b/src/taiko/difficulty/object.rs index c639420c..33ffcbf8 100644 --- a/src/taiko/difficulty/object.rs +++ b/src/taiko/difficulty/object.rs @@ -132,15 +132,23 @@ impl TaikoDifficultyObjects { } } - pub fn previous_note( - &self, + pub fn previous_note<'a>( + &'a self, curr: &TaikoDifficultyObject, backwards_idx: usize, - ) -> Option<&RefCount> { + ) -> Option<&'a RefCount> { curr.note_idx .checked_sub(backwards_idx + 1) .and_then(|idx| self.note_objects.get(idx)) } + + pub fn next_note<'a>( + &'a self, + curr: &TaikoDifficultyObject, + forwards_idx: usize, + ) -> Option<&'a RefCount> { + self.note_objects.get(curr.note_idx + (forwards_idx + 1)) + } } #[rustfmt::skip] @@ -180,3 +188,9 @@ impl IDifficultyObject for TaikoDifficultyObject { self.idx } } + +impl PartialEq for TaikoDifficultyObject { + fn eq(&self, other: &Self) -> bool { + self.idx == other.idx + } +} diff --git a/src/taiko/difficulty/skills/color.rs b/src/taiko/difficulty/skills/color.rs index 4d4c52a0..087126f5 100644 --- a/src/taiko/difficulty/skills/color.rs +++ b/src/taiko/difficulty/skills/color.rs @@ -154,7 +154,11 @@ impl ColorEvaluator { let mut difficulty = 0.0; if let Some(mono_streak) = color.mono_streak.as_ref().and_then(Weak::upgrade) { - difficulty += Self::evaluate_diff_of_mono_streak(&mono_streak); + if let Some(first_hit_object) = mono_streak.get().first_hit_object() { + if &*first_hit_object.get() == hit_object { + difficulty += Self::evaluate_diff_of_mono_streak(&mono_streak); + } + } } if let Some(alternating_mono_pattern) = color @@ -162,12 +166,21 @@ impl ColorEvaluator { .as_ref() .and_then(Weak::upgrade) { - difficulty += - Self::evaluate_diff_of_alternating_mono_pattern(&alternating_mono_pattern); + if let Some(first_hit_object) = alternating_mono_pattern.get().first_hit_object() { + if &*first_hit_object.get() == hit_object { + difficulty += + Self::evaluate_diff_of_alternating_mono_pattern(&alternating_mono_pattern); + } + } } if let Some(repeating_hit_patterns) = color.repeating_hit_patterns.as_ref() { - difficulty += Self::evaluate_diff_of_repeating_hit_patterns(repeating_hit_patterns); + if let Some(first_hit_object) = repeating_hit_patterns.get().first_hit_object() { + if &*first_hit_object.get() == hit_object { + difficulty += + Self::evaluate_diff_of_repeating_hit_patterns(repeating_hit_patterns); + } + } } difficulty diff --git a/src/taiko/difficulty/skills/mod.rs b/src/taiko/difficulty/skills/mod.rs index 88971f32..0dc5a3f1 100644 --- a/src/taiko/difficulty/skills/mod.rs +++ b/src/taiko/difficulty/skills/mod.rs @@ -1,4 +1,21 @@ +use self::{color::Color, rhythm::Rhythm, stamina::Stamina}; + pub mod color; -pub mod peaks; pub mod rhythm; pub mod stamina; + +pub struct TaikoSkills { + pub rhythm: Rhythm, + pub color: Color, + pub stamina: Stamina, +} + +impl TaikoSkills { + pub fn new() -> Self { + Self { + rhythm: Rhythm::default(), + color: Color::default(), + stamina: Stamina::default(), + } + } +} diff --git a/src/taiko/difficulty/skills/peaks.rs b/src/taiko/difficulty/skills/peaks.rs deleted file mode 100644 index 3d8d900d..00000000 --- a/src/taiko/difficulty/skills/peaks.rs +++ /dev/null @@ -1,114 +0,0 @@ -use std::cmp; - -use crate::{ - any::difficulty::skills::Skill, - taiko::difficulty::object::{TaikoDifficultyObject, TaikoDifficultyObjects}, -}; - -use super::{color::Color, rhythm::Rhythm, stamina::Stamina}; - -const RHYTHM_SKILL_MULTIPLIER: f64 = 0.2 * FINAL_MULTIPLIER; -const COLOR_SKILL_MULTIPLIER: f64 = 0.375 * FINAL_MULTIPLIER; -const STAMINA_SKILL_MULTIPLIER: f64 = 0.375 * FINAL_MULTIPLIER; - -const FINAL_MULTIPLIER: f64 = 0.0625; - -#[derive(Clone)] -pub struct Peaks { - pub color: Color, - pub rhythm: Rhythm, - pub stamina: Stamina, -} - -impl Peaks { - pub fn new() -> Self { - Self { - color: Color::default(), - rhythm: Rhythm::default(), - stamina: Stamina::default(), - } - } - - pub fn color_difficulty_value(&self) -> f64 { - self.color.as_difficulty_value() * COLOR_SKILL_MULTIPLIER - } - - pub fn rhythm_difficulty_value(&self) -> f64 { - self.rhythm.as_difficulty_value() * RHYTHM_SKILL_MULTIPLIER - } - - pub fn stamina_difficulty_value(&self) -> f64 { - self.stamina.as_difficulty_value() * STAMINA_SKILL_MULTIPLIER - } - - fn norm(p: f64, values: impl IntoIterator) -> f64 { - values - .into_iter() - .fold(0.0, |sum, x| sum + x.powf(p)) - .powf(p.recip()) - } - - pub fn difficulty_value(self) -> f64 { - let color_peaks = self.color.get_curr_strain_peaks(); - let rhythm_peaks = self.rhythm.get_curr_strain_peaks(); - let stamina_peaks = self.stamina.get_curr_strain_peaks(); - - let cap = cmp::min( - cmp::min(color_peaks.len(), rhythm_peaks.len()), - stamina_peaks.len(), - ); - let mut peaks = Vec::with_capacity(cap); - - let zip = color_peaks - .iter() - .zip(rhythm_peaks.iter()) - .zip(stamina_peaks.iter()); - - for ((mut color_peak, mut rhythm_peak), mut stamina_peak) in zip { - color_peak *= COLOR_SKILL_MULTIPLIER; - rhythm_peak *= RHYTHM_SKILL_MULTIPLIER; - stamina_peak *= STAMINA_SKILL_MULTIPLIER; - - let mut peak = Self::norm(1.5, [color_peak, stamina_peak]); - peak = Self::norm(2.0, [peak, rhythm_peak]); - - if peak > 0.0 { - peaks.push(peak); - } - } - - let mut difficulty = 0.0; - let mut weight = 1.0; - - peaks.sort_by(|a, b| b.total_cmp(a)); - - for strain in peaks { - difficulty += strain * weight; - weight *= 0.9; - } - - difficulty - } -} - -pub struct PeaksSkill<'a> { - pub color: Skill<'a, Color>, - pub rhythm: Skill<'a, Rhythm>, - pub stamina: Skill<'a, Stamina>, -} - -impl<'a> PeaksSkill<'a> { - pub fn new(peaks: &'a mut Peaks, diff_objects: &'a TaikoDifficultyObjects) -> Self { - Self { - color: Skill::new(&mut peaks.color, diff_objects), - rhythm: Skill::new(&mut peaks.rhythm, diff_objects), - stamina: Skill::new(&mut peaks.stamina, diff_objects), - } - } - - pub fn process(&mut self, curr: &TaikoDifficultyObject) { - self.rhythm.process(curr); - self.color.process(curr); - self.stamina.process(curr); - } -} diff --git a/src/taiko/difficulty/skills/stamina.rs b/src/taiko/difficulty/skills/stamina.rs index 7b49c29d..acbc7ef7 100644 --- a/src/taiko/difficulty/skills/stamina.rs +++ b/src/taiko/difficulty/skills/stamina.rs @@ -100,28 +100,53 @@ struct StaminaEvaluator; impl StaminaEvaluator { fn speed_bonus(mut interval: f64) -> f64 { - // * Cap to 600bpm 1/4, 25ms note interval, 50ms key interval - // * Interval will be capped at a very small value to avoid infinite/negative speed bonuses. - // * TODO - This is a temporary measure as we need to implement methods of detecting playstyle-abuse of SpeedBonus. - interval = interval.max(50.0); + // * Interval is capped at a very small value to prevent infinite values. + interval = interval.max(1.0); 30.0 / interval } + fn available_fingers_for( + hit_object: &TaikoDifficultyObject, + hit_objects: &TaikoDifficultyObjects, + ) -> usize { + let prev_color_change = hit_object.color.previous_color_change(hit_objects); + + if prev_color_change + .is_some_and(|change| hit_object.start_time - change.get().start_time < 300.0) + { + return 2; + } + + let next_color_change = hit_object.color.next_color_change(hit_objects); + + if next_color_change + .is_some_and(|change| change.get().start_time - hit_object.start_time < 300.0) + { + return 2; + } + + 4 + } + fn evaluate_diff_of(curr: &TaikoDifficultyObject, hit_objects: &TaikoDifficultyObjects) -> f64 { if matches!(curr.base_hit_type, HitType::NonHit) { return 0.0; } - // * Find the previous hit object hit by the current key, which is two notes of the same colour prior. + // * Find the previous hit object hit by the current finger, which is n notes prior, n being the number of + // * available fingers. let taiko_curr = curr; - let key_prev = hit_objects.previous_mono(taiko_curr, 1); + let key_prev = hit_objects.previous_mono( + taiko_curr, + Self::available_fingers_for(taiko_curr, hit_objects) - 1, + ); if let Some(key_prev) = key_prev { // * Add a base strain to all objects 0.5 + Self::speed_bonus(taiko_curr.start_time - key_prev.get().start_time) } else { - // * There is no previous hit object hit by the current key + // * There is no previous hit object hit by the current finger 0.0 } } diff --git a/src/taiko/performance/mod.rs b/src/taiko/performance/mod.rs index 0c6fa508..4fea39d9 100644 --- a/src/taiko/performance/mod.rs +++ b/src/taiko/performance/mod.rs @@ -446,7 +446,7 @@ impl TaikoPerformanceInner<'_> { diff_value *= 0.985; } - if self.mods.hd() { + if self.mods.hd() && !self.attrs.is_convert { diff_value *= 1.025; } @@ -476,8 +476,8 @@ impl TaikoPerformanceInner<'_> { let len_bonus = (self.total_hits() / 1500.0).powf(0.3).min(1.15); acc_value *= len_bonus; - // * Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values - if self.mods.hd() && self.mods.fl() { + // * Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values. + if self.mods.hd() && self.mods.fl() && !self.attrs.is_convert { acc_value *= (1.075 * len_bonus).max(1.05); } diff --git a/src/taiko/strains.rs b/src/taiko/strains.rs index 4807bbb2..9dd2e287 100644 --- a/src/taiko/strains.rs +++ b/src/taiko/strains.rs @@ -24,8 +24,8 @@ pub fn strains(difficulty: &Difficulty, converted: &TaikoBeatmap<'_>) -> TaikoSt let values = DifficultyValues::calculate(difficulty, converted); TaikoStrains { - color: values.peaks.color.get_curr_strain_peaks().into_vec(), - rhythm: values.peaks.rhythm.get_curr_strain_peaks().into_vec(), - stamina: values.peaks.stamina.get_curr_strain_peaks().into_vec(), + color: values.skills.color.get_curr_strain_peaks().into_vec(), + rhythm: values.skills.rhythm.get_curr_strain_peaks().into_vec(), + stamina: values.skills.stamina.get_curr_strain_peaks().into_vec(), } } diff --git a/src/util/sync.rs b/src/util/sync.rs index ad5db40d..3aad15bd 100644 --- a/src/util/sync.rs +++ b/src/util/sync.rs @@ -6,8 +6,10 @@ pub use inner::*; mod inner { use std::{cell::RefCell, rc::Rc}; + #[repr(transparent)] pub struct RefCount(pub(super) Rc>); + #[repr(transparent)] pub struct Weak(pub(super) std::rc::Weak>); pub type Ref<'a, T> = std::cell::Ref<'a, T>; @@ -45,8 +47,10 @@ mod inner { sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, }; + #[repr(transparent)] pub struct RefCount(pub(super) Arc>); + #[repr(transparent)] pub struct Weak(pub(super) std::sync::Weak>); pub struct Ref<'a, T: ?Sized>(RwLockReadGuard<'a, T>); From a02b8a83235f90f7d7f3a0735d44597afdf30954 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Sat, 28 Sep 2024 19:46:11 +0200 Subject: [PATCH 03/48] refactor: cleanup taiko skill eval --- src/taiko/difficulty/gradual.rs | 12 +-------- src/taiko/difficulty/mod.rs | 39 ++++++------------------------ src/taiko/difficulty/skills/mod.rs | 1 + 3 files changed, 10 insertions(+), 42 deletions(-) diff --git a/src/taiko/difficulty/gradual.rs b/src/taiko/difficulty/gradual.rs index d71dde59..f89a8898 100644 --- a/src/taiko/difficulty/gradual.rs +++ b/src/taiko/difficulty/gradual.rs @@ -9,7 +9,6 @@ use crate::{ }; use super::{ - combined_difficulty_value, object::{TaikoDifficultyObject, TaikoDifficultyObjects}, skills::TaikoSkills, DifficultyValues, TaikoDifficultyAttributes, @@ -171,18 +170,9 @@ impl Iterator for TaikoGradualDifficulty { self.idx += 1; - let color = self.skills.color.as_difficulty_value(); - let rhythm = self.skills.rhythm.as_difficulty_value(); - let stamina = self.skills.stamina.as_difficulty_value(); - let combined = combined_difficulty_value( - self.skills.color.clone(), - self.skills.rhythm.clone(), - self.skills.stamina.clone(), - ); - let mut attrs = self.attrs.clone(); - DifficultyValues::eval(&mut attrs, color, rhythm, stamina, combined); + DifficultyValues::eval(&mut attrs, self.skills.clone()); Some(attrs) } diff --git a/src/taiko/difficulty/mod.rs b/src/taiko/difficulty/mod.rs index 930173b2..9e2b3f64 100644 --- a/src/taiko/difficulty/mod.rs +++ b/src/taiko/difficulty/mod.rs @@ -37,14 +37,7 @@ pub fn difficulty( .hit_windows() .od; - let DifficultyValues { - skills: TaikoSkills { - rhythm, - color, - stamina, - }, - max_combo, - } = DifficultyValues::calculate(difficulty, converted); + let DifficultyValues { skills, max_combo } = DifficultyValues::calculate(difficulty, converted); let mut attrs = TaikoDifficultyAttributes { hit_window, @@ -53,18 +46,7 @@ pub fn difficulty( ..Default::default() }; - let color_rating = color.as_difficulty_value(); - let rhythm_rating = rhythm.as_difficulty_value(); - let stamina_rating = stamina.as_difficulty_value(); - let combined_rating = combined_difficulty_value(color, rhythm, stamina); - - DifficultyValues::eval( - &mut attrs, - color_rating, - rhythm_rating, - stamina_rating, - combined_rating, - ); + DifficultyValues::eval(&mut attrs, skills); attrs } @@ -169,17 +151,12 @@ impl DifficultyValues { Self { skills, max_combo } } - pub fn eval( - attrs: &mut TaikoDifficultyAttributes, - color_difficulty_value: f64, - rhythm_difficulty_value: f64, - stamina_difficulty_value: f64, - combined_difficulty_value: f64, - ) { - let color_rating = color_difficulty_value * COLOR_SKILL_MULTIPLIER; - let rhythm_rating = rhythm_difficulty_value * RHYTHM_SKILL_MULTIPLIER; - let stamina_rating = stamina_difficulty_value * STAMINA_SKILL_MULTIPLIER; - let combined_rating = combined_difficulty_value; + pub fn eval(attrs: &mut TaikoDifficultyAttributes, skills: TaikoSkills) { + let color_rating = skills.color.as_difficulty_value() * COLOR_SKILL_MULTIPLIER; + let rhythm_rating = skills.rhythm.as_difficulty_value() * RHYTHM_SKILL_MULTIPLIER; + let stamina_rating = skills.stamina.as_difficulty_value() * STAMINA_SKILL_MULTIPLIER; + let combined_rating = + combined_difficulty_value(skills.color, skills.rhythm, skills.stamina); let star_rating = rescale(combined_rating * 1.4); diff --git a/src/taiko/difficulty/skills/mod.rs b/src/taiko/difficulty/skills/mod.rs index 0dc5a3f1..bf032a84 100644 --- a/src/taiko/difficulty/skills/mod.rs +++ b/src/taiko/difficulty/skills/mod.rs @@ -4,6 +4,7 @@ pub mod color; pub mod rhythm; pub mod stamina; +#[derive(Clone)] pub struct TaikoSkills { pub rhythm: Rhythm, pub color: Color, From c7b5414a8ab570bc9b62a9b62b2dacf40ec2c166 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Sat, 28 Sep 2024 19:57:02 +0200 Subject: [PATCH 04/48] feat: update mania calc --- src/mania/difficulty/gradual.rs | 4 ++-- src/mania/difficulty/mod.rs | 4 ++-- src/mania/difficulty/skills/strain.rs | 11 ++++++----- src/mania/performance/mod.rs | 6 ++---- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/mania/difficulty/gradual.rs b/src/mania/difficulty/gradual.rs index 6af40416..669b27ca 100644 --- a/src/mania/difficulty/gradual.rs +++ b/src/mania/difficulty/gradual.rs @@ -9,7 +9,7 @@ use crate::{ use super::{ object::ManiaDifficultyObject, skills::strain::Strain, DifficultyValues, - ManiaDifficultyAttributes, ManiaObject, STAR_SCALING_FACTOR, + ManiaDifficultyAttributes, ManiaObject, DIFFICULTY_MULTIPLIER, }; /// Gradually calculate the difficulty attributes of an osu!mania map. @@ -136,7 +136,7 @@ impl Iterator for ManiaGradualDifficulty { self.idx += 1; Some(ManiaDifficultyAttributes { - stars: self.strain.as_difficulty_value() * STAR_SCALING_FACTOR, + stars: self.strain.as_difficulty_value() * DIFFICULTY_MULTIPLIER, hit_window: self.hit_window, max_combo: self.curr_combo, n_objects: self.idx as u32, diff --git a/src/mania/difficulty/mod.rs b/src/mania/difficulty/mod.rs index ad66ec3d..c04eee2c 100644 --- a/src/mania/difficulty/mod.rs +++ b/src/mania/difficulty/mod.rs @@ -14,7 +14,7 @@ pub mod gradual; mod object; mod skills; -const STAR_SCALING_FACTOR: f64 = 0.018; +const DIFFICULTY_MULTIPLIER: f64 = 0.018; pub fn difficulty( difficulty: &Difficulty, @@ -31,7 +31,7 @@ pub fn difficulty( .od; ManiaDifficultyAttributes { - stars: values.strain.difficulty_value() * STAR_SCALING_FACTOR, + stars: values.strain.difficulty_value() * DIFFICULTY_MULTIPLIER, hit_window, max_combo: values.max_combo, n_objects, diff --git a/src/mania/difficulty/skills/strain.rs b/src/mania/difficulty/skills/strain.rs index ab4a139d..5ec995ca 100644 --- a/src/mania/difficulty/skills/strain.rs +++ b/src/mania/difficulty/skills/strain.rs @@ -9,7 +9,7 @@ use crate::{ const INDIVIDUAL_DECAY_BASE: f64 = 0.125; const OVERALL_DECAY_BASE: f64 = 0.3; -const RELEASE_THRESHOLD: f64 = 24.0; +const RELEASE_THRESHOLD: f64 = 30.0; const SKILL_MULTIPLIER: f64 = 1.0; const STRAIN_DECAY_BASE: f64 = 1.0; @@ -87,11 +87,12 @@ impl Strain { for i in 0..self.end_times.len() { // * The current note is overlapped if a previous note or end is overlapping the current note body - is_overlapping |= - self.end_times[i] > start_time + 1.0 && end_time > self.end_times[i] + 1.0; + is_overlapping |= self.end_times[i] > start_time + 1.0 + && end_time > self.end_times[i] + 1.0 + && start_time > self.start_times[i] + 1.0; // * We give a slight bonus to everything if something is held meanwhile - if self.end_times[i] > end_time + 1.0 { + if self.end_times[i] > end_time + 1.0 && start_time > self.start_times[i] + 1.0 { hold_factor = 1.25; } @@ -109,7 +110,7 @@ impl Strain { // * 0.0 +--------+-+---------------> Release Difference / ms // * release_threshold if is_overlapping { - hold_addition = (1.0 + (0.5 * (RELEASE_THRESHOLD - closest_end_time)).exp()).recip(); + hold_addition = (1.0 + (0.27 * (RELEASE_THRESHOLD - closest_end_time)).exp()).recip(); } // * Decay and increase individualStrains in own column diff --git a/src/mania/performance/mod.rs b/src/mania/performance/mod.rs index 9400e1b2..d4d87273 100644 --- a/src/mania/performance/mod.rs +++ b/src/mania/performance/mod.rs @@ -863,9 +863,7 @@ struct ManiaPerformanceInner<'mods> { impl ManiaPerformanceInner<'_> { fn calculate(self) -> ManiaPerformanceAttributes { - // * Arbitrary initial value for scaling pp in order to standardize distributions across game modes. - // * The specific number has no intrinsic meaning and can be adjusted as needed. - let mut multiplier = 8.0; + let mut multiplier = 1.0; if self.mods.nf() { multiplier *= 0.75; @@ -887,7 +885,7 @@ impl ManiaPerformanceInner<'_> { fn compute_difficulty_value(&self) -> f64 { // * Star rating to pp curve - (self.attrs.stars - 0.15).max(0.05).powf(2.2) + 8.0 * (self.attrs.stars - 0.15).max(0.05).powf(2.2) // * From 80% accuracy, 1/20th of total pp is awarded per additional 1% accuracy * (5.0 * self.calculate_custom_accuracy() - 4.0).max(0.0) // * Length bonus, capped at 1500 notes From cc0a041a517dcaffdf74c10290e83064781d4737 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Sun, 29 Sep 2024 14:41:04 +0200 Subject: [PATCH 05/48] feat: update catch calc --- src/catch/difficulty/mod.rs | 4 ++-- src/catch/difficulty/skills/movement.rs | 2 +- src/catch/performance/mod.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/catch/difficulty/mod.rs b/src/catch/difficulty/mod.rs index 36fc6950..b811b36e 100644 --- a/src/catch/difficulty/mod.rs +++ b/src/catch/difficulty/mod.rs @@ -18,7 +18,7 @@ pub mod gradual; mod object; mod skills; -const STAR_SCALING_FACTOR: f64 = 0.153; +const DIFFICULTY_MULTIPLIER: f64 = 4.59; pub fn difficulty( difficulty: &Difficulty, @@ -96,7 +96,7 @@ impl DifficultyValues { } pub fn eval(attrs: &mut CatchDifficultyAttributes, movement_difficulty_value: f64) { - attrs.stars = movement_difficulty_value.sqrt() * STAR_SCALING_FACTOR; + attrs.stars = movement_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER; } pub fn create_difficulty_objects<'a>( diff --git a/src/catch/difficulty/skills/movement.rs b/src/catch/difficulty/skills/movement.rs index 4300abd2..e35fa604 100644 --- a/src/catch/difficulty/skills/movement.rs +++ b/src/catch/difficulty/skills/movement.rs @@ -11,7 +11,7 @@ const ABSOLUTE_PLAYER_POSITIONING_ERROR: f32 = 16.0; const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0; const DIRECTION_CHANGE_BONUS: f64 = 21.0; -const SKILL_MULTIPLIER: f64 = 900.0; +const SKILL_MULTIPLIER: f64 = 1.0; const STRAIN_DECAY_BASE: f64 = 0.2; const DECAY_WEIGHT: f64 = 0.94; diff --git a/src/catch/performance/mod.rs b/src/catch/performance/mod.rs index 7b9620a7..69cb7426 100644 --- a/src/catch/performance/mod.rs +++ b/src/catch/performance/mod.rs @@ -569,7 +569,7 @@ impl CatchPerformanceInner<'_> { // NF penalty if self.mods.nf() { - pp *= 0.9; + pp *= (1.0 - 0.02 * f64::from(self.state.misses)).max(0.9); } CatchPerformanceAttributes { From 092cf34eb464e2eea21fafaafc4d922ba469bf7b Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Mon, 30 Sep 2024 23:53:00 +0200 Subject: [PATCH 06/48] fix: adjust remaining catch pieces --- src/catch/catcher.rs | 2 +- src/catch/convert.rs | 16 +++++++++++----- src/catch/object/juice_stream.rs | 31 +++++++++++++++++++++++++------ 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/catch/catcher.rs b/src/catch/catcher.rs index 42fc1dcb..a552c59b 100644 --- a/src/catch/catcher.rs +++ b/src/catch/catcher.rs @@ -15,6 +15,6 @@ impl Catcher { } fn calculate_scale(cs: f32) -> f32 { - 1.0 - 0.7 * (cs - 5.0) / 5.0 + ((1.0 - 0.7 * ((f64::from(cs) - 5.0) / 5.0)) as f32 / 2.0 * 1.0) * 2.0 } } diff --git a/src/catch/convert.rs b/src/catch/convert.rs index a1a64eca..04f1dd63 100644 --- a/src/catch/convert.rs +++ b/src/catch/convert.rs @@ -232,11 +232,14 @@ fn apply_hr_offset( ) { let mut offset_pos = x; - let Some(last_pos) = last_pos else { - *last_pos = Some(offset_pos); - *last_start_time = start_time; + let last_pos = match last_pos { + Some(pos) if pos.abs() >= f32::EPSILON => pos, + Some(_) | None => { + *last_pos = Some(offset_pos); + *last_start_time = start_time; - return; + return; + } }; let pos_diff = offset_pos - *last_pos; @@ -310,7 +313,10 @@ fn initialize_hyper_dash(cs: f32, palpable_objects: &mut [PalpableObject]) { -1 }; - let time_to_next = next.start_time - curr.start_time - f64::from(1000.0_f32 / 60.0 / 4.0); + // * Int truncation added to match osu!stable. + let time_to_next = f64::from( + (next.start_time as i32 - curr.start_time as i32) as f32 - 1000.0 / 60.0 / 4.0, + ); let dist_to_next = f64::from((next.effective_x() - curr.effective_x()).abs()) - if last_dir == this_dir { diff --git a/src/catch/object/juice_stream.rs b/src/catch/object/juice_stream.rs index c6ef2990..7944417d 100644 --- a/src/catch/object/juice_stream.rs +++ b/src/catch/object/juice_stream.rs @@ -44,12 +44,29 @@ impl<'a> JuiceStream<'a> { let path = slider.curve(GameMode::Catch, &mut bufs.curve); - let velocity_factor = JuiceStream::BASE_SCORING_DIST * slider_multiplier / beat_len; - let velocity = velocity_factor * slider_velocity; - let tick_dist_factor = - JuiceStream::BASE_SCORING_DIST * slider_multiplier / slider_tick_rate; + fn get_precision_adjusted_beat_len(slider_velocity_multiplier: f64, beat_len: f64) -> f64 { + let slider_velocity_as_beat_len = -100.0 / slider_velocity_multiplier; - let tick_dist = tick_dist_factor * slider_velocity; + let bpm_multiplier = if slider_velocity_as_beat_len < 0.0 { + f64::from(((-slider_velocity_as_beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0 + } else { + 1.0 + }; + + beat_len * bpm_multiplier + } + + let velocity = JuiceStream::BASE_SCORING_DIST * slider_multiplier + / get_precision_adjusted_beat_len(slider_velocity, beat_len); + let scoring_dist = velocity * beat_len; + + let tick_dist_multiplier = if converted.version < 8 { + slider_velocity.recip() + } else { + 1.0 + }; + + let tick_dist = scoring_dist / slider_tick_rate * tick_dist_multiplier; let span_count = slider.span_count() as f64; let duration = span_count * path.dist() / velocity; @@ -70,7 +87,7 @@ impl<'a> JuiceStream<'a> { for e in events { if let Some(last_event_time) = last_event_time { let mut tiny_droplets = 0; - let since_last_tick = e.time - last_event_time; + let since_last_tick = f64::from(e.time as i32 - last_event_time as i32); if since_last_tick > 80.0 { let mut time_between_tiny = since_last_tick; @@ -135,12 +152,14 @@ impl<'a> JuiceStream<'a> { } } +#[derive(Debug)] pub struct NestedJuiceStreamObject { pub pos: f32, pub start_time: f64, pub kind: NestedJuiceStreamObjectKind, } +#[derive(Debug)] pub enum NestedJuiceStreamObjectKind { Fruit, Droplet, From f9dfa5e35c3c2efc36b3b41470477d6739cd40fa Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Mon, 30 Sep 2024 23:58:21 +0200 Subject: [PATCH 07/48] chore: made clippy happy --- src/any/performance/mod.rs | 4 ++-- src/catch/object/juice_stream.rs | 24 +++++++++---------- src/catch/performance/mod.rs | 2 +- src/mania/performance/mod.rs | 2 +- src/osu/difficulty/skills/speed.rs | 2 +- src/osu/object.rs | 38 ++++++++++++++---------------- src/osu/performance/mod.rs | 2 +- src/taiko/performance/mod.rs | 2 +- 8 files changed, 37 insertions(+), 39 deletions(-) diff --git a/src/any/performance/mod.rs b/src/any/performance/mod.rs index 5ef1e869..b51b24a6 100644 --- a/src/any/performance/mod.rs +++ b/src/any/performance/mod.rs @@ -27,8 +27,8 @@ impl<'map> Performance<'map> { /// /// The argument `map_or_attrs` must be either /// - previously calculated attributes ([`DifficultyAttributes`], - /// [`PerformanceAttributes`], or mode-specific attributes like - /// [`TaikoDifficultyAttributes`], [`ManiaPerformanceAttributes`], ...) + /// [`PerformanceAttributes`], or mode-specific attributes like + /// [`TaikoDifficultyAttributes`], [`ManiaPerformanceAttributes`], ...) /// - a beatmap ([`Beatmap`] or [`Converted<'_, M>`]) /// /// If a map is given, difficulty attributes will need to be calculated diff --git a/src/catch/object/juice_stream.rs b/src/catch/object/juice_stream.rs index 7944417d..f709fa6c 100644 --- a/src/catch/object/juice_stream.rs +++ b/src/catch/object/juice_stream.rs @@ -44,18 +44,6 @@ impl<'a> JuiceStream<'a> { let path = slider.curve(GameMode::Catch, &mut bufs.curve); - fn get_precision_adjusted_beat_len(slider_velocity_multiplier: f64, beat_len: f64) -> f64 { - let slider_velocity_as_beat_len = -100.0 / slider_velocity_multiplier; - - let bpm_multiplier = if slider_velocity_as_beat_len < 0.0 { - f64::from(((-slider_velocity_as_beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0 - } else { - 1.0 - }; - - beat_len * bpm_multiplier - } - let velocity = JuiceStream::BASE_SCORING_DIST * slider_multiplier / get_precision_adjusted_beat_len(slider_velocity, beat_len); let scoring_dist = velocity * beat_len; @@ -171,3 +159,15 @@ pub struct JuiceStreamBufs { pub curve: CurveBuffers, pub ticks: Vec, } + +fn get_precision_adjusted_beat_len(slider_velocity_multiplier: f64, beat_len: f64) -> f64 { + let slider_velocity_as_beat_len = -100.0 / slider_velocity_multiplier; + + let bpm_multiplier = if slider_velocity_as_beat_len < 0.0 { + f64::from(((-slider_velocity_as_beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0 + } else { + 1.0 + }; + + beat_len * bpm_multiplier +} diff --git a/src/catch/performance/mod.rs b/src/catch/performance/mod.rs index 69cb7426..5c939dfc 100644 --- a/src/catch/performance/mod.rs +++ b/src/catch/performance/mod.rs @@ -36,7 +36,7 @@ impl<'map> CatchPerformance<'map> { /// /// The argument `map_or_attrs` must be either /// - previously calculated attributes ([`CatchDifficultyAttributes`] - /// or [`CatchPerformanceAttributes`]) + /// or [`CatchPerformanceAttributes`]) /// - a beatmap ([`CatchBeatmap<'map>`]) /// /// If a map is given, difficulty attributes will need to be calculated diff --git a/src/mania/performance/mod.rs b/src/mania/performance/mod.rs index d4d87273..4d097e39 100644 --- a/src/mania/performance/mod.rs +++ b/src/mania/performance/mod.rs @@ -37,7 +37,7 @@ impl<'map> ManiaPerformance<'map> { /// /// The argument `map_or_attrs` must be either /// - previously calculated attributes ([`ManiaDifficultyAttributes`] - /// or [`ManiaPerformanceAttributes`]) + /// or [`ManiaPerformanceAttributes`]) /// - a beatmap ([`ManiaBeatmap<'map>`]) /// /// If a map is given, difficulty attributes will need to be calculated diff --git a/src/osu/difficulty/skills/speed.rs b/src/osu/difficulty/skills/speed.rs index 030b00bc..2643bad8 100644 --- a/src/osu/difficulty/skills/speed.rs +++ b/src/osu/difficulty/skills/speed.rs @@ -196,7 +196,7 @@ impl SpeedEvaluator { let difficulty = speed_bonus * dist_bonus * 1000.0 / strain_time; // * Apply penalty if there's doubletappable doubles - return difficulty * doubletapness; + difficulty * doubletapness } } diff --git a/src/osu/object.rs b/src/osu/object.rs index a189b4c7..526fc5d4 100644 --- a/src/osu/object.rs +++ b/src/osu/object.rs @@ -142,7 +142,7 @@ impl OsuObject { self.end_pos() + self.stack_offset } - pub fn lazy_travel_time(&self) -> f64 { + pub const fn lazy_travel_time(&self) -> f64 { match self.kind { OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => 0.0, OsuObjectKind::Slider(ref slider) => slider.lazy_travel_time, @@ -207,18 +207,6 @@ impl OsuSlider { let span_count = slider.span_count() as f64; - fn get_precision_adjusted_beat_len(slider_velocity_multiplier: f64, beat_len: f64) -> f64 { - let slider_velocity_as_beat_len = -100.0 / slider_velocity_multiplier; - - let bpm_multiplier = if slider_velocity_as_beat_len < 0.0 { - f64::from(((-slider_velocity_as_beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0 - } else { - 1.0 - }; - - beat_len * bpm_multiplier - } - let velocity = f64::from(OsuObject::BASE_SCORING_DIST) * slider_multiplier / get_precision_adjusted_beat_len(slider_velocity, beat_len); let scoring_dist = velocity * beat_len; @@ -277,13 +265,11 @@ impl OsuSlider { start_time: start_time + f64::from(e.span_idx + 1) * span_duration, kind: NestedSliderObjectKind::Repeat, }, - SliderEventType::Tail => { - NestedSliderObject { - pos: end_path_pos, // no `h.pos` yet to keep order of float operations - start_time: e.time, - kind: NestedSliderObjectKind::Tail, - } - } + SliderEventType::Tail => NestedSliderObject { + pos: end_path_pos, // no `h.pos` yet to keep order of float operations + start_time: e.time, + kind: NestedSliderObjectKind::Tail, + }, SliderEventType::Head | SliderEventType::LastTick => return None, }; @@ -399,3 +385,15 @@ pub enum NestedSliderObjectKind { Tail, Tick, } + +fn get_precision_adjusted_beat_len(slider_velocity_multiplier: f64, beat_len: f64) -> f64 { + let slider_velocity_as_beat_len = -100.0 / slider_velocity_multiplier; + + let bpm_multiplier = if slider_velocity_as_beat_len < 0.0 { + f64::from(((-slider_velocity_as_beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0 + } else { + 1.0 + }; + + beat_len * bpm_multiplier +} diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index 499bfb66..a094aec6 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -40,7 +40,7 @@ impl<'map> OsuPerformance<'map> { /// /// The argument `map_or_attrs` must be either /// - previously calculated attributes ([`OsuDifficultyAttributes`] - /// or [`OsuPerformanceAttributes`]) + /// or [`OsuPerformanceAttributes`]) /// - a beatmap ([`OsuBeatmap<'map>`]) /// /// If a map is given, difficulty attributes will need to be calculated diff --git a/src/taiko/performance/mod.rs b/src/taiko/performance/mod.rs index 4fea39d9..aebe7086 100644 --- a/src/taiko/performance/mod.rs +++ b/src/taiko/performance/mod.rs @@ -35,7 +35,7 @@ impl<'map> TaikoPerformance<'map> { /// /// The argument `map_or_attrs` must be either /// - previously calculated attributes ([`TaikoDifficultyAttributes`] - /// or [`TaikoPerformanceAttributes`]) + /// or [`TaikoPerformanceAttributes`]) /// - a beatmap ([`TaikoBeatmap<'map>`]) /// /// If a map is given, difficulty attributes will need to be calculated From 5bdb2babed9181aa7891c725b2f0a02de9264fe4 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Thu, 10 Oct 2024 16:18:18 +0200 Subject: [PATCH 08/48] Port osu!std updates since f08134f --- src/any/performance/mod.rs | 17 ++ src/catch/performance/mod.rs | 1 + src/mania/performance/mod.rs | 1 + src/model/mods.rs | 20 ++ src/osu/attributes.rs | 4 + src/osu/convert.rs | 13 +- src/osu/difficulty/mod.rs | 23 ++- src/osu/difficulty/object.rs | 20 +- src/osu/difficulty/scaling_factor.rs | 4 +- src/osu/difficulty/skills/aim.rs | 13 +- src/osu/difficulty/skills/speed.rs | 271 +++++++++++++++++++-------- src/osu/difficulty/skills/strain.rs | 68 ++++++- src/osu/object.rs | 63 +------ src/osu/performance/mod.rs | 63 +++++-- src/taiko/performance/mod.rs | 1 + tests/difficulty.rs | 16 ++ 16 files changed, 419 insertions(+), 179 deletions(-) diff --git a/src/any/performance/mod.rs b/src/any/performance/mod.rs index b51b24a6..36e48352 100644 --- a/src/any/performance/mod.rs +++ b/src/any/performance/mod.rs @@ -299,6 +299,23 @@ impl<'map> Performance<'map> { } } + /// Whether the calculated attributes belong to an osu!lazer or osu!stable + /// score. + /// + /// Defaults to lazer. + /// + /// This affects internal accuracy calculation because lazer considers + /// slider heads for accuracy whereas stable does not. + /// + /// Only relevant for osu!standard. + pub fn lazer(self, lazer: bool) -> Self { + if let Self::Osu(osu) = self { + Self::Osu(osu.lazer(lazer)) + } else { + self + } + } + /// Specify the amount of 300s of a play. pub fn n300(self, n300: u32) -> Self { match self { diff --git a/src/catch/performance/mod.rs b/src/catch/performance/mod.rs index 5c939dfc..d8149ddf 100644 --- a/src/catch/performance/mod.rs +++ b/src/catch/performance/mod.rs @@ -479,6 +479,7 @@ impl<'map> TryFrom> for CatchPerformance<'map> { n50, misses, hitresult_priority: _, + lazer: _, } = osu; Ok(Self { diff --git a/src/mania/performance/mod.rs b/src/mania/performance/mod.rs index 4d097e39..5f66023d 100644 --- a/src/mania/performance/mod.rs +++ b/src/mania/performance/mod.rs @@ -832,6 +832,7 @@ impl<'map> TryFrom> for ManiaPerformance<'map> { n50, misses, hitresult_priority, + lazer: _, } = osu; Ok(Self { diff --git a/src/model/mods.rs b/src/model/mods.rs index 16370343..321a3060 100644 --- a/src/model/mods.rs +++ b/src/model/mods.rs @@ -178,6 +178,26 @@ impl_has_mod! { tc: - Traceable ["Traceable"], } +impl GameMods { + pub fn no_slider_head_acc(&self, lazer: bool) -> bool { + match self.inner { + GameModsInner::Lazer(ref mods) => mods + .iter() + .find_map(|m| match m { + GameMod::ClassicOsu(classic) => Some(classic), + _ => None, + }) + .map_or(!lazer, |classic| { + classic.no_slider_head_accuracy.unwrap_or(true) + }), + GameModsInner::Intermode(ref mods) => { + mods.contains(GameModIntermode::Classic) || !lazer + } + GameModsInner::Legacy(_) => !lazer, + } + } +} + impl Default for GameMods { fn default() -> Self { Self::DEFAULT diff --git a/src/osu/attributes.rs b/src/osu/attributes.rs index c968d8e2..68715cf5 100644 --- a/src/osu/attributes.rs +++ b/src/osu/attributes.rs @@ -13,6 +13,10 @@ pub struct OsuDifficultyAttributes { pub slider_factor: f64, /// The number of clickable objects weighted by difficulty. pub speed_note_count: f64, + /// Weighted sum of aim strains. + pub aim_difficult_strain_count: f64, + /// Weighted sum of speed strains. + pub speed_difficult_strain_count: f64, /// The approach rate. pub ar: f64, /// The overall difficulty diff --git a/src/osu/convert.rs b/src/osu/convert.rs index 4d13b160..19de59d8 100644 --- a/src/osu/convert.rs +++ b/src/osu/convert.rs @@ -67,7 +67,7 @@ pub fn convert_objects( .iter_mut() .for_each(OsuObject::reflect_vertically); } else { - osu_objects.iter_mut().for_each(OsuObject::finalize_tail); + osu_objects.iter_mut().for_each(OsuObject::finalize_nested); } let stack_threshold = time_preempt * f64::from(converted.stack_leniency); @@ -246,13 +246,20 @@ fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) { break; } + // * Note the use of `StartTime` in the code below doesn't match stable's use of `EndTime`. + // * This is because in the stable implementation, `UpdateCalculations` is not called on the inner-loop hitobject (j) + // * and therefore it does not have a correct `EndTime`, but instead the default of `EndTime = StartTime`. + // * + // * Effects of this can be seen on https://osu.ppy.sh/beatmapsets/243#osu/1146 at sliders around 86647 ms, where + // * if we use `EndTime` here it would result in unexpected stacking. + if hit_objects[j].pos.distance(hit_objects[i].pos) < STACK_DISTANCE { hit_objects[i].stack_height += 1; - start_time = hit_objects[j].end_time(); + start_time = hit_objects[j].start_time; } else if hit_objects[j].pos.distance(pos2) < STACK_DISTANCE { slider_stack += 1; hit_objects[j].stack_height -= slider_stack; - start_time = hit_objects[j].end_time(); + start_time = hit_objects[j].start_time; } } } diff --git a/src/osu/difficulty/mod.rs b/src/osu/difficulty/mod.rs index c038d6f8..0b5ca3ee 100644 --- a/src/osu/difficulty/mod.rs +++ b/src/osu/difficulty/mod.rs @@ -1,6 +1,9 @@ use std::{cmp, pin::Pin}; -use skills::{flashlight::Flashlight, strain::OsuStrainSkill}; +use skills::{ + flashlight::Flashlight, + strain::{DifficultyValue, OsuStrainSkill, UsedOsuStrainSkills}, +}; use crate::{ any::difficulty::{skills::Skill, Difficulty}, @@ -148,15 +151,16 @@ impl DifficultyValues { pub fn eval( attrs: &mut OsuDifficultyAttributes, mods: &GameMods, - aim_difficulty_value: f64, - aim_no_sliders_difficulty_value: f64, - speed_difficulty_value: f64, + aim: UsedOsuStrainSkills, + aim_no_sliders: UsedOsuStrainSkills, + speed: UsedOsuStrainSkills, speed_relevant_note_count: f64, flashlight_difficulty_value: f64, ) { - let mut aim_rating = aim_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER; - let aim_rating_no_sliders = aim_no_sliders_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER; - let mut speed_rating = speed_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER; + let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER; + let aim_rating_no_sliders = + aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER; + let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER; let mut flashlight_rating = flashlight_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER; let slider_factor = if aim_rating > 0.0 { @@ -165,6 +169,9 @@ impl DifficultyValues { 1.0 }; + let aim_difficult_strain_count = aim.count_difficult_strains(); + let speed_difficult_strain_count = speed.count_difficult_strains(); + if mods.td() { aim_rating = aim_rating.powf(0.8); flashlight_rating = flashlight_rating.powf(0.8); @@ -202,6 +209,8 @@ impl DifficultyValues { attrs.speed = speed_rating; attrs.flashlight = flashlight_rating; attrs.slider_factor = 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; } diff --git a/src/osu/difficulty/object.rs b/src/osu/difficulty/object.rs index 5557e86c..a5c6eae0 100644 --- a/src/osu/difficulty/object.rs +++ b/src/osu/difficulty/object.rs @@ -27,7 +27,7 @@ pub struct OsuDifficultyObject<'a> { impl<'a> OsuDifficultyObject<'a> { pub const NORMALIZED_RADIUS: f32 = 50.0; - const MIN_DELTA_TIME: f64 = 25.0; + pub const MIN_DELTA_TIME: f64 = 25.0; const MAX_SLIDER_RADIUS: f32 = Self::NORMALIZED_RADIUS * 2.4; const ASSUMED_SLIDER_RADIUS: f32 = Self::NORMALIZED_RADIUS * 1.8; @@ -86,6 +86,24 @@ impl<'a> OsuDifficultyObject<'a> { } } + pub fn get_doubletapness(&self, next: Option<&Self>, hit_window: f64) -> f64 { + let Some(next) = next else { return 0.0 }; + + let hit_window = if self.base.is_spinner() { + 0.0 + } else { + hit_window + }; + + 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); + + 1.0 - (speed_ratio).powf(1.0 - window_ratio) + } + fn set_distances( &mut self, last_object: &OsuObject, diff --git a/src/osu/difficulty/scaling_factor.rs b/src/osu/difficulty/scaling_factor.rs index 0b99bdc8..854ee871 100644 --- a/src/osu/difficulty/scaling_factor.rs +++ b/src/osu/difficulty/scaling_factor.rs @@ -19,8 +19,8 @@ pub struct ScalingFactor { impl ScalingFactor { pub fn new(cs: f64) -> Self { - let scale = - (1.0 - 0.7 * ((cs - 5.0) / 5.0)) as f32 / 2.0 * BROKEN_GAMEFIELD_ROUNDING_ALLOWANCE; + let scale = (f64::from(1.0_f32) - f64::from(0.7_f32) * ((cs - 5.0) / 5.0)) as f32 / 2.0 + * BROKEN_GAMEFIELD_ROUNDING_ALLOWANCE; let radius = f64::from(OsuObject::OBJECT_RADIUS * scale); let factor = OsuDifficultyObject::NORMALIZED_RADIUS / radius as f32; diff --git a/src/osu/difficulty/skills/aim.rs b/src/osu/difficulty/skills/aim.rs index 0aefd1c8..4b4e9e69 100644 --- a/src/osu/difficulty/skills/aim.rs +++ b/src/osu/difficulty/skills/aim.rs @@ -9,9 +9,9 @@ use crate::{ util::{float_ext::FloatExt, strains_vec::StrainsVec}, }; -use super::strain::OsuStrainSkill; +use super::strain::{DifficultyValue, OsuStrainSkill, UsedOsuStrainSkills}; -const SKILL_MULTIPLIER: f64 = 24.963; +const SKILL_MULTIPLIER: f64 = 25.18; const STRAIN_DECAY_BASE: f64 = 0.15; #[derive(Clone)] @@ -31,20 +31,20 @@ impl Aim { } pub fn get_curr_strain_peaks(self) -> StrainsVec { - self.inner.get_curr_strain_peaks() + self.inner.get_curr_strain_peaks().strains() } - pub fn difficulty_value(self) -> f64 { + pub fn difficulty_value(self) -> UsedOsuStrainSkills { Self::static_difficulty_value(self.inner) } /// Use [`difficulty_value`] instead whenever possible because /// [`as_difficulty_value`] clones internally. - pub fn as_difficulty_value(&self) -> f64 { + pub fn as_difficulty_value(&self) -> UsedOsuStrainSkills { Self::static_difficulty_value(self.inner.clone()) } - fn static_difficulty_value(skill: OsuStrainSkill) -> f64 { + fn static_difficulty_value(skill: OsuStrainSkill) -> UsedOsuStrainSkills { skill.difficulty_value( OsuStrainSkill::REDUCED_SECTION_COUNT, OsuStrainSkill::REDUCED_STRAIN_BASELINE, @@ -104,6 +104,7 @@ impl<'a> Skill<'a, Aim> { self.inner.curr_strain += AimEvaluator::evaluate_diff_of(curr, self.diff_objects, self.inner.with_sliders) * SKILL_MULTIPLIER; + self.inner.inner.object_strains.push(self.inner.curr_strain); self.inner.curr_strain } diff --git a/src/osu/difficulty/skills/speed.rs b/src/osu/difficulty/skills/speed.rs index 2643bad8..ab27c7b7 100644 --- a/src/osu/difficulty/skills/speed.rs +++ b/src/osu/difficulty/skills/speed.rs @@ -1,4 +1,7 @@ -use std::{cmp, f64::consts::PI}; +use std::{ + cmp, + f64::consts::{E, PI}, +}; use crate::{ any::difficulty::{ @@ -9,7 +12,7 @@ use crate::{ util::strains_vec::StrainsVec, }; -use super::strain::OsuStrainSkill; +use super::strain::{DifficultyValue, OsuStrainSkill, UsedOsuStrainSkills}; const SKILL_MULTIPLIER: f64 = 1.430; const STRAIN_DECAY_BASE: f64 = 0.3; @@ -20,7 +23,6 @@ const REDUCED_SECTION_COUNT: usize = 5; pub struct Speed { curr_strain: f64, curr_rhythm: f64, - object_strains: Vec, hit_window: f64, inner: OsuStrainSkill, } @@ -30,28 +32,26 @@ impl Speed { Self { curr_strain: 0.0, curr_rhythm: 0.0, - // mean=406.72 | median=307 - object_strains: Vec::with_capacity(256), hit_window, inner: OsuStrainSkill::default(), } } pub fn get_curr_strain_peaks(self) -> StrainsVec { - self.inner.get_curr_strain_peaks() + self.inner.get_curr_strain_peaks().strains() } - pub fn difficulty_value(self) -> f64 { + pub fn difficulty_value(self) -> UsedOsuStrainSkills { Self::static_difficulty_value(self.inner) } /// Use [`difficulty_value`] instead whenever possible because /// [`as_difficulty_value`] clones internally. - pub fn as_difficulty_value(&self) -> f64 { + pub fn as_difficulty_value(&self) -> UsedOsuStrainSkills { Self::static_difficulty_value(self.inner.clone()) } - fn static_difficulty_value(skill: OsuStrainSkill) -> f64 { + fn static_difficulty_value(skill: OsuStrainSkill) -> UsedOsuStrainSkills { skill.difficulty_value( REDUCED_SECTION_COUNT, OsuStrainSkill::REDUCED_STRAIN_BASELINE, @@ -60,13 +60,14 @@ impl Speed { } pub fn relevant_note_count(&self) -> f64 { - self.object_strains + self.inner + .object_strains .iter() .copied() .max_by(f64::total_cmp) .filter(|&n| n > 0.0) .map_or(0.0, |max_strain| { - self.object_strains.iter().fold(0.0, |sum, strain| { + self.inner.object_strains.iter().fold(0.0, |sum, strain| { sum + (1.0 + (-(strain / max_strain * 12.0 - 6.0)).exp()).recip() }) }) @@ -129,7 +130,7 @@ impl<'a> Skill<'a, Speed> { RhythmEvaluator::evaluate_diff_of(curr, self.diff_objects, self.inner.hit_window); let total_strain = self.inner.curr_strain * self.inner.curr_rhythm; - self.inner.object_strains.push(total_strain); + self.inner.inner.object_strains.push(total_strain); total_strain } @@ -140,7 +141,8 @@ struct SpeedEvaluator; impl SpeedEvaluator { const SINGLE_SPACING_THRESHOLD: f64 = 125.0; // 1.25 circlers distance between centers const MIN_SPEED_BONUS: f64 = 75.0; // ~200BPM - const SPEED_BALANCING_FACTOR: f64 = 40.; + const SPEED_BALANCING_FACTOR: f64 = 40.0; + const DIST_MULTIPLIER: f64 = 0.94; fn evaluate_diff_of<'a>( curr: &'a OsuDifficultyObject<'a>, @@ -157,17 +159,10 @@ impl SpeedEvaluator { let osu_next_obj = curr.next(0, diff_objects); let mut strain_time = curr.strain_time; - let mut doubletapness = 1.0; - - // * Nerf doubletappable doubles. - if let Some(osu_next_obj) = osu_next_obj { - let curr_delta_time = osu_curr_obj.delta_time.max(1.0); - let next_delta_time = osu_next_obj.delta_time.max(1.0); - let delta_diff = (next_delta_time - curr_delta_time).abs(); - let speed_ratio = curr_delta_time / curr_delta_time.max(delta_diff); - let window_ratio = (curr_delta_time / hit_window).min(1.0).powf(2.0); - doubletapness = speed_ratio.powf(1.0 - window_ratio); - } + // 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. @@ -177,10 +172,10 @@ impl SpeedEvaluator { // * Add additional scaling bonus for streams/bursts higher than 200bpm let base = (Self::MIN_SPEED_BONUS - strain_time) / Self::SPEED_BALANCING_FACTOR; - 1.0 + 0.75 * base.powf(2.0) + 0.75 * base.powf(2.0) } else { - // * speedBonus will be 1.0 for BPM < 200 - 1.0 + // * speedBonus will be 0.0 for BPM < 200 + 0.0 }; let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.travel_dist); @@ -189,11 +184,11 @@ impl SpeedEvaluator { // * Cap distance at single_spacing_threshold dist = Self::SINGLE_SPACING_THRESHOLD.min(dist); - // * Max distance bonus is 2 at single_spacing_threshold - let dist_bonus = 1.0 + (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.5); + // * Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold + let dist_bonus = (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.95) * Self::DIST_MULTIPLIER; // * Base difficulty with all bonuses - let difficulty = speed_bonus * dist_bonus * 1000.0 / strain_time; + let difficulty = (1.0 + speed_bonus + dist_bonus) * 1000.0 / strain_time; // * Apply penalty if there's doubletappable doubles difficulty * doubletapness @@ -203,9 +198,10 @@ impl SpeedEvaluator { struct RhythmEvaluator; impl RhythmEvaluator { - // * 5 seconds of calculatingRhythmBonus max. - const HISTORY_TIME_MAX: u32 = 5000; - const RHYTHM_MULTIPLIER: f64 = 0.75; + 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_MULTIPLIER: f64 = 12.0; fn evaluate_diff_of<'a>( curr: &'a OsuDifficultyObject<'a>, @@ -216,16 +212,23 @@ impl RhythmEvaluator { return 0.0; } - let mut prev_island_size = 0; - let mut rhythm_complexity_sum = 0.0; - let mut island_size = 1; + + 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, 32); + let historical_note_count = cmp::min(curr.idx, Self::HISTORY_OBJECTS_MAX); let mut rhythm_start = 0; @@ -244,47 +247,50 @@ impl RhythmEvaluator { .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 mut curr_historical_decay = (f64::from(Self::HISTORY_TIME_MAX) + 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. - curr_historical_decay = curr_historical_decay - .min((historical_note_count - i) as f64 / historical_note_count as f64); + let curr_historical_decay = note_decay.min(time_decay); let curr_delta = curr_obj.strain_time; let prev_delta = prev_obj.strain_time; let last_delta = last_obj.strain_time; - // * fancy function to calculate rhythmbonuses. - let base = (PI / (prev_delta.min(curr_delta) / prev_delta.max(curr_delta))).sin(); - let curr_ratio = 1.0 + 6.0 * base.powf(2.0).min(0.5); - - let hit_window = u64::from(!curr_obj.base.is_spinner()) as f64 * hit_window; - - let mut window_penalty = ((((prev_delta - curr_delta).abs() - hit_window * 0.3) - .max(0.0)) - / (hit_window * 0.3)) + // * 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.min(curr_delta) / prev_delta.max(curr_delta); + let curr_ratio = 1.0 + + Self::RHYTHM_RATIO_MULTIPLIER + * (PI / delta_difference_ratio).sin().powf(2.0).min(0.5); + + // reduce ratio bonus if delta difference is too big + let fraction = (prev_delta / curr_delta).max(curr_delta / prev_delta); + let fraction_multiplier = (2.0 - fraction / 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); - window_penalty = window_penalty.min(1.0); - - let mut effective_ratio = window_penalty * curr_ratio; + let mut effective_ratio = window_penalty * curr_ratio * fraction_multiplier; if first_delta_switch { // Keep in-sync with lazer #[allow(clippy::if_not_else)] - if !(prev_delta > 1.25 * curr_delta || prev_delta * 1.25 < curr_delta) { - if island_size < 7 { - // * island is still progressing, count size. - island_size += 1; - } + 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() { @@ -292,51 +298,86 @@ impl RhythmEvaluator { } // * 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.25; - } - - // * repeated island size (ex: triplet -> triplet) - if prev_island_size == island_size { - effective_ratio *= 0.25; + effective_ratio *= 0.3; } - // * repeated island polartiy (2 -> 4, 3 -> 5) - if prev_island_size % 2 == island_size % 2 { + // * 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 + 10.0 && prev_delta > curr_delta + 10.0 { + if last_delta > prev_delta + delta_difference_eps + && prev_delta > curr_delta + delta_difference_eps + { effective_ratio *= 0.125; } - rhythm_complexity_sum += (effective_ratio * start_ratio).sqrt() - * curr_historical_decay - * f64::from(4 + island_size).sqrt() - / 2.0 - * f64::from(4 + prev_island_size).sqrt() - / 2.0; + // * 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), 2.75, 0.24, 14.0); + 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; - // * log the last island size. - prev_island_size = island_size; + prev_island = island; // * we're slowing down, stop counting - if prev_delta * 1.25 < curr_delta { - // * if we're speeding up, this stays true and we keep counting island size. + 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_size = 1; + island = + RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps); } - } else if prev_delta > 1.25 * curr_delta { - // * we want to be speeding up. + } 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_size = 1; + + island = RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps); } last_obj = prev_obj; @@ -345,6 +386,74 @@ impl RhythmEvaluator { } // * produces multiplier that can be applied to strain. range [1, infinity) (not really though) - (4.0 + rhythm_complexity_sum * Self::RHYTHM_MULTIPLIER).sqrt() / 2.0 + (4.0 + rhythm_complexity_sum * Self::RHYTHM_OVERALL_MULTIPLIER).sqrt() / 2.0 } } + +fn logistic(x: f64, max_value: f64, multiplier: f64, offset: f64) -> f64 { + max_value / (1.0 + E.powf(offset - (multiplier * x))) +} + +#[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 { + 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: delta.max(MIN_DELTA_TIME), + delta_count: 1, + } + } + + fn add_delta(&mut self, delta: i32) { + if self.delta == i32::MAX { + self.delta = delta.max(MIN_DELTA_TIME); + } + + self.delta_count += 1; + } + + 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/skills/strain.rs b/src/osu/difficulty/skills/strain.rs index 6cfd2b07..f563fbd5 100644 --- a/src/osu/difficulty/skills/strain.rs +++ b/src/osu/difficulty/skills/strain.rs @@ -1,10 +1,21 @@ use crate::{any::difficulty::skills::StrainSkill, util::strains_vec::StrainsVec}; -#[derive(Clone, Default)] +#[derive(Clone)] pub struct OsuStrainSkill { + pub object_strains: Vec, pub inner: StrainSkill, } +impl Default for OsuStrainSkill { + fn default() -> Self { + Self { + // mean=406.72 | median=307 + object_strains: Vec::with_capacity(256), + inner: Default::default(), + } + } +} + impl OsuStrainSkill { pub const REDUCED_SECTION_COUNT: usize = 10; pub const REDUCED_STRAIN_BASELINE: f64 = 0.75; @@ -20,8 +31,11 @@ impl OsuStrainSkill { self.inner.start_new_section_from(initial_strain); } - pub fn get_curr_strain_peaks(self) -> StrainsVec { - self.inner.get_curr_strain_peaks() + pub fn get_curr_strain_peaks(self) -> UsedOsuStrainSkills { + UsedOsuStrainSkills { + value: self.inner.get_curr_strain_peaks(), + object_strains: self.object_strains, + } } pub fn difficulty_value( @@ -29,11 +43,14 @@ impl OsuStrainSkill { reduced_section_count: usize, reduced_strain_baseline: f64, decay_weight: f64, - ) -> f64 { + ) -> UsedOsuStrainSkills { let mut difficulty = 0.0; let mut weight = 1.0; - let mut peaks = self.get_curr_strain_peaks(); + let UsedOsuStrainSkills { + value: mut peaks, + object_strains, + } = self.get_curr_strain_peaks(); let peaks_iter = peaks.sorted_non_zero_iter_mut().take(reduced_section_count); @@ -50,7 +67,10 @@ impl OsuStrainSkill { weight *= decay_weight; } - difficulty + UsedOsuStrainSkills { + value: DifficultyValue(difficulty), + object_strains, + } } pub fn difficulty_to_performance(difficulty: f64) -> f64 { @@ -61,3 +81,39 @@ impl OsuStrainSkill { fn lerp(start: f64, end: f64, amount: f64) -> f64 { start + (end - start) * amount } + +pub struct DifficultyValue(f64); + +pub struct UsedOsuStrainSkills { + value: T, + object_strains: Vec, +} + +impl UsedOsuStrainSkills { + pub fn difficulty_value(&self) -> f64 { + self.value.0 + } + + pub fn count_difficult_strains(&self) -> f64 { + let DifficultyValue(diff) = self.value; + + if diff.abs() < f64::EPSILON { + return 0.0; + } + + // * What would the top strain be if all strain values were identical + let consistent_top_strain = diff / 10.0; + + // * Use a weighted sum of all strains. Constants are arbitrary and give nice values + self.object_strains + .iter() + .map(|s| 1.1 / (1.0 + (-10.0 * (s / consistent_top_strain - 0.88)).exp())) + .sum() + } +} + +impl UsedOsuStrainSkills { + pub fn strains(self) -> StrainsVec { + self.value + } +} diff --git a/src/osu/object.rs b/src/osu/object.rs index 526fc5d4..2dfac359 100644 --- a/src/osu/object.rs +++ b/src/osu/object.rs @@ -66,53 +66,21 @@ impl OsuObject { reflect_y(&mut self.pos.y); if let OsuObjectKind::Slider(ref mut slider) = self.kind { - let repeat_count = slider.repeat_count(); - // Requires `stack_offset` so we can't add `h.pos` just yet slider.lazy_end_pos.y = -slider.lazy_end_pos.y; - let mut nested_iter = slider.nested_objects.iter_mut(); - - // Since the tail is handled differently but it's not necessarily - // the last object, we first search for it, and then handle the - // other nested objects - for nested in nested_iter.by_ref().rev() { - if let NestedSliderObjectKind::Tail = nested.kind { - let mut tail_pos = self.pos; // already reflected at this point - tail_pos += Pos::new(nested.pos.x, -nested.pos.y); - nested.pos = tail_pos; - - break; - } - - reflect_y(&mut nested.pos.y); - } - - // Same for the last repeat point - for nested in nested_iter.by_ref().rev() { - if let NestedSliderObjectKind::Repeat = nested.kind { - nested.pos = if repeat_count % 2 == 0 { - self.pos - } else { - self.pos + Pos::new(slider.path_end_pos.x, -slider.path_end_pos.y) - }; - - break; - } - - reflect_y(&mut nested.pos.y); - } - - for nested in nested_iter { - reflect_y(&mut nested.pos.y); + for nested in slider.nested_objects.iter_mut() { + let mut nested_pos = self.pos; // already reflected at this point + nested_pos += Pos::new(nested.pos.x, -nested.pos.y); + nested.pos = nested_pos; } } } - pub fn finalize_tail(&mut self) { + pub fn finalize_nested(&mut self) { if let OsuObjectKind::Slider(ref mut slider) = self.kind { - if let Some(tail) = slider.tail_mut() { - tail.pos += self.pos; + for nested in slider.nested_objects.iter_mut() { + nested.pos += self.pos; } } } @@ -173,9 +141,6 @@ pub struct OsuSlider { pub lazy_end_pos: Pos, pub lazy_travel_dist: f32, pub lazy_travel_time: f64, - // Very annoyingly, this position might be needed solely to update the last - // repeat point's position on HR. - pub path_end_pos: Pos, pub nested_objects: Vec, } @@ -256,12 +221,12 @@ impl OsuSlider { .filter_map(|e| { let obj = match e.kind { SliderEventType::Tick => NestedSliderObject { - pos: h.pos + path.position_at(e.path_progress), + pos: path.position_at(e.path_progress), start_time: e.time, kind: NestedSliderObjectKind::Tick, }, SliderEventType::Repeat => NestedSliderObject { - pos: h.pos + path.position_at(e.path_progress), + pos: path.position_at(e.path_progress), start_time: start_time + f64::from(e.span_idx + 1) * span_duration, kind: NestedSliderObjectKind::Repeat, }, @@ -293,14 +258,12 @@ impl OsuSlider { } let lazy_end_pos = path.position_at(end_time_min); - let path_end_pos = path.position_at(1.0); Self { end_time, lazy_end_pos, lazy_travel_dist: 0.0, lazy_travel_time, - path_end_pos, nested_objects, } } @@ -352,14 +315,6 @@ impl OsuSlider { // short and fast buzz sliders (/b/1001757) .rfind(|nested| matches!(nested.kind, NestedSliderObjectKind::Tail)) } - - fn tail_mut(&mut self) -> Option<&mut NestedSliderObject> { - self.nested_objects - .iter_mut() - // The tail is not necessarily the last nested object, e.g. on very - // short and fast buzz sliders (/b/1001757) - .rfind(|nested| matches!(nested.kind, NestedSliderObjectKind::Tail)) - } } #[derive(Clone, Debug)] diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index a094aec6..6b3d20ff 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -33,6 +33,7 @@ pub struct OsuPerformance<'map> { pub(crate) n50: Option, pub(crate) misses: Option, pub(crate) hitresult_priority: HitResultPriority, + pub(crate) lazer: Option, } impl<'map> OsuPerformance<'map> { @@ -152,6 +153,19 @@ impl<'map> OsuPerformance<'map> { self } + /// Whether the calculated attributes belong to an osu!lazer or osu!stable + /// score. + /// + /// Defaults to lazer. + /// + /// This affects internal accuracy calculation because lazer considers + /// slider heads for accuracy whereas stable does not. + pub const fn lazer(mut self, lazer: bool) -> Self { + self.lazer = Some(lazer); + + self + } + /// Specify the amount of 300s of a play. pub const fn n300(mut self, n300: u32) -> Self { self.n300 = Some(n300); @@ -509,6 +523,7 @@ impl<'map> OsuPerformance<'map> { acc: state.accuracy(), state, effective_miss_count, + lazer: self.lazer.unwrap_or(true), }; inner.calculate() @@ -525,6 +540,7 @@ impl<'map> OsuPerformance<'map> { n50: None, misses: None, hitresult_priority: HitResultPriority::DEFAULT, + lazer: None, } } } @@ -535,7 +551,8 @@ impl<'map, T: IntoModePerformance<'map, Osu>> From for OsuPerformance<'map> { } } -pub const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14; +// * 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.15; struct OsuPerformanceInner<'mods> { attrs: OsuDifficultyAttributes, @@ -543,10 +560,13 @@ struct OsuPerformanceInner<'mods> { acc: f64, state: OsuScoreState, effective_miss_count: f64, + lazer: bool, } impl OsuPerformanceInner<'_> { fn calculate(mut self) -> OsuPerformanceAttributes { + let using_classic_slider_acc = self.mods.no_slider_head_acc(self.lazer); + let total_hits = self.state.total_hits(); if total_hits == 0 { @@ -592,7 +612,7 @@ impl OsuPerformanceInner<'_> { let aim_value = self.compute_aim_value(); let speed_value = self.compute_speed_value(); - let acc_value = self.compute_accuracy_value(); + let acc_value = self.compute_accuracy_value(using_classic_slider_acc); let flashlight_value = self.compute_flashlight_value(); let pp = (aim_value.powf(1.1) @@ -624,16 +644,13 @@ impl OsuPerformanceInner<'_> { aim_value *= len_bonus; - // * Penalize misses by assessing # of misses relative to the total # of objects. - // * Default a 3% reduction for any # of misses. if self.effective_miss_count > 0.0 { - aim_value *= 0.97 - * (1.0 - (self.effective_miss_count / total_hits).powf(0.775)) - .powf(self.effective_miss_count); + aim_value *= self.calculate_miss_penalty( + self.effective_miss_count, + self.attrs.aim_difficult_strain_count, + ); } - aim_value *= self.get_combo_scaling_factor(); - let ar_factor = if self.mods.rx() { 0.0 } else if self.attrs.ar > 10.33 { @@ -696,16 +713,13 @@ impl OsuPerformanceInner<'_> { speed_value *= len_bonus; - // * Penalize misses by assessing # of misses relative to the total # of objects. - // * Default a 3% reduction for any # of misses. if self.effective_miss_count > 0.0 { - speed_value *= 0.97 - * (1.0 - (self.effective_miss_count / total_hits).powf(0.775)) - .powf(self.effective_miss_count.powf(0.875)); + speed_value *= self.calculate_miss_penalty( + self.effective_miss_count, + self.attrs.speed_difficult_strain_count, + ); } - speed_value *= self.get_combo_scaling_factor(); - let ar_factor = if self.attrs.ar > 10.33 { 0.3 * (self.attrs.ar - 10.33) } else { @@ -744,7 +758,7 @@ impl OsuPerformanceInner<'_> { // * Scale the speed value with accuracy and OD. speed_value *= (0.95 + self.attrs.od * self.attrs.od / 750.0) - * ((self.acc + relevant_acc) / 2.0).powf((14.5 - (self.attrs.od).max(8.0)) / 2.0); + * ((self.acc + relevant_acc) / 2.0).powf((14.5 - self.attrs.od) / 2.0); // * Scale the speed value with # of 50s to punish doubletapping. speed_value *= 0.99_f64.powf( @@ -755,14 +769,18 @@ impl OsuPerformanceInner<'_> { speed_value } - fn compute_accuracy_value(&self) -> f64 { + fn compute_accuracy_value(&self, using_classic_slider_acc: bool) -> f64 { if self.mods.rx() { return 0.0; } // * This percentage only considers HitCircles of any value - in this part // * of the calculation we focus on hitting the timing hit window. - let amount_hit_objects_with_acc = self.attrs.n_circles; + let mut amount_hit_objects_with_acc = self.attrs.n_circles; + + if using_classic_slider_acc { + amount_hit_objects_with_acc += self.attrs.n_sliders; + } let better_acc_percentage = if amount_hit_objects_with_acc > 0 { let sub = self.state.total_hits() - amount_hit_objects_with_acc; @@ -836,6 +854,13 @@ impl OsuPerformanceInner<'_> { flashlight_value } + // * 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(&self, miss_count: f64, diff_strain_count: f64) -> f64 { + 0.96 / ((miss_count / (4.0 * diff_strain_count.ln().powf(0.94))) + 1.0) + } + fn get_combo_scaling_factor(&self) -> f64 { if self.attrs.max_combo == 0 { 1.0 diff --git a/src/taiko/performance/mod.rs b/src/taiko/performance/mod.rs index aebe7086..b9b8ab88 100644 --- a/src/taiko/performance/mod.rs +++ b/src/taiko/performance/mod.rs @@ -369,6 +369,7 @@ impl<'map> TryFrom> for TaikoPerformance<'map> { n50: _, misses, hitresult_priority, + lazer: _, } = osu; Ok(Self { diff --git a/tests/difficulty.rs b/tests/difficulty.rs index 6c607b09..c8b7837a 100644 --- a/tests/difficulty.rs +++ b/tests/difficulty.rs @@ -35,6 +35,8 @@ macro_rules! test_cases { flashlight: $flashlight:literal, slider_factor: $slider_factor:literal, speed_note_count: $speed_note_count:literal, + aim_difficult_strain_count: $aim_difficult_strain_count:literal, + speed_difficult_strain_count: $speed_difficult_strain_count:literal, ar: $ar:literal, od: $od:literal, hp: $hp:literal, @@ -50,6 +52,8 @@ macro_rules! test_cases { flashlight: $flashlight, slider_factor: $slider_factor, speed_note_count: $speed_note_count, + aim_difficult_strain_count: $aim_difficult_strain_count, + speed_difficult_strain_count: $speed_difficult_strain_count, ar: $ar, od: $od, hp: $hp, @@ -126,6 +130,8 @@ fn basic_osu() { flashlight: 2.288770487900865, slider_factor: 0.9803052946037858, speed_note_count: 210.36373973116545, + aim_difficult_strain_count: 0.0, // TODO + speed_difficult_strain_count: 0.0, // TODO ar: 9.300000190734863, od: 8.800000190734863, hp: 5.0, @@ -141,6 +147,8 @@ fn basic_osu() { flashlight: 2.606877929965889, slider_factor: 0.9803052946037858, speed_note_count: 210.36373973116545, + aim_difficult_strain_count: 0.0, // TODO + speed_difficult_strain_count: 0.0, // TODO ar: 9.300000190734863, od: 8.800000190734863, hp: 5.0, @@ -156,6 +164,8 @@ fn basic_osu() { flashlight: 2.8549217213059936, slider_factor: 0.9690667605258665, speed_note_count: 184.01205359079387, + aim_difficult_strain_count: 0.0, // TODO + speed_difficult_strain_count: 0.0, // TODO ar: 10.0, od: 10.0, hp: 7.0, @@ -171,6 +181,8 @@ fn basic_osu() { flashlight: 3.319522943625448, slider_factor: 0.9776943279272041, speed_note_count: 214.80421464205617, + aim_difficult_strain_count: 0.0, // TODO + speed_difficult_strain_count: 0.0, // TODO ar: 10.53333346048991, od: 10.311111238267687, hp: 5.0, @@ -186,6 +198,8 @@ fn basic_osu() { flashlight: 2.288770487900865, slider_factor: 0.9803052946037858, speed_note_count: 210.36373973116545, + aim_difficult_strain_count: 0.0, // TODO + speed_difficult_strain_count: 0.0, // TODO ar: 9.300000190734863, od: 8.800000190734863, hp: 5.0, @@ -201,6 +215,8 @@ fn basic_osu() { flashlight: 2.606877929965889, slider_factor: 0.9803052946037858, speed_note_count: 210.36373973116545, + aim_difficult_strain_count: 0.0, // TODO + speed_difficult_strain_count: 0.0, // TODO ar: 9.300000190734863, od: 8.800000190734863, hp: 5.0, From b33e259afeaad5ee972f513601ffd26df4df3ff5 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 11 Oct 2024 00:35:31 +0200 Subject: [PATCH 09/48] Port osu!taiko updates since f08134f --- src/mania/difficulty/gradual.rs | 6 +- src/mania/difficulty/mod.rs | 2 +- src/model/beatmap/attributes.rs | 95 +++++++---- src/osu/difficulty/skills/mod.rs | 2 +- src/taiko/attributes.rs | 6 +- src/taiko/convert.rs | 45 ++--- src/taiko/difficulty/gradual.rs | 10 +- src/taiko/difficulty/mod.rs | 14 +- src/taiko/performance/mod.rs | 115 +++++++++---- src/util/mod.rs | 1 + src/util/special_functions.rs | 277 +++++++++++++++++++++++++++++++ tests/difficulty.rs | 27 ++- 12 files changed, 500 insertions(+), 100 deletions(-) create mode 100644 src/util/special_functions.rs diff --git a/src/mania/difficulty/gradual.rs b/src/mania/difficulty/gradual.rs index 669b27ca..5d16baa0 100644 --- a/src/mania/difficulty/gradual.rs +++ b/src/mania/difficulty/gradual.rs @@ -65,8 +65,10 @@ impl ManiaGradualDifficulty { let clock_rate = difficulty.get_clock_rate(); let mut params = ObjectParams::new(converted); - let HitWindows { od: hit_window, .. } = - converted.attributes().difficulty(&difficulty).hit_windows(); + let HitWindows { + od_great: hit_window, + .. + } = converted.attributes().difficulty(&difficulty).hit_windows(); let mania_objects = converted .hit_objects diff --git a/src/mania/difficulty/mod.rs b/src/mania/difficulty/mod.rs index c04eee2c..8dbc4b8a 100644 --- a/src/mania/difficulty/mod.rs +++ b/src/mania/difficulty/mod.rs @@ -28,7 +28,7 @@ pub fn difficulty( .attributes() .difficulty(difficulty) .hit_windows() - .od; + .od_great; ManiaDifficultyAttributes { stars: values.strain.difficulty_value() * DIFFICULTY_MULTIPLIER, diff --git a/src/model/beatmap/attributes.rs b/src/model/beatmap/attributes.rs index ba057ad1..7133a379 100644 --- a/src/model/beatmap/attributes.rs +++ b/src/model/beatmap/attributes.rs @@ -27,7 +27,11 @@ pub struct HitWindows { /// Hit window for approach rate i.e. `TimePreempt` in milliseconds. pub ar: f64, /// Hit window for overall difficulty i.e. time to hit a 300 ("Great") in milliseconds. - pub od: f64, + pub od_great: f64, + /// Hit window for overall difficulty i.e. time to hit a 100 ("Ok") in milliseconds. + /// + /// `None` for osu!mania. + pub od_ok: Option, } /// A builder for [`BeatmapAttributes`] and [`HitWindows`]. @@ -44,15 +48,43 @@ pub struct BeatmapAttributesBuilder { clock_rate: Option, } -impl BeatmapAttributesBuilder { - const OSU_MIN: f64 = 80.0; - const OSU_AVG: f64 = 50.0; - const OSU_MAX: f64 = 20.0; +struct GameModeHitWindows { + min: f64, + avg: f64, + max: f64, +} - const TAIKO_MIN: f64 = 50.0; - const TAIKO_AVG: f64 = 35.0; - const TAIKO_MAX: f64 = 20.0; +const OSU_GREAT: GameModeHitWindows = GameModeHitWindows { + min: 80.0, + avg: 50.0, + max: 20.0, +}; + +const OSU_OK: GameModeHitWindows = GameModeHitWindows { + min: 140.0, + avg: 100.0, + max: 60.0, +}; + +const TAIKO_GREAT: GameModeHitWindows = GameModeHitWindows { + min: 50.0, + avg: 35.0, + max: 20.0, +}; + +const TAIKO_OK: GameModeHitWindows = GameModeHitWindows { + min: 120.0, + avg: 80.0, + max: 50.0, +}; + +const AR_WINDOWS: GameModeHitWindows = GameModeHitWindows { + min: 1800.0, + avg: 1200.0, + max: 450.0, +}; +impl BeatmapAttributesBuilder { /// Create a new [`BeatmapAttributesBuilder`]. /// /// The mode will be `GameMode::Osu` and attributes are set to `5.0`. @@ -219,10 +251,10 @@ impl BeatmapAttributesBuilder { mod_mult(self.ar.value(mods, GameMods::ar)) }; - let preempt = difficulty_range(f64::from(raw_ar), 1800.0, 1200.0, 450.0) / ar_clock_rate; + let preempt = difficulty_range(f64::from(raw_ar), AR_WINDOWS) / ar_clock_rate; // OD - let hit_window = match self.mode { + let (great, ok) = match self.mode { GameMode::Osu | GameMode::Catch => { let raw_od = if self.od.with_mods() { self.od.value(mods, GameMods::od) @@ -230,12 +262,10 @@ impl BeatmapAttributesBuilder { mod_mult(self.od.value(mods, GameMods::od)) }; - difficulty_range( - f64::from(raw_od), - Self::OSU_MIN, - Self::OSU_AVG, - Self::OSU_MAX, - ) / od_clock_rate + let great = difficulty_range(f64::from(raw_od), OSU_GREAT) / od_clock_rate; + let ok = difficulty_range(f64::from(raw_od), OSU_OK) / od_clock_rate; + + (great, Some(ok)) } GameMode::Taiko => { let raw_od = if self.od.with_mods() { @@ -244,14 +274,10 @@ impl BeatmapAttributesBuilder { mod_mult(self.od.value(mods, GameMods::od)) }; - let diff_range = difficulty_range( - f64::from(raw_od), - Self::TAIKO_MIN, - Self::TAIKO_AVG, - Self::TAIKO_MAX, - ); + let great = difficulty_range(f64::from(raw_od), TAIKO_GREAT) / od_clock_rate; + let ok = difficulty_range(f64::from(raw_od), TAIKO_OK) / od_clock_rate; - diff_range / od_clock_rate + (great, Some(ok)) } GameMode::Mania => { let mut value = if !self.is_convert { @@ -270,13 +296,16 @@ impl BeatmapAttributesBuilder { } } - ((f64::from(value) * od_clock_rate).floor() / od_clock_rate).ceil() + let great = ((f64::from(value) * od_clock_rate).floor() / od_clock_rate).ceil(); + + (great, None) } }; HitWindows { ar: preempt, - od: hit_window, + od_great: great, + od_ok: ok, } } @@ -308,7 +337,11 @@ impl BeatmapAttributesBuilder { } let hit_windows = self.hit_windows(); - let HitWindows { ar, od } = hit_windows; + let HitWindows { + ar, + od_great, + od_ok: _, + } = hit_windows; // AR let ar = if ar > 1200.0 { @@ -319,8 +352,10 @@ impl BeatmapAttributesBuilder { // OD let od = match self.mode { - GameMode::Osu => (Self::OSU_MIN - od) / 6.0, - GameMode::Taiko => (Self::TAIKO_MIN - od) / (Self::TAIKO_MIN - Self::TAIKO_AVG) * 5.0, + GameMode::Osu => (OSU_GREAT.min - od_great) / 6.0, + GameMode::Taiko => { + (TAIKO_GREAT.min - od_great) / (TAIKO_GREAT.min - TAIKO_GREAT.avg) * 5.0 + } GameMode::Catch | GameMode::Mania => f64::from(self.od.value(mods, GameMods::od)), }; @@ -347,7 +382,9 @@ impl From<&Converted<'_, M>> for BeatmapAttributesBuilder { } } -fn difficulty_range(difficulty: f64, min: f64, mid: f64, max: f64) -> f64 { +fn difficulty_range(difficulty: f64, windows: GameModeHitWindows) -> f64 { + let GameModeHitWindows { min, avg: mid, max } = windows; + if difficulty > 5.0 { mid + (max - mid) * (difficulty - 5.0) / 5.0 } else if difficulty < 5.0 { diff --git a/src/osu/difficulty/skills/mod.rs b/src/osu/difficulty/skills/mod.rs index de319f5d..3042875f 100644 --- a/src/osu/difficulty/skills/mod.rs +++ b/src/osu/difficulty/skills/mod.rs @@ -26,7 +26,7 @@ impl OsuSkills { map_attrs: &BeatmapAttributes, time_preempt: f64, ) -> Self { - let hit_window = 2.0 * map_attrs.hit_windows.od; + let hit_window = 2.0 * map_attrs.hit_windows.od_great; // * Preempt time can go below 450ms. Normally, this is achieved via the DT mod // * which uniformly speeds up all animations game wide regardless of AR. diff --git a/src/taiko/attributes.rs b/src/taiko/attributes.rs index df089f39..5748bdb0 100644 --- a/src/taiko/attributes.rs +++ b/src/taiko/attributes.rs @@ -12,7 +12,9 @@ pub struct TaikoDifficultyAttributes { /// The difficulty of the hardest parts of the map. pub peak: f64, /// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc) - pub hit_window: f64, + pub great_hit_window: f64, + /// The perceived hit window for an n100 inclusive of rate-adjusting mods (DT/HT/etc) + pub ok_hit_window: f64, /// The final star rating. pub stars: f64, /// The maximum combo. @@ -55,6 +57,8 @@ pub struct TaikoPerformanceAttributes { pub pp_difficulty: f64, /// Scaled miss count based on total hits. pub effective_miss_count: f64, + /// Upper bound on the player's tap deviation. + pub estimated_unstable_rate: Option, } impl TaikoPerformanceAttributes { diff --git a/src/taiko/convert.rs b/src/taiko/convert.rs index 27106f36..b0e0189c 100644 --- a/src/taiko/convert.rs +++ b/src/taiko/convert.rs @@ -1,9 +1,6 @@ use std::cmp; -use rosu_map::{ - section::{general::GameMode, hit_objects::CurveBuffers}, - util::Pos, -}; +use rosu_map::{section::general::GameMode, util::Pos}; use crate::{ model::{ @@ -20,7 +17,7 @@ use super::Taiko; /// A [`Beatmap`] for [`Taiko`] calculations. pub type TaikoBeatmap<'a> = Converted<'a, Taiko>; -const LEGACY_TAIKO_VELOCITY_MULTIPLIER: f32 = 1.4; +const VELOCITY_MULTIPLIER: f32 = 1.4; const OSU_BASE_SCORING_DIST: f32 = 100.0; pub const fn check_convert(map: &Beatmap) -> ConvertStatus { @@ -44,8 +41,6 @@ pub fn try_convert(map: &mut Beatmap) -> ConvertStatus { } fn convert(map: &mut Beatmap) { - map.slider_multiplier *= f64::from(LEGACY_TAIKO_VELOCITY_MULTIPLIER); - let mut new_objects = Vec::new(); let mut new_sounds = Vec::new(); @@ -125,32 +120,46 @@ fn convert(map: &mut Beatmap) { fn should_convert_slider_to_taiko_hits(map: &Beatmap, params: &mut SliderParams<'_>) -> bool { let SliderParams { slider, - bufs, duration, start_time, tick_spacing, } = params; - let curve = slider.curve(GameMode::Taiko, bufs); - // * The true distance, accounting for any repeats. This ends up being the drum roll distance later let spans = slider.span_count() as f64; - let dist = curve.dist() * spans * f64::from(LEGACY_TAIKO_VELOCITY_MULTIPLIER); + let mut dist = slider.expected_dist.unwrap_or(0.0); + + // * Do not combine the following two lines! + dist *= f64::from(VELOCITY_MULTIPLIER); + dist *= spans; let timing_beat_len = map .timing_point_at(*start_time) .map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len); - let bpm_multiplier = map + let slider_velocity = map .difficulty_point_at(*start_time) - .map_or(DifficultyPoint::DEFAULT_BPM_MULTIPLIER, |point| { - point.bpm_multiplier + .map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| { + point.slider_velocity }); - let mut beat_len = timing_beat_len * bpm_multiplier; + fn get_precision_adjusted_beat_len(slider_velocity_multiplier: f64, beat_len: f64) -> f64 { + let slider_velocity_as_beat_len = -100.0 / slider_velocity_multiplier; + + let bpm_multiplier = if slider_velocity_as_beat_len < 0.0 { + f64::from(((-slider_velocity_as_beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0 + } else { + 1.0 + }; + + beat_len * bpm_multiplier + } + + let mut beat_len = get_precision_adjusted_beat_len(slider_velocity, timing_beat_len); - let slider_scoring_point_dist = - f64::from(OSU_BASE_SCORING_DIST) * map.slider_multiplier / map.slider_tick_rate; + let slider_scoring_point_dist = f64::from(OSU_BASE_SCORING_DIST) + * (map.slider_multiplier * f64::from(VELOCITY_MULTIPLIER)) + / map.slider_tick_rate; // * The velocity and duration of the taiko hit object - calculated as the velocity of a drum roll. let taiko_vel = slider_scoring_point_dist * map.slider_tick_rate; @@ -171,7 +180,6 @@ fn should_convert_slider_to_taiko_hits(map: &Beatmap, params: &mut SliderParams< struct SliderParams<'c> { slider: &'c Slider, - bufs: CurveBuffers, duration: u32, start_time: f64, tick_spacing: f64, @@ -181,7 +189,6 @@ impl<'c> SliderParams<'c> { fn new(start_time: f64, slider: &'c Slider) -> Self { Self { slider, - bufs: CurveBuffers::default(), start_time, duration: 0, tick_spacing: 0.0, diff --git a/src/taiko/difficulty/gradual.rs b/src/taiko/difficulty/gradual.rs index f89a8898..3ebfb53c 100644 --- a/src/taiko/difficulty/gradual.rs +++ b/src/taiko/difficulty/gradual.rs @@ -83,8 +83,11 @@ impl TaikoGradualDifficulty { (Some(true), Some(true)) => FirstTwoCombos::Both, }; - let HitWindows { od: hit_window, .. } = - converted.attributes().difficulty(&difficulty).hit_windows(); + let HitWindows { + od_great, + od_ok, + ar: _, + } = converted.attributes().difficulty(&difficulty).hit_windows(); let mut n_diff_objects = 0; let mut max_combo = 0; @@ -100,7 +103,8 @@ impl TaikoGradualDifficulty { let skills = TaikoSkills::new(); let attrs = TaikoDifficultyAttributes { - hit_window, + great_hit_window: od_great, + ok_hit_window: od_ok.unwrap_or(0.0), is_convert: converted.is_convert, ..Default::default() }; diff --git a/src/taiko/difficulty/mod.rs b/src/taiko/difficulty/mod.rs index 9e2b3f64..9395fadf 100644 --- a/src/taiko/difficulty/mod.rs +++ b/src/taiko/difficulty/mod.rs @@ -2,6 +2,7 @@ use std::cmp; use crate::{ any::difficulty::skills::Skill, + model::beatmap::HitWindows, taiko::{ difficulty::{ color::preprocessor::ColorDifficultyPreprocessor, @@ -31,16 +32,17 @@ pub fn difficulty( difficulty: &Difficulty, converted: &TaikoBeatmap<'_>, ) -> TaikoDifficultyAttributes { - let hit_window = converted - .attributes() - .difficulty(difficulty) - .hit_windows() - .od; + let HitWindows { + od_great, + od_ok, + ar: _, + } = converted.attributes().difficulty(difficulty).hit_windows(); let DifficultyValues { skills, max_combo } = DifficultyValues::calculate(difficulty, converted); let mut attrs = TaikoDifficultyAttributes { - hit_window, + great_hit_window: od_great, + ok_hit_window: od_ok.unwrap_or(0.0), max_combo, is_convert: converted.is_convert, ..Default::default() diff --git a/src/taiko/performance/mod.rs b/src/taiko/performance/mod.rs index b9b8ab88..7c407778 100644 --- a/src/taiko/performance/mod.rs +++ b/src/taiko/performance/mod.rs @@ -4,7 +4,7 @@ use crate::{ any::{Difficulty, HitResultPriority, IntoModePerformance, IntoPerformance}, model::mods::GameMods, osu::OsuPerformance, - util::map_or_attrs::MapOrAttrs, + util::{map_or_attrs::MapOrAttrs, special_functions}, Performance, }; @@ -403,6 +403,10 @@ impl TaikoPerformanceInner<'_> { // * and increasing the miss penalty for shorter object counts lower than 1000. let total_successful_hits = self.total_successful_hits(); + let estimated_unstable_rate = self + .compute_deviation_upper_bound(total_successful_hits) + .map(|v| v * 10.0); + let effective_miss_count = if total_successful_hits > 0 { (1000.0 / f64::from(total_successful_hits)).max(1.0) * f64::from(self.state.misses) } else { @@ -419,8 +423,9 @@ impl TaikoPerformanceInner<'_> { multiplier *= 0.975; } - let diff_value = self.compute_difficulty_value(effective_miss_count); - let acc_value = self.compute_accuracy_value(); + let diff_value = + self.compute_difficulty_value(effective_miss_count, estimated_unstable_rate); + let acc_value = self.compute_accuracy_value(estimated_unstable_rate); let pp = (diff_value.powf(1.1) + acc_value.powf(1.1)).powf(1.0 / 1.1) * multiplier; @@ -430,10 +435,19 @@ impl TaikoPerformanceInner<'_> { pp_acc: acc_value, pp_difficulty: diff_value, effective_miss_count, + estimated_unstable_rate, } } - fn compute_difficulty_value(&self, effective_miss_count: f64) -> f64 { + fn compute_difficulty_value( + &self, + effective_miss_count: f64, + estimated_unstable_rate: Option, + ) -> f64 { + let Some(estimated_unstable_rate) = estimated_unstable_rate else { + return 0.0; + }; + let attrs = &self.attrs; let exp_base = 5.0 * (attrs.stars / 0.115).max(1.0) - 4.0; let mut diff_value = exp_base.powf(2.25) / 1150.0; @@ -452,39 +466,95 @@ impl TaikoPerformanceInner<'_> { } if self.mods.hr() { - diff_value *= 1.05; + diff_value *= 1.10; } if self.mods.fl() { diff_value *= 1.05 * len_bonus; } - let acc = self.custom_accuracy(); - - diff_value * acc.powf(2.0) + diff_value + * (special_functions::erf(400.0 / (2.0_f64.sqrt() * estimated_unstable_rate))).powf(2.0) } - fn compute_accuracy_value(&self) -> f64 { - if self.attrs.hit_window <= 0.0 { + fn compute_accuracy_value(&self, estimated_unstable_rate: Option) -> f64 { + if self.attrs.great_hit_window <= 0.0 { return 0.0; } - let mut acc_value = (60.0 / self.attrs.hit_window).powf(1.1) - * self.custom_accuracy().powf(8.0) - * self.attrs.stars.powf(0.4) - * 27.0; + let Some(estimated_unstable_rate) = estimated_unstable_rate else { + return 0.0; + }; + + let mut acc_value = + (70.0 / estimated_unstable_rate).powf(1.1) * self.attrs.stars.powf(0.4) * 100.0; let len_bonus = (self.total_hits() / 1500.0).powf(0.3).min(1.15); - acc_value *= len_bonus; // * Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values. if self.mods.hd() && self.mods.fl() && !self.attrs.is_convert { - acc_value *= (1.075 * len_bonus).max(1.05); + acc_value *= (1.05 * len_bonus).max(1.0); } acc_value } + // * Computes an upper bound on the player's tap deviation based on the OD, number of circles and sliders, + // * and the hit judgements, assuming the player's mean hit error is 0. The estimation is consistent in that + // * two SS scores on the same map with the same settings will always return the same deviation. + fn compute_deviation_upper_bound(&self, total_successful_hits: u32) -> Option { + if total_successful_hits == 0 || self.attrs.great_hit_window <= 0.0 { + return None; + } + + let h300 = self.attrs.great_hit_window; + let h100 = self.attrs.ok_hit_window; + let n = self.total_hits(); + + // * 99% critical value for the normal distribution (one-tailed). + const Z: f64 = 2.32634787404; + + // * The upper bound on deviation, calculated with the ratio of 300s to objects, and the great hit window. + let calc_deviation_great_window = || { + if self.state.n300 == 0 { + return None; + } + + // * Proportion of greats hit. + let p = f64::from(self.state.n300) / n; + + // * We can be 99% confident that p is at least this value. + let p_lower_bound = (n * p + Z * Z / 2.0) / (n + Z * Z) + - Z / (n + Z * Z) * (n * p * (1.0 - p) + Z * Z / 4.0).sqrt(); + + // * We can be 99% confident that the deviation is not higher than: + Some(h300 / (2.0_f64.sqrt() * special_functions::erf_inv(p_lower_bound))) + }; + + // * The upper bound on deviation, calculated with the ratio of 300s + 100s to objects, and the good hit window. + // * This will return a lower value than the first method when the number of 100s is high, but the miss count is low. + let calc_deviation_good_window = || { + // * Proportion of greats + goods hit. + let p = f64::from(total_successful_hits) / n; + + // * We can be 99% confident that p is at least this value. + let p_lower_bound = (n * p + Z * Z / 2.0) / (n + Z * Z) + - Z / (n + Z * Z) * (n * p * (1.0 - p) + Z * Z / 4.0).sqrt(); + + // * We can be 99% confident that the deviation is not higher than: + h100 / (2.0_f64.sqrt() * special_functions::erf_inv(p_lower_bound)) + }; + + let deviation_great_window = calc_deviation_great_window(); + let deviation_good_window = calc_deviation_good_window(); + + let Some(deviation_great_window) = deviation_great_window else { + return Some(deviation_good_window); + }; + + Some(deviation_great_window.min(deviation_good_window)) + } + const fn total_hits(&self) -> f64 { self.state.total_hits() as f64 } @@ -492,19 +562,6 @@ impl TaikoPerformanceInner<'_> { const fn total_successful_hits(&self) -> u32 { self.state.n300 + self.state.n100 } - - fn custom_accuracy(&self) -> f64 { - let total_hits = self.state.total_hits(); - - if total_hits == 0 { - return 0.0; - } - - let numerator = self.state.n300 * 300 + self.state.n100 * 150; - let denominator = total_hits * 300; - - f64::from(numerator) / f64::from(denominator) - } } fn accuracy(n300: u32, n100: u32, misses: u32) -> f64 { diff --git a/src/util/mod.rs b/src/util/mod.rs index 4ec48e96..3d858bec 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -4,5 +4,6 @@ pub mod limited_queue; pub mod map_or_attrs; pub mod random; pub mod sort; +pub mod special_functions; pub mod strains_vec; pub mod sync; diff --git a/src/util/special_functions.rs b/src/util/special_functions.rs new file mode 100644 index 00000000..96c4ee82 --- /dev/null +++ b/src/util/special_functions.rs @@ -0,0 +1,277 @@ +#[rustfmt::skip] +mod consts { + pub const ERF_IMP_AN: &[f64] = &[ 0.00337916709551257388990745, -0.00073695653048167948530905, -0.374732337392919607868241, 0.0817442448733587196071743, -0.0421089319936548595203468, 0.0070165709512095756344528, -0.00495091255982435110337458, 0.000871646599037922480317225 ]; + pub const ERF_IMP_AD: &[f64] = &[ 1.0, -0.218088218087924645390535, 0.412542972725442099083918, -0.0841891147873106755410271, 0.0655338856400241519690695, -0.0120019604454941768171266, 0.00408165558926174048329689, -0.000615900721557769691924509 ]; + pub const ERF_IMP_BN: &[f64] = &[ -0.0361790390718262471360258, 0.292251883444882683221149, 0.281447041797604512774415, 0.125610208862766947294894, 0.0274135028268930549240776, 0.00250839672168065762786937 ]; + pub const ERF_IMP_BD: &[f64] = &[ 1.0, 1.8545005897903486499845, 1.43575803037831418074962, 0.582827658753036572454135, 0.124810476932949746447682, 0.0113724176546353285778481 ]; + pub const ERF_IMP_CN: &[f64] = &[ -0.0397876892611136856954425, 0.153165212467878293257683, 0.191260295600936245503129, 0.10276327061989304213645, 0.029637090615738836726027, 0.0046093486780275489468812, 0.000307607820348680180548455 ]; + pub const ERF_IMP_CD: &[f64] = &[ 1.0, 1.95520072987627704987886, 1.64762317199384860109595, 0.768238607022126250082483, 0.209793185936509782784315, 0.0319569316899913392596356, 0.00213363160895785378615014 ]; + pub const ERF_IMP_DN: &[f64] = &[ -0.0300838560557949717328341, 0.0538578829844454508530552, 0.0726211541651914182692959, 0.0367628469888049348429018, 0.00964629015572527529605267, 0.00133453480075291076745275, 0.778087599782504251917881e-4 ]; + pub const ERF_IMP_DD: &[f64] = &[ 1.0, 1.75967098147167528287343, 1.32883571437961120556307, 0.552528596508757581287907, 0.133793056941332861912279, 0.0179509645176280768640766, 0.00104712440019937356634038, -0.106640381820357337177643e-7 ]; + pub const ERF_IMP_EN: &[f64] = &[ -0.0117907570137227847827732, 0.014262132090538809896674, 0.0202234435902960820020765, 0.00930668299990432009042239, 0.00213357802422065994322516, 0.00025022987386460102395382, 0.120534912219588189822126e-4 ]; + pub const ERF_IMP_ED: &[f64] = &[ 1.0, 1.50376225203620482047419, 0.965397786204462896346934, 0.339265230476796681555511, 0.0689740649541569716897427, 0.00771060262491768307365526, 0.000371421101531069302990367 ]; + pub const ERF_IMP_FN: &[f64] = &[ -0.00546954795538729307482955, 0.00404190278731707110245394, 0.0054963369553161170521356, 0.00212616472603945399437862, 0.000394984014495083900689956, 0.365565477064442377259271e-4, 0.135485897109932323253786e-5 ]; + pub const ERF_IMP_FD: &[f64] = &[ 1.0, 1.21019697773630784832251, 0.620914668221143886601045, 0.173038430661142762569515, 0.0276550813773432047594539, 0.00240625974424309709745382, 0.891811817251336577241006e-4, -0.465528836283382684461025e-11 ]; + pub const ERF_IMP_GN: &[f64] = &[ -0.00270722535905778347999196, 0.0013187563425029400461378, 0.00119925933261002333923989, 0.00027849619811344664248235, 0.267822988218331849989363e-4, 0.923043672315028197865066e-6 ]; + pub const ERF_IMP_GD: &[f64] = &[ 1.0, 0.814632808543141591118279, 0.268901665856299542168425, 0.0449877216103041118694989, 0.00381759663320248459168994, 0.000131571897888596914350697, 0.404815359675764138445257e-11 ]; + pub const ERF_IMP_HN: &[f64] = &[ -0.00109946720691742196814323, 0.000406425442750422675169153, 0.000274499489416900707787024, 0.465293770646659383436343e-4, 0.320955425395767463401993e-5, 0.778286018145020892261936e-7 ]; + pub const ERF_IMP_HD: &[f64] = &[ 1.0, 0.588173710611846046373373, 0.139363331289409746077541, 0.0166329340417083678763028, 0.00100023921310234908642639, 0.24254837521587225125068e-4 ]; + pub const ERF_IMP_IN: &[f64] = &[ -0.00056907993601094962855594, 0.000169498540373762264416984, 0.518472354581100890120501e-4, 0.382819312231928859704678e-5, 0.824989931281894431781794e-7 ]; + pub const ERF_IMP_ID: &[f64] = &[ 1.0, 0.339637250051139347430323, 0.043472647870310663055044, 0.00248549335224637114641629, 0.535633305337152900549536e-4, -0.117490944405459578783846e-12 ]; + pub const ERF_IMP_JN: &[f64] = &[ -0.000241313599483991337479091, 0.574224975202501512365975e-4, 0.115998962927383778460557e-4, 0.581762134402593739370875e-6, 0.853971555085673614607418e-8 ]; + pub const ERF_IMP_JD: &[f64] = &[ 1.0, 0.233044138299687841018015, 0.0204186940546440312625597, 0.000797185647564398289151125, 0.117019281670172327758019e-4 ]; + pub const ERF_IMP_KN: &[f64] = &[ -0.000146674699277760365803642, 0.162666552112280519955647e-4, 0.269116248509165239294897e-5, 0.979584479468091935086972e-7, 0.101994647625723465722285e-8 ]; + pub const ERF_IMP_KD: &[f64] = &[ 1.0, 0.165907812944847226546036, 0.0103361716191505884359634, 0.000286593026373868366935721, 0.298401570840900340874568e-5 ]; + pub const ERF_IMP_LN: &[f64] = &[ -0.583905797629771786720406e-4, 0.412510325105496173512992e-5, 0.431790922420250949096906e-6, 0.993365155590013193345569e-8, 0.653480510020104699270084e-10 ]; + pub const ERF_IMP_LD: &[f64] = &[ 1.0, 0.105077086072039915406159, 0.00414278428675475620830226, 0.726338754644523769144108e-4, 0.477818471047398785369849e-6 ]; + pub const ERF_IMP_MN: &[f64] = &[ -0.196457797609229579459841e-4, 0.157243887666800692441195e-5, 0.543902511192700878690335e-7, 0.317472492369117710852685e-9 ]; + pub const ERF_IMP_MD: &[f64] = &[ 1.0, 0.052803989240957632204885, 0.000926876069151753290378112, 0.541011723226630257077328e-5, 0.535093845803642394908747e-15 ]; + pub const ERF_IMP_NN: &[f64] = &[ -0.789224703978722689089794e-5, 0.622088451660986955124162e-6, 0.145728445676882396797184e-7, 0.603715505542715364529243e-10 ]; + pub const ERF_IMP_ND: &[f64] = &[ 1.0, 0.0375328846356293715248719, 0.000467919535974625308126054, 0.193847039275845656900547e-5 ]; + + pub const ERV_INV_IMP_AN: &[f64] = &[ -0.000508781949658280665617, -0.00836874819741736770379, 0.0334806625409744615033, -0.0126926147662974029034, -0.0365637971411762664006, 0.0219878681111168899165, 0.00822687874676915743155, -0.00538772965071242932965 ]; + pub const ERV_INV_IMP_AD: &[f64] = &[ 1.0, -0.970005043303290640362, -1.56574558234175846809, 1.56221558398423026363, 0.662328840472002992063, -0.71228902341542847553, -0.0527396382340099713954, 0.0795283687341571680018, -0.00233393759374190016776, 0.000886216390456424707504 ]; + pub const ERV_INV_IMP_BN: &[f64] = &[ -0.202433508355938759655, 0.105264680699391713268, 8.37050328343119927838, 17.6447298408374015486, -18.8510648058714251895, -44.6382324441786960818, 17.445385985570866523, 21.1294655448340526258, -3.67192254707729348546 ]; + pub const ERV_INV_IMP_BD: &[f64] = &[ 1.0, 6.24264124854247537712, 3.9713437953343869095, -28.6608180499800029974, -20.1432634680485188801, 48.5609213108739935468, 10.8268667355460159008, -22.6436933413139721736, 1.72114765761200282724 ]; + pub const ERV_INV_IMP_CN: &[f64] = &[ -0.131102781679951906451, -0.163794047193317060787, 0.117030156341995252019, 0.387079738972604337464, 0.337785538912035898924, 0.142869534408157156766, 0.0290157910005329060432, 0.00214558995388805277169, -0.679465575181126350155e-6, 0.285225331782217055858e-7, -0.681149956853776992068e-9 ]; + pub const ERV_INV_IMP_CD: &[f64] = &[ 1.0, 3.46625407242567245975, 5.38168345707006855425, 4.77846592945843778382, 2.59301921623620271374, 0.848854343457902036425, 0.152264338295331783612, 0.01105924229346489121 ]; + pub const ERV_INV_IMP_DN: &[f64] = &[ -0.0350353787183177984712, -0.00222426529213447927281, 0.0185573306514231072324, 0.00950804701325919603619, 0.00187123492819559223345, 0.000157544617424960554631, 0.460469890584317994083e-5, -0.230404776911882601748e-9, 0.266339227425782031962e-11 ]; + pub const ERV_INV_IMP_DD: &[f64] = &[ 1.0, 1.3653349817554063097, 0.762059164553623404043, 0.220091105764131249824, 0.0341589143670947727934, 0.00263861676657015992959, 0.764675292302794483503e-4 ]; + pub const ERV_INV_IMP_EN: &[f64] = &[ -0.0167431005076633737133, -0.00112951438745580278863, 0.00105628862152492910091, 0.000209386317487588078668, 0.149624783758342370182e-4, 0.449696789927706453732e-6, 0.462596163522878599135e-8, -0.281128735628831791805e-13, 0.99055709973310326855e-16 ]; + pub const ERV_INV_IMP_ED: &[f64] = &[ 1.0, 0.591429344886417493481, 0.138151865749083321638, 0.0160746087093676504695, 0.000964011807005165528527, 0.275335474764726041141e-4, 0.282243172016108031869e-6 ]; + pub const ERV_INV_IMP_FN: &[f64] = &[ -0.0024978212791898131227, -0.779190719229053954292e-5, 0.254723037413027451751e-4, 0.162397777342510920873e-5, 0.396341011304801168516e-7, 0.411632831190944208473e-9, 0.145596286718675035587e-11, -0.116765012397184275695e-17 ]; + pub const ERV_INV_IMP_FD: &[f64] = &[ 1.0, 0.207123112214422517181, 0.0169410838120975906478, 0.000690538265622684595676, 0.145007359818232637924e-4, 0.144437756628144157666e-6, 0.509761276599778486139e-9 ]; + pub const ERV_INV_IMP_GN: &[f64] = &[ -0.000539042911019078575891, -0.28398759004727721098e-6, 0.899465114892291446442e-6, 0.229345859265920864296e-7, 0.225561444863500149219e-9, 0.947846627503022684216e-12, 0.135880130108924861008e-14, -0.348890393399948882918e-21 ]; + pub const ERV_INV_IMP_GD: &[f64] = &[ 1.0, 0.0845746234001899436914, 0.00282092984726264681981, 0.468292921940894236786e-4, 0.399968812193862100054e-6, 0.161809290887904476097e-8, 0.231558608310259605225e-11 ]; +} + +use consts::*; + +pub fn erf(x: f64) -> f64 { + if x.abs() < f64::EPSILON { + return 0.0; + } + + if x == f64::INFINITY { + return 1.0; + } + + if x == f64::NEG_INFINITY { + return -1.0; + } + + if x.is_nan() { + return f64::NAN; + } + + erf_imp(x, false) +} + +pub fn erf_inv(z: f64) -> f64 { + if z.abs() < f64::EPSILON { + return 0.0; + } + + if z >= 1.0 { + return f64::INFINITY; + } + + if z <= -1.0 { + return f64::NEG_INFINITY; + } + + if z < 0.0 { + erf_inv_impl(-z, 1.0 - (-z), -1.0) + } else { + erf_inv_impl(z, 1.0 - z, 1.0) + } +} + +fn erf_imp(z: f64, mut invert: bool) -> f64 { + if z < 0.0 { + if !invert { + return -erf_imp(-z, false); + } + + if z < -0.5 { + return 2.0 - erf_imp(-z, true); + } + + return 1.0 + erf_imp(-z, false); + } + + let result = if z < 0.5 { + if z < 1e-10 { + (z * 1.125) + (z * 0.003379167095512573896158903121545171688) + } else { + (z * 1.125) + + (z * evaluate_polynomial(z, ERF_IMP_AN) / evaluate_polynomial(z, ERF_IMP_AD)) + } + } else if z < 110.0 { + invert = !invert; + + let (r, b) = if z < 0.75 { + ( + evaluate_polynomial(z - 0.5, ERF_IMP_BN) / evaluate_polynomial(z - 0.5, ERF_IMP_BD), + f64::from(0.3440242112_f32), + ) + } else if z < 1.25 { + ( + evaluate_polynomial(z - 0.75, ERF_IMP_CN) + / evaluate_polynomial(z - 0.75, ERF_IMP_CD), + f64::from(0.419990927_f32), + ) + } else if z < 2.25 { + ( + evaluate_polynomial(z - 1.25, ERF_IMP_DN) + / evaluate_polynomial(z - 1.25, ERF_IMP_DD), + f64::from(0.4898625016_f32), + ) + } else if z < 3.5 { + ( + evaluate_polynomial(z - 2.25, ERF_IMP_EN) + / evaluate_polynomial(z - 2.25, ERF_IMP_ED), + f64::from(0.5317370892_f32), + ) + } else if z < 5.25 { + ( + evaluate_polynomial(z - 3.5, ERF_IMP_FN) / evaluate_polynomial(z - 3.5, ERF_IMP_FD), + f64::from(0.5489973426_f32), + ) + } else if z < 8.0 { + ( + evaluate_polynomial(z - 5.25, ERF_IMP_GN) + / evaluate_polynomial(z - 5.25, ERF_IMP_GD), + f64::from(0.5571740866_f32), + ) + } else if z < 11.5 { + ( + evaluate_polynomial(z - 8.0, ERF_IMP_HN) / evaluate_polynomial(z - 8.0, ERF_IMP_HD), + f64::from(0.5609807968_f32), + ) + } else if z < 17.0 { + ( + evaluate_polynomial(z - 11.5, ERF_IMP_IN) + / evaluate_polynomial(z - 11.5, ERF_IMP_ID), + f64::from(0.5626493692_f32), + ) + } else if z < 24.0 { + ( + evaluate_polynomial(z - 17.0, ERF_IMP_JN) + / evaluate_polynomial(z - 17.0, ERF_IMP_JD), + f64::from(0.5634598136_f32), + ) + } else if z < 38.0 { + ( + evaluate_polynomial(z - 24.0, ERF_IMP_KN) + / evaluate_polynomial(z - 24.0, ERF_IMP_KD), + f64::from(0.5638477802_f32), + ) + } else if z < 60.0 { + ( + evaluate_polynomial(z - 38.0, ERF_IMP_LN) + / evaluate_polynomial(z - 38.0, ERF_IMP_LD), + f64::from(0.5640528202_f32), + ) + } else if z < 85.0 { + ( + evaluate_polynomial(z - 60.0, ERF_IMP_MN) + / evaluate_polynomial(z - 60.0, ERF_IMP_MD), + f64::from(0.5641309023_f32), + ) + } else { + ( + evaluate_polynomial(z - 85.0, ERF_IMP_NN) + / evaluate_polynomial(z - 85.0, ERF_IMP_ND), + f64::from(0.5641584396_f32), + ) + }; + + let g = (-z * z).exp() / z; + + (g * b) + (g * r) + } else { + invert = !invert; + + 0.0 + }; + + if invert { + 1.0 - result + } else { + result + } +} + +fn erf_inv_impl(p: f64, q: f64, s: f64) -> f64 { + let result = if p <= 0.5 { + const Y: f32 = 0.0891314744949340820313; + + let g = p * (p + 10.0); + let r = evaluate_polynomial(p, ERV_INV_IMP_AN) / evaluate_polynomial(p, ERV_INV_IMP_AD); + + (g * f64::from(Y)) + (g * r) + } else if q >= 0.25 { + const Y: f32 = 2.249481201171875; + + let g = (-2.0 * q.ln()).sqrt(); + let xs = q - 0.25; + let r = evaluate_polynomial(xs, ERV_INV_IMP_BN) / evaluate_polynomial(xs, ERV_INV_IMP_BD); + + g / (f64::from(Y) + r) + } else { + let x = (-q.ln()).sqrt(); + + if x < 3.0 { + const Y: f32 = 0.807220458984375; + + let xs = x - 1.125; + let r = + evaluate_polynomial(xs, ERV_INV_IMP_CN) / evaluate_polynomial(xs, ERV_INV_IMP_CD); + + (f64::from(Y) * x) + (r * x) + } else if x < 6.0 { + const Y: f32 = 0.93995571136474609375; + let xs = x - 3.0; + let r = + evaluate_polynomial(xs, ERV_INV_IMP_DN) / evaluate_polynomial(xs, ERV_INV_IMP_DD); + + (f64::from(Y) * x) + (r * x) + } else if x < 18.0 { + const Y: f32 = 0.98362827301025390625; + + let xs = x - 6.0; + let r = + evaluate_polynomial(xs, ERV_INV_IMP_EN) / evaluate_polynomial(xs, ERV_INV_IMP_ED); + + (f64::from(Y) * x) + (r * x) + } else if x < 44.0 { + const Y: f32 = 0.99714565277099609375; + + let xs = x - 18.0; + let r = + evaluate_polynomial(xs, ERV_INV_IMP_FN) / evaluate_polynomial(xs, ERV_INV_IMP_FD); + (f64::from(Y) * x) + (r * x) + } else { + const Y: f32 = 0.99941349029541015625; + + let xs = x - 44.0; + let r = + evaluate_polynomial(xs, ERV_INV_IMP_GN) / evaluate_polynomial(xs, ERV_INV_IMP_GD); + + (f64::from(Y) * x) + (r * x) + } + }; + + result * s +} + +fn evaluate_polynomial(z: f64, coefficients: &[f64]) -> f64 { + let mut coefficients = coefficients.iter().copied().rev(); + + let Some(last) = coefficients.next() else { + return 0.0; + }; + + coefficients.fold(last, |sum, coefficient| (sum * z) + coefficient) +} diff --git a/tests/difficulty.rs b/tests/difficulty.rs index c8b7837a..6e53ddf2 100644 --- a/tests/difficulty.rs +++ b/tests/difficulty.rs @@ -69,7 +69,8 @@ macro_rules! test_cases { rhythm: $rhythm:literal, color: $color:literal, peak: $peak:literal, - hit_window: $hit_window:literal, + great_hit_window: $great_hit_window:literal, + ok_hit_window: $ok_hit_window:literal, stars: $stars:literal, max_combo: $max_combo:literal, is_convert: $is_convert:literal, @@ -79,7 +80,8 @@ macro_rules! test_cases { rhythm: $rhythm, color: $color, peak: $peak, - hit_window: $hit_window, + great_hit_window: $great_hit_window, + ok_hit_window: $ok_hit_window, stars: $stars, max_combo: $max_combo, is_convert: $is_convert, @@ -334,7 +336,8 @@ fn basic_taiko() { rhythm: 0.20130047251681948, color: 1.0487315549761433, peak: 1.8881824429738323, - hit_window: 35.0, + great_hit_window: 35.0, + ok_hit_window: 0.0, // TODO stars: 2.9778030386845606, max_combo: 289, is_convert: false, @@ -344,7 +347,8 @@ fn basic_taiko() { rhythm: 0.20130047251681948, color: 1.0487315549761433, peak: 1.8881824429738323, - hit_window: 29.0, + great_hit_window: 29.0, + ok_hit_window: 0.0, // TODO stars: 2.9778030386845606, max_combo: 289, is_convert: false, @@ -354,7 +358,8 @@ fn basic_taiko() { rhythm: 0.4448175371191029, color: 1.3637624960988888, peak: 2.6393434317991886, - hit_window: 23.333333333333332, + great_hit_window: 23.333333333333332, + ok_hit_window: 0.0, // TODO stars: 3.9605501866340607, max_combo: 289, is_convert: false, @@ -372,7 +377,8 @@ fn convert_taiko() { rhythm: 1.4696991260446617, color: 2.3032281729649067, peak: 4.130240422926277, - hit_window: 23.59999942779541, + great_hit_window: 23.59999942779541, + ok_hit_window: 0.0, // TODO stars: 5.247857660585606, max_combo: 908, is_convert: true, @@ -382,7 +388,8 @@ fn convert_taiko() { rhythm: 1.4696991260446617, color: 2.3032281729649067, peak: 4.130240422926277, - hit_window: 20.0, + great_hit_window: 20.0, + ok_hit_window: 0.0, // TODO stars: 5.247857660585606, max_combo: 908, is_convert: true, @@ -392,7 +399,8 @@ fn convert_taiko() { rhythm: 2.002843919169095, color: 3.1864894777399986, peak: 6.107962386775966, - hit_window: 15.733332951863607, + great_hit_window: 15.733332951863607, + ok_hit_window: 0.0, // TODO stars: 7.0140481946324815, max_combo: 908, is_convert: true, @@ -559,7 +567,8 @@ impl AssertEq for TaikoDifficultyAttributes { assert_eq_float(self.rhythm, expected.rhythm); assert_eq_float(self.color, expected.color); assert_eq_float(self.peak, expected.peak); - assert_eq_float(self.hit_window, expected.hit_window); + assert_eq_float(self.great_hit_window, expected.great_hit_window); + assert_eq_float(self.ok_hit_window, expected.ok_hit_window); assert_eq_float(self.stars, expected.stars); assert_eq!(self.max_combo, expected.max_combo); assert_eq!(self.is_convert, expected.is_convert); From ec52aaa5a8be99380df7a6f3cf5e45920afa14e3 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 11 Oct 2024 13:56:11 +0200 Subject: [PATCH 10/48] test: update test case values --- tests/difficulty.rs | 172 +++++++++++++++++++++---------------------- tests/performance.rs | 166 +++++++++++++++++++++-------------------- 2 files changed, 174 insertions(+), 164 deletions(-) diff --git a/tests/difficulty.rs b/tests/difficulty.rs index 6e53ddf2..3263777b 100644 --- a/tests/difficulty.rs +++ b/tests/difficulty.rs @@ -127,110 +127,110 @@ fn basic_osu() { test_cases! { Osu: OSU { NM => { - aim: 2.8693628443424104, - speed: 2.533869745015772, + aim: 2.881184366758021, + speed: 2.468469273849314, flashlight: 2.288770487900865, - slider_factor: 0.9803052946037858, - speed_note_count: 210.36373973116545, - aim_difficult_strain_count: 0.0, // TODO - speed_difficult_strain_count: 0.0, // TODO + slider_factor: 0.9803293523973865, + speed_note_count: 204.88794724609374, + aim_difficult_strain_count: 106.63833474488378, + speed_difficult_strain_count: 79.9883004295862, ar: 9.300000190734863, od: 8.800000190734863, hp: 5.0, n_circles: 307, n_sliders: 293, n_spinners: 1, - stars: 5.669858729379628, + stars: 5.643619989739299, max_combo: 909, }; HD => { - aim: 2.8693628443424104, - speed: 2.533869745015772, - flashlight: 2.606877929965889, - slider_factor: 0.9803052946037858, - speed_note_count: 210.36373973116545, - aim_difficult_strain_count: 0.0, // TODO - speed_difficult_strain_count: 0.0, // TODO + aim: 2.881184366758021, + speed: 2.468469273849314, + flashlight: 0.0, + slider_factor: 0.9803293523973865, + speed_note_count: 204.88794724609374, + aim_difficult_strain_count: 106.63833474488378, + speed_difficult_strain_count: 79.9883004295862, ar: 9.300000190734863, od: 8.800000190734863, hp: 5.0, n_circles: 307, n_sliders: 293, n_spinners: 1, - stars: 5.669858729379628, + stars: 5.643619989739299, max_combo: 909, }; HR => { - aim: 3.2385394176190507, - speed: 2.7009854505234308, - flashlight: 2.8549217213059936, - slider_factor: 0.9690667605258665, - speed_note_count: 184.01205359079387, - aim_difficult_strain_count: 0.0, // TODO - speed_difficult_strain_count: 0.0, // TODO + aim: 3.2515300463985666, + speed: 2.6323568908654615, + flashlight: 0.0, + slider_factor: 0.969089944826546, + speed_note_count: 178.52041495886283, + aim_difficult_strain_count: 108.03970474535397, + speed_difficult_strain_count: 73.27713411796513, ar: 10.0, od: 10.0, hp: 7.0, n_circles: 307, n_sliders: 293, n_spinners: 1, - stars: 6.263576582906263, + stars: 6.243301253337941, max_combo: 909, }; DT => { - aim: 4.041442573946681, - speed: 3.6784866216272474, - flashlight: 3.319522943625448, - slider_factor: 0.9776943279272041, - speed_note_count: 214.80421464205617, - aim_difficult_strain_count: 0.0, // TODO - speed_difficult_strain_count: 0.0, // TODO + aim: 4.058080039906945, + speed: 3.570932204630734, + flashlight: 0.0, + slider_factor: 0.9777224379583133, + speed_note_count: 211.29204189490912, + aim_difficult_strain_count: 126.9561362975524, + speed_difficult_strain_count: 95.63810649133869, ar: 10.53333346048991, od: 10.311111238267687, hp: 5.0, n_circles: 307, n_sliders: 293, n_spinners: 1, - stars: 8.085307648397622, + stars: 8.030649319285482, max_combo: 909, }; FL => { - aim: 2.8693628443424104, - speed: 2.533869745015772, - flashlight: 2.288770487900865, - slider_factor: 0.9803052946037858, - speed_note_count: 210.36373973116545, - aim_difficult_strain_count: 0.0, // TODO - speed_difficult_strain_count: 0.0, // TODO + aim: 2.881184366758021, + speed: 2.468469273849314, + flashlight: 2.287888783550428, + slider_factor: 0.9803293523973865, + speed_note_count: 204.88794724609374, + aim_difficult_strain_count: 106.63833474488378, + speed_difficult_strain_count: 79.9883004295862, ar: 9.300000190734863, od: 8.800000190734863, hp: 5.0, n_circles: 307, n_sliders: 293, n_spinners: 1, - stars: 6.8667780753884236, + stars: 6.858771801534423, max_combo: 909, }; HD FL => { - aim: 2.8693628443424104, - speed: 2.533869745015772, - flashlight: 2.606877929965889, - slider_factor: 0.9803052946037858, - speed_note_count: 210.36373973116545, - aim_difficult_strain_count: 0.0, // TODO - speed_difficult_strain_count: 0.0, // TODO + aim: 2.881184366758021, + speed: 2.468469273849314, + flashlight: 2.605859779358901, + slider_factor: 0.9803293523973865, + speed_note_count: 204.88794724609374, + aim_difficult_strain_count: 106.63833474488378, + speed_difficult_strain_count: 79.9883004295862, ar: 9.300000190734863, od: 8.800000190734863, hp: 5.0, n_circles: 307, n_sliders: 293, n_spinners: 1, - stars: 7.17258038247615, + stars: 7.167932950561898, max_combo: 909, }; } }; - #[cfg(target_os = "linux")] + #[cfg(target_os = "linux")] // TODO test_cases! { Osu: OSU { NM => { @@ -332,35 +332,35 @@ fn basic_taiko() { test_cases! { Taiko: TAIKO { NM => { - stamina: 1.4528845068865617, + stamina: 1.3991746883284406, rhythm: 0.20130047251681948, color: 1.0487315549761433, - peak: 1.8881824429738323, + peak: 1.8422453377400778, great_hit_window: 35.0, - ok_hit_window: 0.0, // TODO - stars: 2.9778030386845606, + ok_hit_window: 80.0, + stars: 2.914589700180437, max_combo: 289, is_convert: false, }; HR => { - stamina: 1.4528845068865617, + stamina: 1.3991746883284406, rhythm: 0.20130047251681948, color: 1.0487315549761433, - peak: 1.8881824429738323, + peak: 1.8422453377400778, great_hit_window: 29.0, - ok_hit_window: 0.0, // TODO - stars: 2.9778030386845606, + ok_hit_window: 68.0, + stars: 2.914589700180437, max_combo: 289, is_convert: false, }; DT => { - stamina: 2.054617838297644, + stamina: 2.0358868555131586, rhythm: 0.4448175371191029, - color: 1.3637624960988888, - peak: 2.6393434317991886, + color: 1.363762496098889, + peak: 2.625066421324458, great_hit_window: 23.333333333333332, - ok_hit_window: 0.0, // TODO - stars: 3.9605501866340607, + ok_hit_window: 53.333333333333336, + stars: 3.942709244618132, max_combo: 289, is_convert: false, }; @@ -373,35 +373,35 @@ fn convert_taiko() { test_cases! { Taiko: OSU { NM => { - stamina: 2.947896529153566, + stamina: 2.9127139214411444, rhythm: 1.4696991260446617, - color: 2.3032281729649067, - peak: 4.130240422926277, + color: 2.303228172964907, + peak: 4.117779264387738, great_hit_window: 23.59999942779541, - ok_hit_window: 0.0, // TODO - stars: 5.247857660585606, + ok_hit_window: 57.19999885559082, + stars: 5.660149021515273, max_combo: 908, is_convert: true, }; HR => { - stamina: 2.947896529153566, + stamina: 2.9127139214411444, rhythm: 1.4696991260446617, - color: 2.3032281729649067, - peak: 4.130240422926277, + color: 2.303228172964907, + peak: 4.117779264387738, great_hit_window: 20.0, - ok_hit_window: 0.0, // TODO - stars: 5.247857660585606, + ok_hit_window: 50.0, + stars: 5.660149021515273, max_combo: 908, is_convert: true, }; DT => { - stamina: 4.412382708370478, + stamina: 4.379782453136822, rhythm: 2.002843919169095, color: 3.1864894777399986, - peak: 6.107962386775966, + peak: 6.103209631166694, great_hit_window: 15.733332951863607, - ok_hit_window: 0.0, // TODO - stars: 7.0140481946324815, + ok_hit_window: 38.13333257039388, + stars: 7.578560915020682, max_combo: 908, is_convert: true, }; @@ -414,7 +414,7 @@ fn basic_catch() { test_cases! { Catch: CATCH { NM => { - stars: 3.2502663133739844, + stars: 3.250266313373984, ar: 8.0, n_fruits: 728, n_droplets: 2, @@ -422,7 +422,7 @@ fn basic_catch() { is_convert: false, }; HR => { - stars: 4.3148698646484265, + stars: 4.313360856186517, ar: 10.0, n_fruits: 728, n_droplets: 2, @@ -430,7 +430,7 @@ fn basic_catch() { is_convert: false, }; EZ => { - stars: 4.021637906259923, + stars: 4.06522224010957, ar: 4.0, n_fruits: 728, n_droplets: 2, @@ -438,7 +438,7 @@ fn basic_catch() { is_convert: false, }; DT => { - stars: 4.635262826575387, + stars: 4.635262826575386, ar: 9.666666666666668, n_fruits: 728, n_droplets: 2, @@ -454,15 +454,15 @@ fn convert_catch() { test_cases! { Catch: OSU { NM => { - stars: 4.513884094512871, - ar: 9.300000190734863, + stars: 4.528720977989276, + ar: 9.300000190734863 n_fruits: 908, n_droplets: 0, n_tiny_droplets: 159, is_convert: true, }; HR => { - stars: 5.082061773944862, + stars: 5.076698043567007, ar: 10.0, n_fruits: 908, n_droplets: 0, @@ -470,7 +470,7 @@ fn convert_catch() { is_convert: true, }; EZ => { - stars: 3.598951063172104, + stars: 3.593264064535228, ar: 4.650000095367432, n_fruits: 908, n_droplets: 0, @@ -478,7 +478,7 @@ fn convert_catch() { is_convert: true, }; DT => { - stars: 6.136837738350475, + stars: 6.15540143757313, ar: 10.53333346048991, n_fruits: 908, n_droplets: 0, @@ -494,14 +494,14 @@ fn basic_mania() { test_cases! { Mania: MANIA { NM => { - stars: 3.441830819988125, + stars: 3.358304846842773, hit_window: 40.0, n_objects: 594, max_combo: 956, is_convert: false, }; DT => { - stars: 4.70051326060948, + stars: 4.6072892053157295, hit_window: 40.0, n_objects: 594, max_combo: 956, diff --git a/tests/performance.rs b/tests/performance.rs index da78d413..6aa7068b 100644 --- a/tests/performance.rs +++ b/tests/performance.rs @@ -15,7 +15,7 @@ mod common; macro_rules! test_cases { ( $mode:ident: $path:ident { $( $( $mods:ident )+ => { - $( $key:ident: $value:literal $( , )? )* + $( $key:ident: $value:expr $( , )? )* } ;)* } ) => { let map = Beatmap::from_path(common::$path) @@ -31,12 +31,12 @@ macro_rules! test_cases { }; ( @Osu { $map:ident, - pp: $pp:literal, - pp_acc: $pp_acc:literal, - pp_aim: $pp_aim:literal, - pp_flashlight: $pp_flashlight:literal, - pp_speed: $pp_speed:literal, - effective_miss_count: $effective_miss_count:literal, + pp: $pp:expr, + pp_acc: $pp_acc:expr, + pp_aim: $pp_aim:expr, + pp_flashlight: $pp_flashlight:expr, + pp_speed: $pp_speed:expr, + effective_miss_count: $effective_miss_count:expr, }) => { ( OsuPerformance::from($map.as_owned()), @@ -53,10 +53,11 @@ macro_rules! test_cases { }; ( @Taiko { $map: ident, - pp: $pp:literal, - pp_acc: $pp_acc:literal, - pp_difficulty: $pp_difficulty:literal, - effective_miss_count: $effective_miss_count:literal, + pp: $pp:expr, + pp_acc: $pp_acc:expr, + pp_difficulty: $pp_difficulty:expr, + effective_miss_count: $effective_miss_count:expr, + estimated_unstable_rate: $estimated_unstable_rate:expr, }) => { ( TaikoPerformance::from($map.as_owned()), @@ -65,13 +66,14 @@ macro_rules! test_cases { pp_acc: $pp_acc, pp_difficulty: $pp_difficulty, effective_miss_count: $effective_miss_count, + estimated_unstable_rate: $estimated_unstable_rate, ..Default::default() }, ) }; ( @Catch { $map:ident, - pp: $pp:literal, + pp: $pp:expr, }) => { ( CatchPerformance::from($map.as_owned()), @@ -83,8 +85,8 @@ macro_rules! test_cases { }; ( @Mania { $map:ident, - pp: $pp:literal, - pp_difficulty: $pp_difficulty:literal, + pp: $pp:expr, + pp_difficulty: $pp_difficulty:expr, }) => { ( ManiaPerformance::from($map.as_owned()), @@ -102,19 +104,19 @@ fn basic_osu() { test_cases! { Osu: OSU { NM => { - pp: 255.9419635475736, - pp_acc: 79.84500076626814, - pp_aim: 98.13131344235279, + pp: 272.6047426867276, + pp_acc: 97.62287463107766, + pp_aim: 99.3726518686143, pp_flashlight: 0.0, - pp_speed: 69.86876965478146, + pp_speed: 64.48542022217285, effective_miss_count: 0.0, }; HD => { - pp: 281.28736211196446, - pp_acc: 86.2326008275696, - pp_aim: 108.72949454544438, + pp: 299.17174736245374, + pp_acc: 105.43270460156388, + pp_aim: 110.10489751227146, pp_flashlight: 0.0, - pp_speed: 77.41459624444144, + pp_speed: 71.4498451141828, effective_miss_count: 0.0, }; EZ HD => { @@ -126,35 +128,35 @@ fn basic_osu() { effective_miss_count: 0.0, }; HR => { - pp: 375.0764291059058, - pp_acc: 132.13521300659738, - pp_aim: 143.28598037767793, + pp: 404.7030358947424, + pp_acc: 161.55575439788055, + pp_aim: 145.04665418031985, pp_flashlight: 0.0, - pp_speed: 87.39375701955078, + pp_speed: 80.77088499277514, effective_miss_count: 0.0, }; DT => { - pp: 716.3683237855254, - pp_acc: 150.5694857734174, - pp_aim: 300.39084638572484, + pp: 738.7899608061098, + pp_acc: 184.09450675506795, + pp_aim: 304.16666833057235, pp_flashlight: 0.0, - pp_speed: 240.8765306794618, + pp_speed: 220.06297202966698, effective_miss_count: 0.0, }; FL => { - pp: 384.8917879591265, - pp_acc: 81.4419007815935, - pp_aim: 98.13131344235279, - pp_flashlight: 132.3991950960219, - pp_speed: 69.86876965478146, + pp: 402.408877784248, + pp_acc: 99.57533212369923, + pp_aim: 99.3726518686143, + pp_flashlight: 132.29720631068272, + pp_speed: 64.48542022217285, effective_miss_count: 0.0, }; HD FL => { - pp: 450.3709760368082, - pp_acc: 87.95725284412099, - pp_aim: 108.72949454544438, - pp_flashlight: 171.7600847331662, - pp_speed: 77.41459624444144, + pp: 469.3245236137446, + pp_acc: 107.54135869359516, + pp_aim: 110.10489751227146, + pp_flashlight: 171.62594459401154, + pp_speed: 71.4498451141828, effective_miss_count: 0.0, }; } @@ -166,28 +168,32 @@ fn basic_taiko() { test_cases! { Taiko: TAIKO { NM => { - pp: 98.47602106219567, - pp_acc: 46.11642717726248, - pp_difficulty: 46.69844233558799, + pp: 117.93083232512124, + pp_acc: 67.10083752258917, + pp_difficulty: 43.804435430934774, effective_miss_count: 0.0, + estimated_unstable_rate: Some(148.44150180469418), }; HD => { - pp: 107.19493857245885, - pp_acc: 46.11642717726248, - pp_difficulty: 47.86590339397769, + pp: 127.99624094636974, + pp_acc: 67.10083752258917, + pp_difficulty: 44.89954631670814, effective_miss_count: 0.0, + estimated_unstable_rate: Some(148.44150180469418), }; HR => { - pp: 112.22705475791287, - pp_acc: 56.71431676265808, - pp_difficulty: 49.033364452367394, + pp: 139.75239372681187, + pp_acc: 82.52109686788792, + pp_difficulty: 48.75926757049594, effective_miss_count: 0.0, + estimated_unstable_rate: Some(122.99438720960376), }; DT => { - pp: 181.5021832786881, - pp_acc: 80.74206626516394, - pp_difficulty: 90.29961105452931, + pp: 220.51543873147975, + pp_acc: 118.28107309573312, + pp_difficulty: 89.35584221033577, effective_miss_count: 0.0, + estimated_unstable_rate: Some(98.96100120312946), }; } }; @@ -198,28 +204,32 @@ fn convert_taiko() { test_cases! { Taiko: OSU { NM => { - pp: 324.23564627433217, - pp_acc: 125.81086361861148, - pp_difficulty: 179.31471072573842, + pp: 396.36982258196866, + pp_acc: 160.00481201044695, + pp_difficulty: 213.19920144243838, effective_miss_count: 0.0, + estimated_unstable_rate: Some(85.75868894575865), }; HD => { - pp: 353.7513933816713, - pp_acc: 125.81086361861148, - pp_difficulty: 183.79757849388187, + pp: 426.0975592756163, + pp_acc: 160.00481201044695, + pp_difficulty: 213.19920144243838, effective_miss_count: 0.0, + estimated_unstable_rate: Some(85.75868894575865), }; HR => { - pp: 360.12274556551137, - pp_acc: 150.9344373000759, - pp_difficulty: 188.28044626202535, + pp: 452.71458235192836, + pp_acc: 191.95668459371925, + pp_difficulty: 234.5205569790155, effective_miss_count: 0.0, + estimated_unstable_rate: Some(72.67685680089848), }; DT => { - pp: 604.8167434609272, - pp_acc: 220.7055264311451, - pp_difficulty: 347.90986552791844, + pp: 739.7393581199891, + pp_acc: 280.8904545747157, + pp_difficulty: 415.0249135067657, effective_miss_count: 0.0, + estimated_unstable_rate: Some(57.17245929717244), }; } } @@ -229,10 +239,10 @@ fn convert_taiko() { fn basic_catch() { test_cases! { Catch: CATCH { - NM => { pp: 113.85903714373049 }; - HD => { pp: 136.63084457247658 }; - HD HR => { pp: 231.90266535529486 }; - DT => { pp: 247.18402249125862 }; + NM => { pp: 113.85903714373046 }; + HD => { pp: 136.63084457247655 }; + HD HR => { pp: 231.7403429678108 }; + DT => { pp: 247.18402249125842 }; } }; } @@ -241,10 +251,10 @@ fn basic_catch() { fn convert_catch() { test_cases! { Catch: OSU { - NM => { pp: 230.99937552589745 }; - HD => { pp: 254.6768082128294 }; - HD HR => { pp: 328.41201070443725 }; - DT => { pp: 500.4365349891725 }; + NM => { pp: 232.52175944328079 }; + HD => { pp: 256.35523645996665 }; + HD HR => { pp: 327.71861407740374 }; + DT => { pp: 503.47065792054815 }; } }; } @@ -253,9 +263,9 @@ fn convert_catch() { fn basic_mania() { test_cases! { Mania: MANIA { - NM => { pp: 114.37175184134917, pp_difficulty: 14.296468980168646 }; - EZ => { pp: 57.18587592067458, pp_difficulty: 14.296468980168646 }; - DT => { pp: 233.17882161546717, pp_difficulty: 29.147352701933396 }; + NM => { pp: 108.08430593303873, pp_difficulty: 108.08430593303873 }; + EZ => { pp: 54.04215296651937, pp_difficulty: 108.08430593303873 }; + DT => { pp: 222.79838979800365, pp_difficulty: 222.79838979800365 }; } }; } @@ -264,9 +274,9 @@ fn basic_mania() { fn convert_mania() { test_cases! { Mania: OSU { - NM => { pp: 99.73849552661329, pp_difficulty: 12.467311940826661 }; - EZ => { pp: 49.869247763306646, pp_difficulty: 12.467311940826661 }; - DT => { pp: 195.23247718805612, pp_difficulty: 24.404059648507015 }; + NM => { pp: 99.73849552661329, pp_difficulty: 99.73849552661329 }; + EZ => { pp: 49.869247763306646, pp_difficulty: 99.73849552661329 }; + DT => { pp: 195.23247718805612, pp_difficulty: 195.23247718805612 }; } }; } From 5f634d8a121d553395622521452e3d0e262bdb69 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 11 Oct 2024 16:06:32 +0200 Subject: [PATCH 11/48] fix: adjust mania converts --- src/mania/convert/mod.rs | 53 +++++++++---------- src/mania/convert/pattern.rs | 6 +-- src/mania/convert/pattern_generator/mod.rs | 2 +- .../{distance_object.rs => path_object.rs} | 12 +++-- 4 files changed, 37 insertions(+), 36 deletions(-) rename src/mania/convert/pattern_generator/{distance_object.rs => path_object.rs} (98%) diff --git a/src/mania/convert/mod.rs b/src/mania/convert/mod.rs index a1e39555..365e779d 100644 --- a/src/mania/convert/mod.rs +++ b/src/mania/convert/mod.rs @@ -1,7 +1,4 @@ -use rosu_map::{ - section::{general::GameMode, hit_objects::CurveBuffers}, - util::Pos, -}; +use rosu_map::{section::general::GameMode, util::Pos}; use crate::{ model::{ @@ -15,8 +12,8 @@ use crate::{ use self::{ pattern::Pattern, pattern_generator::{ - distance_object::DistanceObjectPatternGenerator, end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator, + path_object::PathObjectPatternGenerator, }, pattern_type::PatternType, }; @@ -76,7 +73,6 @@ fn convert(map: &mut Beatmap) { let total_columns = map.cs as i32; let mut last_values = PrevValues::default(); - let mut curve_bufs = CurveBuffers::default(); // mean=668.7 | median=512 let mut new_hit_objects = Vec::with_capacity(512); @@ -108,9 +104,7 @@ fn convert(map: &mut Beatmap) { last_values.pattern = new_pattern; } HitObjectKind::Slider(ref slider) => { - let curve = slider.curve(GameMode::Mania, &mut curve_bufs); - - let mut gen = DistanceObjectPatternGenerator::new( + let mut gen = PathObjectPatternGenerator::new( &mut random, obj, sound, @@ -118,7 +112,7 @@ fn convert(map: &mut Beatmap) { &last_values.pattern, map, slider.repeats, - &curve, + slider.expected_dist, &slider.node_sounds, ); @@ -195,24 +189,29 @@ fn target_columns(map: &Beatmap) -> f32 { let rounded_cs = map.cs.round_ties_even(); let rounded_od = map.od.round_ties_even(); - let slider_or_spinner_count = map - .hit_objects - .iter() - .filter(|h| matches!(h.kind, HitObjectKind::Slider(_) | HitObjectKind::Spinner(_))) - .count(); - - let len = map.hit_objects.len(); - let percent_slider_or_spinner = f64::from(slider_or_spinner_count as f32 / len as f32); - - if percent_slider_or_spinner < 0.2 { - 7.0 - } else if percent_slider_or_spinner < 0.3 || rounded_cs >= 5.0 { - f32::from(6 + u8::from(rounded_od > 5.0)) - } else if percent_slider_or_spinner > 0.6 { - f32::from(4 + u8::from(rounded_od > 4.0)) - } else { - (rounded_od + 1.0).clamp(4.0, 7.0) + if !map.hit_objects.is_empty() { + let count_slider_or_spinner = map + .hit_objects + .iter() + .filter(|h| matches!(h.kind, HitObjectKind::Slider(_) | HitObjectKind::Spinner(_))) + .count(); + + let len = map.hit_objects.len(); + + // * In osu!stable, this division appears as if it happens on floats, but due to release-mode + // * optimisations, it actually ends up happening on doubles. + let percent_slider_or_spinner = f64::from(count_slider_or_spinner as f64 / len as f64); + + if percent_slider_or_spinner < 0.2 { + return 7.0; + } else if percent_slider_or_spinner < 0.3 || rounded_cs >= 5.0 { + return f32::from(6 + u8::from(rounded_od > 5.0)); + } else if percent_slider_or_spinner > 0.6 { + return f32::from(4 + u8::from(rounded_od > 4.0)); + } } + + ((rounded_od as i32) + 1).min(7).max(4) as f32 } #[cfg(test)] diff --git a/src/mania/convert/pattern.rs b/src/mania/convert/pattern.rs index d31de0e6..095464d4 100644 --- a/src/mania/convert/pattern.rs +++ b/src/mania/convert/pattern.rs @@ -5,8 +5,8 @@ use rosu_map::util::Pos; use crate::model::hit_object::{HitObject, HitObjectKind, HoldNote}; use super::pattern_generator::{ - distance_object::DistanceObjectPatternGenerator, end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator, + path_object::PathObjectPatternGenerator, }; #[derive(Default)] @@ -87,7 +87,7 @@ impl Pattern { } pub fn new_slider_note( - generator: &DistanceObjectPatternGenerator<'_>, + generator: &PathObjectPatternGenerator<'_>, column: u8, start_time: i32, end_time: i32, @@ -118,7 +118,7 @@ impl Pattern { pub fn add_slider_note( &mut self, - generator: &DistanceObjectPatternGenerator<'_>, + generator: &PathObjectPatternGenerator<'_>, column: u8, start_time: i32, end_time: i32, diff --git a/src/mania/convert/pattern_generator/mod.rs b/src/mania/convert/pattern_generator/mod.rs index 6f22db7f..20290a82 100644 --- a/src/mania/convert/pattern_generator/mod.rs +++ b/src/mania/convert/pattern_generator/mod.rs @@ -4,9 +4,9 @@ use crate::{ util::random::Random, }; -pub(super) mod distance_object; pub(super) mod end_time_object; pub(super) mod hit_object; +pub(super) mod path_object; pub struct PatternGenerator<'a> { pub hit_object: &'a HitObject, diff --git a/src/mania/convert/pattern_generator/distance_object.rs b/src/mania/convert/pattern_generator/path_object.rs similarity index 98% rename from src/mania/convert/pattern_generator/distance_object.rs rename to src/mania/convert/pattern_generator/path_object.rs index 5728df02..b29200b6 100644 --- a/src/mania/convert/pattern_generator/distance_object.rs +++ b/src/mania/convert/pattern_generator/path_object.rs @@ -1,6 +1,6 @@ use std::cmp; -use rosu_map::section::hit_objects::{hit_samples::HitSoundType, BorrowedCurve}; +use rosu_map::section::hit_objects::hit_samples::HitSoundType; use crate::{ mania::{ @@ -17,7 +17,7 @@ use crate::{ use super::PatternGenerator; -pub struct DistanceObjectPatternGenerator<'h> { +pub struct PathObjectPatternGenerator<'h> { pub segment_duration: i32, pub sample: HitSoundType, pub inner: PatternGenerator<'h>, @@ -29,7 +29,7 @@ pub struct DistanceObjectPatternGenerator<'h> { node_sounds: &'h [HitSoundType], } -impl<'h> DistanceObjectPatternGenerator<'h> { +impl<'h> PathObjectPatternGenerator<'h> { #[allow(clippy::too_many_arguments)] pub fn new( random: &'h mut Random, @@ -39,7 +39,7 @@ impl<'h> DistanceObjectPatternGenerator<'h> { prev_pattern: &'h Pattern, orig: &'h Beatmap, repeats: usize, - curve: &BorrowedCurve<'_>, + expected_dist: Option, node_sounds: &'h [HitSoundType], ) -> Self { let timing_beat_len = orig @@ -67,9 +67,11 @@ impl<'h> DistanceObjectPatternGenerator<'h> { let span_count = (repeats + 1) as i32; let start_time = hit_object.start_time.round_ties_even() as i32; + let dist = expected_dist.unwrap_or(0.0); + // * This matches stable's calculation. let end_time = (f64::from(start_time) - + curve.dist() * beat_len * f64::from(span_count) * 0.01 / orig.slider_multiplier) + + dist * beat_len * f64::from(span_count) * 0.01 / orig.slider_multiplier) .floor() as i32; let segment_duration = (end_time - start_time) / span_count; From 315d4b3c9ec84f70a6da6cbdf734d60be450aa1b Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 11 Oct 2024 16:24:23 +0200 Subject: [PATCH 12/48] fix: ensure f32 usage --- src/catch/catcher.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/catch/catcher.rs b/src/catch/catcher.rs index a552c59b..2074510e 100644 --- a/src/catch/catcher.rs +++ b/src/catch/catcher.rs @@ -15,6 +15,8 @@ impl Catcher { } fn calculate_scale(cs: f32) -> f32 { - ((1.0 - 0.7 * ((f64::from(cs) - 5.0) / 5.0)) as f32 / 2.0 * 1.0) * 2.0 + ((f64::from(1.0_f32) - f64::from(0.7_f32) * ((f64::from(cs) - 5.0) / 5.0)) as f32 / 2.0 + * 1.0) + * 2.0 } } From 14c646d9e13cbeb28faaf09ec9c7f60663763d9b Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 11 Oct 2024 16:58:48 +0200 Subject: [PATCH 13/48] fix: refactor `get_precision_adjusted_beat_len` --- src/catch/object/juice_stream.rs | 13 +------------ src/mania/convert/pattern_generator/path_object.rs | 10 +++++----- src/osu/object.rs | 14 +------------- src/taiko/convert.rs | 14 +------------- src/util/mod.rs | 12 ++++++++++++ 5 files changed, 20 insertions(+), 43 deletions(-) diff --git a/src/catch/object/juice_stream.rs b/src/catch/object/juice_stream.rs index f709fa6c..c69c3269 100644 --- a/src/catch/object/juice_stream.rs +++ b/src/catch/object/juice_stream.rs @@ -11,6 +11,7 @@ use crate::{ control_point::{DifficultyPoint, TimingPoint}, hit_object::Slider, }, + util::get_precision_adjusted_beat_len, }; pub struct JuiceStream<'a> { @@ -159,15 +160,3 @@ pub struct JuiceStreamBufs { pub curve: CurveBuffers, pub ticks: Vec, } - -fn get_precision_adjusted_beat_len(slider_velocity_multiplier: f64, beat_len: f64) -> f64 { - let slider_velocity_as_beat_len = -100.0 / slider_velocity_multiplier; - - let bpm_multiplier = if slider_velocity_as_beat_len < 0.0 { - f64::from(((-slider_velocity_as_beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0 - } else { - 1.0 - }; - - beat_len * bpm_multiplier -} diff --git a/src/mania/convert/pattern_generator/path_object.rs b/src/mania/convert/pattern_generator/path_object.rs index b29200b6..16361af3 100644 --- a/src/mania/convert/pattern_generator/path_object.rs +++ b/src/mania/convert/pattern_generator/path_object.rs @@ -12,7 +12,7 @@ use crate::{ control_point::{DifficultyPoint, EffectPoint, TimingPoint}, hit_object::HitObject, }, - util::random::Random, + util::{get_precision_adjusted_beat_len, random::Random}, }; use super::PatternGenerator; @@ -46,10 +46,10 @@ impl<'h> PathObjectPatternGenerator<'h> { .timing_point_at(hit_object.start_time) .map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len); - let bpm_multiplier = orig + let slider_velocity = orig .difficulty_point_at(hit_object.start_time) - .map_or(DifficultyPoint::DEFAULT_BPM_MULTIPLIER, |point| { - point.bpm_multiplier + .map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| { + point.slider_velocity }); let kiai = orig @@ -62,7 +62,7 @@ impl<'h> PathObjectPatternGenerator<'h> { PatternType::LOW_PROBABILITY }; - let beat_len = timing_beat_len * bpm_multiplier; + let beat_len = get_precision_adjusted_beat_len(slider_velocity, timing_beat_len); let span_count = (repeats + 1) as i32; let start_time = hit_object.start_time.round_ties_even() as i32; diff --git a/src/osu/object.rs b/src/osu/object.rs index 2dfac359..cb078638 100644 --- a/src/osu/object.rs +++ b/src/osu/object.rs @@ -13,7 +13,7 @@ use crate::{ control_point::{DifficultyPoint, TimingPoint}, hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner}, }, - util::sort, + util::{get_precision_adjusted_beat_len, sort}, }; use super::{convert::OsuBeatmap, PLAYFIELD_BASE_SIZE}; @@ -340,15 +340,3 @@ pub enum NestedSliderObjectKind { Tail, Tick, } - -fn get_precision_adjusted_beat_len(slider_velocity_multiplier: f64, beat_len: f64) -> f64 { - let slider_velocity_as_beat_len = -100.0 / slider_velocity_multiplier; - - let bpm_multiplier = if slider_velocity_as_beat_len < 0.0 { - f64::from(((-slider_velocity_as_beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0 - } else { - 1.0 - }; - - beat_len * bpm_multiplier -} diff --git a/src/taiko/convert.rs b/src/taiko/convert.rs index b0e0189c..916077f2 100644 --- a/src/taiko/convert.rs +++ b/src/taiko/convert.rs @@ -9,7 +9,7 @@ use crate::{ hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner}, mode::ConvertStatus, }, - util::{float_ext::FloatExt, sort::TandemSorter}, + util::{float_ext::FloatExt, get_precision_adjusted_beat_len, sort::TandemSorter}, }; use super::Taiko; @@ -143,18 +143,6 @@ fn should_convert_slider_to_taiko_hits(map: &Beatmap, params: &mut SliderParams< point.slider_velocity }); - fn get_precision_adjusted_beat_len(slider_velocity_multiplier: f64, beat_len: f64) -> f64 { - let slider_velocity_as_beat_len = -100.0 / slider_velocity_multiplier; - - let bpm_multiplier = if slider_velocity_as_beat_len < 0.0 { - f64::from(((-slider_velocity_as_beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0 - } else { - 1.0 - }; - - beat_len * bpm_multiplier - } - let mut beat_len = get_precision_adjusted_beat_len(slider_velocity, timing_beat_len); let slider_scoring_point_dist = f64::from(OSU_BASE_SCORING_DIST) diff --git a/src/util/mod.rs b/src/util/mod.rs index 3d858bec..1ff0fd8a 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -7,3 +7,15 @@ pub mod sort; pub mod special_functions; pub mod strains_vec; pub mod sync; + +pub fn get_precision_adjusted_beat_len(slider_velocity_multiplier: f64, beat_len: f64) -> f64 { + let slider_velocity_as_beat_len = -100.0 / slider_velocity_multiplier; + + let bpm_multiplier = if slider_velocity_as_beat_len < 0.0 { + f64::from(((-slider_velocity_as_beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0 + } else { + 1.0 + }; + + beat_len * bpm_multiplier +} From b641f602b6d1f01226aa24930fee878669ab95e3 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 11 Oct 2024 17:49:03 +0200 Subject: [PATCH 14/48] fix: dont sort unstably for catch --- src/catch/convert.rs | 16 ++-------------- src/util/sort/tandem.rs | 37 +------------------------------------ 2 files changed, 3 insertions(+), 50 deletions(-) diff --git a/src/catch/convert.rs b/src/catch/convert.rs index 04f1dd63..3c00c6b5 100644 --- a/src/catch/convert.rs +++ b/src/catch/convert.rs @@ -6,7 +6,7 @@ use crate::{ hit_object::{HitObject, HitObjectKind, HoldNote, Spinner}, mode::ConvertStatus, }, - util::{float_ext::FloatExt, random::Random, sort::TandemSorter}, + util::{float_ext::FloatExt, random::Random}, }; use super::{ @@ -82,20 +82,8 @@ pub fn convert_objects( palpable_objects.extend(new_objects); } - // Initializing hyper dashes requires objects to be sorted by C#'s unstable - // sort. After that, we unsort the objects again and then apply a stable - // sort to have the correct order for generating difficulty objects. - // Required e.g. due to map /b/102923. - let mut sorter = TandemSorter::new_unstable(&palpable_objects, |a, b| { - a.start_time.total_cmp(&b.start_time) - }); - - sorter.sort(&mut palpable_objects); - - initialize_hyper_dash(cs, &mut palpable_objects); - - sorter.unsort(&mut palpable_objects); palpable_objects.sort_by(|a, b| a.start_time.total_cmp(&b.start_time)); + initialize_hyper_dash(cs, &mut palpable_objects); palpable_objects } diff --git a/src/util/sort/tandem.rs b/src/util/sort/tandem.rs index 4fc80795..221f18a9 100644 --- a/src/util/sort/tandem.rs +++ b/src/util/sort/tandem.rs @@ -26,7 +26,6 @@ macro_rules! new_fn { impl TandemSorter { new_fn!(new_stable: <[_]>::sort_by); - new_fn!(new_unstable: super::csharp); /// Sort the given slice based on the internal ordering. pub fn sort(&mut self, slice: &mut [T]) { @@ -59,35 +58,6 @@ impl TandemSorter { self.should_reset = true; } - /// Unsort the given slice based on the internal ordering. - pub fn unsort(mut self, slice: &mut [T]) { - if self.should_reset { - self.toggle_marks(); - self.should_reset = false; - } - - for i in 0..self.indices.len() { - let i_idx = self.indices[i]; - - if Self::idx_is_marked(i_idx) { - continue; - } - - let mut j = i; - let mut j_idx = i_idx; - - while j != j_idx { - self.indices[j] = Self::toggle_mark_idx(j_idx); - self.indices.swap(j, j_idx); - slice.swap(j, j_idx); - j = self.indices[j]; - j_idx = self.indices[j]; - } - - self.indices[j] = Self::toggle_mark_idx(j_idx); - } - } - fn toggle_marks(&mut self) { for idx in self.indices.iter_mut() { *idx = Self::toggle_mark_idx(*idx); @@ -119,15 +89,10 @@ mod tests { let mut expected_sorted = actual.clone(); expected_sorted.sort_unstable(); - let expected_unsorted = actual.clone(); - - let mut sorter = TandemSorter::new_unstable(&actual, u8::cmp); + let mut sorter = TandemSorter::new_stable(&actual, u8::cmp); sorter.sort(&mut actual); assert_eq!(actual, expected_sorted); - - sorter.unsort(&mut actual); - assert_eq!(actual, expected_unsorted); } } } From b683200b9d9ba10e73ae6722d09e7dcdf1692bf4 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 11 Oct 2024 19:57:51 +0200 Subject: [PATCH 15/48] fix: more catch fixes --- src/catch/convert.rs | 4 ++-- src/catch/object/banana_shower.rs | 17 ++++++++++------- src/catch/object/juice_stream.rs | 8 ++------ 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/catch/convert.rs b/src/catch/convert.rs index 3c00c6b5..bffdbcb7 100644 --- a/src/catch/convert.rs +++ b/src/catch/convert.rs @@ -97,14 +97,14 @@ fn convert_object<'a>( let state = match h.kind { HitObjectKind::Circle => ObjectIterState::Fruit(Some(Fruit::new(count))), HitObjectKind::Slider(ref slider) => { - let x = JuiceStream::clamp_to_playfield(h.pos.x); + let x = h.pos.x; let stream = JuiceStream::new(x, h.start_time, slider, converted, count, bufs); ObjectIterState::JuiceStream(stream) } HitObjectKind::Spinner(Spinner { duration }) | HitObjectKind::Hold(HoldNote { duration }) => { - ObjectIterState::BananaShower(BananaShower::new(h.start_time, duration)) + ObjectIterState::BananaShower(BananaShower::new(h.start_time, h.start_time + duration)) } }; diff --git a/src/catch/object/banana_shower.rs b/src/catch/object/banana_shower.rs index 8363d9fc..2c007cac 100644 --- a/src/catch/object/banana_shower.rs +++ b/src/catch/object/banana_shower.rs @@ -3,9 +3,11 @@ pub struct BananaShower { } impl BananaShower { - pub fn new(start_time: f64, duration: f64) -> Self { - let mut spacing = duration; - let end_time = start_time + duration; + pub fn new(start_time: f64, end_time: f64) -> Self { + // * Int truncation added to match osu!stable. + let start_time = start_time as i32; + let end_time = end_time as i32; + let mut spacing = (end_time - start_time) as f32; while spacing > 100.0 { spacing /= 2.0; @@ -14,15 +16,16 @@ impl BananaShower { let n_bananas = if spacing <= 0.0 { 0 } else { - let mut time = start_time; - let mut i = 0; + let end_time = end_time as f32; + let mut time = start_time as f32; + let mut count = 0; while time <= end_time { time += spacing; - i += 1; + count += 1; } - i + count }; Self { n_bananas } diff --git a/src/catch/object/juice_stream.rs b/src/catch/object/juice_stream.rs index c69c3269..1bdd4751 100644 --- a/src/catch/object/juice_stream.rs +++ b/src/catch/object/juice_stream.rs @@ -6,7 +6,7 @@ use rosu_map::section::{ }; use crate::{ - catch::{attributes::ObjectCountBuilder, convert::CatchBeatmap, PLAYFIELD_WIDTH}, + catch::{attributes::ObjectCountBuilder, convert::CatchBeatmap}, model::{ control_point::{DifficultyPoint, TimingPoint}, hit_object::Slider, @@ -122,7 +122,7 @@ impl<'a> JuiceStream<'a> { }; let nested = NestedJuiceStreamObject { - pos: Self::clamp_to_playfield(x + path.position_at(e.path_progress).x), + pos: x + path.position_at(e.path_progress).x, start_time: e.time, kind, }; @@ -135,10 +135,6 @@ impl<'a> JuiceStream<'a> { nested_objects: bufs.nested_objects.drain(..), } } - - pub fn clamp_to_playfield(value: f32) -> f32 { - value.clamp(0.0, PLAYFIELD_WIDTH) - } } #[derive(Debug)] From 915d07e94b8c90a36d77c4630b6e50cbb08da24b Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 11 Oct 2024 21:01:10 +0200 Subject: [PATCH 16/48] test: fix test values --- src/catch/performance/mod.rs | 2 +- tests/difficulty.rs | 16 ++++++++-------- tests/performance.rs | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/catch/performance/mod.rs b/src/catch/performance/mod.rs index d8149ddf..b9c097c3 100644 --- a/src/catch/performance/mod.rs +++ b/src/catch/performance/mod.rs @@ -616,7 +616,7 @@ mod test { const N_FRUITS: u32 = 728; const N_DROPLETS: u32 = 2; - const N_TINY_DROPLETS: u32 = 291; + const N_TINY_DROPLETS: u32 = 263; fn beatmap() -> Beatmap { Beatmap::from_path("./resources/2118524.osu").unwrap() diff --git a/tests/difficulty.rs b/tests/difficulty.rs index 3263777b..552a0707 100644 --- a/tests/difficulty.rs +++ b/tests/difficulty.rs @@ -129,7 +129,7 @@ fn basic_osu() { NM => { aim: 2.881184366758021, speed: 2.468469273849314, - flashlight: 2.288770487900865, + flashlight: 2.287888783550428, slider_factor: 0.9803293523973865, speed_note_count: 204.88794724609374, aim_difficult_strain_count: 106.63833474488378, @@ -146,7 +146,7 @@ fn basic_osu() { HD => { aim: 2.881184366758021, speed: 2.468469273849314, - flashlight: 0.0, + flashlight: 2.605859779358901, slider_factor: 0.9803293523973865, speed_note_count: 204.88794724609374, aim_difficult_strain_count: 106.63833474488378, @@ -163,7 +163,7 @@ fn basic_osu() { HR => { aim: 3.2515300463985666, speed: 2.6323568908654615, - flashlight: 0.0, + flashlight: 2.853761577136605, slider_factor: 0.969089944826546, speed_note_count: 178.52041495886283, aim_difficult_strain_count: 108.03970474535397, @@ -180,7 +180,7 @@ fn basic_osu() { DT => { aim: 4.058080039906945, speed: 3.570932204630734, - flashlight: 0.0, + flashlight: 3.318209122186825, slider_factor: 0.9777224379583133, speed_note_count: 211.29204189490912, aim_difficult_strain_count: 126.9561362975524, @@ -418,7 +418,7 @@ fn basic_catch() { ar: 8.0, n_fruits: 728, n_droplets: 2, - n_tiny_droplets: 291, + n_tiny_droplets: 263, is_convert: false, }; HR => { @@ -426,7 +426,7 @@ fn basic_catch() { ar: 10.0, n_fruits: 728, n_droplets: 2, - n_tiny_droplets: 291, + n_tiny_droplets: 263, is_convert: false, }; EZ => { @@ -434,7 +434,7 @@ fn basic_catch() { ar: 4.0, n_fruits: 728, n_droplets: 2, - n_tiny_droplets: 291, + n_tiny_droplets: 263, is_convert: false, }; DT => { @@ -442,7 +442,7 @@ fn basic_catch() { ar: 9.666666666666668, n_fruits: 728, n_droplets: 2, - n_tiny_droplets: 291, + n_tiny_droplets: 263, is_convert: false, }; } diff --git a/tests/performance.rs b/tests/performance.rs index 6aa7068b..ee06a660 100644 --- a/tests/performance.rs +++ b/tests/performance.rs @@ -39,7 +39,7 @@ macro_rules! test_cases { effective_miss_count: $effective_miss_count:expr, }) => { ( - OsuPerformance::from($map.as_owned()), + OsuPerformance::from($map.as_owned()).lazer(false), OsuPerformanceAttributes { pp: $pp, pp_acc: $pp_acc, @@ -120,11 +120,11 @@ fn basic_osu() { effective_miss_count: 0.0, }; EZ HD => { - pp: 185.64881287702838, - pp_acc: 13.59914468151693, - pp_aim: 96.88083530160195, + pp: 186.7137498214991, + pp_acc: 16.6270597231239, + pp_aim: 98.11121656070222, pp_flashlight: 0.0, - pp_speed: 65.96268917477774, + pp_speed: 61.51901495973101, effective_miss_count: 0.0, }; HR => { From 25d8d295f37f4b344edd02ce2f603572575b6d1f Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 11 Oct 2024 21:14:37 +0200 Subject: [PATCH 17/48] chore: made clippy happy --- src/mania/convert/mod.rs | 8 ++++++-- src/model/beatmap/attributes.rs | 27 +++++++++++++++++---------- src/osu/difficulty/gradual.rs | 6 +++--- src/osu/difficulty/mod.rs | 12 ++++++------ src/osu/difficulty/skills/speed.rs | 5 +++-- src/osu/difficulty/skills/strain.rs | 4 ++-- src/osu/performance/mod.rs | 6 +++--- src/taiko/convert.rs | 2 +- src/taiko/performance/mod.rs | 1 + src/util/special_functions.rs | 8 ++++++++ 10 files changed, 50 insertions(+), 29 deletions(-) diff --git a/src/mania/convert/mod.rs b/src/mania/convert/mod.rs index 365e779d..ad2bf5bb 100644 --- a/src/mania/convert/mod.rs +++ b/src/mania/convert/mod.rs @@ -200,7 +200,7 @@ fn target_columns(map: &Beatmap) -> f32 { // * In osu!stable, this division appears as if it happens on floats, but due to release-mode // * optimisations, it actually ends up happening on doubles. - let percent_slider_or_spinner = f64::from(count_slider_or_spinner as f64 / len as f64); + let percent_slider_or_spinner = count_slider_or_spinner as f64 / len as f64; if percent_slider_or_spinner < 0.2 { return 7.0; @@ -211,7 +211,11 @@ fn target_columns(map: &Beatmap) -> f32 { } } - ((rounded_od as i32) + 1).min(7).max(4) as f32 + // Keeping it in-sync with lazer + #[allow(clippy::manual_clamp)] + { + ((rounded_od as i32) + 1).min(7).max(4) as f32 + } } #[cfg(test)] diff --git a/src/model/beatmap/attributes.rs b/src/model/beatmap/attributes.rs index 7133a379..a75dada0 100644 --- a/src/model/beatmap/attributes.rs +++ b/src/model/beatmap/attributes.rs @@ -382,6 +382,8 @@ impl From<&Converted<'_, M>> for BeatmapAttributesBuilder { } } +// False positive? Value looks consumed to me... +#[allow(clippy::needless_pass_by_value)] fn difficulty_range(difficulty: f64, windows: GameModeHitWindows) -> f64 { let GameModeHitWindows { min, avg: mid, max } = windows; @@ -425,13 +427,18 @@ impl ModsDependentKind { #[cfg(test)] mod tests { - use rosu_mods::{generated_mods::DifficultyAdjustOsu, GameMod, GameMods}; + #![allow(clippy::float_cmp)] + + use rosu_mods::{ + generated_mods::{DifficultyAdjustOsu, DoubleTimeCatch, DoubleTimeOsu, HiddenOsu}, + GameMod, GameMods, + }; use super::*; #[test] fn default_ar() { - let gamemod = GameMod::HiddenOsu(Default::default()); + let gamemod = GameMod::HiddenOsu(HiddenOsu::default()); let diff = Difficulty::new().mods(GameMods::from(gamemod)); let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build(); @@ -440,7 +447,7 @@ mod tests { #[test] fn custom_ar_without_mods() { - let gamemod = GameMod::DoubleTimeOsu(Default::default()); + let gamemod = GameMod::DoubleTimeOsu(DoubleTimeOsu::default()); let diff = Difficulty::new().mods(GameMods::from(gamemod)); let attrs = BeatmapAttributesBuilder::new() .ar(8.5, false) @@ -452,7 +459,7 @@ mod tests { #[test] fn custom_ar_with_mods() { - let gamemod = GameMod::DoubleTimeOsu(Default::default()); + let gamemod = GameMod::DoubleTimeOsu(DoubleTimeOsu::default()); let diff = Difficulty::new().mods(GameMods::from(gamemod)); let attrs = BeatmapAttributesBuilder::new() .ar(8.5, true) @@ -465,10 +472,10 @@ mod tests { #[test] fn custom_mods_ar() { let mut mods = GameMods::new(); - mods.insert(GameMod::DoubleTimeCatch(Default::default())); + mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default())); mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { approach_rate: Some(7.0), - ..Default::default() + ..DifficultyAdjustOsu::default() })); let diff = Difficulty::new().mods(mods); let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build(); @@ -479,10 +486,10 @@ mod tests { #[test] fn custom_ar_custom_mods_ar_without_mods() { let mut mods = GameMods::new(); - mods.insert(GameMod::DoubleTimeCatch(Default::default())); + mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default())); mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { approach_rate: Some(9.0), - ..Default::default() + ..DifficultyAdjustOsu::default() })); let diff = Difficulty::new().mods(mods).ar(8.5, false); @@ -494,10 +501,10 @@ mod tests { #[test] fn custom_ar_custom_mods_ar_with_mods() { let mut mods = GameMods::new(); - mods.insert(GameMod::DoubleTimeCatch(Default::default())); + mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default())); mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { approach_rate: Some(9.0), - ..Default::default() + ..DifficultyAdjustOsu::default() })); let diff = Difficulty::new().mods(mods).ar(8.5, true); diff --git a/src/osu/difficulty/gradual.rs b/src/osu/difficulty/gradual.rs index e12c4f98..fc724ae5 100644 --- a/src/osu/difficulty/gradual.rs +++ b/src/osu/difficulty/gradual.rs @@ -178,9 +178,9 @@ impl Iterator for OsuGradualDifficulty { DifficultyValues::eval( &mut attrs, self.difficulty.get_mods(), - aim_difficulty_value, - aim_no_sliders_difficulty_value, - speed_difficulty_value, + &aim_difficulty_value, + &aim_no_sliders_difficulty_value, + &speed_difficulty_value, speed_relevant_note_count, flashlight_difficulty_value, ); diff --git a/src/osu/difficulty/mod.rs b/src/osu/difficulty/mod.rs index 0b5ca3ee..e358bba8 100644 --- a/src/osu/difficulty/mod.rs +++ b/src/osu/difficulty/mod.rs @@ -53,9 +53,9 @@ pub fn difficulty(difficulty: &Difficulty, converted: &OsuBeatmap<'_>) -> OsuDif DifficultyValues::eval( &mut attrs, mods, - aim_difficulty_value, - aim_no_sliders_difficulty_value, - speed_difficulty_value, + &aim_difficulty_value, + &aim_no_sliders_difficulty_value, + &speed_difficulty_value, speed_relevant_note_count, flashlight_difficulty_value, ); @@ -151,9 +151,9 @@ impl DifficultyValues { pub fn eval( attrs: &mut OsuDifficultyAttributes, mods: &GameMods, - aim: UsedOsuStrainSkills, - aim_no_sliders: UsedOsuStrainSkills, - speed: UsedOsuStrainSkills, + aim: &UsedOsuStrainSkills, + aim_no_sliders: &UsedOsuStrainSkills, + speed: &UsedOsuStrainSkills, speed_relevant_note_count: f64, flashlight_difficulty_value: f64, ) { diff --git a/src/osu/difficulty/skills/speed.rs b/src/osu/difficulty/skills/speed.rs index ab27c7b7..1ff48a82 100644 --- a/src/osu/difficulty/skills/speed.rs +++ b/src/osu/difficulty/skills/speed.rs @@ -203,6 +203,7 @@ impl RhythmEvaluator { const RHYTHM_OVERALL_MULTIPLIER: f64 = 0.95; const RHYTHM_RATIO_MULTIPLIER: f64 = 12.0; + #[allow(clippy::too_many_lines)] fn evaluate_diff_of<'a>( curr: &'a OsuDifficultyObject<'a>, diff_objects: &'a [OsuDifficultyObject<'a>], @@ -409,7 +410,7 @@ const _: [(); 0 - !{ MIN_DELTA_TIME - OsuDifficultyObject::MIN_DELTA_TIME as i32 []; impl RhythmIsland { - fn new(delta_difference_eps: f64) -> Self { + const fn new(delta_difference_eps: f64) -> Self { Self { delta_difference_eps, delta: 0, @@ -433,7 +434,7 @@ impl RhythmIsland { self.delta_count += 1; } - fn is_similar_polarity(&self, other: &Self) -> bool { + 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 diff --git a/src/osu/difficulty/skills/strain.rs b/src/osu/difficulty/skills/strain.rs index f563fbd5..111eb7c1 100644 --- a/src/osu/difficulty/skills/strain.rs +++ b/src/osu/difficulty/skills/strain.rs @@ -11,7 +11,7 @@ impl Default for OsuStrainSkill { Self { // mean=406.72 | median=307 object_strains: Vec::with_capacity(256), - inner: Default::default(), + inner: StrainSkill::default(), } } } @@ -90,7 +90,7 @@ pub struct UsedOsuStrainSkills { } impl UsedOsuStrainSkills { - pub fn difficulty_value(&self) -> f64 { + pub const fn difficulty_value(&self) -> f64 { self.value.0 } diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index 6b3d20ff..4b4310f7 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -645,7 +645,7 @@ impl OsuPerformanceInner<'_> { aim_value *= len_bonus; if self.effective_miss_count > 0.0 { - aim_value *= self.calculate_miss_penalty( + aim_value *= Self::calculate_miss_penalty( self.effective_miss_count, self.attrs.aim_difficult_strain_count, ); @@ -714,7 +714,7 @@ impl OsuPerformanceInner<'_> { speed_value *= len_bonus; if self.effective_miss_count > 0.0 { - speed_value *= self.calculate_miss_penalty( + speed_value *= Self::calculate_miss_penalty( self.effective_miss_count, self.attrs.speed_difficult_strain_count, ); @@ -857,7 +857,7 @@ impl OsuPerformanceInner<'_> { // * 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(&self, miss_count: f64, diff_strain_count: f64) -> f64 { + 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) } diff --git a/src/taiko/convert.rs b/src/taiko/convert.rs index 916077f2..83a19ef9 100644 --- a/src/taiko/convert.rs +++ b/src/taiko/convert.rs @@ -174,7 +174,7 @@ struct SliderParams<'c> { } impl<'c> SliderParams<'c> { - fn new(start_time: f64, slider: &'c Slider) -> Self { + const fn new(start_time: f64, slider: &'c Slider) -> Self { Self { slider, start_time, diff --git a/src/taiko/performance/mod.rs b/src/taiko/performance/mod.rs index 7c407778..6a8ac6e1 100644 --- a/src/taiko/performance/mod.rs +++ b/src/taiko/performance/mod.rs @@ -511,6 +511,7 @@ impl TaikoPerformanceInner<'_> { let h100 = self.attrs.ok_hit_window; let n = self.total_hits(); + #[allow(clippy::items_after_statements, clippy::unreadable_literal)] // * 99% critical value for the normal distribution (one-tailed). const Z: f64 = 2.32634787404; diff --git a/src/util/special_functions.rs b/src/util/special_functions.rs index 96c4ee82..1e5754f2 100644 --- a/src/util/special_functions.rs +++ b/src/util/special_functions.rs @@ -1,3 +1,10 @@ +#![allow( + clippy::excessive_precision, + clippy::too_many_lines, + clippy::unreadable_literal, + clippy::many_single_char_names +)] + #[rustfmt::skip] mod consts { pub const ERF_IMP_AN: &[f64] = &[ 0.00337916709551257388990745, -0.00073695653048167948530905, -0.374732337392919607868241, 0.0817442448733587196071743, -0.0421089319936548595203468, 0.0070165709512095756344528, -0.00495091255982435110337458, 0.000871646599037922480317225 ]; @@ -45,6 +52,7 @@ mod consts { pub const ERV_INV_IMP_GD: &[f64] = &[ 1.0, 0.0845746234001899436914, 0.00282092984726264681981, 0.468292921940894236786e-4, 0.399968812193862100054e-6, 0.161809290887904476097e-8, 0.231558608310259605225e-11 ]; } +#[allow(clippy::wildcard_imports)] use consts::*; pub fn erf(x: f64) -> f64 { From f646cba5386869f59ac61b5c4b824c6b7f671a44 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Sun, 13 Oct 2024 19:01:20 +0200 Subject: [PATCH 18/48] fix: negate classic mod check --- src/osu/performance/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index 4b4310f7..c7d68323 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -778,7 +778,7 @@ impl OsuPerformanceInner<'_> { // * of the calculation we focus on hitting the timing hit window. let mut amount_hit_objects_with_acc = self.attrs.n_circles; - if using_classic_slider_acc { + if !using_classic_slider_acc { amount_hit_objects_with_acc += self.attrs.n_sliders; } From f72ff79b4a11c372da53698c3a67386392a74eb5 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Mon, 14 Oct 2024 00:47:38 +0200 Subject: [PATCH 19/48] feat!: adjust osu!standard hitresult generation --- proptest-regressions/osu/performance/mod.txt | 2 + src/any/score_state.rs | 20 ++ src/catch/performance/mod.rs | 2 + src/mania/performance/mod.rs | 2 + src/osu/attributes.rs | 2 + src/osu/convert.rs | 11 + src/osu/performance/mod.rs | 232 +++++++++++++++++-- src/osu/score_state.rs | 46 +++- src/taiko/performance/mod.rs | 2 + tests/difficulty.rs | 8 + tests/performance.rs | 2 +- 11 files changed, 297 insertions(+), 32 deletions(-) diff --git a/proptest-regressions/osu/performance/mod.txt b/proptest-regressions/osu/performance/mod.txt index 6b101f0b..9e6597b7 100644 --- a/proptest-regressions/osu/performance/mod.txt +++ b/proptest-regressions/osu/performance/mod.txt @@ -12,3 +12,5 @@ cc e5a861f6c665dd09e46423e71d7596edf98897d4130d3144aa6f5be580f31a8b # shrinks to cc 2cd5c105bcca0b4255afccc15bee3894b06bd20ac3f5c5d3b785f7e0ef99df46 # shrinks to acc = 0.0, combo = None, n300 = Some(0), n100 = None, n50 = Some(479), n_misses = Some(123), best_case = false cc 2cba8a76243aac7233e9207a3162aaa1f08f933c0cb3a2ac79580ece3a7329fc # shrinks to acc = 0.0, n300 = Some(0), n100 = Some(0), n50 = Some(0), n_misses = None, best_case = false cc e93787ad8a849ec6d05750c8d09494b8f5a9fa785f843d9a8e2db986c0b32645 # shrinks to acc = 0.0, n300 = None, n100 = None, n50 = None, n_misses = Some(602), best_case = false +cc a53cb48861126aa63be54606f9a770db5eae95242c9a9d75cf1fd101cfb21729 # shrinks to lazer = true, acc = 0.5679586776392227, n_slider_ticks = None, n_slider_ends = None, n300 = None, n100 = None, n50 = Some(0), n_misses = None, best_case = false +cc cacb94cb2a61cf05e7083e332b378290a6267a499bf30821228bc0ae4dfe46f6 # shrinks to lazer = true, acc = 0.5270982297689498, n_slider_ticks = None, n_slider_ends = None, n300 = Some(70), n100 = None, n50 = None, n_misses = None, best_case = false diff --git a/src/any/score_state.rs b/src/any/score_state.rs index 08c50731..94e8c904 100644 --- a/src/any/score_state.rs +++ b/src/any/score_state.rs @@ -15,6 +15,14 @@ pub struct ScoreState { /// /// Irrelevant for osu!mania. pub max_combo: u32, + /// Amount of successfully hit slider ticks and repeats. + /// + /// Only relevant for osu!standard in lazer. + pub slider_tick_hits: u32, + /// Amount of successfully hit slider ends. + /// + /// Only relevant for osu!standard in lazer. + pub slider_end_hits: u32, /// Amount of current gekis (n320 for osu!mania). pub n_geki: u32, /// Amount of current katus (tiny droplet misses for osu!catch / n200 for @@ -35,6 +43,8 @@ impl ScoreState { pub const fn new() -> Self { Self { max_combo: 0, + slider_tick_hits: 0, + slider_end_hits: 0, n_geki: 0, n_katu: 0, n300: 0, @@ -66,6 +76,8 @@ impl From for OsuScoreState { fn from(state: ScoreState) -> Self { Self { max_combo: state.max_combo, + slider_tick_hits: state.slider_tick_hits, + slider_end_hits: state.slider_end_hits, n300: state.n300, n100: state.n100, n50: state.n50, @@ -115,6 +127,8 @@ impl From for ScoreState { fn from(state: OsuScoreState) -> Self { Self { max_combo: state.max_combo, + slider_tick_hits: state.slider_tick_hits, + slider_end_hits: state.slider_end_hits, n_geki: 0, n_katu: 0, n300: state.n300, @@ -129,6 +143,8 @@ impl From for ScoreState { fn from(state: TaikoScoreState) -> Self { Self { max_combo: state.max_combo, + slider_tick_hits: 0, + slider_end_hits: 0, n_geki: 0, n_katu: 0, n300: state.n300, @@ -143,6 +159,8 @@ impl From for ScoreState { fn from(state: CatchScoreState) -> Self { Self { max_combo: state.max_combo, + slider_tick_hits: 0, + slider_end_hits: 0, n_geki: 0, n_katu: state.tiny_droplet_misses, n300: state.fruits, @@ -157,6 +175,8 @@ impl From for ScoreState { fn from(state: ManiaScoreState) -> Self { Self { max_combo: 0, + slider_tick_hits: 0, + slider_end_hits: 0, n_geki: state.n320, n_katu: state.n200, n300: state.n300, diff --git a/src/catch/performance/mod.rs b/src/catch/performance/mod.rs index b9c097c3..8ceb4ac6 100644 --- a/src/catch/performance/mod.rs +++ b/src/catch/performance/mod.rs @@ -474,6 +474,8 @@ impl<'map> TryFrom> for CatchPerformance<'map> { difficulty, acc, combo, + slider_tick_hits: _, + slider_end_hits: _, n300, n100, n50, diff --git a/src/mania/performance/mod.rs b/src/mania/performance/mod.rs index 5f66023d..3e8f0112 100644 --- a/src/mania/performance/mod.rs +++ b/src/mania/performance/mod.rs @@ -827,6 +827,8 @@ impl<'map> TryFrom> for ManiaPerformance<'map> { difficulty, acc, combo: _, + slider_tick_hits: _, + slider_end_hits: _, n300, n100, n50, diff --git a/src/osu/attributes.rs b/src/osu/attributes.rs index 68715cf5..281e8c2f 100644 --- a/src/osu/attributes.rs +++ b/src/osu/attributes.rs @@ -27,6 +27,8 @@ pub struct OsuDifficultyAttributes { pub n_circles: u32, /// The amount of sliders. pub n_sliders: u32, + /// The amount of slider ticks and repeat points. + pub n_slider_ticks: u32, /// The amount of spinners. pub n_spinners: u32, /// The final star rating diff --git a/src/osu/convert.rs b/src/osu/convert.rs index 19de59d8..a3227f57 100644 --- a/src/osu/convert.rs +++ b/src/osu/convert.rs @@ -56,6 +56,17 @@ pub fn convert_objects( OsuObjectKind::Slider(ref slider) => { attrs.n_sliders += 1; attrs.max_combo += slider.nested_objects.len() as u32; + + attrs.n_slider_ticks += slider + .nested_objects + .iter() + .filter(|nested| { + matches!( + nested.kind, + NestedSliderObjectKind::Tick | NestedSliderObjectKind::Repeat + ) + }) + .count() as u32; } OsuObjectKind::Spinner(_) => attrs.n_spinners += 1, } diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index c7d68323..47228420 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -28,6 +28,8 @@ pub struct OsuPerformance<'map> { pub(crate) difficulty: Difficulty, pub(crate) acc: Option, pub(crate) combo: Option, + pub(crate) slider_tick_hits: Option, + pub(crate) slider_end_hits: Option, pub(crate) n300: Option, pub(crate) n100: Option, pub(crate) n50: Option, @@ -166,6 +168,24 @@ impl<'map> OsuPerformance<'map> { self } + /// Specify the amount of hit slider ticks. + /// + /// Only relevant for osu!lazer. + pub const fn n_slider_ticks(mut self, n_slider_ticks: u32) -> Self { + self.slider_tick_hits = Some(n_slider_ticks); + + self + } + + /// Specify the amount of hit slider ends. + /// + /// Only relevant for osu!lazer. + pub const fn n_slider_ends(mut self, n_slider_ends: u32) -> Self { + self.slider_end_hits = Some(n_slider_ends); + + self + } + /// Specify the amount of 300s of a play. pub const fn n300(mut self, n300: u32) -> Self { self.n300 = Some(n300); @@ -293,6 +313,8 @@ impl<'map> OsuPerformance<'map> { pub const fn state(mut self, state: OsuScoreState) -> Self { let OsuScoreState { max_combo, + slider_tick_hits, + slider_end_hits, n300, n100, n50, @@ -300,6 +322,8 @@ impl<'map> OsuPerformance<'map> { } = state; self.combo = Some(max_combo); + self.slider_tick_hits = Some(slider_tick_hits); + self.slider_end_hits = Some(slider_end_hits); self.n300 = Some(n300); self.n100 = Some(n100); self.n50 = Some(n50); @@ -342,8 +366,29 @@ impl<'map> OsuPerformance<'map> { let mut n100 = self.n100.map_or(0, |n| cmp::min(n, n_remaining)); let mut n50 = self.n50.map_or(0, |n| cmp::min(n, n_remaining)); + let lazer = self.lazer.unwrap_or(true); + + let (n_slider_ends, n_slider_ticks, max_slider_ends, max_slider_ticks) = if lazer { + let n_slider_ends = self + .slider_end_hits + .map_or(attrs.n_sliders, |n| cmp::min(n, attrs.n_sliders)); + let n_slider_ticks = self + .slider_tick_hits + .map_or(attrs.n_slider_ticks, |n| cmp::min(n, attrs.n_slider_ticks)); + + ( + n_slider_ends, + n_slider_ticks, + attrs.n_sliders, + attrs.n_slider_ticks, + ) + } else { + (0, 0, 0, 0) + }; + if let Some(acc) = self.acc { - let target_total = acc * f64::from(6 * n_objects); + let target_total = + acc * f64::from(30 * n_objects + 15 * max_slider_ends + 3 * max_slider_ticks); match (self.n300, self.n100, self.n50) { (Some(_), Some(_), Some(_)) => { @@ -363,13 +408,28 @@ impl<'map> OsuPerformance<'map> { n300 = cmp::min(n300, n_remaining); let n_remaining = n_remaining - n300; - let raw_n100 = target_total - f64::from(n_remaining + 6 * n300); + let raw_n100 = (target_total + - f64::from( + 5 * n_remaining + 30 * n300 + 15 * n_slider_ends + 3 * n_slider_ticks, + )) + / 5.0; let min_n100 = cmp::min(n_remaining, raw_n100.floor() as u32); let max_n100 = cmp::min(n_remaining, raw_n100.ceil() as u32); for new100 in min_n100..=max_n100 { let new50 = n_remaining - new100; - let dist = (acc - accuracy(n300, new100, new50, misses)).abs(); + let dist = (acc + - accuracy( + n_slider_ticks, + n_slider_ends, + n300, + new100, + new50, + misses, + max_slider_ticks, + max_slider_ends, + )) + .abs(); if dist < best_dist { best_dist = dist; @@ -384,13 +444,28 @@ impl<'map> OsuPerformance<'map> { n100 = cmp::min(n100, n_remaining); let n_remaining = n_remaining - n100; - let raw_n300 = (target_total - f64::from(n_remaining + 2 * n100)) / 5.0; + let raw_n300 = (target_total + - f64::from( + 5 * n_remaining + 10 * n100 + 15 * n_slider_ends + 3 * n_slider_ticks, + )) + / 25.0; let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32); let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32); for new300 in min_n300..=max_n300 { let new50 = n_remaining - new300; - let curr_dist = (acc - accuracy(new300, n100, new50, misses)).abs(); + let curr_dist = (acc + - accuracy( + n_slider_ticks, + n_slider_ends, + new300, + n100, + new50, + misses, + max_slider_ticks, + max_slider_ends, + )) + .abs(); if curr_dist < best_dist { best_dist = curr_dist; @@ -405,16 +480,27 @@ impl<'map> OsuPerformance<'map> { n50 = cmp::min(n50, n_remaining); let n_remaining = n_remaining - n50; - let raw_n300 = (target_total + f64::from(2 * misses + n50) - - f64::from(2 * n_objects)) - / 4.0; + let raw_n300 = (target_total + f64::from(10 * misses + 5 * n50) + - f64::from(10 * n_objects + 15 * n_slider_ends + 3 * n_slider_ticks)) + / 20.0; let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32); let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32); for new300 in min_n300..=max_n300 { let new100 = n_remaining - new300; - let curr_dist = (acc - accuracy(new300, new100, n50, misses)).abs(); + let curr_dist = (acc + - accuracy( + n_slider_ticks, + n_slider_ends, + new300, + new100, + n50, + misses, + max_slider_ticks, + max_slider_ends, + )) + .abs(); if curr_dist < best_dist { best_dist = curr_dist; @@ -426,18 +512,38 @@ impl<'map> OsuPerformance<'map> { (None, None, None) => { let mut best_dist = f64::MAX; - let raw_n300 = (target_total - f64::from(n_remaining)) / 5.0; + let raw_n300 = (target_total + - f64::from(5 * n_remaining + 15 * n_slider_ends + 3 * n_slider_ticks)) + / 25.0; let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32); let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32); for new300 in min_n300..=max_n300 { - let raw_n100 = target_total - f64::from(n_remaining + 5 * new300); + let raw_n100 = (target_total + - f64::from( + 5 * n_remaining + + 25 * new300 + + 15 * n_slider_ends + + 3 * n_slider_ticks, + )) + / 5.0; let min_n100 = cmp::min(raw_n100.floor() as u32, n_remaining - new300); let max_n100 = cmp::min(raw_n100.ceil() as u32, n_remaining - new300); for new100 in min_n100..=max_n100 { let new50 = n_remaining - new300 - new100; - let curr_dist = (acc - accuracy(new300, new100, new50, misses)).abs(); + let curr_dist = (acc + - accuracy( + n_slider_ticks, + n_slider_ends, + new300, + new100, + new50, + misses, + max_slider_ticks, + max_slider_ends, + )) + .abs(); if curr_dist < best_dist { best_dist = curr_dist; @@ -492,6 +598,8 @@ impl<'map> OsuPerformance<'map> { }); self.combo = Some(max_combo); + self.slider_end_hits = Some(n_slider_ends); + self.slider_tick_hits = Some(n_slider_ticks); self.n300 = Some(n300); self.n100 = Some(n100); self.n50 = Some(n50); @@ -499,6 +607,8 @@ impl<'map> OsuPerformance<'map> { OsuScoreState { max_combo, + slider_tick_hits: n_slider_ticks, + slider_end_hits: n_slider_ends, n300, n100, n50, @@ -517,13 +627,21 @@ impl<'map> OsuPerformance<'map> { let effective_miss_count = calculate_effective_misses(&attrs, &state); + let lazer = self.lazer.unwrap_or(true); + + let (n_slider_ends, n_slider_ticks) = if lazer { + (attrs.n_sliders, attrs.n_slider_ticks) + } else { + (0, 0) + }; + let inner = OsuPerformanceInner { attrs, mods: self.difficulty.get_mods(), - acc: state.accuracy(), + acc: state.accuracy(n_slider_ticks, n_slider_ends), state, effective_miss_count, - lazer: self.lazer.unwrap_or(true), + lazer, }; inner.calculate() @@ -535,6 +653,8 @@ impl<'map> OsuPerformance<'map> { difficulty: Difficulty::new(), acc: None, combo: None, + slider_tick_hits: None, + slider_end_hits: None, n300: None, n100: None, n50: None, @@ -894,13 +1014,24 @@ fn calculate_effective_misses(attrs: &OsuDifficultyAttributes, state: &OsuScoreS combo_based_miss_count.max(f64::from(state.misses)) } -fn accuracy(n300: u32, n100: u32, n50: u32, misses: u32) -> f64 { - if n300 + n100 + n50 + misses == 0 { +fn accuracy( + n_slider_ticks: u32, + n_slider_ends: u32, + n300: u32, + n100: u32, + n50: u32, + misses: u32, + max_slider_ticks: u32, + max_slider_ends: u32, +) -> f64 { + if n_slider_ticks + n_slider_ends + n300 + n100 + n50 + misses == 0 { return 0.0; } - let numerator = 6 * n300 + 2 * n100 + n50; - let denominator = 6 * (n300 + n100 + n50 + misses); + let numerator = 300 * n300 + 100 * n100 + 50 * n50 + 150 * n_slider_ends + 30 * n_slider_ticks; + + let denominator = + 300 * (n300 + n100 + n50 + misses) + 150 * max_slider_ends + 30 * max_slider_ticks; f64::from(numerator) / f64::from(denominator) } @@ -922,6 +1053,8 @@ mod test { static ATTRS: OnceLock = OnceLock::new(); const N_OBJECTS: u32 = 601; + const N_SLIDERS: u32 = 293; + const N_SLIDER_TICKS: u32 = 15; fn beatmap() -> Beatmap { Beatmap::from_path("./resources/2785319.osu").unwrap() @@ -941,6 +1074,8 @@ mod test { attrs.n_circles + attrs.n_sliders + attrs.n_spinners, N_OBJECTS, ); + assert_eq!(attrs.n_sliders, N_SLIDERS); + assert_eq!(attrs.n_slider_ticks, N_SLIDER_TICKS); attrs }) @@ -952,7 +1087,10 @@ mod test { /// /// Very slow but accurate. fn brute_force_best( + lazer: bool, acc: f64, + n_slider_ticks: Option, + n_slider_ends: Option, n300: Option, n100: Option, n50: Option, @@ -961,8 +1099,20 @@ mod test { ) -> OsuScoreState { let misses = cmp::min(misses, N_OBJECTS); + let (n_slider_ends, n_slider_ticks, max_slider_ends, max_slider_ticks) = if lazer { + let n_slider_ends = n_slider_ends.map_or(N_SLIDERS, |n| cmp::min(n, N_SLIDERS)); + let n_slider_ticks = + n_slider_ticks.map_or(N_SLIDER_TICKS, |n| cmp::min(n, N_SLIDER_TICKS)); + + (n_slider_ends, n_slider_ticks, N_SLIDERS, N_SLIDER_TICKS) + } else { + (0, 0, 0, 0) + }; + let mut best_state = OsuScoreState { misses, + slider_end_hits: n_slider_ends, + slider_tick_hits: n_slider_ticks, ..Default::default() }; @@ -998,7 +1148,16 @@ mod test { None => n_remaining.saturating_sub(new300 + new100), }; - let curr_acc = accuracy(new300, new100, new50, misses); + let curr_acc = accuracy( + n_slider_ticks, + n_slider_ends, + new300, + new100, + new50, + misses, + max_slider_ticks, + max_slider_ends, + ); let curr_dist = (acc - curr_acc).abs(); if curr_dist < best_dist { @@ -1042,10 +1201,13 @@ mod test { #[test] fn hitresults( - acc in 0.0..=1.0, - n300 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10), - n100 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10), - n50 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10), + lazer in prop::bool::ANY, + acc in 0.0_f64..=1.0, + n_slider_ticks in prop::option::weighted(0.1, 0_u32..=N_SLIDER_TICKS + 10), + n_slider_ends in prop::option::weighted(0.1, 0_u32..=N_SLIDERS + 10), + n300 in prop::option::weighted(0.1, 0_u32..=N_OBJECTS + 10), + n100 in prop::option::weighted(0.1, 0_u32..=N_OBJECTS + 10), + n50 in prop::option::weighted(0.1, 0_u32..=N_OBJECTS + 10), n_misses in prop::option::weighted(0.15, 0_u32..=N_OBJECTS + 10), best_case in prop::bool::ANY, ) { @@ -1060,8 +1222,17 @@ mod test { let mut state = OsuPerformance::from(attrs) .accuracy(acc * 100.0) + .lazer(lazer) .hitresult_priority(priority); + if let Some(n_slider_ticks) = n_slider_ticks { + state = state.n_slider_ticks(n_slider_ticks); + } + + if let Some(n_slider_ends) = n_slider_ends { + state = state.n_slider_ends(n_slider_ends); + } + if let Some(n300) = n300 { state = state.n300(n300); } @@ -1083,7 +1254,10 @@ mod test { assert_eq!(first, state); let mut expected = brute_force_best( + lazer, acc, + n_slider_ticks, + n_slider_ends, n300, n100, n50, @@ -1100,6 +1274,7 @@ mod test { fn hitresults_n300_n100_misses_best() { let state = OsuPerformance::from(attrs()) .combo(500) + .lazer(true) .n300(300) .n100(20) .misses(2) @@ -1108,6 +1283,8 @@ mod test { let expected = OsuScoreState { max_combo: 500, + slider_tick_hits: N_SLIDER_TICKS, + slider_end_hits: N_SLIDERS, n300: 300, n100: 20, n50: 279, @@ -1120,6 +1297,7 @@ mod test { #[test] fn hitresults_n300_n50_misses_best() { let state = OsuPerformance::from(attrs()) + .lazer(false) .combo(500) .n300(300) .n50(10) @@ -1129,6 +1307,8 @@ mod test { let expected = OsuScoreState { max_combo: 500, + slider_tick_hits: 0, + slider_end_hits: 0, n300: 300, n100: 289, n50: 10, @@ -1141,6 +1321,7 @@ mod test { #[test] fn hitresults_n50_misses_worst() { let state = OsuPerformance::from(attrs()) + .lazer(true) .combo(500) .n50(10) .misses(2) @@ -1149,6 +1330,8 @@ mod test { let expected = OsuScoreState { max_combo: 500, + slider_tick_hits: N_SLIDER_TICKS, + slider_end_hits: N_SLIDERS, n300: 0, n100: 589, n50: 10, @@ -1161,6 +1344,7 @@ mod test { #[test] fn hitresults_n300_n100_n50_misses_worst() { let state = OsuPerformance::from(attrs()) + .lazer(false) .combo(500) .n300(300) .n100(50) @@ -1171,6 +1355,8 @@ mod test { let expected = OsuScoreState { max_combo: 500, + slider_tick_hits: 0, + slider_end_hits: 0, n300: 300, n100: 50, n50: 249, diff --git a/src/osu/score_state.rs b/src/osu/score_state.rs index 2305ebe6..e5a54ea6 100644 --- a/src/osu/score_state.rs +++ b/src/osu/score_state.rs @@ -1,9 +1,17 @@ /// Aggregation for a score's current state. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct OsuScoreState { - /// Maximum combo that the score has had so far. - /// **Not** the maximum possible combo of the map so far. + /// Maximum combo that the score has had so far. **Not** the maximum + /// possible combo of the map so far. pub max_combo: u32, + /// Amount of successfully hit slider ticks and repeat. + /// + /// Only relevant for osu!lazer. + pub slider_tick_hits: u32, + /// Amount of successfully hit slider ends. + /// + /// Only relevant for osu!lazer. + pub slider_end_hits: u32, /// Amount of current 300s. pub n300: u32, /// Amount of current 100s. @@ -19,6 +27,8 @@ impl OsuScoreState { pub const fn new() -> Self { Self { max_combo: 0, + slider_tick_hits: 0, + slider_end_hits: 0, n300: 0, n100: 0, n50: 0, @@ -32,15 +42,35 @@ impl OsuScoreState { } /// Calculate the accuracy between `0.0` and `1.0` for this state. - pub fn accuracy(&self) -> f64 { - let total_hits = self.total_hits(); - - if total_hits == 0 { + /// + /// `max_slider_ticks` and `max_slider_ends` are only relevant for + /// `osu!lazer` scores. Otherwise, they may be `0`. + pub fn accuracy(&self, max_slider_ticks: u32, max_slider_ends: u32) -> f64 { + if self.total_hits() + self.slider_tick_hits + self.slider_end_hits == 0 { return 0.0; } - let numerator = 6 * self.n300 + 2 * self.n100 + self.n50; - let denominator = 6 * total_hits; + debug_assert!( + self.slider_end_hits <= max_slider_ends, + "`self.slider_end_hits` must not be greater than `max_slider_ends`" + ); + debug_assert!( + self.slider_tick_hits <= max_slider_ticks, + "`self.slider_tick_hits` must not be greater than `max_slider_ticks`" + ); + + let numerator = 300 * self.n300 + + 100 * self.n100 + + 50 * self.n50 + + 150 * self.slider_end_hits + + 30 * self.slider_tick_hits; + + let denominator = 300 * self.n300 + + 300 * self.n100 + + 300 * self.n50 + + 300 * self.misses + + 150 * max_slider_ends + + 30 * max_slider_ticks; f64::from(numerator) / f64::from(denominator) } diff --git a/src/taiko/performance/mod.rs b/src/taiko/performance/mod.rs index 6a8ac6e1..95e1ce81 100644 --- a/src/taiko/performance/mod.rs +++ b/src/taiko/performance/mod.rs @@ -364,6 +364,8 @@ impl<'map> TryFrom> for TaikoPerformance<'map> { difficulty, acc, combo, + slider_tick_hits: _, + slider_end_hits: _, n300, n100, n50: _, diff --git a/tests/difficulty.rs b/tests/difficulty.rs index 552a0707..8f3e735a 100644 --- a/tests/difficulty.rs +++ b/tests/difficulty.rs @@ -42,6 +42,7 @@ macro_rules! test_cases { hp: $hp:literal, n_circles: $n_circles:literal, n_sliders: $n_sliders:literal, + n_slider_ticks: $n_slider_ticks:literal, n_spinners: $n_spinners:literal, stars: $stars:literal, max_combo: $max_combo:literal, @@ -59,6 +60,7 @@ macro_rules! test_cases { hp: $hp, n_circles: $n_circles, n_sliders: $n_sliders, + n_slider_ticks: $n_slider_ticks, n_spinners: $n_spinners, stars: $stars, max_combo: $max_combo, @@ -139,6 +141,7 @@ fn basic_osu() { hp: 5.0, n_circles: 307, n_sliders: 293, + n_slider_ticks: 15, n_spinners: 1, stars: 5.643619989739299, max_combo: 909, @@ -156,6 +159,7 @@ fn basic_osu() { hp: 5.0, n_circles: 307, n_sliders: 293, + n_slider_ticks: 15, n_spinners: 1, stars: 5.643619989739299, max_combo: 909, @@ -173,6 +177,7 @@ fn basic_osu() { hp: 7.0, n_circles: 307, n_sliders: 293, + n_slider_ticks: 15, n_spinners: 1, stars: 6.243301253337941, max_combo: 909, @@ -190,6 +195,7 @@ fn basic_osu() { hp: 5.0, n_circles: 307, n_sliders: 293, + n_slider_ticks: 15, n_spinners: 1, stars: 8.030649319285482, max_combo: 909, @@ -207,6 +213,7 @@ fn basic_osu() { hp: 5.0, n_circles: 307, n_sliders: 293, + n_slider_ticks: 15, n_spinners: 1, stars: 6.858771801534423, max_combo: 909, @@ -224,6 +231,7 @@ fn basic_osu() { hp: 5.0, n_circles: 307, n_sliders: 293, + n_slider_ticks: 15, n_spinners: 1, stars: 7.167932950561898, max_combo: 909, diff --git a/tests/performance.rs b/tests/performance.rs index ee06a660..ec6fbcce 100644 --- a/tests/performance.rs +++ b/tests/performance.rs @@ -39,7 +39,7 @@ macro_rules! test_cases { effective_miss_count: $effective_miss_count:expr, }) => { ( - OsuPerformance::from($map.as_owned()).lazer(false), + OsuPerformance::from($map.as_owned()).lazer(true), OsuPerformanceAttributes { pp: $pp, pp_acc: $pp_acc, From 2c77ddf4a972defd43ac9ce1764802a1526453d6 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 18 Oct 2024 13:22:45 +0200 Subject: [PATCH 20/48] refactor!: store `lazer` in `Difficulty` --- src/any/difficulty/inspect.rs | 10 ++++++++++ src/any/difficulty/mod.rs | 22 ++++++++++++++++++++++ src/any/performance/mod.rs | 2 +- src/catch/performance/mod.rs | 1 - src/mania/performance/mod.rs | 1 - src/osu/convert.rs | 12 +----------- src/osu/difficulty/gradual.rs | 2 ++ src/osu/object.rs | 13 +++++++++++++ src/osu/performance/gradual.rs | 5 ++++- src/osu/performance/mod.rs | 12 +++++------- src/taiko/performance/mod.rs | 1 - 11 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/any/difficulty/inspect.rs b/src/any/difficulty/inspect.rs index 1f76ac94..f412fc61 100644 --- a/src/any/difficulty/inspect.rs +++ b/src/any/difficulty/inspect.rs @@ -27,6 +27,11 @@ pub struct InspectDifficulty { /// /// Only relevant for osu!catch. pub hardrock_offsets: Option, + /// Whether the calculated attributes belong to an osu!lazer or osu!stable + /// score. + /// + /// Defaults to `true`. + pub lazer: Option, } impl InspectDifficulty { @@ -41,6 +46,7 @@ impl InspectDifficulty { hp, od, hardrock_offsets, + lazer, } = self; let mut difficulty = Difficulty::new().mods(mods); @@ -73,6 +79,10 @@ impl InspectDifficulty { difficulty = difficulty.hardrock_offsets(hardrock_offsets); } + if let Some(lazer) = lazer { + difficulty = difficulty.lazer(lazer); + } + difficulty } } diff --git a/src/any/difficulty/mod.rs b/src/any/difficulty/mod.rs index fa55fc7a..9a42d244 100644 --- a/src/any/difficulty/mod.rs +++ b/src/any/difficulty/mod.rs @@ -62,6 +62,7 @@ pub struct Difficulty { hp: Option, od: Option, hardrock_offsets: Option, + lazer: Option, } /// Wrapper for beatmap attributes in [`Difficulty`]. @@ -97,6 +98,7 @@ impl Difficulty { hp: None, od: None, hardrock_offsets: None, + lazer: None, } } @@ -120,6 +122,7 @@ impl Difficulty { hp, od, hardrock_offsets, + lazer, } = self; InspectDifficulty { @@ -131,6 +134,7 @@ impl Difficulty { hp, od, hardrock_offsets, + lazer, } } @@ -268,6 +272,18 @@ impl Difficulty { self } + /// Whether the calculated attributes belong to an osu!lazer or osu!stable + /// score. + /// + /// Defaults to `true`. + /// + /// Only relevant for osu!standard performance calculation. + pub const fn lazer(mut self, lazer: bool) -> Self { + self.lazer = Some(lazer); + + self + } + /// Perform the difficulty calculation. pub fn calculate(&self, map: &Beatmap) -> DifficultyAttributes { let map = Cow::Borrowed(map); @@ -347,6 +363,10 @@ impl Difficulty { self.hardrock_offsets .unwrap_or_else(|| self.mods.hardrock_offsets()) } + + pub(crate) fn get_lazer(&self) -> bool { + self.lazer.unwrap_or(true) + } } fn non_zero_u32_to_f32(n: NonZeroU32) -> f32 { @@ -364,6 +384,7 @@ impl Debug for Difficulty { hp, od, hardrock_offsets, + lazer, } = self; f.debug_struct("Difficulty") @@ -375,6 +396,7 @@ impl Debug for Difficulty { .field("hp", hp) .field("od", od) .field("hardrock_offsets", hardrock_offsets) + .field("lazer", lazer) .finish() } } diff --git a/src/any/performance/mod.rs b/src/any/performance/mod.rs index 36e48352..6acf60cb 100644 --- a/src/any/performance/mod.rs +++ b/src/any/performance/mod.rs @@ -302,7 +302,7 @@ impl<'map> Performance<'map> { /// Whether the calculated attributes belong to an osu!lazer or osu!stable /// score. /// - /// Defaults to lazer. + /// Defaults to `true`. /// /// This affects internal accuracy calculation because lazer considers /// slider heads for accuracy whereas stable does not. diff --git a/src/catch/performance/mod.rs b/src/catch/performance/mod.rs index 8ceb4ac6..e384e6fa 100644 --- a/src/catch/performance/mod.rs +++ b/src/catch/performance/mod.rs @@ -481,7 +481,6 @@ impl<'map> TryFrom> for CatchPerformance<'map> { n50, misses, hitresult_priority: _, - lazer: _, } = osu; Ok(Self { diff --git a/src/mania/performance/mod.rs b/src/mania/performance/mod.rs index 3e8f0112..582a40ef 100644 --- a/src/mania/performance/mod.rs +++ b/src/mania/performance/mod.rs @@ -834,7 +834,6 @@ impl<'map> TryFrom> for ManiaPerformance<'map> { n50, misses, hitresult_priority, - lazer: _, } = osu; Ok(Self { diff --git a/src/osu/convert.rs b/src/osu/convert.rs index a3227f57..c5269b10 100644 --- a/src/osu/convert.rs +++ b/src/osu/convert.rs @@ -55,18 +55,8 @@ pub fn convert_objects( OsuObjectKind::Circle => attrs.n_circles += 1, OsuObjectKind::Slider(ref slider) => { attrs.n_sliders += 1; + attrs.n_slider_ticks += slider.tick_count() as u32; attrs.max_combo += slider.nested_objects.len() as u32; - - attrs.n_slider_ticks += slider - .nested_objects - .iter() - .filter(|nested| { - matches!( - nested.kind, - NestedSliderObjectKind::Tick | NestedSliderObjectKind::Repeat - ) - }) - .count() as u32; } OsuObjectKind::Spinner(_) => attrs.n_spinners += 1, } diff --git a/src/osu/difficulty/gradual.rs b/src/osu/difficulty/gradual.rs index fc724ae5..3e3b7548 100644 --- a/src/osu/difficulty/gradual.rs +++ b/src/osu/difficulty/gradual.rs @@ -92,6 +92,7 @@ impl OsuGradualDifficulty { attrs.n_circles = 0; attrs.n_sliders = 0; + attrs.n_slider_ticks = 0; attrs.n_spinners = 0; attrs.max_combo = 0; @@ -128,6 +129,7 @@ impl OsuGradualDifficulty { OsuObjectKind::Circle => attrs.n_circles += 1, OsuObjectKind::Slider(slider) => { attrs.n_sliders += 1; + attrs.n_slider_ticks += slider.tick_count() as u32; attrs.max_combo += slider.nested_objects.len() as u32; } OsuObjectKind::Spinner { .. } => attrs.n_spinners += 1, diff --git a/src/osu/object.rs b/src/osu/object.rs index cb078638..6d929c51 100644 --- a/src/osu/object.rs +++ b/src/osu/object.rs @@ -308,6 +308,19 @@ impl OsuSlider { .count() } + /// Counts both ticks and repeats + pub fn tick_count(&self) -> usize { + self.nested_objects + .iter() + .filter(|nested| { + matches!( + nested.kind, + NestedSliderObjectKind::Tick | NestedSliderObjectKind::Repeat + ) + }) + .count() + } + pub fn tail(&self) -> Option<&NestedSliderObject> { self.nested_objects .iter() diff --git a/src/osu/performance/gradual.rs b/src/osu/performance/gradual.rs index 749ef5d2..f3d43c1b 100644 --- a/src/osu/performance/gradual.rs +++ b/src/osu/performance/gradual.rs @@ -80,15 +80,17 @@ use super::{OsuPerformanceAttributes, OsuScoreState}; /// [`next`]: OsuGradualPerformance::next /// [`nth`]: OsuGradualPerformance::nth pub struct OsuGradualPerformance { + lazer: bool, difficulty: OsuGradualDifficulty, } impl OsuGradualPerformance { /// Create a new gradual performance calculator for osu!standard maps. pub fn new(difficulty: Difficulty, converted: &OsuBeatmap<'_>) -> Self { + let lazer = difficulty.get_lazer(); let difficulty = OsuGradualDifficulty::new(difficulty, converted); - Self { difficulty } + Self { lazer, difficulty } } /// Process the next hit object and calculate the performance attributes @@ -113,6 +115,7 @@ impl OsuGradualPerformance { .difficulty .nth(n)? .performance() + .lazer(self.lazer) .state(state) .difficulty(self.difficulty.difficulty.clone()) .passed_objects(self.difficulty.idx as u32) diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index 47228420..6c1074ae 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -35,7 +35,6 @@ pub struct OsuPerformance<'map> { pub(crate) n50: Option, pub(crate) misses: Option, pub(crate) hitresult_priority: HitResultPriority, - pub(crate) lazer: Option, } impl<'map> OsuPerformance<'map> { @@ -158,12 +157,12 @@ impl<'map> OsuPerformance<'map> { /// Whether the calculated attributes belong to an osu!lazer or osu!stable /// score. /// - /// Defaults to lazer. + /// Defaults to `true`. /// /// This affects internal accuracy calculation because lazer considers /// slider heads for accuracy whereas stable does not. - pub const fn lazer(mut self, lazer: bool) -> Self { - self.lazer = Some(lazer); + pub fn lazer(mut self, lazer: bool) -> Self { + self.difficulty = self.difficulty.lazer(lazer); self } @@ -366,7 +365,7 @@ impl<'map> OsuPerformance<'map> { let mut n100 = self.n100.map_or(0, |n| cmp::min(n, n_remaining)); let mut n50 = self.n50.map_or(0, |n| cmp::min(n, n_remaining)); - let lazer = self.lazer.unwrap_or(true); + let lazer = self.difficulty.get_lazer(); let (n_slider_ends, n_slider_ticks, max_slider_ends, max_slider_ticks) = if lazer { let n_slider_ends = self @@ -627,7 +626,7 @@ impl<'map> OsuPerformance<'map> { let effective_miss_count = calculate_effective_misses(&attrs, &state); - let lazer = self.lazer.unwrap_or(true); + let lazer = self.difficulty.get_lazer(); let (n_slider_ends, n_slider_ticks) = if lazer { (attrs.n_sliders, attrs.n_slider_ticks) @@ -660,7 +659,6 @@ impl<'map> OsuPerformance<'map> { n50: None, misses: None, hitresult_priority: HitResultPriority::DEFAULT, - lazer: None, } } } diff --git a/src/taiko/performance/mod.rs b/src/taiko/performance/mod.rs index 95e1ce81..a5067dd4 100644 --- a/src/taiko/performance/mod.rs +++ b/src/taiko/performance/mod.rs @@ -371,7 +371,6 @@ impl<'map> TryFrom> for TaikoPerformance<'map> { n50: _, misses, hitresult_priority, - lazer: _, } = osu; Ok(Self { From 391eb55329218ba5be8b06f1f265d6a8c82caa95 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 18 Oct 2024 13:32:09 +0200 Subject: [PATCH 21/48] chore: made clippy happy --- src/osu/performance/mod.rs | 134 ++++++++++++++++++++----------------- 1 file changed, 72 insertions(+), 62 deletions(-) diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index 6c1074ae..96c0e295 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -417,18 +417,17 @@ impl<'map> OsuPerformance<'map> { for new100 in min_n100..=max_n100 { let new50 = n_remaining - new100; - let dist = (acc - - accuracy( - n_slider_ticks, - n_slider_ends, - n300, - new100, - new50, - misses, - max_slider_ticks, - max_slider_ends, - )) - .abs(); + + let state = NoComboState { + n300, + n100: new100, + n50: new50, + misses, + n_slider_ticks, + n_slider_ends, + }; + + let dist = (acc - accuracy(state, max_slider_ticks, max_slider_ends)).abs(); if dist < best_dist { best_dist = dist; @@ -453,18 +452,18 @@ impl<'map> OsuPerformance<'map> { for new300 in min_n300..=max_n300 { let new50 = n_remaining - new300; - let curr_dist = (acc - - accuracy( - n_slider_ticks, - n_slider_ends, - new300, - n100, - new50, - misses, - max_slider_ticks, - max_slider_ends, - )) - .abs(); + + let state = NoComboState { + n300: new300, + n100, + n50: new50, + misses, + n_slider_ticks, + n_slider_ends, + }; + + let curr_dist = + (acc - accuracy(state, max_slider_ticks, max_slider_ends)).abs(); if curr_dist < best_dist { best_dist = curr_dist; @@ -488,18 +487,18 @@ impl<'map> OsuPerformance<'map> { for new300 in min_n300..=max_n300 { let new100 = n_remaining - new300; - let curr_dist = (acc - - accuracy( - n_slider_ticks, - n_slider_ends, - new300, - new100, - n50, - misses, - max_slider_ticks, - max_slider_ends, - )) - .abs(); + + let state = NoComboState { + n300: new300, + n100: new100, + n50, + misses, + n_slider_ticks, + n_slider_ends, + }; + + let curr_dist = + (acc - accuracy(state, max_slider_ticks, max_slider_ends)).abs(); if curr_dist < best_dist { best_dist = curr_dist; @@ -531,18 +530,18 @@ impl<'map> OsuPerformance<'map> { for new100 in min_n100..=max_n100 { let new50 = n_remaining - new300 - new100; - let curr_dist = (acc - - accuracy( - n_slider_ticks, - n_slider_ends, - new300, - new100, - new50, - misses, - max_slider_ticks, - max_slider_ends, - )) - .abs(); + + let state = NoComboState { + n300: new300, + n100: new100, + n50: new50, + misses, + n_slider_ticks, + n_slider_ends, + }; + + let curr_dist = + (acc - accuracy(state, max_slider_ticks, max_slider_ends)).abs(); if curr_dist < best_dist { best_dist = curr_dist; @@ -1012,16 +1011,26 @@ fn calculate_effective_misses(attrs: &OsuDifficultyAttributes, state: &OsuScoreS combo_based_miss_count.max(f64::from(state.misses)) } -fn accuracy( - n_slider_ticks: u32, - n_slider_ends: u32, +struct NoComboState { n300: u32, n100: u32, n50: u32, misses: u32, - max_slider_ticks: u32, - max_slider_ends: u32, -) -> f64 { + n_slider_ticks: u32, + n_slider_ends: u32, +} + +#[allow(clippy::needless_pass_by_value)] +fn accuracy(state: NoComboState, max_slider_ticks: u32, max_slider_ends: u32) -> f64 { + let NoComboState { + n300, + n100, + n50, + misses, + n_slider_ticks, + n_slider_ends, + } = state; + if n_slider_ticks + n_slider_ends + n300 + n100 + n50 + misses == 0 { return 0.0; } @@ -1084,6 +1093,7 @@ mod test { /// and returns the [`OsuScoreState`] that matches `acc` the best. /// /// Very slow but accurate. + #[allow(clippy::too_many_arguments)] fn brute_force_best( lazer: bool, acc: f64, @@ -1146,16 +1156,16 @@ mod test { None => n_remaining.saturating_sub(new300 + new100), }; - let curr_acc = accuracy( + let state = NoComboState { + n300: new300, + n100: new100, + n50: new50, + misses, n_slider_ticks, n_slider_ends, - new300, - new100, - new50, - misses, - max_slider_ticks, - max_slider_ends, - ); + }; + + let curr_acc = accuracy(state, max_slider_ticks, max_slider_ends); let curr_dist = (acc - curr_acc).abs(); if curr_dist < best_dist { From 1ee664ae6291645e92c9b6d287ff8f1ba7090458 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 18 Oct 2024 13:40:05 +0200 Subject: [PATCH 22/48] test: remove target_os check --- tests/difficulty.rs | 96 --------------------------------------------- 1 file changed, 96 deletions(-) diff --git a/tests/difficulty.rs b/tests/difficulty.rs index 8f3e735a..8bc24dc8 100644 --- a/tests/difficulty.rs +++ b/tests/difficulty.rs @@ -125,7 +125,6 @@ macro_rules! test_cases { #[test] fn basic_osu() { - #[cfg(target_os = "windows")] test_cases! { Osu: OSU { NM => { @@ -238,101 +237,6 @@ fn basic_osu() { }; } }; - #[cfg(target_os = "linux")] // TODO - test_cases! { - Osu: OSU { - NM => { - aim: 2.8693628443424104, - speed: 2.533869745015772, - flashlight: 2.288770487900865, - slider_factor: 0.9803052946037858, - speed_note_count: 210.36373973116545, - ar: 9.300000190734863, - od: 8.800000190734863, - hp: 5.0, - n_circles: 307, - n_sliders: 293, - n_spinners: 1, - stars: 5.669858729379631, - max_combo: 909, - }; - HD => { - aim: 2.8693628443424104, - speed: 2.533869745015772, - flashlight: 2.606877929965889, - slider_factor: 0.9803052946037858, - speed_note_count: 210.36373973116545, - ar: 9.300000190734863, - od: 8.800000190734863, - hp: 5.0, - n_circles: 307, - n_sliders: 293, - n_spinners: 1, - stars: 5.669858729379631, - max_combo: 909, - }; - HR => { - aim: 3.2385394176190507, - speed: 2.7009854505234308, - flashlight: 2.8549217213059936, - slider_factor: 0.9690667605258665, - speed_note_count: 184.01205359079387, - ar: 10.0, - od: 10.0, - hp: 7.0, - n_circles: 307, - n_sliders: 293, - n_spinners: 1, - stars: 6.263576582906263, - max_combo: 909, - }; - DT => { - aim: 4.041442573946681, - speed: 3.6784866216272474, - flashlight: 3.319522943625448, - slider_factor: 0.9776943279272041, - speed_note_count: 214.80421464205617, - ar: 10.53333346048991, - od: 10.311111238267687, - hp: 5.0, - n_circles: 307, - n_sliders: 293, - n_spinners: 1, - stars: 8.085307648397626, - max_combo: 909, - }; - FL => { - aim: 2.8693628443424104, - speed: 2.533869745015772, - flashlight: 2.288770487900865, - slider_factor: 0.9803052946037858, - speed_note_count: 210.36373973116545, - ar: 9.300000190734863, - od: 8.800000190734863, - hp: 5.0, - n_circles: 307, - n_sliders: 293, - n_spinners: 1, - stars: 6.866778075388425, - max_combo: 909, - }; - HD FL => { - aim: 2.8693628443424104, - speed: 2.533869745015772, - flashlight: 2.606877929965889, - slider_factor: 0.9803052946037858, - speed_note_count: 210.36373973116545, - ar: 9.300000190734863, - od: 8.800000190734863, - hp: 5.0, - n_circles: 307, - n_sliders: 293, - n_spinners: 1, - stars: 7.172580382476152, - max_combo: 909, - }; - } - }; } #[test] From ad2609782b03cf05c8f45499dd54b9e67dafcc3d Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Wed, 30 Oct 2024 10:15:46 +0100 Subject: [PATCH 23/48] feat: avoid using missed slider estimates --- src/osu/performance/mod.rs | 100 +++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 32 deletions(-) diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index 96c0e295..d18c3962 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -623,9 +623,42 @@ impl<'map> OsuPerformance<'map> { MapOrAttrs::Attrs(attrs) => attrs, }; - let effective_miss_count = calculate_effective_misses(&attrs, &state); - + let mods = self.difficulty.get_mods(); let lazer = self.difficulty.get_lazer(); + let using_classic_slider_acc = mods.no_slider_head_acc(lazer); + + let mut effective_miss_count = 0.0; + + if attrs.n_sliders > 0 { + if using_classic_slider_acc { + // * 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); + + if f64::from(state.max_combo) < full_combo_threshold { + effective_miss_count = + full_combo_threshold / total_imperfect_hits(&state).max(1.0); + } + + // * In classic scores there can't be more misses than a sum of all non-perfect judgements + effective_miss_count = effective_miss_count.min(total_imperfect_hits(&state)); + } else { + let full_combo_threshold = + f64::from(attrs.max_combo - n_slider_ends_dropped(&attrs, &state)); + + if f64::from(state.max_combo) < full_combo_threshold { + effective_miss_count = + full_combo_threshold / total_imperfect_hits(&state).max(1.0); + } + + // * Combine regular misses with tick misses since tick misses break combo as well + effective_miss_count = effective_miss_count + .min(f64::from(n_slider_tick_miss(&attrs, &state) + state.misses)); + } + } + + effective_miss_count = effective_miss_count.max(f64::from(state.misses)); let (n_slider_ends, n_slider_ticks) = if lazer { (attrs.n_sliders, attrs.n_slider_ticks) @@ -635,11 +668,11 @@ impl<'map> OsuPerformance<'map> { let inner = OsuPerformanceInner { attrs, - mods: self.difficulty.get_mods(), + mods, acc: state.accuracy(n_slider_ticks, n_slider_ends), state, effective_miss_count, - lazer, + using_classic_slider_acc, }; inner.calculate() @@ -677,13 +710,11 @@ struct OsuPerformanceInner<'mods> { acc: f64, state: OsuScoreState, effective_miss_count: f64, - lazer: bool, + using_classic_slider_acc: bool, } impl OsuPerformanceInner<'_> { fn calculate(mut self) -> OsuPerformanceAttributes { - let using_classic_slider_acc = self.mods.no_slider_head_acc(self.lazer); - let total_hits = self.state.total_hits(); if total_hits == 0 { @@ -729,7 +760,7 @@ impl OsuPerformanceInner<'_> { let aim_value = self.compute_aim_value(); let speed_value = self.compute_speed_value(); - let acc_value = self.compute_accuracy_value(using_classic_slider_acc); + let acc_value = self.compute_accuracy_value(); let flashlight_value = self.compute_flashlight_value(); let pp = (aim_value.powf(1.1) @@ -796,15 +827,27 @@ impl OsuPerformanceInner<'_> { let estimate_diff_sliders = f64::from(self.attrs.n_sliders) * 0.15; if self.attrs.n_sliders > 0 { - let estimate_slider_ends_dropped = f64::from(cmp::min( - self.state.n100 + self.state.n50 + self.state.misses, - self.attrs.max_combo.saturating_sub(self.state.max_combo), - )) - .clamp(0.0, estimate_diff_sliders); + let estimate_improperly_followed_difficult_sliders = if self.using_classic_slider_acc { + // * When the score is considered classic (regardless if it was made on old client or not) we consider all missing combo to be dropped difficult sliders + let maximum_possible_droppled_sliders = total_imperfect_hits(&self.state); + + maximum_possible_droppled_sliders + .min(f64::from(self.attrs.max_combo - self.state.max_combo)) + .clamp(0.0, estimate_diff_sliders) + } else { + // * We add tick misses here since they too mean that the player didn't follow the slider properly + // * We however aren't adding misses here because missing slider heads has a harsh penalty by itself and doesn't mean that the rest of the slider wasn't followed properly + (f64::from( + n_slider_ends_dropped(&self.attrs, &self.state) + + n_slider_tick_miss(&self.attrs, &self.state), + )) + .min(estimate_diff_sliders) + }; + let slider_nerf_factor = (1.0 - self.attrs.slider_factor) - * (1.0 - estimate_slider_ends_dropped / estimate_diff_sliders).powf(3.0) + * (1.0 - estimate_improperly_followed_difficult_sliders / estimate_diff_sliders) + .powf(3.0) + self.attrs.slider_factor; - aim_value *= slider_nerf_factor; } @@ -886,7 +929,7 @@ impl OsuPerformanceInner<'_> { speed_value } - fn compute_accuracy_value(&self, using_classic_slider_acc: bool) -> f64 { + fn compute_accuracy_value(&self) -> f64 { if self.mods.rx() { return 0.0; } @@ -895,7 +938,7 @@ impl OsuPerformanceInner<'_> { // * of the calculation we focus on hitting the timing hit window. let mut amount_hit_objects_with_acc = self.attrs.n_circles; - if !using_classic_slider_acc { + if !self.using_classic_slider_acc { amount_hit_objects_with_acc += self.attrs.n_sliders; } @@ -992,23 +1035,16 @@ impl OsuPerformanceInner<'_> { } } -fn calculate_effective_misses(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> f64 { - // * Guess the number of misses + slider breaks from combo - let mut combo_based_miss_count = 0.0; - - if attrs.n_sliders > 0 { - let full_combo_threshold = f64::from(attrs.max_combo) - 0.1 * f64::from(attrs.n_sliders); - - if f64::from(state.max_combo) < full_combo_threshold { - combo_based_miss_count = full_combo_threshold / f64::from(state.max_combo).max(1.0); - } - } +fn total_imperfect_hits(state: &OsuScoreState) -> f64 { + f64::from(state.n100 + state.n50 + state.misses) +} - // * Clamp miss count to maximum amount of possible breaks - combo_based_miss_count = - combo_based_miss_count.min(f64::from(state.n100 + state.n50 + state.misses)); +fn n_slider_ends_dropped(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> u32 { + attrs.n_sliders - state.slider_end_hits +} - combo_based_miss_count.max(f64::from(state.misses)) +fn n_slider_tick_miss(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> u32 { + attrs.n_slider_ticks - state.slider_tick_hits } struct NoComboState { From 4053623d372be71c5ea34e936c22d57b42ca4500 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Wed, 30 Oct 2024 11:10:13 +0100 Subject: [PATCH 24/48] test: split target_os tests again for std --- tests/difficulty.rs | 114 +++++++++++++++++++++++++++++++++++++++++++ tests/performance.rs | 62 +++++++++++++++++++++++ 2 files changed, 176 insertions(+) diff --git a/tests/difficulty.rs b/tests/difficulty.rs index 8bc24dc8..80fac8e2 100644 --- a/tests/difficulty.rs +++ b/tests/difficulty.rs @@ -125,6 +125,7 @@ macro_rules! test_cases { #[test] fn basic_osu() { + #[cfg(target_os = "windows")] test_cases! { Osu: OSU { NM => { @@ -237,6 +238,119 @@ fn basic_osu() { }; } }; + #[cfg(target_os = "linux")] + test_cases! { + Osu: OSU { + NM => { + aim: 2.8811843667580206, + speed: 2.468469273849314, + flashlight: 2.287888783550428, + slider_factor: 0.9803293523973866, + speed_note_count: 204.88794724609374, + aim_difficult_strain_count: 106.63833474488393, + speed_difficult_strain_count: 79.9883004295862, + ar: 9.300000190734863, + od: 8.800000190734863, + hp: 5.0, + n_circles: 307, + n_sliders: 293, + n_slider_ticks: 15, + n_spinners: 1, + stars: 5.6436199897393005, + max_combo: 909, + }; + HD => { + aim: 2.8811843667580206, + speed: 2.468469273849314, + flashlight: 2.605859779358901, + slider_factor: 0.9803293523973866, + speed_note_count: 204.88794724609374, + aim_difficult_strain_count: 106.63833474488393, + speed_difficult_strain_count: 79.9883004295862, + ar: 9.300000190734863, + od: 8.800000190734863, + hp: 5.0, + n_circles: 307, + n_sliders: 293, + n_slider_ticks: 15, + n_spinners: 1, + stars: 5.6436199897393005, + max_combo: 909, + }; + HR => { + aim: 3.2515300463985666, + speed: 2.6323568908654615, + flashlight: 2.853761577136605, + slider_factor: 0.969089944826546, + speed_note_count: 178.52041495886283, + aim_difficult_strain_count: 108.03970474535397, + speed_difficult_strain_count: 73.27713411796513, + ar: 10.0, + od: 10.0, + hp: 7.0, + n_circles: 307, + n_sliders: 293, + n_slider_ticks: 15, + n_spinners: 1, + stars: 6.2433012533379415, + max_combo: 909, + }; + DT => { + aim: 4.058080039906945, + speed: 3.570932204630734, + flashlight: 3.318209122186825, + slider_factor: 0.9777224379583133, + speed_note_count: 211.29204189490912, + aim_difficult_strain_count: 126.95613629755243, + speed_difficult_strain_count: 95.63810649133869, + ar: 10.53333346048991, + od: 10.311111238267687, + hp: 5.0, + n_circles: 307, + n_sliders: 293, + n_slider_ticks: 15, + n_spinners: 1, + stars: 8.030649319285482, + max_combo: 909, + }; + FL => { + aim: 2.8811843667580206, + speed: 2.468469273849314, + flashlight: 2.287888783550428, + slider_factor: 0.9803293523973866, + speed_note_count: 204.88794724609374, + aim_difficult_strain_count: 106.63833474488393, + speed_difficult_strain_count: 79.9883004295862, + ar: 9.300000190734863, + od: 8.800000190734863, + hp: 5.0, + n_circles: 307, + n_sliders: 293, + n_slider_ticks: 15, + n_spinners: 1, + stars: 6.858771801534423, + max_combo: 909, + }; + HD FL => { + aim: 2.8811843667580206, + speed: 2.468469273849314, + flashlight: 2.605859779358901, + slider_factor: 0.9803293523973866, + speed_note_count: 204.88794724609374, + aim_difficult_strain_count: 106.63833474488393, + speed_difficult_strain_count: 79.9883004295862, + ar: 9.300000190734863, + od: 8.800000190734863, + hp: 5.0, + n_circles: 307, + n_sliders: 293, + n_slider_ticks: 15, + n_spinners: 1, + stars: 7.167932950561899, + max_combo: 909, + }; + } + }; } #[test] diff --git a/tests/performance.rs b/tests/performance.rs index ec6fbcce..4e729051 100644 --- a/tests/performance.rs +++ b/tests/performance.rs @@ -101,6 +101,7 @@ macro_rules! test_cases { #[test] fn basic_osu() { + #[cfg(target_os = "windows")] test_cases! { Osu: OSU { NM => { @@ -161,6 +162,67 @@ fn basic_osu() { }; } }; + #[cfg(target_os = "linux")] + test_cases! { + Osu: OSU { + NM => { + pp: 272.6047426867276, + pp_acc: 97.62287463107766, + pp_aim: 99.37265186861426, + pp_flashlight: 0.0, + pp_speed: 64.48542022217285, + effective_miss_count: 0.0, + }; + HD => { + pp: 299.17174736245363, + pp_acc: 105.43270460156388, + pp_aim: 110.10489751227142, + pp_flashlight: 0.0, + pp_speed: 71.4498451141828, + effective_miss_count: 0.0, + }; + EZ HD => { + pp: 186.7137498214991, + pp_acc: 16.6270597231239, + pp_aim: 98.11121656070222, + pp_flashlight: 0.0, + pp_speed: 61.51901495973101, + effective_miss_count: 0.0, + }; + HR => { + pp: 404.7030358947424, + pp_acc: 161.55575439788055, + pp_aim: 145.04665418031985, + pp_flashlight: 0.0, + pp_speed: 80.77088499277514, + effective_miss_count: 0.0, + }; + DT => { + pp: 738.7899608061098, + pp_acc: 184.09450675506795, + pp_aim: 304.16666833057235, + pp_flashlight: 0.0, + pp_speed: 220.06297202966698, + effective_miss_count: 0.0, + }; + FL => { + pp: 402.408877784248, + pp_acc: 99.57533212369923, + pp_aim: 99.37265186861426, + pp_flashlight: 132.29720631068272, + pp_speed: 64.48542022217285, + effective_miss_count: 0.0, + }; + HD FL => { + pp: 469.3245236137446, + pp_acc: 107.54135869359516, + pp_aim: 110.10489751227142, + pp_flashlight: 171.62594459401154, + pp_speed: 71.4498451141828, + effective_miss_count: 0.0, + }; + } + }; } #[test] From 19609cb1218ad7f07f41916dc25991bf7152c190 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Thu, 31 Oct 2024 21:11:22 +0100 Subject: [PATCH 25/48] feat: add slider hitresult methods to Performance --- src/any/performance/mod.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/any/performance/mod.rs b/src/any/performance/mod.rs index 6acf60cb..884bf361 100644 --- a/src/any/performance/mod.rs +++ b/src/any/performance/mod.rs @@ -316,6 +316,28 @@ impl<'map> Performance<'map> { } } + /// Specify the amount of hit slider ticks. + /// + /// Only relevant for osu!standard. + pub fn n_slider_ticks(self, n_slider_ticks: u32) -> Self { + if let Self::Osu(osu) = self { + Self::Osu(osu.n_slider_ticks(n_slider_ticks)) + } else { + self + } + } + + /// Specify the amount of hit slider ends. + /// + /// Only relevant for osu!standard. + pub fn n_slider_ends(self, n_slider_ends: u32) -> Self { + if let Self::Osu(osu) = self { + Self::Osu(osu.n_slider_ends(n_slider_ends)) + } else { + self + } + } + /// Specify the amount of 300s of a play. pub fn n300(self, n300: u32) -> Self { match self { From 98ca34182d835a731fce520d9593407616850918 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 1 Nov 2024 18:57:37 +0100 Subject: [PATCH 26/48] fix: cap effective miss count --- src/osu/performance/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index d18c3962..a111a983 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -659,6 +659,7 @@ impl<'map> OsuPerformance<'map> { } effective_miss_count = effective_miss_count.max(f64::from(state.misses)); + effective_miss_count = effective_miss_count.min(f64::from(state.total_hits())); let (n_slider_ends, n_slider_ticks) = if lazer { (attrs.n_sliders, attrs.n_slider_ticks) From 674cbec4c2384ca5b9d98f9c0931a67edafb6cd5 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 1 Nov 2024 18:59:31 +0100 Subject: [PATCH 27/48] chore: satisfy clippy --- src/osu/performance/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index a111a983..21070def 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -1040,11 +1040,11 @@ fn total_imperfect_hits(state: &OsuScoreState) -> f64 { f64::from(state.n100 + state.n50 + state.misses) } -fn n_slider_ends_dropped(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> u32 { +const fn n_slider_ends_dropped(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> u32 { attrs.n_sliders - state.slider_end_hits } -fn n_slider_tick_miss(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> u32 { +const fn n_slider_tick_miss(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> u32 { attrs.n_slider_ticks - state.slider_tick_hits } From c504511c86fbee317872456d6aebd350f0cb3c8a Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Sun, 3 Nov 2024 22:52:14 +0100 Subject: [PATCH 28/48] fix: properly divide by combo --- src/osu/performance/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index 21070def..06502e3a 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -638,7 +638,7 @@ impl<'map> OsuPerformance<'map> { if f64::from(state.max_combo) < full_combo_threshold { effective_miss_count = - full_combo_threshold / total_imperfect_hits(&state).max(1.0); + full_combo_threshold / f64::from(state.max_combo).max(1.0); } // * In classic scores there can't be more misses than a sum of all non-perfect judgements @@ -649,7 +649,7 @@ impl<'map> OsuPerformance<'map> { if f64::from(state.max_combo) < full_combo_threshold { effective_miss_count = - full_combo_threshold / total_imperfect_hits(&state).max(1.0); + full_combo_threshold / f64::from(state.max_combo).max(1.0); } // * Combine regular misses with tick misses since tick misses break combo as well From 45cd1ed357d3f01534d65faf4adc622e509caa60 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Thu, 7 Nov 2024 21:21:44 +0100 Subject: [PATCH 29/48] refactor: start effective miss count with miss count --- src/osu/performance/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index 06502e3a..06fe00e6 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -627,7 +627,7 @@ impl<'map> OsuPerformance<'map> { let lazer = self.difficulty.get_lazer(); let using_classic_slider_acc = mods.no_slider_head_acc(lazer); - let mut effective_miss_count = 0.0; + let mut effective_miss_count = f64::from(state.misses); if attrs.n_sliders > 0 { if using_classic_slider_acc { From 9e4db4096e56809281b68cb983578a66590053b7 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 8 Nov 2024 02:21:46 +0100 Subject: [PATCH 30/48] feat: include latest taiko changes --- src/taiko/attributes.rs | 3 ++ src/taiko/difficulty/gradual.rs | 5 ++ src/taiko/difficulty/mod.rs | 22 ++++++++- src/taiko/difficulty/skills/mod.rs | 4 +- src/taiko/difficulty/skills/stamina.rs | 65 +++++++++++++++++++------- src/taiko/performance/mod.rs | 20 +++++--- tests/difficulty.rs | 16 +++++-- tests/performance.rs | 40 ++++++++-------- 8 files changed, 126 insertions(+), 49 deletions(-) diff --git a/src/taiko/attributes.rs b/src/taiko/attributes.rs index 5748bdb0..fe1b4be5 100644 --- a/src/taiko/attributes.rs +++ b/src/taiko/attributes.rs @@ -15,6 +15,9 @@ pub struct TaikoDifficultyAttributes { pub great_hit_window: f64, /// The perceived hit window for an n100 inclusive of rate-adjusting mods (DT/HT/etc) pub ok_hit_window: f64, + /// The ratio of stamina difficulty from mono-color (single color) streams to total + /// stamina difficulty. + pub mono_stamina_factor: f64, /// The final star rating. pub stars: f64, /// The maximum combo. diff --git a/src/taiko/difficulty/gradual.rs b/src/taiko/difficulty/gradual.rs index 3ebfb53c..2dd54789 100644 --- a/src/taiko/difficulty/gradual.rs +++ b/src/taiko/difficulty/gradual.rs @@ -153,6 +153,8 @@ impl Iterator for TaikoGradualDifficulty { Skill::new(&mut self.skills.rhythm, &self.diff_objects).process(&borrowed); Skill::new(&mut self.skills.color, &self.diff_objects).process(&borrowed); Skill::new(&mut self.skills.stamina, &self.diff_objects).process(&borrowed); + Skill::new(&mut self.skills.single_color_stamina, &self.diff_objects) + .process(&borrowed); if borrowed.base_hit_type.is_hit() { self.attrs.max_combo += 1; @@ -231,6 +233,8 @@ impl Iterator for TaikoGradualDifficulty { let mut rhythm = Skill::new(&mut self.skills.rhythm, &self.diff_objects); let mut color = Skill::new(&mut self.skills.color, &self.diff_objects); let mut stamina = Skill::new(&mut self.skills.stamina, &self.diff_objects); + let mut single_color_stamina = + Skill::new(&mut self.skills.single_color_stamina, &self.diff_objects); for _ in 0..take { loop { @@ -239,6 +243,7 @@ impl Iterator for TaikoGradualDifficulty { rhythm.process(&borrowed); color.process(&borrowed); stamina.process(&borrowed); + single_color_stamina.process(&borrowed); if borrowed.base_hit_type.is_hit() { self.attrs.max_combo += 1; diff --git a/src/taiko/difficulty/mod.rs b/src/taiko/difficulty/mod.rs index 9395fadf..26299a5e 100644 --- a/src/taiko/difficulty/mod.rs +++ b/src/taiko/difficulty/mod.rs @@ -142,11 +142,14 @@ impl DifficultyValues { let mut rhythm = Skill::new(&mut skills.rhythm, &diff_objects); let mut color = Skill::new(&mut skills.color, &diff_objects); let mut stamina = Skill::new(&mut skills.stamina, &diff_objects); + let mut single_color_stamina = + Skill::new(&mut skills.single_color_stamina, &diff_objects); for hit_object in diff_objects.iter().take(n_diff_objects) { rhythm.process(&hit_object.get()); color.process(&hit_object.get()); stamina.process(&hit_object.get()); + single_color_stamina.process(&hit_object.get()); } } @@ -157,15 +160,32 @@ impl DifficultyValues { let color_rating = skills.color.as_difficulty_value() * COLOR_SKILL_MULTIPLIER; let rhythm_rating = skills.rhythm.as_difficulty_value() * RHYTHM_SKILL_MULTIPLIER; let stamina_rating = skills.stamina.as_difficulty_value() * STAMINA_SKILL_MULTIPLIER; + let mono_stamina_rating = + skills.single_color_stamina.as_difficulty_value() * STAMINA_SKILL_MULTIPLIER; + let mono_stamina_factor = if stamina_rating.abs() >= f64::EPSILON { + (mono_stamina_rating / stamina_rating).powf(5.0) + } else { + 1.0 + }; let combined_rating = combined_difficulty_value(skills.color, skills.rhythm, skills.stamina); - let star_rating = rescale(combined_rating * 1.4); + let mut star_rating = rescale(combined_rating * 1.4); + + // * TODO: This is temporary measure as we don't detect abuse of multiple-input playstyles of converts within the current system. + if attrs.is_convert { + star_rating *= 0.925; + // * For maps with low colour variance and high stamina requirement, multiple inputs are more likely to be abused. + if color_rating < 2.0 && stamina_rating > 8.0 { + star_rating *= 0.80; + } + } attrs.stamina = stamina_rating; attrs.rhythm = rhythm_rating; attrs.color = color_rating; attrs.peak = combined_rating; + attrs.mono_stamina_factor = mono_stamina_factor; attrs.stars = star_rating; } diff --git a/src/taiko/difficulty/skills/mod.rs b/src/taiko/difficulty/skills/mod.rs index bf032a84..b6bdf906 100644 --- a/src/taiko/difficulty/skills/mod.rs +++ b/src/taiko/difficulty/skills/mod.rs @@ -9,6 +9,7 @@ pub struct TaikoSkills { pub rhythm: Rhythm, pub color: Color, pub stamina: Stamina, + pub single_color_stamina: Stamina, } impl TaikoSkills { @@ -16,7 +17,8 @@ impl TaikoSkills { Self { rhythm: Rhythm::default(), color: Color::default(), - stamina: Stamina::default(), + stamina: Stamina::new(false), + single_color_stamina: Stamina::new(true), } } } diff --git a/src/taiko/difficulty/skills/stamina.rs b/src/taiko/difficulty/skills/stamina.rs index acbc7ef7..2aaa0f4e 100644 --- a/src/taiko/difficulty/skills/stamina.rs +++ b/src/taiko/difficulty/skills/stamina.rs @@ -1,24 +1,34 @@ use crate::{ any::difficulty::{ object::IDifficultyObject, - skills::{strain_decay, ISkill, Skill, StrainDecaySkill}, + skills::{strain_decay, ISkill, Skill, StrainDecaySkill, StrainSkill}, }, taiko::{ difficulty::object::{TaikoDifficultyObject, TaikoDifficultyObjects}, object::HitType, }, - util::strains_vec::StrainsVec, + util::{strains_vec::StrainsVec, sync::Weak}, }; const SKILL_MULTIPLIER: f64 = 1.1; const STRAIN_DECAY_BASE: f64 = 0.4; -#[derive(Clone, Default)] +#[derive(Clone)] pub struct Stamina { - inner: StrainDecaySkill, + inner: StrainSkill, + single_color: bool, + curr_strain: f64, } impl Stamina { + pub fn new(single_color: bool) -> Self { + Self { + inner: StrainSkill::default(), + single_color, + curr_strain: 0.0, + } + } + pub fn get_curr_strain_peaks(self) -> StrainsVec { self.inner.get_curr_strain_peaks() } @@ -36,6 +46,10 @@ impl ISkill for Stamina { impl Skill<'_, Stamina> { fn calculate_initial_strain(&mut self, time: f64, curr: &TaikoDifficultyObject) -> f64 { + if self.inner.single_color { + return 0.0; + } + let prev_start_time = curr .previous(0, &self.diff_objects.objects) .map_or(0.0, |prev| prev.get().start_time); @@ -44,27 +58,27 @@ impl Skill<'_, Stamina> { } const fn curr_strain(&self) -> f64 { - self.inner.inner.curr_strain + self.inner.curr_strain } fn curr_strain_mut(&mut self) -> &mut f64 { - &mut self.inner.inner.curr_strain + &mut self.inner.curr_strain } const fn curr_section_peak(&self) -> f64 { - self.inner.inner.inner.curr_section_peak + self.inner.inner.curr_section_peak } fn curr_section_peak_mut(&mut self) -> &mut f64 { - &mut self.inner.inner.inner.curr_section_peak + &mut self.inner.inner.curr_section_peak } const fn curr_section_end(&self) -> f64 { - self.inner.inner.inner.curr_section_end + self.inner.inner.curr_section_end } fn curr_section_end_mut(&mut self) -> &mut f64 { - &mut self.inner.inner.inner.curr_section_end + &mut self.inner.inner.curr_section_end } pub fn process(&mut self, curr: &TaikoDifficultyObject) { @@ -86,13 +100,30 @@ impl Skill<'_, Stamina> { fn strain_value_at(&mut self, curr: &TaikoDifficultyObject) -> f64 { *self.curr_strain_mut() *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE); - *self.curr_strain_mut() += self.strain_value_of(curr) * SKILL_MULTIPLIER; - - self.curr_strain() - } - - fn strain_value_of(&self, curr: &TaikoDifficultyObject) -> f64 { - StaminaEvaluator::evaluate_diff_of(curr, self.diff_objects) + *self.curr_strain_mut() += + StaminaEvaluator::evaluate_diff_of(curr, self.diff_objects) * SKILL_MULTIPLIER; + + // Safely prevents previous strains from shifting as new notes are added. + let index = curr + .color + .mono_streak + .as_ref() + .and_then(Weak::upgrade) + .and_then(|mono| { + mono.get().hit_objects.iter().position(|h| { + let Some(h) = h.upgrade() else { return false }; + let h = h.get(); + + h.idx == curr.idx + }) + }) + .unwrap_or(0); + + if self.inner.single_color { + self.curr_strain() / (1.0 + ((-(index as isize - 10)) as f64 / 2.0).exp()) + } else { + self.curr_strain() + } } } diff --git a/src/taiko/performance/mod.rs b/src/taiko/performance/mod.rs index a5067dd4..bd422df8 100644 --- a/src/taiko/performance/mod.rs +++ b/src/taiko/performance/mod.rs @@ -416,12 +416,12 @@ impl TaikoPerformanceInner<'_> { let mut multiplier = 1.13; - if self.mods.hd() { + if self.mods.hd() && !self.attrs.is_convert { multiplier *= 1.075; } if self.mods.ez() { - multiplier *= 0.975; + multiplier *= 0.95; } let diff_value = @@ -459,10 +459,10 @@ impl TaikoPerformanceInner<'_> { diff_value *= 0.986_f64.powf(effective_miss_count); if self.mods.ez() { - diff_value *= 0.985; + diff_value *= 0.9; } - if self.mods.hd() && !self.attrs.is_convert { + if self.mods.hd() { diff_value *= 1.025; } @@ -471,11 +471,19 @@ impl TaikoPerformanceInner<'_> { } if self.mods.fl() { - diff_value *= 1.05 * len_bonus; + diff_value *= + (1.05 - (self.attrs.mono_stamina_factor / 50.0).min(1.0) * len_bonus).max(1.0); } + // * Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps. + let acc_scaling_exp = f64::from(2) + self.attrs.mono_stamina_factor; + let acc_scaling_shift = f64::from(300) - f64::from(100) * self.attrs.mono_stamina_factor; + diff_value - * (special_functions::erf(400.0 / (2.0_f64.sqrt() * estimated_unstable_rate))).powf(2.0) + * (special_functions::erf( + acc_scaling_shift / (2.0_f64.sqrt() * estimated_unstable_rate), + )) + .powf(acc_scaling_exp) } fn compute_accuracy_value(&self, estimated_unstable_rate: Option) -> f64 { diff --git a/tests/difficulty.rs b/tests/difficulty.rs index 80fac8e2..1be41638 100644 --- a/tests/difficulty.rs +++ b/tests/difficulty.rs @@ -71,6 +71,7 @@ macro_rules! test_cases { rhythm: $rhythm:literal, color: $color:literal, peak: $peak:literal, + mono_stamina_factor: $mono_stamina_factor:literal, great_hit_window: $great_hit_window:literal, ok_hit_window: $ok_hit_window:literal, stars: $stars:literal, @@ -82,6 +83,7 @@ macro_rules! test_cases { rhythm: $rhythm, color: $color, peak: $peak, + mono_stamina_factor: $mono_stamina_factor, great_hit_window: $great_hit_window, ok_hit_window: $ok_hit_window, stars: $stars, @@ -362,6 +364,7 @@ fn basic_taiko() { rhythm: 0.20130047251681948, color: 1.0487315549761433, peak: 1.8422453377400778, + mono_stamina_factor: 2.66403971858592e-07, great_hit_window: 35.0, ok_hit_window: 80.0, stars: 2.914589700180437, @@ -373,6 +376,7 @@ fn basic_taiko() { rhythm: 0.20130047251681948, color: 1.0487315549761433, peak: 1.8422453377400778, + mono_stamina_factor: 2.66403971858592e-07, great_hit_window: 29.0, ok_hit_window: 68.0, stars: 2.914589700180437, @@ -384,6 +388,7 @@ fn basic_taiko() { rhythm: 0.4448175371191029, color: 1.363762496098889, peak: 2.625066421324458, + mono_stamina_factor: 2.515617502055679e-07, great_hit_window: 23.333333333333332, ok_hit_window: 53.333333333333336, stars: 3.942709244618132, @@ -403,9 +408,10 @@ fn convert_taiko() { rhythm: 1.4696991260446617, color: 2.303228172964907, peak: 4.117779264387738, + mono_stamina_factor: 0.0016957378202796742, great_hit_window: 23.59999942779541, ok_hit_window: 57.19999885559082, - stars: 5.660149021515273, + stars: 5.235637844901627, max_combo: 908, is_convert: true, }; @@ -414,9 +420,10 @@ fn convert_taiko() { rhythm: 1.4696991260446617, color: 2.303228172964907, peak: 4.117779264387738, + mono_stamina_factor: 0.0016957378202796742, great_hit_window: 20.0, - ok_hit_window: 50.0, - stars: 5.660149021515273, + ok_hit_window: 50.0 + stars: 5.235637844901627, max_combo: 908, is_convert: true, }; @@ -425,9 +432,10 @@ fn convert_taiko() { rhythm: 2.002843919169095, color: 3.1864894777399986, peak: 6.103209631166694, + mono_stamina_factor: 0.0017075184344987763, great_hit_window: 15.733332951863607, ok_hit_window: 38.13333257039388, - stars: 7.578560915020682, + stars: 7.010168846394131, max_combo: 908, is_convert: true, }; diff --git a/tests/performance.rs b/tests/performance.rs index 4e729051..13726b3e 100644 --- a/tests/performance.rs +++ b/tests/performance.rs @@ -230,30 +230,30 @@ fn basic_taiko() { test_cases! { Taiko: TAIKO { NM => { - pp: 117.93083232512124, + pp: 114.68651694107942, pp_acc: 67.10083752258917, - pp_difficulty: 43.804435430934774, + pp_difficulty: 40.6658183165898, effective_miss_count: 0.0, estimated_unstable_rate: Some(148.44150180469418), }; HD => { - pp: 127.99624094636974, + pp: 124.41592086295445, pp_acc: 67.10083752258917, - pp_difficulty: 44.89954631670814, + pp_difficulty: 41.68246377450454, effective_miss_count: 0.0, estimated_unstable_rate: Some(148.44150180469418), }; HR => { - pp: 139.75239372681187, + pp: 138.3981102935321, pp_acc: 82.52109686788792, - pp_difficulty: 48.75926757049594, + pp_difficulty: 47.44272798866182, effective_miss_count: 0.0, estimated_unstable_rate: Some(122.99438720960376), }; DT => { - pp: 220.51543873147975, + pp: 220.07140899937482, pp_acc: 118.28107309573312, - pp_difficulty: 89.35584221033577, + pp_difficulty: 88.93091255724303, effective_miss_count: 0.0, estimated_unstable_rate: Some(98.96100120312946), }; @@ -266,30 +266,30 @@ fn convert_taiko() { test_cases! { Taiko: OSU { NM => { - pp: 396.36982258196866, - pp_acc: 160.00481201044695, - pp_difficulty: 213.19920144243838, + pp: 353.6961706002712, + pp_acc: 155.09212159726567, + pp_difficulty: 178.19145253120928, effective_miss_count: 0.0, estimated_unstable_rate: Some(85.75868894575865), }; HD => { - pp: 426.0975592756163, - pp_acc: 160.00481201044695, - pp_difficulty: 213.19920144243838, + pp: 358.45704044422996, + pp_acc: 155.09212159726567, + pp_difficulty: 182.6462388444895, effective_miss_count: 0.0, estimated_unstable_rate: Some(85.75868894575865), }; HR => { - pp: 452.71458235192836, - pp_acc: 191.95668459371925, - pp_difficulty: 234.5205569790155, + pp: 405.57235351353773, + pp_acc: 186.06296332183615, + pp_difficulty: 196.1813610529617, effective_miss_count: 0.0, estimated_unstable_rate: Some(72.67685680089848), }; DT => { - pp: 739.7393581199891, - pp_acc: 280.8904545747157, - pp_difficulty: 415.0249135067657, + pp: 658.0214875413873, + pp_acc: 272.26616492989393, + pp_difficulty: 347.4712042359611, effective_miss_count: 0.0, estimated_unstable_rate: Some(57.17245929717244), }; From 0448980cd575176cac994853aed5acfef1241864 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 8 Nov 2024 22:04:37 +0100 Subject: [PATCH 31/48] fix: clamp effective x --- src/catch/convert.rs | 5 +++-- src/catch/object/juice_stream.rs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/catch/convert.rs b/src/catch/convert.rs index bffdbcb7..d9eadac7 100644 --- a/src/catch/convert.rs +++ b/src/catch/convert.rs @@ -97,8 +97,9 @@ fn convert_object<'a>( let state = match h.kind { HitObjectKind::Circle => ObjectIterState::Fruit(Some(Fruit::new(count))), HitObjectKind::Slider(ref slider) => { - let x = h.pos.x; - let stream = JuiceStream::new(x, h.start_time, slider, converted, count, bufs); + let effective_x = h.pos.x.clamp(0.0, PLAYFIELD_WIDTH); + let stream = + JuiceStream::new(effective_x, h.start_time, slider, converted, count, bufs); ObjectIterState::JuiceStream(stream) } diff --git a/src/catch/object/juice_stream.rs b/src/catch/object/juice_stream.rs index 1bdd4751..4ead239d 100644 --- a/src/catch/object/juice_stream.rs +++ b/src/catch/object/juice_stream.rs @@ -23,7 +23,7 @@ impl<'a> JuiceStream<'a> { pub const BASE_SCORING_DIST: f64 = 100.0; pub fn new( - x: f32, + effective_x: f32, start_time: f64, slider: &'a Slider, converted: &CatchBeatmap<'_>, @@ -122,7 +122,7 @@ impl<'a> JuiceStream<'a> { }; let nested = NestedJuiceStreamObject { - pos: x + path.position_at(e.path_progress).x, + pos: effective_x + path.position_at(e.path_progress).x, start_time: e.time, kind, }; From 9edad8a3d5ca3957c78731871b5c7dde2df0c82d Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Sat, 9 Nov 2024 21:19:53 +0100 Subject: [PATCH 32/48] dep: use f64 for clock rate --- Cargo.toml | 3 ++- src/any/difficulty/mod.rs | 22 ++++++++++------------ src/model/beatmap/attributes.rs | 4 ++-- src/model/mods.rs | 4 ++-- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index abd830cf..03998d82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,8 @@ tracing = ["rosu-map/tracing"] [dependencies] # rosu-map = { version = "0.1.2" } rosu-map = { git = "https://github.com/MaxOhn/rosu-map", branch = "pp-update" } -rosu-mods = { version = "0.1.0" } +# rosu-mods = { version = "0.1.0" } +rosu-mods = { path = "../rosu-mods" } [dev-dependencies] proptest = "1.4.0" diff --git a/src/any/difficulty/mod.rs b/src/any/difficulty/mod.rs index 9a42d244..c0513b44 100644 --- a/src/any/difficulty/mod.rs +++ b/src/any/difficulty/mod.rs @@ -1,7 +1,7 @@ use std::{ borrow::Cow, fmt::{Debug, Formatter, Result as FmtResult}, - num::NonZeroU32, + num::NonZeroU64, }; use rosu_map::section::general::GameMode; @@ -51,12 +51,10 @@ pub struct Difficulty { /// Clock rate will be clamped internally between 0.01 and 100.0. /// /// Since its minimum value is 0.01, its bits are never zero. - /// Additionally, values between 0.01 and 100 are represented sufficiently - /// precise with 32 bits. /// /// This allows for an optimization to reduce the struct size by storing its - /// bits as a [`NonZeroU32`]. - clock_rate: Option, + /// bits as a [`NonZeroU64`]. + clock_rate: Option, ar: Option, cs: Option, hp: Option, @@ -128,7 +126,7 @@ impl Difficulty { InspectDifficulty { mods, passed_objects, - clock_rate: clock_rate.map(non_zero_u32_to_f32).map(f64::from), + clock_rate: clock_rate.map(non_zero_u64_to_f64), ar, cs, hp, @@ -171,11 +169,11 @@ impl Difficulty { /// | :-----: | :-----: | /// | 0.01 | 100 | pub fn clock_rate(self, clock_rate: f64) -> Self { - let clock_rate = (clock_rate as f32).clamp(0.01, 100.0).to_bits(); + let clock_rate = clock_rate.clamp(0.01, 100.0).to_bits(); // SAFETY: The minimum value is 0.01 so its bits can never be fully // zero. - let non_zero = unsafe { NonZeroU32::new_unchecked(clock_rate) }; + let non_zero = unsafe { NonZeroU64::new_unchecked(clock_rate) }; Self { clock_rate: Some(non_zero), @@ -334,7 +332,7 @@ impl Difficulty { pub(crate) fn get_clock_rate(&self) -> f64 { let clock_rate = self .clock_rate - .map_or(self.mods.clock_rate(), non_zero_u32_to_f32); + .map_or(self.mods.clock_rate(), non_zero_u64_to_f64); f64::from(clock_rate) } @@ -369,8 +367,8 @@ impl Difficulty { } } -fn non_zero_u32_to_f32(n: NonZeroU32) -> f32 { - f32::from_bits(n.get()) +fn non_zero_u64_to_f64(n: NonZeroU64) -> f64 { + f64::from_bits(n.get()) } impl Debug for Difficulty { @@ -390,7 +388,7 @@ impl Debug for Difficulty { f.debug_struct("Difficulty") .field("mods", mods) .field("passed_objects", passed_objects) - .field("clock_rate", &clock_rate.map(non_zero_u32_to_f32)) + .field("clock_rate", &clock_rate.map(non_zero_u64_to_f64)) .field("ar", ar) .field("cs", cs) .field("hp", hp) diff --git a/src/model/beatmap/attributes.rs b/src/model/beatmap/attributes.rs index a75dada0..027cb7d1 100644 --- a/src/model/beatmap/attributes.rs +++ b/src/model/beatmap/attributes.rs @@ -417,9 +417,9 @@ impl ModsDependentKind { } } - fn value(&self, mods: &GameMods, mods_fn: impl Fn(&GameMods) -> Option) -> f32 { + fn value(&self, mods: &GameMods, mods_fn: impl Fn(&GameMods) -> Option) -> f32 { match self { - ModsDependentKind::Default(inner) => mods_fn(mods).unwrap_or(inner.value), + ModsDependentKind::Default(inner) => mods_fn(mods).map_or(inner.value, |n| n as f32), ModsDependentKind::Custom(inner) => inner.value, } } diff --git a/src/model/mods.rs b/src/model/mods.rs index 321a3060..3ca1512f 100644 --- a/src/model/mods.rs +++ b/src/model/mods.rs @@ -61,7 +61,7 @@ impl GameMods { /// /// In case of variable clock rates like for `WindUp`, this will return /// `1.0`. - pub(crate) fn clock_rate(&self) -> f32 { + pub(crate) fn clock_rate(&self) -> f64 { match self.inner { GameModsInner::Lazer(ref mods) => mods.clock_rate().unwrap_or(1.0), GameModsInner::Intermode(ref mods) => mods.legacy_clock_rate(), @@ -105,7 +105,7 @@ macro_rules! impl_map_attr { #[doc = "Check whether the mods specify a custom "] #[doc = $s] #[doc = "value."] - pub(crate) fn $fn(&self) -> Option { + pub(crate) fn $fn(&self) -> Option { match self.inner { GameModsInner::Lazer(ref mods) => mods.iter().find_map(|gamemod| match gamemod { $( impl_map_attr!( @ $mode $field) => *$field, )* From 757b57bfa0098e582a94a370aa7309b0bb09c258 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Mon, 11 Nov 2024 12:07:40 +0100 Subject: [PATCH 33/48] feat: support horizontal flipping --- src/model/mods.rs | 46 ++++++++++++++++++++++++++++++++++- src/osu/convert.rs | 18 +++++++++----- src/osu/difficulty/gradual.rs | 2 +- src/osu/difficulty/mod.rs | 2 +- src/osu/object.rs | 40 ++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 9 deletions(-) diff --git a/src/model/mods.rs b/src/model/mods.rs index 3ca1512f..f32a38fc 100644 --- a/src/model/mods.rs +++ b/src/model/mods.rs @@ -179,7 +179,7 @@ impl_has_mod! { } impl GameMods { - pub fn no_slider_head_acc(&self, lazer: bool) -> bool { + pub(crate) fn no_slider_head_acc(&self, lazer: bool) -> bool { match self.inner { GameModsInner::Lazer(ref mods) => mods .iter() @@ -196,6 +196,42 @@ impl GameMods { GameModsInner::Legacy(_) => !lazer, } } + + pub(crate) fn reflection(&self) -> Reflection { + match self.inner { + GameModsInner::Lazer(ref mods) => { + if mods.contains_intermode(GameModIntermode::HardRock) { + return Reflection::Vertical; + } + + mods.iter() + .find_map(|m| match m { + GameMod::MirrorOsu(mirror) => Some(mirror), + _ => None, + }) + .map_or(Reflection::None, |mr| match mr.reflection.as_deref() { + Some("Horizontal") | None => Reflection::Horizontal, + Some("Vertical") => Reflection::Vertical, + Some("Both") => Reflection::Both, + Some(_) => Reflection::None, + }) + } + GameModsInner::Intermode(ref mods) => { + if mods.contains(GameModIntermode::HardRock) { + Reflection::Vertical + } else { + Reflection::None + } + } + GameModsInner::Legacy(mods) => { + if mods.contains(GameModsLegacy::HardRock) { + Reflection::Vertical + } else { + Reflection::None + } + } + } + } } impl Default for GameMods { @@ -244,3 +280,11 @@ impl From for GameMods { GameModsLegacy::from_bits(bits).into() } } + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Reflection { + None, + Vertical, + Horizontal, + Both, +} diff --git a/src/osu/convert.rs b/src/osu/convert.rs index c5269b10..30e33f5b 100644 --- a/src/osu/convert.rs +++ b/src/osu/convert.rs @@ -3,6 +3,7 @@ use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers}; use crate::model::{ beatmap::{Beatmap, Converted}, mode::ConvertStatus, + mods::Reflection, }; use super::{ @@ -30,7 +31,7 @@ pub fn try_convert(map: &mut Beatmap) -> ConvertStatus { pub fn convert_objects( converted: &OsuBeatmap<'_>, scaling_factor: &ScalingFactor, - hr: bool, + reflection: Reflection, time_preempt: f64, mut take: usize, attrs: &mut OsuDifficultyAttributes, @@ -63,12 +64,17 @@ pub fn convert_objects( }) .collect(); - if hr { - osu_objects + match reflection { + Reflection::None => osu_objects.iter_mut().for_each(OsuObject::finalize_nested), + Reflection::Vertical => osu_objects .iter_mut() - .for_each(OsuObject::reflect_vertically); - } else { - osu_objects.iter_mut().for_each(OsuObject::finalize_nested); + .for_each(OsuObject::reflect_vertically), + Reflection::Horizontal => osu_objects + .iter_mut() + .for_each(OsuObject::reflect_horizontally), + Reflection::Both => osu_objects + .iter_mut() + .for_each(OsuObject::reflect_both_axes), } let stack_threshold = time_preempt * f64::from(converted.stack_leniency); diff --git a/src/osu/difficulty/gradual.rs b/src/osu/difficulty/gradual.rs index 3e3b7548..bf314ec8 100644 --- a/src/osu/difficulty/gradual.rs +++ b/src/osu/difficulty/gradual.rs @@ -84,7 +84,7 @@ impl OsuGradualDifficulty { let osu_objects = convert_objects( converted, &scaling_factor, - mods.hr(), + mods.reflection(), time_preempt, converted.hit_objects.len(), &mut attrs, diff --git a/src/osu/difficulty/mod.rs b/src/osu/difficulty/mod.rs index e358bba8..f8ec0ab2 100644 --- a/src/osu/difficulty/mod.rs +++ b/src/osu/difficulty/mod.rs @@ -114,7 +114,7 @@ impl DifficultyValues { let mut osu_objects = convert_objects( converted, &scaling_factor, - mods.hr(), + mods.reflection(), time_preempt, take, &mut attrs, diff --git a/src/osu/object.rs b/src/osu/object.rs index 6d929c51..085e9cf0 100644 --- a/src/osu/object.rs +++ b/src/osu/object.rs @@ -77,6 +77,46 @@ impl OsuObject { } } + pub fn reflect_horizontally(&mut self) { + fn reflect_x(x: &mut f32) { + *x = PLAYFIELD_BASE_SIZE.x - *x; + } + + reflect_x(&mut self.pos.x); + + if let OsuObjectKind::Slider(ref mut slider) = self.kind { + // Requires `stack_offset` so we can't add `h.pos` just yet + slider.lazy_end_pos.x = -slider.lazy_end_pos.x; + + for nested in slider.nested_objects.iter_mut() { + let mut nested_pos = self.pos; // already reflected at this point + nested_pos += Pos::new(-nested.pos.x, nested.pos.y); + nested.pos = nested_pos; + } + } + } + + pub fn reflect_both_axes(&mut self) { + fn reflect(pos: &mut Pos) { + pos.x = PLAYFIELD_BASE_SIZE.x - pos.x; + pos.y = PLAYFIELD_BASE_SIZE.y - pos.y; + } + + reflect(&mut self.pos); + + if let OsuObjectKind::Slider(ref mut slider) = self.kind { + // Requires `stack_offset` so we can't add `h.pos` just yet + slider.lazy_end_pos.x = -slider.lazy_end_pos.x; + slider.lazy_end_pos.y = -slider.lazy_end_pos.y; + + for nested in slider.nested_objects.iter_mut() { + let mut nested_pos = self.pos; // already reflected at this point + nested_pos += Pos::new(-nested.pos.x, -nested.pos.y); + nested.pos = nested_pos; + } + } + } + pub fn finalize_nested(&mut self) { if let OsuObjectKind::Slider(ref mut slider) = self.kind { for nested in slider.nested_objects.iter_mut() { From 4f1c63e90a015073b2d3da7f884b3991eff53b7d Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Mon, 11 Nov 2024 12:08:36 +0100 Subject: [PATCH 34/48] chore: made clippy happy --- src/any/difficulty/mod.rs | 7 ++----- src/model/beatmap/attributes.rs | 9 ++------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/any/difficulty/mod.rs b/src/any/difficulty/mod.rs index c0513b44..170e6f24 100644 --- a/src/any/difficulty/mod.rs +++ b/src/any/difficulty/mod.rs @@ -330,11 +330,8 @@ impl Difficulty { } pub(crate) fn get_clock_rate(&self) -> f64 { - let clock_rate = self - .clock_rate - .map_or(self.mods.clock_rate(), non_zero_u64_to_f64); - - f64::from(clock_rate) + self.clock_rate + .map_or(self.mods.clock_rate(), non_zero_u64_to_f64) } pub(crate) fn get_passed_objects(&self) -> usize { diff --git a/src/model/beatmap/attributes.rs b/src/model/beatmap/attributes.rs index 027cb7d1..4700ce8b 100644 --- a/src/model/beatmap/attributes.rs +++ b/src/model/beatmap/attributes.rs @@ -227,10 +227,7 @@ impl BeatmapAttributesBuilder { /// Calculate the AR and OD hit windows. pub fn hit_windows(&self) -> HitWindows { let mods = &self.mods; - - let clock_rate = self - .clock_rate - .unwrap_or_else(|| f64::from(mods.clock_rate())); + let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate()); let ar_clock_rate = if self.ar.with_mods() { 1.0 } else { clock_rate }; let od_clock_rate = if self.od.with_mods() { 1.0 } else { clock_rate }; @@ -312,9 +309,7 @@ impl BeatmapAttributesBuilder { /// Calculate the [`BeatmapAttributes`]. pub fn build(&self) -> BeatmapAttributes { let mods = &self.mods; - let clock_rate = self - .clock_rate - .unwrap_or_else(|| f64::from(mods.clock_rate())); + let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate()); // HP let mut hp = self.hp.value(mods, GameMods::hp); From 2743ea3ac3296ecc139c9e315266cac7af0f920e Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Mon, 11 Nov 2024 12:51:16 +0100 Subject: [PATCH 35/48] fix: rx perf calc --- src/osu/performance/mod.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index 06fe00e6..2f8fbfe2 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -743,8 +743,8 @@ impl OsuPerformanceInner<'_> { // * this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11) let (n100_mult, n50_mult) = if self.attrs.od > 0.0 { ( - 1.0 - (self.attrs.od / 13.33).powf(1.8), - 1.0 - (self.attrs.od / 13.33).powf(5.0), + (1.0 - (self.attrs.od / 13.33).powf(1.8)).max(0.0), + (1.0 - (self.attrs.od / 13.33).powf(5.0)).max(0.0), ) } else { (1.0, 1.0) @@ -753,8 +753,7 @@ impl OsuPerformanceInner<'_> { // * As we're adding Oks and Mehs to an approximated number of combo breaks the result can be // * higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it. self.effective_miss_count = (self.effective_miss_count - + f64::from(self.state.n100) - + n100_mult + + f64::from(self.state.n100) * n100_mult + f64::from(self.state.n50) * n50_mult) .min(total_hits); } From 636209e6cf241368a701e085f208475e013ac726 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Mon, 11 Nov 2024 13:08:19 +0100 Subject: [PATCH 36/48] dep: replace local with upstream rosu-mods --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 03998d82..85c8bd9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ tracing = ["rosu-map/tracing"] # rosu-map = { version = "0.1.2" } rosu-map = { git = "https://github.com/MaxOhn/rosu-map", branch = "pp-update" } # rosu-mods = { version = "0.1.0" } -rosu-mods = { path = "../rosu-mods" } +rosu-mods = { git = "https://github.com/MaxOhn/rosu-mods", branch = "main" } [dev-dependencies] proptest = "1.4.0" From 4326ec45e0d7e4fbd01f29715f28ec82469ec342 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Mon, 11 Nov 2024 13:26:47 +0100 Subject: [PATCH 37/48] test: split target_os for taiko convert test --- tests/performance.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/performance.rs b/tests/performance.rs index 13726b3e..ce25804a 100644 --- a/tests/performance.rs +++ b/tests/performance.rs @@ -263,6 +263,7 @@ fn basic_taiko() { #[test] fn convert_taiko() { + #[cfg(target_os = "windows")] test_cases! { Taiko: OSU { NM => { @@ -295,6 +296,39 @@ fn convert_taiko() { }; } } + #[cfg(target_os = "linux")] + test_cases! { + Taiko: OSU { + NM => { + pp: 353.6961706002712, + pp_acc: 155.09212159726567, + pp_difficulty: 178.19145253120928, + effective_miss_count: 0.0, + estimated_unstable_rate: Some(85.75868894575865), + }; + HD => { + pp: 358.45704044423 + pp_acc: 155.09212159726567, + pp_difficulty: 182.6462388444895, + effective_miss_count: 0.0, + estimated_unstable_rate: Some(85.75868894575865), + }; + HR => { + pp: 405.57235351353773, + pp_acc: 186.06296332183615, + pp_difficulty: 196.1813610529617, + effective_miss_count: 0.0, + estimated_unstable_rate: Some(72.67685680089848), + }; + DT => { + pp: 658.0214875413873 + pp_acc: 272.26616492989393, + pp_difficulty: 347.4712042359611, + effective_miss_count: 0.0, + estimated_unstable_rate: Some(57.17245929717244), + }; + } + } } #[test] From 78d7bd3873e8a5bf8816f222a30afb6f0d846eae Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Mon, 11 Nov 2024 14:22:36 +0100 Subject: [PATCH 38/48] fix: handle acc pp calc for niche case The score 3051169674 causes intermediate negative values but still a positive value overall so we need to cast to i32. --- src/osu/performance/mod.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index 2f8fbfe2..351bf867 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -942,20 +942,23 @@ impl OsuPerformanceInner<'_> { amount_hit_objects_with_acc += self.attrs.n_sliders; } - let better_acc_percentage = if amount_hit_objects_with_acc > 0 { - let sub = self.state.total_hits() - amount_hit_objects_with_acc; - - // * It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points. - if self.state.n300 < sub { - 0.0 - } else { - f64::from((self.state.n300 - sub) * 6 + self.state.n100 * 2 + self.state.n50) - / f64::from(amount_hit_objects_with_acc * 6) - } + let mut better_acc_percentage = if amount_hit_objects_with_acc > 0 { + f64::from( + (self.state.n300 as i32 + - (self.state.total_hits() as i32 - amount_hit_objects_with_acc as i32)) + * 6 + + self.state.n100 as i32 * 2 + + self.state.n50 as i32, + ) / f64::from(amount_hit_objects_with_acc * 6) } else { 0.0 }; + // * It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points. + if better_acc_percentage < 0.0 { + better_acc_percentage = 0.0; + } + // * Lots of arbitrary values from testing. // * Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution. let mut acc_value = From e0c53af27a9d6651d38e964017ad79fadb3cb2a6 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Mon, 11 Nov 2024 20:15:18 +0100 Subject: [PATCH 39/48] fix: adjust hitresult gen for mania on lazer --- src/any/difficulty/mod.rs | 2 - src/any/performance/mod.rs | 10 ++--- src/mania/attributes.rs | 2 + src/mania/difficulty/gradual.rs | 30 +++++++++----- src/mania/difficulty/mod.rs | 5 ++- src/mania/object.rs | 10 ++++- src/mania/performance/gradual.rs | 7 ++++ src/mania/performance/mod.rs | 71 +++++++++++++++++++++++++------- tests/difficulty.rs | 6 +++ tests/performance.rs | 12 +++--- 10 files changed, 115 insertions(+), 40 deletions(-) diff --git a/src/any/difficulty/mod.rs b/src/any/difficulty/mod.rs index 170e6f24..8a923958 100644 --- a/src/any/difficulty/mod.rs +++ b/src/any/difficulty/mod.rs @@ -274,8 +274,6 @@ impl Difficulty { /// score. /// /// Defaults to `true`. - /// - /// Only relevant for osu!standard performance calculation. pub const fn lazer(mut self, lazer: bool) -> Self { self.lazer = Some(lazer); diff --git a/src/any/performance/mod.rs b/src/any/performance/mod.rs index 884bf361..e14b965f 100644 --- a/src/any/performance/mod.rs +++ b/src/any/performance/mod.rs @@ -307,12 +307,12 @@ impl<'map> Performance<'map> { /// This affects internal accuracy calculation because lazer considers /// slider heads for accuracy whereas stable does not. /// - /// Only relevant for osu!standard. + /// Only relevant for osu!standard and osu!mania. pub fn lazer(self, lazer: bool) -> Self { - if let Self::Osu(osu) = self { - Self::Osu(osu.lazer(lazer)) - } else { - self + match self { + Self::Osu(o) => Self::Osu(o.lazer(lazer)), + Self::Taiko(_) | Self::Catch(_) => self, + Self::Mania(m) => Self::Mania(m.lazer(lazer)), } } diff --git a/src/mania/attributes.rs b/src/mania/attributes.rs index 925678c4..363a84b2 100644 --- a/src/mania/attributes.rs +++ b/src/mania/attributes.rs @@ -9,6 +9,8 @@ pub struct ManiaDifficultyAttributes { pub hit_window: f64, /// The amount of hitobjects in the map. pub n_objects: u32, + /// The amount of hold notes in the map. + pub n_hold_notes: u32, /// The maximum achievable combo. pub max_combo: u32, /// Whether the [`Beatmap`] was a convert i.e. an osu!standard map. diff --git a/src/mania/difficulty/gradual.rs b/src/mania/difficulty/gradual.rs index 5d16baa0..bc2508d8 100644 --- a/src/mania/difficulty/gradual.rs +++ b/src/mania/difficulty/gradual.rs @@ -54,7 +54,13 @@ pub struct ManiaGradualDifficulty { strain: Strain, diff_objects: Box<[ManiaDifficultyObject]>, hit_window: f64, + note_state: NoteState, +} + +#[derive(Default)] +struct NoteState { curr_combo: u32, + n_hold_notes: u32, } impl ManiaGradualDifficulty { @@ -80,7 +86,7 @@ impl ManiaGradualDifficulty { let strain = Strain::new(total_columns as usize); - let mut curr_combo = 0; + let mut note_state = NoteState::default(); let objects_is_circle: Box<[_]> = converted .hit_objects @@ -95,7 +101,7 @@ impl ManiaGradualDifficulty { objects_is_circle[0], hit_object.start_time, hit_object.end_time, - &mut curr_combo, + &mut note_state, ); } @@ -107,7 +113,7 @@ impl ManiaGradualDifficulty { strain, diff_objects, hit_window, - curr_combo, + note_state, } } } @@ -128,7 +134,7 @@ impl Iterator for ManiaGradualDifficulty { increment_combo( is_circle, curr, - &mut self.curr_combo, + &mut self.note_state, self.difficulty.get_clock_rate(), ); } else if self.objects_is_circle.is_empty() { @@ -140,8 +146,9 @@ impl Iterator for ManiaGradualDifficulty { Some(ManiaDifficultyAttributes { stars: self.strain.as_difficulty_value() * DIFFICULTY_MULTIPLIER, hit_window: self.hit_window, - max_combo: self.curr_combo, + max_combo: self.note_state.curr_combo, n_objects: self.idx as u32, + n_hold_notes: self.note_state.n_hold_notes, is_convert: self.is_convert, }) } @@ -171,7 +178,7 @@ impl Iterator for ManiaGradualDifficulty { let clock_rate = self.difficulty.get_clock_rate(); for (curr, is_circle) in skip_iter.take(take) { - increment_combo(*is_circle, curr, &mut self.curr_combo, clock_rate); + increment_combo(*is_circle, curr, &mut self.note_state, clock_rate); strain.process(curr); self.idx += 1; } @@ -189,22 +196,23 @@ impl ExactSizeIterator for ManiaGradualDifficulty { fn increment_combo( is_circle: bool, diff_obj: &ManiaDifficultyObject, - curr_combo: &mut u32, + state: &mut NoteState, clock_rate: f64, ) { increment_combo_raw( is_circle, diff_obj.start_time * clock_rate, diff_obj.end_time * clock_rate, - curr_combo, + state, ); } -fn increment_combo_raw(is_circle: bool, start_time: f64, end_time: f64, curr_combo: &mut u32) { +fn increment_combo_raw(is_circle: bool, start_time: f64, end_time: f64, state: &mut NoteState) { if is_circle { - *curr_combo += 1; + state.curr_combo += 1; } else { - *curr_combo += 1 + ((end_time - start_time) / 100.0) as u32; + state.curr_combo += 1 + ((end_time - start_time) / 100.0) as u32; + state.n_hold_notes += 1; } } diff --git a/src/mania/difficulty/mod.rs b/src/mania/difficulty/mod.rs index 8dbc4b8a..2848962e 100644 --- a/src/mania/difficulty/mod.rs +++ b/src/mania/difficulty/mod.rs @@ -35,6 +35,7 @@ pub fn difficulty( hit_window, max_combo: values.max_combo, n_objects, + n_hold_notes: values.n_hold_notes, is_convert: converted.is_convert, } } @@ -42,6 +43,7 @@ pub fn difficulty( pub struct DifficultyValues { pub strain: Strain, pub max_combo: u32, + pub n_hold_notes: u32, } impl DifficultyValues { @@ -71,7 +73,8 @@ impl DifficultyValues { Self { strain, - max_combo: params.into_max_combo(), + max_combo: params.max_combo(), + n_hold_notes: params.n_hold_notes(), } } diff --git a/src/mania/object.rs b/src/mania/object.rs index 591f844b..18fddfdf 100644 --- a/src/mania/object.rs +++ b/src/mania/object.rs @@ -47,6 +47,7 @@ impl ManiaObject { let duration = (slider.span_count() as f64) * dist / velocity; params.max_combo += (duration / 100.0) as u32; + params.n_hold_notes += 1; Self { start_time: h.start_time, @@ -57,6 +58,7 @@ impl ManiaObject { HitObjectKind::Spinner(Spinner { duration }) | HitObjectKind::Hold(HoldNote { duration }) => { params.max_combo += (duration / 100.0) as u32; + params.n_hold_notes += 1; Self { start_time: h.start_time, @@ -77,6 +79,7 @@ impl ManiaObject { pub struct ObjectParams<'a> { map: &'a Beatmap, max_combo: u32, + n_hold_notes: u32, curve_bufs: CurveBuffers, } @@ -85,11 +88,16 @@ impl<'a> ObjectParams<'a> { Self { map, max_combo: 0, + n_hold_notes: 0, curve_bufs: CurveBuffers::default(), } } - pub fn into_max_combo(self) -> u32 { + pub fn max_combo(&self) -> u32 { self.max_combo } + + pub fn n_hold_notes(&self) -> u32 { + self.n_hold_notes + } } diff --git a/src/mania/performance/gradual.rs b/src/mania/performance/gradual.rs index 55db847a..ce23f38a 100644 --- a/src/mania/performance/gradual.rs +++ b/src/mania/performance/gradual.rs @@ -146,6 +146,13 @@ mod tests { for i in 1.. { state.misses += 1; + // Hold notes award two hitresults in lazer + if let Some(h) = converted.hit_objects.get(i - 1) { + if !h.is_circle() { + state.n320 += 1; + } + } + 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); diff --git a/src/mania/performance/mod.rs b/src/mania/performance/mod.rs index 582a40ef..50764a68 100644 --- a/src/mania/performance/mod.rs +++ b/src/mania/performance/mod.rs @@ -169,6 +169,19 @@ impl<'map> ManiaPerformance<'map> { self } + /// Whether the calculated attributes belong to an osu!lazer or osu!stable + /// score. + /// + /// Defaults to `true`. + /// + /// This affects internal hitresult generation because lazer gives two + /// hitresults per hold note whereas stable only gives one. + pub fn lazer(mut self, lazer: bool) -> Self { + self.difficulty = self.difficulty.lazer(lazer); + + self + } + /// Specify the amount of 320s of a play. pub const fn n320(mut self, n320: u32) -> Self { self.n320 = Some(n320); @@ -245,11 +258,16 @@ impl<'map> ManiaPerformance<'map> { MapOrAttrs::Attrs(ref attrs) => attrs, }; - let n_objects = cmp::min(self.difficulty.get_passed_objects() as u32, attrs.n_objects); + let mut n_objects = cmp::min(self.difficulty.get_passed_objects() as u32, attrs.n_objects); let priority = self.hitresult_priority; let misses = self.misses.map_or(0, |n| cmp::min(n, n_objects)); + + if self.difficulty.get_lazer() { + n_objects += attrs.n_hold_notes; + } + let n_remaining = n_objects - misses; let mut n320 = self.n320.map_or(0, |n| cmp::min(n, n_remaining)); @@ -950,6 +968,7 @@ mod tests { static ATTRS: OnceLock = OnceLock::new(); const N_OBJECTS: u32 = 594; + const N_HOLD_NOTES: u32 = 121; fn beatmap() -> Beatmap { Beatmap::from_path("./resources/1638954.osu").unwrap() @@ -962,6 +981,14 @@ mod tests { let attrs = Difficulty::new().with_mode().calculate(&converted); assert_eq!(N_OBJECTS, converted.hit_objects.len() as u32); + assert_eq!( + N_HOLD_NOTES, + converted + .hit_objects + .iter() + .filter(|h| !h.is_circle()) + .count() as u32 + ); attrs }) @@ -975,6 +1002,7 @@ mod tests { /// that it doesn't run unreasonably long. #[allow(clippy::too_many_arguments, clippy::too_many_lines)] fn brute_force_best( + lazer: bool, acc: f64, n320: Option, n300: Option, @@ -994,7 +1022,11 @@ mod tests { let mut best_dist = f64::INFINITY; let mut best_custom_acc = 0.0; - let n_remaining = N_OBJECTS - misses; + let mut n_remaining = N_OBJECTS - misses; + + if lazer { + n_remaining += N_HOLD_NOTES; + } let multiple_given = (usize::from(n320.is_some()) + usize::from(n300.is_some()) @@ -1003,17 +1035,23 @@ mod tests { + usize::from(n50.is_some())) > 1; - let max_left = N_OBJECTS + let mut n_objects = N_OBJECTS; + + if lazer { + n_objects += N_HOLD_NOTES; + } + + let max_left = n_objects .saturating_sub(n200.unwrap_or(0) + n100.unwrap_or(0) + n50.unwrap_or(0) + misses); let min_n3x0 = cmp::min( max_left, - (acc * f64::from(3 * N_OBJECTS) - f64::from(2 * n_remaining)).floor() as u32, + (acc * f64::from(3 * n_objects) - f64::from(2 * n_remaining)).floor() as u32, ); let max_n3x0 = cmp::min( max_left, - ((acc * f64::from(6 * N_OBJECTS) - f64::from(n_remaining)) / 5.0).ceil() as u32, + ((acc * f64::from(6 * n_objects) - f64::from(n_remaining)) / 5.0).ceil() as u32, ); let (min_n3x0, max_n3x0) = match (n320, n300) { @@ -1086,9 +1124,9 @@ mod tests { let curr_dist = (acc - curr_acc).abs(); let curr_custom_acc = - custom_accuracy(new320, new300, new200, new100, new50, N_OBJECTS); + custom_accuracy(new320, new300, new200, new100, new50, n_objects); - match curr_dist.partial_cmp(&best_dist).expect("non-NaN") { + match curr_dist.total_cmp(&best_dist) { Ordering::Less => { best_dist = curr_dist; best_custom_acc = curr_custom_acc; @@ -1194,13 +1232,14 @@ mod tests { #[test] fn mania_hitresults( - acc in 0.0..=1.0, - n320 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10), - n300 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10), - n200 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10), - n100 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10), - n50 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10), - n_misses in prop::option::weighted(0.15, 0_u32..=N_OBJECTS + 10), + lazer in prop::bool::ANY, + acc in 0.0_f64..=1.0, + n320 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + N_HOLD_NOTES + 10), + n300 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + N_HOLD_NOTES + 10), + n200 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + N_HOLD_NOTES + 10), + n100 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + N_HOLD_NOTES + 10), + n50 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + N_HOLD_NOTES + 10), + n_misses in prop::option::weighted(0.15, 0_u32..=N_OBJECTS + N_HOLD_NOTES + 10), best_case in prop::bool::ANY, ) { let priority = if best_case { @@ -1211,6 +1250,7 @@ mod tests { let mut state = ManiaPerformance::from(attrs()) .accuracy(acc * 100.0) + .lazer(lazer) .hitresult_priority(priority); if let Some(n320) = n320 { @@ -1242,6 +1282,7 @@ mod tests { assert_eq!(first, state); let expected = brute_force_best( + lazer, acc, n320, n300, @@ -1259,6 +1300,7 @@ mod tests { #[test] fn hitresults_n320_misses_best() { let state = ManiaPerformance::from(attrs()) + .lazer(false) .n320(500) .misses(2) .hitresult_priority(HitResultPriority::BestCase) @@ -1279,6 +1321,7 @@ mod tests { #[test] fn hitresults_n100_n50_misses_worst() { let state = ManiaPerformance::from(attrs()) + .lazer(false) .n100(200) .n50(50) .misses(2) diff --git a/tests/difficulty.rs b/tests/difficulty.rs index 1be41638..00308040 100644 --- a/tests/difficulty.rs +++ b/tests/difficulty.rs @@ -112,6 +112,7 @@ macro_rules! test_cases { stars: $stars:literal, hit_window: $hit_window:literal, n_objects: $n_objects:literal, + n_hold_notes: $n_hold_notes:literal, max_combo: $max_combo:literal, is_convert: $is_convert:literal, }) => { @@ -119,6 +120,7 @@ macro_rules! test_cases { stars: $stars, hit_window: $hit_window, n_objects: $n_objects, + n_hold_notes: $n_hold_notes, max_combo: $max_combo, is_convert: $is_convert, } @@ -531,6 +533,7 @@ fn basic_mania() { stars: 3.358304846842773, hit_window: 40.0, n_objects: 594, + n_hold_notes: 121, max_combo: 956, is_convert: false, }; @@ -538,6 +541,7 @@ fn basic_mania() { stars: 4.6072892053157295, hit_window: 40.0, n_objects: 594, + n_hold_notes: 121, max_combo: 956, is_convert: false, }; @@ -553,6 +557,7 @@ fn convert_mania() { stars: 3.2033142085672255, hit_window: 34.0, n_objects: 1046, + n_hold_notes: 293, max_combo: 1381, is_convert: true, }; @@ -560,6 +565,7 @@ fn convert_mania() { stars: 4.2934063021960185, hit_window: 34.0, n_objects: 1046, + n_hold_notes: 293, max_combo: 1381, is_convert: true, }; diff --git a/tests/performance.rs b/tests/performance.rs index ce25804a..50404bc0 100644 --- a/tests/performance.rs +++ b/tests/performance.rs @@ -359,9 +359,9 @@ fn convert_catch() { fn basic_mania() { test_cases! { Mania: MANIA { - NM => { pp: 108.08430593303873, pp_difficulty: 108.08430593303873 }; - EZ => { pp: 54.04215296651937, pp_difficulty: 108.08430593303873 }; - DT => { pp: 222.79838979800365, pp_difficulty: 222.79838979800365 }; + NM => { pp: 108.92297471705167, pp_difficulty: 108.92297471705167 }; + EZ => { pp: 54.46148735852584, pp_difficulty: 108.92297471705167 }; + DT => { pp: 224.52717042937203, pp_difficulty: 224.52717042937203 }; } }; } @@ -370,9 +370,9 @@ fn basic_mania() { fn convert_mania() { test_cases! { Mania: OSU { - NM => { pp: 99.73849552661329, pp_difficulty: 99.73849552661329 }; - EZ => { pp: 49.869247763306646, pp_difficulty: 99.73849552661329 }; - DT => { pp: 195.23247718805612, pp_difficulty: 195.23247718805612 }; + NM => { pp: 101.39189449271568, pp_difficulty: 101.39189449271568 }; + EZ => { pp: 50.69594724635784, pp_difficulty: 101.39189449271568 }; + DT => { pp: 198.46891237015896, pp_difficulty: 198.46891237015896 }; } }; } From d6d191622f7f22c6ff107c5ab62bd72b88995824 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Tue, 12 Nov 2024 18:18:10 +0100 Subject: [PATCH 40/48] fix: dont overwrite mods & clock_rate --- src/model/beatmap/attributes.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/model/beatmap/attributes.rs b/src/model/beatmap/attributes.rs index 4700ce8b..7954cee0 100644 --- a/src/model/beatmap/attributes.rs +++ b/src/model/beatmap/attributes.rs @@ -109,9 +109,8 @@ impl BeatmapAttributesBuilder { od: ModsDependentKind::Default(ModsDependent::new(map.od)), cs: ModsDependentKind::Default(ModsDependent::new(map.cs)), hp: ModsDependentKind::Default(ModsDependent::new(map.hp)), - mods: GameMods::DEFAULT, - clock_rate: None, is_convert: map.is_convert, + ..self } } From 11095ad6b4d24a6913e0917a2ae4f00be19b3f0d Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Wed, 13 Nov 2024 00:26:27 +0100 Subject: [PATCH 41/48] fix: clamp map attributes --- src/model/beatmap/attributes.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/model/beatmap/attributes.rs b/src/model/beatmap/attributes.rs index 7954cee0..8b75a170 100644 --- a/src/model/beatmap/attributes.rs +++ b/src/model/beatmap/attributes.rs @@ -105,8 +105,9 @@ impl BeatmapAttributesBuilder { pub fn map(self, map: &Beatmap) -> Self { Self { mode: map.mode, - ar: ModsDependentKind::Default(ModsDependent::new(map.ar)), - od: ModsDependentKind::Default(ModsDependent::new(map.od)), + // Clamping necessary to match lazer on maps like /b/4243836. + ar: ModsDependentKind::Default(ModsDependent::new(map.ar.clamp(0.0, 10.0))), + od: ModsDependentKind::Default(ModsDependent::new(map.od.clamp(0.0, 10.0))), cs: ModsDependentKind::Default(ModsDependent::new(map.cs)), hp: ModsDependentKind::Default(ModsDependent::new(map.hp)), is_convert: map.is_convert, From baeaeb77de55de3a7d7cadb4c7496d75f86b5e85 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Wed, 13 Nov 2024 01:26:52 +0100 Subject: [PATCH 42/48] fix: mirror reflection handling --- src/model/mods.rs | 110 +++++++++++++++++++++++----------------------- 1 file changed, 54 insertions(+), 56 deletions(-) diff --git a/src/model/mods.rs b/src/model/mods.rs index f32a38fc..9b876f77 100644 --- a/src/model/mods.rs +++ b/src/model/mods.rs @@ -96,6 +96,60 @@ impl GameMods { custom_hardrock_offsets(self).unwrap_or_else(|| self.hr()) } + + pub(crate) fn no_slider_head_acc(&self, lazer: bool) -> bool { + match self.inner { + GameModsInner::Lazer(ref mods) => mods + .iter() + .find_map(|m| match m { + GameMod::ClassicOsu(classic) => Some(classic), + _ => None, + }) + .map_or(!lazer, |classic| { + classic.no_slider_head_accuracy.unwrap_or(true) + }), + GameModsInner::Intermode(ref mods) => { + mods.contains(GameModIntermode::Classic) || !lazer + } + GameModsInner::Legacy(_) => !lazer, + } + } + + pub(crate) fn reflection(&self) -> Reflection { + match self.inner { + GameModsInner::Lazer(ref mods) => { + if mods.contains_intermode(GameModIntermode::HardRock) { + return Reflection::Vertical; + } + + mods.iter() + .find_map(|m| match m { + GameMod::MirrorOsu(mirror) => Some(mirror), + _ => None, + }) + .map_or(Reflection::None, |mr| match mr.reflection.as_deref() { + None => Reflection::Horizontal, + Some("1") => Reflection::Vertical, + Some("2") => Reflection::Both, + Some(_) => Reflection::None, + }) + } + GameModsInner::Intermode(ref mods) => { + if mods.contains(GameModIntermode::HardRock) { + Reflection::Vertical + } else { + Reflection::None + } + } + GameModsInner::Legacy(mods) => { + if mods.contains(GameModsLegacy::HardRock) { + Reflection::Vertical + } else { + Reflection::None + } + } + } + } } macro_rules! impl_map_attr { @@ -178,62 +232,6 @@ impl_has_mod! { tc: - Traceable ["Traceable"], } -impl GameMods { - pub(crate) fn no_slider_head_acc(&self, lazer: bool) -> bool { - match self.inner { - GameModsInner::Lazer(ref mods) => mods - .iter() - .find_map(|m| match m { - GameMod::ClassicOsu(classic) => Some(classic), - _ => None, - }) - .map_or(!lazer, |classic| { - classic.no_slider_head_accuracy.unwrap_or(true) - }), - GameModsInner::Intermode(ref mods) => { - mods.contains(GameModIntermode::Classic) || !lazer - } - GameModsInner::Legacy(_) => !lazer, - } - } - - pub(crate) fn reflection(&self) -> Reflection { - match self.inner { - GameModsInner::Lazer(ref mods) => { - if mods.contains_intermode(GameModIntermode::HardRock) { - return Reflection::Vertical; - } - - mods.iter() - .find_map(|m| match m { - GameMod::MirrorOsu(mirror) => Some(mirror), - _ => None, - }) - .map_or(Reflection::None, |mr| match mr.reflection.as_deref() { - Some("Horizontal") | None => Reflection::Horizontal, - Some("Vertical") => Reflection::Vertical, - Some("Both") => Reflection::Both, - Some(_) => Reflection::None, - }) - } - GameModsInner::Intermode(ref mods) => { - if mods.contains(GameModIntermode::HardRock) { - Reflection::Vertical - } else { - Reflection::None - } - } - GameModsInner::Legacy(mods) => { - if mods.contains(GameModsLegacy::HardRock) { - Reflection::Vertical - } else { - Reflection::None - } - } - } - } -} - impl Default for GameMods { fn default() -> Self { Self::DEFAULT From f37b917f76231e9f5d4d5980bfc33801f9d94455 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Wed, 13 Nov 2024 02:09:19 +0100 Subject: [PATCH 43/48] feat: support MR for catch --- src/catch/convert.rs | 9 +++++++++ src/catch/difficulty/gradual.rs | 11 +++++++++-- src/catch/difficulty/mod.rs | 10 ++++++++-- src/model/mods.rs | 15 ++++++++------- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/catch/convert.rs b/src/catch/convert.rs index d9eadac7..315b78b3 100644 --- a/src/catch/convert.rs +++ b/src/catch/convert.rs @@ -5,6 +5,7 @@ use crate::{ beatmap::{Beatmap, Converted}, hit_object::{HitObject, HitObjectKind, HoldNote, Spinner}, mode::ConvertStatus, + mods::Reflection, }, util::{float_ext::FloatExt, random::Random}, }; @@ -50,6 +51,7 @@ pub fn try_convert(map: &mut Beatmap) -> ConvertStatus { pub fn convert_objects( converted: &CatchBeatmap<'_>, count: &mut ObjectCountBuilder, + reflection: Reflection, hr_offsets: bool, cs: f32, ) -> Vec { @@ -82,6 +84,13 @@ pub fn convert_objects( palpable_objects.extend(new_objects); } + if let Reflection::Horizontal = reflection { + for h in palpable_objects.iter_mut() { + h.x = PLAYFIELD_WIDTH - h.x; + h.x_offset = -h.x_offset; + } + } + palpable_objects.sort_by(|a, b| a.start_time.total_cmp(&b.start_time)); initialize_hyper_dash(cs, &mut palpable_objects); diff --git a/src/catch/difficulty/gradual.rs b/src/catch/difficulty/gradual.rs index de0ad44e..c6d473cc 100644 --- a/src/catch/difficulty/gradual.rs +++ b/src/catch/difficulty/gradual.rs @@ -72,9 +72,16 @@ impl CatchGradualDifficulty { CatchDifficultySetup::new(&difficulty, converted); let hr_offsets = difficulty.get_hardrock_offsets(); + let reflection = difficulty.get_mods().reflection(); let mut count = ObjectCountBuilder::new_gradual(); - let palpable_objects = - convert_objects(converted, &mut count, hr_offsets, map_attrs.cs as f32); + + let palpable_objects = convert_objects( + converted, + &mut count, + reflection, + hr_offsets, + map_attrs.cs as f32, + ); let diff_objects = DifficultyValues::create_difficulty_objects( &map_attrs, diff --git a/src/catch/difficulty/mod.rs b/src/catch/difficulty/mod.rs index b811b36e..c5b1e1a8 100644 --- a/src/catch/difficulty/mod.rs +++ b/src/catch/difficulty/mod.rs @@ -69,10 +69,16 @@ impl DifficultyValues { } = CatchDifficultySetup::new(difficulty, converted); let hr_offsets = difficulty.get_hardrock_offsets(); + let reflection = difficulty.get_mods().reflection(); let mut count = ObjectCountBuilder::new_regular(take); - let palpable_objects = - convert_objects(converted, &mut count, hr_offsets, map_attrs.cs as f32); + let palpable_objects = convert_objects( + converted, + &mut count, + reflection, + hr_offsets, + map_attrs.cs as f32, + ); let diff_objects = Self::create_difficulty_objects( &map_attrs, diff --git a/src/model/mods.rs b/src/model/mods.rs index 9b876f77..a79f80c7 100644 --- a/src/model/mods.rs +++ b/src/model/mods.rs @@ -124,15 +124,16 @@ impl GameMods { mods.iter() .find_map(|m| match m { - GameMod::MirrorOsu(mirror) => Some(mirror), + GameMod::MirrorOsu(mr) => match mr.reflection.as_deref() { + None => Some(Reflection::Horizontal), + Some("1") => Some(Reflection::Vertical), + Some("2") => Some(Reflection::Both), + Some(_) => Some(Reflection::None), + }, + GameMod::MirrorCatch(_) => Some(Reflection::Horizontal), _ => None, }) - .map_or(Reflection::None, |mr| match mr.reflection.as_deref() { - None => Reflection::Horizontal, - Some("1") => Reflection::Vertical, - Some("2") => Reflection::Both, - Some(_) => Reflection::None, - }) + .unwrap_or(Reflection::None) } GameModsInner::Intermode(ref mods) => { if mods.contains(GameModIntermode::HardRock) { From 93f375636c927e5ed0280d6d4fd08af05e809c11 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Wed, 13 Nov 2024 02:12:37 +0100 Subject: [PATCH 44/48] chore: made clippy happy --- src/mania/object.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mania/object.rs b/src/mania/object.rs index 18fddfdf..06f1c2c4 100644 --- a/src/mania/object.rs +++ b/src/mania/object.rs @@ -93,11 +93,11 @@ impl<'a> ObjectParams<'a> { } } - pub fn max_combo(&self) -> u32 { + pub const fn max_combo(&self) -> u32 { self.max_combo } - pub fn n_hold_notes(&self) -> u32 { + pub const fn n_hold_notes(&self) -> u32 { self.n_hold_notes } } From 30054fed31c3961b73a6b7b5a747eae3b1d28fb0 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Thu, 14 Nov 2024 20:24:43 +0100 Subject: [PATCH 45/48] feat!: distinguish stable|lazer|lazer+classic --- proptest-regressions/osu/performance/mod.txt | 1 + src/any/performance/mod.rs | 16 +- src/any/score_state.rs | 23 +- src/catch/performance/mod.rs | 2 +- src/mania/performance/mod.rs | 2 +- src/model/mods.rs | 7 +- src/osu/mod.rs | 2 +- src/osu/performance/mod.rs | 327 ++++++++++++------- src/osu/score_state.rs | 85 +++-- src/taiko/performance/mod.rs | 2 +- 10 files changed, 296 insertions(+), 171 deletions(-) diff --git a/proptest-regressions/osu/performance/mod.txt b/proptest-regressions/osu/performance/mod.txt index 9e6597b7..12ba8f12 100644 --- a/proptest-regressions/osu/performance/mod.txt +++ b/proptest-regressions/osu/performance/mod.txt @@ -14,3 +14,4 @@ cc 2cba8a76243aac7233e9207a3162aaa1f08f933c0cb3a2ac79580ece3a7329fc # shrinks to cc e93787ad8a849ec6d05750c8d09494b8f5a9fa785f843d9a8e2db986c0b32645 # shrinks to acc = 0.0, n300 = None, n100 = None, n50 = None, n_misses = Some(602), best_case = false cc a53cb48861126aa63be54606f9a770db5eae95242c9a9d75cf1fd101cfb21729 # shrinks to lazer = true, acc = 0.5679586776392227, n_slider_ticks = None, n_slider_ends = None, n300 = None, n100 = None, n50 = Some(0), n_misses = None, best_case = false cc cacb94cb2a61cf05e7083e332b378290a6267a499bf30821228bc0ae4dfe46f6 # shrinks to lazer = true, acc = 0.5270982297689498, n_slider_ticks = None, n_slider_ends = None, n300 = Some(70), n100 = None, n50 = None, n_misses = None, best_case = false +cc 5679a686382f641f1fa3407a6e19e1caa0adff27e42c397778a2d178361719a3 # shrinks to lazer = true, classic = false, acc = 0.4911232243285752, large_tick_hits = None, slider_end_hits = Some(0), n300 = None, n100 = None, n50 = None, n_misses = None, best_case = false diff --git a/src/any/performance/mod.rs b/src/any/performance/mod.rs index e14b965f..ca6d66e0 100644 --- a/src/any/performance/mod.rs +++ b/src/any/performance/mod.rs @@ -316,12 +316,19 @@ impl<'map> Performance<'map> { } } - /// Specify the amount of hit slider ticks. + /// Specify the amount of "large tick" hits. /// /// Only relevant for osu!standard. - pub fn n_slider_ticks(self, n_slider_ticks: u32) -> Self { + /// + /// The meaning depends on the kind of score: + /// - if set on osu!stable, this value is irrelevant and can be `0` + /// - if set on osu!lazer *without* `CL`, this value is the amount of hit + /// slider ticks and repeats + /// - if set on osu!lazer *with* `CL`, this value is the amount of hit + /// slider heads, ticks, and repeats + pub fn large_tick_hits(self, large_tick_hits: u32) -> Self { if let Self::Osu(osu) = self { - Self::Osu(osu.n_slider_ticks(n_slider_ticks)) + Self::Osu(osu.large_tick_hits(large_tick_hits)) } else { self } @@ -330,6 +337,9 @@ impl<'map> Performance<'map> { /// Specify the amount of hit slider ends. /// /// Only relevant for osu!standard. + /// + /// osu! calls this value "slider tail hits" without the classic + /// mod and "small tick hits" with the classic mod. pub fn n_slider_ends(self, n_slider_ends: u32) -> Self { if let Self::Osu(osu) = self { Self::Osu(osu.n_slider_ends(n_slider_ends)) diff --git a/src/any/score_state.rs b/src/any/score_state.rs index 94e8c904..260ea72e 100644 --- a/src/any/score_state.rs +++ b/src/any/score_state.rs @@ -15,10 +15,15 @@ pub struct ScoreState { /// /// Irrelevant for osu!mania. pub max_combo: u32, - /// Amount of successfully hit slider ticks and repeats. + /// "Large tick" hits for osu!standard. /// - /// Only relevant for osu!standard in lazer. - pub slider_tick_hits: u32, + /// The meaning depends on the kind of score: + /// - if set on osu!stable, this field is irrelevant and can be `0` + /// - if set on osu!lazer *without* `CL`, this field is the amount of hit + /// slider ticks and repeats + /// - if set on osu!lazer *with* `CL`, this field is the amount of hit + /// slider heads, ticks, and repeats + pub osu_large_tick_hits: u32, /// Amount of successfully hit slider ends. /// /// Only relevant for osu!standard in lazer. @@ -43,7 +48,7 @@ impl ScoreState { pub const fn new() -> Self { Self { max_combo: 0, - slider_tick_hits: 0, + osu_large_tick_hits: 0, slider_end_hits: 0, n_geki: 0, n_katu: 0, @@ -76,7 +81,7 @@ impl From for OsuScoreState { fn from(state: ScoreState) -> Self { Self { max_combo: state.max_combo, - slider_tick_hits: state.slider_tick_hits, + large_tick_hits: state.osu_large_tick_hits, slider_end_hits: state.slider_end_hits, n300: state.n300, n100: state.n100, @@ -127,7 +132,7 @@ impl From for ScoreState { fn from(state: OsuScoreState) -> Self { Self { max_combo: state.max_combo, - slider_tick_hits: state.slider_tick_hits, + osu_large_tick_hits: state.large_tick_hits, slider_end_hits: state.slider_end_hits, n_geki: 0, n_katu: 0, @@ -143,7 +148,7 @@ impl From for ScoreState { fn from(state: TaikoScoreState) -> Self { Self { max_combo: state.max_combo, - slider_tick_hits: 0, + osu_large_tick_hits: 0, slider_end_hits: 0, n_geki: 0, n_katu: 0, @@ -159,7 +164,7 @@ impl From for ScoreState { fn from(state: CatchScoreState) -> Self { Self { max_combo: state.max_combo, - slider_tick_hits: 0, + osu_large_tick_hits: 0, slider_end_hits: 0, n_geki: 0, n_katu: state.tiny_droplet_misses, @@ -175,7 +180,7 @@ impl From for ScoreState { fn from(state: ManiaScoreState) -> Self { Self { max_combo: 0, - slider_tick_hits: 0, + osu_large_tick_hits: 0, slider_end_hits: 0, n_geki: state.n320, n_katu: state.n200, diff --git a/src/catch/performance/mod.rs b/src/catch/performance/mod.rs index e384e6fa..ce12f016 100644 --- a/src/catch/performance/mod.rs +++ b/src/catch/performance/mod.rs @@ -474,7 +474,7 @@ impl<'map> TryFrom> for CatchPerformance<'map> { difficulty, acc, combo, - slider_tick_hits: _, + large_tick_hits: _, slider_end_hits: _, n300, n100, diff --git a/src/mania/performance/mod.rs b/src/mania/performance/mod.rs index 50764a68..bd11897f 100644 --- a/src/mania/performance/mod.rs +++ b/src/mania/performance/mod.rs @@ -845,7 +845,7 @@ impl<'map> TryFrom> for ManiaPerformance<'map> { difficulty, acc, combo: _, - slider_tick_hits: _, + large_tick_hits: _, slider_end_hits: _, n300, n100, diff --git a/src/model/mods.rs b/src/model/mods.rs index a79f80c7..596e0d42 100644 --- a/src/model/mods.rs +++ b/src/model/mods.rs @@ -102,12 +102,10 @@ impl GameMods { GameModsInner::Lazer(ref mods) => mods .iter() .find_map(|m| match m { - GameMod::ClassicOsu(classic) => Some(classic), + GameMod::ClassicOsu(cl) => Some(cl.no_slider_head_accuracy.unwrap_or(true)), _ => None, }) - .map_or(!lazer, |classic| { - classic.no_slider_head_accuracy.unwrap_or(true) - }), + .unwrap_or(!lazer), GameModsInner::Intermode(ref mods) => { mods.contains(GameModIntermode::Classic) || !lazer } @@ -230,6 +228,7 @@ impl_has_mod! { fl: + Flashlight ["Flashlight"], so: + SpunOut ["SpunOut"], bl: - Blinds ["Blinds"], + cl: - Classic ["Classic"], tc: - Traceable ["Traceable"], } diff --git a/src/osu/mod.rs b/src/osu/mod.rs index 1efcf4ad..60fb37bd 100644 --- a/src/osu/mod.rs +++ b/src/osu/mod.rs @@ -13,7 +13,7 @@ pub use self::{ convert::OsuBeatmap, difficulty::gradual::OsuGradualDifficulty, performance::{gradual::OsuGradualPerformance, OsuPerformance}, - score_state::OsuScoreState, + score_state::{OsuScoreOrigin, OsuScoreState}, strains::OsuStrains, }; diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index 351bf867..a5fe120c 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -14,7 +14,7 @@ use crate::{ use super::{ attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes}, difficulty::skills::{flashlight::Flashlight, strain::OsuStrainSkill}, - score_state::OsuScoreState, + score_state::{OsuScoreOrigin, OsuScoreState}, Osu, }; @@ -28,7 +28,7 @@ pub struct OsuPerformance<'map> { pub(crate) difficulty: Difficulty, pub(crate) acc: Option, pub(crate) combo: Option, - pub(crate) slider_tick_hits: Option, + pub(crate) large_tick_hits: Option, pub(crate) slider_end_hits: Option, pub(crate) n300: Option, pub(crate) n100: Option, @@ -167,11 +167,16 @@ impl<'map> OsuPerformance<'map> { self } - /// Specify the amount of hit slider ticks. + /// Specify the amount of "large tick" hits. /// - /// Only relevant for osu!lazer. - pub const fn n_slider_ticks(mut self, n_slider_ticks: u32) -> Self { - self.slider_tick_hits = Some(n_slider_ticks); + /// The meaning depends on the kind of score: + /// - if set on osu!stable, this value is irrelevant and can be `0` + /// - if set on osu!lazer *without* `CL`, this value is the amount of hit + /// slider ticks and repeats + /// - if set on osu!lazer *with* `CL`, this value is the amount of hit + /// slider heads, ticks, and repeats + pub const fn large_tick_hits(mut self, large_tick_hits: u32) -> Self { + self.large_tick_hits = Some(large_tick_hits); self } @@ -179,6 +184,9 @@ impl<'map> OsuPerformance<'map> { /// Specify the amount of hit slider ends. /// /// Only relevant for osu!lazer. + /// + /// osu! calls this value "slider tail hits" without the classic + /// mod and "small tick hits" with the classic mod. pub const fn n_slider_ends(mut self, n_slider_ends: u32) -> Self { self.slider_end_hits = Some(n_slider_ends); @@ -312,7 +320,7 @@ impl<'map> OsuPerformance<'map> { pub const fn state(mut self, state: OsuScoreState) -> Self { let OsuScoreState { max_combo, - slider_tick_hits, + large_tick_hits, slider_end_hits, n300, n100, @@ -321,7 +329,7 @@ impl<'map> OsuPerformance<'map> { } = state; self.combo = Some(max_combo); - self.slider_tick_hits = Some(slider_tick_hits); + self.large_tick_hits = Some(large_tick_hits); self.slider_end_hits = Some(slider_end_hits); self.n300 = Some(n300); self.n100 = Some(n100); @@ -365,29 +373,67 @@ impl<'map> OsuPerformance<'map> { let mut n100 = self.n100.map_or(0, |n| cmp::min(n, n_remaining)); let mut n50 = self.n50.map_or(0, |n| cmp::min(n, n_remaining)); + let classic = self.difficulty.get_mods().cl(); let lazer = self.difficulty.get_lazer(); - let (n_slider_ends, n_slider_ticks, max_slider_ends, max_slider_ticks) = if lazer { - let n_slider_ends = self - .slider_end_hits - .map_or(attrs.n_sliders, |n| cmp::min(n, attrs.n_sliders)); - let n_slider_ticks = self - .slider_tick_hits - .map_or(attrs.n_slider_ticks, |n| cmp::min(n, attrs.n_slider_ticks)); - - ( - n_slider_ends, - n_slider_ticks, - attrs.n_sliders, - attrs.n_slider_ticks, - ) - } else { - (0, 0, 0, 0) + let (origin, slider_end_hits, large_tick_hits) = match (lazer, classic) { + (false, _) => (OsuScoreOrigin::Stable, 0, 0), + (true, false) => { + let origin = OsuScoreOrigin::LazerWithoutClassic { + max_large_ticks: attrs.n_slider_ticks, + max_slider_ends: attrs.n_sliders, + }; + + let slider_end_hits = self + .slider_end_hits + .map_or(attrs.n_sliders, |n| cmp::min(n, attrs.n_sliders)); + + let large_tick_hits = self + .large_tick_hits + .map_or(attrs.n_slider_ticks, |n| cmp::min(n, attrs.n_slider_ticks)); + + (origin, slider_end_hits, large_tick_hits) + } + (true, true) => { + let origin = OsuScoreOrigin::LazerWithClassic { + max_large_ticks: attrs.n_sliders + attrs.n_slider_ticks, + max_slider_ends: attrs.n_sliders, + }; + + let slider_end_hits = self + .slider_end_hits + .map_or(attrs.n_sliders, |n| cmp::min(n, attrs.n_sliders)); + + let large_tick_hits = self + .large_tick_hits + .map_or(attrs.n_sliders + attrs.n_slider_ticks, |n| { + cmp::min(n, attrs.n_sliders + attrs.n_slider_ticks) + }); + + (origin, slider_end_hits, large_tick_hits) + } + }; + + let (slider_acc_value, max_slider_acc_value) = match origin { + OsuScoreOrigin::Stable => (0, 0), + OsuScoreOrigin::LazerWithoutClassic { + max_large_ticks, + max_slider_ends, + } => ( + 150 * slider_end_hits + 30 * large_tick_hits, + 150 * max_slider_ends + 30 * max_large_ticks, + ), + OsuScoreOrigin::LazerWithClassic { + max_large_ticks, + max_slider_ends, + } => ( + 30 * large_tick_hits + 10 * slider_end_hits, + 30 * max_large_ticks + 10 * max_slider_ends, + ), }; if let Some(acc) = self.acc { - let target_total = - acc * f64::from(30 * n_objects + 15 * max_slider_ends + 3 * max_slider_ticks); + let target_total = acc * f64::from(300 * n_objects + max_slider_acc_value); match (self.n300, self.n100, self.n50) { (Some(_), Some(_), Some(_)) => { @@ -408,10 +454,8 @@ impl<'map> OsuPerformance<'map> { let n_remaining = n_remaining - n300; let raw_n100 = (target_total - - f64::from( - 5 * n_remaining + 30 * n300 + 15 * n_slider_ends + 3 * n_slider_ticks, - )) - / 5.0; + - f64::from(50 * n_remaining + 300 * n300 + slider_acc_value)) + / 50.0; let min_n100 = cmp::min(n_remaining, raw_n100.floor() as u32); let max_n100 = cmp::min(n_remaining, raw_n100.ceil() as u32); @@ -423,11 +467,11 @@ impl<'map> OsuPerformance<'map> { n100: new100, n50: new50, misses, - n_slider_ticks, - n_slider_ends, + large_tick_hits, + slider_end_hits, }; - let dist = (acc - accuracy(state, max_slider_ticks, max_slider_ends)).abs(); + let dist = (acc - state.accuracy(origin)).abs(); if dist < best_dist { best_dist = dist; @@ -443,10 +487,8 @@ impl<'map> OsuPerformance<'map> { let n_remaining = n_remaining - n100; let raw_n300 = (target_total - - f64::from( - 5 * n_remaining + 10 * n100 + 15 * n_slider_ends + 3 * n_slider_ticks, - )) - / 25.0; + - f64::from(50 * n_remaining + 100 * n100 + slider_acc_value)) + / 250.0; let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32); let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32); @@ -458,12 +500,11 @@ impl<'map> OsuPerformance<'map> { n100, n50: new50, misses, - n_slider_ticks, - n_slider_ends, + large_tick_hits, + slider_end_hits, }; - let curr_dist = - (acc - accuracy(state, max_slider_ticks, max_slider_ends)).abs(); + let curr_dist = (acc - state.accuracy(origin)).abs(); if curr_dist < best_dist { best_dist = curr_dist; @@ -478,9 +519,9 @@ impl<'map> OsuPerformance<'map> { n50 = cmp::min(n50, n_remaining); let n_remaining = n_remaining - n50; - let raw_n300 = (target_total + f64::from(10 * misses + 5 * n50) - - f64::from(10 * n_objects + 15 * n_slider_ends + 3 * n_slider_ticks)) - / 20.0; + let raw_n300 = (target_total + f64::from(100 * misses + 50 * n50) + - f64::from(100 * n_objects + slider_acc_value)) + / 200.0; let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32); let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32); @@ -493,12 +534,11 @@ impl<'map> OsuPerformance<'map> { n100: new100, n50, misses, - n_slider_ticks, - n_slider_ends, + large_tick_hits, + slider_end_hits, }; - let curr_dist = - (acc - accuracy(state, max_slider_ticks, max_slider_ends)).abs(); + let curr_dist = (acc - state.accuracy(origin)).abs(); if curr_dist < best_dist { best_dist = curr_dist; @@ -510,21 +550,15 @@ impl<'map> OsuPerformance<'map> { (None, None, None) => { let mut best_dist = f64::MAX; - let raw_n300 = (target_total - - f64::from(5 * n_remaining + 15 * n_slider_ends + 3 * n_slider_ticks)) - / 25.0; + let raw_n300 = + (target_total - f64::from(50 * n_remaining + slider_acc_value)) / 250.0; let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32); let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32); for new300 in min_n300..=max_n300 { let raw_n100 = (target_total - - f64::from( - 5 * n_remaining - + 25 * new300 - + 15 * n_slider_ends - + 3 * n_slider_ticks, - )) - / 5.0; + - f64::from(50 * n_remaining + 250 * new300 + slider_acc_value)) + / 50.0; let min_n100 = cmp::min(raw_n100.floor() as u32, n_remaining - new300); let max_n100 = cmp::min(raw_n100.ceil() as u32, n_remaining - new300); @@ -536,12 +570,11 @@ impl<'map> OsuPerformance<'map> { n100: new100, n50: new50, misses, - n_slider_ticks, - n_slider_ends, + large_tick_hits, + slider_end_hits, }; - let curr_dist = - (acc - accuracy(state, max_slider_ticks, max_slider_ends)).abs(); + let curr_dist = (acc - state.accuracy(origin)).abs(); if curr_dist < best_dist { best_dist = curr_dist; @@ -596,8 +629,8 @@ impl<'map> OsuPerformance<'map> { }); self.combo = Some(max_combo); - self.slider_end_hits = Some(n_slider_ends); - self.slider_tick_hits = Some(n_slider_ticks); + self.slider_end_hits = Some(slider_end_hits); + self.large_tick_hits = Some(large_tick_hits); self.n300 = Some(n300); self.n100 = Some(n100); self.n50 = Some(n50); @@ -605,8 +638,8 @@ impl<'map> OsuPerformance<'map> { OsuScoreState { max_combo, - slider_tick_hits: n_slider_ticks, - slider_end_hits: n_slider_ends, + large_tick_hits, + slider_end_hits, n300, n100, n50, @@ -625,6 +658,7 @@ impl<'map> OsuPerformance<'map> { let mods = self.difficulty.get_mods(); let lazer = self.difficulty.get_lazer(); + let classic = mods.cl(); let using_classic_slider_acc = mods.no_slider_head_acc(lazer); let mut effective_miss_count = f64::from(state.misses); @@ -661,16 +695,24 @@ impl<'map> OsuPerformance<'map> { effective_miss_count = effective_miss_count.max(f64::from(state.misses)); effective_miss_count = effective_miss_count.min(f64::from(state.total_hits())); - let (n_slider_ends, n_slider_ticks) = if lazer { - (attrs.n_sliders, attrs.n_slider_ticks) - } else { - (0, 0) + let origin = match (lazer, classic) { + (false, _) => OsuScoreOrigin::Stable, + (true, false) => OsuScoreOrigin::LazerWithoutClassic { + max_large_ticks: attrs.n_slider_ticks, + max_slider_ends: attrs.n_sliders, + }, + (true, true) => OsuScoreOrigin::LazerWithClassic { + max_large_ticks: attrs.n_sliders + attrs.n_slider_ticks, + max_slider_ends: attrs.n_sliders, + }, }; + let acc = state.accuracy(origin); + let inner = OsuPerformanceInner { attrs, mods, - acc: state.accuracy(n_slider_ticks, n_slider_ends), + acc, state, effective_miss_count, using_classic_slider_acc, @@ -685,7 +727,7 @@ impl<'map> OsuPerformance<'map> { difficulty: Difficulty::new(), acc: None, combo: None, - slider_tick_hits: None, + large_tick_hits: None, slider_end_hits: None, n300: None, n100: None, @@ -1047,7 +1089,7 @@ const fn n_slider_ends_dropped(attrs: &OsuDifficultyAttributes, state: &OsuScore } const fn n_slider_tick_miss(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> u32 { - attrs.n_slider_ticks - state.slider_tick_hits + attrs.n_slider_ticks - state.large_tick_hits } struct NoComboState { @@ -1055,31 +1097,45 @@ struct NoComboState { n100: u32, n50: u32, misses: u32, - n_slider_ticks: u32, - n_slider_ends: u32, + large_tick_hits: u32, + slider_end_hits: u32, } -#[allow(clippy::needless_pass_by_value)] -fn accuracy(state: NoComboState, max_slider_ticks: u32, max_slider_ends: u32) -> f64 { - let NoComboState { - n300, - n100, - n50, - misses, - n_slider_ticks, - n_slider_ends, - } = state; - - if n_slider_ticks + n_slider_ends + n300 + n100 + n50 + misses == 0 { - return 0.0; - } - - let numerator = 300 * n300 + 100 * n100 + 50 * n50 + 150 * n_slider_ends + 30 * n_slider_ticks; - - let denominator = - 300 * (n300 + n100 + n50 + misses) + 150 * max_slider_ends + 30 * max_slider_ticks; +impl NoComboState { + fn accuracy(&self, origin: OsuScoreOrigin) -> f64 { + let mut numerator = 300 * self.n300 + 100 * self.n100 + 50 * self.n50; + let mut denominator = 300 * (self.n300 + self.n100 + self.n50 + self.misses); + + match origin { + OsuScoreOrigin::Stable => {} + OsuScoreOrigin::LazerWithoutClassic { + max_large_ticks, + max_slider_ends, + } => { + let slider_end_hits = self.slider_end_hits.min(max_slider_ends); + let large_tick_hits = self.large_tick_hits.min(max_large_ticks); + + numerator += 150 * slider_end_hits + 30 * large_tick_hits; + denominator += 150 * max_slider_ends + 30 * max_large_ticks; + } + OsuScoreOrigin::LazerWithClassic { + max_large_ticks, + max_slider_ends, + } => { + let large_tick_hits = self.large_tick_hits.min(max_large_ticks); + let slider_end_hits = self.slider_end_hits.min(max_slider_ends); + + numerator += 30 * large_tick_hits + 10 * slider_end_hits; + denominator += 30 * max_large_ticks + 10 * max_slider_ends; + } + } - f64::from(numerator) / f64::from(denominator) + if denominator == 0 { + 0.0 + } else { + f64::from(numerator) / f64::from(denominator) + } + } } #[cfg(test)] @@ -1087,6 +1143,7 @@ mod test { use std::sync::OnceLock; use proptest::prelude::*; + use rosu_mods::{GameModIntermode, GameModsIntermode}; use crate::{ any::{DifficultyAttributes, PerformanceAttributes}, @@ -1135,9 +1192,10 @@ mod test { #[allow(clippy::too_many_arguments)] fn brute_force_best( lazer: bool, + classic: bool, acc: f64, - n_slider_ticks: Option, - n_slider_ends: Option, + large_tick_hits: Option, + slider_end_hits: Option, n300: Option, n100: Option, n50: Option, @@ -1146,20 +1204,41 @@ mod test { ) -> OsuScoreState { let misses = cmp::min(misses, N_OBJECTS); - let (n_slider_ends, n_slider_ticks, max_slider_ends, max_slider_ticks) = if lazer { - let n_slider_ends = n_slider_ends.map_or(N_SLIDERS, |n| cmp::min(n, N_SLIDERS)); - let n_slider_ticks = - n_slider_ticks.map_or(N_SLIDER_TICKS, |n| cmp::min(n, N_SLIDER_TICKS)); + let (origin, slider_end_hits, large_tick_hits) = match (lazer, classic) { + (false, _) => (OsuScoreOrigin::Stable, 0, 0), + (true, false) => { + let origin = OsuScoreOrigin::LazerWithoutClassic { + max_large_ticks: N_SLIDER_TICKS, + max_slider_ends: N_SLIDERS, + }; - (n_slider_ends, n_slider_ticks, N_SLIDERS, N_SLIDER_TICKS) - } else { - (0, 0, 0, 0) + let slider_end_hits = slider_end_hits.map_or(N_SLIDERS, |n| cmp::min(n, N_SLIDERS)); + + let large_tick_hits = + large_tick_hits.map_or(N_SLIDER_TICKS, |n| cmp::min(n, N_SLIDER_TICKS)); + + (origin, slider_end_hits, large_tick_hits) + } + (true, true) => { + let origin = OsuScoreOrigin::LazerWithClassic { + max_large_ticks: N_SLIDERS + N_SLIDER_TICKS, + max_slider_ends: N_SLIDERS, + }; + + let slider_end_hits = slider_end_hits.map_or(N_SLIDERS, |n| cmp::min(n, N_SLIDERS)); + + let large_tick_hits = large_tick_hits.map_or(N_SLIDERS + N_SLIDER_TICKS, |n| { + cmp::min(n, N_SLIDERS + N_SLIDER_TICKS) + }); + + (origin, slider_end_hits, large_tick_hits) + } }; let mut best_state = OsuScoreState { misses, - slider_end_hits: n_slider_ends, - slider_tick_hits: n_slider_ticks, + slider_end_hits, + large_tick_hits, ..Default::default() }; @@ -1200,11 +1279,11 @@ mod test { n100: new100, n50: new50, misses, - n_slider_ticks, - n_slider_ends, + large_tick_hits, + slider_end_hits, }; - let curr_acc = accuracy(state, max_slider_ticks, max_slider_ends); + let curr_acc = state.accuracy(origin); let curr_dist = (acc - curr_acc).abs(); if curr_dist < best_dist { @@ -1249,9 +1328,10 @@ mod test { #[test] fn hitresults( lazer in prop::bool::ANY, + classic in prop::bool::ANY, acc in 0.0_f64..=1.0, - n_slider_ticks in prop::option::weighted(0.1, 0_u32..=N_SLIDER_TICKS + 10), - n_slider_ends in prop::option::weighted(0.1, 0_u32..=N_SLIDERS + 10), + large_tick_hits in prop::option::weighted(0.1, 0_u32..=N_SLIDERS + N_SLIDER_TICKS + 10), + slider_end_hits in prop::option::weighted(0.1, 0_u32..=N_SLIDERS + 10), n300 in prop::option::weighted(0.1, 0_u32..=N_OBJECTS + 10), n100 in prop::option::weighted(0.1, 0_u32..=N_OBJECTS + 10), n50 in prop::option::weighted(0.1, 0_u32..=N_OBJECTS + 10), @@ -1272,11 +1352,17 @@ mod test { .lazer(lazer) .hitresult_priority(priority); - if let Some(n_slider_ticks) = n_slider_ticks { - state = state.n_slider_ticks(n_slider_ticks); + if lazer && classic { + let mut mods = GameModsIntermode::new(); + mods.insert(GameModIntermode::Classic); + state = state.mods(mods); + } + + if let Some(large_tick_hits) = large_tick_hits { + state = state.large_tick_hits(large_tick_hits); } - if let Some(n_slider_ends) = n_slider_ends { + if let Some(n_slider_ends) = slider_end_hits { state = state.n_slider_ends(n_slider_ends); } @@ -1302,9 +1388,10 @@ mod test { let mut expected = brute_force_best( lazer, + classic, acc, - n_slider_ticks, - n_slider_ends, + large_tick_hits, + slider_end_hits, n300, n100, n50, @@ -1330,7 +1417,7 @@ mod test { let expected = OsuScoreState { max_combo: 500, - slider_tick_hits: N_SLIDER_TICKS, + large_tick_hits: N_SLIDER_TICKS, slider_end_hits: N_SLIDERS, n300: 300, n100: 20, @@ -1354,7 +1441,7 @@ mod test { let expected = OsuScoreState { max_combo: 500, - slider_tick_hits: 0, + large_tick_hits: 0, slider_end_hits: 0, n300: 300, n100: 289, @@ -1377,7 +1464,7 @@ mod test { let expected = OsuScoreState { max_combo: 500, - slider_tick_hits: N_SLIDER_TICKS, + large_tick_hits: N_SLIDER_TICKS, slider_end_hits: N_SLIDERS, n300: 0, n100: 589, @@ -1402,7 +1489,7 @@ mod test { let expected = OsuScoreState { max_combo: 500, - slider_tick_hits: 0, + large_tick_hits: 0, slider_end_hits: 0, n300: 300, n100: 50, diff --git a/src/osu/score_state.rs b/src/osu/score_state.rs index e5a54ea6..be1ceac2 100644 --- a/src/osu/score_state.rs +++ b/src/osu/score_state.rs @@ -4,10 +4,15 @@ pub struct OsuScoreState { /// Maximum combo that the score has had so far. **Not** the maximum /// possible combo of the map so far. pub max_combo: u32, - /// Amount of successfully hit slider ticks and repeat. + /// "Large tick" hits. /// - /// Only relevant for osu!lazer. - pub slider_tick_hits: u32, + /// The meaning depends on the kind of score: + /// - if set on osu!stable, this field is irrelevant and can be `0` + /// - if set on osu!lazer *without* `CL`, this field is the amount of hit + /// slider ticks and repeats + /// - if set on osu!lazer *with* `CL`, this field is the amount of hit + /// slider heads, ticks, and repeats + pub large_tick_hits: u32, /// Amount of successfully hit slider ends. /// /// Only relevant for osu!lazer. @@ -27,7 +32,7 @@ impl OsuScoreState { pub const fn new() -> Self { Self { max_combo: 0, - slider_tick_hits: 0, + large_tick_hits: 0, slider_end_hits: 0, n300: 0, n100: 0, @@ -42,37 +47,39 @@ impl OsuScoreState { } /// Calculate the accuracy between `0.0` and `1.0` for this state. - /// - /// `max_slider_ticks` and `max_slider_ends` are only relevant for - /// `osu!lazer` scores. Otherwise, they may be `0`. - pub fn accuracy(&self, max_slider_ticks: u32, max_slider_ends: u32) -> f64 { - if self.total_hits() + self.slider_tick_hits + self.slider_end_hits == 0 { - return 0.0; - } + pub fn accuracy(&self, origin: OsuScoreOrigin) -> f64 { + let mut numerator = 300 * self.n300 + 100 * self.n100 + 50 * self.n50; + let mut denominator = 300 * (self.n300 + self.n100 + self.n50 + self.misses); - debug_assert!( - self.slider_end_hits <= max_slider_ends, - "`self.slider_end_hits` must not be greater than `max_slider_ends`" - ); - debug_assert!( - self.slider_tick_hits <= max_slider_ticks, - "`self.slider_tick_hits` must not be greater than `max_slider_ticks`" - ); + match origin { + OsuScoreOrigin::Stable => {} + OsuScoreOrigin::LazerWithoutClassic { + max_large_ticks, + max_slider_ends, + } => { + let slider_end_hits = self.slider_end_hits.min(max_slider_ends); + let large_tick_hits = self.large_tick_hits.min(max_large_ticks); - let numerator = 300 * self.n300 - + 100 * self.n100 - + 50 * self.n50 - + 150 * self.slider_end_hits - + 30 * self.slider_tick_hits; + numerator += 150 * slider_end_hits + 30 * large_tick_hits; + denominator += 150 * max_slider_ends + 30 * max_large_ticks; + } + OsuScoreOrigin::LazerWithClassic { + max_large_ticks, + max_slider_ends, + } => { + let large_tick_hits = self.large_tick_hits.min(max_large_ticks); + let slider_end_hits = self.slider_end_hits.min(max_slider_ends); - let denominator = 300 * self.n300 - + 300 * self.n100 - + 300 * self.n50 - + 300 * self.misses - + 150 * max_slider_ends - + 30 * max_slider_ticks; + numerator += 30 * large_tick_hits + 10 * slider_end_hits; + denominator += 30 * max_large_ticks + 10 * max_slider_ends; + } + } - f64::from(numerator) / f64::from(denominator) + if denominator == 0 { + 0.0 + } else { + f64::from(numerator) / f64::from(denominator) + } } } @@ -81,3 +88,19 @@ impl Default for OsuScoreState { Self::new() } } + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum OsuScoreOrigin { + /// For scores set on osu!stable + Stable, + /// For scores set on osu!lazer without the `Classic` mod + LazerWithoutClassic { + max_large_ticks: u32, + max_slider_ends: u32, + }, + /// For scores set on osu!lazer with the `Classic` mod + LazerWithClassic { + max_large_ticks: u32, + max_slider_ends: u32, + }, +} diff --git a/src/taiko/performance/mod.rs b/src/taiko/performance/mod.rs index bd422df8..39a76bcd 100644 --- a/src/taiko/performance/mod.rs +++ b/src/taiko/performance/mod.rs @@ -364,7 +364,7 @@ impl<'map> TryFrom> for TaikoPerformance<'map> { difficulty, acc, combo, - slider_tick_hits: _, + large_tick_hits: _, slider_end_hits: _, n300, n100, From e2bfaeaaa1b0ce44250a5e36f69426f76328b8f4 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Fri, 15 Nov 2024 01:03:31 +0100 Subject: [PATCH 46/48] fix: handle hitsounds with filenames differently --- src/model/beatmap/decode.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/model/beatmap/decode.rs b/src/model/beatmap/decode.rs index f2725033..eb707abe 100644 --- a/src/model/beatmap/decode.rs +++ b/src/model/beatmap/decode.rs @@ -614,7 +614,9 @@ impl DecodeBeatmap for Beatmap { // filename match split.next() { None | Some("") => {} - Some(_) => sound = HitSoundType::default(), + // Relevant maps: + // - /b/244784 at 43374 + Some(_) => sound &= !HitSoundType::NORMAL, } Ok(()) From c615e549f1d4ab1a127b50621c25d16e8c774bb7 Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Mon, 18 Nov 2024 12:40:29 +0100 Subject: [PATCH 47/48] dep: use published dependencies --- Cargo.toml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 85c8bd9d..9f07f067 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,10 +17,8 @@ sync = [] tracing = ["rosu-map/tracing"] [dependencies] -# rosu-map = { version = "0.1.2" } -rosu-map = { git = "https://github.com/MaxOhn/rosu-map", branch = "pp-update" } -# rosu-mods = { version = "0.1.0" } -rosu-mods = { git = "https://github.com/MaxOhn/rosu-mods", branch = "main" } +rosu-map = { version = "0.2.0" } +rosu-mods = { version = "0.2.0" } [dev-dependencies] proptest = "1.4.0" From 1668bea2102519f896767f124cd3b736eb122f3a Mon Sep 17 00:00:00 2001 From: MaxOhn Date: Mon, 18 Nov 2024 12:44:27 +0100 Subject: [PATCH 48/48] doc: update readme --- README.md | 12 ++++-------- src/lib.rs | 12 ++++-------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ff5eaf40..2994b404 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,10 @@ with emphasis on a precise translation to Rust for the most [accurate results](# while also providing a significant [boost in performance](#speed). Last commits of the ported code: - - [osu!lazer] : `f08134f443b2cf255fd19c8bc3ef517b6a3bb8e3` (2024-09-23) - - [osu!tools] : `51965515eb4355fde0591728ef4d38eee119a964` (2024-09-01) - -News posts of the latest gamemode updates: - - osu: - - taiko: - - catch: - - mania: + - [osu!lazer] : `8bd65d9938a10fc42e6409501b0282f0fa4a25ef` (2024-11-08) + - [osu!tools] : `89b8f3b1c2e4e5674004eac4723120e7d3aef997` (2024-11-03) + +News posts of the latest updates: ### Usage diff --git a/src/lib.rs b/src/lib.rs index dabf5d09..b2deed0a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,14 +5,10 @@ //! while also providing a significant [boost in performance](#speed). //! //! Last commits of the ported code: -//! - [osu!lazer] : `f08134f443b2cf255fd19c8bc3ef517b6a3bb8e3` (2024-09-23) -//! - [osu!tools] : `51965515eb4355fde0591728ef4d38eee119a964` (2024-09-01) -//! -//! News posts of the latest gamemode updates: -//! - osu: -//! - taiko: -//! - catch: -//! - mania: +//! - [osu!lazer] : `8bd65d9938a10fc42e6409501b0282f0fa4a25ef` (2024-11-08) +//! - [osu!tools] : `89b8f3b1c2e4e5674004eac4723120e7d3aef997` (2024-11-03) +//! +//! News posts of the latest updates: //! //! ## Usage //!