Merge pull request #43 from MaxOhn/pp-update

feat: port pp update
This commit is contained in:
Badewanne3
2024-11-18 12:49:08 +01:00
committed by GitHub
67 changed files with 2694 additions and 1078 deletions
+2 -2
View File
@@ -17,8 +17,8 @@ sync = []
tracing = ["rosu-map/tracing"]
[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"
+3 -7
View File
@@ -11,14 +11,10 @@ with emphasis on a precise translation to Rust for the most [accurate results](#
while also providing a significant [boost in performance](#speed).
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
+10
View File
@@ -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
View File
@@ -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()
}
}
+51 -2
View File
@@ -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 {
+25
View File
@@ -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,
+3 -1
View File
@@ -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
View File
@@ -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 {
+9 -2
View File
@@ -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,
+10 -4
View File
@@ -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>(
+1 -1
View File
@@ -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;
+10 -7
View File
@@ -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 }
+21 -16
View File
@@ -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,
+5 -3
View File
@@ -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
View File
@@ -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
//!
+2
View File
@@ -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
View File
@@ -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
}
}
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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,
@@ -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;
+25 -15
View File
@@ -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;
}
}
+7 -4
View File
@@ -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(),
}
}
+6 -5
View File
@@ -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
View File
@@ -1,4 +1,4 @@
use rosu_map::section::hit_objects::CurveBuffers;
use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers};
use crate::model::{
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
}
}
+7
View File
@@ -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);
+62 -19
View File
@@ -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)
+92 -53
View File
@@ -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);
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
+6
View File
@@ -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
View File
@@ -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;
}
}
}
+6 -4
View File
@@ -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
View File
@@ -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;
}
+30 -6
View File
@@ -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 -1
View File
@@ -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;
+11 -11
View File
@@ -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;
}
+6 -4
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
View File
@@ -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,
}
+66 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
}
+8 -1
View File
@@ -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
View File
@@ -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,
+26
View File
@@ -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)
}
}
+14 -14
View File
@@ -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));
}
}
}
}
+28 -16
View File
@@ -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
View File
@@ -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;
}
+17 -3
View File
@@ -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
}
}
+17 -4
View File
@@ -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
+21 -1
View File
@@ -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),
}
}
}
-114
View File
@@ -1,114 +0,0 @@
use std::cmp;
use crate::{
any::difficulty::skills::Skill,
taiko::difficulty::object::{TaikoDifficultyObject, TaikoDifficultyObjects},
};
use super::{color::Color, rhythm::Rhythm, stamina::Stamina};
const RHYTHM_SKILL_MULTIPLIER: f64 = 0.2 * FINAL_MULTIPLIER;
const COLOR_SKILL_MULTIPLIER: f64 = 0.375 * FINAL_MULTIPLIER;
const STAMINA_SKILL_MULTIPLIER: f64 = 0.375 * FINAL_MULTIPLIER;
const FINAL_MULTIPLIER: f64 = 0.0625;
#[derive(Clone)]
pub struct Peaks {
pub color: Color,
pub rhythm: Rhythm,
pub stamina: Stamina,
}
impl Peaks {
pub fn new() -> Self {
Self {
color: Color::default(),
rhythm: Rhythm::default(),
stamina: Stamina::default(),
}
}
pub fn color_difficulty_value(&self) -> f64 {
self.color.as_difficulty_value() * COLOR_SKILL_MULTIPLIER
}
pub fn rhythm_difficulty_value(&self) -> f64 {
self.rhythm.as_difficulty_value() * RHYTHM_SKILL_MULTIPLIER
}
pub fn stamina_difficulty_value(&self) -> f64 {
self.stamina.as_difficulty_value() * STAMINA_SKILL_MULTIPLIER
}
fn norm(p: f64, values: impl IntoIterator<Item = f64>) -> f64 {
values
.into_iter()
.fold(0.0, |sum, x| sum + x.powf(p))
.powf(p.recip())
}
pub fn difficulty_value(self) -> f64 {
let color_peaks = self.color.get_curr_strain_peaks();
let rhythm_peaks = self.rhythm.get_curr_strain_peaks();
let stamina_peaks = self.stamina.get_curr_strain_peaks();
let cap = cmp::min(
cmp::min(color_peaks.len(), rhythm_peaks.len()),
stamina_peaks.len(),
);
let mut peaks = Vec::with_capacity(cap);
let zip = color_peaks
.iter()
.zip(rhythm_peaks.iter())
.zip(stamina_peaks.iter());
for ((mut color_peak, mut rhythm_peak), mut stamina_peak) in zip {
color_peak *= COLOR_SKILL_MULTIPLIER;
rhythm_peak *= RHYTHM_SKILL_MULTIPLIER;
stamina_peak *= STAMINA_SKILL_MULTIPLIER;
let mut peak = Self::norm(1.5, [color_peak, stamina_peak]);
peak = Self::norm(2.0, [peak, rhythm_peak]);
if peak > 0.0 {
peaks.push(peak);
}
}
let mut difficulty = 0.0;
let mut weight = 1.0;
peaks.sort_by(|a, b| b.total_cmp(a));
for strain in peaks {
difficulty += strain * weight;
weight *= 0.9;
}
difficulty
}
}
pub struct PeaksSkill<'a> {
pub color: Skill<'a, Color>,
pub rhythm: Skill<'a, Rhythm>,
pub stamina: Skill<'a, Stamina>,
}
impl<'a> PeaksSkill<'a> {
pub fn new(peaks: &'a mut Peaks, diff_objects: &'a TaikoDifficultyObjects) -> Self {
Self {
color: Skill::new(&mut peaks.color, diff_objects),
rhythm: Skill::new(&mut peaks.rhythm, diff_objects),
stamina: Skill::new(&mut peaks.stamina, diff_objects),
}
}
pub fn process(&mut self, curr: &TaikoDifficultyObject) {
self.rhythm.process(curr);
self.color.process(curr);
self.stamina.process(curr);
}
}
+78 -22
View File
@@ -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
View File
@@ -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 {
+3 -3
View File
@@ -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(),
}
}
+13
View File
@@ -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
View File
@@ -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);
}
}
}
+285
View File
@@ -0,0 +1,285 @@
#![allow(
clippy::excessive_precision,
clippy::too_many_lines,
clippy::unreadable_literal,
clippy::many_single_char_names
)]
#[rustfmt::skip]
mod consts {
pub const ERF_IMP_AN: &[f64] = &[ 0.00337916709551257388990745, -0.00073695653048167948530905, -0.374732337392919607868241, 0.0817442448733587196071743, -0.0421089319936548595203468, 0.0070165709512095756344528, -0.00495091255982435110337458, 0.000871646599037922480317225 ];
pub const ERF_IMP_AD: &[f64] = &[ 1.0, -0.218088218087924645390535, 0.412542972725442099083918, -0.0841891147873106755410271, 0.0655338856400241519690695, -0.0120019604454941768171266, 0.00408165558926174048329689, -0.000615900721557769691924509 ];
pub const ERF_IMP_BN: &[f64] = &[ -0.0361790390718262471360258, 0.292251883444882683221149, 0.281447041797604512774415, 0.125610208862766947294894, 0.0274135028268930549240776, 0.00250839672168065762786937 ];
pub const ERF_IMP_BD: &[f64] = &[ 1.0, 1.8545005897903486499845, 1.43575803037831418074962, 0.582827658753036572454135, 0.124810476932949746447682, 0.0113724176546353285778481 ];
pub const ERF_IMP_CN: &[f64] = &[ -0.0397876892611136856954425, 0.153165212467878293257683, 0.191260295600936245503129, 0.10276327061989304213645, 0.029637090615738836726027, 0.0046093486780275489468812, 0.000307607820348680180548455 ];
pub const ERF_IMP_CD: &[f64] = &[ 1.0, 1.95520072987627704987886, 1.64762317199384860109595, 0.768238607022126250082483, 0.209793185936509782784315, 0.0319569316899913392596356, 0.00213363160895785378615014 ];
pub const ERF_IMP_DN: &[f64] = &[ -0.0300838560557949717328341, 0.0538578829844454508530552, 0.0726211541651914182692959, 0.0367628469888049348429018, 0.00964629015572527529605267, 0.00133453480075291076745275, 0.778087599782504251917881e-4 ];
pub const ERF_IMP_DD: &[f64] = &[ 1.0, 1.75967098147167528287343, 1.32883571437961120556307, 0.552528596508757581287907, 0.133793056941332861912279, 0.0179509645176280768640766, 0.00104712440019937356634038, -0.106640381820357337177643e-7 ];
pub const ERF_IMP_EN: &[f64] = &[ -0.0117907570137227847827732, 0.014262132090538809896674, 0.0202234435902960820020765, 0.00930668299990432009042239, 0.00213357802422065994322516, 0.00025022987386460102395382, 0.120534912219588189822126e-4 ];
pub const ERF_IMP_ED: &[f64] = &[ 1.0, 1.50376225203620482047419, 0.965397786204462896346934, 0.339265230476796681555511, 0.0689740649541569716897427, 0.00771060262491768307365526, 0.000371421101531069302990367 ];
pub const ERF_IMP_FN: &[f64] = &[ -0.00546954795538729307482955, 0.00404190278731707110245394, 0.0054963369553161170521356, 0.00212616472603945399437862, 0.000394984014495083900689956, 0.365565477064442377259271e-4, 0.135485897109932323253786e-5 ];
pub const ERF_IMP_FD: &[f64] = &[ 1.0, 1.21019697773630784832251, 0.620914668221143886601045, 0.173038430661142762569515, 0.0276550813773432047594539, 0.00240625974424309709745382, 0.891811817251336577241006e-4, -0.465528836283382684461025e-11 ];
pub const ERF_IMP_GN: &[f64] = &[ -0.00270722535905778347999196, 0.0013187563425029400461378, 0.00119925933261002333923989, 0.00027849619811344664248235, 0.267822988218331849989363e-4, 0.923043672315028197865066e-6 ];
pub const ERF_IMP_GD: &[f64] = &[ 1.0, 0.814632808543141591118279, 0.268901665856299542168425, 0.0449877216103041118694989, 0.00381759663320248459168994, 0.000131571897888596914350697, 0.404815359675764138445257e-11 ];
pub const ERF_IMP_HN: &[f64] = &[ -0.00109946720691742196814323, 0.000406425442750422675169153, 0.000274499489416900707787024, 0.465293770646659383436343e-4, 0.320955425395767463401993e-5, 0.778286018145020892261936e-7 ];
pub const ERF_IMP_HD: &[f64] = &[ 1.0, 0.588173710611846046373373, 0.139363331289409746077541, 0.0166329340417083678763028, 0.00100023921310234908642639, 0.24254837521587225125068e-4 ];
pub const ERF_IMP_IN: &[f64] = &[ -0.00056907993601094962855594, 0.000169498540373762264416984, 0.518472354581100890120501e-4, 0.382819312231928859704678e-5, 0.824989931281894431781794e-7 ];
pub const ERF_IMP_ID: &[f64] = &[ 1.0, 0.339637250051139347430323, 0.043472647870310663055044, 0.00248549335224637114641629, 0.535633305337152900549536e-4, -0.117490944405459578783846e-12 ];
pub const ERF_IMP_JN: &[f64] = &[ -0.000241313599483991337479091, 0.574224975202501512365975e-4, 0.115998962927383778460557e-4, 0.581762134402593739370875e-6, 0.853971555085673614607418e-8 ];
pub const ERF_IMP_JD: &[f64] = &[ 1.0, 0.233044138299687841018015, 0.0204186940546440312625597, 0.000797185647564398289151125, 0.117019281670172327758019e-4 ];
pub const ERF_IMP_KN: &[f64] = &[ -0.000146674699277760365803642, 0.162666552112280519955647e-4, 0.269116248509165239294897e-5, 0.979584479468091935086972e-7, 0.101994647625723465722285e-8 ];
pub const ERF_IMP_KD: &[f64] = &[ 1.0, 0.165907812944847226546036, 0.0103361716191505884359634, 0.000286593026373868366935721, 0.298401570840900340874568e-5 ];
pub const ERF_IMP_LN: &[f64] = &[ -0.583905797629771786720406e-4, 0.412510325105496173512992e-5, 0.431790922420250949096906e-6, 0.993365155590013193345569e-8, 0.653480510020104699270084e-10 ];
pub const ERF_IMP_LD: &[f64] = &[ 1.0, 0.105077086072039915406159, 0.00414278428675475620830226, 0.726338754644523769144108e-4, 0.477818471047398785369849e-6 ];
pub const ERF_IMP_MN: &[f64] = &[ -0.196457797609229579459841e-4, 0.157243887666800692441195e-5, 0.543902511192700878690335e-7, 0.317472492369117710852685e-9 ];
pub const ERF_IMP_MD: &[f64] = &[ 1.0, 0.052803989240957632204885, 0.000926876069151753290378112, 0.541011723226630257077328e-5, 0.535093845803642394908747e-15 ];
pub const ERF_IMP_NN: &[f64] = &[ -0.789224703978722689089794e-5, 0.622088451660986955124162e-6, 0.145728445676882396797184e-7, 0.603715505542715364529243e-10 ];
pub const ERF_IMP_ND: &[f64] = &[ 1.0, 0.0375328846356293715248719, 0.000467919535974625308126054, 0.193847039275845656900547e-5 ];
pub const ERV_INV_IMP_AN: &[f64] = &[ -0.000508781949658280665617, -0.00836874819741736770379, 0.0334806625409744615033, -0.0126926147662974029034, -0.0365637971411762664006, 0.0219878681111168899165, 0.00822687874676915743155, -0.00538772965071242932965 ];
pub const ERV_INV_IMP_AD: &[f64] = &[ 1.0, -0.970005043303290640362, -1.56574558234175846809, 1.56221558398423026363, 0.662328840472002992063, -0.71228902341542847553, -0.0527396382340099713954, 0.0795283687341571680018, -0.00233393759374190016776, 0.000886216390456424707504 ];
pub const ERV_INV_IMP_BN: &[f64] = &[ -0.202433508355938759655, 0.105264680699391713268, 8.37050328343119927838, 17.6447298408374015486, -18.8510648058714251895, -44.6382324441786960818, 17.445385985570866523, 21.1294655448340526258, -3.67192254707729348546 ];
pub const ERV_INV_IMP_BD: &[f64] = &[ 1.0, 6.24264124854247537712, 3.9713437953343869095, -28.6608180499800029974, -20.1432634680485188801, 48.5609213108739935468, 10.8268667355460159008, -22.6436933413139721736, 1.72114765761200282724 ];
pub const ERV_INV_IMP_CN: &[f64] = &[ -0.131102781679951906451, -0.163794047193317060787, 0.117030156341995252019, 0.387079738972604337464, 0.337785538912035898924, 0.142869534408157156766, 0.0290157910005329060432, 0.00214558995388805277169, -0.679465575181126350155e-6, 0.285225331782217055858e-7, -0.681149956853776992068e-9 ];
pub const ERV_INV_IMP_CD: &[f64] = &[ 1.0, 3.46625407242567245975, 5.38168345707006855425, 4.77846592945843778382, 2.59301921623620271374, 0.848854343457902036425, 0.152264338295331783612, 0.01105924229346489121 ];
pub const ERV_INV_IMP_DN: &[f64] = &[ -0.0350353787183177984712, -0.00222426529213447927281, 0.0185573306514231072324, 0.00950804701325919603619, 0.00187123492819559223345, 0.000157544617424960554631, 0.460469890584317994083e-5, -0.230404776911882601748e-9, 0.266339227425782031962e-11 ];
pub const ERV_INV_IMP_DD: &[f64] = &[ 1.0, 1.3653349817554063097, 0.762059164553623404043, 0.220091105764131249824, 0.0341589143670947727934, 0.00263861676657015992959, 0.764675292302794483503e-4 ];
pub const ERV_INV_IMP_EN: &[f64] = &[ -0.0167431005076633737133, -0.00112951438745580278863, 0.00105628862152492910091, 0.000209386317487588078668, 0.149624783758342370182e-4, 0.449696789927706453732e-6, 0.462596163522878599135e-8, -0.281128735628831791805e-13, 0.99055709973310326855e-16 ];
pub const ERV_INV_IMP_ED: &[f64] = &[ 1.0, 0.591429344886417493481, 0.138151865749083321638, 0.0160746087093676504695, 0.000964011807005165528527, 0.275335474764726041141e-4, 0.282243172016108031869e-6 ];
pub const ERV_INV_IMP_FN: &[f64] = &[ -0.0024978212791898131227, -0.779190719229053954292e-5, 0.254723037413027451751e-4, 0.162397777342510920873e-5, 0.396341011304801168516e-7, 0.411632831190944208473e-9, 0.145596286718675035587e-11, -0.116765012397184275695e-17 ];
pub const ERV_INV_IMP_FD: &[f64] = &[ 1.0, 0.207123112214422517181, 0.0169410838120975906478, 0.000690538265622684595676, 0.145007359818232637924e-4, 0.144437756628144157666e-6, 0.509761276599778486139e-9 ];
pub const ERV_INV_IMP_GN: &[f64] = &[ -0.000539042911019078575891, -0.28398759004727721098e-6, 0.899465114892291446442e-6, 0.229345859265920864296e-7, 0.225561444863500149219e-9, 0.947846627503022684216e-12, 0.135880130108924861008e-14, -0.348890393399948882918e-21 ];
pub const ERV_INV_IMP_GD: &[f64] = &[ 1.0, 0.0845746234001899436914, 0.00282092984726264681981, 0.468292921940894236786e-4, 0.399968812193862100054e-6, 0.161809290887904476097e-8, 0.231558608310259605225e-11 ];
}
#[allow(clippy::wildcard_imports)]
use consts::*;
pub fn erf(x: f64) -> f64 {
if x.abs() < f64::EPSILON {
return 0.0;
}
if x == f64::INFINITY {
return 1.0;
}
if x == f64::NEG_INFINITY {
return -1.0;
}
if x.is_nan() {
return f64::NAN;
}
erf_imp(x, false)
}
pub fn erf_inv(z: f64) -> f64 {
if z.abs() < f64::EPSILON {
return 0.0;
}
if z >= 1.0 {
return f64::INFINITY;
}
if z <= -1.0 {
return f64::NEG_INFINITY;
}
if z < 0.0 {
erf_inv_impl(-z, 1.0 - (-z), -1.0)
} else {
erf_inv_impl(z, 1.0 - z, 1.0)
}
}
fn erf_imp(z: f64, mut invert: bool) -> f64 {
if z < 0.0 {
if !invert {
return -erf_imp(-z, false);
}
if z < -0.5 {
return 2.0 - erf_imp(-z, true);
}
return 1.0 + erf_imp(-z, false);
}
let result = if z < 0.5 {
if z < 1e-10 {
(z * 1.125) + (z * 0.003379167095512573896158903121545171688)
} else {
(z * 1.125)
+ (z * evaluate_polynomial(z, ERF_IMP_AN) / evaluate_polynomial(z, ERF_IMP_AD))
}
} else if z < 110.0 {
invert = !invert;
let (r, b) = if z < 0.75 {
(
evaluate_polynomial(z - 0.5, ERF_IMP_BN) / evaluate_polynomial(z - 0.5, ERF_IMP_BD),
f64::from(0.3440242112_f32),
)
} else if z < 1.25 {
(
evaluate_polynomial(z - 0.75, ERF_IMP_CN)
/ evaluate_polynomial(z - 0.75, ERF_IMP_CD),
f64::from(0.419990927_f32),
)
} else if z < 2.25 {
(
evaluate_polynomial(z - 1.25, ERF_IMP_DN)
/ evaluate_polynomial(z - 1.25, ERF_IMP_DD),
f64::from(0.4898625016_f32),
)
} else if z < 3.5 {
(
evaluate_polynomial(z - 2.25, ERF_IMP_EN)
/ evaluate_polynomial(z - 2.25, ERF_IMP_ED),
f64::from(0.5317370892_f32),
)
} else if z < 5.25 {
(
evaluate_polynomial(z - 3.5, ERF_IMP_FN) / evaluate_polynomial(z - 3.5, ERF_IMP_FD),
f64::from(0.5489973426_f32),
)
} else if z < 8.0 {
(
evaluate_polynomial(z - 5.25, ERF_IMP_GN)
/ evaluate_polynomial(z - 5.25, ERF_IMP_GD),
f64::from(0.5571740866_f32),
)
} else if z < 11.5 {
(
evaluate_polynomial(z - 8.0, ERF_IMP_HN) / evaluate_polynomial(z - 8.0, ERF_IMP_HD),
f64::from(0.5609807968_f32),
)
} else if z < 17.0 {
(
evaluate_polynomial(z - 11.5, ERF_IMP_IN)
/ evaluate_polynomial(z - 11.5, ERF_IMP_ID),
f64::from(0.5626493692_f32),
)
} else if z < 24.0 {
(
evaluate_polynomial(z - 17.0, ERF_IMP_JN)
/ evaluate_polynomial(z - 17.0, ERF_IMP_JD),
f64::from(0.5634598136_f32),
)
} else if z < 38.0 {
(
evaluate_polynomial(z - 24.0, ERF_IMP_KN)
/ evaluate_polynomial(z - 24.0, ERF_IMP_KD),
f64::from(0.5638477802_f32),
)
} else if z < 60.0 {
(
evaluate_polynomial(z - 38.0, ERF_IMP_LN)
/ evaluate_polynomial(z - 38.0, ERF_IMP_LD),
f64::from(0.5640528202_f32),
)
} else if z < 85.0 {
(
evaluate_polynomial(z - 60.0, ERF_IMP_MN)
/ evaluate_polynomial(z - 60.0, ERF_IMP_MD),
f64::from(0.5641309023_f32),
)
} else {
(
evaluate_polynomial(z - 85.0, ERF_IMP_NN)
/ evaluate_polynomial(z - 85.0, ERF_IMP_ND),
f64::from(0.5641584396_f32),
)
};
let g = (-z * z).exp() / z;
(g * b) + (g * r)
} else {
invert = !invert;
0.0
};
if invert {
1.0 - result
} else {
result
}
}
fn erf_inv_impl(p: f64, q: f64, s: f64) -> f64 {
let result = if p <= 0.5 {
const Y: f32 = 0.0891314744949340820313;
let g = p * (p + 10.0);
let r = evaluate_polynomial(p, ERV_INV_IMP_AN) / evaluate_polynomial(p, ERV_INV_IMP_AD);
(g * f64::from(Y)) + (g * r)
} else if q >= 0.25 {
const Y: f32 = 2.249481201171875;
let g = (-2.0 * q.ln()).sqrt();
let xs = q - 0.25;
let r = evaluate_polynomial(xs, ERV_INV_IMP_BN) / evaluate_polynomial(xs, ERV_INV_IMP_BD);
g / (f64::from(Y) + r)
} else {
let x = (-q.ln()).sqrt();
if x < 3.0 {
const Y: f32 = 0.807220458984375;
let xs = x - 1.125;
let r =
evaluate_polynomial(xs, ERV_INV_IMP_CN) / evaluate_polynomial(xs, ERV_INV_IMP_CD);
(f64::from(Y) * x) + (r * x)
} else if x < 6.0 {
const Y: f32 = 0.93995571136474609375;
let xs = x - 3.0;
let r =
evaluate_polynomial(xs, ERV_INV_IMP_DN) / evaluate_polynomial(xs, ERV_INV_IMP_DD);
(f64::from(Y) * x) + (r * x)
} else if x < 18.0 {
const Y: f32 = 0.98362827301025390625;
let xs = x - 6.0;
let r =
evaluate_polynomial(xs, ERV_INV_IMP_EN) / evaluate_polynomial(xs, ERV_INV_IMP_ED);
(f64::from(Y) * x) + (r * x)
} else if x < 44.0 {
const Y: f32 = 0.99714565277099609375;
let xs = x - 18.0;
let r =
evaluate_polynomial(xs, ERV_INV_IMP_FN) / evaluate_polynomial(xs, ERV_INV_IMP_FD);
(f64::from(Y) * x) + (r * x)
} else {
const Y: f32 = 0.99941349029541015625;
let xs = x - 44.0;
let r =
evaluate_polynomial(xs, ERV_INV_IMP_GN) / evaluate_polynomial(xs, ERV_INV_IMP_GD);
(f64::from(Y) * x) + (r * x)
}
};
result * s
}
fn evaluate_polynomial(z: f64, coefficients: &[f64]) -> f64 {
let mut coefficients = coefficients.iter().copied().rev();
let Some(last) = coefficients.next() else {
return 0.0;
};
coefficients.fold(last, |sum, coefficient| (sum * z) + coefficient)
}
+4
View File
@@ -6,8 +6,10 @@ pub use inner::*;
mod inner {
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
View File
@@ -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
View File
@@ -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 };
}
};
}