Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3a59720
fix: update OsuDifficultyObject calculations
myssto Jul 28, 2026
6441acd
feat: impl osu SnapAimEvaluator
myssto Jul 28, 2026
436c6c2
feat: impl osu AgilityEvaluator
myssto Jul 28, 2026
0d876c2
feat: impl osu FlowAimEvaluator
myssto Jul 28, 2026
7cb0bc0
feat: impl osu speed evaluator group
myssto Jul 28, 2026
8823804
fix: update osu flashlight evaluator
myssto Jul 28, 2026
857dac7
feat: impl osu ReadingEvaluator
myssto Jul 29, 2026
f49347a
refactor: wip refactor of skill traits
myssto Jul 31, 2026
28109d0
fix: various errors and formatting
myssto Jul 31, 2026
e19c790
refactor: finish refactoring osu skills
myssto Jul 31, 2026
1653b5a
feat: update osu difficulty attributes
myssto Jul 31, 2026
01ae6ab
feat: update osu performance calculator
myssto Jul 31, 2026
53849ed
fix: update OsuStrains with new skills
myssto Jul 31, 2026
ffd9966
chore: formatting and fixing warnings
myssto Jul 31, 2026
0e65cec
fix: starting debug
myssto Aug 1, 2026
e6519a4
fix: osu aim skill value parity
myssto Aug 3, 2026
21e649f
fix: osu speed skill value parity
myssto Aug 3, 2026
67fb9a9
fix: osu reading skill value parity
myssto Aug 3, 2026
dc6cc43
fix: remove debug change
myssto Aug 3, 2026
3218ed6
chore: osu nm test passing + cleanup
myssto Aug 4, 2026
7768822
fix: properly use base and clockrate adjusted object values
myssto Aug 4, 2026
b681d37
fix: flashlight difficulty value formula
myssto Aug 4, 2026
b2c2c76
fix: hd+fl bugs
myssto Aug 4, 2026
b9c7bad
chore: remove extra beatmap file
myssto Aug 4, 2026
e303c4b
feat: update osu performance test
myssto Aug 4, 2026
b5140c6
feat: impl some missed changes
myssto Aug 4, 2026
7514e5b
chore: remove unused mock cs function
myssto Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/any/difficulty/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pub mod gradual;
pub mod inspect;
pub mod object;
pub mod skills;
pub mod skills_new;

use crate::model::mode::IGameMode;

Expand Down
82 changes: 82 additions & 0 deletions src/any/difficulty/skills_new/harmonic_skill.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
use crate::{
any::difficulty::skills_new::skill::Skill,
util::traits::{IEnumerable, IOrderedEnumerable},
};

pub trait HarmonicSkill: Skill {
const HARMONIC_SCALE: f64 = 1.0;
const DECAY_EXPONENT: f64 = 0.9;

fn object_difficulty_of<'a>(
&mut self,
curr: &Self::DifficultyObject<'a>,
objects: &Self::DifficultyObjects<'a>,
) -> f64;

fn process_internal<'a>(
&mut self,
curr: &Self::DifficultyObject<'a>,
objects: &Self::DifficultyObjects<'a>,
) -> f64 {
self.object_difficulty_of(curr, objects)
}

#[expect(unused_mut, reason = "staying in-sync with lazer")]
fn get_transformed_difficulties(&self, mut difficulties: Vec<f64>) -> Vec<f64> {
difficulties
}

fn into_transformed_difficulties(self) -> Vec<f64>;

/// Returns `(difficulty_value, object_weight_sum)`.
fn difficulty_value(transformed_object_difficulties: Vec<f64>) -> (f64, f64);

#[expect(dead_code, reason = "staying in-sync with existing skills")]
fn into_difficulty_value(self) -> f64;

/// Returns `(difficulty_value, object_weight_sum)`.
fn cloned_difficulty_value(&self) -> (f64, f64);

fn count_top_weighted_object_difficulties(
&self,
difficulty_value: f64,
object_weight_sum: f64,
) -> f64;

fn difficulty_to_performance(difficulty: f64) -> f64 {
4.0 * difficulty.powf(3.0)
}
}

pub fn harmonic_skill_difficulty_value(
transformed_object_difficulties: &[f64],
harmonic_scale: f64,
decay_exponent: f64,
) -> (f64, f64) {
let mut difficulty = 0.0;
let mut object_weight_sum = 0.0;

if transformed_object_difficulties.is_empty() {
return (difficulty, object_weight_sum);
}

// * Objects with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// * These objects will not contribute to the difficulty.
for (index, obj) in transformed_object_difficulties
.to_vec()
.cs_order_descending()
.cs_where(|v| *v > 0.0)
.iter()
.enumerate()
{
// * Use a harmonic sum that considers each object of the map according to a predefined weight.
let weight = (1.0 + (harmonic_scale / (1 + index) as f64))
/ ((index as f64).powf(decay_exponent) + 1.0 + (harmonic_scale / (1 + index) as f64));

object_weight_sum += weight;

difficulty += obj * weight;
}

(difficulty, object_weight_sum)
}
66 changes: 66 additions & 0 deletions src/any/difficulty/skills_new/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
use crate::util::{difficulty::logistic, float_ext::FloatExt, hint::unlikely};

