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