Merge pull request #43 from MaxOhn/pp-update

feat: port pp update
This commit is contained in:
Badewanne3
2024-11-18 12:49:08 +01:00
committed by GitHub
67 changed files with 2694 additions and 1078 deletions
+2 -2
View File
@@ -17,8 +17,8 @@ sync = []
tracing = ["rosu-map/tracing"] tracing = ["rosu-map/tracing"]
[dependencies] [dependencies]
rosu-map = { version = "0.1.1" } rosu-map = { version = "0.2.0" }
rosu-mods = { version = "0.1.0" } rosu-mods = { version = "0.2.0" }
[dev-dependencies] [dev-dependencies]
proptest = "1.4.0" proptest = "1.4.0"
+3 -7
View File
@@ -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). while also providing a significant [boost in performance](#speed).
Last commits of the ported code: Last commits of the ported code:
- [osu!lazer] : `7342fb7f51b34533a42bffda89c3d6c569cc69ce` (2022-10-11) - [osu!lazer] : `8bd65d9938a10fc42e6409501b0282f0fa4a25ef` (2024-11-08)
- [osu!tools] : `146d5916937161ef65906aa97f85d367035f3712` (2022-10-08) - [osu!tools] : `89b8f3b1c2e4e5674004eac4723120e7d3aef997` (2024-11-03)
News posts of the latest gamemode updates: News posts of the latest updates: <https://osu.ppy.sh/home/news/2024-10-28-performance-points-star-rating-updates>
- osu: <https://osu.ppy.sh/home/news/2022-09-30-changes-to-osu-sr-and-pp>
- taiko: <https://osu.ppy.sh/home/news/2022-09-28-changes-to-osu-taiko-sr-and-pp>
- catch: <https://osu.ppy.sh/home/news/2020-05-14-osucatch-scoring-updates>
- mania: <https://osu.ppy.sh/home/news/2022-10-09-changes-to-osu-mania-sr-and-pp>
### Usage ### Usage
@@ -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 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 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 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
+10
View File
@@ -27,6 +27,11 @@ pub struct InspectDifficulty {
/// ///
/// Only relevant for osu!catch. /// Only relevant for osu!catch.
pub hardrock_offsets: Option<bool>, pub hardrock_offsets: Option<bool>,
/// Whether the calculated attributes belong to an osu!lazer or osu!stable
/// score.
///
/// Defaults to `true`.
pub lazer: Option<bool>,
} }
impl InspectDifficulty { impl InspectDifficulty {
@@ -41,6 +46,7 @@ impl InspectDifficulty {
hp, hp,
od, od,
hardrock_offsets, hardrock_offsets,
lazer,
} = self; } = self;
let mut difficulty = Difficulty::new().mods(mods); let mut difficulty = Difficulty::new().mods(mods);
@@ -73,6 +79,10 @@ impl InspectDifficulty {
difficulty = difficulty.hardrock_offsets(hardrock_offsets); difficulty = difficulty.hardrock_offsets(hardrock_offsets);
} }
if let Some(lazer) = lazer {
difficulty = difficulty.lazer(lazer);
}
difficulty difficulty
} }
} }
+31 -16
View File
@@ -1,7 +1,7 @@
use std::{ use std::{
borrow::Cow, borrow::Cow,
fmt::{Debug, Formatter, Result as FmtResult}, fmt::{Debug, Formatter, Result as FmtResult},
num::NonZeroU32, num::NonZeroU64,
}; };
use rosu_map::section::general::GameMode; 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. /// Clock rate will be clamped internally between 0.01 and 100.0.
/// ///
/// Since its minimum value is 0.01, its bits are never zero. /// 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 /// This allows for an optimization to reduce the struct size by storing its
/// bits as a [`NonZeroU32`]. /// bits as a [`NonZeroU64`].
clock_rate: Option<NonZeroU32>, clock_rate: Option<NonZeroU64>,
ar: Option<ModsDependent>, ar: Option<ModsDependent>,
cs: Option<ModsDependent>, cs: Option<ModsDependent>,
hp: Option<ModsDependent>, hp: Option<ModsDependent>,
od: Option<ModsDependent>, od: Option<ModsDependent>,
hardrock_offsets: Option<bool>, hardrock_offsets: Option<bool>,
lazer: Option<bool>,
} }
/// Wrapper for beatmap attributes in [`Difficulty`]. /// Wrapper for beatmap attributes in [`Difficulty`].
@@ -97,6 +96,7 @@ impl Difficulty {
hp: None, hp: None,
od: None, od: None,
hardrock_offsets: None, hardrock_offsets: None,
lazer: None,
} }
} }
@@ -120,17 +120,19 @@ impl Difficulty {
hp, hp,
od, od,
hardrock_offsets, hardrock_offsets,
lazer,
} = self; } = self;
InspectDifficulty { InspectDifficulty {
mods, mods,
passed_objects, 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, ar,
cs, cs,
hp, hp,
od, od,
hardrock_offsets, hardrock_offsets,
lazer,
} }
} }
@@ -167,11 +169,11 @@ impl Difficulty {
/// | :-----: | :-----: | /// | :-----: | :-----: |
/// | 0.01 | 100 | /// | 0.01 | 100 |
pub fn clock_rate(self, clock_rate: f64) -> Self { 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 // SAFETY: The minimum value is 0.01 so its bits can never be fully
// zero. // zero.
let non_zero = unsafe { NonZeroU32::new_unchecked(clock_rate) }; let non_zero = unsafe { NonZeroU64::new_unchecked(clock_rate) };
Self { Self {
clock_rate: Some(non_zero), clock_rate: Some(non_zero),
@@ -268,6 +270,16 @@ impl Difficulty {
self 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. /// Perform the difficulty calculation.
pub fn calculate(&self, map: &Beatmap) -> DifficultyAttributes { pub fn calculate(&self, map: &Beatmap) -> DifficultyAttributes {
let map = Cow::Borrowed(map); let map = Cow::Borrowed(map);
@@ -316,11 +328,8 @@ impl Difficulty {
} }
pub(crate) fn get_clock_rate(&self) -> f64 { pub(crate) fn get_clock_rate(&self) -> f64 {
let clock_rate = self self.clock_rate
.clock_rate .map_or(self.mods.clock_rate(), non_zero_u64_to_f64)
.map_or(self.mods.clock_rate(), non_zero_u32_to_f32);
f64::from(clock_rate)
} }
pub(crate) fn get_passed_objects(&self) -> usize { pub(crate) fn get_passed_objects(&self) -> usize {
@@ -347,10 +356,14 @@ impl Difficulty {
self.hardrock_offsets self.hardrock_offsets
.unwrap_or_else(|| self.mods.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 { fn non_zero_u64_to_f64(n: NonZeroU64) -> f64 {
f32::from_bits(n.get()) f64::from_bits(n.get())
} }
impl Debug for Difficulty { impl Debug for Difficulty {
@@ -364,17 +377,19 @@ impl Debug for Difficulty {
hp, hp,
od, od,
hardrock_offsets, hardrock_offsets,
lazer,
} = self; } = self;
f.debug_struct("Difficulty") f.debug_struct("Difficulty")
.field("mods", mods) .field("mods", mods)
.field("passed_objects", passed_objects) .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("ar", ar)
.field("cs", cs) .field("cs", cs)
.field("hp", hp) .field("hp", hp)
.field("od", od) .field("od", od)
.field("hardrock_offsets", hardrock_offsets) .field("hardrock_offsets", hardrock_offsets)
.field("lazer", lazer)
.finish() .finish()
} }
} }
+49
View File
@@ -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. /// Specify the amount of 300s of a play.
pub fn n300(self, n300: u32) -> Self { pub fn n300(self, n300: u32) -> Self {
match self { match self {
+25
View File
@@ -15,6 +15,19 @@ pub struct ScoreState {
/// ///
/// Irrelevant for osu!mania. /// Irrelevant for osu!mania.
pub max_combo: u32, 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). /// Amount of current gekis (n320 for osu!mania).
pub n_geki: u32, pub n_geki: u32,
/// Amount of current katus (tiny droplet misses for osu!catch / n200 for /// Amount of current katus (tiny droplet misses for osu!catch / n200 for
@@ -35,6 +48,8 @@ impl ScoreState {
pub const fn new() -> Self { pub const fn new() -> Self {
Self { Self {
max_combo: 0, max_combo: 0,
osu_large_tick_hits: 0,
slider_end_hits: 0,
n_geki: 0, n_geki: 0,
n_katu: 0, n_katu: 0,
n300: 0, n300: 0,
@@ -66,6 +81,8 @@ impl From<ScoreState> for OsuScoreState {
fn from(state: ScoreState) -> Self { fn from(state: ScoreState) -> Self {
Self { Self {
max_combo: state.max_combo, max_combo: state.max_combo,
large_tick_hits: state.osu_large_tick_hits,
slider_end_hits: state.slider_end_hits,
n300: state.n300, n300: state.n300,
n100: state.n100, n100: state.n100,
n50: state.n50, n50: state.n50,
@@ -115,6 +132,8 @@ impl From<OsuScoreState> for ScoreState {
fn from(state: OsuScoreState) -> Self { fn from(state: OsuScoreState) -> Self {
Self { Self {
max_combo: state.max_combo, max_combo: state.max_combo,
osu_large_tick_hits: state.large_tick_hits,
slider_end_hits: state.slider_end_hits,
n_geki: 0, n_geki: 0,
n_katu: 0, n_katu: 0,
n300: state.n300, n300: state.n300,
@@ -129,6 +148,8 @@ impl From<TaikoScoreState> for ScoreState {
fn from(state: TaikoScoreState) -> Self { fn from(state: TaikoScoreState) -> Self {
Self { Self {
max_combo: state.max_combo, max_combo: state.max_combo,
osu_large_tick_hits: 0,
slider_end_hits: 0,
n_geki: 0, n_geki: 0,
n_katu: 0, n_katu: 0,
n300: state.n300, n300: state.n300,
@@ -143,6 +164,8 @@ impl From<CatchScoreState> for ScoreState {
fn from(state: CatchScoreState) -> Self { fn from(state: CatchScoreState) -> Self {
Self { Self {
max_combo: state.max_combo, max_combo: state.max_combo,
osu_large_tick_hits: 0,
slider_end_hits: 0,
n_geki: 0, n_geki: 0,
n_katu: state.tiny_droplet_misses, n_katu: state.tiny_droplet_misses,
n300: state.fruits, n300: state.fruits,
@@ -157,6 +180,8 @@ impl From<ManiaScoreState> for ScoreState {
fn from(state: ManiaScoreState) -> Self { fn from(state: ManiaScoreState) -> Self {
Self { Self {
max_combo: 0, max_combo: 0,
osu_large_tick_hits: 0,
slider_end_hits: 0,
n_geki: state.n320, n_geki: state.n320,
n_katu: state.n200, n_katu: state.n200,
n300: state.n300, n300: state.n300,
+3 -1
View File
@@ -15,6 +15,8 @@ impl Catcher {
} }
fn calculate_scale(cs: f32) -> f32 { 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
} }
} }
+22 -18
View File
@@ -5,8 +5,9 @@ use crate::{
beatmap::{Beatmap, Converted}, beatmap::{Beatmap, Converted},
hit_object::{HitObject, HitObjectKind, HoldNote, Spinner}, hit_object::{HitObject, HitObjectKind, HoldNote, Spinner},
mode::ConvertStatus, mode::ConvertStatus,
mods::Reflection,
}, },
util::{float_ext::FloatExt, random::Random, sort::TandemSorter}, util::{float_ext::FloatExt, random::Random},
}; };
use super::{ use super::{
@@ -50,6 +51,7 @@ pub fn try_convert(map: &mut Beatmap) -> ConvertStatus {
pub fn convert_objects( pub fn convert_objects(
converted: &CatchBeatmap<'_>, converted: &CatchBeatmap<'_>,
count: &mut ObjectCountBuilder, count: &mut ObjectCountBuilder,
reflection: Reflection,
hr_offsets: bool, hr_offsets: bool,
cs: f32, cs: f32,
) -> Vec<PalpableObject> { ) -> Vec<PalpableObject> {
@@ -82,20 +84,15 @@ pub fn convert_objects(
palpable_objects.extend(new_objects); palpable_objects.extend(new_objects);
} }
// Initializing hyper dashes requires objects to be sorted by C#'s unstable if let Reflection::Horizontal = reflection {
// sort. After that, we unsort the objects again and then apply a stable for h in palpable_objects.iter_mut() {
// sort to have the correct order for generating difficulty objects. h.x = PLAYFIELD_WIDTH - h.x;
// Required e.g. due to map /b/102923. h.x_offset = -h.x_offset;
let mut sorter = TandemSorter::new_unstable(&palpable_objects, |a, b| { }
a.start_time.total_cmp(&b.start_time) }
});
sorter.sort(&mut palpable_objects);
initialize_hyper_dash(cs, &mut palpable_objects);
sorter.unsort(&mut palpable_objects);
palpable_objects.sort_by(|a, b| a.start_time.total_cmp(&b.start_time)); palpable_objects.sort_by(|a, b| a.start_time.total_cmp(&b.start_time));
initialize_hyper_dash(cs, &mut palpable_objects);
palpable_objects palpable_objects
} }
@@ -109,14 +106,15 @@ fn convert_object<'a>(
let state = match h.kind { let state = match h.kind {
HitObjectKind::Circle => ObjectIterState::Fruit(Some(Fruit::new(count))), HitObjectKind::Circle => ObjectIterState::Fruit(Some(Fruit::new(count))),
HitObjectKind::Slider(ref slider) => { HitObjectKind::Slider(ref slider) => {
let x = JuiceStream::clamp_to_playfield(h.pos.x); let effective_x = h.pos.x.clamp(0.0, PLAYFIELD_WIDTH);
let stream = JuiceStream::new(x, h.start_time, slider, converted, count, bufs); let stream =
JuiceStream::new(effective_x, h.start_time, slider, converted, count, bufs);
ObjectIterState::JuiceStream(stream) ObjectIterState::JuiceStream(stream)
} }
HitObjectKind::Spinner(Spinner { duration }) HitObjectKind::Spinner(Spinner { duration })
| HitObjectKind::Hold(HoldNote { 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 mut offset_pos = x;
let Some(last_pos) = last_pos else { let last_pos = match last_pos {
Some(pos) if pos.abs() >= f32::EPSILON => pos,
Some(_) | None => {
*last_pos = Some(offset_pos); *last_pos = Some(offset_pos);
*last_start_time = start_time; *last_start_time = start_time;
return; return;
}
}; };
let pos_diff = offset_pos - *last_pos; let pos_diff = offset_pos - *last_pos;
@@ -310,7 +311,10 @@ fn initialize_hyper_dash(cs: f32, palpable_objects: &mut [PalpableObject]) {
-1 -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()) let dist_to_next = f64::from((next.effective_x() - curr.effective_x()).abs())
- if last_dir == this_dir { - if last_dir == this_dir {
+9 -2
View File
@@ -72,9 +72,16 @@ impl CatchGradualDifficulty {
CatchDifficultySetup::new(&difficulty, converted); CatchDifficultySetup::new(&difficulty, converted);
let hr_offsets = difficulty.get_hardrock_offsets(); let hr_offsets = difficulty.get_hardrock_offsets();
let reflection = difficulty.get_mods().reflection();
let mut count = ObjectCountBuilder::new_gradual(); 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( let diff_objects = DifficultyValues::create_difficulty_objects(
&map_attrs, &map_attrs,
+10 -4
View File
@@ -18,7 +18,7 @@ pub mod gradual;
mod object; mod object;
mod skills; mod skills;
const STAR_SCALING_FACTOR: f64 = 0.153; const DIFFICULTY_MULTIPLIER: f64 = 4.59;
pub fn difficulty( pub fn difficulty(
difficulty: &Difficulty, difficulty: &Difficulty,
@@ -69,10 +69,16 @@ impl DifficultyValues {
} = CatchDifficultySetup::new(difficulty, converted); } = CatchDifficultySetup::new(difficulty, converted);
let hr_offsets = difficulty.get_hardrock_offsets(); let hr_offsets = difficulty.get_hardrock_offsets();
let reflection = difficulty.get_mods().reflection();
let mut count = ObjectCountBuilder::new_regular(take); let mut count = ObjectCountBuilder::new_regular(take);
let palpable_objects = let palpable_objects = convert_objects(
convert_objects(converted, &mut count, hr_offsets, map_attrs.cs as f32); converted,
&mut count,
reflection,
hr_offsets,
map_attrs.cs as f32,
);
let diff_objects = Self::create_difficulty_objects( let diff_objects = Self::create_difficulty_objects(
&map_attrs, &map_attrs,
@@ -96,7 +102,7 @@ impl DifficultyValues {
} }
pub fn eval(attrs: &mut CatchDifficultyAttributes, movement_difficulty_value: f64) { 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>( pub fn create_difficulty_objects<'a>(
+1 -1
View File
@@ -11,7 +11,7 @@ const ABSOLUTE_PLAYER_POSITIONING_ERROR: f32 = 16.0;
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0; const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
const DIRECTION_CHANGE_BONUS: f64 = 21.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 STRAIN_DECAY_BASE: f64 = 0.2;
const DECAY_WEIGHT: f64 = 0.94; const DECAY_WEIGHT: f64 = 0.94;
+10 -7
View File
@@ -3,9 +3,11 @@ pub struct BananaShower {
} }
impl BananaShower { impl BananaShower {
pub fn new(start_time: f64, duration: f64) -> Self { pub fn new(start_time: f64, end_time: f64) -> Self {
let mut spacing = duration; // * Int truncation added to match osu!stable.
let end_time = start_time + duration; 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 { while spacing > 100.0 {
spacing /= 2.0; spacing /= 2.0;
@@ -14,15 +16,16 @@ impl BananaShower {
let n_bananas = if spacing <= 0.0 { let n_bananas = if spacing <= 0.0 {
0 0
} else { } else {
let mut time = start_time; let end_time = end_time as f32;
let mut i = 0; let mut time = start_time as f32;
let mut count = 0;
while time <= end_time { while time <= end_time {
time += spacing; time += spacing;
i += 1; count += 1;
} }
i count
}; };
Self { n_bananas } Self { n_bananas }
+21 -16
View File
@@ -1,15 +1,17 @@
use std::vec::Drain; use std::vec::Drain;
use rosu_map::section::hit_objects::{ use rosu_map::section::{
CurveBuffers, PathControlPoint, SliderEvent, SliderEventType, SliderEventsIter, general::GameMode,
hit_objects::{CurveBuffers, PathControlPoint, SliderEvent, SliderEventType, SliderEventsIter},
}; };
use crate::{ use crate::{
catch::{attributes::ObjectCountBuilder, convert::CatchBeatmap, PLAYFIELD_WIDTH}, catch::{attributes::ObjectCountBuilder, convert::CatchBeatmap},
model::{ model::{
control_point::{DifficultyPoint, TimingPoint}, control_point::{DifficultyPoint, TimingPoint},
hit_object::Slider, hit_object::Slider,
}, },
util::get_precision_adjusted_beat_len,
}; };
pub struct JuiceStream<'a> { pub struct JuiceStream<'a> {
@@ -21,7 +23,7 @@ impl<'a> JuiceStream<'a> {
pub const BASE_SCORING_DIST: f64 = 100.0; pub const BASE_SCORING_DIST: f64 = 100.0;
pub fn new( pub fn new(
x: f32, effective_x: f32,
start_time: f64, start_time: f64,
slider: &'a Slider, slider: &'a Slider,
converted: &CatchBeatmap<'_>, converted: &CatchBeatmap<'_>,
@@ -41,14 +43,19 @@ impl<'a> JuiceStream<'a> {
point.slider_velocity 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 = JuiceStream::BASE_SCORING_DIST * slider_multiplier
let velocity = velocity_factor * slider_velocity; / get_precision_adjusted_beat_len(slider_velocity, beat_len);
let tick_dist_factor = let scoring_dist = velocity * beat_len;
JuiceStream::BASE_SCORING_DIST * slider_multiplier / slider_tick_rate;
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 span_count = slider.span_count() as f64;
let duration = span_count * path.dist() / velocity; let duration = span_count * path.dist() / velocity;
@@ -69,7 +76,7 @@ impl<'a> JuiceStream<'a> {
for e in events { for e in events {
if let Some(last_event_time) = last_event_time { if let Some(last_event_time) = last_event_time {
let mut tiny_droplets = 0; 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 { if since_last_tick > 80.0 {
let mut time_between_tiny = since_last_tick; let mut time_between_tiny = since_last_tick;
@@ -115,7 +122,7 @@ impl<'a> JuiceStream<'a> {
}; };
let nested = NestedJuiceStreamObject { 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, start_time: e.time,
kind, kind,
}; };
@@ -128,18 +135,16 @@ impl<'a> JuiceStream<'a> {
nested_objects: bufs.nested_objects.drain(..), 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 struct NestedJuiceStreamObject {
pub pos: f32, pub pos: f32,
pub start_time: f64, pub start_time: f64,
pub kind: NestedJuiceStreamObjectKind, pub kind: NestedJuiceStreamObjectKind,
} }
#[derive(Debug)]
pub enum NestedJuiceStreamObjectKind { pub enum NestedJuiceStreamObjectKind {
Fruit, Fruit,
Droplet, Droplet,
+4 -2
View File
@@ -474,6 +474,8 @@ impl<'map> TryFrom<OsuPerformance<'map>> for CatchPerformance<'map> {
difficulty, difficulty,
acc, acc,
combo, combo,
large_tick_hits: _,
slider_end_hits: _,
n300, n300,
n100, n100,
n50, n50,
@@ -569,7 +571,7 @@ impl CatchPerformanceInner<'_> {
// NF penalty // NF penalty
if self.mods.nf() { if self.mods.nf() {
pp *= 0.9; pp *= (1.0 - 0.02 * f64::from(self.state.misses)).max(0.9);
} }
CatchPerformanceAttributes { CatchPerformanceAttributes {
@@ -615,7 +617,7 @@ mod test {
const N_FRUITS: u32 = 728; const N_FRUITS: u32 = 728;
const N_DROPLETS: u32 = 2; const N_DROPLETS: u32 = 2;
const N_TINY_DROPLETS: u32 = 291; const N_TINY_DROPLETS: u32 = 263;
fn beatmap() -> Beatmap { fn beatmap() -> Beatmap {
Beatmap::from_path("./resources/2118524.osu").unwrap() Beatmap::from_path("./resources/2118524.osu").unwrap()
+3 -7
View File
@@ -5,14 +5,10 @@
//! while also providing a significant [boost in performance](#speed). //! while also providing a significant [boost in performance](#speed).
//! //!
//! Last commits of the ported code: //! Last commits of the ported code:
//! - [osu!lazer] : `7342fb7f51b34533a42bffda89c3d6c569cc69ce` (2022-10-11) //! - [osu!lazer] : `8bd65d9938a10fc42e6409501b0282f0fa4a25ef` (2024-11-08)
//! - [osu!tools] : `146d5916937161ef65906aa97f85d367035f3712` (2022-10-08) //! - [osu!tools] : `89b8f3b1c2e4e5674004eac4723120e7d3aef997` (2024-11-03)
//! //!
//! News posts of the latest gamemode updates: //! News posts of the latest updates: <https://osu.ppy.sh/home/news/2024-10-28-performance-points-star-rating-updates>
//! - osu: <https://osu.ppy.sh/home/news/2022-09-30-changes-to-osu-sr-and-pp>
//! - taiko: <https://osu.ppy.sh/home/news/2022-09-28-changes-to-osu-taiko-sr-and-pp>
//! - catch: <https://osu.ppy.sh/home/news/2020-05-14-osucatch-scoring-updates>
//! - mania: <https://osu.ppy.sh/home/news/2022-10-09-changes-to-osu-mania-sr-and-pp>
//! //!
//! ## Usage //! ## Usage
//! //!
+2
View File
@@ -9,6 +9,8 @@ pub struct ManiaDifficultyAttributes {
pub hit_window: f64, pub hit_window: f64,
/// The amount of hitobjects in the map. /// The amount of hitobjects in the map.
pub n_objects: u32, pub n_objects: u32,
/// The amount of hold notes in the map.
pub n_hold_notes: u32,
/// The maximum achievable combo. /// The maximum achievable combo.
pub max_combo: u32, pub max_combo: u32,
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map. /// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
+20 -17
View File
@@ -1,7 +1,4 @@
use rosu_map::{ use rosu_map::{section::general::GameMode, util::Pos};
section::{general::GameMode, hit_objects::CurveBuffers},
util::Pos,
};
use crate::{ use crate::{
model::{ model::{
@@ -15,8 +12,8 @@ use crate::{
use self::{ use self::{
pattern::Pattern, pattern::Pattern,
pattern_generator::{ pattern_generator::{
distance_object::DistanceObjectPatternGenerator,
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator, end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
path_object::PathObjectPatternGenerator,
}, },
pattern_type::PatternType, pattern_type::PatternType,
}; };
@@ -76,7 +73,6 @@ fn convert(map: &mut Beatmap) {
let total_columns = map.cs as i32; let total_columns = map.cs as i32;
let mut last_values = PrevValues::default(); let mut last_values = PrevValues::default();
let mut curve_bufs = CurveBuffers::default();
// mean=668.7 | median=512 // mean=668.7 | median=512
let mut new_hit_objects = Vec::with_capacity(512); let mut new_hit_objects = Vec::with_capacity(512);
@@ -108,9 +104,7 @@ fn convert(map: &mut Beatmap) {
last_values.pattern = new_pattern; last_values.pattern = new_pattern;
} }
HitObjectKind::Slider(ref slider) => { HitObjectKind::Slider(ref slider) => {
let curve = slider.curve(&mut curve_bufs); let mut gen = PathObjectPatternGenerator::new(
let mut gen = DistanceObjectPatternGenerator::new(
&mut random, &mut random,
obj, obj,
sound, sound,
@@ -118,7 +112,7 @@ fn convert(map: &mut Beatmap) {
&last_values.pattern, &last_values.pattern,
map, map,
slider.repeats, slider.repeats,
&curve, slider.expected_dist,
&slider.node_sounds, &slider.node_sounds,
); );
@@ -195,23 +189,32 @@ fn target_columns(map: &Beatmap) -> f32 {
let rounded_cs = map.cs.round_ties_even(); let rounded_cs = map.cs.round_ties_even();
let rounded_od = map.od.round_ties_even(); let rounded_od = map.od.round_ties_even();
let slider_or_spinner_count = map if !map.hit_objects.is_empty() {
let count_slider_or_spinner = map
.hit_objects .hit_objects
.iter() .iter()
.filter(|h| matches!(h.kind, HitObjectKind::Slider(_) | HitObjectKind::Spinner(_))) .filter(|h| matches!(h.kind, HitObjectKind::Slider(_) | HitObjectKind::Spinner(_)))
.count(); .count();
let len = map.hit_objects.len(); let len = map.hit_objects.len();
let percent_slider_or_spinner = f64::from(slider_or_spinner_count as f32 / len as f32);
// * In osu!stable, this division appears as if it happens on floats, but due to release-mode
// * optimisations, it actually ends up happening on doubles.
let percent_slider_or_spinner = count_slider_or_spinner as f64 / len as f64;
if percent_slider_or_spinner < 0.2 { if percent_slider_or_spinner < 0.2 {
7.0 return 7.0;
} else if percent_slider_or_spinner < 0.3 || rounded_cs >= 5.0 { } else if percent_slider_or_spinner < 0.3 || rounded_cs >= 5.0 {
f32::from(6 + u8::from(rounded_od > 5.0)) return f32::from(6 + u8::from(rounded_od > 5.0));
} else if percent_slider_or_spinner > 0.6 { } else if percent_slider_or_spinner > 0.6 {
f32::from(4 + u8::from(rounded_od > 4.0)) return f32::from(4 + u8::from(rounded_od > 4.0));
} else { }
(rounded_od + 1.0).clamp(4.0, 7.0) }
// Keeping it in-sync with lazer
#[allow(clippy::manual_clamp)]
{
((rounded_od as i32) + 1).min(7).max(4) as f32
} }
} }
+3 -3
View File
@@ -5,8 +5,8 @@ use rosu_map::util::Pos;
use crate::model::hit_object::{HitObject, HitObjectKind, HoldNote}; use crate::model::hit_object::{HitObject, HitObjectKind, HoldNote};
use super::pattern_generator::{ use super::pattern_generator::{
distance_object::DistanceObjectPatternGenerator,
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator, end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
path_object::PathObjectPatternGenerator,
}; };
#[derive(Default)] #[derive(Default)]
@@ -87,7 +87,7 @@ impl Pattern {
} }
pub fn new_slider_note( pub fn new_slider_note(
generator: &DistanceObjectPatternGenerator<'_>, generator: &PathObjectPatternGenerator<'_>,
column: u8, column: u8,
start_time: i32, start_time: i32,
end_time: i32, end_time: i32,
@@ -118,7 +118,7 @@ impl Pattern {
pub fn add_slider_note( pub fn add_slider_note(
&mut self, &mut self,
generator: &DistanceObjectPatternGenerator<'_>, generator: &PathObjectPatternGenerator<'_>,
column: u8, column: u8,
start_time: i32, start_time: i32,
end_time: i32, end_time: i32,
+1 -1
View File
@@ -4,9 +4,9 @@ use crate::{
util::random::Random, util::random::Random,
}; };
pub(super) mod distance_object;
pub(super) mod end_time_object; pub(super) mod end_time_object;
pub(super) mod hit_object; pub(super) mod hit_object;
pub(super) mod path_object;
pub struct PatternGenerator<'a> { pub struct PatternGenerator<'a> {
pub hit_object: &'a HitObject, pub hit_object: &'a HitObject,
@@ -1,6 +1,6 @@
use std::cmp; use std::cmp;
use rosu_map::section::hit_objects::{hit_samples::HitSoundType, BorrowedCurve}; use rosu_map::section::hit_objects::hit_samples::HitSoundType;
use crate::{ use crate::{
mania::{ mania::{
@@ -12,12 +12,12 @@ use crate::{
control_point::{DifficultyPoint, EffectPoint, TimingPoint}, control_point::{DifficultyPoint, EffectPoint, TimingPoint},
hit_object::HitObject, hit_object::HitObject,
}, },
util::random::Random, util::{get_precision_adjusted_beat_len, random::Random},
}; };
use super::PatternGenerator; use super::PatternGenerator;
pub struct DistanceObjectPatternGenerator<'h> { pub struct PathObjectPatternGenerator<'h> {
pub segment_duration: i32, pub segment_duration: i32,
pub sample: HitSoundType, pub sample: HitSoundType,
pub inner: PatternGenerator<'h>, pub inner: PatternGenerator<'h>,
@@ -29,7 +29,7 @@ pub struct DistanceObjectPatternGenerator<'h> {
node_sounds: &'h [HitSoundType], node_sounds: &'h [HitSoundType],
} }
impl<'h> DistanceObjectPatternGenerator<'h> { impl<'h> PathObjectPatternGenerator<'h> {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
random: &'h mut Random, random: &'h mut Random,
@@ -39,17 +39,17 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
prev_pattern: &'h Pattern, prev_pattern: &'h Pattern,
orig: &'h Beatmap, orig: &'h Beatmap,
repeats: usize, repeats: usize,
curve: &BorrowedCurve<'_>, expected_dist: Option<f64>,
node_sounds: &'h [HitSoundType], node_sounds: &'h [HitSoundType],
) -> Self { ) -> Self {
let timing_beat_len = orig let timing_beat_len = orig
.timing_point_at(hit_object.start_time) .timing_point_at(hit_object.start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len); .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) .difficulty_point_at(hit_object.start_time)
.map_or(DifficultyPoint::DEFAULT_BPM_MULTIPLIER, |point| { .map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
point.bpm_multiplier point.slider_velocity
}); });
let kiai = orig let kiai = orig
@@ -62,14 +62,16 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
PatternType::LOW_PROBABILITY 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 span_count = (repeats + 1) as i32;
let start_time = hit_object.start_time.round_ties_even() 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. // * This matches stable's calculation.
let end_time = (f64::from(start_time) 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; .floor() as i32;
let segment_duration = (end_time - start_time) / span_count; let segment_duration = (end_time - start_time) / span_count;
+25 -15
View File
@@ -9,7 +9,7 @@ use crate::{
use super::{ use super::{
object::ManiaDifficultyObject, skills::strain::Strain, DifficultyValues, 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. /// Gradually calculate the difficulty attributes of an osu!mania map.
@@ -54,7 +54,13 @@ pub struct ManiaGradualDifficulty {
strain: Strain, strain: Strain,
diff_objects: Box<[ManiaDifficultyObject]>, diff_objects: Box<[ManiaDifficultyObject]>,
hit_window: f64, hit_window: f64,
note_state: NoteState,
}
#[derive(Default)]
struct NoteState {
curr_combo: u32, curr_combo: u32,
n_hold_notes: u32,
} }
impl ManiaGradualDifficulty { impl ManiaGradualDifficulty {
@@ -65,8 +71,10 @@ impl ManiaGradualDifficulty {
let clock_rate = difficulty.get_clock_rate(); let clock_rate = difficulty.get_clock_rate();
let mut params = ObjectParams::new(converted); let mut params = ObjectParams::new(converted);
let HitWindows { od: hit_window, .. } = let HitWindows {
converted.attributes().difficulty(&difficulty).hit_windows(); od_great: hit_window,
..
} = converted.attributes().difficulty(&difficulty).hit_windows();
let mania_objects = converted let mania_objects = converted
.hit_objects .hit_objects
@@ -78,7 +86,7 @@ impl ManiaGradualDifficulty {
let strain = Strain::new(total_columns as usize); 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 let objects_is_circle: Box<[_]> = converted
.hit_objects .hit_objects
@@ -93,7 +101,7 @@ impl ManiaGradualDifficulty {
objects_is_circle[0], objects_is_circle[0],
hit_object.start_time, hit_object.start_time,
hit_object.end_time, hit_object.end_time,
&mut curr_combo, &mut note_state,
); );
} }
@@ -105,7 +113,7 @@ impl ManiaGradualDifficulty {
strain, strain,
diff_objects, diff_objects,
hit_window, hit_window,
curr_combo, note_state,
} }
} }
} }
@@ -126,7 +134,7 @@ impl Iterator for ManiaGradualDifficulty {
increment_combo( increment_combo(
is_circle, is_circle,
curr, curr,
&mut self.curr_combo, &mut self.note_state,
self.difficulty.get_clock_rate(), self.difficulty.get_clock_rate(),
); );
} else if self.objects_is_circle.is_empty() { } else if self.objects_is_circle.is_empty() {
@@ -136,10 +144,11 @@ impl Iterator for ManiaGradualDifficulty {
self.idx += 1; self.idx += 1;
Some(ManiaDifficultyAttributes { Some(ManiaDifficultyAttributes {
stars: self.strain.as_difficulty_value() * STAR_SCALING_FACTOR, stars: self.strain.as_difficulty_value() * DIFFICULTY_MULTIPLIER,
hit_window: self.hit_window, hit_window: self.hit_window,
max_combo: self.curr_combo, max_combo: self.note_state.curr_combo,
n_objects: self.idx as u32, n_objects: self.idx as u32,
n_hold_notes: self.note_state.n_hold_notes,
is_convert: self.is_convert, is_convert: self.is_convert,
}) })
} }
@@ -169,7 +178,7 @@ impl Iterator for ManiaGradualDifficulty {
let clock_rate = self.difficulty.get_clock_rate(); let clock_rate = self.difficulty.get_clock_rate();
for (curr, is_circle) in skip_iter.take(take) { 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); strain.process(curr);
self.idx += 1; self.idx += 1;
} }
@@ -187,22 +196,23 @@ impl ExactSizeIterator for ManiaGradualDifficulty {
fn increment_combo( fn increment_combo(
is_circle: bool, is_circle: bool,
diff_obj: &ManiaDifficultyObject, diff_obj: &ManiaDifficultyObject,
curr_combo: &mut u32, state: &mut NoteState,
clock_rate: f64, clock_rate: f64,
) { ) {
increment_combo_raw( increment_combo_raw(
is_circle, is_circle,
diff_obj.start_time * clock_rate, diff_obj.start_time * clock_rate,
diff_obj.end_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 { if is_circle {
*curr_combo += 1; state.curr_combo += 1;
} else { } 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;
} }
} }
+7 -4
View File
@@ -14,7 +14,7 @@ pub mod gradual;
mod object; mod object;
mod skills; mod skills;
const STAR_SCALING_FACTOR: f64 = 0.018; const DIFFICULTY_MULTIPLIER: f64 = 0.018;
pub fn difficulty( pub fn difficulty(
difficulty: &Difficulty, difficulty: &Difficulty,
@@ -28,13 +28,14 @@ pub fn difficulty(
.attributes() .attributes()
.difficulty(difficulty) .difficulty(difficulty)
.hit_windows() .hit_windows()
.od; .od_great;
ManiaDifficultyAttributes { ManiaDifficultyAttributes {
stars: values.strain.difficulty_value() * STAR_SCALING_FACTOR, stars: values.strain.difficulty_value() * DIFFICULTY_MULTIPLIER,
hit_window, hit_window,
max_combo: values.max_combo, max_combo: values.max_combo,
n_objects, n_objects,
n_hold_notes: values.n_hold_notes,
is_convert: converted.is_convert, is_convert: converted.is_convert,
} }
} }
@@ -42,6 +43,7 @@ pub fn difficulty(
pub struct DifficultyValues { pub struct DifficultyValues {
pub strain: Strain, pub strain: Strain,
pub max_combo: u32, pub max_combo: u32,
pub n_hold_notes: u32,
} }
impl DifficultyValues { impl DifficultyValues {
@@ -71,7 +73,8 @@ impl DifficultyValues {
Self { Self {
strain, strain,
max_combo: params.into_max_combo(), max_combo: params.max_combo(),
n_hold_notes: params.n_hold_notes(),
} }
} }
+6 -5
View File
@@ -9,7 +9,7 @@ use crate::{
const INDIVIDUAL_DECAY_BASE: f64 = 0.125; const INDIVIDUAL_DECAY_BASE: f64 = 0.125;
const OVERALL_DECAY_BASE: f64 = 0.3; 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 SKILL_MULTIPLIER: f64 = 1.0;
const STRAIN_DECAY_BASE: f64 = 1.0; const STRAIN_DECAY_BASE: f64 = 1.0;
@@ -87,11 +87,12 @@ impl Strain {
for i in 0..self.end_times.len() { 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 // * The current note is overlapped if a previous note or end is overlapping the current note body
is_overlapping |= is_overlapping |= self.end_times[i] > start_time + 1.0
self.end_times[i] > start_time + 1.0 && end_time > self.end_times[i] + 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 // * 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; hold_factor = 1.25;
} }
@@ -109,7 +110,7 @@ impl Strain {
// * 0.0 +--------+-+---------------> Release Difference / ms // * 0.0 +--------+-+---------------> Release Difference / ms
// * release_threshold // * release_threshold
if is_overlapping { 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 // * Decay and increase individualStrains in own column
+11 -3
View File
@@ -1,4 +1,4 @@
use rosu_map::section::hit_objects::CurveBuffers; use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers};
use crate::model::{ use crate::model::{
beatmap::Beatmap, beatmap::Beatmap,
@@ -26,7 +26,7 @@ impl ManiaObject {
HitObjectKind::Slider(ref slider) => { HitObjectKind::Slider(ref slider) => {
const BASE_SCORING_DIST: f32 = 100.0; 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 let beat_len = params
.map .map
@@ -47,6 +47,7 @@ impl ManiaObject {
let duration = (slider.span_count() as f64) * dist / velocity; let duration = (slider.span_count() as f64) * dist / velocity;
params.max_combo += (duration / 100.0) as u32; params.max_combo += (duration / 100.0) as u32;
params.n_hold_notes += 1;
Self { Self {
start_time: h.start_time, start_time: h.start_time,
@@ -57,6 +58,7 @@ impl ManiaObject {
HitObjectKind::Spinner(Spinner { duration }) HitObjectKind::Spinner(Spinner { duration })
| HitObjectKind::Hold(HoldNote { duration }) => { | HitObjectKind::Hold(HoldNote { duration }) => {
params.max_combo += (duration / 100.0) as u32; params.max_combo += (duration / 100.0) as u32;
params.n_hold_notes += 1;
Self { Self {
start_time: h.start_time, start_time: h.start_time,
@@ -77,6 +79,7 @@ impl ManiaObject {
pub struct ObjectParams<'a> { pub struct ObjectParams<'a> {
map: &'a Beatmap, map: &'a Beatmap,
max_combo: u32, max_combo: u32,
n_hold_notes: u32,
curve_bufs: CurveBuffers, curve_bufs: CurveBuffers,
} }
@@ -85,11 +88,16 @@ impl<'a> ObjectParams<'a> {
Self { Self {
map, map,
max_combo: 0, max_combo: 0,
n_hold_notes: 0,
curve_bufs: CurveBuffers::default(), curve_bufs: CurveBuffers::default(),
} }
} }
pub fn into_max_combo(self) -> u32 { pub const fn max_combo(&self) -> u32 {
self.max_combo self.max_combo
} }
pub const fn n_hold_notes(&self) -> u32 {
self.n_hold_notes
}
} }
+7
View File
@@ -146,6 +146,13 @@ mod tests {
for i in 1.. { for i in 1.. {
state.misses += 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 { let Some(next_gradual) = gradual.next(state.clone()) else {
assert_eq!(i, hit_objects_len + 1); assert_eq!(i, hit_objects_len + 1);
assert!(gradual_2nd.last(state.clone()).is_some() || hit_objects_len % 2 == 0); assert!(gradual_2nd.last(state.clone()).is_some() || hit_objects_len % 2 == 0);
+61 -18
View File
@@ -169,6 +169,19 @@ impl<'map> ManiaPerformance<'map> {
self 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. /// Specify the amount of 320s of a play.
pub const fn n320(mut self, n320: u32) -> Self { pub const fn n320(mut self, n320: u32) -> Self {
self.n320 = Some(n320); self.n320 = Some(n320);
@@ -245,11 +258,16 @@ impl<'map> ManiaPerformance<'map> {
MapOrAttrs::Attrs(ref attrs) => attrs, 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 priority = self.hitresult_priority;
let misses = self.misses.map_or(0, |n| cmp::min(n, n_objects)); 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 n_remaining = n_objects - misses;
let mut n320 = self.n320.map_or(0, |n| cmp::min(n, n_remaining)); let mut n320 = self.n320.map_or(0, |n| cmp::min(n, n_remaining));
@@ -827,6 +845,8 @@ impl<'map> TryFrom<OsuPerformance<'map>> for ManiaPerformance<'map> {
difficulty, difficulty,
acc, acc,
combo: _, combo: _,
large_tick_hits: _,
slider_end_hits: _,
n300, n300,
n100, n100,
n50, n50,
@@ -863,9 +883,7 @@ struct ManiaPerformanceInner<'mods> {
impl ManiaPerformanceInner<'_> { impl ManiaPerformanceInner<'_> {
fn calculate(self) -> ManiaPerformanceAttributes { fn calculate(self) -> ManiaPerformanceAttributes {
// * Arbitrary initial value for scaling pp in order to standardize distributions across game modes. let mut multiplier = 1.0;
// * The specific number has no intrinsic meaning and can be adjusted as needed.
let mut multiplier = 8.0;
if self.mods.nf() { if self.mods.nf() {
multiplier *= 0.75; multiplier *= 0.75;
@@ -887,7 +905,7 @@ impl ManiaPerformanceInner<'_> {
fn compute_difficulty_value(&self) -> f64 { fn compute_difficulty_value(&self) -> f64 {
// * Star rating to pp curve // * 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 // * 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) * (5.0 * self.calculate_custom_accuracy() - 4.0).max(0.0)
// * Length bonus, capped at 1500 notes // * Length bonus, capped at 1500 notes
@@ -950,6 +968,7 @@ mod tests {
static ATTRS: OnceLock<ManiaDifficultyAttributes> = OnceLock::new(); static ATTRS: OnceLock<ManiaDifficultyAttributes> = OnceLock::new();
const N_OBJECTS: u32 = 594; const N_OBJECTS: u32 = 594;
const N_HOLD_NOTES: u32 = 121;
fn beatmap() -> Beatmap { fn beatmap() -> Beatmap {
Beatmap::from_path("./resources/1638954.osu").unwrap() Beatmap::from_path("./resources/1638954.osu").unwrap()
@@ -962,6 +981,14 @@ mod tests {
let attrs = Difficulty::new().with_mode().calculate(&converted); let attrs = Difficulty::new().with_mode().calculate(&converted);
assert_eq!(N_OBJECTS, converted.hit_objects.len() as u32); 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 attrs
}) })
@@ -975,6 +1002,7 @@ mod tests {
/// that it doesn't run unreasonably long. /// that it doesn't run unreasonably long.
#[allow(clippy::too_many_arguments, clippy::too_many_lines)] #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn brute_force_best( fn brute_force_best(
lazer: bool,
acc: f64, acc: f64,
n320: Option<u32>, n320: Option<u32>,
n300: Option<u32>, n300: Option<u32>,
@@ -994,7 +1022,11 @@ mod tests {
let mut best_dist = f64::INFINITY; let mut best_dist = f64::INFINITY;
let mut best_custom_acc = 0.0; 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()) let multiple_given = (usize::from(n320.is_some())
+ usize::from(n300.is_some()) + usize::from(n300.is_some())
@@ -1003,17 +1035,23 @@ mod tests {
+ usize::from(n50.is_some())) + usize::from(n50.is_some()))
> 1; > 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); .saturating_sub(n200.unwrap_or(0) + n100.unwrap_or(0) + n50.unwrap_or(0) + misses);
let min_n3x0 = cmp::min( let min_n3x0 = cmp::min(
max_left, 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( let max_n3x0 = cmp::min(
max_left, 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) { let (min_n3x0, max_n3x0) = match (n320, n300) {
@@ -1086,9 +1124,9 @@ mod tests {
let curr_dist = (acc - curr_acc).abs(); let curr_dist = (acc - curr_acc).abs();
let curr_custom_acc = 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 => { Ordering::Less => {
best_dist = curr_dist; best_dist = curr_dist;
best_custom_acc = curr_custom_acc; best_custom_acc = curr_custom_acc;
@@ -1194,13 +1232,14 @@ mod tests {
#[test] #[test]
fn mania_hitresults( fn mania_hitresults(
acc in 0.0..=1.0, lazer in prop::bool::ANY,
n320 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10), acc in 0.0_f64..=1.0,
n300 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10), n320 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 + 10), n300 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 + 10), n200 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 + 10), n100 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 + 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, best_case in prop::bool::ANY,
) { ) {
let priority = if best_case { let priority = if best_case {
@@ -1211,6 +1250,7 @@ mod tests {
let mut state = ManiaPerformance::from(attrs()) let mut state = ManiaPerformance::from(attrs())
.accuracy(acc * 100.0) .accuracy(acc * 100.0)
.lazer(lazer)
.hitresult_priority(priority); .hitresult_priority(priority);
if let Some(n320) = n320 { if let Some(n320) = n320 {
@@ -1242,6 +1282,7 @@ mod tests {
assert_eq!(first, state); assert_eq!(first, state);
let expected = brute_force_best( let expected = brute_force_best(
lazer,
acc, acc,
n320, n320,
n300, n300,
@@ -1259,6 +1300,7 @@ mod tests {
#[test] #[test]
fn hitresults_n320_misses_best() { fn hitresults_n320_misses_best() {
let state = ManiaPerformance::from(attrs()) let state = ManiaPerformance::from(attrs())
.lazer(false)
.n320(500) .n320(500)
.misses(2) .misses(2)
.hitresult_priority(HitResultPriority::BestCase) .hitresult_priority(HitResultPriority::BestCase)
@@ -1279,6 +1321,7 @@ mod tests {
#[test] #[test]
fn hitresults_n100_n50_misses_worst() { fn hitresults_n100_n50_misses_worst() {
let state = ManiaPerformance::from(attrs()) let state = ManiaPerformance::from(attrs())
.lazer(false)
.n100(200) .n100(200)
.n50(50) .n50(50)
.misses(2) .misses(2)
+92 -53
View File
@@ -27,7 +27,11 @@ pub struct HitWindows {
/// Hit window for approach rate i.e. `TimePreempt` in milliseconds. /// Hit window for approach rate i.e. `TimePreempt` in milliseconds.
pub ar: f64, pub ar: f64,
/// Hit window for overall difficulty i.e. time to hit a 300 ("Great") in milliseconds. /// 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<f64>,
} }
/// A builder for [`BeatmapAttributes`] and [`HitWindows`]. /// A builder for [`BeatmapAttributes`] and [`HitWindows`].
@@ -44,15 +48,43 @@ pub struct BeatmapAttributesBuilder {
clock_rate: Option<f64>, clock_rate: Option<f64>,
} }
struct GameModeHitWindows {
min: f64,
avg: f64,
max: f64,
}
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 { impl BeatmapAttributesBuilder {
const OSU_MIN: f64 = 80.0;
const OSU_AVG: f64 = 50.0;
const OSU_MAX: f64 = 20.0;
const TAIKO_MIN: f64 = 50.0;
const TAIKO_AVG: f64 = 35.0;
const TAIKO_MAX: f64 = 20.0;
/// Create a new [`BeatmapAttributesBuilder`]. /// Create a new [`BeatmapAttributesBuilder`].
/// ///
/// The mode will be `GameMode::Osu` and attributes are set to `5.0`. /// 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 { pub fn map(self, map: &Beatmap) -> Self {
Self { Self {
mode: map.mode, mode: map.mode,
ar: ModsDependentKind::Default(ModsDependent::new(map.ar)), // Clamping necessary to match lazer on maps like /b/4243836.
od: ModsDependentKind::Default(ModsDependent::new(map.od)), 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)), cs: ModsDependentKind::Default(ModsDependent::new(map.cs)),
hp: ModsDependentKind::Default(ModsDependent::new(map.hp)), hp: ModsDependentKind::Default(ModsDependent::new(map.hp)),
mods: GameMods::DEFAULT,
clock_rate: None,
is_convert: map.is_convert, is_convert: map.is_convert,
..self
} }
} }
@@ -195,10 +227,7 @@ impl BeatmapAttributesBuilder {
/// Calculate the AR and OD hit windows. /// Calculate the AR and OD hit windows.
pub fn hit_windows(&self) -> HitWindows { pub fn hit_windows(&self) -> HitWindows {
let mods = &self.mods; let mods = &self.mods;
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
let clock_rate = self
.clock_rate
.unwrap_or_else(|| f64::from(mods.clock_rate()));
let ar_clock_rate = if self.ar.with_mods() { 1.0 } else { 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 }; 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)) 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 // OD
let hit_window = match self.mode { let (great, ok) = match self.mode {
GameMode::Osu | GameMode::Catch => { GameMode::Osu | GameMode::Catch => {
let raw_od = if self.od.with_mods() { let raw_od = if self.od.with_mods() {
self.od.value(mods, GameMods::od) self.od.value(mods, GameMods::od)
@@ -230,12 +259,10 @@ impl BeatmapAttributesBuilder {
mod_mult(self.od.value(mods, GameMods::od)) mod_mult(self.od.value(mods, GameMods::od))
}; };
difficulty_range( let great = difficulty_range(f64::from(raw_od), OSU_GREAT) / od_clock_rate;
f64::from(raw_od), let ok = difficulty_range(f64::from(raw_od), OSU_OK) / od_clock_rate;
Self::OSU_MIN,
Self::OSU_AVG, (great, Some(ok))
Self::OSU_MAX,
) / od_clock_rate
} }
GameMode::Taiko => { GameMode::Taiko => {
let raw_od = if self.od.with_mods() { let raw_od = if self.od.with_mods() {
@@ -244,14 +271,10 @@ impl BeatmapAttributesBuilder {
mod_mult(self.od.value(mods, GameMods::od)) mod_mult(self.od.value(mods, GameMods::od))
}; };
let diff_range = difficulty_range( let great = difficulty_range(f64::from(raw_od), TAIKO_GREAT) / od_clock_rate;
f64::from(raw_od), let ok = difficulty_range(f64::from(raw_od), TAIKO_OK) / od_clock_rate;
Self::TAIKO_MIN,
Self::TAIKO_AVG,
Self::TAIKO_MAX,
);
diff_range / od_clock_rate (great, Some(ok))
} }
GameMode::Mania => { GameMode::Mania => {
let mut value = if !self.is_convert { 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 { HitWindows {
ar: preempt, ar: preempt,
od: hit_window, od_great: great,
od_ok: ok,
} }
} }
/// Calculate the [`BeatmapAttributes`]. /// Calculate the [`BeatmapAttributes`].
pub fn build(&self) -> BeatmapAttributes { pub fn build(&self) -> BeatmapAttributes {
let mods = &self.mods; let mods = &self.mods;
let clock_rate = self let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
.clock_rate
.unwrap_or_else(|| f64::from(mods.clock_rate()));
// HP // HP
let mut hp = self.hp.value(mods, GameMods::hp); let mut hp = self.hp.value(mods, GameMods::hp);
@@ -308,7 +332,11 @@ impl BeatmapAttributesBuilder {
} }
let hit_windows = self.hit_windows(); let hit_windows = self.hit_windows();
let HitWindows { ar, od } = hit_windows; let HitWindows {
ar,
od_great,
od_ok: _,
} = hit_windows;
// AR // AR
let ar = if ar > 1200.0 { let ar = if ar > 1200.0 {
@@ -319,8 +347,10 @@ impl BeatmapAttributesBuilder {
// OD // OD
let od = match self.mode { let od = match self.mode {
GameMode::Osu => (Self::OSU_MIN - od) / 6.0, GameMode::Osu => (OSU_GREAT.min - od_great) / 6.0,
GameMode::Taiko => (Self::TAIKO_MIN - od) / (Self::TAIKO_MIN - Self::TAIKO_AVG) * 5.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)), GameMode::Catch | GameMode::Mania => f64::from(self.od.value(mods, GameMods::od)),
}; };
@@ -347,7 +377,11 @@ impl<M> 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 { if difficulty > 5.0 {
mid + (max - mid) * (difficulty - 5.0) / 5.0 mid + (max - mid) * (difficulty - 5.0) / 5.0
} else if difficulty < 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>) -> f32 { fn value(&self, mods: &GameMods, mods_fn: impl Fn(&GameMods) -> Option<f64>) -> f32 {
match self { 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, ModsDependentKind::Custom(inner) => inner.value,
} }
} }
@@ -388,13 +422,18 @@ impl ModsDependentKind {
#[cfg(test)] #[cfg(test)]
mod tests { 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::*; use super::*;
#[test] #[test]
fn default_ar() { fn default_ar() {
let gamemod = GameMod::HiddenOsu(Default::default()); let gamemod = GameMod::HiddenOsu(HiddenOsu::default());
let diff = Difficulty::new().mods(GameMods::from(gamemod)); let diff = Difficulty::new().mods(GameMods::from(gamemod));
let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build(); let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build();
@@ -403,7 +442,7 @@ mod tests {
#[test] #[test]
fn custom_ar_without_mods() { 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 diff = Difficulty::new().mods(GameMods::from(gamemod));
let attrs = BeatmapAttributesBuilder::new() let attrs = BeatmapAttributesBuilder::new()
.ar(8.5, false) .ar(8.5, false)
@@ -415,7 +454,7 @@ mod tests {
#[test] #[test]
fn custom_ar_with_mods() { 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 diff = Difficulty::new().mods(GameMods::from(gamemod));
let attrs = BeatmapAttributesBuilder::new() let attrs = BeatmapAttributesBuilder::new()
.ar(8.5, true) .ar(8.5, true)
@@ -428,10 +467,10 @@ mod tests {
#[test] #[test]
fn custom_mods_ar() { fn custom_mods_ar() {
let mut mods = GameMods::new(); let mut mods = GameMods::new();
mods.insert(GameMod::DoubleTimeCatch(Default::default())); mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default()));
mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu {
approach_rate: Some(7.0), approach_rate: Some(7.0),
..Default::default() ..DifficultyAdjustOsu::default()
})); }));
let diff = Difficulty::new().mods(mods); let diff = Difficulty::new().mods(mods);
let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build(); let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build();
@@ -442,10 +481,10 @@ mod tests {
#[test] #[test]
fn custom_ar_custom_mods_ar_without_mods() { fn custom_ar_custom_mods_ar_without_mods() {
let mut mods = GameMods::new(); let mut mods = GameMods::new();
mods.insert(GameMod::DoubleTimeCatch(Default::default())); mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default()));
mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu {
approach_rate: Some(9.0), approach_rate: Some(9.0),
..Default::default() ..DifficultyAdjustOsu::default()
})); }));
let diff = Difficulty::new().mods(mods).ar(8.5, false); let diff = Difficulty::new().mods(mods).ar(8.5, false);
@@ -457,10 +496,10 @@ mod tests {
#[test] #[test]
fn custom_ar_custom_mods_ar_with_mods() { fn custom_ar_custom_mods_ar_with_mods() {
let mut mods = GameMods::new(); let mut mods = GameMods::new();
mods.insert(GameMod::DoubleTimeCatch(Default::default())); mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default()));
mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu {
approach_rate: Some(9.0), approach_rate: Some(9.0),
..Default::default() ..DifficultyAdjustOsu::default()
})); }));
let diff = Difficulty::new().mods(mods).ar(8.5, true); let diff = Difficulty::new().mods(mods).ar(8.5, true);
+3 -1
View File
@@ -614,7 +614,9 @@ impl DecodeBeatmap for Beatmap {
// filename // filename
match split.next() { match split.next() {
None | Some("") => {} None | Some("") => {}
Some(_) => sound = HitSoundType::default(), // Relevant maps:
// - /b/244784 at 43374
Some(_) => sound &= !HitSoundType::NORMAL,
} }
Ok(()) Ok(())
+10 -3
View File
@@ -1,6 +1,9 @@
use std::cmp::Ordering; 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::{ pub use rosu_map::{
section::hit_objects::{hit_samples::HitSoundType, PathControlPoint, PathType, SplineType}, section::hit_objects::{hit_samples::HitSoundType, PathControlPoint, PathType, SplineType},
@@ -76,8 +79,12 @@ impl Slider {
self.repeats + 1 self.repeats + 1
} }
pub(crate) fn curve<'a>(&self, bufs: &'a mut CurveBuffers) -> BorrowedCurve<'a> { pub(crate) fn curve<'a>(
BorrowedCurve::new(&self.control_points, self.expected_dist, bufs) &self,
mode: GameMode,
bufs: &'a mut CurveBuffers,
) -> BorrowedCurve<'a> {
BorrowedCurve::new(mode, &self.control_points, self.expected_dist, bufs)
} }
} }
+65 -2
View File
@@ -61,7 +61,7 @@ impl GameMods {
/// ///
/// In case of variable clock rates like for `WindUp`, this will return /// In case of variable clock rates like for `WindUp`, this will return
/// `1.0`. /// `1.0`.
pub(crate) fn clock_rate(&self) -> f32 { pub(crate) fn clock_rate(&self) -> f64 {
match self.inner { match self.inner {
GameModsInner::Lazer(ref mods) => mods.clock_rate().unwrap_or(1.0), GameModsInner::Lazer(ref mods) => mods.clock_rate().unwrap_or(1.0),
GameModsInner::Intermode(ref mods) => mods.legacy_clock_rate(), GameModsInner::Intermode(ref mods) => mods.legacy_clock_rate(),
@@ -96,6 +96,59 @@ impl GameMods {
custom_hardrock_offsets(self).unwrap_or_else(|| self.hr()) 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 { macro_rules! impl_map_attr {
@@ -105,7 +158,7 @@ macro_rules! impl_map_attr {
#[doc = "Check whether the mods specify a custom "] #[doc = "Check whether the mods specify a custom "]
#[doc = $s] #[doc = $s]
#[doc = "value."] #[doc = "value."]
pub(crate) fn $fn(&self) -> Option<f32> { pub(crate) fn $fn(&self) -> Option<f64> {
match self.inner { match self.inner {
GameModsInner::Lazer(ref mods) => mods.iter().find_map(|gamemod| match gamemod { GameModsInner::Lazer(ref mods) => mods.iter().find_map(|gamemod| match gamemod {
$( impl_map_attr!( @ $mode $field) => *$field, )* $( impl_map_attr!( @ $mode $field) => *$field, )*
@@ -175,6 +228,8 @@ impl_has_mod! {
fl: + Flashlight ["Flashlight"], fl: + Flashlight ["Flashlight"],
so: + SpunOut ["SpunOut"], so: + SpunOut ["SpunOut"],
bl: - Blinds ["Blinds"], bl: - Blinds ["Blinds"],
cl: - Classic ["Classic"],
tc: - Traceable ["Traceable"],
} }
impl Default for GameMods { impl Default for GameMods {
@@ -223,3 +278,11 @@ impl From<u32> for GameMods {
GameModsLegacy::from_bits(bits).into() GameModsLegacy::from_bits(bits).into()
} }
} }
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Reflection {
None,
Vertical,
Horizontal,
Both,
}
+6
View File
@@ -13,6 +13,10 @@ pub struct OsuDifficultyAttributes {
pub slider_factor: f64, pub slider_factor: f64,
/// The number of clickable objects weighted by difficulty. /// The number of clickable objects weighted by difficulty.
pub speed_note_count: f64, 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. /// The approach rate.
pub ar: f64, pub ar: f64,
/// The overall difficulty /// The overall difficulty
@@ -23,6 +27,8 @@ pub struct OsuDifficultyAttributes {
pub n_circles: u32, pub n_circles: u32,
/// The amount of sliders. /// The amount of sliders.
pub n_sliders: u32, pub n_sliders: u32,
/// The amount of slider ticks and repeat points.
pub n_slider_ticks: u32,
/// The amount of spinners. /// The amount of spinners.
pub n_spinners: u32, pub n_spinners: u32,
/// The final star rating /// The final star rating
+22 -8
View File
@@ -3,6 +3,7 @@ use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers};
use crate::model::{ use crate::model::{
beatmap::{Beatmap, Converted}, beatmap::{Beatmap, Converted},
mode::ConvertStatus, mode::ConvertStatus,
mods::Reflection,
}; };
use super::{ use super::{
@@ -30,7 +31,7 @@ pub fn try_convert(map: &mut Beatmap) -> ConvertStatus {
pub fn convert_objects( pub fn convert_objects(
converted: &OsuBeatmap<'_>, converted: &OsuBeatmap<'_>,
scaling_factor: &ScalingFactor, scaling_factor: &ScalingFactor,
hr: bool, reflection: Reflection,
time_preempt: f64, time_preempt: f64,
mut take: usize, mut take: usize,
attrs: &mut OsuDifficultyAttributes, attrs: &mut OsuDifficultyAttributes,
@@ -55,6 +56,7 @@ pub fn convert_objects(
OsuObjectKind::Circle => attrs.n_circles += 1, OsuObjectKind::Circle => attrs.n_circles += 1,
OsuObjectKind::Slider(ref slider) => { OsuObjectKind::Slider(ref slider) => {
attrs.n_sliders += 1; attrs.n_sliders += 1;
attrs.n_slider_ticks += slider.tick_count() as u32;
attrs.max_combo += slider.nested_objects.len() as u32; attrs.max_combo += slider.nested_objects.len() as u32;
} }
OsuObjectKind::Spinner(_) => attrs.n_spinners += 1, OsuObjectKind::Spinner(_) => attrs.n_spinners += 1,
@@ -62,12 +64,17 @@ pub fn convert_objects(
}) })
.collect(); .collect();
if hr { match reflection {
osu_objects Reflection::None => osu_objects.iter_mut().for_each(OsuObject::finalize_nested),
Reflection::Vertical => osu_objects
.iter_mut() .iter_mut()
.for_each(OsuObject::reflect_vertically); .for_each(OsuObject::reflect_vertically),
} else { Reflection::Horizontal => osu_objects
osu_objects.iter_mut().for_each(OsuObject::finalize_tail); .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); 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; 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 { if hit_objects[j].pos.distance(hit_objects[i].pos) < STACK_DISTANCE {
hit_objects[i].stack_height += 1; 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 { } else if hit_objects[j].pos.distance(pos2) < STACK_DISTANCE {
slider_stack += 1; slider_stack += 1;
hit_objects[j].stack_height -= slider_stack; hit_objects[j].stack_height -= slider_stack;
start_time = hit_objects[j].end_time(); start_time = hit_objects[j].start_time;
} }
} }
} }
+6 -4
View File
@@ -84,7 +84,7 @@ impl OsuGradualDifficulty {
let osu_objects = convert_objects( let osu_objects = convert_objects(
converted, converted,
&scaling_factor, &scaling_factor,
mods.hr(), mods.reflection(),
time_preempt, time_preempt,
converted.hit_objects.len(), converted.hit_objects.len(),
&mut attrs, &mut attrs,
@@ -92,6 +92,7 @@ impl OsuGradualDifficulty {
attrs.n_circles = 0; attrs.n_circles = 0;
attrs.n_sliders = 0; attrs.n_sliders = 0;
attrs.n_slider_ticks = 0;
attrs.n_spinners = 0; attrs.n_spinners = 0;
attrs.max_combo = 0; attrs.max_combo = 0;
@@ -128,6 +129,7 @@ impl OsuGradualDifficulty {
OsuObjectKind::Circle => attrs.n_circles += 1, OsuObjectKind::Circle => attrs.n_circles += 1,
OsuObjectKind::Slider(slider) => { OsuObjectKind::Slider(slider) => {
attrs.n_sliders += 1; attrs.n_sliders += 1;
attrs.n_slider_ticks += slider.tick_count() as u32;
attrs.max_combo += slider.nested_objects.len() as u32; attrs.max_combo += slider.nested_objects.len() as u32;
} }
OsuObjectKind::Spinner { .. } => attrs.n_spinners += 1, OsuObjectKind::Spinner { .. } => attrs.n_spinners += 1,
@@ -178,9 +180,9 @@ impl Iterator for OsuGradualDifficulty {
DifficultyValues::eval( DifficultyValues::eval(
&mut attrs, &mut attrs,
self.difficulty.get_mods(), self.difficulty.get_mods(),
aim_difficulty_value, &aim_difficulty_value,
aim_no_sliders_difficulty_value, &aim_no_sliders_difficulty_value,
speed_difficulty_value, &speed_difficulty_value,
speed_relevant_note_count, speed_relevant_note_count,
flashlight_difficulty_value, flashlight_difficulty_value,
); );
+24 -15
View File
@@ -1,5 +1,10 @@
use std::{cmp, pin::Pin}; use std::{cmp, pin::Pin};
use skills::{
flashlight::Flashlight,
strain::{DifficultyValue, OsuStrainSkill, UsedOsuStrainSkills},
};
use crate::{ use crate::{
any::difficulty::{skills::Skill, Difficulty}, any::difficulty::{skills::Skill, Difficulty},
model::{beatmap::BeatmapAttributes, mods::GameMods}, model::{beatmap::BeatmapAttributes, mods::GameMods},
@@ -48,9 +53,9 @@ pub fn difficulty(difficulty: &Difficulty, converted: &OsuBeatmap<'_>) -> OsuDif
DifficultyValues::eval( DifficultyValues::eval(
&mut attrs, &mut attrs,
mods, mods,
aim_difficulty_value, &aim_difficulty_value,
aim_no_sliders_difficulty_value, &aim_no_sliders_difficulty_value,
speed_difficulty_value, &speed_difficulty_value,
speed_relevant_note_count, speed_relevant_note_count,
flashlight_difficulty_value, flashlight_difficulty_value,
); );
@@ -109,7 +114,7 @@ impl DifficultyValues {
let mut osu_objects = convert_objects( let mut osu_objects = convert_objects(
converted, converted,
&scaling_factor, &scaling_factor,
mods.hr(), mods.reflection(),
time_preempt, time_preempt,
take, take,
&mut attrs, &mut attrs,
@@ -146,15 +151,16 @@ impl DifficultyValues {
pub fn eval( pub fn eval(
attrs: &mut OsuDifficultyAttributes, attrs: &mut OsuDifficultyAttributes,
mods: &GameMods, mods: &GameMods,
aim_difficulty_value: f64, aim: &UsedOsuStrainSkills<DifficultyValue>,
aim_no_sliders_difficulty_value: f64, aim_no_sliders: &UsedOsuStrainSkills<DifficultyValue>,
speed_difficulty_value: f64, speed: &UsedOsuStrainSkills<DifficultyValue>,
speed_relevant_note_count: f64, speed_relevant_note_count: f64,
flashlight_difficulty_value: f64, flashlight_difficulty_value: f64,
) { ) {
let mut aim_rating = aim_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 aim_rating_no_sliders =
let mut speed_rating = speed_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER; 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 mut flashlight_rating = flashlight_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER;
let slider_factor = if aim_rating > 0.0 { let slider_factor = if aim_rating > 0.0 {
@@ -163,6 +169,9 @@ impl DifficultyValues {
1.0 1.0
}; };
let aim_difficult_strain_count = aim.count_difficult_strains();
let speed_difficult_strain_count = speed.count_difficult_strains();
if mods.td() { if mods.td() {
aim_rating = aim_rating.powf(0.8); aim_rating = aim_rating.powf(0.8);
flashlight_rating = flashlight_rating.powf(0.8); flashlight_rating = flashlight_rating.powf(0.8);
@@ -174,13 +183,11 @@ impl DifficultyValues {
flashlight_rating *= 0.7; flashlight_rating *= 0.7;
} }
let base_aim_performance = let base_aim_performance = OsuStrainSkill::difficulty_to_performance(aim_rating);
(5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powf(3.0) / 100_000.0; let base_speed_performance = OsuStrainSkill::difficulty_to_performance(speed_rating);
let base_speed_performance =
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powf(3.0) / 100_000.0;
let base_flashlight_performance = if mods.fl() { let base_flashlight_performance = if mods.fl() {
flashlight_rating.powf(2.0) * 25.0 Flashlight::difficulty_to_performance(flashlight_rating)
} else { } else {
0.0 0.0
}; };
@@ -202,6 +209,8 @@ impl DifficultyValues {
attrs.speed = speed_rating; attrs.speed = speed_rating;
attrs.flashlight = flashlight_rating; attrs.flashlight = flashlight_rating;
attrs.slider_factor = slider_factor; 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.stars = star_rating;
attrs.speed_note_count = speed_relevant_note_count; attrs.speed_note_count = speed_relevant_note_count;
} }
+30 -6
View File
@@ -1,10 +1,10 @@
use std::pin::Pin; use std::{borrow::Cow, pin::Pin};
use rosu_map::util::Pos; use rosu_map::util::Pos;
use crate::{ use crate::{
any::difficulty::object::IDifficultyObject, any::difficulty::object::IDifficultyObject,
osu::object::{OsuObject, OsuObjectKind}, osu::object::{OsuObject, OsuObjectKind, OsuSlider},
}; };
use super::{scaling_factor::ScalingFactor, HD_FADE_OUT_DURATION_MULTIPLIER}; use super::{scaling_factor::ScalingFactor, HD_FADE_OUT_DURATION_MULTIPLIER};
@@ -27,7 +27,7 @@ pub struct OsuDifficultyObject<'a> {
impl<'a> OsuDifficultyObject<'a> { impl<'a> OsuDifficultyObject<'a> {
pub const NORMALIZED_RADIUS: f32 = 50.0; 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 MAX_SLIDER_RADIUS: f32 = Self::NORMALIZED_RADIUS * 2.4;
const ASSUMED_SLIDER_RADIUS: f32 = Self::NORMALIZED_RADIUS * 1.8; 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( fn set_distances(
&mut self, &mut self,
last_object: &OsuObject, last_object: &OsuObject,
@@ -158,20 +176,26 @@ impl<'a> OsuDifficultyObject<'a> {
) -> Pin<&mut OsuObject> { ) -> Pin<&mut OsuObject> {
let pos = h.pos; let pos = h.pos;
let stack_offset = h.stack_offset; let stack_offset = h.stack_offset;
let start_time = h.start_time;
let OsuObjectKind::Slider(ref mut slider) = h.kind else { let OsuObjectKind::Slider(ref mut slider) = h.kind else {
return h; 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 mut curr_cursor_pos = pos + stack_offset;
let scaling_factor = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS) / radius; 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 = curr_movement_obj.pos + stack_offset - curr_cursor_pos;
let mut curr_movement_len = scaling_factor * f64::from(curr_movement.length()); let mut curr_movement_len = scaling_factor * f64::from(curr_movement.length());
let mut required_movement = f64::from(OsuDifficultyObject::ASSUMED_SLIDER_RADIUS); 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; let lazy_movement = slider.lazy_end_pos - curr_cursor_pos;
if lazy_movement.length() < curr_movement.length() { if lazy_movement.length() < curr_movement.length() {
@@ -190,7 +214,7 @@ impl<'a> OsuDifficultyObject<'a> {
slider.lazy_travel_dist += curr_movement_len as f32; 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; slider.lazy_end_pos = curr_cursor_pos;
} }
} }
+4 -1
View File
@@ -4,6 +4,8 @@ use crate::osu::object::OsuObject;
use super::object::OsuDifficultyObject; use super::object::OsuDifficultyObject;
const BROKEN_GAMEFIELD_ROUNDING_ALLOWANCE: f32 = 1.00041;
/// Fields around the scaling of hit objects. /// Fields around the scaling of hit objects.
/// ///
/// osu!lazer stores these in each hit object but since all objects share the /// osu!lazer stores these in each hit object but since all objects share the
@@ -17,7 +19,8 @@ pub struct ScalingFactor {
impl ScalingFactor { impl ScalingFactor {
pub fn new(cs: f64) -> Self { 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 radius = f64::from(OsuObject::OBJECT_RADIUS * scale);
let factor = OsuDifficultyObject::NORMALIZED_RADIUS / radius as f32; let factor = OsuDifficultyObject::NORMALIZED_RADIUS / radius as f32;
+11 -11
View File
@@ -9,9 +9,9 @@ use crate::{
util::{float_ext::FloatExt, strains_vec::StrainsVec}, 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; const STRAIN_DECAY_BASE: f64 = 0.15;
#[derive(Clone)] #[derive(Clone)]
@@ -31,25 +31,24 @@ impl Aim {
} }
pub fn get_curr_strain_peaks(self) -> StrainsVec { 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<DifficultyValue> {
Self::static_difficulty_value(self.inner) Self::static_difficulty_value(self.inner)
} }
/// Use [`difficulty_value`] instead whenever possible because /// Use [`difficulty_value`] instead whenever possible because
/// [`as_difficulty_value`] clones internally. /// [`as_difficulty_value`] clones internally.
pub fn as_difficulty_value(&self) -> f64 { pub fn as_difficulty_value(&self) -> UsedOsuStrainSkills<DifficultyValue> {
Self::static_difficulty_value(self.inner.clone()) Self::static_difficulty_value(self.inner.clone())
} }
fn static_difficulty_value(skill: OsuStrainSkill) -> f64 { fn static_difficulty_value(skill: OsuStrainSkill) -> UsedOsuStrainSkills<DifficultyValue> {
skill.difficulty_value( skill.difficulty_value(
OsuStrainSkill::REDUCED_SECTION_COUNT, OsuStrainSkill::REDUCED_SECTION_COUNT,
OsuStrainSkill::REDUCED_STRAIN_BASELINE, OsuStrainSkill::REDUCED_STRAIN_BASELINE,
OsuStrainSkill::DECAY_WEIGHT, OsuStrainSkill::DECAY_WEIGHT,
OsuStrainSkill::DIFFICULTY_MULTIPLER,
) )
} }
} }
@@ -105,6 +104,7 @@ impl<'a> Skill<'a, Aim> {
self.inner.curr_strain += self.inner.curr_strain +=
AimEvaluator::evaluate_diff_of(curr, self.diff_objects, self.inner.with_sliders) AimEvaluator::evaluate_diff_of(curr, self.diff_objects, self.inner.with_sliders)
* SKILL_MULTIPLIER; * SKILL_MULTIPLIER;
self.inner.inner.object_strains.push(self.inner.curr_strain);
self.inner.curr_strain self.inner.curr_strain
} }
@@ -121,7 +121,7 @@ impl AimEvaluator {
fn evaluate_diff_of<'a>( fn evaluate_diff_of<'a>(
curr: &'a OsuDifficultyObject<'a>, curr: &'a OsuDifficultyObject<'a>,
diff_objects: &'a [OsuDifficultyObject<'a>], diff_objects: &'a [OsuDifficultyObject<'a>],
with_sliders: bool, with_slider_travel_dist: bool,
) -> f64 { ) -> f64 {
let osu_curr_obj = curr; let osu_curr_obj = curr;
@@ -139,7 +139,7 @@ impl AimEvaluator {
// * But if the last object is a slider, then we extend the travel // * But if the last object is a slider, then we extend the travel
// * velocity through the slider into the current object. // * 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. // * calculate the slider velocity from slider head to slider end.
let travel_vel = osu_last_obj.travel_dist / osu_last_obj.travel_time; let travel_vel = osu_last_obj.travel_dist / osu_last_obj.travel_time;
// * calculate the movement velocity from slider end to current object // * 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. // * As above, do the same for the previous hitobject.
let mut prev_vel = osu_last_obj.lazy_jump_dist / osu_last_obj.strain_time; 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 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; 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. // * Add in additional slider velocity bonus.
if with_sliders { if with_slider_travel_dist {
aim_strain += slider_bonus * Self::SLIDER_MULTIPLIER; aim_strain += slider_bonus * Self::SLIDER_MULTIPLIER;
} }
+6 -4
View File
@@ -10,9 +10,7 @@ use crate::{
util::strains_vec::StrainsVec, util::strains_vec::StrainsVec,
}; };
use super::strain::OsuStrainSkill; const SKILL_MULTIPLIER: f64 = 0.05512;
const SKILL_MULTIPLIER: f64 = 0.052;
const STRAIN_DECAY_BASE: f64 = 0.15; const STRAIN_DECAY_BASE: f64 = 0.15;
pub struct Flashlight { pub struct Flashlight {
@@ -49,7 +47,11 @@ impl Flashlight {
} }
fn static_difficulty_value(skill: StrainSkill) -> f64 { 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)
} }
} }
+1 -1
View File
@@ -26,7 +26,7 @@ impl OsuSkills {
map_attrs: &BeatmapAttributes, map_attrs: &BeatmapAttributes,
time_preempt: f64, time_preempt: f64,
) -> Self { ) -> 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 // * 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. // * which uniformly speeds up all animations game wide regardless of AR.
+209 -88
View File
@@ -1,4 +1,7 @@
use std::{cmp, f64::consts::PI}; use std::{
cmp,
f64::consts::{E, PI},
};
use crate::{ use crate::{
any::difficulty::{ any::difficulty::{
@@ -9,19 +12,17 @@ use crate::{
util::strains_vec::StrainsVec, 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 STRAIN_DECAY_BASE: f64 = 0.3;
const DIFFICULTY_MULTIPLER: f64 = 1.04;
const REDUCED_SECTION_COUNT: usize = 5; const REDUCED_SECTION_COUNT: usize = 5;
#[derive(Clone)] #[derive(Clone)]
pub struct Speed { pub struct Speed {
curr_strain: f64, curr_strain: f64,
curr_rhythm: f64, curr_rhythm: f64,
object_strains: Vec<f64>,
hit_window: f64, hit_window: f64,
inner: OsuStrainSkill, inner: OsuStrainSkill,
} }
@@ -31,44 +32,42 @@ impl Speed {
Self { Self {
curr_strain: 0.0, curr_strain: 0.0,
curr_rhythm: 0.0, curr_rhythm: 0.0,
// mean=406.72 | median=307
object_strains: Vec::with_capacity(256),
hit_window, hit_window,
inner: OsuStrainSkill::default(), inner: OsuStrainSkill::default(),
} }
} }
pub fn get_curr_strain_peaks(self) -> StrainsVec { 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<DifficultyValue> {
Self::static_difficulty_value(self.inner) Self::static_difficulty_value(self.inner)
} }
/// Use [`difficulty_value`] instead whenever possible because /// Use [`difficulty_value`] instead whenever possible because
/// [`as_difficulty_value`] clones internally. /// [`as_difficulty_value`] clones internally.
pub fn as_difficulty_value(&self) -> f64 { pub fn as_difficulty_value(&self) -> UsedOsuStrainSkills<DifficultyValue> {
Self::static_difficulty_value(self.inner.clone()) Self::static_difficulty_value(self.inner.clone())
} }
fn static_difficulty_value(skill: OsuStrainSkill) -> f64 { fn static_difficulty_value(skill: OsuStrainSkill) -> UsedOsuStrainSkills<DifficultyValue> {
skill.difficulty_value( skill.difficulty_value(
REDUCED_SECTION_COUNT, REDUCED_SECTION_COUNT,
OsuStrainSkill::REDUCED_STRAIN_BASELINE, OsuStrainSkill::REDUCED_STRAIN_BASELINE,
OsuStrainSkill::DECAY_WEIGHT, OsuStrainSkill::DECAY_WEIGHT,
DIFFICULTY_MULTIPLER,
) )
} }
pub fn relevant_note_count(&self) -> f64 { pub fn relevant_note_count(&self) -> f64 {
self.object_strains self.inner
.object_strains
.iter() .iter()
.copied() .copied()
.max_by(f64::total_cmp) .max_by(f64::total_cmp)
.filter(|&n| n > 0.0) .filter(|&n| n > 0.0)
.map_or(0.0, |max_strain| { .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() 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); RhythmEvaluator::evaluate_diff_of(curr, self.diff_objects, self.inner.hit_window);
let total_strain = self.inner.curr_strain * self.inner.curr_rhythm; 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 total_strain
} }
@@ -140,9 +139,10 @@ impl<'a> Skill<'a, Speed> {
struct SpeedEvaluator; struct SpeedEvaluator;
impl 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 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>( fn evaluate_diff_of<'a>(
curr: &'a OsuDifficultyObject<'a>, curr: &'a OsuDifficultyObject<'a>,
@@ -159,47 +159,51 @@ impl SpeedEvaluator {
let osu_next_obj = curr.next(0, diff_objects); let osu_next_obj = curr.next(0, diff_objects);
let mut strain_time = curr.strain_time; let mut strain_time = curr.strain_time;
let mut doubletapness = 1.0; // Note: Technically `osu_next_obj` is never `None` but instead the
// default value. This could maybe invalidate the `get_doubletapness`
// * Nerf doubletappable doubles. // result.
if let Some(osu_next_obj) = osu_next_obj { let doubletapness = 1.0 - osu_curr_obj.get_doubletapness(osu_next_obj, hit_window);
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);
}
// * Cap deltatime to the OD 300 hitwindow. // * 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. // * 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); strain_time /= ((strain_time / hit_window) / 0.93).clamp(0.92, 1.0);
// * derive speedBonus for calculation
let speed_bonus = if strain_time < Self::MIN_SPEED_BONUS { let 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; 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 { } 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 travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.travel_dist);
let dist = Self::SINGLE_SPACING_THRESHOLD.min(travel_dist + osu_curr_obj.min_jump_dist); let mut dist = travel_dist + osu_curr_obj.min_jump_dist;
(speed_bonus + speed_bonus * (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.5)) // * Cap distance at single_spacing_threshold
* doubletapness dist = Self::SINGLE_SPACING_THRESHOLD.min(dist);
/ 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; struct RhythmEvaluator;
impl RhythmEvaluator { impl RhythmEvaluator {
// * 5 seconds of calculatingRhythmBonus max. const HISTORY_TIME_MAX: u32 = 5 * 1000; // 5 seconds
const HISTORY_TIME_MAX: u32 = 5000; const HISTORY_OBJECTS_MAX: usize = 32;
const RHYTHM_MULTIPLIER: f64 = 0.75; const RHYTHM_OVERALL_MULTIPLIER: f64 = 0.95;
const RHYTHM_RATIO_MULTIPLIER: f64 = 12.0;
#[allow(clippy::too_many_lines)]
fn evaluate_diff_of<'a>( fn evaluate_diff_of<'a>(
curr: &'a OsuDifficultyObject<'a>, curr: &'a OsuDifficultyObject<'a>,
diff_objects: &'a [OsuDifficultyObject<'a>], diff_objects: &'a [OsuDifficultyObject<'a>],
@@ -209,16 +213,23 @@ impl RhythmEvaluator {
return 0.0; return 0.0;
} }
let mut prev_island_size = 0;
let mut rhythm_complexity_sum = 0.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::<IslandCount>::new();
// * store the ratio of the current start of an island to buff for tighter rhythms // * store the ratio of the current start of an island to buff for tighter rhythms
let mut start_ratio = 0.0; let mut start_ratio = 0.0;
let mut first_delta_switch = false; 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; let mut rhythm_start = 0;
@@ -233,51 +244,54 @@ impl RhythmEvaluator {
rhythm_start += 1; rhythm_start += 1;
} }
if let Some((mut prev_obj, mut last_obj)) = curr
.previous(rhythm_start, diff_objects)
.zip(curr.previous(rhythm_start + 1, diff_objects))
{
// * we go from the furthest object back to the current one
for i in (1..=rhythm_start).rev() { for i in (1..=rhythm_start).rev() {
let Some(((curr_obj, prev_obj), last_obj)) = curr let Some(curr_obj) = curr.previous(i - 1, diff_objects) else {
.previous(i - 1, diff_objects)
.zip(curr.previous(i, diff_objects))
.zip(curr.previous(i + 1, diff_objects))
else {
break; break;
}; };
// * scales note 0 to 1 from history to now // * scales note 0 to 1 from history to now
let mut curr_historical_decay = (f64::from(Self::HISTORY_TIME_MAX) let time_decay = (f64::from(Self::HISTORY_TIME_MAX)
- (curr.start_time - curr_obj.start_time)) - (curr.start_time - curr_obj.start_time))
/ f64::from(Self::HISTORY_TIME_MAX); / 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. // * either we're limited by time or limited by object count.
curr_historical_decay = curr_historical_decay let curr_historical_decay = note_decay.min(time_decay);
.min((historical_note_count - i) as f64 / historical_note_count as f64);
let curr_delta = curr_obj.strain_time; let curr_delta = curr_obj.strain_time;
let prev_delta = prev_obj.strain_time; let prev_delta = prev_obj.strain_time;
let last_delta = last_obj.strain_time; let last_delta = last_obj.strain_time;
// * fancy function to calculate rhythmbonuses. // * calculate how much current delta difference deserves a rhythm bonus
let base = (PI / (prev_delta.min(curr_delta) / prev_delta.max(curr_delta))).sin(); // * this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200)
let curr_ratio = 1.0 + 6.0 * base.powf(2.0).min(0.5); 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);
let hit_window = u64::from(!curr_obj.base.is_spinner()) as f64 * hit_window; // 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 mut window_penalty = ((((prev_delta - curr_delta).abs() - hit_window * 0.3) let window_penalty = (((prev_delta - curr_delta).abs() - delta_difference_eps)
.max(0.0)) .max(0.0)
/ (hit_window * 0.3)) / delta_difference_eps)
.min(1.0); .min(1.0);
window_penalty = window_penalty.min(1.0); let mut effective_ratio = window_penalty * curr_ratio * fraction_multiplier;
let mut effective_ratio = window_penalty * curr_ratio;
if first_delta_switch { if first_delta_switch {
// Keep in-sync with lazer // Keep in-sync with lazer
#[allow(clippy::if_not_else)] #[allow(clippy::if_not_else)]
if !(prev_delta > 1.25 * curr_delta || prev_delta * 1.25 < curr_delta) { if (prev_delta - curr_delta).abs() < delta_difference_eps {
if island_size < 7 { // * island is still progressing
// * island is still progressing, count size. island.add_delta(curr_delta as i32);
island_size += 1;
}
} else { } else {
// * bpm change is into slider, this is easy acc window // * bpm change is into slider, this is easy acc window
if curr_obj.base.is_slider() { if curr_obj.base.is_slider() {
@@ -285,55 +299,162 @@ impl RhythmEvaluator {
} }
// * bpm change was from a slider, this is easier typically than circle -> circle // * 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() { if prev_obj.base.is_slider() {
effective_ratio *= 0.25; effective_ratio *= 0.3;
} }
// * repeated island size (ex: triplet -> triplet) // * repeated island polarity (2 -> 4, 3 -> 5)
if prev_island_size == island_size { if island.is_similar_polarity(&prev_island) {
effective_ratio *= 0.25;
}
// * repeated island polartiy (2 -> 4, 3 -> 5)
if prev_island_size % 2 == island_size % 2 {
effective_ratio *= 0.5; effective_ratio *= 0.5;
} }
// * previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. // * previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
if last_delta > prev_delta + 10.0 && prev_delta > curr_delta + 10.0 { if last_delta > prev_delta + delta_difference_eps
&& prev_delta > curr_delta + delta_difference_eps
{
effective_ratio *= 0.125; effective_ratio *= 0.125;
} }
rhythm_complexity_sum += (effective_ratio * start_ratio).sqrt() // * repeated island size (ex: triplet -> triplet)
* curr_historical_decay // * TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
* f64::from(4 + island_size).sqrt() if prev_island.delta_count == island.delta_count {
/ 2.0 effective_ratio *= 0.5;
* f64::from(4 + prev_island_size).sqrt() }
/ 2.0;
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; start_ratio = effective_ratio;
// * log the last island size. prev_island = island;
prev_island_size = island_size;
// * we're slowing down, stop counting // * we're slowing down, stop counting
if prev_delta * 1.25 < curr_delta { if prev_delta + delta_difference_eps < curr_delta {
// * if we're speeding up, this stays true and we keep counting island size. // * if we're speeding up, this stays true and we keep counting island size.
first_delta_switch = false; first_delta_switch = false;
} }
island_size = 1; island =
RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps);
} }
} else if prev_delta > 1.25 * curr_delta { } else if prev_delta > curr_delta + delta_difference_eps {
// * we want to be speeding up. // * we're speeding up.
// * Begin counting island until we change speed again. // * Begin counting island until we change speed again.
first_delta_switch = true; first_delta_switch = true;
// * bpm change is into slider, this is easy acc window
if curr_obj.base.is_slider() {
effective_ratio *= 0.6;
}
// * bpm change was from a slider, this is easier typically than circle -> circle
// * unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
if prev_obj.base.is_slider() {
effective_ratio *= 0.6;
}
start_ratio = effective_ratio; start_ratio = effective_ratio;
island_size = 1;
island = RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps);
}
last_obj = prev_obj;
prev_obj = curr_obj;
} }
} }
// * produces multiplier that can be applied to strain. range [1, infinity) (not really though) // * produces multiplier that can be applied to strain. range [1, infinity) (not really though)
(4.0 + rhythm_complexity_sum * Self::RHYTHM_MULTIPLIER).sqrt() / 2.0 (4.0 + rhythm_complexity_sum * Self::RHYTHM_OVERALL_MULTIPLIER).sqrt() / 2.0
} }
} }
fn logistic(x: f64, max_value: f64, multiplier: f64, offset: f64) -> f64 {
max_value / (1.0 + E.powf(offset - (multiplier * x)))
}
#[derive(Copy, Clone)]
struct RhythmIsland {
delta_difference_eps: f64,
delta: i32,
delta_count: i32,
}
const MIN_DELTA_TIME: i32 = 25;
// Compile-time check in case `OsuDifficultyObject::MIN_DELTA_TIME` changes
// but we forget to update this value.
const _: [(); 0 - !{ MIN_DELTA_TIME - OsuDifficultyObject::MIN_DELTA_TIME as i32 == 0 } as usize] =
[];
impl RhythmIsland {
const fn new(delta_difference_eps: f64) -> Self {
Self {
delta_difference_eps,
delta: 0,
delta_count: 0,
}
}
fn new_with_delta(delta: i32, delta_difference_eps: f64) -> Self {
Self {
delta_difference_eps,
delta: 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,
}
+66 -8
View File
@@ -1,14 +1,24 @@
use crate::{any::difficulty::skills::StrainSkill, util::strains_vec::StrainsVec}; use crate::{any::difficulty::skills::StrainSkill, util::strains_vec::StrainsVec};
#[derive(Clone, Default)] #[derive(Clone)]
pub struct OsuStrainSkill { pub struct OsuStrainSkill {
pub object_strains: Vec<f64>,
pub inner: StrainSkill, 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 { impl OsuStrainSkill {
pub const REDUCED_SECTION_COUNT: usize = 10; pub const REDUCED_SECTION_COUNT: usize = 10;
pub const REDUCED_STRAIN_BASELINE: f64 = 0.75; pub const REDUCED_STRAIN_BASELINE: f64 = 0.75;
pub const DIFFICULTY_MULTIPLER: f64 = 1.06;
pub const DECAY_WEIGHT: f64 = 0.9; pub const DECAY_WEIGHT: f64 = 0.9;
pub const SECTION_LEN: f64 = 400.0; pub const SECTION_LEN: f64 = 400.0;
@@ -21,8 +31,11 @@ impl OsuStrainSkill {
self.inner.start_new_section_from(initial_strain); self.inner.start_new_section_from(initial_strain);
} }
pub fn get_curr_strain_peaks(self) -> StrainsVec { pub fn get_curr_strain_peaks(self) -> UsedOsuStrainSkills<StrainsVec> {
self.inner.get_curr_strain_peaks() UsedOsuStrainSkills {
value: self.inner.get_curr_strain_peaks(),
object_strains: self.object_strains,
}
} }
pub fn difficulty_value( pub fn difficulty_value(
@@ -30,12 +43,14 @@ impl OsuStrainSkill {
reduced_section_count: usize, reduced_section_count: usize,
reduced_strain_baseline: f64, reduced_strain_baseline: f64,
decay_weight: f64, decay_weight: f64,
difficulty_multiplier: f64, ) -> UsedOsuStrainSkills<DifficultyValue> {
) -> f64 {
let mut difficulty = 0.0; let mut difficulty = 0.0;
let mut weight = 1.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); let peaks_iter = peaks.sorted_non_zero_iter_mut().take(reduced_section_count);
@@ -52,10 +67,53 @@ impl OsuStrainSkill {
weight *= decay_weight; 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 { fn lerp(start: f64, end: f64, amount: f64) -> f64 {
start + (end - start) * amount start + (end - start) * amount
} }
pub struct DifficultyValue(f64);
pub struct UsedOsuStrainSkills<T> {
value: T,
object_strains: Vec<f64>,
}
impl UsedOsuStrainSkills<DifficultyValue> {
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<StrainsVec> {
pub fn strains(self) -> StrainsVec {
self.value
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ pub use self::{
convert::OsuBeatmap, convert::OsuBeatmap,
difficulty::gradual::OsuGradualDifficulty, difficulty::gradual::OsuGradualDifficulty,
performance::{gradual::OsuGradualPerformance, OsuPerformance}, performance::{gradual::OsuGradualPerformance, OsuPerformance},
score_state::OsuScoreState, score_state::{OsuScoreOrigin, OsuScoreState},
strains::OsuStrains, strains::OsuStrains,
}; };
+120 -50
View File
@@ -1,5 +1,10 @@
use std::borrow::Cow;
use rosu_map::{ use rosu_map::{
section::hit_objects::{CurveBuffers, SliderEvent, SliderEventType, SliderEventsIter}, section::{
general::GameMode,
hit_objects::{CurveBuffers, SliderEvent, SliderEventType, SliderEventsIter},
},
util::Pos, util::Pos,
}; };
@@ -8,7 +13,7 @@ use crate::{
control_point::{DifficultyPoint, TimingPoint}, control_point::{DifficultyPoint, TimingPoint},
hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner}, hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner},
}, },
util::sort, util::{get_precision_adjusted_beat_len, sort},
}; };
use super::{convert::OsuBeatmap, PLAYFIELD_BASE_SIZE}; 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 // Requires `stack_offset` so we can't add `h.pos` just yet
slider.lazy_end_pos.y = -slider.lazy_end_pos.y; 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
// Since the tail is handled differently but it's not necessarily nested_pos += Pos::new(nested.pos.x, -nested.pos.y);
// the last object, we first search for it, and then handle the nested.pos = nested_pos;
// other nested objects
for nested in nested_iter.by_ref().rev() {
if let NestedSliderObjectKind::Tail = nested.kind {
let mut tail_pos = self.pos; // already reflected at this point
tail_pos += Pos::new(nested.pos.x, -nested.pos.y);
nested.pos = tail_pos;
break;
}
reflect_y(&mut nested.pos.y);
}
for nested in nested_iter {
reflect_y(&mut nested.pos.y);
} }
} }
} }
pub fn finalize_tail(&mut self) { pub fn reflect_horizontally(&mut self) {
fn reflect_x(x: &mut f32) {
*x = PLAYFIELD_BASE_SIZE.x - *x;
}
reflect_x(&mut self.pos.x);
if let OsuObjectKind::Slider(ref mut slider) = self.kind { if let OsuObjectKind::Slider(ref mut slider) = self.kind {
if let Some(tail) = slider.tail_mut() { // Requires `stack_offset` so we can't add `h.pos` just yet
tail.pos += self.pos; slider.lazy_end_pos.x = -slider.lazy_end_pos.x;
for nested in slider.nested_objects.iter_mut() {
let mut nested_pos = self.pos; // already reflected at this point
nested_pos += Pos::new(-nested.pos.x, nested.pos.y);
nested.pos = nested_pos;
}
}
}
pub fn reflect_both_axes(&mut self) {
fn reflect(pos: &mut Pos) {
pos.x = PLAYFIELD_BASE_SIZE.x - pos.x;
pos.y = PLAYFIELD_BASE_SIZE.y - pos.y;
}
reflect(&mut self.pos);
if let OsuObjectKind::Slider(ref mut slider) = self.kind {
// Requires `stack_offset` so we can't add `h.pos` just yet
slider.lazy_end_pos.x = -slider.lazy_end_pos.x;
slider.lazy_end_pos.y = -slider.lazy_end_pos.y;
for nested in slider.nested_objects.iter_mut() {
let mut nested_pos = self.pos; // already reflected at this point
nested_pos += Pos::new(-nested.pos.x, -nested.pos.y);
nested.pos = nested_pos;
}
}
}
pub fn finalize_nested(&mut self) {
if let OsuObjectKind::Slider(ref mut slider) = self.kind {
for nested in slider.nested_objects.iter_mut() {
nested.pos += self.pos;
} }
} }
} }
@@ -120,15 +150,10 @@ impl OsuObject {
self.end_pos() + self.stack_offset self.end_pos() + self.stack_offset
} }
pub fn lazy_travel_time(&self) -> f64 { pub const fn lazy_travel_time(&self) -> f64 {
match self.kind { match self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => 0.0, OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => 0.0,
OsuObjectKind::Slider(ref slider) => slider OsuObjectKind::Slider(ref slider) => slider.lazy_travel_time,
.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),
} }
} }
@@ -155,6 +180,7 @@ pub struct OsuSlider {
pub end_time: f64, pub end_time: f64,
pub lazy_end_pos: Pos, pub lazy_end_pos: Pos,
pub lazy_travel_dist: f32, pub lazy_travel_dist: f32,
pub lazy_travel_time: f64,
pub nested_objects: Vec<NestedSliderObject>, pub nested_objects: Vec<NestedSliderObject>,
} }
@@ -182,13 +208,13 @@ impl OsuSlider {
|point| (point.slider_velocity, point.generate_ticks), |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 span_count = slider.span_count() as f64;
let scoring_dist = let velocity = f64::from(OsuObject::BASE_SCORING_DIST) * slider_multiplier
f64::from(OsuObject::BASE_SCORING_DIST) * slider_multiplier * slider_velocity; / get_precision_adjusted_beat_len(slider_velocity, beat_len);
let velocity = scoring_dist / beat_len; let scoring_dist = velocity * beat_len;
let end_time = start_time + span_count * path.dist() / velocity; let end_time = start_time + span_count * path.dist() / velocity;
@@ -235,21 +261,21 @@ impl OsuSlider {
.filter_map(|e| { .filter_map(|e| {
let obj = match e.kind { let obj = match e.kind {
SliderEventType::Tick => NestedSliderObject { SliderEventType::Tick => NestedSliderObject {
pos: h.pos + path.position_at(e.path_progress), pos: path.position_at(e.path_progress),
start_time: e.time, start_time: e.time,
kind: NestedSliderObjectKind::Tick, kind: NestedSliderObjectKind::Tick,
}, },
SliderEventType::Repeat => NestedSliderObject { 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, start_time: start_time + f64::from(e.span_idx + 1) * span_duration,
kind: NestedSliderObjectKind::Repeat, kind: NestedSliderObjectKind::Repeat,
}, },
SliderEventType::LastTick => NestedSliderObject { SliderEventType::Tail => NestedSliderObject {
pos: end_path_pos, // no `h.pos` yet to keep order of float operations pos: end_path_pos, // no `h.pos` yet to keep order of float operations
start_time: e.time, start_time: e.time,
kind: NestedSliderObjectKind::Tail, kind: NestedSliderObjectKind::Tail,
}, },
SliderEventType::Head | SliderEventType::Tail => return None, SliderEventType::Head | SliderEventType::LastTick => return None,
}; };
Some(obj) Some(obj)
@@ -260,9 +286,8 @@ impl OsuSlider {
a.start_time.total_cmp(&b.start_time) a.start_time.total_cmp(&b.start_time)
}); });
let lazy_travel_time = nested_objects let mut nested = Cow::Borrowed(nested_objects.as_slice());
.last() let lazy_travel_time = OsuSlider::lazy_travel_time(start_time, duration, &mut nested);
.map_or(0.0, |nested| nested.start_time - h.start_time);
let mut end_time_min = lazy_travel_time / span_duration; let mut end_time_min = lazy_travel_time / span_duration;
@@ -278,10 +303,44 @@ impl OsuSlider {
end_time, end_time,
lazy_end_pos, lazy_end_pos,
lazy_travel_dist: 0.0, lazy_travel_dist: 0.0,
lazy_travel_time,
nested_objects, 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 { pub fn repeat_count(&self) -> usize {
self.nested_objects self.nested_objects
.iter() .iter()
@@ -289,6 +348,19 @@ impl OsuSlider {
.count() .count()
} }
/// Counts both ticks and repeats
pub fn tick_count(&self) -> usize {
self.nested_objects
.iter()
.filter(|nested| {
matches!(
nested.kind,
NestedSliderObjectKind::Tick | NestedSliderObjectKind::Repeat
)
})
.count()
}
pub fn tail(&self) -> Option<&NestedSliderObject> { pub fn tail(&self) -> Option<&NestedSliderObject> {
self.nested_objects self.nested_objects
.iter() .iter()
@@ -296,16 +368,9 @@ impl OsuSlider {
// short and fast buzz sliders (/b/1001757) // short and fast buzz sliders (/b/1001757)
.rfind(|nested| matches!(nested.kind, NestedSliderObjectKind::Tail)) .rfind(|nested| matches!(nested.kind, NestedSliderObjectKind::Tail))
} }
fn tail_mut(&mut self) -> Option<&mut NestedSliderObject> {
self.nested_objects
.iter_mut()
// The tail is not necessarily the last nested object, e.g. on very
// short and fast buzz sliders (/b/1001757)
.rfind(|nested| matches!(nested.kind, NestedSliderObjectKind::Tail))
}
} }
#[derive(Clone, Debug)]
pub struct NestedSliderObject { pub struct NestedSliderObject {
pub pos: Pos, pub pos: Pos,
pub start_time: f64, pub start_time: f64,
@@ -316,8 +381,13 @@ impl NestedSliderObject {
pub const fn is_repeat(&self) -> bool { pub const fn is_repeat(&self) -> bool {
matches!(self.kind, NestedSliderObjectKind::Repeat) matches!(self.kind, NestedSliderObjectKind::Repeat)
} }
pub const fn is_tick(&self) -> bool {
matches!(self.kind, NestedSliderObjectKind::Tick)
}
} }
#[derive(Copy, Clone, Debug)]
pub enum NestedSliderObjectKind { pub enum NestedSliderObjectKind {
Repeat, Repeat,
Tail, Tail,
+4 -1
View File
@@ -80,15 +80,17 @@ use super::{OsuPerformanceAttributes, OsuScoreState};
/// [`next`]: OsuGradualPerformance::next /// [`next`]: OsuGradualPerformance::next
/// [`nth`]: OsuGradualPerformance::nth /// [`nth`]: OsuGradualPerformance::nth
pub struct OsuGradualPerformance { pub struct OsuGradualPerformance {
lazer: bool,
difficulty: OsuGradualDifficulty, difficulty: OsuGradualDifficulty,
} }
impl OsuGradualPerformance { impl OsuGradualPerformance {
/// Create a new gradual performance calculator for osu!standard maps. /// Create a new gradual performance calculator for osu!standard maps.
pub fn new(difficulty: Difficulty, converted: &OsuBeatmap<'_>) -> Self { pub fn new(difficulty: Difficulty, converted: &OsuBeatmap<'_>) -> Self {
let lazer = difficulty.get_lazer();
let difficulty = OsuGradualDifficulty::new(difficulty, converted); let difficulty = OsuGradualDifficulty::new(difficulty, converted);
Self { difficulty } Self { lazer, difficulty }
} }
/// Process the next hit object and calculate the performance attributes /// Process the next hit object and calculate the performance attributes
@@ -113,6 +115,7 @@ impl OsuGradualPerformance {
.difficulty .difficulty
.nth(n)? .nth(n)?
.performance() .performance()
.lazer(self.lazer)
.state(state) .state(state)
.difficulty(self.difficulty.difficulty.clone()) .difficulty(self.difficulty.difficulty.clone())
.passed_objects(self.difficulty.idx as u32) .passed_objects(self.difficulty.idx as u32)
+434 -89
View File
@@ -13,7 +13,8 @@ use crate::{
use super::{ use super::{
attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes}, attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes},
score_state::OsuScoreState, difficulty::skills::{flashlight::Flashlight, strain::OsuStrainSkill},
score_state::{OsuScoreOrigin, OsuScoreState},
Osu, Osu,
}; };
@@ -27,6 +28,8 @@ pub struct OsuPerformance<'map> {
pub(crate) difficulty: Difficulty, pub(crate) difficulty: Difficulty,
pub(crate) acc: Option<f64>, pub(crate) acc: Option<f64>,
pub(crate) combo: Option<u32>, pub(crate) combo: Option<u32>,
pub(crate) large_tick_hits: Option<u32>,
pub(crate) slider_end_hits: Option<u32>,
pub(crate) n300: Option<u32>, pub(crate) n300: Option<u32>,
pub(crate) n100: Option<u32>, pub(crate) n100: Option<u32>,
pub(crate) n50: Option<u32>, pub(crate) n50: Option<u32>,
@@ -151,6 +154,45 @@ impl<'map> OsuPerformance<'map> {
self 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. /// Specify the amount of 300s of a play.
pub const fn n300(mut self, n300: u32) -> Self { pub const fn n300(mut self, n300: u32) -> Self {
self.n300 = Some(n300); self.n300 = Some(n300);
@@ -278,6 +320,8 @@ impl<'map> OsuPerformance<'map> {
pub const fn state(mut self, state: OsuScoreState) -> Self { pub const fn state(mut self, state: OsuScoreState) -> Self {
let OsuScoreState { let OsuScoreState {
max_combo, max_combo,
large_tick_hits,
slider_end_hits,
n300, n300,
n100, n100,
n50, n50,
@@ -285,6 +329,8 @@ impl<'map> OsuPerformance<'map> {
} = state; } = state;
self.combo = Some(max_combo); 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.n300 = Some(n300);
self.n100 = Some(n100); self.n100 = Some(n100);
self.n50 = Some(n50); 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 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 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 { 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) { match (self.n300, self.n100, self.n50) {
(Some(_), Some(_), Some(_)) => { (Some(_), Some(_), Some(_)) => {
@@ -348,13 +453,25 @@ impl<'map> OsuPerformance<'map> {
n300 = cmp::min(n300, n_remaining); n300 = cmp::min(n300, n_remaining);
let n_remaining = n_remaining - n300; 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 min_n100 = cmp::min(n_remaining, raw_n100.floor() as u32);
let max_n100 = cmp::min(n_remaining, raw_n100.ceil() as u32); let max_n100 = cmp::min(n_remaining, raw_n100.ceil() as u32);
for new100 in min_n100..=max_n100 { for new100 in min_n100..=max_n100 {
let new50 = n_remaining - new100; 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 { if dist < best_dist {
best_dist = dist; best_dist = dist;
@@ -369,13 +486,25 @@ impl<'map> OsuPerformance<'map> {
n100 = cmp::min(n100, n_remaining); n100 = cmp::min(n100, n_remaining);
let n_remaining = n_remaining - n100; 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 min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32);
let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32); let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32);
for new300 in min_n300..=max_n300 { for new300 in min_n300..=max_n300 {
let new50 = n_remaining - new300; 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 { if curr_dist < best_dist {
best_dist = curr_dist; best_dist = curr_dist;
@@ -390,16 +519,26 @@ impl<'map> OsuPerformance<'map> {
n50 = cmp::min(n50, n_remaining); n50 = cmp::min(n50, n_remaining);
let n_remaining = n_remaining - n50; let n_remaining = n_remaining - n50;
let raw_n300 = (target_total + f64::from(2 * misses + n50) let raw_n300 = (target_total + f64::from(100 * misses + 50 * n50)
- f64::from(2 * n_objects)) - f64::from(100 * n_objects + slider_acc_value))
/ 4.0; / 200.0;
let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32); let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32);
let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32); let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32);
for new300 in min_n300..=max_n300 { for new300 in min_n300..=max_n300 {
let new100 = n_remaining - new300; 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 { if curr_dist < best_dist {
best_dist = curr_dist; best_dist = curr_dist;
@@ -411,18 +550,31 @@ impl<'map> OsuPerformance<'map> {
(None, None, None) => { (None, None, None) => {
let mut best_dist = f64::MAX; 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 min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32);
let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32); let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32);
for new300 in min_n300..=max_n300 { 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 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); let max_n100 = cmp::min(raw_n100.ceil() as u32, n_remaining - new300);
for new100 in min_n100..=max_n100 { for new100 in min_n100..=max_n100 {
let new50 = n_remaining - new300 - new100; 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 { if curr_dist < best_dist {
best_dist = curr_dist; best_dist = curr_dist;
@@ -477,6 +629,8 @@ impl<'map> OsuPerformance<'map> {
}); });
self.combo = Some(max_combo); 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.n300 = Some(n300);
self.n100 = Some(n100); self.n100 = Some(n100);
self.n50 = Some(n50); self.n50 = Some(n50);
@@ -484,6 +638,8 @@ impl<'map> OsuPerformance<'map> {
OsuScoreState { OsuScoreState {
max_combo, max_combo,
large_tick_hits,
slider_end_hits,
n300, n300,
n100, n100,
n50, n50,
@@ -500,14 +656,66 @@ impl<'map> OsuPerformance<'map> {
MapOrAttrs::Attrs(attrs) => attrs, 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 { let inner = OsuPerformanceInner {
attrs, attrs,
mods: self.difficulty.get_mods(), mods,
acc: state.accuracy(), acc,
state, state,
effective_miss_count, effective_miss_count,
using_classic_slider_acc,
}; };
inner.calculate() inner.calculate()
@@ -519,6 +727,8 @@ impl<'map> OsuPerformance<'map> {
difficulty: Difficulty::new(), difficulty: Difficulty::new(),
acc: None, acc: None,
combo: None, combo: None,
large_tick_hits: None,
slider_end_hits: None,
n300: None, n300: None,
n100: None, n100: None,
n50: None, n50: None,
@@ -534,7 +744,8 @@ impl<'map, T: IntoModePerformance<'map, Osu>> From<T> 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> { struct OsuPerformanceInner<'mods> {
attrs: OsuDifficultyAttributes, attrs: OsuDifficultyAttributes,
@@ -542,6 +753,7 @@ struct OsuPerformanceInner<'mods> {
acc: f64, acc: f64,
state: OsuScoreState, state: OsuScoreState,
effective_miss_count: f64, effective_miss_count: f64,
using_classic_slider_acc: bool,
} }
impl OsuPerformanceInner<'_> { impl OsuPerformanceInner<'_> {
@@ -573,8 +785,8 @@ impl OsuPerformanceInner<'_> {
// * this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11) // * 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 { 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(1.8)).max(0.0),
1.0 - (self.attrs.od / 13.33).powf(5.0), (1.0 - (self.attrs.od / 13.33).powf(5.0)).max(0.0),
) )
} else { } else {
(1.0, 1.0) (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 // * 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. // * higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it.
self.effective_miss_count = (self.effective_miss_count self.effective_miss_count = (self.effective_miss_count
+ f64::from(self.state.n100) + f64::from(self.state.n100) * n100_mult
+ n100_mult
+ f64::from(self.state.n50) * n50_mult) + f64::from(self.state.n50) * n50_mult)
.min(total_hits); .min(total_hits);
} }
@@ -613,7 +824,7 @@ impl OsuPerformanceInner<'_> {
} }
fn compute_aim_value(&self) -> f64 { 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(); let total_hits = self.total_hits();
@@ -623,16 +834,13 @@ impl OsuPerformanceInner<'_> {
aim_value *= len_bonus; 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 { if self.effective_miss_count > 0.0 {
aim_value *= 0.97 aim_value *= Self::calculate_miss_penalty(
* (1.0 - (self.effective_miss_count / total_hits).powf(0.775)) self.effective_miss_count,
.powf(self.effective_miss_count); self.attrs.aim_difficult_strain_count,
);
} }
aim_value *= self.get_combo_scaling_factor();
let ar_factor = if self.mods.rx() { let ar_factor = if self.mods.rx() {
0.0 0.0
} else if self.attrs.ar > 10.33 { } else if self.attrs.ar > 10.33 {
@@ -652,7 +860,7 @@ impl OsuPerformanceInner<'_> {
* (0.0016 / (1.0 + 2.0 * self.effective_miss_count)) * (0.0016 / (1.0 + 2.0 * self.effective_miss_count))
* self.acc.powf(16.0)) * self.acc.powf(16.0))
* (1.0 - 0.003 * self.attrs.hp * self.attrs.hp); * (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. // * 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); 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; let estimate_diff_sliders = f64::from(self.attrs.n_sliders) * 0.15;
if self.attrs.n_sliders > 0 { if self.attrs.n_sliders > 0 {
let estimate_slider_ends_dropped = f64::from(cmp::min( let estimate_improperly_followed_difficult_sliders = if self.using_classic_slider_acc {
self.state.n100 + self.state.n50 + self.state.misses, // * 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
self.attrs.max_combo.saturating_sub(self.state.max_combo), let maximum_possible_droppled_sliders = total_imperfect_hits(&self.state);
))
.clamp(0.0, 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)
+ self.attrs.slider_factor;
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_improperly_followed_difficult_sliders / estimate_diff_sliders)
.powf(3.0)
+ self.attrs.slider_factor;
aim_value *= slider_nerf_factor; aim_value *= slider_nerf_factor;
} }
@@ -685,8 +905,7 @@ impl OsuPerformanceInner<'_> {
return 0.0; return 0.0;
} }
let mut speed_value = let mut speed_value = OsuStrainSkill::difficulty_to_performance(self.attrs.speed);
(5.0 * (self.attrs.speed / 0.0675).max(1.0) - 4.0).powf(3.0) / 100_000.0;
let total_hits = self.total_hits(); let total_hits = self.total_hits();
@@ -696,16 +915,13 @@ impl OsuPerformanceInner<'_> {
speed_value *= len_bonus; 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 { if self.effective_miss_count > 0.0 {
speed_value *= 0.97 speed_value *= Self::calculate_miss_penalty(
* (1.0 - (self.effective_miss_count / total_hits).powf(0.775)) self.effective_miss_count,
.powf(self.effective_miss_count.powf(0.875)); self.attrs.speed_difficult_strain_count,
);
} }
speed_value *= self.get_combo_scaling_factor();
let ar_factor = if self.attrs.ar > 10.33 { let ar_factor = if self.attrs.ar > 10.33 {
0.3 * (self.attrs.ar - 10.33) 0.3 * (self.attrs.ar - 10.33)
} else { } else {
@@ -719,7 +935,7 @@ impl OsuPerformanceInner<'_> {
// * Increasing the speed value by object count for Blinds isn't // * Increasing the speed value by object count for Blinds isn't
// * ideal, so the minimum buff is given. // * ideal, so the minimum buff is given.
speed_value *= 1.12; 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. // * We want to give more reward for lower AR when it comes to aim and HD.
// * This nerfs high AR and buffs lower AR. // * This nerfs high AR and buffs lower AR.
speed_value *= 1.0 + 0.04 * (12.0 - self.attrs.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. // * Scale the speed value with accuracy and OD.
speed_value *= (0.95 + self.attrs.od * self.attrs.od / 750.0) 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. // * Scale the speed value with # of 50s to punish doubletapping.
speed_value *= 0.99_f64.powf( speed_value *= 0.99_f64.powf(
@@ -762,22 +978,29 @@ impl OsuPerformanceInner<'_> {
// * This percentage only considers HitCircles of any value - in this part // * This percentage only considers HitCircles of any value - in this part
// * of the calculation we focus on hitting the timing hit window. // * 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 { if !self.using_classic_slider_acc {
let sub = self.state.total_hits() - amount_hit_objects_with_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 { } else {
0.0 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. // * Lots of arbitrary values from testing.
// * Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution. // * Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution.
let mut acc_value = let mut acc_value =
@@ -792,7 +1015,7 @@ impl OsuPerformanceInner<'_> {
// * ideal, so the minimum buff is given. // * ideal, so the minimum buff is given.
if self.mods.bl() { if self.mods.bl() {
acc_value *= 1.14; acc_value *= 1.14;
} else if self.mods.hd() { } else if self.mods.hd() || self.mods.tc() {
acc_value *= 1.08; acc_value *= 1.08;
} }
@@ -808,7 +1031,7 @@ impl OsuPerformanceInner<'_> {
return 0.0; 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(); let total_hits = self.total_hits();
@@ -836,6 +1059,13 @@ impl OsuPerformanceInner<'_> {
flashlight_value 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 { fn get_combo_scaling_factor(&self) -> f64 {
if self.attrs.max_combo == 0 { if self.attrs.max_combo == 0 {
1.0 1.0
@@ -850,34 +1080,62 @@ impl OsuPerformanceInner<'_> {
} }
} }
fn calculate_effective_misses(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> f64 { fn total_imperfect_hits(state: &OsuScoreState) -> f64 {
// * Guess the number of misses + slider breaks from combo f64::from(state.n100 + state.n50 + state.misses)
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);
}
}
// * 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));
combo_based_miss_count.max(f64::from(state.misses))
} }
fn accuracy(n300: u32, n100: u32, n50: u32, misses: u32) -> f64 { const fn n_slider_ends_dropped(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> u32 {
if n300 + n100 + n50 + misses == 0 { attrs.n_sliders - state.slider_end_hits
return 0.0; }
const fn n_slider_tick_miss(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> u32 {
attrs.n_slider_ticks - state.large_tick_hits
}
struct NoComboState {
n300: u32,
n100: u32,
n50: u32,
misses: u32,
large_tick_hits: u32,
slider_end_hits: u32,
}
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;
}
} }
let numerator = 6 * n300 + 2 * n100 + n50; if denominator == 0 {
let denominator = 6 * (n300 + n100 + n50 + misses); 0.0
} else {
f64::from(numerator) / f64::from(denominator) f64::from(numerator) / f64::from(denominator)
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -885,6 +1143,7 @@ mod test {
use std::sync::OnceLock; use std::sync::OnceLock;
use proptest::prelude::*; use proptest::prelude::*;
use rosu_mods::{GameModIntermode, GameModsIntermode};
use crate::{ use crate::{
any::{DifficultyAttributes, PerformanceAttributes}, any::{DifficultyAttributes, PerformanceAttributes},
@@ -897,6 +1156,8 @@ mod test {
static ATTRS: OnceLock<OsuDifficultyAttributes> = OnceLock::new(); static ATTRS: OnceLock<OsuDifficultyAttributes> = OnceLock::new();
const N_OBJECTS: u32 = 601; const N_OBJECTS: u32 = 601;
const N_SLIDERS: u32 = 293;
const N_SLIDER_TICKS: u32 = 15;
fn beatmap() -> Beatmap { fn beatmap() -> Beatmap {
Beatmap::from_path("./resources/2785319.osu").unwrap() Beatmap::from_path("./resources/2785319.osu").unwrap()
@@ -916,6 +1177,8 @@ mod test {
attrs.n_circles + attrs.n_sliders + attrs.n_spinners, attrs.n_circles + attrs.n_sliders + attrs.n_spinners,
N_OBJECTS, N_OBJECTS,
); );
assert_eq!(attrs.n_sliders, N_SLIDERS);
assert_eq!(attrs.n_slider_ticks, N_SLIDER_TICKS);
attrs attrs
}) })
@@ -926,8 +1189,13 @@ mod test {
/// and returns the [`OsuScoreState`] that matches `acc` the best. /// and returns the [`OsuScoreState`] that matches `acc` the best.
/// ///
/// Very slow but accurate. /// Very slow but accurate.
#[allow(clippy::too_many_arguments)]
fn brute_force_best( fn brute_force_best(
lazer: bool,
classic: bool,
acc: f64, acc: f64,
large_tick_hits: Option<u32>,
slider_end_hits: Option<u32>,
n300: Option<u32>, n300: Option<u32>,
n100: Option<u32>, n100: Option<u32>,
n50: Option<u32>, n50: Option<u32>,
@@ -936,8 +1204,41 @@ mod test {
) -> OsuScoreState { ) -> OsuScoreState {
let misses = cmp::min(misses, N_OBJECTS); 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 { let mut best_state = OsuScoreState {
misses, misses,
slider_end_hits,
large_tick_hits,
..Default::default() ..Default::default()
}; };
@@ -973,7 +1274,16 @@ mod test {
None => n_remaining.saturating_sub(new300 + new100), 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(); let curr_dist = (acc - curr_acc).abs();
if curr_dist < best_dist { if curr_dist < best_dist {
@@ -1017,10 +1327,14 @@ mod test {
#[test] #[test]
fn hitresults( fn hitresults(
acc in 0.0..=1.0, lazer in prop::bool::ANY,
n300 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10), classic in prop::bool::ANY,
n100 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10), acc in 0.0_f64..=1.0,
n50 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10), large_tick_hits in prop::option::weighted(0.1, 0_u32..=N_SLIDERS + N_SLIDER_TICKS + 10),
slider_end_hits in prop::option::weighted(0.1, 0_u32..=N_SLIDERS + 10),
n300 in prop::option::weighted(0.1, 0_u32..=N_OBJECTS + 10),
n100 in prop::option::weighted(0.1, 0_u32..=N_OBJECTS + 10),
n50 in prop::option::weighted(0.1, 0_u32..=N_OBJECTS + 10),
n_misses in prop::option::weighted(0.15, 0_u32..=N_OBJECTS + 10), n_misses in prop::option::weighted(0.15, 0_u32..=N_OBJECTS + 10),
best_case in prop::bool::ANY, best_case in prop::bool::ANY,
) { ) {
@@ -1035,8 +1349,23 @@ mod test {
let mut state = OsuPerformance::from(attrs) let mut state = OsuPerformance::from(attrs)
.accuracy(acc * 100.0) .accuracy(acc * 100.0)
.lazer(lazer)
.hitresult_priority(priority); .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 { if let Some(n300) = n300 {
state = state.n300(n300); state = state.n300(n300);
} }
@@ -1058,7 +1387,11 @@ mod test {
assert_eq!(first, state); assert_eq!(first, state);
let mut expected = brute_force_best( let mut expected = brute_force_best(
lazer,
classic,
acc, acc,
large_tick_hits,
slider_end_hits,
n300, n300,
n100, n100,
n50, n50,
@@ -1075,6 +1408,7 @@ mod test {
fn hitresults_n300_n100_misses_best() { fn hitresults_n300_n100_misses_best() {
let state = OsuPerformance::from(attrs()) let state = OsuPerformance::from(attrs())
.combo(500) .combo(500)
.lazer(true)
.n300(300) .n300(300)
.n100(20) .n100(20)
.misses(2) .misses(2)
@@ -1083,6 +1417,8 @@ mod test {
let expected = OsuScoreState { let expected = OsuScoreState {
max_combo: 500, max_combo: 500,
large_tick_hits: N_SLIDER_TICKS,
slider_end_hits: N_SLIDERS,
n300: 300, n300: 300,
n100: 20, n100: 20,
n50: 279, n50: 279,
@@ -1095,6 +1431,7 @@ mod test {
#[test] #[test]
fn hitresults_n300_n50_misses_best() { fn hitresults_n300_n50_misses_best() {
let state = OsuPerformance::from(attrs()) let state = OsuPerformance::from(attrs())
.lazer(false)
.combo(500) .combo(500)
.n300(300) .n300(300)
.n50(10) .n50(10)
@@ -1104,6 +1441,8 @@ mod test {
let expected = OsuScoreState { let expected = OsuScoreState {
max_combo: 500, max_combo: 500,
large_tick_hits: 0,
slider_end_hits: 0,
n300: 300, n300: 300,
n100: 289, n100: 289,
n50: 10, n50: 10,
@@ -1116,6 +1455,7 @@ mod test {
#[test] #[test]
fn hitresults_n50_misses_worst() { fn hitresults_n50_misses_worst() {
let state = OsuPerformance::from(attrs()) let state = OsuPerformance::from(attrs())
.lazer(true)
.combo(500) .combo(500)
.n50(10) .n50(10)
.misses(2) .misses(2)
@@ -1124,6 +1464,8 @@ mod test {
let expected = OsuScoreState { let expected = OsuScoreState {
max_combo: 500, max_combo: 500,
large_tick_hits: N_SLIDER_TICKS,
slider_end_hits: N_SLIDERS,
n300: 0, n300: 0,
n100: 589, n100: 589,
n50: 10, n50: 10,
@@ -1136,6 +1478,7 @@ mod test {
#[test] #[test]
fn hitresults_n300_n100_n50_misses_worst() { fn hitresults_n300_n100_n50_misses_worst() {
let state = OsuPerformance::from(attrs()) let state = OsuPerformance::from(attrs())
.lazer(false)
.combo(500) .combo(500)
.n300(300) .n300(300)
.n100(50) .n100(50)
@@ -1146,6 +1489,8 @@ mod test {
let expected = OsuScoreState { let expected = OsuScoreState {
max_combo: 500, max_combo: 500,
large_tick_hits: 0,
slider_end_hits: 0,
n300: 300, n300: 300,
n100: 50, n100: 50,
n50: 249, n50: 249,
+62 -9
View File
@@ -1,9 +1,22 @@
/// Aggregation for a score's current state. /// Aggregation for a score's current state.
#[derive(Copy, Clone, Debug, PartialEq, Eq)] #[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct OsuScoreState { pub struct OsuScoreState {
/// Maximum combo that the score has had so far. /// Maximum combo that the score has had so far. **Not** the maximum
/// **Not** the maximum possible combo of the map so far. /// possible combo of the map so far.
pub max_combo: u32, 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. /// Amount of current 300s.
pub n300: u32, pub n300: u32,
/// Amount of current 100s. /// Amount of current 100s.
@@ -19,6 +32,8 @@ impl OsuScoreState {
pub const fn new() -> Self { pub const fn new() -> Self {
Self { Self {
max_combo: 0, max_combo: 0,
large_tick_hits: 0,
slider_end_hits: 0,
n300: 0, n300: 0,
n100: 0, n100: 0,
n50: 0, n50: 0,
@@ -32,18 +47,40 @@ impl OsuScoreState {
} }
/// Calculate the accuracy between `0.0` and `1.0` for this state. /// Calculate the accuracy between `0.0` and `1.0` for this state.
pub fn accuracy(&self) -> f64 { pub fn accuracy(&self, origin: OsuScoreOrigin) -> f64 {
let total_hits = self.total_hits(); 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 { match origin {
return 0.0; 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;
}
} }
let numerator = 6 * self.n300 + 2 * self.n100 + self.n50; if denominator == 0 {
let denominator = 6 * total_hits; 0.0
} else {
f64::from(numerator) / f64::from(denominator) f64::from(numerator) / f64::from(denominator)
} }
}
} }
impl Default for OsuScoreState { impl Default for OsuScoreState {
@@ -51,3 +88,19 @@ impl Default for OsuScoreState {
Self::new() 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,
},
}
+8 -1
View File
@@ -12,7 +12,12 @@ pub struct TaikoDifficultyAttributes {
/// The difficulty of the hardest parts of the map. /// The difficulty of the hardest parts of the map.
pub peak: f64, pub peak: f64,
/// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc) /// 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. /// The final star rating.
pub stars: f64, pub stars: f64,
/// The maximum combo. /// The maximum combo.
@@ -55,6 +60,8 @@ pub struct TaikoPerformanceAttributes {
pub pp_difficulty: f64, pub pp_difficulty: f64,
/// Scaled miss count based on total hits. /// Scaled miss count based on total hits.
pub effective_miss_count: f64, pub effective_miss_count: f64,
/// Upper bound on the player's tap deviation.
pub estimated_unstable_rate: Option<f64>,
} }
impl TaikoPerformanceAttributes { impl TaikoPerformanceAttributes {
+16 -21
View File
@@ -1,9 +1,6 @@
use std::cmp; use std::cmp;
use rosu_map::{ use rosu_map::{section::general::GameMode, util::Pos};
section::{general::GameMode, hit_objects::CurveBuffers},
util::Pos,
};
use crate::{ use crate::{
model::{ model::{
@@ -12,7 +9,7 @@ use crate::{
hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner}, hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner},
mode::ConvertStatus, mode::ConvertStatus,
}, },
util::{float_ext::FloatExt, sort::TandemSorter}, util::{float_ext::FloatExt, get_precision_adjusted_beat_len, sort::TandemSorter},
}; };
use super::Taiko; use super::Taiko;
@@ -20,7 +17,7 @@ use super::Taiko;
/// A [`Beatmap`] for [`Taiko`] calculations. /// A [`Beatmap`] for [`Taiko`] calculations.
pub type TaikoBeatmap<'a> = Converted<'a, Taiko>; 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; const OSU_BASE_SCORING_DIST: f32 = 100.0;
pub const fn check_convert(map: &Beatmap) -> ConvertStatus { 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) { fn convert(map: &mut Beatmap) {
map.slider_multiplier *= f64::from(LEGACY_TAIKO_VELOCITY_MULTIPLIER);
let mut new_objects = Vec::new(); let mut new_objects = Vec::new();
let mut new_sounds = 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 { fn should_convert_slider_to_taiko_hits(map: &Beatmap, params: &mut SliderParams<'_>) -> bool {
let SliderParams { let SliderParams {
slider, slider,
bufs,
duration, duration,
start_time, start_time,
tick_spacing, tick_spacing,
} = params; } = params;
let curve = slider.curve(bufs);
// * The true distance, accounting for any repeats. This ends up being the drum roll distance later // * The true distance, accounting for any repeats. This ends up being the drum roll distance later
let spans = slider.span_count() as f64; 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 let timing_beat_len = map
.timing_point_at(*start_time) .timing_point_at(*start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len); .map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let bpm_multiplier = map let slider_velocity = map
.difficulty_point_at(*start_time) .difficulty_point_at(*start_time)
.map_or(DifficultyPoint::DEFAULT_BPM_MULTIPLIER, |point| { .map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
point.bpm_multiplier 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 = let slider_scoring_point_dist = f64::from(OSU_BASE_SCORING_DIST)
f64::from(OSU_BASE_SCORING_DIST) * map.slider_multiplier / map.slider_tick_rate; * (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. // * 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; 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> { struct SliderParams<'c> {
slider: &'c Slider, slider: &'c Slider,
bufs: CurveBuffers,
duration: u32, duration: u32,
start_time: f64, start_time: f64,
tick_spacing: f64, tick_spacing: f64,
} }
impl<'c> SliderParams<'c> { impl<'c> SliderParams<'c> {
fn new(start_time: f64, slider: &'c Slider) -> Self { const fn new(start_time: f64, slider: &'c Slider) -> Self {
Self { Self {
slider, slider,
bufs: CurveBuffers::default(),
start_time, start_time,
duration: 0, duration: 0,
tick_spacing: 0.0, tick_spacing: 0.0,
+26
View File
@@ -5,6 +5,8 @@ use self::{
repeating_hit_patterns::RepeatingHitPatterns, repeating_hit_patterns::RepeatingHitPatterns,
}; };
use super::object::{TaikoDifficultyObject, TaikoDifficultyObjects};
pub mod alternating_mono_pattern; pub mod alternating_mono_pattern;
pub mod mono_streak; pub mod mono_streak;
pub mod preprocessor; pub mod preprocessor;
@@ -16,3 +18,27 @@ pub struct TaikoDifficultyColor {
pub alternating_mono_pattern: Option<Weak<AlternatingMonoPattern>>, pub alternating_mono_pattern: Option<Weak<AlternatingMonoPattern>>,
pub repeating_hit_patterns: Option<RefCount<RepeatingHitPatterns>>, pub repeating_hit_patterns: Option<RefCount<RepeatingHitPatterns>>,
} }
impl TaikoDifficultyColor {
pub fn previous_color_change<'a>(
&self,
hit_objects: &'a TaikoDifficultyObjects,
) -> Option<&'a RefCount<TaikoDifficultyObject>> {
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<TaikoDifficultyObject>> {
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))
}
}
@@ -35,4 +35,8 @@ impl MonoStreak {
pub fn first_hit_object(&self) -> Option<RefCount<TaikoDifficultyObject>> { pub fn first_hit_object(&self) -> Option<RefCount<TaikoDifficultyObject>> {
self.hit_objects.first().and_then(Weak::upgrade) self.hit_objects.first().and_then(Weak::upgrade)
} }
pub fn last_hit_object(&self) -> Option<RefCount<TaikoDifficultyObject>> {
self.hit_objects.last().and_then(Weak::upgrade)
}
} }
+14 -14
View File
@@ -2,7 +2,7 @@ use std::collections::VecDeque;
use crate::{ use crate::{
taiko::difficulty::object::TaikoDifficultyObjects, taiko::difficulty::object::TaikoDifficultyObjects,
util::sync::{Ref, RefCount}, util::sync::{Ref, RefCount, Weak},
}; };
use super::{ use super::{
@@ -17,11 +17,6 @@ impl ColorDifficultyPreprocessor {
let hit_patterns = Self::encode(hit_objects); let hit_patterns = Self::encode(hit_objects);
for repeating_hit_pattern in hit_patterns { 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| { let mono_patterns = Ref::map(repeating_hit_pattern.get(), |repeating| {
repeating.alternating_mono_patterns.as_slice() repeating.alternating_mono_patterns.as_slice()
}); });
@@ -33,11 +28,6 @@ impl ColorDifficultyPreprocessor {
mono_pattern.idx = i; 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| { let mono_streaks = Ref::map(mono_pattern.get(), |alternating| {
alternating.mono_streaks.as_slice() alternating.mono_streaks.as_slice()
}); });
@@ -49,9 +39,19 @@ impl ColorDifficultyPreprocessor {
borrowed.idx = j; borrowed.idx = j;
} }
if let Some(obj) = mono_streak.get().first_hit_object() { for hit_object in mono_streak
obj.get_mut().color.mono_streak = Some(RefCount::downgrade(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));
}
} }
} }
} }
+28 -16
View File
@@ -1,6 +1,7 @@
use std::{cmp, mem, slice::Iter}; use std::{cmp, mem, slice::Iter};
use crate::{ use crate::{
any::difficulty::skills::Skill,
model::{beatmap::HitWindows, hit_object::HitObject}, model::{beatmap::HitWindows, hit_object::HitObject},
taiko::TaikoBeatmap, taiko::TaikoBeatmap,
util::sync::RefCount, util::sync::RefCount,
@@ -9,7 +10,7 @@ use crate::{
use super::{ use super::{
object::{TaikoDifficultyObject, TaikoDifficultyObjects}, object::{TaikoDifficultyObject, TaikoDifficultyObjects},
skills::peaks::{Peaks, PeaksSkill}, skills::TaikoSkills,
DifficultyValues, TaikoDifficultyAttributes, DifficultyValues, TaikoDifficultyAttributes,
}; };
@@ -53,7 +54,7 @@ pub struct TaikoGradualDifficulty {
attrs: TaikoDifficultyAttributes, attrs: TaikoDifficultyAttributes,
diff_objects: TaikoDifficultyObjects, diff_objects: TaikoDifficultyObjects,
diff_objects_iter: Iter<'static, RefCount<TaikoDifficultyObject>>, diff_objects_iter: Iter<'static, RefCount<TaikoDifficultyObject>>,
peaks: Peaks, skills: TaikoSkills,
total_hits: usize, total_hits: usize,
first_combos: FirstTwoCombos, first_combos: FirstTwoCombos,
} }
@@ -82,8 +83,11 @@ impl TaikoGradualDifficulty {
(Some(true), Some(true)) => FirstTwoCombos::Both, (Some(true), Some(true)) => FirstTwoCombos::Both,
}; };
let HitWindows { od: hit_window, .. } = let HitWindows {
converted.attributes().difficulty(&difficulty).hit_windows(); od_great,
od_ok,
ar: _,
} = converted.attributes().difficulty(&difficulty).hit_windows();
let mut n_diff_objects = 0; let mut n_diff_objects = 0;
let mut max_combo = 0; let mut max_combo = 0;
@@ -96,10 +100,11 @@ impl TaikoGradualDifficulty {
&mut n_diff_objects, &mut n_diff_objects,
); );
let peaks = Peaks::new(); let skills = TaikoSkills::new();
let attrs = TaikoDifficultyAttributes { let attrs = TaikoDifficultyAttributes {
hit_window, great_hit_window: od_great,
ok_hit_window: od_ok.unwrap_or(0.0),
is_convert: converted.is_convert, is_convert: converted.is_convert,
..Default::default() ..Default::default()
}; };
@@ -117,7 +122,7 @@ impl TaikoGradualDifficulty {
difficulty, difficulty,
diff_objects, diff_objects,
diff_objects_iter, diff_objects_iter,
peaks, skills,
attrs, attrs,
total_hits, total_hits,
first_combos, first_combos,
@@ -144,7 +149,12 @@ impl Iterator for TaikoGradualDifficulty {
loop { loop {
let curr = self.diff_objects_iter.next()?; let curr = self.diff_objects_iter.next()?;
let borrowed = curr.get(); 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() { if borrowed.base_hit_type.is_hit() {
self.attrs.max_combo += 1; self.attrs.max_combo += 1;
@@ -166,14 +176,9 @@ impl Iterator for TaikoGradualDifficulty {
self.idx += 1; 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(); let mut attrs = self.attrs.clone();
DifficultyValues::eval(&mut attrs, color, rhythm, stamina, combined); DifficultyValues::eval(&mut attrs, self.skills.clone());
Some(attrs) 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 { for _ in 0..take {
loop { loop {
let curr = self.diff_objects_iter.next()?; let curr = self.diff_objects_iter.next()?;
let borrowed = curr.get(); 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() { if borrowed.base_hit_type.is_hit() {
self.attrs.max_combo += 1; self.attrs.max_combo += 1;
+98 -44
View File
@@ -1,16 +1,19 @@
use std::cmp;
use crate::{ use crate::{
any::difficulty::skills::Skill,
model::beatmap::HitWindows,
taiko::{ taiko::{
difficulty::{ difficulty::{
color::preprocessor::ColorDifficultyPreprocessor, color::preprocessor::ColorDifficultyPreprocessor,
object::{TaikoDifficultyObject, TaikoDifficultyObjects}, object::{TaikoDifficultyObject, TaikoDifficultyObjects},
skills::peaks::PeaksSkill,
}, },
object::TaikoObject, object::TaikoObject,
}, },
Difficulty, Difficulty,
}; };
use self::skills::peaks::Peaks; use self::skills::{color::Color, rhythm::Rhythm, stamina::Stamina, TaikoSkills};
use super::{attributes::TaikoDifficultyAttributes, convert::TaikoBeatmap}; use super::{attributes::TaikoDifficultyAttributes, convert::TaikoBeatmap};
@@ -20,43 +23,87 @@ mod object;
mod rhythm; mod rhythm;
mod skills; 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( pub fn difficulty(
difficulty: &Difficulty, difficulty: &Difficulty,
converted: &TaikoBeatmap<'_>, converted: &TaikoBeatmap<'_>,
) -> TaikoDifficultyAttributes { ) -> TaikoDifficultyAttributes {
let hit_window = converted let HitWindows {
.attributes() od_great,
.difficulty(difficulty) od_ok,
.hit_windows() ar: _,
.od; } = 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 { let mut attrs = TaikoDifficultyAttributes {
hit_window, great_hit_window: od_great,
ok_hit_window: od_ok.unwrap_or(0.0),
max_combo, max_combo,
is_convert: converted.is_convert, is_convert: converted.is_convert,
..Default::default() ..Default::default()
}; };
let color_rating = peaks.color_difficulty_value(); DifficultyValues::eval(&mut attrs, skills);
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,
);
attrs 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 { fn rescale(stars: f64) -> f64 {
if stars < 0.0 { if stars < 0.0 {
stars stars
@@ -66,7 +113,7 @@ fn rescale(stars: f64) -> f64 {
} }
pub struct DifficultyValues { pub struct DifficultyValues {
pub peaks: Peaks, pub skills: TaikoSkills,
pub max_combo: u32, pub max_combo: u32,
} }
@@ -89,42 +136,48 @@ impl DifficultyValues {
// The first two hit objects have no difficulty object // The first two hit objects have no difficulty object
n_diff_objects = n_diff_objects.saturating_sub(2); 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) { 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( pub fn eval(attrs: &mut TaikoDifficultyAttributes, skills: TaikoSkills) {
attrs: &mut TaikoDifficultyAttributes, let color_rating = skills.color.as_difficulty_value() * COLOR_SKILL_MULTIPLIER;
color_difficulty_value: f64, let rhythm_rating = skills.rhythm.as_difficulty_value() * RHYTHM_SKILL_MULTIPLIER;
rhythm_difficulty_value: f64, let stamina_rating = skills.stamina.as_difficulty_value() * STAMINA_SKILL_MULTIPLIER;
stamina_difficulty_value: f64, let mono_stamina_rating =
peaks_difficulty_value: f64, skills.single_color_stamina.as_difficulty_value() * STAMINA_SKILL_MULTIPLIER;
) { let mono_stamina_factor = if stamina_rating.abs() >= f64::EPSILON {
let color_rating = color_difficulty_value * DIFFICULTY_MULTIPLIER; (mono_stamina_rating / stamina_rating).powf(5.0)
let rhythm_rating = rhythm_difficulty_value * DIFFICULTY_MULTIPLIER; } else {
let stamina_rating = stamina_difficulty_value * DIFFICULTY_MULTIPLIER; 1.0
let combined_rating = peaks_difficulty_value * DIFFICULTY_MULTIPLIER; };
let combined_rating =
combined_difficulty_value(skills.color, skills.rhythm, skills.stamina);
let mut star_rating = rescale(combined_rating * 1.4); let mut star_rating = rescale(combined_rating * 1.4);
// * TODO: This is temporary measure as we don't detect abuse of multiple-input // * TODO: This is temporary measure as we don't detect abuse of multiple-input playstyles of converts within the current system.
// * playstyles of converts within the current system.
if attrs.is_convert { if attrs.is_convert {
star_rating *= 0.925; 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 { 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.rhythm = rhythm_rating;
attrs.color = color_rating; attrs.color = color_rating;
attrs.peak = combined_rating; attrs.peak = combined_rating;
attrs.mono_stamina_factor = mono_stamina_factor;
attrs.stars = star_rating; attrs.stars = star_rating;
} }
+17 -3
View File
@@ -132,15 +132,23 @@ impl TaikoDifficultyObjects {
} }
} }
pub fn previous_note( pub fn previous_note<'a>(
&self, &'a self,
curr: &TaikoDifficultyObject, curr: &TaikoDifficultyObject,
backwards_idx: usize, backwards_idx: usize,
) -> Option<&RefCount<TaikoDifficultyObject>> { ) -> Option<&'a RefCount<TaikoDifficultyObject>> {
curr.note_idx curr.note_idx
.checked_sub(backwards_idx + 1) .checked_sub(backwards_idx + 1)
.and_then(|idx| self.note_objects.get(idx)) .and_then(|idx| self.note_objects.get(idx))
} }
pub fn next_note<'a>(
&'a self,
curr: &TaikoDifficultyObject,
forwards_idx: usize,
) -> Option<&'a RefCount<TaikoDifficultyObject>> {
self.note_objects.get(curr.note_idx + (forwards_idx + 1))
}
} }
#[rustfmt::skip] #[rustfmt::skip]
@@ -180,3 +188,9 @@ impl IDifficultyObject for TaikoDifficultyObject {
self.idx self.idx
} }
} }
impl PartialEq for TaikoDifficultyObject {
fn eq(&self, other: &Self) -> bool {
self.idx == other.idx
}
}
+14 -1
View File
@@ -154,20 +154,33 @@ impl ColorEvaluator {
let mut difficulty = 0.0; let mut difficulty = 0.0;
if let Some(mono_streak) = color.mono_streak.as_ref().and_then(Weak::upgrade) { if let Some(mono_streak) = color.mono_streak.as_ref().and_then(Weak::upgrade) {
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); difficulty += Self::evaluate_diff_of_mono_streak(&mono_streak);
} }
}
}
if let Some(alternating_mono_pattern) = color if let Some(alternating_mono_pattern) = color
.alternating_mono_pattern .alternating_mono_pattern
.as_ref() .as_ref()
.and_then(Weak::upgrade) .and_then(Weak::upgrade)
{ {
if let Some(first_hit_object) = alternating_mono_pattern.get().first_hit_object() {
if &*first_hit_object.get() == hit_object {
difficulty += difficulty +=
Self::evaluate_diff_of_alternating_mono_pattern(&alternating_mono_pattern); Self::evaluate_diff_of_alternating_mono_pattern(&alternating_mono_pattern);
} }
}
}
if let Some(repeating_hit_patterns) = color.repeating_hit_patterns.as_ref() { 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 difficulty
+21 -1
View File
@@ -1,4 +1,24 @@
use self::{color::Color, rhythm::Rhythm, stamina::Stamina};
pub mod color; pub mod color;
pub mod peaks;
pub mod rhythm; pub mod rhythm;
pub mod stamina; 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),
}
}
}
-114
View File
@@ -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<Item = f64>) -> 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);
}
}
+77 -21
View File
@@ -1,24 +1,34 @@
use crate::{ use crate::{
any::difficulty::{ any::difficulty::{
object::IDifficultyObject, object::IDifficultyObject,
skills::{strain_decay, ISkill, Skill, StrainDecaySkill}, skills::{strain_decay, ISkill, Skill, StrainDecaySkill, StrainSkill},
}, },
taiko::{ taiko::{
difficulty::object::{TaikoDifficultyObject, TaikoDifficultyObjects}, difficulty::object::{TaikoDifficultyObject, TaikoDifficultyObjects},
object::HitType, object::HitType,
}, },
util::strains_vec::StrainsVec, util::{strains_vec::StrainsVec, sync::Weak},
}; };
const SKILL_MULTIPLIER: f64 = 1.1; const SKILL_MULTIPLIER: f64 = 1.1;
const STRAIN_DECAY_BASE: f64 = 0.4; const STRAIN_DECAY_BASE: f64 = 0.4;
#[derive(Clone, Default)] #[derive(Clone)]
pub struct Stamina { pub struct Stamina {
inner: StrainDecaySkill, inner: StrainSkill,
single_color: bool,
curr_strain: f64,
} }
impl Stamina { 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 { pub fn get_curr_strain_peaks(self) -> StrainsVec {
self.inner.get_curr_strain_peaks() self.inner.get_curr_strain_peaks()
} }
@@ -36,6 +46,10 @@ impl ISkill for Stamina {
impl Skill<'_, Stamina> { impl Skill<'_, Stamina> {
fn calculate_initial_strain(&mut self, time: f64, curr: &TaikoDifficultyObject) -> f64 { fn calculate_initial_strain(&mut self, time: f64, curr: &TaikoDifficultyObject) -> f64 {
if self.inner.single_color {
return 0.0;
}
let prev_start_time = curr let prev_start_time = curr
.previous(0, &self.diff_objects.objects) .previous(0, &self.diff_objects.objects)
.map_or(0.0, |prev| prev.get().start_time); .map_or(0.0, |prev| prev.get().start_time);
@@ -44,27 +58,27 @@ impl Skill<'_, Stamina> {
} }
const fn curr_strain(&self) -> f64 { const fn curr_strain(&self) -> f64 {
self.inner.inner.curr_strain self.inner.curr_strain
} }
fn curr_strain_mut(&mut self) -> &mut f64 { 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 { 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 { 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 { 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 { 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) { pub fn process(&mut self, curr: &TaikoDifficultyObject) {
@@ -86,13 +100,30 @@ impl Skill<'_, Stamina> {
fn strain_value_at(&mut self, curr: &TaikoDifficultyObject) -> f64 { 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() *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE);
*self.curr_strain_mut() += self.strain_value_of(curr) * SKILL_MULTIPLIER; *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() self.curr_strain()
} }
fn strain_value_of(&self, curr: &TaikoDifficultyObject) -> f64 {
StaminaEvaluator::evaluate_diff_of(curr, self.diff_objects)
} }
} }
@@ -100,28 +131,53 @@ struct StaminaEvaluator;
impl StaminaEvaluator { impl StaminaEvaluator {
fn speed_bonus(mut interval: f64) -> f64 { fn speed_bonus(mut interval: f64) -> f64 {
// * Cap to 600bpm 1/4, 25ms note interval, 50ms key interval // * Interval is capped at a very small value to prevent infinite values.
// * Interval will be capped at a very small value to avoid infinite/negative speed bonuses. interval = interval.max(1.0);
// * TODO - This is a temporary measure as we need to implement methods of detecting playstyle-abuse of SpeedBonus.
interval = interval.max(50.0);
30.0 / interval 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 { fn evaluate_diff_of(curr: &TaikoDifficultyObject, hit_objects: &TaikoDifficultyObjects) -> f64 {
if matches!(curr.base_hit_type, HitType::NonHit) { if matches!(curr.base_hit_type, HitType::NonHit) {
return 0.0; 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 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 { if let Some(key_prev) = key_prev {
// * Add a base strain to all objects // * Add a base strain to all objects
0.5 + Self::speed_bonus(taiko_curr.start_time - key_prev.get().start_time) 0.5 + Self::speed_bonus(taiko_curr.start_time - key_prev.get().start_time)
} else { } 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 0.0
} }
} }
+102 -34
View File
@@ -4,7 +4,7 @@ use crate::{
any::{Difficulty, HitResultPriority, IntoModePerformance, IntoPerformance}, any::{Difficulty, HitResultPriority, IntoModePerformance, IntoPerformance},
model::mods::GameMods, model::mods::GameMods,
osu::OsuPerformance, osu::OsuPerformance,
util::map_or_attrs::MapOrAttrs, util::{map_or_attrs::MapOrAttrs, special_functions},
Performance, Performance,
}; };
@@ -364,6 +364,8 @@ impl<'map> TryFrom<OsuPerformance<'map>> for TaikoPerformance<'map> {
difficulty, difficulty,
acc, acc,
combo, combo,
large_tick_hits: _,
slider_end_hits: _,
n300, n300,
n100, n100,
n50: _, n50: _,
@@ -402,6 +404,10 @@ impl TaikoPerformanceInner<'_> {
// * and increasing the miss penalty for shorter object counts lower than 1000. // * and increasing the miss penalty for shorter object counts lower than 1000.
let total_successful_hits = self.total_successful_hits(); 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 { let effective_miss_count = if total_successful_hits > 0 {
(1000.0 / f64::from(total_successful_hits)).max(1.0) * f64::from(self.state.misses) (1000.0 / f64::from(total_successful_hits)).max(1.0) * f64::from(self.state.misses)
} else { } else {
@@ -410,16 +416,17 @@ impl TaikoPerformanceInner<'_> {
let mut multiplier = 1.13; let mut multiplier = 1.13;
if self.mods.hd() { if self.mods.hd() && !self.attrs.is_convert {
multiplier *= 1.075; multiplier *= 1.075;
} }
if self.mods.ez() { if self.mods.ez() {
multiplier *= 0.975; multiplier *= 0.95;
} }
let diff_value = self.compute_difficulty_value(effective_miss_count); let diff_value =
let acc_value = self.compute_accuracy_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; 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_acc: acc_value,
pp_difficulty: diff_value, pp_difficulty: diff_value,
effective_miss_count, 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>,
) -> f64 {
let Some(estimated_unstable_rate) = estimated_unstable_rate else {
return 0.0;
};
let attrs = &self.attrs; let attrs = &self.attrs;
let exp_base = 5.0 * (attrs.stars / 0.115).max(1.0) - 4.0; 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; 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); diff_value *= 0.986_f64.powf(effective_miss_count);
if self.mods.ez() { if self.mods.ez() {
diff_value *= 0.985; diff_value *= 0.9;
} }
if self.mods.hd() { if self.mods.hd() {
@@ -451,39 +467,104 @@ impl TaikoPerformanceInner<'_> {
} }
if self.mods.hr() { if self.mods.hr() {
diff_value *= 1.05; diff_value *= 1.10;
} }
if self.mods.fl() { 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 { fn compute_accuracy_value(&self, estimated_unstable_rate: Option<f64>) -> f64 {
if self.attrs.hit_window <= 0.0 { if self.attrs.great_hit_window <= 0.0 {
return 0.0; return 0.0;
} }
let mut acc_value = (60.0 / self.attrs.hit_window).powf(1.1) let Some(estimated_unstable_rate) = estimated_unstable_rate else {
* self.custom_accuracy().powf(8.0) return 0.0;
* self.attrs.stars.powf(0.4) };
* 27.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); 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 // * Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values.
if self.mods.hd() && self.mods.fl() { if self.mods.hd() && self.mods.fl() && !self.attrs.is_convert {
acc_value *= (1.075 * len_bonus).max(1.05); acc_value *= (1.05 * len_bonus).max(1.0);
} }
acc_value 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<f64> {
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 { const fn total_hits(&self) -> f64 {
self.state.total_hits() as f64 self.state.total_hits() as f64
} }
@@ -491,19 +572,6 @@ impl TaikoPerformanceInner<'_> {
const fn total_successful_hits(&self) -> u32 { const fn total_successful_hits(&self) -> u32 {
self.state.n300 + self.state.n100 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 { fn accuracy(n300: u32, n100: u32, misses: u32) -> f64 {
+3 -3
View File
@@ -24,8 +24,8 @@ pub fn strains(difficulty: &Difficulty, converted: &TaikoBeatmap<'_>) -> TaikoSt
let values = DifficultyValues::calculate(difficulty, converted); let values = DifficultyValues::calculate(difficulty, converted);
TaikoStrains { TaikoStrains {
color: values.peaks.color.get_curr_strain_peaks().into_vec(), color: values.skills.color.get_curr_strain_peaks().into_vec(),
rhythm: values.peaks.rhythm.get_curr_strain_peaks().into_vec(), rhythm: values.skills.rhythm.get_curr_strain_peaks().into_vec(),
stamina: values.peaks.stamina.get_curr_strain_peaks().into_vec(), stamina: values.skills.stamina.get_curr_strain_peaks().into_vec(),
} }
} }
+13
View File
@@ -4,5 +4,18 @@ pub mod limited_queue;
pub mod map_or_attrs; pub mod map_or_attrs;
pub mod random; pub mod random;
pub mod sort; pub mod sort;
pub mod special_functions;
pub mod strains_vec; pub mod strains_vec;
pub mod sync; 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
}
+1 -36
View File
@@ -26,7 +26,6 @@ macro_rules! new_fn {
impl TandemSorter { impl TandemSorter {
new_fn!(new_stable: <[_]>::sort_by); new_fn!(new_stable: <[_]>::sort_by);
new_fn!(new_unstable: super::csharp);
/// Sort the given slice based on the internal ordering. /// Sort the given slice based on the internal ordering.
pub fn sort<T>(&mut self, slice: &mut [T]) { pub fn sort<T>(&mut self, slice: &mut [T]) {
@@ -59,35 +58,6 @@ impl TandemSorter {
self.should_reset = true; self.should_reset = true;
} }
/// Unsort the given slice based on the internal ordering.
pub fn unsort<T>(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) { fn toggle_marks(&mut self) {
for idx in self.indices.iter_mut() { for idx in self.indices.iter_mut() {
*idx = Self::toggle_mark_idx(*idx); *idx = Self::toggle_mark_idx(*idx);
@@ -119,15 +89,10 @@ mod tests {
let mut expected_sorted = actual.clone(); let mut expected_sorted = actual.clone();
expected_sorted.sort_unstable(); expected_sorted.sort_unstable();
let expected_unsorted = actual.clone(); let mut sorter = TandemSorter::new_stable(&actual, u8::cmp);
let mut sorter = TandemSorter::new_unstable(&actual, u8::cmp);
sorter.sort(&mut actual); sorter.sort(&mut actual);
assert_eq!(actual, expected_sorted); assert_eq!(actual, expected_sorted);
sorter.unsort(&mut actual);
assert_eq!(actual, expected_unsorted);
} }
} }
} }
+285
View File
@@ -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)
}
+4
View File
@@ -6,8 +6,10 @@ pub use inner::*;
mod inner { mod inner {
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
#[repr(transparent)]
pub struct RefCount<T>(pub(super) Rc<RefCell<T>>); pub struct RefCount<T>(pub(super) Rc<RefCell<T>>);
#[repr(transparent)]
pub struct Weak<T>(pub(super) std::rc::Weak<RefCell<T>>); pub struct Weak<T>(pub(super) std::rc::Weak<RefCell<T>>);
pub type Ref<'a, T> = std::cell::Ref<'a, T>; pub type Ref<'a, T> = std::cell::Ref<'a, T>;
@@ -45,8 +47,10 @@ mod inner {
sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard},
}; };
#[repr(transparent)]
pub struct RefCount<T>(pub(super) Arc<RwLock<T>>); pub struct RefCount<T>(pub(super) Arc<RwLock<T>>);
#[repr(transparent)]
pub struct Weak<T>(pub(super) std::sync::Weak<RwLock<T>>); pub struct Weak<T>(pub(super) std::sync::Weak<RwLock<T>>);
pub struct Ref<'a, T: ?Sized>(RwLockReadGuard<'a, T>); pub struct Ref<'a, T: ?Sized>(RwLockReadGuard<'a, T>);
+182 -117
View File
@@ -35,11 +35,14 @@ macro_rules! test_cases {
flashlight: $flashlight:literal, flashlight: $flashlight:literal,
slider_factor: $slider_factor:literal, slider_factor: $slider_factor:literal,
speed_note_count: $speed_note_count: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, ar: $ar:literal,
od: $od:literal, od: $od:literal,
hp: $hp:literal, hp: $hp:literal,
n_circles: $n_circles:literal, n_circles: $n_circles:literal,
n_sliders: $n_sliders:literal, n_sliders: $n_sliders:literal,
n_slider_ticks: $n_slider_ticks:literal,
n_spinners: $n_spinners:literal, n_spinners: $n_spinners:literal,
stars: $stars:literal, stars: $stars:literal,
max_combo: $max_combo:literal, max_combo: $max_combo:literal,
@@ -50,11 +53,14 @@ macro_rules! test_cases {
flashlight: $flashlight, flashlight: $flashlight,
slider_factor: $slider_factor, slider_factor: $slider_factor,
speed_note_count: $speed_note_count, speed_note_count: $speed_note_count,
aim_difficult_strain_count: $aim_difficult_strain_count,
speed_difficult_strain_count: $speed_difficult_strain_count,
ar: $ar, ar: $ar,
od: $od, od: $od,
hp: $hp, hp: $hp,
n_circles: $n_circles, n_circles: $n_circles,
n_sliders: $n_sliders, n_sliders: $n_sliders,
n_slider_ticks: $n_slider_ticks,
n_spinners: $n_spinners, n_spinners: $n_spinners,
stars: $stars, stars: $stars,
max_combo: $max_combo, max_combo: $max_combo,
@@ -65,7 +71,9 @@ macro_rules! test_cases {
rhythm: $rhythm:literal, rhythm: $rhythm:literal,
color: $color:literal, color: $color:literal,
peak: $peak: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, stars: $stars:literal,
max_combo: $max_combo:literal, max_combo: $max_combo:literal,
is_convert: $is_convert:literal, is_convert: $is_convert:literal,
@@ -75,7 +83,9 @@ macro_rules! test_cases {
rhythm: $rhythm, rhythm: $rhythm,
color: $color, color: $color,
peak: $peak, 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, stars: $stars,
max_combo: $max_combo, max_combo: $max_combo,
is_convert: $is_convert, is_convert: $is_convert,
@@ -102,6 +112,7 @@ macro_rules! test_cases {
stars: $stars:literal, stars: $stars:literal,
hit_window: $hit_window:literal, hit_window: $hit_window:literal,
n_objects: $n_objects:literal, n_objects: $n_objects:literal,
n_hold_notes: $n_hold_notes:literal,
max_combo: $max_combo:literal, max_combo: $max_combo:literal,
is_convert: $is_convert:literal, is_convert: $is_convert:literal,
}) => { }) => {
@@ -109,6 +120,7 @@ macro_rules! test_cases {
stars: $stars, stars: $stars,
hit_window: $hit_window, hit_window: $hit_window,
n_objects: $n_objects, n_objects: $n_objects,
n_hold_notes: $n_hold_notes,
max_combo: $max_combo, max_combo: $max_combo,
is_convert: $is_convert, is_convert: $is_convert,
} }
@@ -121,93 +133,111 @@ fn basic_osu() {
test_cases! { test_cases! {
Osu: OSU { Osu: OSU {
NM => { NM => {
aim: 2.8693628443424104, aim: 2.881184366758021,
speed: 2.533869745015772, speed: 2.468469273849314,
flashlight: 2.288770487900865, flashlight: 2.287888783550428,
slider_factor: 0.9803052946037858, slider_factor: 0.9803293523973865,
speed_note_count: 210.36373973116545, speed_note_count: 204.88794724609374,
aim_difficult_strain_count: 106.63833474488378,
speed_difficult_strain_count: 79.9883004295862,
ar: 9.300000190734863, ar: 9.300000190734863,
od: 8.800000190734863, od: 8.800000190734863,
hp: 5.0, hp: 5.0,
n_circles: 307, n_circles: 307,
n_sliders: 293, n_sliders: 293,
n_slider_ticks: 15,
n_spinners: 1, n_spinners: 1,
stars: 5.669858729379628, stars: 5.643619989739299,
max_combo: 909, max_combo: 909,
}; };
HD => { HD => {
aim: 2.8693628443424104, aim: 2.881184366758021,
speed: 2.533869745015772, speed: 2.468469273849314,
flashlight: 2.606877929965889, flashlight: 2.605859779358901,
slider_factor: 0.9803052946037858, slider_factor: 0.9803293523973865,
speed_note_count: 210.36373973116545, speed_note_count: 204.88794724609374,
aim_difficult_strain_count: 106.63833474488378,
speed_difficult_strain_count: 79.9883004295862,
ar: 9.300000190734863, ar: 9.300000190734863,
od: 8.800000190734863, od: 8.800000190734863,
hp: 5.0, hp: 5.0,
n_circles: 307, n_circles: 307,
n_sliders: 293, n_sliders: 293,
n_slider_ticks: 15,
n_spinners: 1, n_spinners: 1,
stars: 5.669858729379628, stars: 5.643619989739299,
max_combo: 909, max_combo: 909,
}; };
HR => { HR => {
aim: 3.2385394176190507, aim: 3.2515300463985666,
speed: 2.7009854505234308, speed: 2.6323568908654615,
flashlight: 2.8549217213059936, flashlight: 2.853761577136605,
slider_factor: 0.9690667605258665, slider_factor: 0.969089944826546,
speed_note_count: 184.01205359079387, speed_note_count: 178.52041495886283,
aim_difficult_strain_count: 108.03970474535397,
speed_difficult_strain_count: 73.27713411796513,
ar: 10.0, ar: 10.0,
od: 10.0, od: 10.0,
hp: 7.0, hp: 7.0,
n_circles: 307, n_circles: 307,
n_sliders: 293, n_sliders: 293,
n_slider_ticks: 15,
n_spinners: 1, n_spinners: 1,
stars: 6.263576582906263, stars: 6.243301253337941,
max_combo: 909, max_combo: 909,
}; };
DT => { DT => {
aim: 4.041442573946681, aim: 4.058080039906945,
speed: 3.6784866216272474, speed: 3.570932204630734,
flashlight: 3.319522943625448, flashlight: 3.318209122186825,
slider_factor: 0.9776943279272041, slider_factor: 0.9777224379583133,
speed_note_count: 214.80421464205617, speed_note_count: 211.29204189490912,
aim_difficult_strain_count: 126.9561362975524,
speed_difficult_strain_count: 95.63810649133869,
ar: 10.53333346048991, ar: 10.53333346048991,
od: 10.311111238267687, od: 10.311111238267687,
hp: 5.0, hp: 5.0,
n_circles: 307, n_circles: 307,
n_sliders: 293, n_sliders: 293,
n_slider_ticks: 15,
n_spinners: 1, n_spinners: 1,
stars: 8.085307648397622, stars: 8.030649319285482,
max_combo: 909, max_combo: 909,
}; };
FL => { FL => {
aim: 2.8693628443424104, aim: 2.881184366758021,
speed: 2.533869745015772, speed: 2.468469273849314,
flashlight: 2.288770487900865, flashlight: 2.287888783550428,
slider_factor: 0.9803052946037858, slider_factor: 0.9803293523973865,
speed_note_count: 210.36373973116545, speed_note_count: 204.88794724609374,
aim_difficult_strain_count: 106.63833474488378,
speed_difficult_strain_count: 79.9883004295862,
ar: 9.300000190734863, ar: 9.300000190734863,
od: 8.800000190734863, od: 8.800000190734863,
hp: 5.0, hp: 5.0,
n_circles: 307, n_circles: 307,
n_sliders: 293, n_sliders: 293,
n_slider_ticks: 15,
n_spinners: 1, n_spinners: 1,
stars: 6.8667780753884236, stars: 6.858771801534423,
max_combo: 909, max_combo: 909,
}; };
HD FL => { HD FL => {
aim: 2.8693628443424104, aim: 2.881184366758021,
speed: 2.533869745015772, speed: 2.468469273849314,
flashlight: 2.606877929965889, flashlight: 2.605859779358901,
slider_factor: 0.9803052946037858, slider_factor: 0.9803293523973865,
speed_note_count: 210.36373973116545, speed_note_count: 204.88794724609374,
aim_difficult_strain_count: 106.63833474488378,
speed_difficult_strain_count: 79.9883004295862,
ar: 9.300000190734863, ar: 9.300000190734863,
od: 8.800000190734863, od: 8.800000190734863,
hp: 5.0, hp: 5.0,
n_circles: 307, n_circles: 307,
n_sliders: 293, n_sliders: 293,
n_slider_ticks: 15,
n_spinners: 1, n_spinners: 1,
stars: 7.17258038247615, stars: 7.167932950561898,
max_combo: 909, max_combo: 909,
}; };
} }
@@ -216,93 +246,111 @@ fn basic_osu() {
test_cases! { test_cases! {
Osu: OSU { Osu: OSU {
NM => { NM => {
aim: 2.8693628443424104, aim: 2.8811843667580206,
speed: 2.533869745015772, speed: 2.468469273849314,
flashlight: 2.288770487900865, flashlight: 2.287888783550428,
slider_factor: 0.9803052946037858, slider_factor: 0.9803293523973866,
speed_note_count: 210.36373973116545, speed_note_count: 204.88794724609374,
aim_difficult_strain_count: 106.63833474488393,
speed_difficult_strain_count: 79.9883004295862,
ar: 9.300000190734863, ar: 9.300000190734863,
od: 8.800000190734863, od: 8.800000190734863,
hp: 5.0, hp: 5.0,
n_circles: 307, n_circles: 307,
n_sliders: 293, n_sliders: 293,
n_slider_ticks: 15,
n_spinners: 1, n_spinners: 1,
stars: 5.669858729379631, stars: 5.6436199897393005,
max_combo: 909, max_combo: 909,
}; };
HD => { HD => {
aim: 2.8693628443424104, aim: 2.8811843667580206,
speed: 2.533869745015772, speed: 2.468469273849314,
flashlight: 2.606877929965889, flashlight: 2.605859779358901,
slider_factor: 0.9803052946037858, slider_factor: 0.9803293523973866,
speed_note_count: 210.36373973116545, speed_note_count: 204.88794724609374,
aim_difficult_strain_count: 106.63833474488393,
speed_difficult_strain_count: 79.9883004295862,
ar: 9.300000190734863, ar: 9.300000190734863,
od: 8.800000190734863, od: 8.800000190734863,
hp: 5.0, hp: 5.0,
n_circles: 307, n_circles: 307,
n_sliders: 293, n_sliders: 293,
n_slider_ticks: 15,
n_spinners: 1, n_spinners: 1,
stars: 5.669858729379631, stars: 5.6436199897393005,
max_combo: 909, max_combo: 909,
}; };
HR => { HR => {
aim: 3.2385394176190507, aim: 3.2515300463985666,
speed: 2.7009854505234308, speed: 2.6323568908654615,
flashlight: 2.8549217213059936, flashlight: 2.853761577136605,
slider_factor: 0.9690667605258665, slider_factor: 0.969089944826546,
speed_note_count: 184.01205359079387, speed_note_count: 178.52041495886283,
aim_difficult_strain_count: 108.03970474535397,
speed_difficult_strain_count: 73.27713411796513,
ar: 10.0, ar: 10.0,
od: 10.0, od: 10.0,
hp: 7.0, hp: 7.0,
n_circles: 307, n_circles: 307,
n_sliders: 293, n_sliders: 293,
n_slider_ticks: 15,
n_spinners: 1, n_spinners: 1,
stars: 6.263576582906263, stars: 6.2433012533379415,
max_combo: 909, max_combo: 909,
}; };
DT => { DT => {
aim: 4.041442573946681, aim: 4.058080039906945,
speed: 3.6784866216272474, speed: 3.570932204630734,
flashlight: 3.319522943625448, flashlight: 3.318209122186825,
slider_factor: 0.9776943279272041, slider_factor: 0.9777224379583133,
speed_note_count: 214.80421464205617, speed_note_count: 211.29204189490912,
aim_difficult_strain_count: 126.95613629755243,
speed_difficult_strain_count: 95.63810649133869,
ar: 10.53333346048991, ar: 10.53333346048991,
od: 10.311111238267687, od: 10.311111238267687,
hp: 5.0, hp: 5.0,
n_circles: 307, n_circles: 307,
n_sliders: 293, n_sliders: 293,
n_slider_ticks: 15,
n_spinners: 1, n_spinners: 1,
stars: 8.085307648397626, stars: 8.030649319285482,
max_combo: 909, max_combo: 909,
}; };
FL => { FL => {
aim: 2.8693628443424104, aim: 2.8811843667580206,
speed: 2.533869745015772, speed: 2.468469273849314,
flashlight: 2.288770487900865, flashlight: 2.287888783550428,
slider_factor: 0.9803052946037858, slider_factor: 0.9803293523973866,
speed_note_count: 210.36373973116545, speed_note_count: 204.88794724609374,
aim_difficult_strain_count: 106.63833474488393,
speed_difficult_strain_count: 79.9883004295862,
ar: 9.300000190734863, ar: 9.300000190734863,
od: 8.800000190734863, od: 8.800000190734863,
hp: 5.0, hp: 5.0,
n_circles: 307, n_circles: 307,
n_sliders: 293, n_sliders: 293,
n_slider_ticks: 15,
n_spinners: 1, n_spinners: 1,
stars: 6.866778075388425, stars: 6.858771801534423,
max_combo: 909, max_combo: 909,
}; };
HD FL => { HD FL => {
aim: 2.8693628443424104, aim: 2.8811843667580206,
speed: 2.533869745015772, speed: 2.468469273849314,
flashlight: 2.606877929965889, flashlight: 2.605859779358901,
slider_factor: 0.9803052946037858, slider_factor: 0.9803293523973866,
speed_note_count: 210.36373973116545, speed_note_count: 204.88794724609374,
aim_difficult_strain_count: 106.63833474488393,
speed_difficult_strain_count: 79.9883004295862,
ar: 9.300000190734863, ar: 9.300000190734863,
od: 8.800000190734863, od: 8.800000190734863,
hp: 5.0, hp: 5.0,
n_circles: 307, n_circles: 307,
n_sliders: 293, n_sliders: 293,
n_slider_ticks: 15,
n_spinners: 1, n_spinners: 1,
stars: 7.172580382476152, stars: 7.167932950561899,
max_combo: 909, max_combo: 909,
}; };
} }
@@ -314,32 +362,38 @@ fn basic_taiko() {
test_cases! { test_cases! {
Taiko: TAIKO { Taiko: TAIKO {
NM => { NM => {
stamina: 1.4528845068865617, stamina: 1.3991746883284406,
rhythm: 0.20130047251681948, rhythm: 0.20130047251681948,
color: 1.0487315549761433, color: 1.0487315549761433,
peak: 1.8881824429738323, peak: 1.8422453377400778,
hit_window: 35.0, mono_stamina_factor: 2.66403971858592e-07,
stars: 2.9778030386845606, great_hit_window: 35.0,
ok_hit_window: 80.0,
stars: 2.914589700180437,
max_combo: 289, max_combo: 289,
is_convert: false, is_convert: false,
}; };
HR => { HR => {
stamina: 1.4528845068865617, stamina: 1.3991746883284406,
rhythm: 0.20130047251681948, rhythm: 0.20130047251681948,
color: 1.0487315549761433, color: 1.0487315549761433,
peak: 1.8881824429738323, peak: 1.8422453377400778,
hit_window: 29.0, mono_stamina_factor: 2.66403971858592e-07,
stars: 2.9778030386845606, great_hit_window: 29.0,
ok_hit_window: 68.0,
stars: 2.914589700180437,
max_combo: 289, max_combo: 289,
is_convert: false, is_convert: false,
}; };
DT => { DT => {
stamina: 2.054617838297644, stamina: 2.0358868555131586,
rhythm: 0.4448175371191029, rhythm: 0.4448175371191029,
color: 1.3637624960988888, color: 1.363762496098889,
peak: 2.6393434317991886, peak: 2.625066421324458,
hit_window: 23.333333333333332, mono_stamina_factor: 2.515617502055679e-07,
stars: 3.9605501866340607, great_hit_window: 23.333333333333332,
ok_hit_window: 53.333333333333336,
stars: 3.942709244618132,
max_combo: 289, max_combo: 289,
is_convert: false, is_convert: false,
}; };
@@ -352,32 +406,38 @@ fn convert_taiko() {
test_cases! { test_cases! {
Taiko: OSU { Taiko: OSU {
NM => { NM => {
stamina: 2.947896529153566, stamina: 2.9127139214411444,
rhythm: 1.4696991260446617, rhythm: 1.4696991260446617,
color: 2.3032281729649067, color: 2.303228172964907,
peak: 4.130240422926277, peak: 4.117779264387738,
hit_window: 23.59999942779541, mono_stamina_factor: 0.0016957378202796742,
stars: 5.247857660585606, great_hit_window: 23.59999942779541,
ok_hit_window: 57.19999885559082,
stars: 5.235637844901627,
max_combo: 908, max_combo: 908,
is_convert: true, is_convert: true,
}; };
HR => { HR => {
stamina: 2.947896529153566, stamina: 2.9127139214411444,
rhythm: 1.4696991260446617, rhythm: 1.4696991260446617,
color: 2.3032281729649067, color: 2.303228172964907,
peak: 4.130240422926277, peak: 4.117779264387738,
hit_window: 20.0, mono_stamina_factor: 0.0016957378202796742,
stars: 5.247857660585606, great_hit_window: 20.0,
ok_hit_window: 50.0
stars: 5.235637844901627,
max_combo: 908, max_combo: 908,
is_convert: true, is_convert: true,
}; };
DT => { DT => {
stamina: 4.412382708370478, stamina: 4.379782453136822,
rhythm: 2.002843919169095, rhythm: 2.002843919169095,
color: 3.1864894777399986, color: 3.1864894777399986,
peak: 6.107962386775966, peak: 6.103209631166694,
hit_window: 15.733332951863607, mono_stamina_factor: 0.0017075184344987763,
stars: 7.0140481946324815, great_hit_window: 15.733332951863607,
ok_hit_window: 38.13333257039388,
stars: 7.010168846394131,
max_combo: 908, max_combo: 908,
is_convert: true, is_convert: true,
}; };
@@ -390,35 +450,35 @@ fn basic_catch() {
test_cases! { test_cases! {
Catch: CATCH { Catch: CATCH {
NM => { NM => {
stars: 3.2502663133739844, stars: 3.250266313373984,
ar: 8.0, ar: 8.0,
n_fruits: 728, n_fruits: 728,
n_droplets: 2, n_droplets: 2,
n_tiny_droplets: 291, n_tiny_droplets: 263,
is_convert: false, is_convert: false,
}; };
HR => { HR => {
stars: 4.3148698646484265, stars: 4.313360856186517,
ar: 10.0, ar: 10.0,
n_fruits: 728, n_fruits: 728,
n_droplets: 2, n_droplets: 2,
n_tiny_droplets: 291, n_tiny_droplets: 263,
is_convert: false, is_convert: false,
}; };
EZ => { EZ => {
stars: 4.021637906259923, stars: 4.06522224010957,
ar: 4.0, ar: 4.0,
n_fruits: 728, n_fruits: 728,
n_droplets: 2, n_droplets: 2,
n_tiny_droplets: 291, n_tiny_droplets: 263,
is_convert: false, is_convert: false,
}; };
DT => { DT => {
stars: 4.635262826575387, stars: 4.635262826575386,
ar: 9.666666666666668, ar: 9.666666666666668,
n_fruits: 728, n_fruits: 728,
n_droplets: 2, n_droplets: 2,
n_tiny_droplets: 291, n_tiny_droplets: 263,
is_convert: false, is_convert: false,
}; };
} }
@@ -430,15 +490,15 @@ fn convert_catch() {
test_cases! { test_cases! {
Catch: OSU { Catch: OSU {
NM => { NM => {
stars: 4.513884094512871, stars: 4.528720977989276,
ar: 9.300000190734863, ar: 9.300000190734863
n_fruits: 908, n_fruits: 908,
n_droplets: 0, n_droplets: 0,
n_tiny_droplets: 159, n_tiny_droplets: 159,
is_convert: true, is_convert: true,
}; };
HR => { HR => {
stars: 5.082061773944862, stars: 5.076698043567007,
ar: 10.0, ar: 10.0,
n_fruits: 908, n_fruits: 908,
n_droplets: 0, n_droplets: 0,
@@ -446,7 +506,7 @@ fn convert_catch() {
is_convert: true, is_convert: true,
}; };
EZ => { EZ => {
stars: 3.598951063172104, stars: 3.593264064535228,
ar: 4.650000095367432, ar: 4.650000095367432,
n_fruits: 908, n_fruits: 908,
n_droplets: 0, n_droplets: 0,
@@ -454,7 +514,7 @@ fn convert_catch() {
is_convert: true, is_convert: true,
}; };
DT => { DT => {
stars: 6.136837738350475, stars: 6.15540143757313,
ar: 10.53333346048991, ar: 10.53333346048991,
n_fruits: 908, n_fruits: 908,
n_droplets: 0, n_droplets: 0,
@@ -470,16 +530,18 @@ fn basic_mania() {
test_cases! { test_cases! {
Mania: MANIA { Mania: MANIA {
NM => { NM => {
stars: 3.441830819988125, stars: 3.358304846842773,
hit_window: 40.0, hit_window: 40.0,
n_objects: 594, n_objects: 594,
n_hold_notes: 121,
max_combo: 956, max_combo: 956,
is_convert: false, is_convert: false,
}; };
DT => { DT => {
stars: 4.70051326060948, stars: 4.6072892053157295,
hit_window: 40.0, hit_window: 40.0,
n_objects: 594, n_objects: 594,
n_hold_notes: 121,
max_combo: 956, max_combo: 956,
is_convert: false, is_convert: false,
}; };
@@ -495,6 +557,7 @@ fn convert_mania() {
stars: 3.2033142085672255, stars: 3.2033142085672255,
hit_window: 34.0, hit_window: 34.0,
n_objects: 1046, n_objects: 1046,
n_hold_notes: 293,
max_combo: 1381, max_combo: 1381,
is_convert: true, is_convert: true,
}; };
@@ -502,6 +565,7 @@ fn convert_mania() {
stars: 4.2934063021960185, stars: 4.2934063021960185,
hit_window: 34.0, hit_window: 34.0,
n_objects: 1046, n_objects: 1046,
n_hold_notes: 293,
max_combo: 1381, max_combo: 1381,
is_convert: true, is_convert: true,
}; };
@@ -543,7 +607,8 @@ impl AssertEq for TaikoDifficultyAttributes {
assert_eq_float(self.rhythm, expected.rhythm); assert_eq_float(self.rhythm, expected.rhythm);
assert_eq_float(self.color, expected.color); assert_eq_float(self.color, expected.color);
assert_eq_float(self.peak, expected.peak); 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_float(self.stars, expected.stars);
assert_eq!(self.max_combo, expected.max_combo); assert_eq!(self.max_combo, expected.max_combo);
assert_eq!(self.is_convert, expected.is_convert); assert_eq!(self.is_convert, expected.is_convert);
+189 -83
View File
@@ -15,7 +15,7 @@ mod common;
macro_rules! test_cases { macro_rules! test_cases {
( $mode:ident: $path:ident { ( $mode:ident: $path:ident {
$( $( $mods:ident )+ => { $( $( $mods:ident )+ => {
$( $key:ident: $value:literal $( , )? )* $( $key:ident: $value:expr $( , )? )*
} ;)* } ;)*
} ) => { } ) => {
let map = Beatmap::from_path(common::$path) let map = Beatmap::from_path(common::$path)
@@ -31,15 +31,15 @@ macro_rules! test_cases {
}; };
( @Osu { ( @Osu {
$map:ident, $map:ident,
pp: $pp:literal, pp: $pp:expr,
pp_acc: $pp_acc:literal, pp_acc: $pp_acc:expr,
pp_aim: $pp_aim:literal, pp_aim: $pp_aim:expr,
pp_flashlight: $pp_flashlight:literal, pp_flashlight: $pp_flashlight:expr,
pp_speed: $pp_speed:literal, pp_speed: $pp_speed:expr,
effective_miss_count: $effective_miss_count:literal, effective_miss_count: $effective_miss_count:expr,
}) => { }) => {
( (
OsuPerformance::from($map.as_owned()), OsuPerformance::from($map.as_owned()).lazer(true),
OsuPerformanceAttributes { OsuPerformanceAttributes {
pp: $pp, pp: $pp,
pp_acc: $pp_acc, pp_acc: $pp_acc,
@@ -53,10 +53,11 @@ macro_rules! test_cases {
}; };
( @Taiko { ( @Taiko {
$map: ident, $map: ident,
pp: $pp:literal, pp: $pp:expr,
pp_acc: $pp_acc:literal, pp_acc: $pp_acc:expr,
pp_difficulty: $pp_difficulty:literal, pp_difficulty: $pp_difficulty:expr,
effective_miss_count: $effective_miss_count:literal, effective_miss_count: $effective_miss_count:expr,
estimated_unstable_rate: $estimated_unstable_rate:expr,
}) => { }) => {
( (
TaikoPerformance::from($map.as_owned()), TaikoPerformance::from($map.as_owned()),
@@ -65,13 +66,14 @@ macro_rules! test_cases {
pp_acc: $pp_acc, pp_acc: $pp_acc,
pp_difficulty: $pp_difficulty, pp_difficulty: $pp_difficulty,
effective_miss_count: $effective_miss_count, effective_miss_count: $effective_miss_count,
estimated_unstable_rate: $estimated_unstable_rate,
..Default::default() ..Default::default()
}, },
) )
}; };
( @Catch { ( @Catch {
$map:ident, $map:ident,
pp: $pp:literal, pp: $pp:expr,
}) => { }) => {
( (
CatchPerformance::from($map.as_owned()), CatchPerformance::from($map.as_owned()),
@@ -83,8 +85,8 @@ macro_rules! test_cases {
}; };
( @Mania { ( @Mania {
$map:ident, $map:ident,
pp: $pp:literal, pp: $pp:expr,
pp_difficulty: $pp_difficulty:literal, pp_difficulty: $pp_difficulty:expr,
}) => { }) => {
( (
ManiaPerformance::from($map.as_owned()), ManiaPerformance::from($map.as_owned()),
@@ -99,62 +101,124 @@ macro_rules! test_cases {
#[test] #[test]
fn basic_osu() { fn basic_osu() {
#[cfg(target_os = "windows")]
test_cases! { test_cases! {
Osu: OSU { Osu: OSU {
NM => { NM => {
pp: 255.9419635475736, pp: 272.6047426867276,
pp_acc: 79.84500076626814, pp_acc: 97.62287463107766,
pp_aim: 98.13131344235279, pp_aim: 99.3726518686143,
pp_flashlight: 0.0, pp_flashlight: 0.0,
pp_speed: 69.86876965478146, pp_speed: 64.48542022217285,
effective_miss_count: 0.0, effective_miss_count: 0.0,
}; };
HD => { HD => {
pp: 281.28736211196446, pp: 299.17174736245374,
pp_acc: 86.2326008275696, pp_acc: 105.43270460156388,
pp_aim: 108.72949454544438, pp_aim: 110.10489751227146,
pp_flashlight: 0.0, pp_flashlight: 0.0,
pp_speed: 77.41459624444144, pp_speed: 71.4498451141828,
effective_miss_count: 0.0, effective_miss_count: 0.0,
}; };
EZ HD => { EZ HD => {
pp: 185.64881287702838, pp: 186.7137498214991,
pp_acc: 13.59914468151693, pp_acc: 16.6270597231239,
pp_aim: 96.88083530160195, pp_aim: 98.11121656070222,
pp_flashlight: 0.0, pp_flashlight: 0.0,
pp_speed: 65.96268917477774, pp_speed: 61.51901495973101,
effective_miss_count: 0.0, effective_miss_count: 0.0,
}; };
HR => { HR => {
pp: 375.0764291059058, pp: 404.7030358947424,
pp_acc: 132.13521300659738, pp_acc: 161.55575439788055,
pp_aim: 143.28598037767793, pp_aim: 145.04665418031985,
pp_flashlight: 0.0, pp_flashlight: 0.0,
pp_speed: 87.39375701955078, pp_speed: 80.77088499277514,
effective_miss_count: 0.0, effective_miss_count: 0.0,
}; };
DT => { DT => {
pp: 716.3683237855254, pp: 738.7899608061098,
pp_acc: 150.5694857734174, pp_acc: 184.09450675506795,
pp_aim: 300.39084638572484, pp_aim: 304.16666833057235,
pp_flashlight: 0.0, pp_flashlight: 0.0,
pp_speed: 240.8765306794618, pp_speed: 220.06297202966698,
effective_miss_count: 0.0, effective_miss_count: 0.0,
}; };
FL => { FL => {
pp: 384.8917879591265, pp: 402.408877784248,
pp_acc: 81.4419007815935, pp_acc: 99.57533212369923,
pp_aim: 98.13131344235279, pp_aim: 99.3726518686143,
pp_flashlight: 132.3991950960219, pp_flashlight: 132.29720631068272,
pp_speed: 69.86876965478146, pp_speed: 64.48542022217285,
effective_miss_count: 0.0, effective_miss_count: 0.0,
}; };
HD FL => { HD FL => {
pp: 450.3709760368082, pp: 469.3245236137446,
pp_acc: 87.95725284412099, pp_acc: 107.54135869359516,
pp_aim: 108.72949454544438, pp_aim: 110.10489751227146,
pp_flashlight: 171.7600847331662, pp_flashlight: 171.62594459401154,
pp_speed: 77.41459624444144, 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, effective_miss_count: 0.0,
}; };
} }
@@ -166,28 +230,32 @@ fn basic_taiko() {
test_cases! { test_cases! {
Taiko: TAIKO { Taiko: TAIKO {
NM => { NM => {
pp: 98.47602106219567, pp: 114.68651694107942,
pp_acc: 46.11642717726248, pp_acc: 67.10083752258917,
pp_difficulty: 46.69844233558799, pp_difficulty: 40.6658183165898,
effective_miss_count: 0.0, effective_miss_count: 0.0,
estimated_unstable_rate: Some(148.44150180469418),
}; };
HD => { HD => {
pp: 107.19493857245885, pp: 124.41592086295445,
pp_acc: 46.11642717726248, pp_acc: 67.10083752258917,
pp_difficulty: 47.86590339397769, pp_difficulty: 41.68246377450454,
effective_miss_count: 0.0, effective_miss_count: 0.0,
estimated_unstable_rate: Some(148.44150180469418),
}; };
HR => { HR => {
pp: 112.22705475791287, pp: 138.3981102935321,
pp_acc: 56.71431676265808, pp_acc: 82.52109686788792,
pp_difficulty: 49.033364452367394, pp_difficulty: 47.44272798866182,
effective_miss_count: 0.0, effective_miss_count: 0.0,
estimated_unstable_rate: Some(122.99438720960376),
}; };
DT => { DT => {
pp: 181.5021832786881, pp: 220.07140899937482,
pp_acc: 80.74206626516394, pp_acc: 118.28107309573312,
pp_difficulty: 90.29961105452931, pp_difficulty: 88.93091255724303,
effective_miss_count: 0.0, effective_miss_count: 0.0,
estimated_unstable_rate: Some(98.96100120312946),
}; };
} }
}; };
@@ -195,31 +263,69 @@ fn basic_taiko() {
#[test] #[test]
fn convert_taiko() { fn convert_taiko() {
#[cfg(target_os = "windows")]
test_cases! { test_cases! {
Taiko: OSU { Taiko: OSU {
NM => { NM => {
pp: 324.23564627433217, pp: 353.6961706002712,
pp_acc: 125.81086361861148, pp_acc: 155.09212159726567,
pp_difficulty: 179.31471072573842, pp_difficulty: 178.19145253120928,
effective_miss_count: 0.0, effective_miss_count: 0.0,
estimated_unstable_rate: Some(85.75868894575865),
}; };
HD => { HD => {
pp: 353.7513933816713, pp: 358.45704044422996,
pp_acc: 125.81086361861148, pp_acc: 155.09212159726567,
pp_difficulty: 183.79757849388187, pp_difficulty: 182.6462388444895,
effective_miss_count: 0.0, effective_miss_count: 0.0,
estimated_unstable_rate: Some(85.75868894575865),
}; };
HR => { HR => {
pp: 360.12274556551137, pp: 405.57235351353773,
pp_acc: 150.9344373000759, pp_acc: 186.06296332183615,
pp_difficulty: 188.28044626202535, pp_difficulty: 196.1813610529617,
effective_miss_count: 0.0, effective_miss_count: 0.0,
estimated_unstable_rate: Some(72.67685680089848),
}; };
DT => { DT => {
pp: 604.8167434609272, pp: 658.0214875413873,
pp_acc: 220.7055264311451, pp_acc: 272.26616492989393,
pp_difficulty: 347.90986552791844, pp_difficulty: 347.4712042359611,
effective_miss_count: 0.0, effective_miss_count: 0.0,
estimated_unstable_rate: Some(57.17245929717244),
};
}
}
#[cfg(target_os = "linux")]
test_cases! {
Taiko: OSU {
NM => {
pp: 353.6961706002712,
pp_acc: 155.09212159726567,
pp_difficulty: 178.19145253120928,
effective_miss_count: 0.0,
estimated_unstable_rate: Some(85.75868894575865),
};
HD => {
pp: 358.45704044423
pp_acc: 155.09212159726567,
pp_difficulty: 182.6462388444895,
effective_miss_count: 0.0,
estimated_unstable_rate: Some(85.75868894575865),
};
HR => {
pp: 405.57235351353773,
pp_acc: 186.06296332183615,
pp_difficulty: 196.1813610529617,
effective_miss_count: 0.0,
estimated_unstable_rate: Some(72.67685680089848),
};
DT => {
pp: 658.0214875413873
pp_acc: 272.26616492989393,
pp_difficulty: 347.4712042359611,
effective_miss_count: 0.0,
estimated_unstable_rate: Some(57.17245929717244),
}; };
} }
} }
@@ -229,10 +335,10 @@ fn convert_taiko() {
fn basic_catch() { fn basic_catch() {
test_cases! { test_cases! {
Catch: CATCH { Catch: CATCH {
NM => { pp: 113.85903714373049 }; NM => { pp: 113.85903714373046 };
HD => { pp: 136.63084457247658 }; HD => { pp: 136.63084457247655 };
HD HR => { pp: 231.90266535529486 }; HD HR => { pp: 231.7403429678108 };
DT => { pp: 247.18402249125862 }; DT => { pp: 247.18402249125842 };
} }
}; };
} }
@@ -241,10 +347,10 @@ fn basic_catch() {
fn convert_catch() { fn convert_catch() {
test_cases! { test_cases! {
Catch: OSU { Catch: OSU {
NM => { pp: 230.99937552589745 }; NM => { pp: 232.52175944328079 };
HD => { pp: 254.6768082128294 }; HD => { pp: 256.35523645996665 };
HD HR => { pp: 328.41201070443725 }; HD HR => { pp: 327.71861407740374 };
DT => { pp: 500.4365349891725 }; DT => { pp: 503.47065792054815 };
} }
}; };
} }
@@ -253,9 +359,9 @@ fn convert_catch() {
fn basic_mania() { fn basic_mania() {
test_cases! { test_cases! {
Mania: MANIA { Mania: MANIA {
NM => { pp: 114.37175184134917, pp_difficulty: 14.296468980168646 }; NM => { pp: 108.92297471705167, pp_difficulty: 108.92297471705167 };
EZ => { pp: 57.18587592067458, pp_difficulty: 14.296468980168646 }; EZ => { pp: 54.46148735852584, pp_difficulty: 108.92297471705167 };
DT => { pp: 233.17882161546717, pp_difficulty: 29.147352701933396 }; DT => { pp: 224.52717042937203, pp_difficulty: 224.52717042937203 };
} }
}; };
} }
@@ -264,9 +370,9 @@ fn basic_mania() {
fn convert_mania() { fn convert_mania() {
test_cases! { test_cases! {
Mania: OSU { Mania: OSU {
NM => { pp: 99.73849552661329, pp_difficulty: 12.467311940826661 }; NM => { pp: 101.39189449271568, pp_difficulty: 101.39189449271568 };
EZ => { pp: 49.869247763306646, pp_difficulty: 12.467311940826661 }; EZ => { pp: 50.69594724635784, pp_difficulty: 101.39189449271568 };
DT => { pp: 195.23247718805612, pp_difficulty: 24.404059648507015 }; DT => { pp: 198.46891237015896, pp_difficulty: 198.46891237015896 };
} }
}; };
} }