diff --git a/Cargo.toml b/Cargo.toml index 1e41427e..9f07f067 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,8 @@ sync = [] tracing = ["rosu-map/tracing"] [dependencies] -rosu-map = { version = "0.1.1" } -rosu-mods = { version = "0.1.0" } +rosu-map = { version = "0.2.0" } +rosu-mods = { version = "0.2.0" } [dev-dependencies] proptest = "1.4.0" diff --git a/README.md b/README.md index 741aa511..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] : `7342fb7f51b34533a42bffda89c3d6c569cc69ce` (2022-10-11) - - [osu!tools] : `146d5916937161ef65906aa97f85d367035f3712` (2022-10-08) - -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/proptest-regressions/osu/performance/mod.txt b/proptest-regressions/osu/performance/mod.txt index 6b101f0b..12ba8f12 100644 --- a/proptest-regressions/osu/performance/mod.txt +++ b/proptest-regressions/osu/performance/mod.txt @@ -12,3 +12,6 @@ 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 +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/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..8a923958 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,17 +51,16 @@ 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, od: Option, hardrock_offsets: Option, + lazer: Option, } /// Wrapper for beatmap attributes in [`Difficulty`]. @@ -97,6 +96,7 @@ impl Difficulty { hp: None, od: None, hardrock_offsets: None, + lazer: None, } } @@ -120,17 +120,19 @@ impl Difficulty { hp, od, hardrock_offsets, + lazer, } = self; 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, od, hardrock_offsets, + lazer, } } @@ -167,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), @@ -268,6 +270,16 @@ impl Difficulty { self } + /// Whether the calculated attributes belong to an osu!lazer or osu!stable + /// score. + /// + /// Defaults to `true`. + 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); @@ -316,11 +328,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_u32_to_f32); - - 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 { @@ -347,10 +356,14 @@ 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 { - f32::from_bits(n.get()) +fn non_zero_u64_to_f64(n: NonZeroU64) -> f64 { + f64::from_bits(n.get()) } impl Debug for Difficulty { @@ -364,17 +377,19 @@ impl Debug for Difficulty { hp, od, hardrock_offsets, + lazer, } = self; 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) .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 5ef1e869..ca6d66e0 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 @@ -299,6 +299,55 @@ impl<'map> Performance<'map> { } } + /// Whether the calculated attributes belong to an osu!lazer or osu!stable + /// score. + /// + /// Defaults to `true`. + /// + /// This affects internal accuracy calculation because lazer considers + /// slider heads for accuracy whereas stable does not. + /// + /// Only relevant for osu!standard and osu!mania. + pub fn lazer(self, lazer: bool) -> Self { + match self { + Self::Osu(o) => Self::Osu(o.lazer(lazer)), + Self::Taiko(_) | Self::Catch(_) => self, + Self::Mania(m) => Self::Mania(m.lazer(lazer)), + } + } + + /// Specify the amount of "large tick" hits. + /// + /// Only relevant for osu!standard. + /// + /// 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.large_tick_hits(large_tick_hits)) + } else { + self + } + } + + /// 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)) + } else { + self + } + } + /// Specify the amount of 300s of a play. pub fn n300(self, n300: u32) -> Self { match self { diff --git a/src/any/score_state.rs b/src/any/score_state.rs index 08c50731..260ea72e 100644 --- a/src/any/score_state.rs +++ b/src/any/score_state.rs @@ -15,6 +15,19 @@ pub struct ScoreState { /// /// Irrelevant for osu!mania. pub max_combo: u32, + /// "Large tick" hits for osu!standard. + /// + /// 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. + 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 +48,8 @@ impl ScoreState { pub const fn new() -> Self { Self { max_combo: 0, + osu_large_tick_hits: 0, + slider_end_hits: 0, n_geki: 0, n_katu: 0, n300: 0, @@ -66,6 +81,8 @@ impl From for OsuScoreState { fn from(state: ScoreState) -> Self { Self { max_combo: state.max_combo, + large_tick_hits: state.osu_large_tick_hits, + slider_end_hits: state.slider_end_hits, n300: state.n300, n100: state.n100, n50: state.n50, @@ -115,6 +132,8 @@ impl From for ScoreState { fn from(state: OsuScoreState) -> Self { Self { max_combo: state.max_combo, + osu_large_tick_hits: state.large_tick_hits, + slider_end_hits: state.slider_end_hits, n_geki: 0, n_katu: 0, n300: state.n300, @@ -129,6 +148,8 @@ impl From for ScoreState { fn from(state: TaikoScoreState) -> Self { Self { max_combo: state.max_combo, + osu_large_tick_hits: 0, + slider_end_hits: 0, n_geki: 0, n_katu: 0, n300: state.n300, @@ -143,6 +164,8 @@ impl From for ScoreState { fn from(state: CatchScoreState) -> Self { Self { max_combo: state.max_combo, + osu_large_tick_hits: 0, + slider_end_hits: 0, n_geki: 0, n_katu: state.tiny_droplet_misses, n300: state.fruits, @@ -157,6 +180,8 @@ impl From for ScoreState { fn from(state: ManiaScoreState) -> Self { Self { max_combo: 0, + osu_large_tick_hits: 0, + slider_end_hits: 0, n_geki: state.n320, n_katu: state.n200, n300: state.n300, diff --git a/src/catch/catcher.rs b/src/catch/catcher.rs index 42fc1dcb..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 * (cs - 5.0) / 5.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 } } diff --git a/src/catch/convert.rs b/src/catch/convert.rs index a1a64eca..315b78b3 100644 --- a/src/catch/convert.rs +++ b/src/catch/convert.rs @@ -5,8 +5,9 @@ use crate::{ beatmap::{Beatmap, Converted}, hit_object::{HitObject, HitObjectKind, HoldNote, Spinner}, mode::ConvertStatus, + mods::Reflection, }, - util::{float_ext::FloatExt, random::Random, sort::TandemSorter}, + util::{float_ext::FloatExt, random::Random}, }; use super::{ @@ -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,20 +84,15 @@ 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); + if let Reflection::Horizontal = reflection { + for h in palpable_objects.iter_mut() { + h.x = PLAYFIELD_WIDTH - h.x; + h.x_offset = -h.x_offset; + } + } - 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 } @@ -109,14 +106,15 @@ 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 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) } 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)) } }; @@ -232,11 +230,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 +311,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/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 36fc6950..c5b1e1a8 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, @@ -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, @@ -96,7 +102,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/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 5b4e870b..4ead239d 100644 --- a/src/catch/object/juice_stream.rs +++ b/src/catch/object/juice_stream.rs @@ -1,15 +1,17 @@ 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::{ - catch::{attributes::ObjectCountBuilder, convert::CatchBeatmap, PLAYFIELD_WIDTH}, + catch::{attributes::ObjectCountBuilder, convert::CatchBeatmap}, model::{ control_point::{DifficultyPoint, TimingPoint}, hit_object::Slider, }, + util::get_precision_adjusted_beat_len, }; pub struct JuiceStream<'a> { @@ -21,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<'_>, @@ -41,14 +43,19 @@ 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; - let tick_dist_factor = - JuiceStream::BASE_SCORING_DIST * slider_multiplier / slider_tick_rate; + 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 = tick_dist_factor * slider_velocity; + 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; @@ -69,7 +76,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; @@ -115,7 +122,7 @@ impl<'a> JuiceStream<'a> { }; let nested = NestedJuiceStreamObject { - pos: Self::clamp_to_playfield(x + path.position_at(e.path_progress).x), + pos: effective_x + path.position_at(e.path_progress).x, start_time: e.time, kind, }; @@ -128,18 +135,16 @@ 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)] pub struct NestedJuiceStreamObject { pub pos: f32, pub start_time: f64, pub kind: NestedJuiceStreamObjectKind, } +#[derive(Debug)] pub enum NestedJuiceStreamObjectKind { Fruit, Droplet, diff --git a/src/catch/performance/mod.rs b/src/catch/performance/mod.rs index 7b9620a7..ce12f016 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 @@ -474,6 +474,8 @@ impl<'map> TryFrom> for CatchPerformance<'map> { difficulty, acc, combo, + large_tick_hits: _, + slider_end_hits: _, n300, n100, n50, @@ -569,7 +571,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 { @@ -615,7 +617,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/src/lib.rs b/src/lib.rs index 60a28124..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] : `7342fb7f51b34533a42bffda89c3d6c569cc69ce` (2022-10-11) -//! - [osu!tools] : `146d5916937161ef65906aa97f85d367035f3712` (2022-10-08) -//! -//! 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/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/convert/mod.rs b/src/mania/convert/mod.rs index f358930f..ad2bf5bb 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(&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,23 +189,32 @@ 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 = 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)); + } + } + + // Keeping it in-sync with lazer + #[allow(clippy::manual_clamp)] + { + ((rounded_od as i32) + 1).min(7).max(4) as f32 } } 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 96% rename from src/mania/convert/pattern_generator/distance_object.rs rename to src/mania/convert/pattern_generator/path_object.rs index 5728df02..16361af3 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::{ @@ -12,12 +12,12 @@ use crate::{ control_point::{DifficultyPoint, EffectPoint, TimingPoint}, hit_object::HitObject, }, - util::random::Random, + util::{get_precision_adjusted_beat_len, random::Random}, }; 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,17 +39,17 @@ 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 .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,14 +62,16 @@ impl<'h> DistanceObjectPatternGenerator<'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; + 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; diff --git a/src/mania/difficulty/gradual.rs b/src/mania/difficulty/gradual.rs index 6af40416..bc2508d8 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. @@ -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 { @@ -65,8 +71,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 @@ -78,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 @@ -93,7 +101,7 @@ impl ManiaGradualDifficulty { objects_is_circle[0], hit_object.start_time, hit_object.end_time, - &mut curr_combo, + &mut note_state, ); } @@ -105,7 +113,7 @@ impl ManiaGradualDifficulty { strain, diff_objects, hit_window, - curr_combo, + note_state, } } } @@ -126,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() { @@ -136,10 +144,11 @@ 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, + 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, }) } @@ -169,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; } @@ -187,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 ad66ec3d..2848962e 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, @@ -28,13 +28,14 @@ pub fn difficulty( .attributes() .difficulty(difficulty) .hit_windows() - .od; + .od_great; 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, + 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/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/object.rs b/src/mania/object.rs index 03919cb6..06f1c2c4 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 @@ -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 const fn max_combo(&self) -> u32 { self.max_combo } + + pub const 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 9400e1b2..bd11897f 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 @@ -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)); @@ -827,6 +845,8 @@ impl<'map> TryFrom> for ManiaPerformance<'map> { difficulty, acc, combo: _, + large_tick_hits: _, + slider_end_hits: _, n300, n100, n50, @@ -863,9 +883,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 +905,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 @@ -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/src/model/beatmap/attributes.rs b/src/model/beatmap/attributes.rs index ba057ad1..8b75a170 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`. @@ -73,13 +105,13 @@ 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)), - mods: GameMods::DEFAULT, - clock_rate: None, is_convert: map.is_convert, + ..self } } @@ -195,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 }; @@ -219,10 +248,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 +259,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 +271,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,22 +293,23 @@ 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, } } /// 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); @@ -308,7 +332,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 +347,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 +377,11 @@ impl From<&Converted<'_, M>> for BeatmapAttributesBuilder { } } -fn difficulty_range(difficulty: f64, min: f64, mid: f64, max: f64) -> f64 { +// 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; + if difficulty > 5.0 { mid + (max - mid) * (difficulty - 5.0) / 5.0 } else if difficulty < 5.0 { @@ -378,9 +412,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, } } @@ -388,13 +422,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(); @@ -403,7 +442,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) @@ -415,7 +454,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) @@ -428,10 +467,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(); @@ -442,10 +481,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); @@ -457,10 +496,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/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(()) 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..596e0d42 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(), @@ -96,6 +96,59 @@ 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(cl) => Some(cl.no_slider_head_accuracy.unwrap_or(true)), + _ => None, + }) + .unwrap_or(!lazer), + 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(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, + }) + .unwrap_or(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 { @@ -105,7 +158,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, )* @@ -175,6 +228,8 @@ impl_has_mod! { fl: + Flashlight ["Flashlight"], so: + SpunOut ["SpunOut"], bl: - Blinds ["Blinds"], + cl: - Classic ["Classic"], + tc: - Traceable ["Traceable"], } impl Default for GameMods { @@ -223,3 +278,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/attributes.rs b/src/osu/attributes.rs index c968d8e2..281e8c2f 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 @@ -23,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 4d13b160..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, @@ -55,6 +56,7 @@ 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; } OsuObjectKind::Spinner(_) => attrs.n_spinners += 1, @@ -62,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_tail); + .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); @@ -246,13 +253,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/gradual.rs b/src/osu/difficulty/gradual.rs index e12c4f98..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, @@ -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, @@ -178,9 +180,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 94f39f73..f8ec0ab2 100644 --- a/src/osu/difficulty/mod.rs +++ b/src/osu/difficulty/mod.rs @@ -1,5 +1,10 @@ use std::{cmp, pin::Pin}; +use skills::{ + flashlight::Flashlight, + strain::{DifficultyValue, OsuStrainSkill, UsedOsuStrainSkills}, +}; + use crate::{ any::difficulty::{skills::Skill, Difficulty}, model::{beatmap::BeatmapAttributes, mods::GameMods}, @@ -48,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, ); @@ -109,7 +114,7 @@ impl DifficultyValues { let mut osu_objects = convert_objects( converted, &scaling_factor, - mods.hr(), + mods.reflection(), time_preempt, take, &mut attrs, @@ -146,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 { @@ -163,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); @@ -174,13 +183,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 }; @@ -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 4c24371c..a5c6eae0 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}; @@ -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, @@ -158,20 +176,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 +214,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..854ee871 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 = (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 2ceff6f6..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 = 23.55; +const SKILL_MULTIPLIER: f64 = 25.18; const STRAIN_DECAY_BASE: f64 = 0.15; #[derive(Clone)] @@ -31,25 +31,24 @@ 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, OsuStrainSkill::DECAY_WEIGHT, - OsuStrainSkill::DIFFICULTY_MULTIPLER, ) } } @@ -105,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 } @@ -121,7 +121,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 +139,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 +152,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 +254,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/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/osu/difficulty/skills/speed.rs b/src/osu/difficulty/skills/speed.rs index 7b4260e0..1ff48a82 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,19 +12,17 @@ use crate::{ util::strains_vec::StrainsVec, }; -use super::strain::OsuStrainSkill; +use super::strain::{DifficultyValue, OsuStrainSkill, UsedOsuStrainSkills}; -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)] pub struct Speed { curr_strain: f64, curr_rhythm: f64, - object_strains: Vec, hit_window: f64, inner: OsuStrainSkill, } @@ -31,44 +32,42 @@ 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, OsuStrainSkill::DECAY_WEIGHT, - DIFFICULTY_MULTIPLER, ) } 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() }) }) @@ -131,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,9 +139,10 @@ 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.; + const SPEED_BALANCING_FACTOR: f64 = 40.0; + const DIST_MULTIPLIER: f64 = 0.94; fn evaluate_diff_of<'a>( curr: &'a OsuDifficultyObject<'a>, @@ -159,47 +159,51 @@ 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. 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) + 0.75 * base.powf(2.0) } else { - 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); - 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; + + // * Cap distance at single_spacing_threshold + dist = Self::SINGLE_SPACING_THRESHOLD.min(dist); - (speed_bonus + speed_bonus * (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.5)) - * doubletapness - / strain_time + // * 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 = (1.0 + speed_bonus + dist_bonus) * 1000.0 / strain_time; + + // * Apply penalty if there's doubletappable doubles + difficulty * doubletapness } } 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; + #[allow(clippy::too_many_lines)] fn evaluate_diff_of<'a>( curr: &'a OsuDifficultyObject<'a>, diff_objects: &'a [OsuDifficultyObject<'a>], @@ -209,16 +213,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; @@ -233,107 +244,217 @@ 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; + if let Some((mut prev_obj, mut last_obj)) = curr + .previous(rhythm_start, diff_objects) + .zip(curr.previous(rhythm_start + 1, diff_objects)) + { + // * we go from the furthest object back to the current one + for i in (1..=rhythm_start).rev() { + let Some(curr_obj) = curr.previous(i - 1, diff_objects) else { + break; + }; + + // * scales note 0 to 1 from history to now + let time_decay = (f64::from(Self::HISTORY_TIME_MAX) + - (curr.start_time - curr_obj.start_time)) + / f64::from(Self::HISTORY_TIME_MAX); + let note_decay = (historical_note_count - i) as f64 / historical_note_count as f64; + + // * either we're limited by time or limited by object count. + let curr_historical_decay = note_decay.min(time_decay); + + let curr_delta = curr_obj.strain_time; + let prev_delta = prev_obj.strain_time; + let last_delta = last_obj.strain_time; + + // * 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); + + 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 - curr_delta).abs() < delta_difference_eps { + // * island is still progressing + island.add_delta(curr_delta as i32); + } else { + // * bpm change is into slider, this is easy acc window + if curr_obj.base.is_slider() { + effective_ratio *= 0.125; + } + + // * bpm change was from a slider, this is easier typically than circle -> circle + // * unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders + if prev_obj.base.is_slider() { + effective_ratio *= 0.3; + } + + // * repeated island polarity (2 -> 4, 3 -> 5) + if island.is_similar_polarity(&prev_island) { + effective_ratio *= 0.5; + } + + // * previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + if last_delta > prev_delta + delta_difference_eps + && prev_delta > curr_delta + delta_difference_eps + { + effective_ratio *= 0.125; + } + + // * repeated island size (ex: triplet -> triplet) + // * TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation + if prev_island.delta_count == island.delta_count { + effective_ratio *= 0.5; + } + + if let Some(island_count) = island_counts + .iter_mut() + .find(|entry| entry.island == island) + .filter(|entry| !entry.island.is_default()) + { + // * only add island to island counts if they're going one after another + if prev_island == island { + island_count.count += 1; + } + + // * repeated island (ex: triplet -> triplet) + let power = logistic(f64::from(island.delta), 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; + + prev_island = island; + + // * we're slowing down, stop counting + if prev_delta + delta_difference_eps < curr_delta { + // * if we're speeding up, this stays true and we keep counting island size. + first_delta_switch = false; + } + + island = + RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps); } - } else { + } 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.125; + 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.25; + effective_ratio *= 0.6; } - // * repeated island size (ex: triplet -> triplet) - if prev_island_size == island_size { - effective_ratio *= 0.25; - } + start_ratio = effective_ratio; - // * repeated island polartiy (2 -> 4, 3 -> 5) - if prev_island_size % 2 == island_size % 2 { - effective_ratio *= 0.5; - } + island = RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps); + } - // * 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; - } + last_obj = prev_obj; + prev_obj = curr_obj; + } + } - 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; + // * produces multiplier that can be applied to strain. range [1, infinity) (not really though) + (4.0 + rhythm_complexity_sum * Self::RHYTHM_OVERALL_MULTIPLIER).sqrt() / 2.0 + } +} - start_ratio = effective_ratio; +fn logistic(x: f64, max_value: f64, multiplier: f64, offset: f64) -> f64 { + max_value / (1.0 + E.powf(offset - (multiplier * x))) +} - // * log the last island size. - prev_island_size = island_size; +#[derive(Copy, Clone)] +struct RhythmIsland { + delta_difference_eps: f64, + delta: i32, + delta_count: i32, +} - // * 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; - } +const MIN_DELTA_TIME: i32 = 25; - 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; - } +// Compile-time check in case `OsuDifficultyObject::MIN_DELTA_TIME` changes +// but we forget to update this value. +const _: [(); 0 - !{ MIN_DELTA_TIME - OsuDifficultyObject::MIN_DELTA_TIME as i32 == 0 } as usize] = + []; + +impl RhythmIsland { + const fn new(delta_difference_eps: f64) -> Self { + Self { + delta_difference_eps, + delta: 0, + delta_count: 0, } + } - // * 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 + 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; + } + + const fn is_similar_polarity(&self, other: &Self) -> bool { + // * TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple) + // * naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation + self.delta_count % 2 == other.delta_count % 2 + } + + fn is_default(&self) -> bool { + self.delta_difference_eps.abs() < f64::EPSILON + && self.delta == i32::MAX + && self.delta_count == 0 + } +} + +impl PartialEq for RhythmIsland { + fn eq(&self, other: &Self) -> bool { + f64::from((self.delta - other.delta).abs()) < self.delta_difference_eps + && self.delta_count == other.delta_count } } + +struct IslandCount { + island: RhythmIsland, + count: usize, +} diff --git a/src/osu/difficulty/skills/strain.rs b/src/osu/difficulty/skills/strain.rs index 48045494..111eb7c1 100644 --- a/src/osu/difficulty/skills/strain.rs +++ b/src/osu/difficulty/skills/strain.rs @@ -1,14 +1,24 @@ 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: StrainSkill::default(), + } + } +} + 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; @@ -21,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( @@ -30,12 +43,14 @@ impl OsuStrainSkill { reduced_section_count: usize, reduced_strain_baseline: f64, decay_weight: f64, - difficulty_multiplier: 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); @@ -52,10 +67,53 @@ impl OsuStrainSkill { weight *= decay_weight; } - difficulty * difficulty_multiplier + UsedOsuStrainSkills { + value: DifficultyValue(difficulty), + object_strains, + } + } + + pub fn difficulty_to_performance(difficulty: f64) -> f64 { + (5.0 * (difficulty / 0.0675).max(1.0) - 4.0).powf(3.0) / 100_000.0 } } 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 const 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/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/object.rs b/src/osu/object.rs index dd48526f..085e9cf0 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, }; @@ -8,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}; @@ -64,33 +69,58 @@ impl OsuObject { // 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(); + 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; + } + } + } - // 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; + pub fn reflect_horizontally(&mut self) { + fn reflect_x(x: &mut f32) { + *x = PLAYFIELD_BASE_SIZE.x - *x; + } + + reflect_x(&mut self.pos.x); - break; - } + 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; - 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 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 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; } } } @@ -120,15 +150,10 @@ 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 - .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 +180,7 @@ pub struct OsuSlider { pub end_time: f64, pub lazy_end_pos: Pos, pub lazy_travel_dist: f32, + pub lazy_travel_time: f64, pub nested_objects: Vec, } @@ -182,13 +208,13 @@ 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; + 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; @@ -235,21 +261,21 @@ 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, }, - SliderEventType::LastTick => NestedSliderObject { + 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::Tail => return None, + SliderEventType::Head | SliderEventType::LastTick => return None, }; Some(obj) @@ -260,9 +286,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; @@ -278,10 +303,44 @@ impl OsuSlider { end_time, lazy_end_pos, lazy_travel_dist: 0.0, + lazy_travel_time, 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() @@ -289,23 +348,29 @@ impl OsuSlider { .count() } - pub fn tail(&self) -> Option<&NestedSliderObject> { + /// Counts both ticks and repeats + pub fn tick_count(&self) -> usize { self.nested_objects .iter() - // 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)) + .filter(|nested| { + matches!( + nested.kind, + NestedSliderObjectKind::Tick | NestedSliderObjectKind::Repeat + ) + }) + .count() } - fn tail_mut(&mut self) -> Option<&mut NestedSliderObject> { + pub fn tail(&self) -> Option<&NestedSliderObject> { self.nested_objects - .iter_mut() + .iter() // 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)] pub struct NestedSliderObject { pub pos: Pos, pub start_time: f64, @@ -316,8 +381,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/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 95ec37b9..a5fe120c 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -13,7 +13,8 @@ use crate::{ use super::{ attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes}, - score_state::OsuScoreState, + difficulty::skills::{flashlight::Flashlight, strain::OsuStrainSkill}, + score_state::{OsuScoreOrigin, OsuScoreState}, Osu, }; @@ -27,6 +28,8 @@ pub struct OsuPerformance<'map> { pub(crate) difficulty: Difficulty, pub(crate) acc: Option, pub(crate) combo: Option, + pub(crate) large_tick_hits: Option, + pub(crate) slider_end_hits: Option, pub(crate) n300: Option, pub(crate) n100: Option, pub(crate) n50: Option, @@ -39,7 +42,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 @@ -151,6 +154,45 @@ impl<'map> OsuPerformance<'map> { self } + /// Whether the calculated attributes belong to an osu!lazer or osu!stable + /// score. + /// + /// Defaults to `true`. + /// + /// This affects internal accuracy calculation because lazer considers + /// slider heads for accuracy whereas stable does not. + pub fn lazer(mut self, lazer: bool) -> Self { + self.difficulty = self.difficulty.lazer(lazer); + + self + } + + /// Specify the amount of "large tick" hits. + /// + /// 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 + } + + /// 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); + + self + } + /// Specify the amount of 300s of a play. pub const fn n300(mut self, n300: u32) -> Self { self.n300 = Some(n300); @@ -278,6 +320,8 @@ impl<'map> OsuPerformance<'map> { pub const fn state(mut self, state: OsuScoreState) -> Self { let OsuScoreState { max_combo, + large_tick_hits, + slider_end_hits, n300, n100, n50, @@ -285,6 +329,8 @@ impl<'map> OsuPerformance<'map> { } = state; self.combo = Some(max_combo); + self.large_tick_hits = Some(large_tick_hits); + self.slider_end_hits = Some(slider_end_hits); self.n300 = Some(n300); self.n100 = Some(n100); self.n50 = Some(n50); @@ -327,8 +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 (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(6 * n_objects); + let target_total = acc * f64::from(300 * n_objects + max_slider_acc_value); match (self.n300, self.n100, self.n50) { (Some(_), Some(_), Some(_)) => { @@ -348,13 +453,25 @@ 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(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); for new100 in min_n100..=max_n100 { let new50 = n_remaining - new100; - let dist = (acc - accuracy(n300, new100, new50, misses)).abs(); + + let state = NoComboState { + n300, + n100: new100, + n50: new50, + misses, + large_tick_hits, + slider_end_hits, + }; + + let dist = (acc - state.accuracy(origin)).abs(); if dist < best_dist { best_dist = dist; @@ -369,13 +486,25 @@ 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(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); for new300 in min_n300..=max_n300 { let new50 = n_remaining - new300; - let curr_dist = (acc - accuracy(new300, n100, new50, misses)).abs(); + + let state = NoComboState { + n300: new300, + n100, + n50: new50, + misses, + large_tick_hits, + slider_end_hits, + }; + + let curr_dist = (acc - state.accuracy(origin)).abs(); if curr_dist < best_dist { best_dist = curr_dist; @@ -390,16 +519,26 @@ 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(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); for new300 in min_n300..=max_n300 { let new100 = n_remaining - new300; - let curr_dist = (acc - accuracy(new300, new100, n50, misses)).abs(); + + let state = NoComboState { + n300: new300, + n100: new100, + n50, + misses, + large_tick_hits, + slider_end_hits, + }; + + let curr_dist = (acc - state.accuracy(origin)).abs(); if curr_dist < best_dist { best_dist = curr_dist; @@ -411,18 +550,31 @@ 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(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(n_remaining + 5 * new300); + let raw_n100 = (target_total + - 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); for new100 in min_n100..=max_n100 { let new50 = n_remaining - new300 - new100; - let curr_dist = (acc - accuracy(new300, new100, new50, misses)).abs(); + + let state = NoComboState { + n300: new300, + n100: new100, + n50: new50, + misses, + large_tick_hits, + slider_end_hits, + }; + + let curr_dist = (acc - state.accuracy(origin)).abs(); if curr_dist < best_dist { best_dist = curr_dist; @@ -477,6 +629,8 @@ impl<'map> OsuPerformance<'map> { }); self.combo = Some(max_combo); + 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); @@ -484,6 +638,8 @@ impl<'map> OsuPerformance<'map> { OsuScoreState { max_combo, + large_tick_hits, + slider_end_hits, n300, n100, n50, @@ -500,14 +656,66 @@ 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 classic = mods.cl(); + let using_classic_slider_acc = mods.no_slider_head_acc(lazer); + + let mut effective_miss_count = f64::from(state.misses); + + 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 / 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 + 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 / f64::from(state.max_combo).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)); + effective_miss_count = effective_miss_count.min(f64::from(state.total_hits())); + + 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: self.difficulty.get_mods(), - acc: state.accuracy(), + mods, + acc, state, effective_miss_count, + using_classic_slider_acc, }; inner.calculate() @@ -519,6 +727,8 @@ impl<'map> OsuPerformance<'map> { difficulty: Difficulty::new(), acc: None, combo: None, + large_tick_hits: None, + slider_end_hits: None, n300: None, n100: None, n50: None, @@ -534,7 +744,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, @@ -542,6 +753,7 @@ struct OsuPerformanceInner<'mods> { acc: f64, state: OsuScoreState, effective_miss_count: f64, + using_classic_slider_acc: bool, } impl OsuPerformanceInner<'_> { @@ -573,8 +785,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) @@ -583,8 +795,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); } @@ -613,7 +824,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(); @@ -623,16 +834,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 { @@ -652,7 +860,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); } @@ -661,15 +869,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; } @@ -685,8 +905,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(); @@ -696,16 +915,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 { @@ -719,7 +935,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); @@ -744,7 +960,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( @@ -762,22 +978,29 @@ impl OsuPerformanceInner<'_> { // * 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; - let better_acc_percentage = if amount_hit_objects_with_acc > 0 { - let sub = self.state.total_hits() - amount_hit_objects_with_acc; + if !self.using_classic_slider_acc { + amount_hit_objects_with_acc += self.attrs.n_sliders; + } - // * 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 = @@ -792,7 +1015,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 +1031,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(); @@ -836,6 +1059,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(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 @@ -850,34 +1080,62 @@ 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)); +const 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)) +const fn n_slider_tick_miss(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> u32 { + attrs.n_slider_ticks - state.large_tick_hits } -fn accuracy(n300: u32, n100: u32, n50: u32, misses: u32) -> f64 { - if n300 + n100 + n50 + misses == 0 { - return 0.0; - } +struct NoComboState { + n300: u32, + n100: u32, + n50: u32, + misses: u32, + large_tick_hits: u32, + slider_end_hits: u32, +} - let numerator = 6 * n300 + 2 * n100 + n50; - let denominator = 6 * (n300 + n100 + n50 + misses); +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)] @@ -885,6 +1143,7 @@ mod test { use std::sync::OnceLock; use proptest::prelude::*; + use rosu_mods::{GameModIntermode, GameModsIntermode}; use crate::{ any::{DifficultyAttributes, PerformanceAttributes}, @@ -897,6 +1156,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() @@ -916,6 +1177,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 }) @@ -926,8 +1189,13 @@ 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, + classic: bool, acc: f64, + large_tick_hits: Option, + slider_end_hits: Option, n300: Option, n100: Option, n50: Option, @@ -936,8 +1204,41 @@ mod test { ) -> OsuScoreState { let misses = cmp::min(misses, N_OBJECTS); + 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, + }; + + 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, + large_tick_hits, ..Default::default() }; @@ -973,7 +1274,16 @@ mod test { None => n_remaining.saturating_sub(new300 + new100), }; - let curr_acc = accuracy(new300, new100, new50, misses); + let state = NoComboState { + n300: new300, + n100: new100, + n50: new50, + misses, + large_tick_hits, + slider_end_hits, + }; + + let curr_acc = state.accuracy(origin); let curr_dist = (acc - curr_acc).abs(); if curr_dist < best_dist { @@ -1017,10 +1327,14 @@ 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, + classic in prop::bool::ANY, + acc in 0.0_f64..=1.0, + 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), n_misses in prop::option::weighted(0.15, 0_u32..=N_OBJECTS + 10), best_case in prop::bool::ANY, ) { @@ -1035,8 +1349,23 @@ mod test { let mut state = OsuPerformance::from(attrs) .accuracy(acc * 100.0) + .lazer(lazer) .hitresult_priority(priority); + 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) = slider_end_hits { + state = state.n_slider_ends(n_slider_ends); + } + if let Some(n300) = n300 { state = state.n300(n300); } @@ -1058,7 +1387,11 @@ mod test { assert_eq!(first, state); let mut expected = brute_force_best( + lazer, + classic, acc, + large_tick_hits, + slider_end_hits, n300, n100, n50, @@ -1075,6 +1408,7 @@ mod test { fn hitresults_n300_n100_misses_best() { let state = OsuPerformance::from(attrs()) .combo(500) + .lazer(true) .n300(300) .n100(20) .misses(2) @@ -1083,6 +1417,8 @@ mod test { let expected = OsuScoreState { max_combo: 500, + large_tick_hits: N_SLIDER_TICKS, + slider_end_hits: N_SLIDERS, n300: 300, n100: 20, n50: 279, @@ -1095,6 +1431,7 @@ mod test { #[test] fn hitresults_n300_n50_misses_best() { let state = OsuPerformance::from(attrs()) + .lazer(false) .combo(500) .n300(300) .n50(10) @@ -1104,6 +1441,8 @@ mod test { let expected = OsuScoreState { max_combo: 500, + large_tick_hits: 0, + slider_end_hits: 0, n300: 300, n100: 289, n50: 10, @@ -1116,6 +1455,7 @@ mod test { #[test] fn hitresults_n50_misses_worst() { let state = OsuPerformance::from(attrs()) + .lazer(true) .combo(500) .n50(10) .misses(2) @@ -1124,6 +1464,8 @@ mod test { let expected = OsuScoreState { max_combo: 500, + large_tick_hits: N_SLIDER_TICKS, + slider_end_hits: N_SLIDERS, n300: 0, n100: 589, n50: 10, @@ -1136,6 +1478,7 @@ mod test { #[test] fn hitresults_n300_n100_n50_misses_worst() { let state = OsuPerformance::from(attrs()) + .lazer(false) .combo(500) .n300(300) .n100(50) @@ -1146,6 +1489,8 @@ mod test { let expected = OsuScoreState { max_combo: 500, + large_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..be1ceac2 100644 --- a/src/osu/score_state.rs +++ b/src/osu/score_state.rs @@ -1,9 +1,22 @@ /// 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, + /// "Large tick" hits. + /// + /// 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. + pub slider_end_hits: u32, /// Amount of current 300s. pub n300: u32, /// Amount of current 100s. @@ -19,6 +32,8 @@ impl OsuScoreState { pub const fn new() -> Self { Self { max_combo: 0, + large_tick_hits: 0, + slider_end_hits: 0, n300: 0, n100: 0, n50: 0, @@ -32,17 +47,39 @@ 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(); + 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); - if total_hits == 0 { - return 0.0; - } + 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); - let numerator = 6 * self.n300 + 2 * self.n100 + self.n50; - let denominator = 6 * total_hits; + 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) + } } } @@ -51,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/attributes.rs b/src/taiko/attributes.rs index df089f39..fe1b4be5 100644 --- a/src/taiko/attributes.rs +++ b/src/taiko/attributes.rs @@ -12,7 +12,12 @@ 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 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. @@ -55,6 +60,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 f690c2f7..83a19ef9 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::{ @@ -12,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; @@ -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,34 @@ 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(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; + 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,17 +168,15 @@ 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, } 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, - bufs: CurveBuffers::default(), start_time, duration: 0, tick_spacing: 0.0, 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..2dd54789 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, @@ -9,7 +10,7 @@ use crate::{ use super::{ object::{TaikoDifficultyObject, TaikoDifficultyObjects}, - skills::peaks::{Peaks, PeaksSkill}, + skills::TaikoSkills, DifficultyValues, TaikoDifficultyAttributes, }; @@ -53,7 +54,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, } @@ -82,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; @@ -96,10 +100,11 @@ impl TaikoGradualDifficulty { &mut n_diff_objects, ); - let peaks = Peaks::new(); + 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() }; @@ -117,7 +122,7 @@ impl TaikoGradualDifficulty { difficulty, diff_objects, diff_objects_iter, - peaks, + skills, attrs, total_hits, first_combos, @@ -144,7 +149,12 @@ 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); + 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; @@ -166,14 +176,9 @@ 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 mut attrs = self.attrs.clone(); - DifficultyValues::eval(&mut attrs, color, rhythm, stamina, combined); + DifficultyValues::eval(&mut attrs, self.skills.clone()); Some(attrs) } @@ -225,13 +230,20 @@ 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); + let mut single_color_stamina = + Skill::new(&mut self.skills.single_color_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); + 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 d9a97759..26299a5e 100644 --- a/src/taiko/difficulty/mod.rs +++ b/src/taiko/difficulty/mod.rs @@ -1,16 +1,19 @@ +use std::cmp; + use crate::{ + any::difficulty::skills::Skill, + model::beatmap::HitWindows, 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,43 +23,87 @@ 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, 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 { peaks, max_combo } = DifficultyValues::calculate(difficulty, converted); + 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() }; - 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(); - - DifficultyValues::eval( - &mut attrs, - color_rating, - rhythm_rating, - stamina_rating, - combined_rating, - ); + DifficultyValues::eval(&mut attrs, skills); 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 +113,7 @@ fn rescale(stars: f64) -> f64 { } pub struct DifficultyValues { - pub peaks: Peaks, + pub skills: TaikoSkills, pub max_combo: u32, } @@ -89,42 +136,48 @@ 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); + 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) { - peaks.process(&hit_object.get()); + rhythm.process(&hit_object.get()); + color.process(&hit_object.get()); + stamina.process(&hit_object.get()); + single_color_stamina.process(&hit_object.get()); } } - Self { peaks, max_combo } + Self { skills, max_combo } } - pub fn eval( - attrs: &mut TaikoDifficultyAttributes, - color_difficulty_value: f64, - rhythm_difficulty_value: f64, - stamina_difficulty_value: f64, - peaks_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; + 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 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 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. + // * 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. + // * 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; + star_rating *= 0.80; } } @@ -132,6 +185,7 @@ impl DifficultyValues { 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/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..b6bdf906 100644 --- a/src/taiko/difficulty/skills/mod.rs +++ b/src/taiko/difficulty/skills/mod.rs @@ -1,4 +1,24 @@ +use self::{color::Color, rhythm::Rhythm, stamina::Stamina}; + pub mod color; -pub mod peaks; pub mod rhythm; pub mod stamina; + +#[derive(Clone)] +pub struct TaikoSkills { + pub rhythm: Rhythm, + pub color: Color, + pub stamina: Stamina, + pub single_color_stamina: Stamina, +} + +impl TaikoSkills { + pub fn new() -> Self { + Self { + rhythm: Rhythm::default(), + color: Color::default(), + stamina: Stamina::new(false), + single_color_stamina: Stamina::new(true), + } + } +} 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..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() + } } } @@ -100,28 +131,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..39a76bcd 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, }; @@ -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 @@ -364,6 +364,8 @@ impl<'map> TryFrom> for TaikoPerformance<'map> { difficulty, acc, combo, + large_tick_hits: _, + slider_end_hits: _, n300, n100, n50: _, @@ -402,6 +404,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 { @@ -410,16 +416,17 @@ 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 = 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; @@ -429,10 +436,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; @@ -443,7 +459,7 @@ 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() { @@ -451,39 +467,104 @@ impl TaikoPerformanceInner<'_> { } if self.mods.hr() { - diff_value *= 1.05; + diff_value *= 1.10; } 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); } - let acc = self.custom_accuracy(); + // * 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 * acc.powf(2.0) + diff_value + * (special_functions::erf( + acc_scaling_shift / (2.0_f64.sqrt() * estimated_unstable_rate), + )) + .powf(acc_scaling_exp) } - 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() { - acc_value *= (1.075 * len_bonus).max(1.05); + // * 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.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(); + + #[allow(clippy::items_after_statements, clippy::unreadable_literal)] + // * 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 } @@ -491,19 +572,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/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/mod.rs b/src/util/mod.rs index 4ec48e96..1ff0fd8a 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -4,5 +4,18 @@ 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; + +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 +} 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); } } } diff --git a/src/util/special_functions.rs b/src/util/special_functions.rs new file mode 100644 index 00000000..1e5754f2 --- /dev/null +++ b/src/util/special_functions.rs @@ -0,0 +1,285 @@ +#![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 ]; + 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 ]; +} + +#[allow(clippy::wildcard_imports)] +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/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>); diff --git a/tests/difficulty.rs b/tests/difficulty.rs index 6c607b09..00308040 100644 --- a/tests/difficulty.rs +++ b/tests/difficulty.rs @@ -35,11 +35,14 @@ 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, 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, @@ -50,11 +53,14 @@ 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, n_circles: $n_circles, n_sliders: $n_sliders, + n_slider_ticks: $n_slider_ticks, n_spinners: $n_spinners, stars: $stars, max_combo: $max_combo, @@ -65,7 +71,9 @@ macro_rules! test_cases { rhythm: $rhythm:literal, color: $color:literal, peak: $peak:literal, - hit_window: $hit_window: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, max_combo: $max_combo:literal, is_convert: $is_convert:literal, @@ -75,7 +83,9 @@ macro_rules! test_cases { rhythm: $rhythm, color: $color, peak: $peak, - hit_window: $hit_window, + mono_stamina_factor: $mono_stamina_factor, + great_hit_window: $great_hit_window, + ok_hit_window: $ok_hit_window, stars: $stars, max_combo: $max_combo, is_convert: $is_convert, @@ -102,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, }) => { @@ -109,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, } @@ -121,93 +133,111 @@ fn basic_osu() { test_cases! { Osu: OSU { NM => { - aim: 2.8693628443424104, - speed: 2.533869745015772, - flashlight: 2.288770487900865, - slider_factor: 0.9803052946037858, - speed_note_count: 210.36373973116545, + 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_slider_ticks: 15, 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: 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_slider_ticks: 15, 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: 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.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: 4.058080039906945, + speed: 3.570932204630734, + flashlight: 3.318209122186825, + 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_slider_ticks: 15, 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: 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_slider_ticks: 15, 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: 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_slider_ticks: 15, n_spinners: 1, - stars: 7.17258038247615, + stars: 7.167932950561898, max_combo: 909, }; } @@ -216,93 +246,111 @@ fn basic_osu() { test_cases! { Osu: OSU { NM => { - aim: 2.8693628443424104, - speed: 2.533869745015772, - flashlight: 2.288770487900865, - slider_factor: 0.9803052946037858, - speed_note_count: 210.36373973116545, + 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.669858729379631, + stars: 5.6436199897393005, max_combo: 909, }; HD => { - aim: 2.8693628443424104, - speed: 2.533869745015772, - flashlight: 2.606877929965889, - slider_factor: 0.9803052946037858, - speed_note_count: 210.36373973116545, + 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.669858729379631, + stars: 5.6436199897393005, max_combo: 909, }; HR => { - aim: 3.2385394176190507, - speed: 2.7009854505234308, - flashlight: 2.8549217213059936, - slider_factor: 0.9690667605258665, - speed_note_count: 184.01205359079387, + 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.263576582906263, + stars: 6.2433012533379415, max_combo: 909, }; DT => { - aim: 4.041442573946681, - speed: 3.6784866216272474, - flashlight: 3.319522943625448, - slider_factor: 0.9776943279272041, - speed_note_count: 214.80421464205617, + 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.085307648397626, + 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: 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.866778075388425, + 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: 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.172580382476152, + stars: 7.167932950561899, max_combo: 909, }; } @@ -314,32 +362,38 @@ fn basic_taiko() { test_cases! { Taiko: TAIKO { NM => { - stamina: 1.4528845068865617, + stamina: 1.3991746883284406, rhythm: 0.20130047251681948, color: 1.0487315549761433, - peak: 1.8881824429738323, - hit_window: 35.0, - stars: 2.9778030386845606, + peak: 1.8422453377400778, + mono_stamina_factor: 2.66403971858592e-07, + great_hit_window: 35.0, + 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, - hit_window: 29.0, - stars: 2.9778030386845606, + peak: 1.8422453377400778, + mono_stamina_factor: 2.66403971858592e-07, + great_hit_window: 29.0, + 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, - hit_window: 23.333333333333332, - stars: 3.9605501866340607, + 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, max_combo: 289, is_convert: false, }; @@ -352,32 +406,38 @@ fn convert_taiko() { test_cases! { Taiko: OSU { NM => { - stamina: 2.947896529153566, + stamina: 2.9127139214411444, rhythm: 1.4696991260446617, - color: 2.3032281729649067, - peak: 4.130240422926277, - hit_window: 23.59999942779541, - stars: 5.247857660585606, + color: 2.303228172964907, + peak: 4.117779264387738, + mono_stamina_factor: 0.0016957378202796742, + great_hit_window: 23.59999942779541, + ok_hit_window: 57.19999885559082, + stars: 5.235637844901627, max_combo: 908, is_convert: true, }; HR => { - stamina: 2.947896529153566, + stamina: 2.9127139214411444, rhythm: 1.4696991260446617, - color: 2.3032281729649067, - peak: 4.130240422926277, - hit_window: 20.0, - stars: 5.247857660585606, + color: 2.303228172964907, + peak: 4.117779264387738, + mono_stamina_factor: 0.0016957378202796742, + great_hit_window: 20.0, + ok_hit_window: 50.0 + stars: 5.235637844901627, max_combo: 908, is_convert: true, }; DT => { - stamina: 4.412382708370478, + stamina: 4.379782453136822, rhythm: 2.002843919169095, color: 3.1864894777399986, - peak: 6.107962386775966, - hit_window: 15.733332951863607, - stars: 7.0140481946324815, + peak: 6.103209631166694, + mono_stamina_factor: 0.0017075184344987763, + great_hit_window: 15.733332951863607, + ok_hit_window: 38.13333257039388, + stars: 7.010168846394131, max_combo: 908, is_convert: true, }; @@ -390,35 +450,35 @@ fn basic_catch() { test_cases! { Catch: CATCH { NM => { - stars: 3.2502663133739844, + stars: 3.250266313373984, ar: 8.0, n_fruits: 728, n_droplets: 2, - n_tiny_droplets: 291, + n_tiny_droplets: 263, is_convert: false, }; HR => { - stars: 4.3148698646484265, + stars: 4.313360856186517, ar: 10.0, n_fruits: 728, n_droplets: 2, - n_tiny_droplets: 291, + n_tiny_droplets: 263, is_convert: false, }; EZ => { - stars: 4.021637906259923, + stars: 4.06522224010957, ar: 4.0, n_fruits: 728, n_droplets: 2, - n_tiny_droplets: 291, + n_tiny_droplets: 263, is_convert: false, }; DT => { - stars: 4.635262826575387, + stars: 4.635262826575386, ar: 9.666666666666668, n_fruits: 728, n_droplets: 2, - n_tiny_droplets: 291, + n_tiny_droplets: 263, is_convert: false, }; } @@ -430,15 +490,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, @@ -446,7 +506,7 @@ fn convert_catch() { is_convert: true, }; EZ => { - stars: 3.598951063172104, + stars: 3.593264064535228, ar: 4.650000095367432, n_fruits: 908, n_droplets: 0, @@ -454,7 +514,7 @@ fn convert_catch() { is_convert: true, }; DT => { - stars: 6.136837738350475, + stars: 6.15540143757313, ar: 10.53333346048991, n_fruits: 908, n_droplets: 0, @@ -470,16 +530,18 @@ fn basic_mania() { test_cases! { Mania: MANIA { NM => { - stars: 3.441830819988125, + stars: 3.358304846842773, hit_window: 40.0, n_objects: 594, + n_hold_notes: 121, max_combo: 956, is_convert: false, }; DT => { - stars: 4.70051326060948, + stars: 4.6072892053157295, hit_window: 40.0, n_objects: 594, + n_hold_notes: 121, max_combo: 956, is_convert: false, }; @@ -495,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, }; @@ -502,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, }; @@ -543,7 +607,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); diff --git a/tests/performance.rs b/tests/performance.rs index da78d413..50404bc0 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,15 +31,15 @@ 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()), + OsuPerformance::from($map.as_owned()).lazer(true), OsuPerformanceAttributes { pp: $pp, pp_acc: $pp_acc, @@ -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()), @@ -99,62 +101,124 @@ macro_rules! test_cases { #[test] fn basic_osu() { + #[cfg(target_os = "windows")] 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 => { - 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 => { - 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, + }; + } + }; + #[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, }; } @@ -166,28 +230,32 @@ fn basic_taiko() { test_cases! { Taiko: TAIKO { NM => { - pp: 98.47602106219567, - pp_acc: 46.11642717726248, - pp_difficulty: 46.69844233558799, + pp: 114.68651694107942, + pp_acc: 67.10083752258917, + pp_difficulty: 40.6658183165898, effective_miss_count: 0.0, + estimated_unstable_rate: Some(148.44150180469418), }; HD => { - pp: 107.19493857245885, - pp_acc: 46.11642717726248, - pp_difficulty: 47.86590339397769, + pp: 124.41592086295445, + pp_acc: 67.10083752258917, + pp_difficulty: 41.68246377450454, effective_miss_count: 0.0, + estimated_unstable_rate: Some(148.44150180469418), }; HR => { - pp: 112.22705475791287, - pp_acc: 56.71431676265808, - pp_difficulty: 49.033364452367394, + pp: 138.3981102935321, + pp_acc: 82.52109686788792, + pp_difficulty: 47.44272798866182, 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.07140899937482, + pp_acc: 118.28107309573312, + pp_difficulty: 88.93091255724303, effective_miss_count: 0.0, + estimated_unstable_rate: Some(98.96100120312946), }; } }; @@ -195,31 +263,69 @@ fn basic_taiko() { #[test] fn convert_taiko() { + #[cfg(target_os = "windows")] + 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.45704044422996, + 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), + }; + } + } + #[cfg(target_os = "linux")] test_cases! { Taiko: OSU { NM => { - pp: 324.23564627433217, - pp_acc: 125.81086361861148, - pp_difficulty: 179.31471072573842, + pp: 353.6961706002712, + pp_acc: 155.09212159726567, + pp_difficulty: 178.19145253120928, effective_miss_count: 0.0, + estimated_unstable_rate: Some(85.75868894575865), }; HD => { - pp: 353.7513933816713, - pp_acc: 125.81086361861148, - pp_difficulty: 183.79757849388187, + pp: 358.45704044423 + pp_acc: 155.09212159726567, + pp_difficulty: 182.6462388444895, effective_miss_count: 0.0, + estimated_unstable_rate: Some(85.75868894575865), }; HR => { - pp: 360.12274556551137, - pp_acc: 150.9344373000759, - pp_difficulty: 188.28044626202535, + pp: 405.57235351353773, + pp_acc: 186.06296332183615, + pp_difficulty: 196.1813610529617, effective_miss_count: 0.0, + estimated_unstable_rate: Some(72.67685680089848), }; DT => { - pp: 604.8167434609272, - pp_acc: 220.7055264311451, - pp_difficulty: 347.90986552791844, + pp: 658.0214875413873 + pp_acc: 272.26616492989393, + pp_difficulty: 347.4712042359611, effective_miss_count: 0.0, + estimated_unstable_rate: Some(57.17245929717244), }; } } @@ -229,10 +335,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 +347,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 +359,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.92297471705167, pp_difficulty: 108.92297471705167 }; + EZ => { pp: 54.46148735852584, pp_difficulty: 108.92297471705167 }; + DT => { pp: 224.52717042937203, pp_difficulty: 224.52717042937203 }; } }; } @@ -264,9 +370,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: 101.39189449271568, pp_difficulty: 101.39189449271568 }; + EZ => { pp: 50.69594724635784, pp_difficulty: 101.39189449271568 }; + DT => { pp: 198.46891237015896, pp_difficulty: 198.46891237015896 }; } }; }