pub mod harmonic_skill;
pub mod skill;
pub mod strain_decay_skill;
pub mod strain_skill;
pub mod variable_length_strain_skill;

pub fn count_top_weighted_object_difficulties(
difficulty_value: f64,
object_difficulties: &[f64],
object_weight_sum: f64,
midpoint_offset: f64,
multiplier: f64,
max_value: Option<f64>,
) -> f64 {
if object_difficulties.is_empty() || FloatExt::eq(object_weight_sum, 0.0) {
return 0.0;
}

// * What would the top difficulty be if all object difficulties were identical
let consistent_top_obj = difficulty_value / object_weight_sum;

if FloatExt::eq(consistent_top_obj, 0.0) {
return 0.0;
}

object_difficulties
.iter()
.map(|s| {
logistic(
s / consistent_top_obj,
midpoint_offset,
multiplier,
max_value,
)
})
.sum()
}

pub fn count_top_weighted_strains(
object_difficulties: &[f64],
difficulty_value: f64,
decay_weight: f64,
) -> f64 {
if unlikely(object_difficulties.is_empty()) {
return 0.0;
}

// * What would the top strain be if all strain values were identical
let consistent_top_strain = difficulty_value * (1.0 - decay_weight);

if unlikely(FloatExt::eq(consistent_top_strain, 0.0)) {
return object_difficulties.len() as f64;
}

// * Use a weighted sum of all strains. Constants are arbitrary and give nice values
object_difficulties
.iter()
.map(|s| logistic(s / consistent_top_strain, 0.88, 10.0, Some(1.1)))
.sum()
}

pub fn strain_decay_base(ms: f64, strain_decay_base: f64) -> f64 {
f64::powf(strain_decay_base, ms / 1000.0)
}
12 changes: 12 additions & 0 deletions src/any/difficulty/skills_new/skill.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
pub trait Skill: Sized {
type DifficultyObject<'a>;
type DifficultyObjects<'a>: ?Sized;

fn process<'a>(
&mut self,
curr: &Self::DifficultyObject<'a>,
objects: &Self::DifficultyObjects<'a>,
);

fn get_object_difficulties(&self) -> &[f64];
}
14 changes: 14 additions & 0 deletions src/any/difficulty/skills_new/strain_decay_skill.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
use crate::any::difficulty::skills_new::strain_skill::NewStrainSkill;

pub trait NewStrainDecaySkill: NewStrainSkill {
const SKILL_MULTIPLIER: f64 = 0.0;
const STRAIN_DECAY_BASE: f64 = 0.0;

fn strain_value_of<'a>(
&mut self,
curr: &Self::DifficultyObject<'a>,
objects: &Self::DifficultyObjects<'a>,
) -> f64;

fn strain_decay(ms: f64) -> f64;
}
73 changes: 73 additions & 0 deletions src/any/difficulty/skills_new/strain_skill.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use crate::{
any::difficulty::skills_new::skill::Skill,
util::traits::{IEnumerable, IOrderedEnumerable},
};

pub trait NewStrainSkill: Skill {
const DECAY_WEIGHT: f64 = 0.9;
const SECTION_LENGTH: i32 = 400;

fn process_internal<'a>(
&mut self,
curr: &Self::DifficultyObject<'a>,
objects: &Self::DifficultyObjects<'a>,
) -> f64;

#[expect(dead_code, reason = "used by process_internal")]
fn strain_value_at<'a>(
&mut self,
curr: &Self::DifficultyObject<'a>,
objects: &Self::DifficultyObjects<'a>,
) -> f64;

fn count_top_weighted_strains(&self, difficulty_value: f64) -> f64;

fn save_current_peak(&mut self);

fn start_new_section_from<'a>(
&mut self,
time: f64,
curr: &Self::DifficultyObject<'a>,
objects: &Self::DifficultyObjects<'a>,
);

#[expect(dead_code, reason = "used by start_new_section_from")]
fn calculate_initial_strain<'a>(
&self,
time: f64,
curr: &Self::DifficultyObject<'a>,
objects: &Self::DifficultyObjects<'a>,
) -> f64;

fn into_current_strain_peaks(self) -> Vec<f64>;

fn get_current_strain_peaks(mut strain_peaks: Vec<f64>, current_section_peak: f64) -> Vec<f64> {
strain_peaks.push(current_section_peak);

strain_peaks
}

