use std::{borrow::Cow, cmp};
use rosu_map::section::general::GameMode;
use self::calculator::OsuPerformanceCalculator;
pub use self::{calculator::PERFORMANCE_BASE_MULTIPLIER, inspect::InspectOsuPerformance};
use crate::{
Beatmap,
any::{
CalculateError, Difficulty, HitResultGenerator, HitResultPriority, InspectablePerformance,
IntoModePerformance, IntoPerformance, Performance, hitresult_generator::Fast,
},
catch::CatchPerformance,
mania::ManiaPerformance,
model::{mode::ConvertError, mods::GameMods},
osu::OsuHitResults,
taiko::TaikoPerformance,
util::map_or_attrs::MapOrAttrs,
};
use super::{
Osu,
attributes::OsuPerformanceAttributes,
score_state::{OsuScoreOrigin, OsuScoreState},
};
mod calculator;
pub mod gradual;
mod hitresult_generator;
mod inspect;
#[derive(Clone, Debug)]
#[must_use]
pub struct OsuPerformance<'map> {
pub(crate) map_or_attrs: MapOrAttrs<'map, Osu>,
pub(crate) difficulty: Difficulty,
pub(crate) acc: Option<f64>,
pub(crate) combo: Option<u32>,
pub(crate) large_tick_hits: Option<u32>,
pub(crate) small_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>,
pub(crate) misses: Option<u32>,
pub(crate) legacy_total_score: Option<u32>,
pub(crate) hitresult_priority: HitResultPriority,
pub(crate) hitresult_generator: Option<fn(InspectOsuPerformance<'_>) -> OsuHitResults>,
}
impl PartialEq for OsuPerformance<'_> {
fn eq(&self, other: &Self) -> bool {
let Self {
map_or_attrs,
difficulty,
acc,
combo,
large_tick_hits,
small_tick_hits,
slider_end_hits,
n300,
n100,
n50,
misses,
hitresult_priority,
hitresult_generator: _,
legacy_total_score,
} = self;
map_or_attrs == &other.map_or_attrs
&& difficulty == &other.difficulty
&& acc == &other.acc
&& combo == &other.combo
&& large_tick_hits == &other.large_tick_hits
&& small_tick_hits == &other.small_tick_hits
&& slider_end_hits == &other.slider_end_hits
&& n300 == &other.n300
&& n100 == &other.n100
&& n50 == &other.n50
&& misses == &other.misses
&& hitresult_priority == &other.hitresult_priority
&& legacy_total_score == &other.legacy_total_score
}
}
impl<'map> OsuPerformance<'map> {
pub fn new(map_or_attrs: impl IntoModePerformance<'map, Osu>) -> Self {
map_or_attrs.into_performance()
}
pub fn try_new(map_or_attrs: impl IntoPerformance<'map>) -> Option<Self> {
if let Performance::Osu(calc) = map_or_attrs.into_performance() {
Some(calc)
} else {
None
}
}
#[expect(
clippy::result_large_err,
reason = "Ok-variant is still larger in size"
)]
pub fn try_mode(self, mode: GameMode) -> Result<Performance<'map>, Self> {
match mode {
GameMode::Osu => Ok(Performance::Osu(self)),
GameMode::Taiko => TaikoPerformance::try_from(self).map(Performance::Taiko),
GameMode::Catch => CatchPerformance::try_from(self).map(Performance::Catch),
GameMode::Mania => ManiaPerformance::try_from(self).map(Performance::Mania),
}
}
pub fn mode_or_ignore(self, mode: GameMode) -> Performance<'map> {
match mode {
GameMode::Osu => Performance::Osu(self),
GameMode::Taiko => {
TaikoPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Taiko)
}
GameMode::Catch => {
CatchPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Catch)
}
GameMode::Mania => {
ManiaPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Mania)
}
}
}
pub fn mods(mut self, mods: impl Into<GameMods>) -> Self {
self.difficulty = self.difficulty.mods(mods);
self
}
pub const fn combo(mut self, combo: u32) -> Self {
self.combo = Some(combo);
self
}
pub const fn hitresult_priority(mut self, priority: HitResultPriority) -> Self {
self.hitresult_priority = priority;
self
}
pub fn hitresult_generator<H: HitResultGenerator<Osu>>(self) -> OsuPerformance<'map> {
OsuPerformance {
map_or_attrs: self.map_or_attrs,
difficulty: self.difficulty,
acc: self.acc,
combo: self.combo,
large_tick_hits: self.large_tick_hits,
small_tick_hits: self.small_tick_hits,
slider_end_hits: self.slider_end_hits,
n300: self.n300,
n100: self.n100,
n50: self.n50,
misses: self.misses,
hitresult_priority: self.hitresult_priority,
hitresult_generator: Some(H::generate_hitresults),
legacy_total_score: self.legacy_total_score,
}
}
pub fn lazer(mut self, lazer: bool) -> Self {
self.difficulty = self.difficulty.lazer(lazer);
self
}
pub const fn large_tick_hits(mut self, large_tick_hits: u32) -> Self {
self.large_tick_hits = Some(large_tick_hits);
self
}
pub const fn small_tick_hits(mut self, small_tick_hits: u32) -> Self {
self.small_tick_hits = Some(small_tick_hits);
self
}
pub const fn slider_end_hits(mut self, slider_end_hits: u32) -> Self {
self.slider_end_hits = Some(slider_end_hits);
self
}
pub const fn n300(mut self, n300: u32) -> Self {
self.n300 = Some(n300);
self
}
pub const fn n100(mut self, n100: u32) -> Self {
self.n100 = Some(n100);
self
}
pub const fn n50(mut self, n50: u32) -> Self {
self.n50 = Some(n50);
self
}
pub const fn misses(mut self, n_misses: u32) -> Self {
self.misses = Some(n_misses);
self
}
pub fn difficulty(mut self, difficulty: Difficulty) -> Self {
self.difficulty = difficulty;
self
}
pub fn passed_objects(mut self, passed_objects: u32) -> Self {
self.difficulty = self.difficulty.passed_objects(passed_objects);
self
}
pub fn clock_rate(mut self, clock_rate: f64) -> Self {
self.difficulty = self.difficulty.clock_rate(clock_rate);
self
}
pub fn ar(mut self, ar: f32, fixed: bool) -> Self {
self.difficulty = self.difficulty.ar(ar, fixed);
self
}
pub fn cs(mut self, cs: f32, fixed: bool) -> Self {
self.difficulty = self.difficulty.cs(cs, fixed);
self
}
pub fn hp(mut self, hp: f32, fixed: bool) -> Self {
self.difficulty = self.difficulty.hp(hp, fixed);
self
}
pub fn od(mut self, od: f32, fixed: bool) -> Self {
self.difficulty = self.difficulty.od(od, fixed);
self
}
pub const fn legacy_total_score(mut self, legacy_total_score: u32) -> Self {
self.legacy_total_score = Some(legacy_total_score);
self
}
pub const fn state(mut self, state: OsuScoreState) -> Self {
let OsuScoreState {
max_combo,
hitresults,
legacy_total_score,
} = state;
self.combo = Some(max_combo);
self.legacy_total_score = legacy_total_score;
self.hitresults(hitresults)
}
#[expect(clippy::needless_pass_by_value, reason = "more convenient")]
pub const fn hitresults(mut self, hitresults: OsuHitResults) -> Self {
let OsuHitResults {
large_tick_hits,
small_tick_hits,
slider_end_hits,
n300,
n100,
n50,
misses,
} = hitresults;
self.large_tick_hits = Some(large_tick_hits);
self.small_tick_hits = Some(small_tick_hits);
self.slider_end_hits = Some(slider_end_hits);
self.n300 = Some(n300);
self.n100 = Some(n100);
self.n50 = Some(n50);
self.misses = Some(misses);
self
}
pub fn accuracy(mut self, acc: f64) -> Self {
self.acc = Some(acc.clamp(0.0, 100.0) / 100.0);
self
}
pub fn generate_state(&mut self) -> Result<OsuScoreState, ConvertError> {
self.map_or_attrs.insert_attrs(&self.difficulty)?;
let state = unsafe { generate_state(self) };
Ok(state)
}
pub fn checked_generate_state(&mut self) -> Result<OsuScoreState, CalculateError> {
self.map_or_attrs.checked_insert_attrs(&self.difficulty)?;
let state = unsafe { generate_state(self) };
Ok(state)
}
pub fn calculate(mut self) -> Result<OsuPerformanceAttributes, ConvertError> {
let state = self.generate_state()?;
let attrs = unsafe { calculate(self, state) };
Ok(attrs)
}
pub fn checked_calculate(mut self) -> Result<OsuPerformanceAttributes, CalculateError> {
let state = self.checked_generate_state()?;
let attrs = unsafe { calculate(self, state) };
Ok(attrs)
}
pub(crate) const fn from_map_or_attrs(map_or_attrs: MapOrAttrs<'map, Osu>) -> Self {
Self {
map_or_attrs,
difficulty: Difficulty::new(),
acc: None,
combo: None,
large_tick_hits: None,
small_tick_hits: None,
slider_end_hits: None,
n300: None,
n100: None,
n50: None,
misses: None,
hitresult_priority: HitResultPriority::DEFAULT,
hitresult_generator: None,
legacy_total_score: None,
}
}
#[expect(
clippy::result_large_err,
reason = "Result type serves more as 'Either'"
)]
pub(crate) fn try_convert_map(
map_or_attrs: MapOrAttrs<'map, Osu>,
mode: GameMode,
mods: &GameMods,
) -> Result<Cow<'map, Beatmap>, MapOrAttrs<'map, Osu>> {
let MapOrAttrs::Map(map) = map_or_attrs else {
return Err(map_or_attrs);
};
match map {
Cow::Borrowed(map) => match map.convert_ref(mode, mods) {
Ok(map) => Ok(map),
Err(_) => Err(MapOrAttrs::Map(Cow::Borrowed(map))),
},
Cow::Owned(mut map) => {
if map.convert_mut(mode, mods).is_err() {
return Err(MapOrAttrs::Map(Cow::Owned(map)));
}
Ok(Cow::Owned(map))
}
}
}
}
impl<'map, T: IntoModePerformance<'map, Osu>> From<T> for OsuPerformance<'map> {
fn from(into: T) -> Self {
into.into_performance()
}
}
unsafe fn generate_state(perf: &mut OsuPerformance<'_>) -> OsuScoreState {
let attrs = unsafe { perf.map_or_attrs.get_attrs() };
let inspect = Osu::inspect_performance(perf, attrs);
let total_hits = inspect.total_hits();
let misses = inspect.misses();
let mut hitresults = match perf.hitresult_generator {
Some(generator) => generator(inspect),
None => <Fast as HitResultGenerator<Osu>>::generate_hitresults(inspect),
};
let remain = total_hits.saturating_sub(hitresults.total_hits());
match perf.hitresult_priority {
HitResultPriority::BestCase => match (perf.n300, perf.n100, perf.n50) {
(None, ..) => hitresults.n300 += remain,
(_, None, _) => hitresults.n100 += remain,
(.., None) => hitresults.n50 += remain,
_ => hitresults.n300 += remain,
},
HitResultPriority::WorstCase => match (perf.n50, perf.n100, perf.n300) {
(None, ..) => hitresults.n50 += remain,
(_, None, _) => hitresults.n100 += remain,
(.., None) => hitresults.n300 += remain,
_ => hitresults.n50 += remain,
},
}
let max_possible_combo = attrs.max_combo.saturating_sub(misses);
let max_combo = perf.combo.map_or(max_possible_combo, |combo| {
cmp::min(combo, max_possible_combo)
});
perf.combo = Some(max_combo);
let OsuHitResults {
large_tick_hits,
small_tick_hits,
slider_end_hits,
n300,
n100,
n50,
misses,
} = &hitresults;
perf.slider_end_hits = Some(*slider_end_hits);
perf.large_tick_hits = Some(*large_tick_hits);
perf.small_tick_hits = Some(*small_tick_hits);
perf.n300 = Some(*n300);
perf.n100 = Some(*n100);
perf.n50 = Some(*n50);
perf.misses = Some(*misses);
OsuScoreState {
max_combo,
hitresults,
legacy_total_score: perf.legacy_total_score,
}
}
unsafe fn calculate(perf: OsuPerformance<'_>, state: OsuScoreState) -> OsuPerformanceAttributes {
let attrs = unsafe { perf.map_or_attrs.into_attrs() };
let mods = perf.difficulty.get_mods();
let lazer = perf.difficulty.get_lazer();
let using_classic_slider_acc = mods.no_slider_head_acc(lazer);
let origin = match (lazer, using_classic_slider_acc) {
(false, _) => OsuScoreOrigin::Stable,
(true, false) => OsuScoreOrigin::WithSliderAcc {
max_large_ticks: attrs.n_large_ticks,
max_slider_ends: attrs.n_sliders,
},
(true, true) => OsuScoreOrigin::WithoutSliderAcc {
max_large_ticks: attrs.n_sliders + attrs.n_large_ticks,
max_small_ticks: attrs.n_sliders,
},
};
let acc = state.hitresults.accuracy(origin);
let inner = OsuPerformanceCalculator::new(attrs, mods, acc, state, using_classic_slider_acc);
inner.calculate()
}
#[cfg(test)]
mod test {
use std::sync::OnceLock;
use crate::{
Beatmap,
any::{DifficultyAttributes, PerformanceAttributes},
osu::OsuDifficultyAttributes,
taiko::{TaikoDifficultyAttributes, TaikoPerformanceAttributes},
};
use super::*;
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()
}
fn attrs() -> OsuDifficultyAttributes {
ATTRS
.get_or_init(|| {
let map = beatmap();
let attrs = Difficulty::new().calculate_for_mode::<Osu>(&map).unwrap();
assert_eq!(
(attrs.n_circles, attrs.n_sliders, attrs.n_spinners),
(307, 293, 1)
);
assert_eq!(
attrs.n_circles + attrs.n_sliders + attrs.n_spinners,
N_OBJECTS,
);
assert_eq!(attrs.n_sliders, N_SLIDERS);
assert_eq!(attrs.n_large_ticks, N_SLIDER_TICKS);
attrs
})
.to_owned()
}
#[test]
fn hitresults_n300_n100_misses_best() {
let state = OsuPerformance::from(attrs())
.combo(500)
.lazer(true)
.n300(300)
.n100(20)
.misses(2)
.hitresult_priority(HitResultPriority::BestCase)
.generate_state()
.unwrap();
let expected = OsuHitResults {
large_tick_hits: N_SLIDER_TICKS,
small_tick_hits: 0,
slider_end_hits: N_SLIDERS,
n300: 300,
n100: 20,
n50: 279,
misses: 2,
};
assert_eq!(state.hitresults, expected);
}
#[test]
fn hitresults_n300_n50_misses_best() {
let state = OsuPerformance::from(attrs())
.lazer(false)
.combo(500)
.n300(300)
.n50(10)
.misses(2)
.hitresult_priority(HitResultPriority::BestCase)
.generate_state()
.unwrap();
let expected = OsuHitResults {
large_tick_hits: 0,
small_tick_hits: 0,
slider_end_hits: 0,
n300: 300,
n100: 289,
n50: 10,
misses: 2,
};
assert_eq!(state.hitresults, expected);
}
#[test]
fn hitresults_n50_misses_worst() {
let state = OsuPerformance::from(attrs())
.lazer(true)
.combo(500)
.n50(10)
.misses(2)
.hitresult_priority(HitResultPriority::WorstCase)
.generate_state()
.unwrap();
let expected = OsuHitResults {
large_tick_hits: N_SLIDER_TICKS,
small_tick_hits: 0,
slider_end_hits: N_SLIDERS,
n300: 0,
n100: 589,
n50: 10,
misses: 2,
};
assert_eq!(state.hitresults, expected);
}
#[test]
fn hitresults_n300_n100_n50_misses_worst() {
let state = OsuPerformance::from(attrs())
.lazer(false)
.combo(500)
.n300(300)
.n100(50)
.n50(10)
.misses(2)
.hitresult_priority(HitResultPriority::WorstCase)
.generate_state()
.unwrap();
let expected = OsuHitResults {
large_tick_hits: 0,
small_tick_hits: 0,
slider_end_hits: 0,
n300: 300,
n100: 50,
n50: 249,
misses: 2,
};
assert_eq!(state.hitresults, expected);
}
#[test]
fn create() {
let mut map = beatmap();
let _ = OsuPerformance::new(OsuDifficultyAttributes::default());
let _ = OsuPerformance::new(OsuPerformanceAttributes::default());
let _ = OsuPerformance::new(&map);
let _ = OsuPerformance::new(map.clone());
let _ = OsuPerformance::try_new(OsuDifficultyAttributes::default()).unwrap();
let _ = OsuPerformance::try_new(OsuPerformanceAttributes::default()).unwrap();
let _ =
OsuPerformance::try_new(DifficultyAttributes::Osu(OsuDifficultyAttributes::default()))
.unwrap();
let _ = OsuPerformance::try_new(PerformanceAttributes::Osu(
OsuPerformanceAttributes::default(),
))
.unwrap();
let _ = OsuPerformance::try_new(&map).unwrap();
let _ = OsuPerformance::try_new(map.clone()).unwrap();
let _ = OsuPerformance::from(OsuDifficultyAttributes::default());
let _ = OsuPerformance::from(OsuPerformanceAttributes::default());
let _ = OsuPerformance::from(&map);
let _ = OsuPerformance::from(map.clone());
let _ = OsuDifficultyAttributes::default().performance();
let _ = OsuPerformanceAttributes::default().performance();
map.convert_mut(GameMode::Taiko, &GameMods::default())
.unwrap();
assert!(OsuPerformance::try_new(TaikoDifficultyAttributes::default()).is_none());
assert!(OsuPerformance::try_new(TaikoPerformanceAttributes::default()).is_none());
assert!(
OsuPerformance::try_new(DifficultyAttributes::Taiko(
TaikoDifficultyAttributes::default()
))
.is_none()
);
assert!(
OsuPerformance::try_new(PerformanceAttributes::Taiko(
TaikoPerformanceAttributes::default()
))
.is_none()
);
assert!(OsuPerformance::try_new(&map).is_none());
assert!(OsuPerformance::try_new(map).is_none());
}
}