Port osu!std updates since f08134f

This commit is contained in:
MaxOhn
2024-10-10 16:18:18 +02:00
committed by tsunyoku
parent 41411eec87
commit 0c79c4073e
15 changed files with 402 additions and 178 deletions
+17
View File
@@ -299,6 +299,23 @@ impl<'map> Performance<'map> {
}
}
/// Whether the calculated attributes belong to an osu!lazer or osu!stable
/// score.
///
/// Defaults to lazer.
///
/// This affects internal accuracy calculation because lazer considers
/// slider heads for accuracy whereas stable does not.
///
/// Only relevant for osu!standard.
pub fn lazer(self, lazer: bool) -> Self {
if let Self::Osu(osu) = self {
Self::Osu(osu.lazer(lazer))
} else {
self
}
}
/// Specify the amount of 300s of a play.
pub fn n300(self, n300: u32) -> Self {
match self {
+1
View File
@@ -479,6 +479,7 @@ impl<'map> TryFrom<OsuPerformance<'map>> for CatchPerformance<'map> {
n50,
misses,
hitresult_priority: _,
lazer: _,
} = osu;
Ok(Self {
+1
View File
@@ -832,6 +832,7 @@ impl<'map> TryFrom<OsuPerformance<'map>> for ManiaPerformance<'map> {
n50,
misses,
hitresult_priority,
lazer: _,
} = osu;
Ok(Self {
+20
View File
@@ -182,6 +182,26 @@ impl_has_mod! {
tc: - Traceable ["Traceable"],
}
impl GameMods {
pub fn no_slider_head_acc(&self, lazer: bool) -> bool {
match self.inner {
GameModsInner::Lazer(ref mods) => mods
.iter()
.find_map(|m| match m {
GameMod::ClassicOsu(classic) => Some(classic),
_ => None,
})
.map_or(!lazer, |classic| {
classic.no_slider_head_accuracy.unwrap_or(true)
}),
GameModsInner::Intermode(ref mods) => {
mods.contains(GameModIntermode::Classic) || !lazer
}
GameModsInner::Legacy(_) => !lazer,
}
}
}
impl Default for GameMods {
fn default() -> Self {
Self::DEFAULT
+4
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
+10 -3
View File
@@ -67,7 +67,7 @@ pub fn convert_objects(
.iter_mut()
.for_each(OsuObject::reflect_vertically);
} else {
osu_objects.iter_mut().for_each(OsuObject::finalize_tail);
osu_objects.iter_mut().for_each(OsuObject::finalize_nested);
}
let stack_threshold = time_preempt * f64::from(converted.stack_leniency);
@@ -246,13 +246,20 @@ fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
break;
}
// * Note the use of `StartTime` in the code below doesn't match stable's use of `EndTime`.
// * This is because in the stable implementation, `UpdateCalculations` is not called on the inner-loop hitobject (j)
// * and therefore it does not have a correct `EndTime`, but instead the default of `EndTime = StartTime`.
// *
// * Effects of this can be seen on https://osu.ppy.sh/beatmapsets/243#osu/1146 at sliders around 86647 ms, where
// * if we use `EndTime` here it would result in unexpected stacking.
if hit_objects[j].pos.distance(hit_objects[i].pos) < STACK_DISTANCE {
hit_objects[i].stack_height += 1;
start_time = hit_objects[j].end_time();
start_time = hit_objects[j].start_time;
} else if hit_objects[j].pos.distance(pos2) < STACK_DISTANCE {
slider_stack += 1;
hit_objects[j].stack_height -= slider_stack;
start_time = hit_objects[j].end_time();
start_time = hit_objects[j].start_time;
}
}
}
+16 -7
View File
@@ -1,6 +1,9 @@
use std::{cmp, pin::Pin};
use skills::{flashlight::Flashlight, strain::OsuStrainSkill};
use skills::{
flashlight::Flashlight,
strain::{DifficultyValue, OsuStrainSkill, UsedOsuStrainSkills},
};
use crate::{
any::difficulty::{skills::Skill, Difficulty},
@@ -148,15 +151,16 @@ impl DifficultyValues {
pub fn eval(
attrs: &mut OsuDifficultyAttributes,
mods: &GameMods,
aim_difficulty_value: f64,
aim_no_sliders_difficulty_value: f64,
speed_difficulty_value: f64,
aim: UsedOsuStrainSkills<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 {
@@ -165,6 +169,9 @@ impl DifficultyValues {
1.0
};
let aim_difficult_strain_count = aim.count_difficult_strains();
let speed_difficult_strain_count = speed.count_difficult_strains();
if mods.td() {
aim_rating = aim_rating.powf(0.8);
flashlight_rating = flashlight_rating.powf(0.8);
@@ -207,6 +214,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;
}
+19 -1
View File
@@ -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,
+2 -2
View File
@@ -19,8 +19,8 @@ pub struct ScalingFactor {
impl ScalingFactor {
pub fn new(cs: f64) -> Self {
let scale =
(1.0 - 0.7 * ((cs - 5.0) / 5.0)) as f32 / 2.0 * BROKEN_GAMEFIELD_ROUNDING_ALLOWANCE;
let scale = (f64::from(1.0_f32) - f64::from(0.7_f32) * ((cs - 5.0) / 5.0)) as f32 / 2.0
* BROKEN_GAMEFIELD_ROUNDING_ALLOWANCE;
let radius = f64::from(OsuObject::OBJECT_RADIUS * scale);
let factor = OsuDifficultyObject::NORMALIZED_RADIUS / radius as f32;
+7 -6
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 = 24.963;
const SKILL_MULTIPLIER: f64 = 25.18;
const STRAIN_DECAY_BASE: f64 = 0.15;
#[derive(Clone)]
@@ -31,20 +31,20 @@ impl Aim {
}
pub fn get_curr_strain_peaks(self) -> StrainsVec {
self.inner.get_curr_strain_peaks()
self.inner.get_curr_strain_peaks().strains()
}
pub fn difficulty_value(self) -> f64 {
pub fn difficulty_value(self) -> UsedOsuStrainSkills<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,
@@ -104,6 +104,7 @@ impl<'a> Skill<'a, Aim> {
self.inner.curr_strain +=
AimEvaluator::evaluate_diff_of(curr, self.diff_objects, self.inner.with_sliders)
* SKILL_MULTIPLIER;
self.inner.inner.object_strains.push(self.inner.curr_strain);
self.inner.curr_strain
}
+189 -80
View File
@@ -1,4 +1,7 @@
use std::{cmp, f64::consts::PI};
use std::{
cmp,
f64::consts::{E, PI},
};
use crate::{
any::difficulty::{
@@ -10,7 +13,7 @@ use crate::{
GameMods,
};
use super::strain::OsuStrainSkill;
use super::strain::{DifficultyValue, OsuStrainSkill, UsedOsuStrainSkills};
const SKILL_MULTIPLIER: f64 = 1.430;
const STRAIN_DECAY_BASE: f64 = 0.3;
@@ -21,7 +24,6 @@ const REDUCED_SECTION_COUNT: usize = 5;
pub struct Speed {
curr_strain: f64,
curr_rhythm: f64,
object_strains: Vec<f64>,
hit_window: f64,
has_autopilot_mod: bool,
inner: OsuStrainSkill,
@@ -32,8 +34,6 @@ impl Speed {
Self {
curr_strain: 0.0,
curr_rhythm: 0.0,
// mean=406.72 | median=307
object_strains: Vec::with_capacity(256),
hit_window,
has_autopilot_mod: mods.ap(),
inner: OsuStrainSkill::default(),
@@ -41,20 +41,20 @@ impl Speed {
}
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,
@@ -63,13 +63,14 @@ impl Speed {
}
pub fn relevant_note_count(&self) -> f64 {
self.object_strains
self.inner
.object_strains
.iter()
.copied()
.max_by(f64::total_cmp)
.filter(|&n| n > 0.0)
.map_or(0.0, |max_strain| {
self.object_strains.iter().fold(0.0, |sum, strain| {
self.inner.object_strains.iter().fold(0.0, |sum, strain| {
sum + (1.0 + (-(strain / max_strain * 12.0 - 6.0)).exp()).recip()
})
})
@@ -135,7 +136,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
}
@@ -146,7 +147,8 @@ struct SpeedEvaluator;
impl SpeedEvaluator {
const SINGLE_SPACING_THRESHOLD: f64 = 125.0; // 1.25 circlers distance between centers
const MIN_SPEED_BONUS: f64 = 75.0; // ~200BPM
const SPEED_BALANCING_FACTOR: f64 = 40.;
const SPEED_BALANCING_FACTOR: f64 = 40.0;
const DIST_MULTIPLIER: f64 = 0.94;
fn evaluate_diff_of<'a>(
curr: &'a OsuDifficultyObject<'a>,
@@ -164,17 +166,10 @@ impl SpeedEvaluator {
let osu_next_obj = curr.next(0, diff_objects);
let mut strain_time = curr.strain_time;
let mut doubletapness = 1.0;
// * Nerf doubletappable doubles.
if let Some(osu_next_obj) = osu_next_obj {
let curr_delta_time = osu_curr_obj.delta_time.max(1.0);
let next_delta_time = osu_next_obj.delta_time.max(1.0);
let delta_diff = (next_delta_time - curr_delta_time).abs();
let speed_ratio = curr_delta_time / curr_delta_time.max(delta_diff);
let window_ratio = (curr_delta_time / hit_window).min(1.0).powf(2.0);
doubletapness = speed_ratio.powf(1.0 - window_ratio);
}
// Note: Technically `osu_next_obj` is never `None` but instead the
// default value. This could maybe invalidate the `get_doubletapness`
// result.
let doubletapness = 1.0 - osu_curr_obj.get_doubletapness(osu_next_obj, hit_window);
// * Cap deltatime to the OD 300 hitwindow.
// * 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap.
@@ -184,10 +179,10 @@ impl SpeedEvaluator {
// * Add additional scaling bonus for streams/bursts higher than 200bpm
let base = (Self::MIN_SPEED_BONUS - strain_time) / Self::SPEED_BALANCING_FACTOR;
1.0 + 0.75 * base.powf(2.0)
0.75 * base.powf(2.0)
} else {
// * speedBonus will be 1.0 for BPM < 200
1.0
// * speedBonus will be 0.0 for BPM < 200
0.0
};
let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.travel_dist);
@@ -196,15 +191,15 @@ impl SpeedEvaluator {
// * Cap distance at single_spacing_threshold
dist = Self::SINGLE_SPACING_THRESHOLD.min(dist);
// * Max distance bonus is 2 at single_spacing_threshold
// * Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
let dist_bonus = if has_autopilot_mod {
1.0
0.0
} else {
1.0 + (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.5)
(dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.95) * Self::DIST_MULTIPLIER
};
// * Base difficulty with all bonuses
let difficulty = speed_bonus * dist_bonus * 1000.0 / strain_time;
let difficulty = (1.0 + speed_bonus + dist_bonus) * 1000.0 / strain_time;
// * Apply penalty if there's doubletappable doubles
difficulty * doubletapness
@@ -214,9 +209,10 @@ impl SpeedEvaluator {
struct RhythmEvaluator;
impl RhythmEvaluator {
// * 5 seconds of calculatingRhythmBonus max.
const HISTORY_TIME_MAX: u32 = 5000;
const RHYTHM_MULTIPLIER: f64 = 0.75;
const HISTORY_TIME_MAX: u32 = 5 * 1000; // 5 seconds
const HISTORY_OBJECTS_MAX: usize = 32;
const RHYTHM_OVERALL_MULTIPLIER: f64 = 0.95;
const RHYTHM_RATIO_MULTIPLIER: f64 = 12.0;
fn evaluate_diff_of<'a>(
curr: &'a OsuDifficultyObject<'a>,
@@ -227,16 +223,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;
@@ -255,47 +258,50 @@ impl RhythmEvaluator {
.previous(rhythm_start, diff_objects)
.zip(curr.previous(rhythm_start + 1, diff_objects))
{
// * we go from the furthest object back to the current one
for i in (1..=rhythm_start).rev() {
let Some(curr_obj) = curr.previous(i - 1, diff_objects) else {
break;
};
// * scales note 0 to 1 from history to now
let mut curr_historical_decay = (f64::from(Self::HISTORY_TIME_MAX)
let time_decay = (f64::from(Self::HISTORY_TIME_MAX)
- (curr.start_time - curr_obj.start_time))
/ f64::from(Self::HISTORY_TIME_MAX);
let note_decay = (historical_note_count - i) as f64 / historical_note_count as f64;
// * either we're limited by time or limited by object count.
curr_historical_decay = curr_historical_decay
.min((historical_note_count - i) as f64 / historical_note_count as f64);
let curr_historical_decay = note_decay.min(time_decay);
let curr_delta = curr_obj.strain_time;
let prev_delta = prev_obj.strain_time;
let last_delta = last_obj.strain_time;
// * fancy function to calculate rhythmbonuses.
let base = (PI / (prev_delta.min(curr_delta) / prev_delta.max(curr_delta))).sin();
let curr_ratio = 1.0 + 6.0 * base.powf(2.0).min(0.5);
// * 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))
let window_penalty = (((prev_delta - curr_delta).abs() - delta_difference_eps)
.max(0.0)
/ delta_difference_eps)
.min(1.0);
window_penalty = window_penalty.min(1.0);
let mut effective_ratio = window_penalty * curr_ratio;
let mut effective_ratio = window_penalty * curr_ratio * fraction_multiplier;
if first_delta_switch {
// Keep in-sync with lazer
#[allow(clippy::if_not_else)]
if !(prev_delta > 1.25 * curr_delta || prev_delta * 1.25 < curr_delta) {
if island_size < 7 {
// * island is still progressing, count size.
island_size += 1;
}
if (prev_delta - curr_delta).abs() < delta_difference_eps {
// * island is still progressing
island.add_delta(curr_delta as i32);
} else {
// * bpm change is into slider, this is easy acc window
if curr_obj.base.is_slider() {
@@ -303,51 +309,86 @@ impl RhythmEvaluator {
}
// * bpm change was from a slider, this is easier typically than circle -> circle
// * unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
if prev_obj.base.is_slider() {
effective_ratio *= 0.25;
effective_ratio *= 0.3;
}
// * 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 {
// * repeated island polarity (2 -> 4, 3 -> 5)
if island.is_similar_polarity(&prev_island) {
effective_ratio *= 0.5;
}
// * previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
if last_delta > prev_delta + 10.0 && prev_delta > curr_delta + 10.0 {
if last_delta > prev_delta + delta_difference_eps
&& prev_delta > curr_delta + delta_difference_eps
{
effective_ratio *= 0.125;
}
rhythm_complexity_sum += (effective_ratio * start_ratio).sqrt()
* curr_historical_decay
* f64::from(4 + island_size).sqrt()
/ 2.0
* f64::from(4 + prev_island_size).sqrt()
/ 2.0;
// * repeated island size (ex: triplet -> triplet)
// * TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
if prev_island.delta_count == island.delta_count {
effective_ratio *= 0.5;
}
if let Some(island_count) = island_counts
.iter_mut()
.find(|entry| entry.island == island)
.filter(|entry| !entry.island.is_default())
{
// * only add island to island counts if they're going one after another
if prev_island == island {
island_count.count += 1;
}
// * repeated island (ex: triplet -> triplet)
let power = logistic(f64::from(island.delta), 2.75, 0.24, 14.0);
effective_ratio *= (3.0 / island_count.count as f64)
.min((island_count.count as f64).recip().powf(power));
} else {
island_counts.push(IslandCount { island, count: 1 });
}
// * scale down the difficulty if the object is doubletappable
let doubletapness = prev_obj.get_doubletapness(Some(curr_obj), hit_window);
effective_ratio *= 1.0 - doubletapness * 0.75;
rhythm_complexity_sum +=
(effective_ratio * start_ratio).sqrt() * curr_historical_decay;
start_ratio = effective_ratio;
// * log the last island size.
prev_island_size = island_size;
prev_island = island;
// * we're slowing down, stop counting
if prev_delta * 1.25 < curr_delta {
// * if we're speeding up, this stays true and we keep counting island size.
if prev_delta + delta_difference_eps < curr_delta {
// * if we're speeding up, this stays true and we keep counting island size.
first_delta_switch = false;
}
island_size = 1;
island =
RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps);
}
} else if prev_delta > 1.25 * curr_delta {
// * we want to be speeding up.
} else if prev_delta > curr_delta + delta_difference_eps {
// * we're speeding up.
// * Begin counting island until we change speed again.
first_delta_switch = true;
// * bpm change is into slider, this is easy acc window
if curr_obj.base.is_slider() {
effective_ratio *= 0.6;
}
// * bpm change was from a slider, this is easier typically than circle -> circle
// * unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
if prev_obj.base.is_slider() {
effective_ratio *= 0.6;
}
start_ratio = effective_ratio;
island_size = 1;
island = RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps);
}
last_obj = prev_obj;
@@ -356,6 +397,74 @@ impl RhythmEvaluator {
}
// * produces multiplier that can be applied to strain. range [1, infinity) (not really though)
(4.0 + rhythm_complexity_sum * Self::RHYTHM_MULTIPLIER).sqrt() / 2.0
(4.0 + rhythm_complexity_sum * Self::RHYTHM_OVERALL_MULTIPLIER).sqrt() / 2.0
}
}
fn logistic(x: f64, max_value: f64, multiplier: f64, offset: f64) -> f64 {
max_value / (1.0 + E.powf(offset - (multiplier * x)))
}
#[derive(Copy, Clone)]
struct RhythmIsland {
delta_difference_eps: f64,
delta: i32,
delta_count: i32,
}
const MIN_DELTA_TIME: i32 = 25;
// Compile-time check in case `OsuDifficultyObject::MIN_DELTA_TIME` changes
// but we forget to update this value.
const _: [(); 0 - !{ MIN_DELTA_TIME - OsuDifficultyObject::MIN_DELTA_TIME as i32 == 0 } as usize] =
[];
impl RhythmIsland {
fn new(delta_difference_eps: f64) -> Self {
Self {
delta_difference_eps,
delta: 0,
delta_count: 0,
}
}
fn new_with_delta(delta: i32, delta_difference_eps: f64) -> Self {
Self {
delta_difference_eps,
delta: delta.max(MIN_DELTA_TIME),
delta_count: 1,
}
}
fn add_delta(&mut self, delta: i32) {
if self.delta == i32::MAX {
self.delta = delta.max(MIN_DELTA_TIME);
}
self.delta_count += 1;
}
fn is_similar_polarity(&self, other: &Self) -> bool {
// * TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple)
// * naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation
self.delta_count % 2 == other.delta_count % 2
}
fn is_default(&self) -> bool {
self.delta_difference_eps.abs() < f64::EPSILON
&& self.delta == i32::MAX
&& self.delta_count == 0
}
}
impl PartialEq for RhythmIsland {
fn eq(&self, other: &Self) -> bool {
f64::from((self.delta - other.delta).abs()) < self.delta_difference_eps
&& self.delta_count == other.delta_count
}
}
struct IslandCount {
island: RhythmIsland,
count: usize,
}
+62 -6
View File
@@ -1,10 +1,21 @@
use crate::{any::difficulty::skills::StrainSkill, util::strains_vec::StrainsVec};
#[derive(Clone, Default)]
#[derive(Clone)]
pub struct OsuStrainSkill {
pub object_strains: Vec<f64>,
pub inner: StrainSkill,
}
impl Default for OsuStrainSkill {
fn default() -> Self {
Self {
// mean=406.72 | median=307
object_strains: Vec::with_capacity(256),
inner: Default::default(),
}
}
}
impl OsuStrainSkill {
pub const REDUCED_SECTION_COUNT: usize = 10;
pub const REDUCED_STRAIN_BASELINE: f64 = 0.75;
@@ -20,8 +31,11 @@ impl OsuStrainSkill {
self.inner.start_new_section_from(initial_strain);
}
pub fn get_curr_strain_peaks(self) -> StrainsVec {
self.inner.get_curr_strain_peaks()
pub fn get_curr_strain_peaks(self) -> UsedOsuStrainSkills<StrainsVec> {
UsedOsuStrainSkills {
value: self.inner.get_curr_strain_peaks(),
object_strains: self.object_strains,
}
}
pub fn difficulty_value(
@@ -29,11 +43,14 @@ impl OsuStrainSkill {
reduced_section_count: usize,
reduced_strain_baseline: f64,
decay_weight: f64,
) -> f64 {
) -> UsedOsuStrainSkills<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);
@@ -50,7 +67,10 @@ impl OsuStrainSkill {
weight *= decay_weight;
}
difficulty
UsedOsuStrainSkills {
value: DifficultyValue(difficulty),
object_strains,
}
}
pub fn difficulty_to_performance(difficulty: f64) -> f64 {
@@ -61,3 +81,39 @@ impl OsuStrainSkill {
fn lerp(start: f64, end: f64, amount: f64) -> f64 {
start + (end - start) * amount
}
pub struct DifficultyValue(f64);
pub struct UsedOsuStrainSkills<T> {
value: T,
object_strains: Vec<f64>,
}
impl UsedOsuStrainSkills<DifficultyValue> {
pub fn difficulty_value(&self) -> f64 {
self.value.0
}
pub fn count_difficult_strains(&self) -> f64 {
let DifficultyValue(diff) = self.value;
if diff.abs() < f64::EPSILON {
return 0.0;
}
// * What would the top strain be if all strain values were identical
let consistent_top_strain = diff / 10.0;
// * Use a weighted sum of all strains. Constants are arbitrary and give nice values
self.object_strains
.iter()
.map(|s| 1.1 / (1.0 + (-10.0 * (s / consistent_top_strain - 0.88)).exp()))
.sum()
}
}
impl UsedOsuStrainSkills<StrainsVec> {
pub fn strains(self) -> StrainsVec {
self.value
}
}
+9 -54
View File
@@ -66,53 +66,21 @@ impl OsuObject {
reflect_y(&mut self.pos.y);
if let OsuObjectKind::Slider(ref mut slider) = self.kind {
let repeat_count = slider.repeat_count();
// Requires `stack_offset` so we can't add `h.pos` just yet
slider.lazy_end_pos.y = -slider.lazy_end_pos.y;
let mut nested_iter = slider.nested_objects.iter_mut();
// Since the tail is handled differently but it's not necessarily
// the last object, we first search for it, and then handle the
// other nested objects
for nested in nested_iter.by_ref().rev() {
if let NestedSliderObjectKind::Tail = nested.kind {
let mut tail_pos = self.pos; // already reflected at this point
tail_pos += Pos::new(nested.pos.x, -nested.pos.y);
nested.pos = tail_pos;
break;
}
reflect_y(&mut nested.pos.y);
}
// Same for the last repeat point
for nested in nested_iter.by_ref().rev() {
if let NestedSliderObjectKind::Repeat = nested.kind {
nested.pos = if repeat_count % 2 == 0 {
self.pos
} else {
self.pos + Pos::new(slider.path_end_pos.x, -slider.path_end_pos.y)
};
break;
}
reflect_y(&mut nested.pos.y);
}
for nested in nested_iter {
reflect_y(&mut nested.pos.y);
for nested in slider.nested_objects.iter_mut() {
let mut nested_pos = self.pos; // already reflected at this point
nested_pos += Pos::new(nested.pos.x, -nested.pos.y);
nested.pos = nested_pos;
}
}
}
pub fn finalize_tail(&mut self) {
pub fn finalize_nested(&mut self) {
if let OsuObjectKind::Slider(ref mut slider) = self.kind {
if let Some(tail) = slider.tail_mut() {
tail.pos += self.pos;
for nested in slider.nested_objects.iter_mut() {
nested.pos += self.pos;
}
}
}
@@ -173,9 +141,6 @@ pub struct OsuSlider {
pub lazy_end_pos: Pos,
pub lazy_travel_dist: f32,
pub lazy_travel_time: f64,
// Very annoyingly, this position might be needed solely to update the last
// repeat point's position on HR.
pub path_end_pos: Pos,
pub nested_objects: Vec<NestedSliderObject>,
}
@@ -256,12 +221,12 @@ impl OsuSlider {
.filter_map(|e| {
let obj = match e.kind {
SliderEventType::Tick => NestedSliderObject {
pos: h.pos + path.position_at(e.path_progress),
pos: path.position_at(e.path_progress),
start_time: e.time,
kind: NestedSliderObjectKind::Tick,
},
SliderEventType::Repeat => NestedSliderObject {
pos: h.pos + path.position_at(e.path_progress),
pos: path.position_at(e.path_progress),
start_time: start_time + f64::from(e.span_idx + 1) * span_duration,
kind: NestedSliderObjectKind::Repeat,
},
@@ -293,14 +258,12 @@ impl OsuSlider {
}
let lazy_end_pos = path.position_at(end_time_min);
let path_end_pos = path.position_at(1.0);
Self {
end_time,
lazy_end_pos,
lazy_travel_dist: 0.0,
lazy_travel_time,
path_end_pos,
nested_objects,
}
}
@@ -352,14 +315,6 @@ impl OsuSlider {
// short and fast buzz sliders (/b/1001757)
.rfind(|nested| matches!(nested.kind, NestedSliderObjectKind::Tail))
}
fn tail_mut(&mut self) -> Option<&mut NestedSliderObject> {
self.nested_objects
.iter_mut()
// The tail is not necessarily the last nested object, e.g. on very
// short and fast buzz sliders (/b/1001757)
.rfind(|nested| matches!(nested.kind, NestedSliderObjectKind::Tail))
}
}
#[derive(Clone, Debug)]
+44 -19
View File
@@ -33,6 +33,7 @@ pub struct OsuPerformance<'map> {
pub(crate) n50: Option<u32>,
pub(crate) misses: Option<u32>,
pub(crate) hitresult_priority: HitResultPriority,
pub(crate) lazer: Option<bool>,
}
impl<'map> OsuPerformance<'map> {
@@ -152,6 +153,19 @@ impl<'map> OsuPerformance<'map> {
self
}
/// Whether the calculated attributes belong to an osu!lazer or osu!stable
/// score.
///
/// Defaults to lazer.
///
/// This affects internal accuracy calculation because lazer considers
/// slider heads for accuracy whereas stable does not.
pub const fn lazer(mut self, lazer: bool) -> Self {
self.lazer = Some(lazer);
self
}
/// Specify the amount of 300s of a play.
pub const fn n300(mut self, n300: u32) -> Self {
self.n300 = Some(n300);
@@ -509,6 +523,7 @@ impl<'map> OsuPerformance<'map> {
acc: state.accuracy(),
state,
effective_miss_count,
lazer: self.lazer.unwrap_or(true),
};
inner.calculate()
@@ -525,6 +540,7 @@ impl<'map> OsuPerformance<'map> {
n50: None,
misses: None,
hitresult_priority: HitResultPriority::DEFAULT,
lazer: None,
}
}
}
@@ -535,7 +551,8 @@ impl<'map, T: IntoModePerformance<'map, Osu>> From<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,
@@ -543,10 +560,13 @@ struct OsuPerformanceInner<'mods> {
acc: f64,
state: OsuScoreState,
effective_miss_count: f64,
lazer: bool,
}
impl OsuPerformanceInner<'_> {
fn calculate(mut self) -> OsuPerformanceAttributes {
let using_classic_slider_acc = self.mods.no_slider_head_acc(self.lazer);
let total_hits = self.state.total_hits();
if total_hits == 0 {
@@ -592,7 +612,7 @@ impl OsuPerformanceInner<'_> {
let aim_value = self.compute_aim_value();
let speed_value = self.compute_speed_value();
let acc_value = self.compute_accuracy_value();
let acc_value = self.compute_accuracy_value(using_classic_slider_acc);
let flashlight_value = self.compute_flashlight_value();
let pp = (aim_value.powf(1.1)
@@ -628,16 +648,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 {
@@ -700,16 +717,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.mods.ap() {
0.0
} else if self.attrs.ar > 10.33 {
@@ -750,7 +764,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(
@@ -761,14 +775,18 @@ impl OsuPerformanceInner<'_> {
speed_value
}
fn compute_accuracy_value(&self) -> f64 {
fn compute_accuracy_value(&self, using_classic_slider_acc: bool) -> f64 {
if self.mods.rx() {
return 0.0;
}
// * This percentage only considers HitCircles of any value - in this part
// * of the calculation we focus on hitting the timing hit window.
let amount_hit_objects_with_acc = self.attrs.n_circles;
let mut amount_hit_objects_with_acc = self.attrs.n_circles;
if using_classic_slider_acc {
amount_hit_objects_with_acc += self.attrs.n_sliders;
}
let better_acc_percentage = if amount_hit_objects_with_acc > 0 {
let sub = self.state.total_hits() - amount_hit_objects_with_acc;
@@ -842,6 +860,13 @@ impl OsuPerformanceInner<'_> {
flashlight_value
}
// * Miss penalty assumes that a player will miss on the hardest parts of a map,
// * so we use the amount of relatively difficult sections to adjust miss penalty
// * to make it more punishing on maps with lower amount of hard sections.
fn calculate_miss_penalty(&self, miss_count: f64, diff_strain_count: f64) -> f64 {
0.96 / ((miss_count / (4.0 * diff_strain_count.ln().powf(0.94))) + 1.0)
}
fn get_combo_scaling_factor(&self) -> f64 {
if self.attrs.max_combo == 0 {
1.0
+1
View File
@@ -369,6 +369,7 @@ impl<'map> TryFrom<OsuPerformance<'map>> for TaikoPerformance<'map> {
n50: _,
misses,
hitresult_priority,
lazer: _,
} = osu;
Ok(Self {