fn difficulty_value(current_strain_peaks: Vec<f64>) -> f64;

fn into_difficulty_value(self) -> f64;

fn cloned_difficulty_value(&self) -> f64;
}

pub fn strain_skill_difficulty_value(current_strain_peaks: Vec<f64>, decay_weight: f64) -> f64 {
let mut difficulty = 0.0;
let mut weight = 1.0;

// * 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.
let peaks = current_strain_peaks.cs_where(|&p| p > 0.0);

// * Difficulty is the weighted sum of the highest strains from every section.
// * We're sorting from highest to lowest strain.
for strain in peaks.cs_order_descending() {
difficulty += strain * weight;
weight *= decay_weight;
}

difficulty
}
111 changes: 111 additions & 0 deletions src/any/difficulty/skills_new/variable_length_strain_skill.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
use crate::{
any::difficulty::skills_new::skill::Skill,
util::traits::{IEnumerable, IOrderedEnumerable},
};

pub trait VariableLengthStrainSkill: Skill {
const DECAY_WEIGHT: f64 = 0.9;
const MAX_SECTION_LENGTH: f64 = 400.0;
const MAX_STORED_LENGTH: f64 = 11.0 / (1.0 - Self::DECAY_WEIGHT);

fn process_internal<'a>(
&mut self,
curr: &Self::DifficultyObject<'a>,
objects: &Self::DifficultyObjects<'a>,
) -> f64;

#[expect(dead_code, reason = "used by process_internal")]
fn strain_value_at<'a>(
&mut self,
curr: &Self::DifficultyObject<'a>,
objects: &Self::DifficultyObjects<'a>,
) -> f64;

fn backfill_peaks<'a>(
&mut self,
curr: &Self::DifficultyObject<'a>,
objects: &Self::DifficultyObjects<'a>,
);

fn save_current_peak(&mut self, section_length: f64);
Comment on lines +11 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is implementing these in the trait not an option? Relying on the implementation inside the macros being noticed is kinda meh, and I almost left a comment asking where the code disappeared to.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried my best to base these off of the existing skill traits where presumably the design philosophy was "if it needs to mutate stateful variables on the struct that are filled in by the macro, implement in the macro instead of the trait".


fn start_new_section_from<'a>(
&mut self,
time: f64,
curr: &Self::DifficultyObject<'a>,
objects: &Self::DifficultyObjects<'a>,
);

#[expect(dead_code, reason = "used by start_new_section_from")]
fn calculate_initial_strain<'a>(
&self,
time: f64,
curr: &Self::DifficultyObject<'a>,
objects: &Self::DifficultyObjects<'a>,
) -> f64;

fn into_current_strain_peaks(self) -> Vec<StrainPeak>;

fn get_current_strain_peaks(
mut strain_peaks: Vec<StrainPeak>,
current_section_peak: f64,
current_section_begin: f64,
current_section_end: f64,
) -> Vec<StrainPeak> {
let final_peak = StrainPeak::new(
current_section_peak,
current_section_end - current_section_begin,
);
if strain_peaks.last().is_some_and(|peak| *peak != final_peak) {
strain_peaks.cs_add_in_place(final_peak);
}

strain_peaks
}

fn count_top_weighted_strains(&self, difficulty_value: f64) -> f64;
}

#[derive(Debug, Clone, Copy)]
pub struct StrainPeak {
pub value: f64,
pub section_length: f64,
}

impl StrainPeak {
pub const fn new(value: f64, section_length: f64) -> Self {
Self {
value,
section_length: section_length.round_ties_even(),
}
}
}

impl PartialEq for StrainPeak {
fn eq(&self, other: &Self) -> bool {
self.value == other.value && self.section_length == other.section_length
}
}

impl Eq for StrainPeak {}

impl Ord for StrainPeak {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
// * Reverse sort, highest is first.
other.value.total_cmp(&self.value)
}
}

impl PartialOrd for StrainPeak {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}

impl IOrderedEnumerable<StrainPeak> for Vec<StrainPeak> {
fn cs_order_descending(mut self) -> Self {
self.sort_by(StrainPeak::cmp);

self
}
}
10 changes: 10 additions & 0 deletions src/model/mods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,16 @@ impl GameMods {
})
.flatten()
}

/// Whether Hidden is active and configured to fade the whole object
/// (not just the approach circle).
///
/// `OnlyFadeApproachCircles` is a lazer-only setting that defaults to
/// `false`, so legacy/intermode mods (which cannot carry that setting)
/// are treated the same as an explicit `false`.
pub(crate) fn hd_full_fade(&self) -> bool {
self.hd() && self.hd_only_fade_approach_circles() != Some(true)
}
}

macro_rules! impl_has_mod {
Expand Down
Loading
Loading