initial rewrite

This commit is contained in:
MaxOhn
2024-02-09 17:54:14 +01:00
parent ecf1b2c7f4
commit a8bf9601b6
190 changed files with 10892 additions and 18142 deletions
+186
View File
@@ -0,0 +1,186 @@
use crate::{
catch::{Catch, CatchDifficultyAttributes, CatchPerformanceAttributes},
mania::{Mania, ManiaDifficultyAttributes, ManiaPerformanceAttributes},
model::mode::IGameMode,
osu::{Osu, OsuDifficultyAttributes, OsuPerformanceAttributes},
taiko::{Taiko, TaikoDifficultyAttributes, TaikoPerformanceAttributes},
};
use super::performance::Performance;
/// The result of a difficulty calculation based on the mode.
#[derive(Clone, Debug, PartialEq)]
pub enum DifficultyAttributes {
/// osu!standard difficulty calculation result.
Osu(OsuDifficultyAttributes),
/// osu!taiko difficulty calculation result.
Taiko(TaikoDifficultyAttributes),
/// osu!catch difficulty calculation result.
Catch(CatchDifficultyAttributes),
/// osu!mania difficulty calculation result.
Mania(ManiaDifficultyAttributes),
}
impl DifficultyAttributes {
/// The star value.
pub const fn stars(&self) -> f64 {
match self {
Self::Osu(attrs) => attrs.stars,
Self::Taiko(attrs) => attrs.stars,
Self::Catch(attrs) => attrs.stars,
Self::Mania(attrs) => attrs.stars,
}
}
/// The maximum combo of the map.
pub const fn max_combo(&self) -> u32 {
match self {
Self::Osu(attrs) => attrs.max_combo,
Self::Taiko(attrs) => attrs.max_combo,
Self::Catch(attrs) => attrs.max_combo(),
Self::Mania(attrs) => attrs.max_combo,
}
}
/// Returns a builder for performance calculation.
pub fn pp<'a>(self) -> Performance<'a> {
self.into()
}
}
/// The result of a performance calculation based on the mode.
#[derive(Clone, Debug, PartialEq)]
pub enum PerformanceAttributes {
/// osu!standard performance calculation result.
Osu(OsuPerformanceAttributes),
/// osu!taiko performance calculation result.
Taiko(TaikoPerformanceAttributes),
/// osu!catch performance calculation result.
Catch(CatchPerformanceAttributes),
/// osu!mania performance calculation result.
Mania(ManiaPerformanceAttributes),
}
impl PerformanceAttributes {
/// The pp value.
pub const fn pp(&self) -> f64 {
match self {
Self::Osu(attrs) => attrs.pp,
Self::Taiko(attrs) => attrs.pp,
Self::Catch(attrs) => attrs.pp,
Self::Mania(attrs) => attrs.pp,
}
}
/// The star value.
pub const fn stars(&self) -> f64 {
match self {
Self::Osu(attrs) => attrs.stars(),
Self::Taiko(attrs) => attrs.stars(),
Self::Catch(attrs) => attrs.stars(),
Self::Mania(attrs) => attrs.stars(),
}
}
/// Difficulty attributes that were used for the performance calculation.
pub fn difficulty_attributes(&self) -> DifficultyAttributes {
match self {
Self::Osu(attrs) => DifficultyAttributes::Osu(attrs.difficulty.clone()),
Self::Taiko(attrs) => DifficultyAttributes::Taiko(attrs.difficulty.clone()),
Self::Catch(attrs) => DifficultyAttributes::Catch(attrs.difficulty.clone()),
Self::Mania(attrs) => DifficultyAttributes::Mania(attrs.difficulty.clone()),
}
}
/// The maximum combo of the map.
pub const fn max_combo(&self) -> u32 {
match self {
Self::Osu(attrs) => attrs.difficulty.max_combo,
Self::Taiko(attrs) => attrs.difficulty.max_combo,
Self::Catch(attrs) => attrs.difficulty.max_combo(),
Self::Mania(attrs) => attrs.difficulty.max_combo,
}
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait AttributeProvider {
/// Provide the actual difficulty attributes.
fn attributes(self) -> DifficultyAttributes;
}
impl AttributeProvider for DifficultyAttributes {
fn attributes(self) -> DifficultyAttributes {
self
}
}
impl AttributeProvider for PerformanceAttributes {
fn attributes(self) -> DifficultyAttributes {
match self {
Self::Osu(attrs) => DifficultyAttributes::Osu(attrs.difficulty),
Self::Taiko(attrs) => DifficultyAttributes::Taiko(attrs.difficulty),
Self::Catch(attrs) => DifficultyAttributes::Catch(attrs.difficulty),
Self::Mania(attrs) => DifficultyAttributes::Mania(attrs.difficulty),
}
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait ModeAttributeProvider<M: IGameMode> {
/// Provide the actual difficulty attributes.
fn attributes(self) -> Option<M::DifficultyAttributes>;
}
impl<M: IGameMode> ModeAttributeProvider<M> for M::DifficultyAttributes {
fn attributes(self) -> Option<M::DifficultyAttributes> {
Some(self)
}
}
macro_rules! impl_attr_provider {
( $mode:ident: $difficulty:ident, $performance:ident ) => {
impl AttributeProvider for $difficulty {
fn attributes(self) -> DifficultyAttributes {
DifficultyAttributes::$mode(self)
}
}
impl AttributeProvider for $performance {
fn attributes(self) -> DifficultyAttributes {
DifficultyAttributes::$mode(self.difficulty)
}
}
impl ModeAttributeProvider<$mode> for $performance {
fn attributes(self) -> Option<<$mode as IGameMode>::DifficultyAttributes> {
Some(self.difficulty)
}
}
impl ModeAttributeProvider<$mode> for DifficultyAttributes {
fn attributes(self) -> Option<<$mode as IGameMode>::DifficultyAttributes> {
if let Self::$mode(attrs) = self {
Some(attrs)
} else {
None
}
}
}
impl ModeAttributeProvider<$mode> for PerformanceAttributes {
fn attributes(self) -> Option<<$mode as IGameMode>::DifficultyAttributes> {
if let Self::$mode(attrs) = self {
Some(attrs.difficulty)
} else {
None
}
}
}
};
}
impl_attr_provider!(Catch: CatchDifficultyAttributes, CatchPerformanceAttributes);
impl_attr_provider!(Mania: ManiaDifficultyAttributes, ManiaPerformanceAttributes);
impl_attr_provider!(Osu: OsuDifficultyAttributes, OsuPerformanceAttributes);
impl_attr_provider!(Taiko: TaikoDifficultyAttributes, TaikoPerformanceAttributes);
+210
View File
@@ -0,0 +1,210 @@
use std::borrow::Cow;
use rosu_map::section::general::GameMode;
use crate::{
catch::{Catch, CatchBeatmap},
mania::{Mania, ManiaBeatmap},
model::beatmap::{Beatmap, Converted},
osu::{Osu, OsuBeatmap},
taiko::{Taiko, TaikoBeatmap},
};
pub use self::mode::ModeDifficulty;
use super::attributes::DifficultyAttributes;
mod mode;
pub mod object;
pub mod skills;
/// Difficulty calculator on maps of any mode.
#[derive(Clone, Debug, PartialEq)]
pub struct Difficulty<'map> {
map: Cow<'map, Beatmap>,
is_convert: bool,
inner: ModeDifficulty,
}
impl<'map> Difficulty<'map> {
/// Create a new difficulty calculator for the given beatmap.
pub fn new(map: &'map Beatmap) -> Self {
Self::new_with_is_convert(Cow::Borrowed(map), false)
}
fn new_with_is_convert(map: Cow<'map, Beatmap>, is_convert: bool) -> Self {
Self {
map,
is_convert,
inner: ModeDifficulty::new(),
}
}
}
macro_rules! impl_from_mode {
( $mode:ident ) => {
impl<'a> From<Converted<'a, $mode>> for Difficulty<'a> {
fn from(converted: Converted<'a, $mode>) -> Self {
Self::new_with_is_convert(converted.map, converted.is_convert)
}
}
impl<'a, 'b: 'a> From<&'b Converted<'a, $mode>> for Difficulty<'a> {
fn from(converted: &'b Converted<'a, $mode>) -> Self {
Self::new_with_is_convert(
Cow::Borrowed(converted.map.as_ref()),
converted.is_convert,
)
}
}
};
}
impl_from_mode!(Osu);
impl_from_mode!(Taiko);
impl_from_mode!(Catch);
impl_from_mode!(Mania);
impl Difficulty<'_> {
/// Attempt to convert the map to the specified mode.
///
/// If the conversion is incompatible, `None` is returned.
///
/// If the given mode should be ignored in case it is incompatible, use
/// [`mode_or_ignore`] instead.
///
/// [`mode_or_ignore`]: Self::mode_or_ignore
pub fn try_mode(&mut self, mode: GameMode) -> Option<&mut Self> {
let (map, is_convert) = match mode {
GameMode::Osu => {
let converted = OsuBeatmap::try_from_ref(self.map.as_ref())?;
(converted.map, converted.is_convert)
}
GameMode::Taiko => {
let converted = TaikoBeatmap::try_from_ref(self.map.as_ref())?;
(converted.map, converted.is_convert)
}
GameMode::Catch => {
let converted = CatchBeatmap::try_from_ref(self.map.as_ref())?;
(converted.map, converted.is_convert)
}
GameMode::Mania => {
let converted = ManiaBeatmap::try_from_ref(self.map.as_ref())?;
(converted.map, converted.is_convert)
}
};
if matches!(map, Cow::Owned(_)) {
self.is_convert |= is_convert;
let map = map.into_owned();
self.map = Cow::Owned(map);
}
Some(self)
}
/// Attempt to convert the map to the specified mode.
///
/// If the conversion is incompatible, the map won't be modified.
///
/// To see whether the given mode is incompatible, use [`try_mode`]
/// instead.
///
/// [`try_mode`]: Self::try_mode
pub fn mode_or_ignore(&mut self, mode: GameMode) -> &mut Self {
let (map, is_convert) = match mode {
GameMode::Osu => {
let Some(converted) = OsuBeatmap::try_from_ref(self.map.as_ref()) else {
return self;
};
(converted.map, converted.is_convert)
}
GameMode::Taiko => {
let Some(converted) = TaikoBeatmap::try_from_ref(self.map.as_ref()) else {
return self;
};
(converted.map, converted.is_convert)
}
GameMode::Catch => {
let Some(converted) = CatchBeatmap::try_from_ref(self.map.as_ref()) else {
return self;
};
(converted.map, converted.is_convert)
}
GameMode::Mania => {
let Some(converted) = ManiaBeatmap::try_from_ref(self.map.as_ref()) else {
return self;
};
(converted.map, converted.is_convert)
}
};
if matches!(map, Cow::Owned(_)) {
self.is_convert |= is_convert;
let map = map.into_owned();
self.map = Cow::Owned(map);
}
self
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
pub fn mods(&mut self, mods: u32) -> &mut Self {
self.inner.mods(mods);
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
pub fn passed_objects(&mut self, passed_objects: u32) -> &mut Self {
self.inner.passed_objects(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
///
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
pub fn clock_rate(&mut self, clock_rate: f64) -> &mut Self {
self.inner.clock_rate(clock_rate);
self
}
/// Perform the difficulty calculation.
///
/// The returned attributes depend on the map's mode.
pub fn calculate(&self) -> DifficultyAttributes {
let map = Cow::Borrowed(self.map.as_ref());
match self.map.mode {
GameMode::Osu => DifficultyAttributes::Osu(
self.inner
.calculate(&Converted::<Osu>::new(map, self.is_convert)),
),
GameMode::Taiko => DifficultyAttributes::Taiko(
self.inner
.calculate(&Converted::<Taiko>::new(map, self.is_convert)),
),
GameMode::Catch => DifficultyAttributes::Catch(
self.inner
.calculate(&Converted::<Catch>::new(map, self.is_convert)),
),
GameMode::Mania => DifficultyAttributes::Mania(
self.inner
.calculate(&Converted::<Mania>::new(map, self.is_convert)),
),
}
}
}
+69
View File
@@ -0,0 +1,69 @@
use crate::{
model::{beatmap::Converted, mode::IGameMode},
util::mods::Mods,
};
/// Difficulty calculator on maps of a given mode.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModeDifficulty {
mods: u32,
passed_objects: Option<u32>,
clock_rate: Option<f64>,
}
impl ModeDifficulty {
/// Create a new difficulty calculator.
pub fn new() -> Self {
Self::default()
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
pub fn mods(&mut self, mods: u32) -> &mut Self {
self.mods = mods;
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
pub fn passed_objects(&mut self, passed_objects: u32) -> &mut Self {
self.passed_objects = Some(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
///
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
pub fn clock_rate(&mut self, clock_rate: f64) -> &mut Self {
self.clock_rate = Some(clock_rate);
self
}
/// Perform the difficulty calculation for a [`Converted`] beatmap and
/// process the final skill values.
pub fn calculate<M: IGameMode>(&self, map: &Converted<'_, M>) -> M::DifficultyAttributes {
M::difficulty(self, map)
}
/// Perform a difficulty calculation for a [`Converted`] beatmap without
/// processing the final skill values.
pub fn strains<M: IGameMode>(&self, map: &Converted<'_, M>) -> M::Strains {
M::strains(self, map)
}
pub(crate) const fn get_mods(&self) -> u32 {
self.mods
}
pub(crate) fn get_clock_rate(&self) -> f64 {
self.clock_rate.unwrap_or_else(|| self.mods.clock_rate())
}
pub(crate) fn get_passed_objects(&self) -> usize {
self.passed_objects.map_or(usize::MAX, |n| n as usize)
}
}
+13
View File
@@ -0,0 +1,13 @@
pub trait IDifficultyObject: Sized {
fn idx(&self) -> usize;
fn previous<'a, D>(&self, backwards_idx: usize, diff_objects: &'a [D]) -> Option<&'a D> {
self.idx()
.checked_sub(backwards_idx + 1)
.and_then(|idx| diff_objects.get(idx))
}
fn next<'a, D>(&self, forwards_idx: usize, diff_objects: &'a [D]) -> Option<&'a D> {
diff_objects.get(self.idx() + (forwards_idx + 1))
}
}
+94
View File
@@ -0,0 +1,94 @@
pub fn strain_decay(ms: f64, strain_decay_base: f64) -> f64 {
strain_decay_base.powf(ms / 1000.0)
}
/// Wrapper around a difficulty skill that carries a list of all difficulty
/// objects.
pub struct Skill<'a, S: ISkill> {
pub inner: S,
pub diff_objects: &'a S::DifficultyObjects<'a>,
}
impl<'a, S: ISkill> Skill<'a, S> {
pub const fn new(skill: S, diff_objects: &'a S::DifficultyObjects<'a>) -> Self {
Self {
inner: skill,
diff_objects,
}
}
}
/// Trait required for [`Skill`].
pub trait ISkill {
type DifficultyObjects<'a>: ?Sized;
}
#[derive(Clone, Default)]
pub struct StrainSkill {
pub curr_section_peak: f64,
pub curr_section_end: f64,
pub strain_peaks: Vec<f64>, // TODO: default capacity
}
impl StrainSkill {
pub const DECAY_WEIGHT: f64 = 0.9;
pub const SECTION_LEN: f64 = 400.0;
pub fn save_curr_peak(&mut self) {
self.strain_peaks.push(self.curr_section_peak);
}
pub fn start_new_section_from(&mut self, initial_strain: f64) {
self.curr_section_peak = initial_strain;
}
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
let mut strain_peaks = self.strain_peaks;
strain_peaks.push(self.curr_section_peak);
strain_peaks
}
pub fn difficulty_value(self, decay_weight: f64) -> f64 {
let mut difficulty = 0.0;
let mut weight = 1.0;
let mut peaks = self.get_curr_strain_peaks();
peaks.retain(|&strain| strain > 0.0);
peaks.sort_by(|a, b| b.total_cmp(a));
for strain in peaks {
difficulty += strain * weight;
weight *= decay_weight;
}
difficulty
}
}
#[derive(Clone, Default)]
pub struct StrainDecaySkill {
pub inner: StrainSkill,
pub curr_strain: f64,
}
impl StrainDecaySkill {
pub const DECAY_WEIGHT: f64 = StrainSkill::DECAY_WEIGHT;
pub const SECTION_LEN: f64 = StrainSkill::SECTION_LEN;
pub fn save_curr_peak(&mut self) {
self.inner.save_curr_peak();
}
pub fn start_new_section_from(&mut self, initial_strain: f64) {
self.inner.start_new_section_from(initial_strain);
}
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub fn difficulty_value(self, decay_weight: f64) -> f64 {
self.inner.difficulty_value(decay_weight)
}
}
+13
View File
@@ -0,0 +1,13 @@
pub use self::{
attributes::{
AttributeProvider, DifficultyAttributes, ModeAttributeProvider, PerformanceAttributes,
},
difficulty::{Difficulty, ModeDifficulty},
performance::{HitResultPriority, Performance},
score_state::ScoreState,
};
mod attributes;
pub(crate) mod difficulty;
mod performance;
mod score_state;
+97 -157
View File
@@ -1,63 +1,41 @@
use std::borrow::Cow;
use rosu_map::section::general::GameMode;
use crate::{
catch::{CatchDifficultyAttributes, CatchPP, CatchPerformanceAttributes},
mania::{ManiaDifficultyAttributes, ManiaPP, ManiaPerformanceAttributes},
osu::{OsuDifficultyAttributes, OsuPP, OsuPerformanceAttributes},
taiko::{TaikoDifficultyAttributes, TaikoPP, TaikoPerformanceAttributes},
Beatmap, DifficultyAttributes, GameMode, PerformanceAttributes, ScoreState,
any::attributes::DifficultyAttributes,
catch::{Catch, CatchPerformance},
mania::{Mania, ManiaPerformance},
model::beatmap::{Beatmap, Converted},
osu::{Osu, OsuPerformance},
taiko::{Taiko, TaikoPerformance},
};
use super::{
attributes::{AttributeProvider, PerformanceAttributes},
score_state::ScoreState,
};
/// Performance calculator on maps of any mode.
///
/// # Example
///
/// ```no_run
/// use rosu_pp::{AnyPP, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
///
/// # let map = Beatmap::default();
/// let pp_result = AnyPP::new(&map)
/// .mods(8 + 64) // HDDT
/// .combo(1234)
/// .accuracy(98.5)
/// .n_misses(1)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", pp_result.pp(), pp_result.stars());
///
/// let next_result = AnyPP::new(&map)
/// .attributes(pp_result) // reusing previous results for performance
/// .mods(8 + 64) // has to be the same to reuse attributes
/// .accuracy(99.5)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
/// ```
#[allow(clippy::upper_case_acronyms)]
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub enum AnyPP<'map> {
/// osu!standard performance calculator
Osu(OsuPP<'map>),
/// osu!taiko performance calculator
Taiko(TaikoPP<'map>),
/// osu!catch performance calculator
Catch(CatchPP<'map>),
/// osu!mania performance calculator
Mania(ManiaPP<'map>),
pub enum Performance<'map> {
Osu(OsuPerformance<'map>),
Taiko(TaikoPerformance<'map>),
Catch(CatchPerformance<'map>),
Mania(ManiaPerformance<'map>),
}
impl<'map> AnyPP<'map> {
impl<'map> Performance<'map> {
/// Create a new performance calculator for maps of any mode.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
let map = Cow::Borrowed(map);
match map.mode {
GameMode::Osu => Self::Osu(OsuPP::new(map)),
GameMode::Taiko => Self::Taiko(TaikoPP::new(map)),
GameMode::Catch => Self::Catch(CatchPP::new(map)),
GameMode::Mania => Self::Mania(ManiaPP::new(map)),
GameMode::Osu => Self::Osu(OsuPerformance::new(Converted::new(map, false))),
GameMode::Taiko => Self::Taiko(TaikoPerformance::new(Converted::new(map, false))),
GameMode::Catch => Self::Catch(CatchPerformance::new(Converted::new(map, false))),
GameMode::Mania => Self::Mania(ManiaPerformance::new(Converted::new(map, false))),
}
}
@@ -66,14 +44,12 @@ impl<'map> AnyPP<'map> {
///
/// Note that the map, mods, and passed object count should be the same
/// as when the attributes were calculated.
#[inline]
pub fn from_attributes(attributes: impl AttributeProvider) -> Self {
Self::from(attributes)
}
/// Consume the performance calculator and calculate
/// performance attributes for the given parameters.
#[inline]
pub fn calculate(self) -> PerformanceAttributes {
match self {
Self::Osu(o) => PerformanceAttributes::Osu(o.calculate()),
@@ -86,7 +62,6 @@ impl<'map> AnyPP<'map> {
/// Provide the result of a previous difficulty or performance calculation.
/// If you already calculated the attributes for the current map-mod combination,
/// be sure to put them in here so that they don't have to be recalculated.
#[inline]
pub fn attributes(self, attributes: impl AttributeProvider) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.attributes(attributes.attributes())),
@@ -96,23 +71,47 @@ impl<'map> AnyPP<'map> {
}
}
/// If the map is an osu!standard map, convert it to another mode.
/// Attempt to convert the map to the specified mode.
///
/// Returns `None` if `self` already replaced it's internal [`Beatmap`]
/// with difficulty attributes, i.e. if [`AnyPP::attributes`]
/// or [`AnyPP::generate_state`] was called.
#[inline]
/// Returns `None` if the conversion is incompatible or the internal
/// beatmap was already replaced with difficulty attributes, i.e. if
/// [`Performance::attributes`] or [`Performance::generate_state`] was
/// called.
///
/// If the given mode should be ignored in case it is incompatible or if
/// the internal beatmap was replaced, use [`mode_or_ignore`] instead.
///
/// [`mode_or_ignore`]: Self::mode_or_ignore
pub fn try_mode(self, mode: GameMode) -> Option<Self> {
match self {
AnyPP::Osu(o) => o.try_mode(mode),
other => Some(other),
match (self, mode) {
(Self::Osu(o), _) => o.try_mode(mode),
(this @ Self::Taiko(_), GameMode::Taiko)
| (this @ Self::Catch(_), GameMode::Catch)
| (this @ Self::Mania(_), GameMode::Mania) => Some(this),
_ => None,
}
}
/// Attempt to convert the map to the specified mode.
///
/// If the conversion is incompatible or if the internal beatmap was
/// already replaced with difficulty attributes, the map won't be modified.
///
/// To see whether the given mode is incompatible or the internal beatmap
/// was replaced, use [`try_mode`] instead.
///
/// [`try_mode`]: Self::try_mode
pub fn mode_or_ignore(self, mode: GameMode) -> Self {
if let Self::Osu(osu) = self {
osu.mode_or_ignore(mode)
} else {
self
}
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub fn mods(self, mods: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.mods(mods)),
@@ -130,8 +129,7 @@ impl<'map> AnyPP<'map> {
using [`AnyPP`] multiple times with different `passed_objects`, you should use
[`GradualPerformanceAttributes`](crate::GradualPerformance)."
)]
#[inline]
pub fn passed_objects(self, passed_objects: usize) -> Self {
pub fn passed_objects(self, passed_objects: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.passed_objects(passed_objects)),
Self::Taiko(t) => Self::Taiko(t.passed_objects(passed_objects)),
@@ -143,7 +141,6 @@ impl<'map> AnyPP<'map> {
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub fn clock_rate(self, clock_rate: f64) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.clock_rate(clock_rate)),
@@ -153,20 +150,7 @@ impl<'map> AnyPP<'map> {
}
}
/// Specify whether the map is a convert.
///
/// This only needs to be specified if the map was converted manually beforehand.
#[inline]
pub fn is_convert(self, is_convert: bool) -> Self {
match self {
Self::Osu(_) | Self::Catch(_) => self,
Self::Taiko(t) => Self::Taiko(t.is_convert(is_convert)),
Self::Mania(m) => Self::Mania(m.is_convert(is_convert)),
}
}
/// Provide parameters through a [`ScoreState`].
#[inline]
pub fn state(self, state: ScoreState) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.state(state.into())),
@@ -177,7 +161,6 @@ impl<'map> AnyPP<'map> {
}
/// Set the accuracy between `0.0` and `100.0`.
#[inline]
pub fn accuracy(self, acc: f64) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.accuracy(acc)),
@@ -188,8 +171,7 @@ impl<'map> AnyPP<'map> {
}
/// Specify the amount of misses of a play.
#[inline]
pub fn n_misses(self, n_misses: usize) -> Self {
pub fn n_misses(self, n_misses: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.n_misses(n_misses)),
Self::Taiko(t) => Self::Taiko(t.n_misses(n_misses)),
@@ -201,8 +183,7 @@ impl<'map> AnyPP<'map> {
/// Specify the max combo of the play.
///
/// Irrelevant for osu!mania.
#[inline]
pub fn combo(self, combo: usize) -> Self {
pub fn combo(self, combo: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.combo(combo)),
Self::Taiko(t) => Self::Taiko(t.combo(combo)),
@@ -214,7 +195,6 @@ impl<'map> AnyPP<'map> {
/// Specify how hitresults should be generated.
///
/// Defauls to [`HitResultPriority::BestCase`].
#[inline]
pub fn hitresult_priority(self, priority: HitResultPriority) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.hitresult_priority(priority)),
@@ -225,8 +205,7 @@ impl<'map> AnyPP<'map> {
}
/// Specify the amount of 300s of a play.
#[inline]
pub fn n300(self, n300: usize) -> Self {
pub fn n300(self, n300: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.n300(n300)),
Self::Taiko(t) => Self::Taiko(t.n300(n300)),
@@ -236,8 +215,7 @@ impl<'map> AnyPP<'map> {
}
/// Specify the amount of 100s of a play.
#[inline]
pub fn n100(self, n100: usize) -> Self {
pub fn n100(self, n100: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.n100(n100)),
Self::Taiko(t) => Self::Taiko(t.n100(n100)),
@@ -249,8 +227,7 @@ impl<'map> AnyPP<'map> {
/// Specify the amount of 50s of a play.
///
/// Irrelevant for osu!taiko.
#[inline]
pub fn n50(self, n50: usize) -> Self {
pub fn n50(self, n50: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.n50(n50)),
Self::Taiko(_) => self,
@@ -264,8 +241,7 @@ impl<'map> AnyPP<'map> {
/// This value is only relevant for osu!catch for which it represents
/// the amount of tiny droplet misses and osu!mania for which it.
/// repesents the amount of n200.
#[inline]
pub fn n_katu(self, n_katu: usize) -> Self {
pub fn n_katu(self, n_katu: u32) -> Self {
match self {
Self::Osu(_) | Self::Taiko(_) => self,
Self::Catch(f) => Self::Catch(f.tiny_droplet_misses(n_katu)),
@@ -277,8 +253,7 @@ impl<'map> AnyPP<'map> {
///
/// This value is only relevant for osu!mania for which it.
/// repesents the amount of n320.
#[inline]
pub fn n_geki(self, n_geki: usize) -> Self {
pub fn n_geki(self, n_geki: u32) -> Self {
match self {
Self::Osu(_) | Self::Taiko(_) | Self::Catch(_) => self,
Self::Mania(m) => Self::Mania(m.n320(n_geki)),
@@ -286,7 +261,6 @@ impl<'map> AnyPP<'map> {
}
/// Create the [`ScoreState`] that will be used for performance calculation.
#[inline]
pub fn generate_state(&mut self) -> ScoreState {
match self {
Self::Osu(o) => o.generate_state().into(),
@@ -297,15 +271,14 @@ impl<'map> AnyPP<'map> {
}
}
impl<A: AttributeProvider> From<A> for AnyPP<'_> {
#[inline]
impl<A: AttributeProvider> From<A> for Performance<'_> {
fn from(attrs: A) -> Self {
fn inner(attrs: DifficultyAttributes) -> AnyPP<'static> {
fn inner(attrs: DifficultyAttributes) -> Performance<'static> {
match attrs {
DifficultyAttributes::Osu(attrs) => AnyPP::Osu(attrs.pp()),
DifficultyAttributes::Taiko(attrs) => AnyPP::Taiko(attrs.pp()),
DifficultyAttributes::Catch(attrs) => AnyPP::Catch(attrs.pp()),
DifficultyAttributes::Mania(attrs) => AnyPP::Mania(attrs.pp()),
DifficultyAttributes::Osu(attrs) => Performance::Osu(attrs.pp()),
DifficultyAttributes::Taiko(attrs) => Performance::Taiko(attrs.pp()),
DifficultyAttributes::Catch(attrs) => Performance::Catch(attrs.pp()),
DifficultyAttributes::Mania(attrs) => Performance::Mania(attrs.pp()),
}
}
@@ -313,66 +286,33 @@ impl<A: AttributeProvider> From<A> for AnyPP<'_> {
}
}
/// While generating remaining hitresults, decide how they should be distributed.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum HitResultPriority {
/// Prioritize good hitresults over bad ones
BestCase,
/// Prioritize bad hitresults over good ones
WorstCase,
}
impl Default for HitResultPriority {
#[inline]
fn default() -> Self {
Self::BestCase
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait AttributeProvider {
/// Provide the actual difficulty attributes.
fn attributes(self) -> DifficultyAttributes;
}
impl AttributeProvider for DifficultyAttributes {
#[inline]
fn attributes(self) -> DifficultyAttributes {
self
}
}
impl AttributeProvider for PerformanceAttributes {
#[inline]
fn attributes(self) -> DifficultyAttributes {
match self {
Self::Osu(attrs) => DifficultyAttributes::Osu(attrs.difficulty),
Self::Taiko(attrs) => DifficultyAttributes::Taiko(attrs.difficulty),
Self::Catch(attrs) => DifficultyAttributes::Catch(attrs.difficulty),
Self::Mania(attrs) => DifficultyAttributes::Mania(attrs.difficulty),
}
}
}
macro_rules! impl_attr_provider {
($mode:ident: $difficulty:ident, $performance:ident) => {
impl AttributeProvider for $difficulty {
#[inline]
fn attributes(self) -> DifficultyAttributes {
DifficultyAttributes::$mode(self)
macro_rules! impl_from_mode {
( $mode:ident: $performance:ident ) => {
impl<'a> From<Converted<'a, $mode>> for Performance<'a> {
fn from(converted: Converted<'a, $mode>) -> Self {
Self::$mode($performance::new(converted))
}
}
impl AttributeProvider for $performance {
#[inline]
fn attributes(self) -> DifficultyAttributes {
DifficultyAttributes::$mode(self.difficulty)
impl<'a, 'b: 'a> From<&'b Converted<'a, $mode>> for Performance<'a> {
fn from(converted: &'b Converted<'a, $mode>) -> Self {
Self::$mode($performance::new(converted.as_owned()))
}
}
};
}
impl_attr_provider!(Catch: CatchDifficultyAttributes, CatchPerformanceAttributes);
impl_attr_provider!(Mania: ManiaDifficultyAttributes, ManiaPerformanceAttributes);
impl_attr_provider!(Osu: OsuDifficultyAttributes, OsuPerformanceAttributes);
impl_attr_provider!(Taiko: TaikoDifficultyAttributes, TaikoPerformanceAttributes);
impl_from_mode!(Osu: OsuPerformance);
impl_from_mode!(Taiko: TaikoPerformance);
impl_from_mode!(Catch: CatchPerformance);
impl_from_mode!(Mania: ManiaPerformance);
/// While generating remaining hitresults, decide how they should be distributed.
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub enum HitResultPriority {
/// Prioritize good hitresults over bad ones
#[default]
BestCase,
/// Prioritize bad hitresults over good ones
WorstCase,
}
+20 -28
View File
@@ -1,44 +1,44 @@
use rosu_map::section::general::GameMode;
use crate::{
catch::CatchScoreState, mania::ManiaScoreState, osu::OsuScoreState, taiko::TaikoScoreState,
GameMode,
};
/// Aggregation for a score's current state i.e. what is
/// the maximum combo so far, what are the current
/// hitresults and what is the current score.
/// Aggregation for a score's current state.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ScoreState {
/// 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.
///
/// Note that for osu!catch only fruits and droplets are considered for combo.
/// Note that for osu!catch only fruits and droplets are considered for
/// combo.
///
/// Irrelevant for osu!mania.
pub max_combo: usize,
pub max_combo: u32,
/// Amount of current gekis (n320 for osu!mania).
pub n_geki: usize,
/// Amount of current katus (tiny droplet misses for osu!catch / n200 for osu!mania).
pub n_katu: usize,
pub n_geki: u32,
/// Amount of current katus (tiny droplet misses for osu!catch / n200 for
/// osu!mania).
pub n_katu: u32,
/// Amount of current 300s (fruits for osu!catch).
pub n300: usize,
pub n300: u32,
/// Amount of current 100s (droplets for osu!catch).
pub n100: usize,
pub n100: u32,
/// Amount of current 50s (tiny droplets for osu!catch).
pub n50: usize,
pub n50: u32,
/// Amount of current misses (fruits + droplets for osu!catch).
pub n_misses: usize,
pub n_misses: u32,
}
impl ScoreState {
/// Create a new empty score state.
#[inline]
pub fn new() -> Self {
Self::default()
}
/// Return the total amount of hits by adding everything up based on the mode.
#[inline]
pub fn total_hits(&self, mode: GameMode) -> usize {
/// Return the total amount of hits by adding everything up based on the
/// mode.
pub fn total_hits(&self, mode: GameMode) -> u32 {
let mut amount = self.n300 + self.n100 + self.n_misses;
if mode != GameMode::Taiko {
@@ -46,7 +46,7 @@ impl ScoreState {
if mode != GameMode::Osu {
amount += self.n_katu;
amount += usize::from(mode != GameMode::Catch) * self.n_geki;
amount += u32::from(mode != GameMode::Catch) * self.n_geki;
}
}
@@ -55,7 +55,6 @@ impl ScoreState {
}
impl From<ScoreState> for OsuScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
@@ -68,7 +67,6 @@ impl From<ScoreState> for OsuScoreState {
}
impl From<ScoreState> for TaikoScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
@@ -80,7 +78,6 @@ impl From<ScoreState> for TaikoScoreState {
}
impl From<ScoreState> for CatchScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
@@ -94,7 +91,6 @@ impl From<ScoreState> for CatchScoreState {
}
impl From<ScoreState> for ManiaScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
n320: state.n_geki,
@@ -108,7 +104,6 @@ impl From<ScoreState> for ManiaScoreState {
}
impl From<OsuScoreState> for ScoreState {
#[inline]
fn from(state: OsuScoreState) -> Self {
Self {
max_combo: state.max_combo,
@@ -123,7 +118,6 @@ impl From<OsuScoreState> for ScoreState {
}
impl From<TaikoScoreState> for ScoreState {
#[inline]
fn from(state: TaikoScoreState) -> Self {
Self {
max_combo: state.max_combo,
@@ -138,7 +132,6 @@ impl From<TaikoScoreState> for ScoreState {
}
impl From<CatchScoreState> for ScoreState {
#[inline]
fn from(state: CatchScoreState) -> Self {
Self {
max_combo: state.max_combo,
@@ -153,7 +146,6 @@ impl From<CatchScoreState> for ScoreState {
}
impl From<ManiaScoreState> for ScoreState {
#[inline]
fn from(state: ManiaScoreState) -> Self {
Self {
max_combo: 0,
-16
View File
@@ -1,16 +0,0 @@
/// A break point of a [`Beatmap`](crate::beatmap::Beatmap).
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct Break {
/// Start timestamp of the break.
pub start_time: f64,
/// End timestamp of the break.
pub end_time: f64,
}
impl Break {
/// Duration of the break.
#[inline]
pub fn duration(&self) -> f64 {
self.end_time - self.start_time
}
}
-133
View File
@@ -1,133 +0,0 @@
use std::cmp::Ordering;
/// New rhythm speed change.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct TimingPoint {
/// The beat length for this timing section
pub beat_len: f64,
/// The start time of this timing section
pub time: f64,
}
impl TimingPoint {
/// Create a new [`TimingPoint`].
#[inline]
pub const fn new(time: f64, beat_len: f64) -> Self {
Self { beat_len, time }
}
}
impl PartialOrd for TimingPoint {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.time.partial_cmp(&other.time)
}
}
impl Default for TimingPoint {
#[inline]
fn default() -> Self {
Self::new(0.0, 60_000.0 / 60.0)
}
}
/// [`TimingPoint`] that depends on a previous one.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct DifficultyPoint {
/// The time at which the control point takes effect.
pub time: f64,
/// The slider velocity at this control point.
pub slider_vel: f64,
/// Legacy BPM multiplier that introduces floating-point errors for rulesets that depend on it.
pub bpm_mult: f64,
/// Whether or not slider ticks should be generated at this control point.
/// This exists for backwards compatibility with maps that abuse NaN
/// slider velocity behavior on osu!stable (e.g. /b/2628991).
pub generate_ticks: bool,
}
impl DifficultyPoint {
/// The default slider velocity for a [`DifficultyPoint`]
pub const DEFAULT_SLIDER_VEL: f64 = 1.0;
/// The default BPM multipler for a [`DifficultyPoint`]
pub const DEFAULT_BPM_MULT: f64 = 1.0;
/// The default for generating ticks of a [`DifficultyPoint`]
pub const DEFAULT_GENERATE_TICKS: bool = true;
/// Create a new [`DifficultyPoint`].
#[inline]
pub fn new(time: f64, beat_len: f64, speed_multiplier: f64) -> Self {
// * Note: In stable, the division occurs on floats, but with compiler optimisations
// * turned on actually seems to occur on doubles via some .NET black magic (possibly inlining?).
let bpm_mult = if beat_len < 0.0 {
f64::from(((-beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0
} else {
1.0
};
Self {
time,
slider_vel: speed_multiplier.clamp(0.1, 10.0),
bpm_mult,
generate_ticks: !beat_len.is_nan(),
}
}
pub(crate) fn is_redundant(&self, existing: &Self) -> bool {
(self.slider_vel - existing.slider_vel).abs() <= f64::EPSILON
&& self.generate_ticks == existing.generate_ticks
}
}
impl PartialOrd for DifficultyPoint {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.time.partial_cmp(&other.time)
}
}
impl Default for DifficultyPoint {
#[inline]
fn default() -> Self {
Self {
time: 0.0,
slider_vel: Self::DEFAULT_SLIDER_VEL,
bpm_mult: Self::DEFAULT_BPM_MULT,
generate_ticks: Self::DEFAULT_GENERATE_TICKS,
}
}
}
/// Control point storing effects and their timestamps.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct EffectPoint {
/// The time at which the control point takes effect.
pub time: f64,
/// Whether this control point enables Kiai mode.
pub kiai: bool,
}
impl EffectPoint {
/// The default slider velocity for a [`DifficultyPoint`]
pub const DEFAULT_KIAI: bool = false;
/// Create a new [`EffectPoint`].
#[inline]
pub const fn new(time: f64, kiai: bool) -> Self {
Self { time, kiai }
}
}
impl PartialOrd for EffectPoint {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.time.partial_cmp(&other.time)
}
}
impl Default for EffectPoint {
#[inline]
fn default() -> Self {
Self::new(0.0, Self::DEFAULT_KIAI)
}
}
@@ -1,42 +0,0 @@
const INT_TO_REAL: f64 = 1.0 / (i32::MAX as f64 + 1.0);
const INT_MASK: u32 = 0x7F_FF_FF_FF;
pub(crate) struct Random {
x: u32,
y: u32,
z: u32,
w: u32,
}
impl Random {
pub(crate) const fn new(seed: i32) -> Self {
Self {
x: seed as u32,
y: 842_502_087,
z: 3_579_807_591,
w: 273_326_509,
}
}
pub(crate) fn gen_unsigned(&mut self) -> u32 {
let t = self.x ^ (self.x << 11);
self.x = self.y;
self.y = self.z;
self.z = self.w;
self.w = self.w ^ (self.w >> 19) ^ t ^ (t >> 8);
self.w
}
pub(crate) fn gen_signed(&mut self) -> i32 {
(INT_MASK & self.gen_unsigned()) as i32
}
pub(crate) fn gen_double(&mut self) -> f64 {
INT_TO_REAL * f64::from(self.gen_signed())
}
pub(crate) fn gen_int_range(&mut self, min: i32, max: i32) -> i32 {
(f64::from(min) + self.gen_double() * f64::from(max - min)) as i32
}
}
-220
View File
@@ -1,220 +0,0 @@
use std::cmp::Ordering;
use crate::{
curve::{Curve, CurveBuffers},
parse::{legacy_sort, HitObjectKind, Pos2},
util::{FloatExt, LimitedQueue},
Beatmap, GameMode,
};
use self::{
legacy_random::Random,
pattern::Pattern,
pattern_generator::{
distance_object::DistanceObjectPatternGenerator,
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
},
pattern_type::PatternType,
};
mod legacy_random;
mod pattern;
mod pattern_generator;
mod pattern_type;
const MAX_NOTES_FOR_DENSITY: usize = 7;
impl Beatmap {
#[allow(clippy::too_many_lines)]
pub(in crate::beatmap) fn convert_to_mania(&self) -> Self {
let mut map = self.clone_without_hit_objects(false);
let mut n_circles = 0;
let mut n_sliders = 0;
let seed = (map.hp + map.cs).round_even() as i32 * 20
+ (map.od * 41.2) as i32
+ map.ar.round_even() as i32;
let mut random = Random::new(seed);
let rounded_cs = map.cs.round_even();
let rounded_od = map.od.round_even();
let slider_or_spinner_count = self
.hit_objects
.iter()
.filter(|h| {
matches!(
h.kind,
HitObjectKind::Slider { .. } | HitObjectKind::Spinner { .. }
)
})
.count();
let percent_slider_or_spinner =
f64::from(slider_or_spinner_count as f32 / self.hit_objects.len() as f32);
let target_columns = 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)
};
map.cs = target_columns;
let mut prev_note_times: LimitedQueue<f64, MAX_NOTES_FOR_DENSITY> = LimitedQueue::new();
let mut density = f64::from(i32::MAX);
let mut compute_density = |new_note_time: f64, d: &mut f64| {
prev_note_times.push(new_note_time);
if prev_note_times.len() >= 2 {
*d = (prev_note_times.last().unwrap() - prev_note_times[0])
/ prev_note_times.len() as f64;
}
};
let total_columns = map.cs as i32;
let mut last_values = PrevValues::default();
let mut curve_bufs = CurveBuffers::default();
for (obj, sound) in self.hit_objects.iter().zip(self.sounds.iter()) {
match obj.kind {
HitObjectKind::Circle => {
compute_density(obj.start_time, &mut density);
let mut gen = HitObjectPatternGenerator::new(
&mut random,
obj,
*sound,
total_columns,
&last_values,
density,
self,
);
let new_pattern = gen.generate();
last_values.stair = gen.stair_type;
last_values.time = obj.start_time;
last_values.pos = obj.pos;
let new_hit_objects = new_pattern.hit_objects.iter().cloned();
map.hit_objects.extend(new_hit_objects);
n_circles += new_pattern.hit_objects.len();
last_values.pattern = new_pattern;
}
HitObjectKind::Slider {
pixel_len,
repeats,
ref control_points,
ref edge_sounds,
} => {
let curve = Curve::new(control_points, pixel_len, &mut curve_bufs);
let mut gen = DistanceObjectPatternGenerator::new(
&mut random,
obj,
*sound,
total_columns,
&last_values.pattern,
self,
repeats,
&curve,
edge_sounds,
);
let segment_duration = f64::from(gen.segment_duration);
for i in 0..=repeats as i32 + 1 {
let time = obj.start_time + segment_duration * f64::from(i);
last_values.time = time;
last_values.pos = obj.pos;
compute_density(time, &mut density);
}
for new_pattern in gen.generate() {
let new_objects = new_pattern.hit_objects.iter().map(|h| {
if h.is_circle() {
n_circles += 1;
} else {
n_sliders += 1;
}
h.to_owned()
});
map.hit_objects.extend(new_objects);
last_values.pattern = new_pattern;
}
}
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
let mut gen = EndTimeObjectPatternGenerator::new(
&mut random,
obj,
end_time,
*sound,
total_columns,
&last_values.pattern,
);
last_values.time = end_time;
last_values.pos = Pos2 { x: 256.0, y: 192.0 };
compute_density(end_time, &mut density);
let new_pattern = gen.generate();
let new_objects = new_pattern.hit_objects.into_iter().inspect(|h| {
if h.is_circle() {
n_circles += 1;
} else {
n_sliders += 1;
}
});
map.hit_objects.extend(new_objects);
}
}
}
map.n_circles = n_circles as u32;
map.n_sliders = n_sliders;
map.hit_objects
.sort_by(|p1, p2| p1.partial_cmp(p2).unwrap_or(Ordering::Equal));
legacy_sort(&mut map.hit_objects);
map.mode = GameMode::Mania;
map
}
}
pub(crate) struct PrevValues {
time: f64,
pos: Pos2,
pattern: Pattern,
stair: PatternType,
}
impl Default for PrevValues {
fn default() -> Self {
Self {
time: 0.0,
pos: Pos2::default(),
pattern: Pattern::default(),
stair: PatternType::STAIR,
}
}
}
@@ -1,97 +0,0 @@
use crate::{
beatmap::converts::mania::{
legacy_random::Random, pattern::Pattern, pattern_type::PatternType,
},
parse::{HitObject, HitSound},
Beatmap,
};
use super::PatternGenerator;
pub(crate) struct EndTimeObjectPatternGenerator<'h> {
pub(crate) hit_object: &'h HitObject,
pub(crate) end_time: f64,
pub(crate) total_columns: i32,
pub(crate) sample: u8,
convert_type: PatternType,
prev_pattern: &'h Pattern,
random: &'h mut Random,
}
impl<'h> EndTimeObjectPatternGenerator<'h> {
pub(crate) fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
end_time: f64,
sample: u8,
total_columns: i32,
prev_pattern: &'h Pattern,
) -> Self {
let convert_type = if prev_pattern.column_with_objs() == total_columns {
PatternType::default()
} else {
PatternType::FORCE_NOT_STACK
};
Self {
hit_object,
end_time,
total_columns,
sample,
convert_type,
prev_pattern,
random,
}
}
pub(crate) fn generate(&mut self) -> Pattern {
let generate_hold = self.end_time - self.hit_object.start_time >= 100.0;
match self.total_columns {
8 if self.sample.finish() && self.end_time - self.hit_object.start_time < 1000.0 => {
Pattern::new_end_time_note(self, 0, generate_hold)
}
8 => {
let column = self.get_random_column(self.random_start());
Pattern::new_end_time_note(self, column, generate_hold)
}
_ => {
let column = self.get_random_column(0);
Pattern::new_end_time_note(self, column, generate_hold)
}
}
}
fn get_random_column(&mut self, lower: i32) -> u8 {
let column = PatternGenerator::get_random_column(self, Some(lower), None);
if self.convert_type.contains(PatternType::FORCE_NOT_STACK) {
self.find_available_column(column, Some(lower), None, None, None, &[self.prev_pattern])
} else {
self.find_available_column(column, Some(lower), None, None, None, &[])
}
}
}
impl PatternGenerator for EndTimeObjectPatternGenerator<'_> {
#[inline]
fn hit_object(&self) -> &HitObject {
self.hit_object
}
#[inline]
fn total_columns(&self) -> i32 {
self.total_columns
}
#[inline]
fn random(&mut self) -> &mut Random {
self.random
}
fn original_map(&self) -> &Beatmap {
panic!("trait method is not used")
}
}
@@ -1,139 +0,0 @@
use crate::{mania::ManiaObject, parse::HitObject, Beatmap};
use super::{legacy_random::Random, pattern::Pattern};
pub(super) mod distance_object;
pub(super) mod end_time_object;
pub(super) mod hit_object;
trait PatternGenerator {
fn hit_object(&self) -> &HitObject;
fn total_columns(&self) -> i32;
fn random(&mut self) -> &mut Random;
fn original_map(&self) -> &Beatmap;
// ----------------------------------
fn random_start(&self) -> i32 {
i32::from(self.total_columns() == 8)
}
fn get_column(&self, allow_special: Option<bool>) -> u8 {
let allow_special = allow_special.unwrap_or(false);
if allow_special && self.total_columns() == 8 {
const LOCAL_X_DIVISOR: f32 = 512.0 / 7.0;
((self.hit_object().pos.x / LOCAL_X_DIVISOR).floor() as u8).clamp(0, 6) + 1
} else {
ManiaObject::column(self.hit_object().pos.x, self.total_columns() as f32) as u8
}
}
fn get_random_note_count(
&mut self,
p2: f64,
p3: f64,
p4: Option<f64>,
p5: Option<f64>,
p6: Option<f64>,
) -> i32 {
let p4 = p4.unwrap_or(0.0);
let p5 = p5.unwrap_or(0.0);
let p6 = p6.unwrap_or(0.0);
let val = self.random().gen_double();
if val >= 1.0 - p6 {
6
} else if val >= 1.0 - p5 {
5
} else if val >= 1.0 - p4 {
4
} else if val >= 1.0 - p3 {
3
} else {
1 + i32::from(val >= 1.0 - p2)
}
}
fn conversion_difficulty(&self) -> f64 {
let orig = self.original_map();
let last_obj_time = orig.hit_objects.last().map_or(0.0, |h| h.start_time);
let first_obj_time = orig.hit_objects.first().map_or(0.0, |h| h.start_time);
// * Drain time in seconds
let total_break_time = orig.total_break_time();
let mut drain_time = ((last_obj_time - first_obj_time - total_break_time) / 1000.0) as i32;
if drain_time == 0 {
drain_time = 10_000;
}
let mut conversion_difficulty = 0.0;
conversion_difficulty += f64::from(orig.hp + orig.ar.clamp(4.0, 7.0)) / 1.5;
conversion_difficulty += orig.hit_objects.len() as f64 / f64::from(drain_time) * 9.0;
conversion_difficulty /= 38.0;
conversion_difficulty *= 5.0;
conversion_difficulty /= 1.15;
conversion_difficulty = conversion_difficulty.min(12.0);
conversion_difficulty
}
fn get_random_column(&mut self, lower: Option<i32>, upper: Option<i32>) -> u8 {
let lower = lower.unwrap_or_else(|| self.random_start());
let upper = upper.unwrap_or_else(|| self.total_columns());
self.random().gen_int_range(lower, upper) as u8
}
fn find_available_column(
&mut self,
mut initial_column: u8,
lower: Option<i32>,
upper: Option<i32>,
next_column: Option<fn(&mut Self, u8) -> u8>,
validation: Option<&dyn Fn(i32) -> bool>,
patterns: &[&Pattern],
) -> u8 {
let lower = lower.unwrap_or_else(|| self.random_start());
let upper = upper.unwrap_or_else(|| self.total_columns());
let is_valid = |column: i32| {
if let Some(fun) = validation {
if !(fun)(column) {
return false;
}
}
let column = column as u8;
patterns
.iter()
.all(|pattern| !pattern.column_has_obj(column))
};
// * Check for the initial column
if is_valid(i32::from(initial_column)) {
return initial_column;
}
// * Ensure that we have at least one free column, so that an endless loop is avoided
let has_valid_column = (lower..upper).any(is_valid);
assert!(has_valid_column);
// * Iterate until a valid column is found. This is a random iteration in the default case.
while {
initial_column = if let Some(fun) = next_column {
(fun)(self, initial_column)
} else {
PatternGenerator::get_random_column(self, Some(lower), Some(upper))
};
!is_valid(i32::from(initial_column))
} {}
initial_column
}
}
-2
View File
@@ -1,2 +0,0 @@
mod mania;
mod taiko;
-158
View File
@@ -1,158 +0,0 @@
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind, Pos2},
util::TandemSorter,
Beatmap, GameMode,
};
const LEGACY_TAIKO_VELOCITY_MULTIPLIER: f32 = 1.4;
const OSU_BASE_SCORING_DIST: f32 = 100.0;
impl Beatmap {
pub(in crate::beatmap) fn convert_to_taiko(&self) -> Self {
let mut map = self.clone_without_hit_objects(true);
let mut curve_bufs = CurveBuffers::default();
map.slider_mult *= f64::from(LEGACY_TAIKO_VELOCITY_MULTIPLIER);
for (obj, sound) in self.hit_objects.iter().zip(self.sounds.iter()) {
match obj.kind {
HitObjectKind::Circle => {
map.hit_objects.push(obj.to_owned());
map.sounds.push(*sound);
map.n_circles += 1;
}
HitObjectKind::Slider {
pixel_len,
repeats,
ref control_points,
ref edge_sounds,
} => {
let curve = Curve::new(control_points, pixel_len, &mut curve_bufs);
let mut params = SliderParams::new(obj.start_time, repeats, &curve);
if map.should_convert_slider_to_taiko_hits(&mut params) {
let mut i = 0;
let mut j = obj.start_time;
let edge_sound_count = edge_sounds.len().max(1);
while j
<= obj.start_time
+ f64::from(params.duration)
+ params.tick_spacing / 8.0
{
let h = HitObject {
pos: Pos2::default(),
start_time: j,
kind: HitObjectKind::Circle,
};
map.hit_objects.push(h);
map.sounds.push(*edge_sounds.get(i).unwrap_or(sound));
map.n_circles += 1;
if params.tick_spacing.abs() <= f64::EPSILON {
break;
}
j += params.tick_spacing;
i = (i + 1) % edge_sound_count;
}
} else {
map.hit_objects.push(obj.to_owned());
map.sounds.push(*sound);
map.n_sliders += 1;
}
}
HitObjectKind::Spinner { .. } => {
map.hit_objects.push(obj.to_owned());
map.sounds.push(*sound);
map.n_spinners += 1;
}
// Pathological case; shouldn't realistically happen
HitObjectKind::Hold { end_time } => {
let obj = HitObject {
pos: obj.pos,
start_time: obj.start_time,
kind: HitObjectKind::Spinner { end_time },
};
map.hit_objects.push(obj);
map.sounds.push(*sound);
map.n_spinners += 1;
}
}
}
// We only convert STD to TKO so we don't need to remove objects
// with the same timestamp that would appear only in MNA
let mut sorter = TandemSorter::new(&map.hit_objects, true);
sorter.sort(&mut map.hit_objects);
sorter.toggle_marks();
sorter.sort(&mut map.sounds);
map.mode = GameMode::Taiko;
map
}
fn should_convert_slider_to_taiko_hits(&self, params: &mut SliderParams<'_>) -> bool {
let SliderParams {
curve,
duration,
repeats,
start_time,
tick_spacing,
} = params;
// * The true distance, accounting for any repeats. This ends up being the drum roll distance later
let spans = (*repeats + 1) as f64;
let dist = curve.dist() * spans * f64::from(LEGACY_TAIKO_VELOCITY_MULTIPLIER);
let timing_point = self.timing_point_at(*start_time);
let difficulty_point = self.difficulty_point_at(*start_time).unwrap_or_default();
let mut beat_len = timing_point.beat_len * difficulty_point.bpm_mult;
let slider_scoring_point_dist =
f64::from(OSU_BASE_SCORING_DIST) * self.slider_mult / self.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 * self.tick_rate;
*duration = (dist / taiko_vel * beat_len) as u32;
let osu_vel = taiko_vel * (f64::from(1000.0_f32) / beat_len);
// * osu-stable always uses the speed-adjusted beatlength to determine the osu! velocity, but only uses it for conversion if beatmap version < 8
if self.version >= 8 {
beat_len = timing_point.beat_len;
}
// * If the drum roll is to be split into hit circles, assume the ticks are 1/8 spaced within the duration of one beat
*tick_spacing = (beat_len / self.tick_rate).min(f64::from(*duration) / spans);
*tick_spacing > 0.0 && dist / osu_vel * 1000.0 < 2.0 * beat_len
}
}
struct SliderParams<'c> {
curve: &'c Curve<'c>,
duration: u32,
repeats: usize,
start_time: f64,
tick_spacing: f64,
}
impl<'c> SliderParams<'c> {
const fn new(start_time: f64, repeats: usize, curve: &'c Curve<'c>) -> Self {
Self {
curve,
repeats,
start_time,
duration: 0,
tick_spacing: 0.0,
}
}
}
-182
View File
@@ -1,182 +0,0 @@
use crate::{
catch::{
calculate_catch_width, CatchDifficultyAttributes, CatchObject, FruitOrJuice, FruitParams,
ALLOWED_CATCH_RANGE,
},
curve::CurveBuffers,
mania::{ManiaObject, ObjectParameters},
osu::{OsuDifficultyAttributes, OsuObject, ScalingFactor},
taiko::{IntoTaikoObjectIter, TaikoObject},
util::FloatExt,
AnyPP, AnyStars, Beatmap, CatchPP, CatchStars, GameMode, ManiaPP, ManiaStars, Mods, OsuPP,
OsuStars, PerformanceAttributes, Strains, TaikoPP, TaikoStars,
};
/// Provides some additional methods on [`Beatmap`].
pub trait BeatmapExt {
/// Calculate the stars and other attributes of a beatmap which are required for pp calculation.
fn stars(&self) -> AnyStars<'_>;
/// Calculate the max pp of a beatmap.
///
/// If you seek more fine-tuning you can use the [`pp`](BeatmapExt::pp) method.
fn max_pp(&self, mods: u32) -> PerformanceAttributes;
/// Returns a builder for performance calculation.
///
/// Convenient method that matches on the map's mode to choose the appropriate calculator.
fn pp(&self) -> AnyPP<'_>;
/// Calculate the strains of a map.
/// This essentially performs the same calculation as [`BeatmapExt::stars`] but
/// instead of evaluating the final strains, they are just returned as is.
///
/// Suitable to plot the difficulty of a map over time.
fn strains(&self, mods: u32) -> Strains;
/// Process each [`HitObject`](crate::parse::HitObject) into a an osu!-specific [`OsuObject`],
/// just like the difficulty calculation does.
fn osu_hitobjects(&self, mods: u32) -> Vec<OsuObject>;
/// Process each [`HitObject`](crate::parse::HitObject) into a an osu!taiko-specific [`TaikoObject`],
/// just like the difficulty calculation does.
///
/// Clockrate is *not* considered.
fn taiko_hitobjects(&self) -> Vec<TaikoObject>;
/// Process each [`HitObject`](crate::parse::HitObject) into a an osu!ctb-specific [`CatchObject`],
/// just like the difficulty calculation does.
///
/// A [`CatchObject`] is either a fruit or a droplet which means
/// tiny droplets and bananas are not included.
fn catch_hitobjects(&self, mods: u32) -> Vec<CatchObject>;
/// Process each [`HitObject`](crate::parse::HitObject) into a an osu!mania-specific [`ManiaObject`],
/// just like the difficulty calculation does.
///
/// Clockrate is *not* considered.
fn mania_hitobjects(&self) -> Vec<ManiaObject>;
}
impl BeatmapExt for Beatmap {
#[inline]
fn stars(&self) -> AnyStars<'_> {
match self.mode {
GameMode::Osu => AnyStars::Osu(OsuStars::new(self)),
GameMode::Taiko => AnyStars::Taiko(TaikoStars::new(self)),
GameMode::Catch => AnyStars::Catch(CatchStars::new(self)),
GameMode::Mania => AnyStars::Mania(ManiaStars::new(self)),
}
}
#[inline]
fn max_pp(&self, mods: u32) -> PerformanceAttributes {
match self.mode {
GameMode::Osu => PerformanceAttributes::Osu(OsuPP::new(self).mods(mods).calculate()),
GameMode::Taiko => {
PerformanceAttributes::Taiko(TaikoPP::new(self).mods(mods).calculate())
}
GameMode::Catch => {
PerformanceAttributes::Catch(CatchPP::new(self).mods(mods).calculate())
}
GameMode::Mania => {
PerformanceAttributes::Mania(ManiaPP::new(self).mods(mods).calculate())
}
}
}
#[inline]
fn pp(&self) -> AnyPP<'_> {
AnyPP::new(self)
}
#[inline]
fn strains(&self, mods: u32) -> Strains {
match self.mode {
GameMode::Osu => Strains::Osu(OsuStars::new(self).mods(mods).strains()),
GameMode::Taiko => Strains::Taiko(TaikoStars::new(self).mods(mods).strains()),
GameMode::Catch => Strains::Catch(CatchStars::new(self).mods(mods).strains()),
GameMode::Mania => Strains::Mania(ManiaStars::new(self).mods(mods).strains()),
}
}
fn osu_hitobjects(&self, mods: u32) -> Vec<OsuObject> {
let attrs = self.attributes().mods(mods).build();
let scaling_factor = ScalingFactor::new(attrs.cs);
let hr = mods.hr();
let time_preempt = f64::from((attrs.hit_windows.ar * attrs.clock_rate) as f32);
let mut attrs = OsuDifficultyAttributes::default();
crate::osu::create_osu_objects(
self,
&mut attrs,
&scaling_factor,
usize::MAX,
hr,
time_preempt,
)
}
fn taiko_hitobjects(&self) -> Vec<TaikoObject> {
let map = self.convert_mode(GameMode::Taiko);
map.taiko_objects()
.map(|(h, start_time)| TaikoObject {
start_time,
is_hit: h.is_hit,
is_rim: h.is_rim,
})
.collect()
}
fn catch_hitobjects(&self, mods: u32) -> Vec<CatchObject> {
let attrs = self.attributes().mods(mods).build();
let mut params = FruitParams {
attributes: CatchDifficultyAttributes::default(),
curve_bufs: CurveBuffers::default(),
last_pos: None,
last_time: 0.0,
ticks: Vec::new(),
with_hr: mods.hr(),
};
let mut hit_objects: Vec<_> = self
.hit_objects
.iter()
.filter_map(|h| FruitOrJuice::new(h, &mut params, self))
.flatten()
.collect();
let half_catcher_width =
f64::from(calculate_catch_width(attrs.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE);
let mut last_direction = 0;
let mut last_excess = half_catcher_width;
for i in 1..hit_objects.len() {
// SAFETY: The indices are guaranteed the be included based on the loop condition
let window = unsafe { hit_objects.get_unchecked_mut(i - 1..=i) };
let [curr, next] = window else { unreachable!() };
curr.init_hyper_dash(
half_catcher_width,
&*next,
&mut last_direction,
&mut last_excess,
);
}
hit_objects
}
fn mania_hitobjects(&self) -> Vec<ManiaObject> {
let map = self.convert_mode(GameMode::Mania);
let total_columns = map.cs.round_even().max(1.0);
let mut params = ObjectParameters::new(map.as_ref());
self.hit_objects
.iter()
.map(|h| ManiaObject::new(h, total_columns, &mut params))
.collect()
}
}
-232
View File
@@ -1,232 +0,0 @@
use std::{borrow::Cow, cmp::Ordering, collections::HashMap};
use crate::parse::HitObject;
pub use self::{
attributes::{BeatmapAttributes, BeatmapAttributesBuilder, BeatmapHitWindows},
breaks::Break,
control_points::{DifficultyPoint, EffectPoint, TimingPoint},
ext::*,
mode::GameMode,
sorted_vec::{Sortable, SortedVec},
};
mod attributes;
mod breaks;
mod control_points;
mod converts;
mod ext;
mod mode;
mod sorted_vec;
/// The main beatmap struct containing all data relevant
/// for difficulty and performance calculation
#[derive(Clone, Default, Debug)]
pub struct Beatmap {
/// The game mode.
pub mode: GameMode,
/// The version of the .osu file.
pub version: u8,
/// The amount of circles.
pub n_circles: u32,
/// The amount of sliders.
pub n_sliders: u32,
/// The amount of spinners.
pub n_spinners: u32,
/// The approach rate.
pub ar: f32,
/// The overall difficulty.
pub od: f32,
/// The circle size.
pub cs: f32,
/// The health drain rate.
pub hp: f32,
/// Base slider velocity in pixels per beat
pub slider_mult: f64,
/// Amount of slider ticks per beat.
pub tick_rate: f64,
/// All hitobjects of the beatmap.
pub hit_objects: Vec<HitObject>,
/// Store the sounds for all objects in their own Vec to minimize the struct size.
/// Hitsounds are only used in osu!taiko in which they represent color.
pub sounds: Vec<u8>,
/// Timing points that indicate a new timing section.
pub timing_points: SortedVec<TimingPoint>,
/// Timing point for the current timing section.
pub difficulty_points: SortedVec<DifficultyPoint>,
/// Control points for effect sections.
pub effect_points: SortedVec<EffectPoint>,
/// The stack leniency that is used to calculate
/// the stack offset for stacked positions.
pub stack_leniency: f32,
/// All break points of the beatmap.
pub breaks: Vec<Break>,
}
impl Beatmap {
/// Extract a beatmap's attributes into their own type.
#[inline]
pub fn attributes(&self) -> BeatmapAttributesBuilder {
BeatmapAttributesBuilder::new(self)
}
/// The beats per minute of the map.
#[inline]
pub fn bpm(&self) -> f64 {
// This is incorrect if the last object is a slider since there
// is no reasonable way to get the slider end time at this point.
let last_time = self
.hit_objects
.last()
.map(HitObject::end_time)
.or_else(|| self.timing_points.last().map(|t| t.time))
.unwrap_or(0.0);
/// Maps `beat_len` to a cumulative duration
#[derive(Debug)]
struct BeatLenDuration {
last_time: f64,
map: HashMap<u64, f64>,
}
impl BeatLenDuration {
fn new(last_time: f64) -> Self {
Self {
last_time,
map: HashMap::default(),
}
}
fn add(&mut self, beat_len: f64, curr_time: f64, next_time: f64) {
let beat_len = (1000.0 * beat_len).round() / 1000.0;
let entry = self.map.entry(beat_len.to_bits()).or_default();
if curr_time <= self.last_time {
*entry += next_time - curr_time;
}
}
}
let mut bpm_points = BeatLenDuration::new(last_time);
// * osu-stable forced the first control point to start at 0.
// * This is reproduced here to maintain compatibility around
// * osu!mania scroll speed and song select display.
match &self.timing_points[..] {
[curr] => bpm_points.add(curr.beat_len, 0.0, last_time),
[curr, next, ..] => bpm_points.add(curr.beat_len, 0.0, next.time),
[] => {}
}
self.timing_points
.iter()
.skip(1)
.zip(self.timing_points.iter().skip(2).map(|t| t.time))
.for_each(|(curr, next_time)| bpm_points.add(curr.beat_len, curr.time, next_time));
if let [.., _, curr] = &self.timing_points[..] {
bpm_points.add(curr.beat_len, curr.time, last_time);
}
let most_common_beat_len = bpm_points
.map
.into_iter()
// * Get the most common one, or 0 as a suitable default
.max_by(|(_, a), (_, b)| a.total_cmp(b))
.map_or(0.0, |(beat_len, _)| f64::from_bits(beat_len));
60_000.0 / most_common_beat_len
}
/// Sum up the duration of all breaks (in milliseconds).
#[inline]
pub fn total_break_time(&self) -> f64 {
self.breaks.iter().map(Break::duration).sum()
}
/// Return the [`TimingPoint`] for the given timestamp.
#[inline]
pub fn timing_point_at(&self, time: f64) -> TimingPoint {
let idx_result = self
.timing_points
.binary_search_by(|probe| probe.time.partial_cmp(&time).unwrap_or(Ordering::Less));
match idx_result {
Ok(idx) => self.timing_points[idx],
Err(0) => self.timing_points.first().copied().unwrap_or_default(),
Err(idx) => self.timing_points[idx - 1],
}
}
/// Return the [`DifficultyPoint`] for the given timestamp.
///
/// If `time` is before the first difficulty point, `None` is returned.
#[inline]
pub fn difficulty_point_at(&self, time: f64) -> Option<DifficultyPoint> {
self.difficulty_points
.binary_search_by(|probe| probe.time.partial_cmp(&time).unwrap_or(Ordering::Less))
.map_or_else(|i| i.checked_sub(1), Some)
.map(|i| self.difficulty_points[i])
}
/// Return the [`EffectPoint`] for the given timestamp.
///
/// If `time` is before the first effect point, `None` is returned.
#[inline]
pub fn effect_point_at(&self, time: f64) -> Option<EffectPoint> {
self.effect_points
.binary_search_by(|probe| probe.time.partial_cmp(&time).unwrap_or(Ordering::Less))
.map_or_else(|i| i.checked_sub(1), Some)
.map(|i| self.effect_points[i])
}
/// Convert a [`Beatmap`] of some mode into a different mode.
///
/// # Note
/// - Since hitsounds are irrelevant for difficulty and performance calculations
/// in osu!mania, the resulting map of a conversion to mania will not contain hitsounds.
/// - To avoid having to clone the map for osu!catch conversions, the field `Beatmap::mode`
/// will not be adjusted in a osu!catch-converted map.
#[inline]
pub fn convert_mode(&self, mode: GameMode) -> Cow<'_, Self> {
if mode == self.mode {
return Cow::Borrowed(self);
}
match mode {
GameMode::Osu | GameMode::Catch => Cow::Borrowed(self),
GameMode::Taiko => Cow::Owned(self.convert_to_taiko()),
GameMode::Mania => Cow::Owned(self.convert_to_mania()),
}
}
fn clone_without_hit_objects(&self, with_sounds: bool) -> Self {
Self {
mode: self.mode,
version: self.version,
n_circles: 0,
n_sliders: 0,
n_spinners: 0,
ar: self.ar,
od: self.od,
cs: self.cs,
hp: self.hp,
slider_mult: self.slider_mult,
tick_rate: self.tick_rate,
hit_objects: Vec::with_capacity(self.hit_objects.len()),
sounds: Vec::with_capacity((usize::from(with_sounds)) * self.sounds.len()),
timing_points: self.timing_points.clone(),
difficulty_points: self.difficulty_points.clone(),
effect_points: self.effect_points.clone(),
stack_leniency: self.stack_leniency,
breaks: self.breaks.clone(),
}
}
}
-28
View File
@@ -1,28 +0,0 @@
/// The mode of a beatmap.
#[derive(Copy, Clone, Debug, Default, Hash, PartialEq, Eq)]
pub enum GameMode {
/// osu!standard
#[default]
Osu = 0,
/// osu!taiko
Taiko = 1,
/// osu!catch
Catch = 2,
/// osu!mania
Mania = 3,
}
impl From<u8> for GameMode {
#[inline]
fn from(mode: u8) -> Self {
// `0` will happen most commonly so it should be the first branch
#[allow(clippy::match_same_arms)]
match mode {
0 => Self::Osu,
1 => Self::Taiko,
2 => Self::Catch,
3 => Self::Mania,
_ => Self::Osu,
}
}
}
-293
View File
@@ -1,293 +0,0 @@
use std::{
cmp::Ordering,
fmt::{Debug, Formatter, Result as FmtResult},
ops::{Deref, Index},
slice::SliceIndex,
};
use crate::beatmap::{DifficultyPoint, EffectPoint, TimingPoint};
/// A [`Vec`] whose elements are guaranteed to be unique and in order.
#[derive(Clone)]
pub struct SortedVec<T> {
inner: Vec<T>,
}
impl<T> SortedVec<T> {
/// Constructs a new, empty `SortedVec<T>`.
#[inline]
pub const fn new() -> Self {
Self { inner: Vec::new() }
}
/// Constructs a new, empty `SortedVec<T>` with at least the specified capacity.
#[inline]
pub fn with_capacity(capacity: usize) -> Self {
Self {
inner: Vec::with_capacity(capacity),
}
}
/// Extracts the inner [`Vec`].
#[inline]
pub fn into_inner(self) -> Vec<T> {
self.inner
}
/// Extracts a slice containing the entire sorted vector.
pub fn as_slice(&self) -> &[T] {
self.inner.as_slice()
}
/// Returns a mutable reference to the underlying `Vec`.
///
/// # Safety
///
/// The caller must ensure that the items stay in order.
pub unsafe fn as_inner_mut(&mut self) -> &mut Vec<T> {
&mut self.inner
}
/// Removes the last element and returns it, or `None` if the vec is empty.
pub fn pop(&mut self) -> Option<T> {
self.inner.pop()
}
/// Retains only the elements specified by the predicate.
///
/// In other words, remove all items `i` for which `f(&i)` returns `false`.
pub fn retain<F>(&mut self, f: F)
where
F: FnMut(&T) -> bool,
{
self.inner.retain(f);
}
}
impl<T: Sortable> SortedVec<T> {
/// Same as [`slice::binary_search_by`] with the function
/// [`<T as Sortable>::cmp`](Sortable::cmp).
#[inline]
pub fn find(&self, value: &T) -> Result<usize, usize> {
self.inner
.binary_search_by(|probe| <T as Sortable>::cmp(probe, value))
}
/// Push a new value into the sorted list based on [`<T as Sortable>::push`](Sortable::push).
pub fn push(&mut self, value: T) {
<T as Sortable>::push(value, self);
}
}
impl<T> Deref for SortedVec<T> {
type Target = [T];
#[inline]
fn deref(&self) -> &Self::Target {
<Vec<T> as Deref>::deref(&self.inner)
}
}
impl<T, I> Index<I> for SortedVec<T>
where
I: SliceIndex<[T]>,
{
type Output = <I as SliceIndex<[T]>>::Output;
#[inline]
fn index(&self, index: I) -> &Self::Output {
<Vec<T> as Index<I>>::index(&self.inner, index)
}
}
impl<T: Debug> Debug for SortedVec<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
<Vec<T> as Debug>::fmt(&self.inner, f)
}
}
impl<T> Default for SortedVec<T> {
#[inline]
fn default() -> Self {
Self::new()
}
}
impl<T: Sortable> From<Vec<T>> for SortedVec<T> {
fn from(mut v: Vec<T>) -> Self {
v.sort_by(<T as Sortable>::cmp);
v.dedup_by(|a, b| {
<T as Sortable>::cmp(a, b) == Ordering::Equal || <T as Sortable>::is_redundant(b, a)
});
Self { inner: v }
}
}
impl<T: Sortable> FromIterator<T> for SortedVec<T> {
fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
Self::from(Vec::from_iter(iter))
}
}
impl<T: Sortable> Extend<T> for SortedVec<T> {
fn extend<I: IntoIterator<Item = T>>(&mut self, iter: I) {
for value in iter {
self.push(value);
}
}
}
/// Trait for types that can be sorted in a [`SortedVec`].
pub trait Sortable: Sized {
/// An [`Ordering`] between `self` and `other`.
fn cmp(&self, other: &Self) -> Ordering;
/// Indicates whether `self` and `_existing` are identical.
#[inline]
fn is_redundant(&self, _existing: &Self) -> bool {
false
}
/// Pushes a value into the [`SortedVec`].
#[inline]
fn push(self, sorted_vec: &mut SortedVec<Self>) {
match sorted_vec.find(&self) {
Ok(i) => sorted_vec.inner[i] = self,
Err(i) if i == sorted_vec.len() => sorted_vec.inner.push(self),
Err(i) => sorted_vec.inner.insert(i, self),
}
}
}
impl Sortable for TimingPoint {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
self.partial_cmp(other).unwrap_or(Ordering::Equal)
}
}
impl Sortable for DifficultyPoint {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
self.partial_cmp(other).unwrap_or(Ordering::Equal)
}
#[inline]
fn is_redundant(&self, existing: &Self) -> bool {
self.is_redundant(existing)
}
fn push(self, sorted_vec: &mut SortedVec<Self>) {
enum Action {
Insert(usize),
Replace(usize),
Push,
Skip,
}
let action = match sorted_vec.find(&self).map_err(|idx| idx.checked_sub(1)) {
Ok(i) | Err(Some(i)) if self.is_redundant(&sorted_vec[i]) => Action::Skip,
Ok(i) => Action::Replace(i),
Err(Some(i)) if i == sorted_vec.len() - 1 => Action::Push,
Err(Some(i)) => Action::Insert(i),
Err(None) if self.is_redundant(&Self::default()) => Action::Skip,
Err(None) => Action::Insert(0),
};
match action {
Action::Insert(i) => sorted_vec.inner.insert(i, self),
Action::Replace(i) => sorted_vec.inner[i] = self,
Action::Push => sorted_vec.inner.push(self),
Action::Skip => {}
}
}
}
impl Sortable for EffectPoint {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
self.partial_cmp(other).unwrap_or(Ordering::Equal)
}
}
impl<T: Ord> Sortable for T {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
<Self as Ord>::cmp(self, other)
}
}
#[cfg(test)]
mod tests {
use crate::beatmap::DifficultyPoint;
use super::SortedVec;
#[test]
fn sorts_on_push() {
let mut v = SortedVec::with_capacity(4);
v.push(42);
v.push(13);
v.push(20);
v.push(0);
assert_eq!(v.as_slice(), &[0_i32, 13, 20, 42]);
}
#[test]
fn no_push_if_redundant() {
let mut v = SortedVec::default();
v.push(DifficultyPoint::default());
assert_eq!(v.len(), 0);
v.push(DifficultyPoint::new(1.0, 2.0, 3.0));
assert_eq!(v.len(), 1);
v.push(DifficultyPoint::new(2.0, 2.0, 3.0));
v.push(DifficultyPoint::default());
assert_eq!(v.len(), 1);
}
#[test]
fn from_iter() {
let base = vec![
DifficultyPoint {
time: 5.0,
slider_vel: 10.0,
bpm_mult: 1.0,
generate_ticks: true,
},
DifficultyPoint {
time: 3.0,
slider_vel: 20.0,
bpm_mult: 2.0,
generate_ticks: false,
},
DifficultyPoint {
time: 6.0,
slider_vel: 10.0,
bpm_mult: 3.0,
generate_ticks: true,
},
DifficultyPoint {
time: 10.0,
slider_vel: 15.0,
bpm_mult: 4.0,
generate_ticks: true,
},
];
let sorted = SortedVec::from_iter(base);
let v: Vec<_> = sorted
.into_inner()
.into_iter()
.map(|tp| tp.bpm_mult)
.collect();
assert_eq!(v, vec![2.0, 1.0, 4.0]);
}
}
+113
View File
@@ -0,0 +1,113 @@
use crate::catch::performance::CatchPerformance;
/// The result of a difficulty calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchDifficultyAttributes {
/// The final star rating
pub stars: f64,
/// The approach rate.
pub ar: f64,
/// The amount of fruits.
pub n_fruits: u32,
/// The amount of droplets.
pub n_droplets: u32,
/// The amount of tiny droplets.
pub n_tiny_droplets: u32,
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub is_convert: bool,
}
impl CatchDifficultyAttributes {
/// Return the maximum combo.
pub const fn max_combo(&self) -> u32 {
self.n_fruits + self.n_droplets
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub const fn is_convert(&self) -> bool {
self.is_convert
}
/// Returns a builder for performance calculation.
pub fn pp<'a>(self) -> CatchPerformance<'a> {
self.into()
}
}
/// The result of a performance calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation
pub difficulty: CatchDifficultyAttributes,
/// The final performance points.
pub pp: f64,
}
impl CatchPerformanceAttributes {
/// Return the star value.
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
pub const fn max_combo(&self) -> u32 {
self.difficulty.max_combo()
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub const fn is_convert(&self) -> bool {
self.difficulty.is_convert
}
}
pub struct CatchDifficultyAttributesBuilder {
inner: CatchDifficultyAttributes,
take: usize,
}
impl CatchDifficultyAttributesBuilder {
pub const fn new(attrs: CatchDifficultyAttributes, take: usize) -> Self {
Self { inner: attrs, take }
}
pub const fn into_inner(self) -> CatchDifficultyAttributes {
self.inner
}
pub const fn take_more(&self) -> bool {
self.take > 0
}
pub fn inc_fruits(&mut self) {
Self::inc_value(&mut self.inner.n_fruits, &mut self.take);
}
pub fn inc_droplets(&mut self) {
Self::inc_value(&mut self.inner.n_droplets, &mut self.take);
}
/// Should only be used if [`take_more`] returns `true`.
///
/// [`take_more`]: Self::take_more
pub fn inc_tiny_droplets(&mut self) {
self.inner.n_tiny_droplets += 1;
}
fn inc_value(value: &mut u32, take: &mut usize) {
if *take > 0 {
*value += 1;
*take -= 1;
}
}
}
-95
View File
@@ -1,95 +0,0 @@
use crate::parse::Pos2;
use super::fruit_or_juice::FruitParams;
const PLAYFIELD_WIDTH: f32 = 512.0;
const BASE_SPEED: f64 = 1.0;
/// A [`HitObject`](crate::parse::HitObject) that was processed for the osu!ctb gamemode.
#[derive(Clone, Debug, PartialEq)]
pub struct CatchObject {
/// The X position of the object.
pub pos: f32,
/// The time of the object.
pub time: f64,
/// Whether the object is a hyper dash.
pub hyper_dash: bool,
/// The hyper distance to the next object
pub hyper_dist: f32,
}
impl CatchObject {
#[inline]
pub(crate) const fn new((pos, time): (Pos2, f64)) -> Self {
Self {
pos: pos.x,
time,
hyper_dash: false,
hyper_dist: 0.0,
}
}
pub(crate) fn with_hr(mut self, params: &mut FruitParams) -> Self {
let mut offset_pos = self.pos;
let time_diff = self.time - params.last_time;
if let Some(last_pos_ref) = params.last_pos.filter(|_| time_diff <= 1000.0) {
let pos_diff = offset_pos - last_pos_ref;
if pos_diff.abs() > f32::EPSILON {
if pos_diff.abs() < (time_diff as f32 / 3.0).floor() {
if pos_diff > 0.0 {
if offset_pos + pos_diff < PLAYFIELD_WIDTH {
offset_pos += pos_diff;
}
} else if offset_pos + pos_diff > 0.0 {
offset_pos += pos_diff;
}
}
params.last_pos.replace(offset_pos);
params.last_time = self.time;
}
self.pos = offset_pos;
} else {
params.last_pos.replace(offset_pos);
params.last_time = self.time;
}
self
}
pub(crate) fn init_hyper_dash(
&mut self,
half_catcher_width: f64,
next: &CatchObject,
last_direction: &mut i8,
last_excess: &mut f64,
) {
let next_x = next.pos;
let curr_x = self.pos;
let this_direction = i8::from(next_x > curr_x) * 2 - 1;
let time_to_next = next.time - self.time - 1000.0 / 60.0 / 4.0;
let sub = if *last_direction == this_direction {
*last_excess
} else {
half_catcher_width
};
let dist_to_next = f64::from((next_x - curr_x).abs()) - sub;
let hyper_dist = (time_to_next * BASE_SPEED - dist_to_next) as f32;
if hyper_dist < 0.0 {
self.hyper_dash = true;
*last_excess = half_catcher_width;
} else {
self.hyper_dist = hyper_dist;
*last_excess = f64::from(hyper_dist).clamp(0.0, half_catcher_width);
}
*last_direction = this_direction;
}
}
+20
View File
@@ -0,0 +1,20 @@
pub struct Catcher;
const AREA_CATCHER_SIZE: f32 = 106.75;
impl Catcher {
pub const BASE_SPEED: f64 = 1.0;
pub const ALLOWED_CATCH_RANGE: f32 = 0.8;
pub fn calculate_catch_width(cs: f32) -> f32 {
Self::calculate_catch_width_by_scale(Self::calculate_scale(cs))
}
fn calculate_catch_width_by_scale(scale: f32) -> f32 {
AREA_CATCHER_SIZE * scale.abs() * Self::ALLOWED_CATCH_RANGE
}
fn calculate_scale(cs: f32) -> f32 {
1.0 - 0.7 * (cs - 5.0) / 5.0
}
}
+315
View File
@@ -0,0 +1,315 @@
use std::borrow::Cow;
use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers};
use crate::{
model::{
beatmap::{Beatmap, Converted},
hit_object::{HitObject, HitObjectKind, HoldNote, Spinner},
mode::ConvertStatus,
},
util::{float_ext::FloatExt, random::Random},
};
use super::{
attributes::CatchDifficultyAttributesBuilder,
catcher::Catcher,
object::{
banana_shower::BananaShower,
fruit::Fruit,
juice_stream::{JuiceStream, JuiceStreamBufs, NestedJuiceStreamObjectKind},
palpable::PalpableObject,
},
Catch, PLAYFIELD_WIDTH,
};
const RNG_SEED: i32 = 1337;
/// A [`Beatmap`] for [`Catch`] calculations.
pub type CatchBeatmap<'a> = Converted<'a, Catch>;
pub fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
match map.mode {
GameMode::Osu => {
map.to_mut().mode = GameMode::Catch;
ConvertStatus::Done
}
GameMode::Catch => ConvertStatus::Noop,
GameMode::Taiko | GameMode::Mania => ConvertStatus::Incompatible,
}
}
pub fn convert_objects(
converted: &CatchBeatmap<'_>,
attrs: &mut CatchDifficultyAttributesBuilder,
hr: bool,
cs: f32,
) -> Vec<PalpableObject> {
// TODO: check for better default capacity
let mut palpable_objects = Vec::with_capacity(converted.map.hit_objects.len() * 2);
let mut bufs = JuiceStreamBufs {
curve: CurveBuffers::default(),
nested_objects: Vec::new(), // TODO: default capacity
ticks: Vec::new(), // TODO: default capacity
};
let mut rng = Random::new(RNG_SEED);
let mut last_pos = None;
let mut last_start_time = 0.0;
for h in converted.map.hit_objects.iter() {
let mut new_objects = convert_object(h, converted, attrs, &mut bufs);
apply_pos_offset(
&mut new_objects,
hr,
&mut last_pos,
&mut last_start_time,
&mut rng,
);
palpable_objects.extend(new_objects);
}
palpable_objects.sort_by(|a, b| a.start_time.total_cmp(&b.start_time));
initialize_hyper_dash(cs, &mut palpable_objects);
palpable_objects
}
fn convert_object<'a>(
h: &'a HitObject,
converted: &CatchBeatmap<'_>,
attrs: &mut CatchDifficultyAttributesBuilder,
bufs: &'a mut JuiceStreamBufs,
) -> ObjectIter<'a> {
let state = match h.kind {
HitObjectKind::Circle => ObjectIterState::Fruit(Some(Fruit::new(attrs))),
HitObjectKind::Slider(ref slider) => {
let stream = JuiceStream::new(h.pos.x, h.start_time, slider, converted, attrs, bufs);
ObjectIterState::JuiceStream(stream)
}
HitObjectKind::Spinner(Spinner { end_time })
| HitObjectKind::Hold(HoldNote { end_time }) => {
ObjectIterState::BananaShower(BananaShower::new(h.start_time, end_time))
}
};
ObjectIter {
x: h.pos.x,
start_time: h.start_time,
state,
}
}
struct ObjectIter<'a> {
x: f32,
start_time: f64,
state: ObjectIterState<'a>,
}
enum ObjectIterState<'a> {
Fruit(Option<Fruit>),
JuiceStream(JuiceStream<'a>),
BananaShower(BananaShower),
}
impl Iterator for ObjectIter<'_> {
type Item = PalpableObject;
fn next(&mut self) -> Option<Self::Item> {
match self.state {
ObjectIterState::Fruit(ref mut fruit) => fruit
.take()
.map(|fruit| PalpableObject::new(self.x, fruit.x_offset, self.start_time)),
ObjectIterState::JuiceStream(ref mut stream) => stream
.nested_objects
.find(|nested| !matches!(nested.kind, NestedJuiceStreamObjectKind::TinyDroplet))
.map(|nested| PalpableObject::new(nested.pos, 0.0, nested.start_time)),
ObjectIterState::BananaShower(_) => None,
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
}
impl ExactSizeIterator for ObjectIter<'_> {
fn len(&self) -> usize {
match self.state {
ObjectIterState::Fruit(ref fruit) => usize::from(fruit.is_some()),
ObjectIterState::JuiceStream(ref stream) => stream.nested_objects.len(),
ObjectIterState::BananaShower(_) => 0,
}
}
}
fn apply_pos_offset(
hit_object: &mut ObjectIter<'_>,
should_apply_hr_offset: bool,
last_pos: &mut Option<f32>,
last_start_time: &mut f64,
rng: &mut Random,
) {
match hit_object.state {
ObjectIterState::Fruit(Some(ref mut fruit)) => {
if should_apply_hr_offset {
apply_hr_offset(
hit_object.x,
&mut fruit.x_offset,
hit_object.start_time,
last_pos,
last_start_time,
rng,
);
}
}
ObjectIterState::JuiceStream(ref stream) => {
let pos = hit_object.x
+ stream
.control_points
.last()
.map_or(0.0, |control_point| control_point.pos.x);
*last_pos = Some(pos);
*last_start_time = hit_object.start_time;
for nested in stream.nested_objects.as_slice() {
if let NestedJuiceStreamObjectKind::Droplet
| NestedJuiceStreamObjectKind::TinyDroplet = nested.kind
{
let _ = rng.next_int();
}
}
}
ObjectIterState::BananaShower(ref shower) => {
for _ in 0..shower.n_bananas {
let _ = rng.next_double();
let _ = rng.next_int();
let _ = rng.next_int();
let _ = rng.next_int();
}
}
ObjectIterState::Fruit(None) => unreachable!(),
}
}
fn apply_hr_offset(
x: f32,
x_offset: &mut f32,
start_time: f64,
last_pos: &mut Option<f32>,
last_start_time: &mut f64,
rng: &mut Random,
) {
let mut offset_pos = x;
let Some(last_pos) = last_pos else {
*last_pos = Some(offset_pos);
*last_start_time = start_time;
return;
};
let pos_diff = offset_pos - *last_pos;
let time_diff = (start_time - *last_start_time) as i32;
if time_diff > 1000 {
*last_pos = offset_pos;
*last_start_time = start_time;
return;
}
if pos_diff.eq(0.0) {
apply_random_offset(&mut offset_pos, f64::from(time_diff) / 4.0, rng);
*x_offset = offset_pos - x;
return;
}
if pos_diff.abs() < (time_diff / 3) as f32 {
apply_offset(&mut offset_pos, pos_diff);
}
*x_offset = offset_pos - x;
*last_pos = offset_pos;
*last_start_time = start_time;
}
fn apply_random_offset(pos: &mut f32, max_offset: f64, rng: &mut Random) {
let right = rng.next_bool();
let rand = (rng.next_double_range(0.0, max_offset.max(0.0)) as f32).min(20.0);
if right {
if *pos + rand <= PLAYFIELD_WIDTH {
*pos += rand;
} else {
*pos -= rand;
}
} else if *pos - rand >= 0.0 {
*pos -= rand;
} else {
*pos += rand;
}
}
fn apply_offset(pos: &mut f32, amount: f32) {
if amount > 0.0 {
if *pos + amount < PLAYFIELD_WIDTH {
*pos += amount;
}
} else if *pos + amount > 0.0 {
*pos += amount;
}
}
fn initialize_hyper_dash(cs: f32, palpable_objects: &mut [PalpableObject]) {
let mut half_catcher_width = f64::from(Catcher::calculate_catch_width(cs) / 2.0);
half_catcher_width /= f64::from(Catcher::ALLOWED_CATCH_RANGE);
let mut last_dir = 0;
let mut last_excess = half_catcher_width;
for i in 0..palpable_objects.len() - 1 {
let next = &palpable_objects[i + 1];
let curr = &palpable_objects[i];
let this_dir = if next.effective_x() > curr.effective_x() {
1
} else {
-1
};
let time_to_next = next.start_time - curr.start_time - f64::from(1000.0_f32 / 60.0 / 4.0);
let dist_to_next = f64::from((next.effective_x() - curr.effective_x()).abs())
- if last_dir == this_dir {
last_excess
} else {
half_catcher_width
};
let dist_to_hyper = (time_to_next * Catcher::BASE_SPEED - dist_to_next) as f32;
let curr = &mut palpable_objects[i];
if dist_to_hyper < 0.0 {
curr.hyper_dash = true;
last_excess = half_catcher_width;
} else {
curr.dist_to_hyper_dash = dist_to_hyper;
last_excess = f64::from(dist_to_hyper).clamp(0.0, half_catcher_width);
}
last_dir = this_dir;
}
}
+104
View File
@@ -0,0 +1,104 @@
use crate::{
any::difficulty::{skills::Skill, ModeDifficulty},
catch::{
catcher::Catcher, convert::convert_objects, difficulty::object::CatchDifficultyObject,
},
util::mods::Mods,
};
use self::skills::movement::Movement;
use super::{
attributes::{CatchDifficultyAttributes, CatchDifficultyAttributesBuilder},
convert::CatchBeatmap,
};
mod object;
mod skills;
const STAR_SCALING_FACTOR: f64 = 0.153;
pub fn difficulty(
difficulty: &ModeDifficulty,
converted: &CatchBeatmap<'_>,
) -> CatchDifficultyAttributes {
let DifficultyValues {
movement,
mut attrs,
} = DifficultyValues::calculate(difficulty, converted);
attrs.stars = movement.difficulty_value().sqrt() * STAR_SCALING_FACTOR;
attrs
}
pub struct DifficultyValues {
pub movement: Movement,
pub attrs: CatchDifficultyAttributes,
}
impl DifficultyValues {
pub fn calculate(difficulty: &ModeDifficulty, converted: &CatchBeatmap<'_>) -> Self {
let take = difficulty.get_passed_objects();
let clock_rate = difficulty.get_clock_rate();
let map_attrs = converted
.attributes()
.mods(difficulty.get_mods())
.clock_rate(clock_rate)
.build();
let attrs = CatchDifficultyAttributes {
ar: map_attrs.ar,
is_convert: converted.is_convert,
..Default::default()
};
let mut attrs = CatchDifficultyAttributesBuilder::new(attrs, take);
let hr = difficulty.get_mods().hr();
let movement = Movement::new(clock_rate);
let palpable_objects = convert_objects(converted, &mut attrs, hr, map_attrs.cs as f32);
let mut palpable_objects_iter = palpable_objects.iter().take(take);
let Some(mut last_object) = palpable_objects_iter.next() else {
return Self {
movement,
attrs: attrs.into_inner(),
};
};
let mut half_catcher_width = Catcher::calculate_catch_width(map_attrs.cs as f32) * 0.5;
half_catcher_width *= 1.0 - ((map_attrs.cs as f32 - 5.5).max(0.0) * 0.0625);
let scaling_factor =
CatchDifficultyObject::NORMALIZED_HITOBJECT_RADIUS / half_catcher_width;
let diff_objects: Vec<_> = palpable_objects_iter
.enumerate()
.map(|(i, hit_object)| {
let diff_object = CatchDifficultyObject::new(
hit_object,
last_object,
clock_rate,
scaling_factor,
i,
);
last_object = hit_object;
diff_object
})
.collect();
let mut movement = Skill::new(movement, &diff_objects);
for curr in diff_objects.iter() {
movement.process(curr);
}
Self {
movement: movement.inner,
attrs: attrs.into_inner(),
}
}
}
+56
View File
@@ -0,0 +1,56 @@
use crate::{any::difficulty::object::IDifficultyObject, catch::object::palpable::PalpableObject};
pub struct CatchDifficultyObject {
pub idx: usize,
pub start_time: f64,
pub delta_time: f64,
pub normalized_pos: f32,
pub last_normalized_pos: f32,
pub strain_time: f64,
pub last_object: LastObject,
}
impl CatchDifficultyObject {
pub const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
pub fn new(
hit_object: &PalpableObject,
last_object: &PalpableObject,
clock_rate: f64,
scaling_factor: f32,
idx: usize,
) -> Self {
let normalized_pos = hit_object.effective_x() * scaling_factor;
let last_normalized_pos = last_object.effective_x() * scaling_factor;
let start_time = hit_object.start_time / clock_rate;
let delta_time = (hit_object.start_time - last_object.start_time) / clock_rate;
let strain_time = delta_time.max(40.0);
let last_object = LastObject {
hyper_dash: last_object.hyper_dash,
dist_to_hyper_dash: last_object.dist_to_hyper_dash,
};
Self {
idx,
start_time,
delta_time,
normalized_pos,
last_normalized_pos,
strain_time,
last_object,
}
}
}
pub struct LastObject {
pub hyper_dash: bool,
pub dist_to_hyper_dash: f32,
}
impl IDifficultyObject for CatchDifficultyObject {
fn idx(&self) -> usize {
self.idx
}
}
+1
View File
@@ -0,0 +1 @@
pub mod movement;
+163
View File
@@ -0,0 +1,163 @@
use crate::{
any::difficulty::{
object::IDifficultyObject,
skills::{strain_decay, ISkill, Skill, StrainDecaySkill},
},
catch::difficulty::object::CatchDifficultyObject,
};
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 STRAIN_DECAY_BASE: f64 = 0.2;
const DECAY_WEIGHT: f64 = 0.94;
const SECTION_LEN: f64 = 750.0;
pub struct Movement {
inner: StrainDecaySkill,
last_player_pos: Option<f32>,
last_dist_moved: f32,
last_strain_time: f64,
clock_rate: f64,
}
impl Movement {
pub fn new(clock_rate: f64) -> Self {
Self {
inner: StrainDecaySkill::default(),
last_player_pos: None,
last_dist_moved: 0.0,
last_strain_time: 0.0,
clock_rate,
}
}
const fn curr_strain(&self) -> f64 {
self.inner.curr_strain
}
fn curr_strain_mut(&mut self) -> &mut f64 {
&mut self.inner.curr_strain
}
fn strain_value_at(&mut self, curr: &CatchDifficultyObject) -> 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()
}
fn strain_value_of(&mut self, curr: &CatchDifficultyObject) -> f64 {
let last_player_pos = self.last_player_pos.unwrap_or(curr.last_normalized_pos);
let term = NORMALIZED_HITOBJECT_RADIUS - ABSOLUTE_PLAYER_POSITIONING_ERROR;
let mut player_pos =
last_player_pos.clamp(curr.normalized_pos - term, curr.normalized_pos + term);
let dist_moved = player_pos - last_player_pos;
let weighted_strain_time = curr.strain_time + 13.0 + (3.0 / self.clock_rate);
let mut dist_addition = f64::from(dist_moved.abs()).powf(1.3) / 510.0;
let sqrt_strain = weighted_strain_time.sqrt();
let mut edge_dash_bonus: f64 = 0.0;
if dist_moved.abs() > 0.1 {
if self.last_dist_moved.abs() > 0.1
&& dist_moved.signum() != self.last_dist_moved.signum()
{
let bonus_factor = f64::from(dist_moved.abs().min(50.0) / 50.0);
let anti_flow_factor =
f64::from(self.last_dist_moved.abs().min(70.0) / 70.0).max(0.38);
dist_addition += DIRECTION_CHANGE_BONUS / (self.last_strain_time + 16.0).sqrt()
* bonus_factor
* anti_flow_factor
* (1.0 - (weighted_strain_time / 1000.0).powi(3)).max(0.0);
}
dist_addition += 12.5
* f64::from(dist_moved.abs().min(NORMALIZED_HITOBJECT_RADIUS * 2.0))
/ f64::from(NORMALIZED_HITOBJECT_RADIUS * 6.0)
/ sqrt_strain;
}
if curr.last_object.dist_to_hyper_dash <= 20.0 {
if curr.last_object.hyper_dash {
player_pos = curr.normalized_pos;
} else {
edge_dash_bonus += 5.7;
}
dist_addition *= 1.0
+ edge_dash_bonus
* f64::from((20.0 - curr.last_object.dist_to_hyper_dash) / 20.0)
* ((curr.strain_time * self.clock_rate).min(265.0) / 265.0).powf(1.5);
}
self.last_player_pos = Some(player_pos);
self.last_dist_moved = dist_moved;
self.last_strain_time = curr.strain_time;
dist_addition / weighted_strain_time
}
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub fn difficulty_value(self) -> f64 {
self.inner.difficulty_value(DECAY_WEIGHT)
}
}
impl ISkill for Movement {
type DifficultyObjects<'a> = [CatchDifficultyObject];
}
impl<'a> Skill<'a, Movement> {
fn calculate_initial_strain(&mut self, time: f64, curr: &CatchDifficultyObject) -> f64 {
let prev_start_time = curr
.previous(0, self.diff_objects)
.map_or(0.0, |prev| prev.start_time);
self.inner.curr_strain() * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
}
const fn curr_section_peak(&self) -> f64 {
self.inner.inner.inner.curr_section_peak
}
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_peak
}
const fn curr_section_end(&self) -> f64 {
self.inner.inner.inner.curr_section_end
}
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_end
}
pub fn process(&mut self, curr: &CatchDifficultyObject) {
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / SECTION_LEN).ceil() * SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.inner.inner.save_curr_peak();
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
self.inner.inner.start_new_section_from(initial_strain);
*self.curr_section_end_mut() += SECTION_LEN;
}
let strain_value_at = self.inner.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
}
-46
View File
@@ -1,46 +0,0 @@
use super::CatchObject;
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
pub(crate) struct DifficultyObject<'o> {
pub(crate) base: &'o CatchObject,
pub(crate) last: &'o CatchObject,
pub(crate) delta: f64,
pub(crate) start_time: f64,
pub(crate) normalized_pos: f32,
pub(crate) last_normalized_pos: f32,
pub(crate) strain_time: f64,
pub(crate) clock_rate: f64,
}
impl<'o> DifficultyObject<'o> {
#[inline]
pub(crate) fn new(
base: &'o CatchObject,
last: &'o CatchObject,
half_catcher_width: f32,
clock_rate: f64,
) -> Self {
let delta = (base.time - last.time) / clock_rate;
let start_time = base.time / clock_rate;
let strain_time = delta.max(40.0);
let scaling_factor = NORMALIZED_HITOBJECT_RADIUS / half_catcher_width;
let normalized_pos = base.pos * scaling_factor;
let last_normalized_pos = last.pos * scaling_factor;
Self {
base,
last,
delta,
start_time,
normalized_pos,
last_normalized_pos,
strain_time,
clock_rate,
}
}
}
-278
View File
@@ -1,278 +0,0 @@
use std::{iter::Map, vec::IntoIter};
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind, Pos2},
Beatmap,
};
use super::{catch_object::CatchObject, CatchDifficultyAttributes};
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
const BASE_SCORING_DISTANCE: f64 = 100.0;
#[derive(Clone, Debug)]
pub(crate) struct FruitParams {
pub(crate) attributes: CatchDifficultyAttributes,
pub(crate) curve_bufs: CurveBuffers,
pub(crate) last_pos: Option<f32>,
pub(crate) last_time: f64,
pub(crate) ticks: Vec<(Pos2, f64)>,
pub(crate) with_hr: bool,
}
type JuiceStream = Map<IntoIter<(Pos2, f64)>, fn((Pos2, f64)) -> CatchObject>;
#[derive(Clone, Debug)]
pub(crate) enum FruitOrJuice {
Fruit(Option<CatchObject>),
Juice(JuiceStream),
}
impl FruitOrJuice {
pub(crate) fn new(h: &HitObject, params: &mut FruitParams, map: &Beatmap) -> Option<Self> {
match &h.kind {
HitObjectKind::Circle => {
let mut h = CatchObject::new((h.pos, h.start_time));
if params.with_hr {
h = h.with_hr(params);
}
params.attributes.n_fruits += 1;
Some(FruitOrJuice::Fruit(Some(h)))
}
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
..
} => {
// HR business
params.last_pos = Some(h.pos.x + control_points[control_points.len() - 1].pos.x);
params.last_time = h.start_time;
let timing_point = map.timing_point_at(h.start_time);
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
let vel_factor = BASE_SCORING_DISTANCE * map.slider_mult / timing_point.beat_len;
let tick_dist_factor = BASE_SCORING_DISTANCE * map.slider_mult / map.tick_rate;
let vel = vel_factor * difficulty_point.slider_vel;
let mut tick_dist = tick_dist_factor * difficulty_point.slider_vel;
let span_count = (*repeats + 1) as f64;
// Build the curve w.r.t. the control points
let curve = Curve::new(control_points, *pixel_len, &mut params.curve_bufs);
let total_duration = span_count * curve.dist() / vel;
let span_duration = total_duration / span_count;
// * A very lenient maximum length of a slider for ticks to be generated.
// * This exists for edge cases such as /b/1573664 where the beatmap has
// * been edited by the user, and should never be reached in normal usage.
let max_len = 100_000.0;
let len = curve.dist().min(max_len);
tick_dist = tick_dist.clamp(0.0, len);
let min_dist_from_end = vel * 10.0;
let mut curr_dist = tick_dist;
let pixel_len = pixel_len.unwrap_or(0.0);
let target = pixel_len - tick_dist / 8.0;
let mut slider_objects = vec![(h.pos, h.start_time)];
if tick_dist > 0.0 {
params.ticks.reserve((target / tick_dist) as usize);
// Tick of the first span
while curr_dist < len - min_dist_from_end {
let progress = curr_dist / len;
let pos = h.pos + curve.position_at(progress);
let time = h.start_time + progress * span_duration;
params.ticks.push((pos, time));
curr_dist += tick_dist;
}
if pixel_len > 0.0 {
let time_add = total_duration * tick_dist / (pixel_len * span_count);
params.attributes.n_tiny_droplets += tiny_droplet_count(
h.start_time,
time_add,
total_duration,
span_count as usize,
&params.ticks,
);
}
slider_objects.reserve(span_count as usize * (params.ticks.len()));
// Other spans
if *repeats == 0 {
slider_objects.append(&mut params.ticks); // automatically empties buffer for next slider
} else {
slider_objects.extend(&params.ticks);
for span_idx in 1..=*repeats {
let progress = f64::from(u8::from(span_idx % 2 == 1));
let pos = h.pos + curve.position_at(progress);
let time_offset = span_duration * span_idx as f64;
// Reverse tick
slider_objects.push((pos, h.start_time + time_offset));
// Actual ticks
if span_idx & 1 == 1 {
let tick_iter = params
.ticks
.iter()
.rev()
.zip(params.ticks.iter())
.map(|((pos, _), (_, time))| (*pos, *time + time_offset));
slider_objects.extend(tick_iter);
} else {
let tick_iter = params
.ticks
.iter()
.map(|(pos, time)| (*pos, *time + time_offset));
slider_objects.extend(tick_iter);
}
}
params.ticks.clear();
}
}
// Slider tail
let progress = f64::from(u8::from(*repeats % 2 == 0));
let pos = h.pos + curve.position_at(progress);
slider_objects.push((pos, h.start_time + total_duration));
let new_fruits = 2 + usize::from(tick_dist > 0.0) * *repeats;
params.attributes.n_fruits += new_fruits;
params.attributes.n_droplets += slider_objects.len() - new_fruits;
let iter = slider_objects
.into_iter()
.map(CatchObject::new as fn(_) -> _);
Some(FruitOrJuice::Juice(iter))
}
HitObjectKind::Spinner { .. } | HitObjectKind::Hold { .. } => None,
}
}
}
impl Iterator for FruitOrJuice {
type Item = CatchObject;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Fruit(fruit) => fruit.take(),
Self::Juice(slider) => slider.next(),
}
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
}
impl ExactSizeIterator for FruitOrJuice {
#[inline]
fn len(&self) -> usize {
match self {
FruitOrJuice::Fruit(Some(_)) => 1,
FruitOrJuice::Fruit(None) => 0,
FruitOrJuice::Juice(slider) => slider.len(),
}
}
}
// BUG: Sometimes there are off-by-one errors,
// presumably caused by floating point inaccuracies
fn tiny_droplet_count(
start_time: f64,
time_between_ticks: f64,
duration: f64,
span_count: usize,
ticks: &[(Pos2, f64)],
) -> usize {
// tiny droplets preceeding a _tick_
let per_tick = if !ticks.is_empty() && time_between_ticks > 80.0 {
let time_between_tiny = shrink_down(time_between_ticks);
// add a little for floating point inaccuracies
let start = time_between_tiny + 0.001;
count_iterations(start, time_between_tiny, time_between_ticks)
} else {
0
};
// tiny droplets preceeding a _reverse_
let last = ticks.last().map_or(start_time, |(_, last)| *last);
let repeat_time = start_time + duration / span_count as f64;
let since_last_tick = repeat_time - last;
let span_last_section = if since_last_tick > 80.0 {
let time_between_tiny = shrink_down(since_last_tick);
count_iterations(time_between_tiny, time_between_tiny, since_last_tick)
} else {
0
};
// tiny droplets preceeding the slider tail
// necessary to handle distinctly because of the legacy last tick
let last = ticks.last().map_or(start_time, |(_, last)| *last);
let end_time = start_time + duration / span_count as f64 - LEGACY_LAST_TICK_OFFSET;
let since_last_tick = end_time - last;
let last_section = if since_last_tick > 80.0 {
let time_between_tiny = shrink_down(since_last_tick);
count_iterations(time_between_tiny, time_between_tiny, since_last_tick)
} else {
0
};
// Combine tiny droplets counts
per_tick * ticks.len() * span_count
+ span_last_section * (span_count.saturating_sub(1))
+ last_section
}
#[inline]
fn shrink_down(mut val: f64) -> f64 {
while val > 100.0 {
val /= 2.0;
}
val
}
#[inline]
fn count_iterations(mut start: f64, step: f64, end: f64) -> usize {
let mut count = 0;
while start < end {
count += 1;
start += step;
}
count
}
-269
View File
@@ -1,269 +0,0 @@
use std::iter;
use crate::{
catch::{difficulty_object::DifficultyObject, SECTION_LENGTH, STAR_SCALING_FACTOR},
curve::CurveBuffers,
parse::Pos2,
Beatmap, Mods,
};
use super::{
calculate_catch_width,
catch_object::CatchObject,
fruit_or_juice::{FruitOrJuice, FruitParams},
movement::Movement,
CatchDifficultyAttributes, ALLOWED_CATCH_RANGE,
};
/// Gradually calculate the difficulty attributes of an osu!catch map.
///
/// Note that this struct implements [`Iterator`].
/// On every call of [`Iterator::next`], the map's next fruit or droplet
/// will be processed and the [`CatchDifficultyAttributes`] will be updated and returned.
///
/// Note that it does not return attributes after a tiny droplet. Only for fruits and droplets.
///
/// If you want to calculate performance attributes, use
/// [`CatchGradualPerformance`](crate::catch::CatchGradualPerformance) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, catch::CatchGradualDifficulty};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = CatchGradualDifficulty::new(&map, mods);
///
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
/// let attrs2 = iter.next(); // after the second hit object
///
/// // Remaining hit objects
/// for difficulty in iter {
/// // ...
/// }
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct CatchGradualDifficulty<'map> {
map: &'map Beatmap,
inner: CatchGradualDifficultyInner,
}
impl<'map> CatchGradualDifficulty<'map> {
/// Create a new difficulty attributes iterator for osu!catch maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let inner = CatchGradualDifficultyInner::new(map, mods);
Self { map, inner }
}
pub(crate) const fn idx(&self) -> usize {
self.inner.idx
}
}
impl Iterator for CatchGradualDifficulty<'_> {
type Item = CatchDifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
self.inner.next(self.map)
}
}
/// Gradually calculate the difficulty attributes of an osu!catch map.
///
/// Check [`CatchGradualDifficulty`] for more information. This struct does the same
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct CatchOwnedGradualDifficulty {
pub(crate) map: Beatmap,
inner: CatchGradualDifficultyInner,
}
impl CatchOwnedGradualDifficulty {
/// Create a new difficulty attributes iterator for osu!catch maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let inner = CatchGradualDifficultyInner::new(&map, mods);
Self { map, inner }
}
#[allow(unused)]
pub(crate) const fn idx(&self) -> usize {
self.inner.idx
}
}
impl Iterator for CatchOwnedGradualDifficulty {
type Item = CatchDifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
self.inner.next(&self.map)
}
}
#[derive(Clone, Debug)]
struct CatchObjectIter {
hit_object_idx: usize,
last_object: Option<FruitOrJuice>,
params: FruitParams,
}
impl CatchObjectIter {
fn new(mods: impl Mods, attributes: CatchDifficultyAttributes) -> Self {
let params = FruitParams {
attributes,
curve_bufs: CurveBuffers::default(),
last_pos: None,
last_time: 0.0,
ticks: Vec::new(),
with_hr: mods.hr(),
};
Self {
hit_object_idx: 0,
last_object: None,
params,
}
}
fn attributes(&self) -> CatchDifficultyAttributes {
self.params.attributes.clone()
}
fn next(&mut self, map: &Beatmap) -> Option<CatchObject> {
if let opt @ Some(_) = self.last_object.as_mut().and_then(Iterator::next) {
return opt;
}
map.hit_objects[self.hit_object_idx..]
.iter()
.find_map(|h| {
self.hit_object_idx += 1;
FruitOrJuice::new(h, &mut self.params, map)
})
.and_then(|h| self.last_object.insert(h).next())
}
}
#[derive(Clone, Debug)]
struct CatchGradualDifficultyInner {
idx: usize,
clock_rate: f64,
hit_objects: CatchObjectIter,
movement: Movement,
prev: CatchObject,
half_catcher_width: f64,
last_direction: i8,
last_excess: f64,
curr_section_end: f64,
strain_peak_buf: Vec<f64>,
}
impl CatchGradualDifficultyInner {
fn new(map: &Beatmap, mods: u32) -> Self {
let map_attributes = map.attributes().mods(mods).build();
let attributes = CatchDifficultyAttributes {
ar: map_attributes.ar,
..Default::default()
};
let hit_objects = CatchObjectIter::new(mods, attributes);
let half_catcher_width =
f64::from(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE);
let last_direction = 0;
let last_excess = half_catcher_width;
let movement = Movement::new(map_attributes.cs as f32);
let prev = CatchObject::new((Pos2::zero(), 0.0));
Self {
idx: 0,
clock_rate: mods.clock_rate(),
hit_objects,
movement,
prev,
half_catcher_width,
last_direction,
last_excess,
curr_section_end: 0.0,
strain_peak_buf: Vec::new(),
}
}
fn init_hyper_dash(&mut self, next: &CatchObject) {
self.prev.init_hyper_dash(
self.half_catcher_width,
next,
&mut self.last_direction,
&mut self.last_excess,
);
}
fn next(&mut self, map: &Beatmap) -> Option<CatchDifficultyAttributes> {
let curr = self.hit_objects.next(map)?;
self.idx += 1;
if self.idx == 1 {
self.prev = curr;
return Some(self.hit_objects.attributes());
}
self.init_hyper_dash(&curr);
let h = DifficultyObject::new(
&curr,
&self.prev,
self.movement.half_catcher_width,
self.clock_rate,
);
if self.idx == 2 {
self.curr_section_end =
(h.base.time / self.clock_rate / SECTION_LENGTH).ceil() * SECTION_LENGTH;
} else {
let base_time = h.base.time / self.clock_rate;
while base_time > self.curr_section_end {
self.movement.save_current_peak();
self.movement.start_new_section_from(self.curr_section_end);
self.curr_section_end += SECTION_LENGTH;
}
}
self.movement.process(&h);
self.prev = curr;
let len = self.movement.strain_peaks.len();
let missing = len + 1 - self.strain_peak_buf.len();
self.strain_peak_buf.extend(iter::repeat(0.0).take(missing));
self.strain_peak_buf[..len].copy_from_slice(&self.movement.strain_peaks);
if let Some(last) = self.strain_peak_buf.last_mut() {
*last = self.movement.curr_section_peak;
}
let stars =
Movement::difficulty_value(&mut self.strain_peak_buf).sqrt() * STAR_SCALING_FACTOR;
let attrs = CatchDifficultyAttributes {
stars,
..self.hit_objects.attributes()
};
Some(attrs)
}
}
-201
View File
@@ -1,201 +0,0 @@
use crate::{Beatmap, CatchPP};
use super::{
CatchGradualDifficulty, CatchOwnedGradualDifficulty, CatchPerformanceAttributes,
CatchScoreState,
};
/// Gradually calculate the performance attributes of an osu!catch map.
///
/// After each hit object you can call [`next`](`CatchGradualPerformance::next`)
/// and it will return the resulting current [`CatchPerformanceAttributes`].
/// To process multiple objects at once, use [`nth`](`CatchGradualPerformance::nth`) instead.
///
/// Both methods require a [`CatchScoreState`] that contains the current
/// hitresults as well as the maximum combo so far.
///
/// Note that neither hits nor misses of tiny droplets require
/// to be processed. Only fruits and droplets do.
///
/// If you only want to calculate difficulty attributes use [`CatchGradualDifficulty`] instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, catch::{CatchGradualPerformance, CatchScoreState}};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = CatchGradualPerformance::new(&map, mods);
/// let mut state = CatchScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are only fruits
/// for _ in 0..10 {
/// state.n_fruits += 1;
/// state.max_combo += 1;
///
/// # /*
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
/// }
///
/// // Then comes a miss.
/// // Note that state's max combo won't be incremented for
/// // the next few objects because the combo is reset.
/// state.n_misses += 1;
/// # /*
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
///
/// // The next 10 objects will be a mixture of fruits and droplets.
/// // Notice how tiny droplets from sliders do not count as hit objects
/// // that require processing. Only fruits and droplets do.
/// // Also notice how all 10 objects will be processed in one go.
/// state.n_fruits += 4;
/// state.n_droplets += 6;
/// state.n_tiny_droplets += 12;
/// // The `nth` method takes a zero-based value.
/// # /*
/// let performance = gradual_perf.nth(state.clone(), 9).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.nth(state.clone(), 9);
///
/// // Now comes another fruit. Note that the max combo gets incremented again.
/// state.n_fruits += 1;
/// state.max_combo += 1;
/// # /*
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n_fruits = ...
/// state.n_droplets = ...
/// state.n_tiny_droplets = ...
/// state.n_tiny_droplet_misses = ...
/// state.n_misses = ...
/// let final_performance = gradual_perf.nth(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.nth(state.clone(), usize::MAX);
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.next(state).is_none());
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct CatchGradualPerformance<'map> {
difficulty: CatchGradualDifficulty<'map>,
performance: CatchPP<'map>,
}
impl<'map> CatchGradualPerformance<'map> {
/// Create a new gradual performance calculator for osu!standard maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let difficulty = CatchGradualDifficulty::new(map, mods);
let performance = CatchPP::new(map).mods(mods).passed_objects(0);
Self {
difficulty,
performance,
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score state.
///
/// Note that neither hits nor misses of tiny droplets require
/// to be processed. Only fruits and droplets do.
pub fn next(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
pub fn nth(&mut self, state: CatchScoreState, n: usize) -> Option<CatchPerformanceAttributes> {
let difficulty = self.difficulty.by_ref().take(n.saturating_add(1)).last()?;
let performance = self
.performance
.clone()
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx())
.calculate();
Some(performance)
}
}
/// Gradually calculate the performance attributes of an osu!catch map.
///
/// Check [`CatchGradualPerformance`] for more information. This struct does the same
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct CatchOwnedGradualPerformance {
difficulty: CatchOwnedGradualDifficulty,
mods: u32,
}
impl CatchOwnedGradualPerformance {
/// Create a new gradual performance calculator for osu!standard maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let difficulty = CatchOwnedGradualDifficulty::new(map, mods);
Self { difficulty, mods }
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score state.
///
/// Note that neither hits nor misses of tiny droplets require
/// to be processed. Only fruits and droplets do.
pub fn next(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
pub fn nth(&mut self, state: CatchScoreState, n: usize) -> Option<CatchPerformanceAttributes> {
let difficulty = self.difficulty.by_ref().take(n.saturating_add(1)).last()?;
let performance = CatchPP::new(&self.difficulty.map)
.mods(self.mods)
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx())
.calculate();
Some(performance)
}
}
+39 -329
View File
@@ -1,343 +1,53 @@
mod catch_object;
mod difficulty_object;
mod fruit_or_juice;
mod movement;
mod pp;
mod score_state;
use std::borrow::Cow;
#[cfg(feature = "gradual")]
mod gradual_difficulty;
#[cfg(feature = "gradual")]
mod gradual_performance;
use difficulty_object::DifficultyObject;
use movement::Movement;
pub use self::{catch_object::CatchObject, pp::*, score_state::CatchScoreState};
#[cfg(feature = "gradual")]
pub use self::{
gradual_difficulty::{CatchGradualDifficulty, CatchOwnedGradualDifficulty},
gradual_performance::{CatchGradualPerformance, CatchOwnedGradualPerformance},
use crate::{
any::ModeDifficulty,
model::{
beatmap::Beatmap,
mode::{ConvertStatus, IGameMode},
},
};
pub(crate) use self::fruit_or_juice::{FruitOrJuice, FruitParams};
pub use self::{
attributes::{CatchDifficultyAttributes, CatchPerformanceAttributes},
convert::CatchBeatmap,
performance::CatchPerformance,
score_state::CatchScoreState,
strains::CatchStrains,
};
use crate::{curve::CurveBuffers, Beatmap, Mods, OsuStars};
mod attributes;
mod catcher;
mod convert;
mod difficulty;
mod object;
mod performance;
mod score_state;
mod strains;
const SECTION_LENGTH: f64 = 750.0;
const STAR_SCALING_FACTOR: f64 = 0.153;
const PLAYFIELD_WIDTH: f32 = 512.0;
pub(crate) const ALLOWED_CATCH_RANGE: f32 = 0.8;
const CATCHER_SIZE: f32 = 106.75;
/// Difficulty calculator on osu!catch maps.
/// Marker type for [`GameMode::Catch`].
///
/// # Example
///
/// ```
/// use rosu_pp::{CatchStars, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let difficulty_attrs = CatchStars::new(&map)
/// .mods(8 + 64) // HDDT
/// .calculate();
///
/// println!("Stars: {}", difficulty_attrs.stars);
/// ```
#[derive(Clone, Debug)]
#[must_use]
pub struct CatchStars<'map> {
map: &'map Beatmap,
mods: u32,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
}
/// [`GameMode::Catch`]: rosu_map::section::general::GameMode::Catch
pub struct Catch;
impl<'map> CatchStars<'map> {
/// Create a new difficulty calculator for osu!catch maps.
#[inline]
pub const fn new(map: &'map Beatmap) -> Self {
Self {
map,
mods: 0,
passed_objects: None,
clock_rate: None,
}
impl IGameMode for Catch {
type DifficultyAttributes = CatchDifficultyAttributes;
type Strains = CatchStrains;
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
convert::try_convert(map)
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
fn difficulty(
difficulty: &ModeDifficulty,
converted: &CatchBeatmap<'_>,
) -> Self::DifficultyAttributes {
difficulty::difficulty(difficulty, converted)
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
#[cfg_attr(
feature = "gradual",
doc = "If you want to calculate the difficulty after every few objects, instead of
using [`CatchStars`] multiple times with different `passed_objects`, you should use
[`CatchGradualDifficultyAttributes`](crate::catch::CatchGradualDifficulty)."
)]
#[inline]
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects = Some(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Calculate all difficulty related values, including stars.
#[inline]
pub fn calculate(self) -> CatchDifficultyAttributes {
let (mut movement, mut attributes) = calculate_movement(self);
attributes.stars =
Movement::difficulty_value(&mut movement.strain_peaks).sqrt() * STAR_SCALING_FACTOR;
attributes
}
/// Calculate the skill strains.
///
/// Suitable to plot the difficulty of a map over time.
#[inline]
pub fn strains(self) -> CatchStrains {
let (movement, _) = calculate_movement(self);
CatchStrains {
section_len: SECTION_LENGTH,
movement: movement.strain_peaks,
}
}
}
/// The result of calculating the strains on a osu!catch map.
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug)]
pub struct CatchStrains {
/// Time in ms inbetween two strains.
pub section_len: f64,
/// Strain peaks of the movement skill.
pub movement: Vec<f64>,
}
impl CatchStrains {
/// Returns the number of strain peaks per skill.
#[inline]
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.movement.len()
}
}
#[allow(clippy::needless_pass_by_value)]
fn calculate_movement(params: CatchStars<'_>) -> (Movement, CatchDifficultyAttributes) {
let CatchStars {
map,
mods,
passed_objects,
clock_rate,
} = params;
let take = passed_objects.unwrap_or(usize::MAX);
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
let map_attributes = map.attributes().mods(mods).clock_rate(clock_rate).build();
let attributes = CatchDifficultyAttributes {
ar: map_attributes.ar,
..Default::default()
};
let mut params = FruitParams {
attributes,
curve_bufs: CurveBuffers::default(),
last_pos: None,
last_time: 0.0,
ticks: Vec::new(), // using the same buffer for all sliders
with_hr: mods.hr(),
};
// BUG: Incorrect object order on 2B maps that have fruits within sliders
let mut hit_objects = map
.hit_objects
.iter()
.filter_map(|h| FruitOrJuice::new(h, &mut params, map))
.flatten()
.take(take);
// Hyper dash business
let half_catcher_width =
f64::from(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE);
let mut last_direction = 0;
let mut last_excess = half_catcher_width;
// Strain business
let mut movement = Movement::new(map_attributes.cs as f32);
let (mut prev, curr) = match (hit_objects.next(), hit_objects.next()) {
(Some(prev), Some(curr)) => (prev, curr),
(_, None) => return (movement, params.attributes),
(None, Some(_)) => unreachable!(),
};
let mut curr_section_end = (curr.time / clock_rate / SECTION_LENGTH).ceil() * SECTION_LENGTH;
prev.init_hyper_dash(
half_catcher_width,
&curr,
&mut last_direction,
&mut last_excess,
);
// Handle first object distinctly
let h = DifficultyObject::new(&curr, &prev, movement.half_catcher_width, clock_rate);
movement.process(&h);
prev = curr;
// Handle all other objects
for curr in hit_objects {
prev.init_hyper_dash(
half_catcher_width,
&curr,
&mut last_direction,
&mut last_excess,
);
let h = DifficultyObject::new(&curr, &prev, movement.half_catcher_width, clock_rate);
let base_time = h.base.time / clock_rate;
while base_time > curr_section_end {
movement.save_current_peak();
movement.start_new_section_from(curr_section_end);
curr_section_end += SECTION_LENGTH;
// Optimization to finish the loop early if
// the current peak is 0.0 i.e. it can't decay further.
// If final values don't coincide perfectly anymore,
// this should be looked at and maybe adjusted.
if movement.curr_section_peak.abs() <= f64::EPSILON && base_time > curr_section_end {
let remaining_time = base_time - curr_section_end;
let remaining_iters = (remaining_time / SECTION_LENGTH).ceil();
curr_section_end += remaining_iters * SECTION_LENGTH;
}
}
movement.process(&h);
prev = curr;
}
movement.save_current_peak();
(movement, params.attributes)
}
#[inline]
pub(crate) fn calculate_catch_width(cs: f32) -> f32 {
let scale = 1.0 - 0.7 * (cs - 5.0) / 5.0;
CATCHER_SIZE * scale.abs() * ALLOWED_CATCH_RANGE
}
/// The result of a difficulty calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchDifficultyAttributes {
/// The final star rating
pub stars: f64,
/// The approach rate.
pub ar: f64,
/// The amount of fruits.
pub n_fruits: usize,
/// The amount of droplets.
pub n_droplets: usize,
/// The amount of tiny droplets.
pub n_tiny_droplets: usize,
}
impl CatchDifficultyAttributes {
/// Return the maximum combo.
#[inline]
pub const fn max_combo(&self) -> usize {
self.n_fruits + self.n_droplets
}
/// Returns a builder for performance calculation.
#[inline]
pub fn pp(self) -> CatchPP<'static> {
CatchPP::from(self)
}
}
/// The result of a performance calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation
pub difficulty: CatchDifficultyAttributes,
/// The final performance points.
pub pp: f64,
}
impl CatchPerformanceAttributes {
/// Return the star value.
#[inline]
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
#[inline]
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
#[inline]
pub const fn max_combo(&self) -> usize {
self.difficulty.max_combo()
}
}
impl From<CatchPerformanceAttributes> for CatchDifficultyAttributes {
#[inline]
fn from(attributes: CatchPerformanceAttributes) -> Self {
attributes.difficulty
}
}
impl<'map> From<OsuStars<'map>> for CatchStars<'map> {
#[inline]
fn from(osu: OsuStars<'map>) -> Self {
let OsuStars {
map,
mods,
passed_objects,
clock_rate,
} = osu;
Self {
map,
mods,
passed_objects,
clock_rate,
}
fn strains(difficulty: &ModeDifficulty, converted: &CatchBeatmap<'_>) -> Self::Strains {
strains::strains(difficulty, converted)
}
}
-147
View File
@@ -1,147 +0,0 @@
use super::DifficultyObject;
use std::cmp::Ordering;
const ABSOLUTE_PLAYER_POSITIONING_ERROR: f32 = 16.0;
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
const POSITION_EPSILON: f32 = NORMALIZED_HITOBJECT_RADIUS - ABSOLUTE_PLAYER_POSITIONING_ERROR;
const DIRECTION_CHANGE_BONUS: f64 = 21.0;
const SKILL_MULTIPLIER: f64 = 900.0;
const STRAIN_DECAY_BASE: f64 = 0.2;
const DECAY_WEIGHT: f64 = 0.94;
#[derive(Clone, Debug)]
pub(crate) struct Movement {
pub(crate) half_catcher_width: f32,
last_player_position: Option<f32>,
last_distance_moved: f32,
last_strain_time: f64,
current_strain: f64,
pub(crate) curr_section_peak: f64,
pub(crate) strain_peaks: Vec<f64>,
prev_time: Option<f64>,
}
impl Movement {
#[inline]
pub(crate) fn new(cs: f32) -> Self {
let mut half_catcher_width = super::calculate_catch_width(cs) * 0.5;
half_catcher_width *= 1.0 - ((cs - 5.5).max(0.0) * 0.0625);
Self {
half_catcher_width,
last_player_position: None,
last_distance_moved: 0.0,
last_strain_time: 0.0,
current_strain: 1.0,
curr_section_peak: 1.0,
strain_peaks: Vec::with_capacity(128),
prev_time: None,
}
}
#[inline]
pub(crate) fn save_current_peak(&mut self) {
self.strain_peaks.push(self.curr_section_peak);
}
#[inline]
pub(crate) fn start_new_section_from(&mut self, time: f64) {
self.curr_section_peak = self.peak_strain(time - self.prev_time.unwrap());
}
pub(crate) fn process(&mut self, current: &DifficultyObject<'_>) {
self.current_strain *= strain_decay(current.delta);
self.current_strain += self.strain_value_of(current) * SKILL_MULTIPLIER;
self.curr_section_peak = self.current_strain.max(self.curr_section_peak);
self.prev_time.replace(current.start_time);
}
pub(crate) fn difficulty_value(strain_peaks: &mut [f64]) -> f64 {
let mut difficulty = 0.0;
let mut weight = 1.0;
strain_peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
for &strain in strain_peaks.iter() {
difficulty += strain * weight;
weight *= DECAY_WEIGHT;
}
difficulty
}
fn strain_value_of(&mut self, current: &DifficultyObject<'_>) -> f64 {
let last_player_pos = self
.last_player_position
.unwrap_or(current.last_normalized_pos);
let mut pos = last_player_pos.clamp(
current.normalized_pos - POSITION_EPSILON,
current.normalized_pos + POSITION_EPSILON,
);
let dist_moved = pos - last_player_pos;
let weighted_strain_time = current.strain_time + 13.0 + (3.0 / current.clock_rate);
let mut dist_addition = f64::from(dist_moved.abs().powf(1.3) / 510.0);
if dist_moved.abs() > 0.1 {
if self.last_distance_moved.abs() > 0.1
&& dist_moved.signum() != self.last_distance_moved.signum()
{
let bonus_factor = f64::from(dist_moved.abs().min(50.0) / 50.0);
let anti_flow_factor =
f64::from((self.last_distance_moved.abs().min(70.0) / 70.0).max(0.38));
dist_addition += DIRECTION_CHANGE_BONUS / (self.last_strain_time + 16.0).sqrt()
* bonus_factor
* anti_flow_factor
* (1.0 - (weighted_strain_time / 1000.0).powi(3)).max(0.0);
}
dist_addition += f64::from(
12.5 * dist_moved.abs().min(NORMALIZED_HITOBJECT_RADIUS * 2.0)
/ (NORMALIZED_HITOBJECT_RADIUS * 6.0),
) / weighted_strain_time.sqrt();
}
let mut edge_dash_bonus = 0.0;
if current.last.hyper_dist <= 20.0 {
#[allow(clippy::if_not_else)]
if !current.last.hyper_dash {
edge_dash_bonus += 5.7;
} else {
pos = current.normalized_pos;
}
dist_addition *= 1.0
+ edge_dash_bonus
* f64::from((20.0 - current.last.hyper_dist) / 20.0)
* ((current.strain_time * current.clock_rate).min(265.0) / 265.0).powf(1.5);
}
self.last_player_position.replace(pos);
self.last_distance_moved = dist_moved;
self.last_strain_time = current.strain_time;
dist_addition / weighted_strain_time
}
#[inline]
fn peak_strain(&self, delta_time: f64) -> f64 {
self.current_strain * strain_decay(delta_time)
}
}
#[inline]
fn strain_decay(ms: f64) -> f64 {
STRAIN_DECAY_BASE.powf(ms / 1000.0)
}
+30
View File
@@ -0,0 +1,30 @@
pub struct BananaShower {
pub n_bananas: usize,
}
impl BananaShower {
pub fn new(start_time: f64, end_time: f64) -> Self {
let duration = end_time - start_time;
let mut spacing = duration;
while spacing > 100.0 {
spacing /= 2.0;
}
let n_bananas = if spacing <= 0.0 {
0
} else {
let mut time = start_time;
let mut i = 0;
while time <= end_time {
time += spacing;
i += 1;
}
i
};
Self { n_bananas }
}
}
+13
View File
@@ -0,0 +1,13 @@
use crate::catch::attributes::CatchDifficultyAttributesBuilder;
pub struct Fruit {
pub x_offset: f32,
}
impl Fruit {
pub fn new(attrs: &mut CatchDifficultyAttributesBuilder) -> Self {
attrs.inc_fruits();
Self { x_offset: 0.0 }
}
}
+154
View File
@@ -0,0 +1,154 @@
use std::vec::Drain;
use rosu_map::section::hit_objects::{
CurveBuffers, PathControlPoint, SliderEvent, SliderEventType, SliderEventsIter,
};
use crate::{
catch::{attributes::CatchDifficultyAttributesBuilder, convert::CatchBeatmap},
model::{
control_point::{DifficultyPoint, TimingPoint},
hit_object::Slider,
},
};
pub struct JuiceStream<'a> {
pub control_points: &'a [PathControlPoint], // needed for applying hr offset
pub nested_objects: Drain<'a, NestedJuiceStreamObject>,
}
impl<'a> JuiceStream<'a> {
pub const BASE_SCORING_DIST: f64 = 100.0;
pub fn new(
x: f32,
start_time: f64,
slider: &'a Slider,
converted: &CatchBeatmap<'_>,
attrs: &mut CatchDifficultyAttributesBuilder,
bufs: &'a mut JuiceStreamBufs,
) -> Self {
let slider_multiplier = converted.map.slider_multiplier;
let slider_tick_rate = converted.map.slider_tick_rate;
let beat_len = converted
.map
.timing_point_at(start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let slider_velocity = converted
.map
.difficulty_point_at(start_time)
.map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
point.slider_velocity
});
let path = slider.curve(&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 tick_dist_multiplier = if converted.map.version < 8 {
1.0
} else {
slider_velocity
};
let tick_dist = tick_dist_factor * tick_dist_multiplier;
let span_count = slider.span_count() as f64;
let duration = span_count * path.dist() / velocity;
let span_duration = duration / span_count;
let events = SliderEventsIter::new(
start_time,
span_duration,
velocity,
tick_dist,
path.dist(),
slider.span_count() as i32,
&mut bufs.ticks,
);
let mut last_event_time = None;
for e in events {
if let Some(last_event_time) = last_event_time.filter(|_| attrs.take_more()) {
let since_last_tick = e.time - last_event_time;
if since_last_tick > 80.0 {
let mut time_between_tiny = since_last_tick;
while time_between_tiny > 100.0 {
time_between_tiny /= 2.0;
}
let mut t = time_between_tiny;
while t < since_last_tick {
attrs.inc_tiny_droplets();
let nested = NestedJuiceStreamObject {
pos: 0.0, // not important
start_time: 0.0, // not important
kind: NestedJuiceStreamObjectKind::TinyDroplet,
};
bufs.nested_objects.push(nested);
t += time_between_tiny;
}
}
}
last_event_time = Some(e.time);
let kind = match e.kind {
SliderEventType::Tick => {
attrs.inc_droplets();
NestedJuiceStreamObjectKind::Droplet
}
SliderEventType::Head | SliderEventType::Repeat | SliderEventType::Tail => {
attrs.inc_fruits();
NestedJuiceStreamObjectKind::Fruit
}
SliderEventType::LastTick => continue,
};
let nested = NestedJuiceStreamObject {
pos: x + path.position_at(e.path_progress).x,
start_time: e.time,
kind,
};
bufs.nested_objects.push(nested);
}
Self {
control_points: slider.control_points.as_ref(),
nested_objects: bufs.nested_objects.drain(..),
}
}
}
pub struct NestedJuiceStreamObject {
pub pos: f32,
pub start_time: f64,
pub kind: NestedJuiceStreamObjectKind,
}
pub enum NestedJuiceStreamObjectKind {
Fruit,
Droplet,
TinyDroplet,
}
pub struct JuiceStreamBufs {
pub nested_objects: Vec<NestedJuiceStreamObject>,
pub curve: CurveBuffers,
pub ticks: Vec<SliderEvent>,
}
+4
View File
@@ -0,0 +1,4 @@
pub mod banana_shower;
pub mod fruit;
pub mod juice_stream;
pub mod palpable;
+23
View File
@@ -0,0 +1,23 @@
pub struct PalpableObject {
pub x: f32,
pub x_offset: f32,
pub start_time: f64,
pub dist_to_hyper_dash: f32,
pub hyper_dash: bool,
}
impl PalpableObject {
pub const fn new(x: f32, x_offset: f32, start_time: f64) -> Self {
Self {
x,
x_offset,
start_time,
dist_to_hyper_dash: 0.0,
hyper_dash: false,
}
}
pub fn effective_x(&self) -> f32 {
self.x + self.x_offset
}
}
+555
View File
@@ -0,0 +1,555 @@
use std::cmp::{self, Ordering};
use crate::{
any::ModeAttributeProvider,
any::ModeDifficulty,
osu::OsuPerformance,
util::{map_or_attrs::MapOrAttrs, mods::Mods},
};
use super::{
attributes::{CatchDifficultyAttributes, CatchPerformanceAttributes},
convert::CatchBeatmap,
score_state::CatchScoreState,
Catch,
};
/// Performance calculator on osu!catch maps.
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct CatchPerformance<'map> {
pub(crate) map_or_attrs: MapOrAttrs<'map, Catch>,
pub(crate) mods: u32,
pub(crate) acc: Option<f64>,
pub(crate) combo: Option<u32>,
pub(crate) n_fruits: Option<u32>,
pub(crate) n_droplets: Option<u32>,
pub(crate) n_tiny_droplets: Option<u32>,
pub(crate) n_tiny_droplet_misses: Option<u32>,
pub(crate) n_misses: Option<u32>,
pub(crate) passed_objects: Option<u32>,
pub(crate) clock_rate: Option<f64>,
}
impl<'map> CatchPerformance<'map> {
/// Create a new performance calculator for osu!catch maps.
pub fn new(map: CatchBeatmap<'map>) -> Self {
map.into()
}
/// Provide the result of a previous difficulty or performance calculation.
/// If you already calculated the attributes for the current map-mod combination,
/// be sure to put them in here so that they don't have to be recalculated.
pub fn attributes(mut self, attributes: impl ModeAttributeProvider<Catch>) -> Self {
if let Some(attrs) = attributes.attributes() {
self.map_or_attrs = MapOrAttrs::Attrs(attrs);
}
self
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Specify the max combo of the play.
pub const fn combo(mut self, combo: u32) -> Self {
self.combo = Some(combo);
self
}
/// Specify the amount of fruits of a play i.e. n300.
pub const fn fruits(mut self, n_fruits: u32) -> Self {
self.n_fruits = Some(n_fruits);
self
}
/// Specify the amount of droplets of a play i.e. n100.
pub const fn droplets(mut self, n_droplets: u32) -> Self {
self.n_droplets = Some(n_droplets);
self
}
/// Specify the amount of tiny droplets of a play i.e. n50.
pub const fn tiny_droplets(mut self, n_tiny_droplets: u32) -> Self {
self.n_tiny_droplets = Some(n_tiny_droplets);
self
}
/// Specify the amount of tiny droplet misses of a play i.e. `n_katu`.
pub const fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: u32) -> Self {
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self
}
/// Specify the amount of fruit / droplet misses of the play.
pub const fn misses(mut self, n_misses: u32) -> Self {
self.n_misses = Some(n_misses);
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
#[cfg_attr(
feature = "gradual",
doc = "If you want to calculate the performance after every few objects, instead of
using [`CatchPP`] multiple times with different `passed_objects`, you should use
[`CatchGradualPerformanceAttributes`](crate::catch::CatchGradualPerformance)."
)]
pub const fn passed_objects(mut self, passed_objects: u32) -> Self {
self.passed_objects = Some(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Provide parameters through an [`CatchScoreState`].
#[allow(clippy::needless_pass_by_value)]
pub const fn state(mut self, state: CatchScoreState) -> Self {
let CatchScoreState {
max_combo,
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses,
} = state;
self.combo = Some(max_combo);
self.n_fruits = Some(n_fruits);
self.n_droplets = Some(n_droplets);
self.n_tiny_droplets = Some(n_tiny_droplets);
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self.n_misses = Some(n_misses);
self
}
/// Specify the accuracy of a play between `0.0` and `100.0`.
/// This will be used to generate matching hitresults.
pub fn accuracy(mut self, acc: f64) -> Self {
self.acc = Some(acc / 100.0);
self
}
/// Create the [`CatchScoreState`] that will be used for performance calculation.
#[allow(clippy::too_many_lines)]
pub fn generate_state(&mut self) -> CatchScoreState {
let attrs = match self.map_or_attrs {
MapOrAttrs::Map(ref map) => {
let attrs = self.generate_attributes(map);
self.map_or_attrs.attrs_or_insert(attrs)
}
MapOrAttrs::Attrs(ref attrs) => attrs,
};
let n_misses = self
.n_misses
.map_or(0, |n| n.min(attrs.n_fruits + attrs.n_droplets));
let max_combo = self.combo.unwrap_or_else(|| attrs.max_combo() - n_misses);
let mut best_state = CatchScoreState {
max_combo,
n_misses,
..Default::default()
};
let mut best_dist = f64::INFINITY;
let (n_fruits, n_droplets) = match (self.n_fruits, self.n_droplets) {
(Some(mut n_fruits), Some(mut n_droplets)) => {
let n_remaining = (attrs.n_fruits + attrs.n_droplets)
.saturating_sub(n_fruits + n_droplets + n_misses);
let new_droplets = n_remaining.min(attrs.n_droplets.saturating_sub(n_droplets));
n_droplets += new_droplets;
n_fruits += n_remaining - new_droplets;
n_fruits = n_fruits
.min((attrs.n_fruits + attrs.n_droplets).saturating_sub(n_droplets + n_misses));
n_droplets =
n_droplets.min(attrs.n_fruits + attrs.n_droplets - n_fruits - n_misses);
(n_fruits, n_droplets)
}
(Some(mut n_fruits), None) => {
let n_droplets = attrs.n_droplets.saturating_sub(
n_misses.saturating_sub(attrs.n_fruits.saturating_sub(n_fruits)),
);
n_fruits = attrs.n_fruits + attrs.n_droplets - n_misses - n_droplets;
(n_fruits, n_droplets)
}
(None, Some(mut n_droplets)) => {
let n_fruits = attrs.n_fruits.saturating_sub(
n_misses.saturating_sub(attrs.n_droplets.saturating_sub(n_droplets)),
);
n_droplets = attrs.n_fruits + attrs.n_droplets - n_misses - n_fruits;
(n_fruits, n_droplets)
}
(None, None) => {
let n_droplets = attrs.n_droplets.saturating_sub(n_misses);
let n_fruits =
attrs.n_fruits - (n_misses - (attrs.n_droplets.saturating_sub(n_droplets)));
(n_fruits, n_droplets)
}
};
best_state.n_fruits = n_fruits;
best_state.n_droplets = n_droplets;
let mut find_best_tiny_droplets = |acc: f64| {
let raw_tiny_droplets = acc
* f64::from(attrs.n_fruits + attrs.n_droplets + attrs.n_tiny_droplets)
- f64::from(n_fruits + n_droplets);
let min_tiny_droplets =
cmp::min(attrs.n_tiny_droplets, raw_tiny_droplets.floor() as u32);
let max_tiny_droplets =
cmp::min(attrs.n_tiny_droplets, raw_tiny_droplets.ceil() as u32);
for n_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
let n_tiny_droplet_misses = attrs.n_tiny_droplets - n_tiny_droplets;
let curr_acc = accuracy(
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses,
);
let curr_dist = (acc - curr_acc).abs();
if curr_dist < best_dist {
best_dist = curr_dist;
best_state.n_tiny_droplets = n_tiny_droplets;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
}
};
#[allow(clippy::single_match_else)]
match (self.n_tiny_droplets, self.n_tiny_droplet_misses) {
(Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => match self.acc {
Some(acc) => {
match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&attrs.n_tiny_droplets) {
Ordering::Equal => {
best_state.n_tiny_droplets = n_tiny_droplets;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
Ordering::Less | Ordering::Greater => find_best_tiny_droplets(acc),
}
}
None => {
let n_remaining = attrs
.n_tiny_droplets
.saturating_sub(n_tiny_droplets + n_tiny_droplet_misses);
best_state.n_tiny_droplets = n_tiny_droplets + n_remaining;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
},
(Some(n_tiny_droplets), None) => {
best_state.n_tiny_droplets = attrs.n_tiny_droplets.min(n_tiny_droplets);
best_state.n_tiny_droplet_misses =
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplets);
}
(None, Some(n_tiny_droplet_misses)) => {
best_state.n_tiny_droplets =
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplet_misses);
best_state.n_tiny_droplet_misses = attrs.n_tiny_droplets.min(n_tiny_droplet_misses);
}
(None, None) => match self.acc {
Some(acc) => find_best_tiny_droplets(acc),
None => best_state.n_tiny_droplets = attrs.n_tiny_droplets,
},
}
best_state
}
/// Calculate all performance related values, including pp and stars.
pub fn calculate(mut self) -> CatchPerformanceAttributes {
let state = self.generate_state();
let attrs = match self.map_or_attrs {
MapOrAttrs::Map(ref map) => self.generate_attributes(map),
MapOrAttrs::Attrs(attrs) => attrs,
};
let inner = CatchPerformanceInner {
attrs,
mods: self.mods,
state,
};
inner.calculate()
}
fn generate_attributes(&self, map: &CatchBeatmap<'_>) -> CatchDifficultyAttributes {
let mut calculator = ModeDifficulty::new();
if let Some(passed_objects) = self.passed_objects {
calculator.passed_objects(passed_objects);
}
if let Some(clock_rate) = self.clock_rate {
calculator.clock_rate(clock_rate);
}
calculator.mods(self.mods).calculate(map)
}
/// Try to create [`CatchPerformance`] through a [`ModeAttributeProvider`].
///
/// If you already calculated the attributes for the current map-mod
/// combination, the [`CatchBeatmap`] is no longer necessary to calculate
/// performance attributes so this method can be used instead of
/// [`CatchPerformance::new`].
///
/// Returns `None` only if the [`ModeAttributeProvider`] did not contain
/// attributes for catch e.g. if it's [`DifficultyAttributes::Taiko`].
///
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
pub fn try_from_attributes(attributes: impl ModeAttributeProvider<Catch>) -> Option<Self> {
attributes.attributes().map(Self::from)
}
/// Create [`CatchPerformance`] through a [`ModeAttributeProvider`].
///
/// If you already calculated the attributes for the current map-mod
/// combination, the [`CatchBeatmap`] is no longer necessary to calculate
/// performance attributes so this method can be used instead of
/// [`CatchPerformance::new`].
///
/// # Panics
///
/// Panics if the [`ModeAttributeProvider`] did not contain attributes for
/// catch e.g. if it's [`DifficultyAttributes::Taiko`].
///
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
pub fn unchecked_from_attributes(attributes: impl ModeAttributeProvider<Catch>) -> Self {
Self::try_from_attributes(attributes).expect("invalid catch attributes")
}
}
impl<'map> TryFrom<OsuPerformance<'map>> for CatchPerformance<'map> {
type Error = OsuPerformance<'map>;
/// Try to create [`CatchPerformance`] through [`OsuPerformance`].
///
/// Returns `None` if [`OsuPerformance`] already replaced its internal
/// beatmap with [`OsuDifficultyAttributes`], i.e. if
/// [`OsuPerformance::attributes`] or [`OsuPerformance::generate_state`]
/// was called.
///
/// [`OsuDifficultyAttributes`]: crate::osu::OsuDifficultyAttributes
fn try_from(mut osu: OsuPerformance<'map>) -> Result<Self, Self::Error> {
let MapOrAttrs::Map(converted) = osu.map_or_attrs else {
return Err(osu);
};
let map = match converted.try_convert() {
Ok(map) => map,
Err(map) => {
osu.map_or_attrs = MapOrAttrs::Map(map);
return Err(osu);
}
};
let OsuPerformance {
map_or_attrs: _,
mods,
acc,
combo,
n300,
n100,
n50,
n_misses,
passed_objects,
clock_rate,
hitresult_priority: _,
} = osu;
Ok(Self {
map_or_attrs: MapOrAttrs::Map(map),
mods,
acc,
combo,
n_fruits: n300,
n_droplets: n100,
n_tiny_droplets: n50,
n_tiny_droplet_misses: None,
n_misses,
passed_objects,
clock_rate,
})
}
}
impl<'map> From<CatchBeatmap<'map>> for CatchPerformance<'map> {
fn from(map: CatchBeatmap<'map>) -> Self {
Self {
map_or_attrs: MapOrAttrs::Map(map),
mods: 0,
acc: None,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
}
}
}
impl From<CatchDifficultyAttributes> for CatchPerformance<'_> {
fn from(attrs: CatchDifficultyAttributes) -> Self {
Self {
map_or_attrs: MapOrAttrs::Attrs(attrs),
mods: 0,
acc: None,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
}
}
}
impl From<CatchPerformanceAttributes> for CatchPerformance<'_> {
fn from(attrs: CatchPerformanceAttributes) -> Self {
attrs.difficulty.into()
}
}
struct CatchPerformanceInner {
attrs: CatchDifficultyAttributes,
mods: u32,
state: CatchScoreState,
}
impl CatchPerformanceInner {
fn calculate(self) -> CatchPerformanceAttributes {
let attributes = &self.attrs;
let stars = attributes.stars;
let max_combo = attributes.max_combo();
// Relying heavily on aim
let mut pp = (5.0 * (stars / 0.0049).max(1.0) - 4.0).powi(2) / 100_000.0;
let mut combo_hits = self.combo_hits();
if combo_hits == 0 {
combo_hits = max_combo;
}
// Longer maps are worth more
let len_bonus = 0.95
+ 0.3 * (f64::from(combo_hits) / 2500.0).min(1.0)
+ f64::from(u8::from(combo_hits > 2500))
* (f64::from(combo_hits) / 2500.0).log10()
* 0.475;
pp *= len_bonus;
// Penalize misses exponentially
pp *= 0.97_f64.powi(self.state.n_misses as i32);
// Combo scaling
if self.state.max_combo > 0 {
pp *= (f64::from(self.state.max_combo) / f64::from(max_combo))
.powf(0.8)
.min(1.0);
}
// AR scaling
let ar = attributes.ar;
let mut ar_factor = 1.0;
if ar > 9.0 {
ar_factor += 0.1 * (ar - 9.0) + f64::from(u8::from(ar > 10.0)) * 0.1 * (ar - 10.0);
} else if ar < 8.0 {
ar_factor += 0.025 * (8.0 - ar);
}
pp *= ar_factor;
// HD bonus
if self.mods.hd() {
if ar <= 10.0 {
pp *= 1.05 + 0.075 * (10.0 - ar);
} else if ar > 10.0 {
pp *= 1.01 + 0.04 * (11.0 - ar.min(11.0));
}
}
// FL bonus
if self.mods.fl() {
pp *= 1.35 * len_bonus;
}
// Accuracy scaling
pp *= self.state.accuracy().powf(5.5);
// NF penalty
if self.mods.nf() {
pp *= 0.9;
}
CatchPerformanceAttributes {
difficulty: self.attrs,
pp,
}
}
const fn combo_hits(&self) -> u32 {
self.state.n_fruits + self.state.n_droplets + self.state.n_misses
}
}
fn accuracy(
n_fruits: u32,
n_droplets: u32,
n_tiny_droplets: u32,
n_tiny_droplet_misses: u32,
n_misses: u32,
) -> f64 {
let numerator = n_fruits + n_droplets + n_tiny_droplets;
let denominator = numerator + n_tiny_droplet_misses + n_misses;
f64::from(numerator) / f64::from(denominator)
}
-807
View File
@@ -1,807 +0,0 @@
use super::{CatchDifficultyAttributes, CatchPerformanceAttributes, CatchScoreState, CatchStars};
use crate::{
util::{MapOrElse, MapRef},
Beatmap, DifficultyAttributes, Mods, OsuPP, PerformanceAttributes,
};
use std::cmp::Ordering;
/// Performance calculator on osu!catch maps.
///
/// # Example
///
/// ```
/// use rosu_pp::{CatchPP, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let pp_result = CatchPP::new(&map)
/// .mods(8 + 64) // HDDT
/// .combo(1234)
/// .accuracy(98.5)
/// .misses(1)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", pp_result.pp(), pp_result.stars());
///
/// let next_result = CatchPP::new(&map)
/// .attributes(pp_result) // reusing previous results for performance
/// .mods(8 + 64) // has to be the same to reuse attributes
/// .accuracy(99.5)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
/// ```
#[derive(Clone, Debug)]
#[allow(clippy::upper_case_acronyms)]
#[must_use]
pub struct CatchPP<'map> {
pub(crate) map_or_attrs: MapOrElse<MapRef<'map>, CatchDifficultyAttributes>,
pub(crate) mods: u32,
pub(crate) acc: Option<f64>,
pub(crate) combo: Option<usize>,
pub(crate) n_fruits: Option<usize>,
pub(crate) n_droplets: Option<usize>,
pub(crate) n_tiny_droplets: Option<usize>,
pub(crate) n_tiny_droplet_misses: Option<usize>,
pub(crate) n_misses: Option<usize>,
pub(crate) passed_objects: Option<usize>,
pub(crate) clock_rate: Option<f64>,
}
impl<'map> CatchPP<'map> {
/// Create a new performance calculator for osu!catch maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map_or_attrs: MapOrElse::from(map),
mods: 0,
acc: None,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
}
}
/// Provide the result of a previous difficulty or performance calculation.
/// If you already calculated the attributes for the current map-mod combination,
/// be sure to put them in here so that they don't have to be recalculated.
#[inline]
pub fn attributes(mut self, attributes: impl CatchAttributeProvider) -> Self {
if let Some(attrs) = attributes.attributes() {
self.map_or_attrs = MapOrElse::Else(attrs);
}
self
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Specify the max combo of the play.
#[inline]
pub const fn combo(mut self, combo: usize) -> Self {
self.combo = Some(combo);
self
}
/// Specify the amount of fruits of a play i.e. n300.
#[inline]
pub const fn fruits(mut self, n_fruits: usize) -> Self {
self.n_fruits = Some(n_fruits);
self
}
/// Specify the amount of droplets of a play i.e. n100.
#[inline]
pub const fn droplets(mut self, n_droplets: usize) -> Self {
self.n_droplets = Some(n_droplets);
self
}
/// Specify the amount of tiny droplets of a play i.e. n50.
#[inline]
pub const fn tiny_droplets(mut self, n_tiny_droplets: usize) -> Self {
self.n_tiny_droplets = Some(n_tiny_droplets);
self
}
/// Specify the amount of tiny droplet misses of a play i.e. `n_katu`.
#[inline]
pub const fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: usize) -> Self {
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self
}
/// Specify the amount of fruit / droplet misses of the play.
#[inline]
pub const fn misses(mut self, n_misses: usize) -> Self {
self.n_misses = Some(n_misses);
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
#[cfg_attr(
feature = "gradual",
doc = "If you want to calculate the performance after every few objects, instead of
using [`CatchPP`] multiple times with different `passed_objects`, you should use
[`CatchGradualPerformanceAttributes`](crate::catch::CatchGradualPerformance)."
)]
#[inline]
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects = Some(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Provide parameters through an [`CatchScoreState`].
#[inline]
#[allow(clippy::needless_pass_by_value)]
pub const fn state(mut self, state: CatchScoreState) -> Self {
let CatchScoreState {
max_combo,
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses,
} = state;
self.combo = Some(max_combo);
self.n_fruits = Some(n_fruits);
self.n_droplets = Some(n_droplets);
self.n_tiny_droplets = Some(n_tiny_droplets);
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self.n_misses = Some(n_misses);
self
}
/// Specify the accuracy of a play between `0.0` and `100.0`.
/// This will be used to generate matching hitresults.
#[inline]
pub fn accuracy(mut self, acc: f64) -> Self {
self.acc = Some(acc / 100.0);
self
}
/// Create the [`CatchScoreState`] that will be used for performance calculation.
#[allow(clippy::too_many_lines)]
pub fn generate_state(&mut self) -> CatchScoreState {
let attrs = match self.map_or_attrs {
MapOrElse::Map(ref map) => {
let attrs = self.generate_attributes(map.as_ref());
self.map_or_attrs.else_or_insert(attrs)
}
MapOrElse::Else(ref attrs) => attrs,
};
let n_misses = self
.n_misses
.map_or(0, |n| n.min(attrs.n_fruits + attrs.n_droplets));
let max_combo = self.combo.unwrap_or_else(|| attrs.max_combo() - n_misses);
let mut best_state = CatchScoreState {
max_combo,
n_misses,
..Default::default()
};
let mut best_dist = f64::INFINITY;
let (n_fruits, n_droplets) = match (self.n_fruits, self.n_droplets) {
(Some(mut n_fruits), Some(mut n_droplets)) => {
let n_remaining = (attrs.n_fruits + attrs.n_droplets)
.saturating_sub(n_fruits + n_droplets + n_misses);
let new_droplets = n_remaining.min(attrs.n_droplets.saturating_sub(n_droplets));
n_droplets += new_droplets;
n_fruits += n_remaining - new_droplets;
n_fruits = n_fruits
.min((attrs.n_fruits + attrs.n_droplets).saturating_sub(n_droplets + n_misses));
n_droplets =
n_droplets.min(attrs.n_fruits + attrs.n_droplets - n_fruits - n_misses);
(n_fruits, n_droplets)
}
(Some(mut n_fruits), None) => {
let n_droplets = attrs.n_droplets.saturating_sub(
n_misses.saturating_sub(attrs.n_fruits.saturating_sub(n_fruits)),
);
n_fruits = attrs.n_fruits + attrs.n_droplets - n_misses - n_droplets;
(n_fruits, n_droplets)
}
(None, Some(mut n_droplets)) => {
let n_fruits = attrs.n_fruits.saturating_sub(
n_misses.saturating_sub(attrs.n_droplets.saturating_sub(n_droplets)),
);
n_droplets = attrs.n_fruits + attrs.n_droplets - n_misses - n_fruits;
(n_fruits, n_droplets)
}
(None, None) => {
let n_droplets = attrs.n_droplets.saturating_sub(n_misses);
let n_fruits =
attrs.n_fruits - (n_misses - (attrs.n_droplets.saturating_sub(n_droplets)));
(n_fruits, n_droplets)
}
};
best_state.n_fruits = n_fruits;
best_state.n_droplets = n_droplets;
let mut find_best_tiny_droplets = |acc: f64| {
let raw_tiny_droplets = acc
* (attrs.n_fruits + attrs.n_droplets + attrs.n_tiny_droplets) as f64
- (n_fruits + n_droplets) as f64;
let min_tiny_droplets = attrs
.n_tiny_droplets
.min(raw_tiny_droplets.floor() as usize);
let max_tiny_droplets = attrs.n_tiny_droplets.min(raw_tiny_droplets.ceil() as usize);
for n_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
let n_tiny_droplet_misses = attrs.n_tiny_droplets - n_tiny_droplets;
let curr_acc = accuracy(
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses,
);
let curr_dist = (acc - curr_acc).abs();
if curr_dist < best_dist {
best_dist = curr_dist;
best_state.n_tiny_droplets = n_tiny_droplets;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
}
};
#[allow(clippy::single_match_else)]
match (self.n_tiny_droplets, self.n_tiny_droplet_misses) {
(Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => match self.acc {
Some(acc) => {
match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&attrs.n_tiny_droplets) {
Ordering::Equal => {
best_state.n_tiny_droplets = n_tiny_droplets;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
Ordering::Less | Ordering::Greater => find_best_tiny_droplets(acc),
}
}
None => {
let n_remaining = attrs
.n_tiny_droplets
.saturating_sub(n_tiny_droplets + n_tiny_droplet_misses);
best_state.n_tiny_droplets = n_tiny_droplets + n_remaining;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
},
(Some(n_tiny_droplets), None) => {
best_state.n_tiny_droplets = attrs.n_tiny_droplets.min(n_tiny_droplets);
best_state.n_tiny_droplet_misses =
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplets);
}
(None, Some(n_tiny_droplet_misses)) => {
best_state.n_tiny_droplets =
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplet_misses);
best_state.n_tiny_droplet_misses = attrs.n_tiny_droplets.min(n_tiny_droplet_misses);
}
(None, None) => match self.acc {
Some(acc) => find_best_tiny_droplets(acc),
None => best_state.n_tiny_droplets = attrs.n_tiny_droplets,
},
}
best_state
}
/// Calculate all performance related values, including pp and stars.
pub fn calculate(mut self) -> CatchPerformanceAttributes {
let state = self.generate_state();
let attrs = match self.map_or_attrs {
MapOrElse::Map(ref map) => self.generate_attributes(map.as_ref()),
MapOrElse::Else(attrs) => attrs,
};
let inner = CatchPPInner {
attrs,
mods: self.mods,
state,
};
inner.calculate()
}
fn generate_attributes(&self, map: &Beatmap) -> CatchDifficultyAttributes {
let mut calculator = CatchStars::new(map).mods(self.mods);
if let Some(passed_objects) = self.passed_objects {
calculator = calculator.passed_objects(passed_objects);
}
if let Some(clock_rate) = self.clock_rate {
calculator = calculator.clock_rate(clock_rate);
}
calculator.calculate()
}
/// Try to create [`CatchPP`] through [`OsuPP`].
///
/// Returns `None` if [`OsuPP`] already replaced its internal [`Beatmap`]
/// with [`OsuDifficultyAttributes`], i.e. if [`OsuPP::attributes`]
/// or [`OsuPP::generate_state`] was called.
///
/// [`OsuDifficultyAttributes`]: crate::osu::OsuDifficultyAttributes
#[inline]
pub const fn try_from_osu(osu: OsuPP<'map>) -> Option<Self> {
let OsuPP {
map_or_attrs,
mods,
acc,
combo,
n300,
n100,
n50,
n_misses,
passed_objects,
clock_rate,
hitresult_priority: _,
} = osu;
let MapOrElse::Map(map) = map_or_attrs else {
return None;
};
Some(Self {
map_or_attrs: MapOrElse::Map(map),
mods,
acc,
combo,
n_fruits: n300,
n_droplets: n100,
n_tiny_droplets: n50,
n_tiny_droplet_misses: None,
n_misses,
passed_objects,
clock_rate,
})
}
/// Try to create [`CatchPP`] through a [`CatchAttributeProvider`].
///
/// If you already calculated the attributes for the current map-mod
/// combination, the [`Beatmap`] is no longer necessary to calculate
/// performance attributes so this method can be used instead of
/// [`CatchPP::new`].
///
/// Returns `None` only if the [`CatchAttributeProvider`] did not contain
/// attributes for catch e.g. if it's [`DifficultyAttributes::Taiko`].
#[inline]
pub fn try_from_attributes(attributes: impl CatchAttributeProvider) -> Option<Self> {
attributes.attributes().map(Self::from)
}
}
struct CatchPPInner {
attrs: CatchDifficultyAttributes,
mods: u32,
state: CatchScoreState,
}
impl CatchPPInner {
fn calculate(self) -> CatchPerformanceAttributes {
let attributes = &self.attrs;
let stars = attributes.stars;
let max_combo = attributes.max_combo();
// Relying heavily on aim
let mut pp = (5.0 * (stars / 0.0049).max(1.0) - 4.0).powi(2) / 100_000.0;
let mut combo_hits = self.combo_hits();
if combo_hits == 0 {
combo_hits = max_combo;
}
// Longer maps are worth more
let len_bonus = 0.95
+ 0.3 * (combo_hits as f64 / 2500.0).min(1.0)
+ f64::from(u8::from(combo_hits > 2500)) * (combo_hits as f64 / 2500.0).log10() * 0.475;
pp *= len_bonus;
// Penalize misses exponentially
pp *= 0.97_f64.powi(self.state.n_misses as i32);
// Combo scaling
if self.state.max_combo > 0 {
pp *= (self.state.max_combo as f64 / max_combo as f64)
.powf(0.8)
.min(1.0);
}
// AR scaling
let ar = attributes.ar;
let mut ar_factor = 1.0;
if ar > 9.0 {
ar_factor += 0.1 * (ar - 9.0) + f64::from(u8::from(ar > 10.0)) * 0.1 * (ar - 10.0);
} else if ar < 8.0 {
ar_factor += 0.025 * (8.0 - ar);
}
pp *= ar_factor;
// HD bonus
if self.mods.hd() {
if ar <= 10.0 {
pp *= 1.05 + 0.075 * (10.0 - ar);
} else if ar > 10.0 {
pp *= 1.01 + 0.04 * (11.0 - ar.min(11.0));
}
}
// FL bonus
if self.mods.fl() {
pp *= 1.35 * len_bonus;
}
// Accuracy scaling
pp *= self.state.accuracy().powf(5.5);
// NF penalty
if self.mods.nf() {
pp *= 0.9;
}
CatchPerformanceAttributes {
difficulty: self.attrs,
pp,
}
}
const fn combo_hits(&self) -> usize {
self.state.n_fruits + self.state.n_droplets + self.state.n_misses
}
}
fn accuracy(
n_fruits: usize,
n_droplets: usize,
n_tiny_droplets: usize,
n_tiny_droplet_misses: usize,
n_misses: usize,
) -> f64 {
let numerator = n_fruits + n_droplets + n_tiny_droplets;
let denominator = numerator + n_tiny_droplet_misses + n_misses;
numerator as f64 / denominator as f64
}
impl From<CatchDifficultyAttributes> for CatchPP<'_> {
fn from(attrs: CatchDifficultyAttributes) -> Self {
Self {
map_or_attrs: MapOrElse::Else(attrs),
mods: 0,
acc: None,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
}
}
}
impl From<CatchPerformanceAttributes> for CatchPP<'_> {
fn from(attrs: CatchPerformanceAttributes) -> Self {
attrs.difficulty.into()
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait CatchAttributeProvider {
/// Provide the actual difficulty attributes.
fn attributes(self) -> Option<CatchDifficultyAttributes>;
}
impl CatchAttributeProvider for CatchDifficultyAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
Some(self)
}
}
impl CatchAttributeProvider for CatchPerformanceAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
Some(self.difficulty)
}
}
impl CatchAttributeProvider for DifficultyAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
if let Self::Catch(attributes) = self {
Some(attributes)
} else {
None
}
}
}
impl CatchAttributeProvider for PerformanceAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
if let Self::Catch(attributes) = self {
Some(attributes.difficulty)
} else {
None
}
}
}
#[cfg(not(any(feature = "async_tokio", feature = "async_std")))]
#[cfg(test)]
mod test {
use super::*;
use crate::Beatmap;
use proptest::{option, prelude::*};
use std::sync::OnceLock;
static DATA: OnceLock<(Beatmap, CatchDifficultyAttributes)> = OnceLock::new();
const N_FRUITS: usize = 728;
const N_DROPLETS: usize = 2;
const N_TINY_DROPLETS: usize = 291;
fn test_data() -> (&'static Beatmap, CatchDifficultyAttributes) {
let (map, attrs) = DATA.get_or_init(|| {
let path = "./maps/2118524.osu";
let map = Beatmap::from_path(path).unwrap();
let attrs = CatchStars::new(&map).calculate();
assert_eq!(
(N_FRUITS, N_DROPLETS, N_TINY_DROPLETS),
(attrs.n_fruits, attrs.n_droplets, attrs.n_tiny_droplets)
);
(map, attrs)
});
(map, attrs.to_owned())
}
/// Checks all remaining hitresult combinations w.r.t. the given parameters
/// and returns the [`OsuScoreState`] that matches `acc` the best.
///
/// Very slow but accurate.
fn brute_force_best(
acc: f64,
n_fruits: Option<usize>,
n_droplets: Option<usize>,
n_tiny_droplets: Option<usize>,
n_tiny_droplet_misses: Option<usize>,
n_misses: usize,
) -> CatchScoreState {
let n_misses = n_misses.min(N_FRUITS + N_DROPLETS);
let mut best_state = CatchScoreState {
max_combo: N_FRUITS + N_DROPLETS - n_misses,
n_misses,
..Default::default()
};
let mut best_dist = f64::INFINITY;
let (new_fruits, new_droplets) = match (n_fruits, n_droplets) {
(Some(mut n_fruits), Some(mut n_droplets)) => {
let n_remaining =
(N_FRUITS + N_DROPLETS).saturating_sub(n_fruits + n_droplets + n_misses);
let new_droplets = n_remaining.min(N_DROPLETS.saturating_sub(n_droplets));
n_droplets += new_droplets;
n_fruits += n_remaining - new_droplets;
n_fruits =
n_fruits.min((N_FRUITS + N_DROPLETS).saturating_sub(n_droplets + n_misses));
n_droplets = n_droplets.min(N_FRUITS + N_DROPLETS - n_fruits - n_misses);
(n_fruits, n_droplets)
}
(Some(mut n_fruits), None) => {
let n_droplets = N_DROPLETS
.saturating_sub(n_misses.saturating_sub(N_FRUITS.saturating_sub(n_fruits)));
n_fruits = N_FRUITS + N_DROPLETS - n_misses - n_droplets;
(n_fruits, n_droplets)
}
(None, Some(mut n_droplets)) => {
let n_fruits = N_FRUITS
.saturating_sub(n_misses.saturating_sub(N_DROPLETS.saturating_sub(n_droplets)));
n_droplets = N_FRUITS + N_DROPLETS - n_misses - n_fruits;
(n_fruits, n_droplets)
}
(None, None) => {
let n_droplets = N_DROPLETS.saturating_sub(n_misses);
let n_fruits = N_FRUITS - (n_misses - (N_DROPLETS.saturating_sub(n_droplets)));
(n_fruits, n_droplets)
}
};
best_state.n_fruits = new_fruits;
best_state.n_droplets = new_droplets;
let (min_tiny_droplets, max_tiny_droplets) = match (n_tiny_droplets, n_tiny_droplet_misses)
{
(Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => {
match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&N_TINY_DROPLETS) {
Ordering::Equal => (
N_TINY_DROPLETS.min(n_tiny_droplets),
N_TINY_DROPLETS.min(n_tiny_droplets),
),
Ordering::Less | Ordering::Greater => (0, N_TINY_DROPLETS),
}
}
(Some(n_tiny_droplets), None) => (
N_TINY_DROPLETS.min(n_tiny_droplets),
N_TINY_DROPLETS.min(n_tiny_droplets),
),
(None, Some(n_tiny_droplet_misses)) => (
N_TINY_DROPLETS.saturating_sub(n_tiny_droplet_misses),
N_TINY_DROPLETS.saturating_sub(n_tiny_droplet_misses),
),
(None, None) => (0, N_TINY_DROPLETS),
};
for new_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
let new_tiny_droplet_misses = N_TINY_DROPLETS - new_tiny_droplets;
let curr_acc = accuracy(
new_fruits,
new_droplets,
new_tiny_droplets,
new_tiny_droplet_misses,
n_misses,
);
let curr_dist = (acc - curr_acc).abs();
if curr_dist < best_dist {
best_dist = curr_dist;
best_state.n_tiny_droplets = new_tiny_droplets;
best_state.n_tiny_droplet_misses = new_tiny_droplet_misses;
}
}
best_state
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(20_000))]
#[test]
fn catch_hitresults(
acc in 0.0..=1.0,
n_fruits in option::weighted(0.10, 0_usize..=N_FRUITS + 10),
n_droplets in option::weighted(0.10, 0_usize..=N_DROPLETS + 10),
n_tiny_droplets in option::weighted(0.10, 0_usize..=N_TINY_DROPLETS + 10),
n_tiny_droplet_misses in option::weighted(0.10, 0_usize..=N_TINY_DROPLETS + 10),
n_misses in option::weighted(0.15, 0_usize..=N_FRUITS + N_DROPLETS + 10),
) {
let (map, attrs) = test_data();
let mut state = CatchPP::new(map)
.attributes(attrs)
.accuracy(acc * 100.0);
if let Some(n_fruits) = n_fruits {
state = state.fruits(n_fruits);
}
if let Some(n_droplets) = n_droplets {
state = state.droplets(n_droplets);
}
if let Some(n_tiny_droplets) = n_tiny_droplets {
state = state.tiny_droplets(n_tiny_droplets);
}
if let Some(n_tiny_droplet_misses) = n_tiny_droplet_misses {
state = state.tiny_droplet_misses(n_tiny_droplet_misses);
}
if let Some(n_misses) = n_misses {
state = state.misses(n_misses);
}
let state = state.generate_state();
let expected = brute_force_best(
acc,
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses.unwrap_or(0),
);
assert_eq!(state, expected);
}
}
#[test]
fn fruits_missing_objects() {
let (map, attrs) = test_data();
let state = CatchPP::new(map)
.attributes(attrs)
.fruits(N_FRUITS - 10)
.droplets(N_DROPLETS - 1)
.tiny_droplets(N_TINY_DROPLETS - 50)
.tiny_droplet_misses(20)
.misses(2)
.generate_state();
let expected = CatchScoreState {
max_combo: N_FRUITS + N_DROPLETS - 2,
n_fruits: N_FRUITS - 2,
n_droplets: N_DROPLETS,
n_tiny_droplets: N_TINY_DROPLETS - 20,
n_tiny_droplet_misses: 20,
n_misses: 2,
};
assert_eq!(state, expected);
}
}
+10 -13
View File
@@ -1,22 +1,21 @@
/// Aggregation for a score's current state i.e. what was the
/// maximum combo so far and what are the current hitresults.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
/// Aggregation for a score's current state.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CatchScoreState {
/// Maximum combo that the score has had so far.
/// **Not** the maximum possible combo of the map so far.
///
/// Note that only fruits and droplets are considered for osu!catch combo.
pub max_combo: usize,
pub max_combo: u32,
/// Amount of current fruits (300s).
pub n_fruits: usize,
pub n_fruits: u32,
/// Amount of current droplets (100s).
pub n_droplets: usize,
pub n_droplets: u32,
/// Amount of current tiny droplets (50s).
pub n_tiny_droplets: usize,
pub n_tiny_droplets: u32,
/// Amount of current tiny droplet misses (katus).
pub n_tiny_droplet_misses: usize,
pub n_tiny_droplet_misses: u32,
/// Amount of current misses (fruits and droplets).
pub n_misses: usize,
pub n_misses: u32,
}
impl CatchScoreState {
@@ -26,8 +25,7 @@ impl CatchScoreState {
}
/// Return the total amount of hits by adding everything up.
#[inline]
pub const fn total_hits(&self) -> usize {
pub const fn total_hits(&self) -> u32 {
self.n_fruits
+ self.n_droplets
+ self.n_tiny_droplets
@@ -36,7 +34,6 @@ impl CatchScoreState {
}
/// Calculate the accuracy between `0.0` and `1.0` for this state.
#[inline]
pub fn accuracy(&self) -> f64 {
let total_hits = self.total_hits();
@@ -47,6 +44,6 @@ impl CatchScoreState {
let numerator = self.n_fruits + self.n_droplets + self.n_tiny_droplets;
let denominator = total_hits;
numerator as f64 / denominator as f64
f64::from(numerator) / f64::from(denominator)
}
}
+25
View File
@@ -0,0 +1,25 @@
use crate::{any::ModeDifficulty, catch::difficulty::DifficultyValues};
use super::convert::CatchBeatmap;
/// The result of calculating the strains on a osu!catch map.
///
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug, PartialEq)]
pub struct CatchStrains {
/// Strain peaks of the movement skill.
pub movement: Vec<f64>,
}
impl CatchStrains {
/// Time between two strains in ms.
pub const SECTION_LEN: f64 = 750.0;
}
pub fn strains(difficulty: &ModeDifficulty, converted: &CatchBeatmap<'_>) -> CatchStrains {
let DifficultyValues { movement, .. } = DifficultyValues::calculate(difficulty, converted);
CatchStrains {
movement: movement.get_curr_strain_peaks(),
}
}
-546
View File
@@ -1,546 +0,0 @@
use std::{borrow::Cow, cmp::Ordering, convert::identity, f64::consts::PI, iter};
use crate::parse::{PathControlPoint, PathType, Pos2};
const BEZIER_TOLERANCE: f32 = 0.25;
const CATMULL_DETAIL: usize = 50;
const CIRCULAR_ARC_TOLERANCE: f32 = 0.1;
#[derive(Clone, Debug, Default)]
pub(crate) struct CurveBuffers {
path: Vec<Pos2>,
lengths: Vec<f64>,
vertices: Vec<Pos2>,
bezier: BezierBuffers,
}
#[derive(Clone, Debug, Default)]
struct BezierBuffers {
left: Vec<Pos2>,
right: Vec<Pos2>,
midpoints: Vec<Pos2>,
left_child: Vec<Pos2>,
}
impl BezierBuffers {
/// Fill the buffers with new elements until a
/// length of `len` is reached. Does nothing if `len`
/// is already smaller than the current buffer size.
fn extend_exact(&mut self, len: usize) {
if len <= self.left.len() {
return;
}
let additional = len - self.left.len();
self.left
.extend(iter::repeat(Pos2::zero()).take(additional));
self.right
.extend(iter::repeat(Pos2::zero()).take(additional));
self.midpoints
.extend(iter::repeat(Pos2::zero()).take(additional));
self.left_child
.extend(iter::repeat(Pos2::zero()).take(additional));
}
}
struct CircularArcProperties {
theta_start: f64,
theta_range: f64,
direction: f64,
radius: f32,
centre: Pos2,
}
pub(crate) struct Curve<'bufs> {
path: &'bufs [Pos2],
lengths: &'bufs [f64],
}
impl<'bufs> Curve<'bufs> {
pub(crate) fn new(
points: &[PathControlPoint],
expected_len: Option<f64>,
bufs: &'bufs mut CurveBuffers,
) -> Self {
Self::calculate_path(points, bufs);
Self::calculate_length(points, bufs, expected_len);
Self {
path: &bufs.path,
lengths: &bufs.lengths,
}
}
pub(crate) fn position_at(&self, progress: f64) -> Pos2 {
let d = self.progress_to_dist(progress);
let i = self.idx_of_dist(d);
self.interpolate_vertices(i, d)
}
fn progress_to_dist(&self, progress: f64) -> f64 {
progress.clamp(0.0, 1.0) * self.dist()
}
pub(crate) fn dist(&self) -> f64 {
self.lengths.last().copied().unwrap_or(0.0)
}
fn idx_of_dist(&self, d: f64) -> usize {
self.lengths
.binary_search_by(|len| len.partial_cmp(&d).unwrap_or(Ordering::Equal))
.map_or_else(identity, identity)
}
fn interpolate_vertices(&self, i: usize, d: f64) -> Pos2 {
if self.path.is_empty() {
return Pos2::zero();
}
let p1 = if i == 0 {
return self.path[0];
} else if let Some(p) = self.path.get(i) {
*p
} else {
return self.path[self.path.len() - 1];
};
let p0 = self.path[i - 1];
let d0 = self.lengths[i - 1];
let d1 = self.lengths[i];
// * Avoid division by an almost-zero number in case
// * two points are extremely close to each other
if (d0 - d1).abs() <= f64::EPSILON {
return p0;
}
let w = (d - d0) / (d1 - d0);
p0 + (p1 - p0) * w as f32
}
fn calculate_path(points: &[PathControlPoint], bufs: &mut CurveBuffers) {
bufs.path.clear();
if points.is_empty() {
return;
}
let CurveBuffers {
vertices,
bezier,
path,
..
} = bufs;
vertices.clear();
vertices.extend(points.iter().map(|p| p.pos));
let mut start = 0;
for i in 0..points.len() {
if points[i].kind.is_none() && i < points.len() - 1 {
continue;
}
// * The current vertex ends the segment
let segment_vertices = &vertices[start..=i];
let segment_kind = points[start].kind.unwrap_or(PathType::Linear);
Self::calculate_subpath(path, segment_vertices, segment_kind, bezier);
// * Start the new segment at the current vertex
start = i;
}
path.dedup();
}
fn calculate_length(
points: &[PathControlPoint],
bufs: &mut CurveBuffers,
expected_len: Option<f64>,
) {
let CurveBuffers {
path,
lengths: cumulative_len,
..
} = bufs;
cumulative_len.clear();
let mut calculated_len = 0.0;
cumulative_len.reserve(path.len());
cumulative_len.push(0.0);
let length_iter = path.iter().zip(path.iter().skip(1)).map(|(&curr, &next)| {
calculated_len += f64::from((next - curr).length());
calculated_len
});
cumulative_len.extend(length_iter);
if let Some(expected_len) =
expected_len.filter(|&len| (calculated_len - len).abs() >= f64::EPSILON)
{
// * In osu-stable, if the last two control points of a slider are equal, extension is not performed
let condition_opt = points
.len()
.checked_sub(2)
.and_then(|i| points.get(i..))
.filter(|suffix| suffix[0].pos == suffix[1].pos && expected_len > calculated_len);
if condition_opt.is_some() {
cumulative_len.push(calculated_len);
return;
}
// Shortcut when it's just (0,0) since there's nothing to do anyway
if cumulative_len.len() == 1 {
return;
}
// * The last length is always incorrect
cumulative_len.pop();
let last_valid = cumulative_len
.iter()
.rev()
.position(|l| *l < expected_len)
.map_or(0, |idx| cumulative_len.len() - idx);
// * The path will be shortened further, in which case we should trim
// * any more unnecessary lengths and their associated path segments
if last_valid < cumulative_len.len() {
cumulative_len.truncate(last_valid);
path.truncate(last_valid + 1);
if cumulative_len.is_empty() {
// * The expected distance is negative or zero
// * Perhaps negative path lengths should be disallowed altogether
cumulative_len.push(0.0);
return;
}
}
let end_idx = cumulative_len.len();
let prev_idx = end_idx - 1;
// * The direction of the segment to shorten or lengthen
let dir = (path[end_idx] - path[prev_idx]).normalize();
path[end_idx] = path[prev_idx] + dir * (expected_len - cumulative_len[prev_idx]) as f32;
cumulative_len.push(expected_len);
}
}
fn calculate_subpath(
path: &mut Vec<Pos2>,
sub_points: &[Pos2],
kind: PathType,
bufs: &mut BezierBuffers,
) {
match kind {
PathType::Bezier => Self::approximate_bezier(path, sub_points, bufs),
PathType::Catmull => Self::approximate_catmull(path, sub_points),
PathType::Linear => Self::approximate_linear(path, sub_points),
PathType::PerfectCurve => {
if let [a, b, c] = sub_points {
if Self::approximate_circular_arc(path, *a, *b, *c) {
return;
}
}
Self::approximate_bezier(path, sub_points, bufs);
}
}
}
fn approximate_bezier(path: &mut Vec<Pos2>, points: &[Pos2], bufs: &mut BezierBuffers) {
bufs.extend_exact(points.len());
Self::approximate_bspline(path, points, bufs);
}
fn approximate_catmull(path: &mut Vec<Pos2>, points: &[Pos2]) {
if points.len() == 1 {
return;
}
path.reserve_exact((points.len() - 1) * CATMULL_DETAIL * 2);
// Handle first iteration distinctly because of v1
let v1 = points[0];
let v2 = points[0];
let v3 = points.get(1).copied().unwrap_or(v2);
let v4 = points.get(2).copied().unwrap_or_else(|| v3 * 2.0 - v2);
Self::catmull_subpath(path, v1, v2, v3, v4);
// Remaining iterations
for (i, (&v1, &v2)) in (2..points.len()).zip(points.iter().zip(points.iter().skip(1))) {
let v3 = points.get(i).copied().unwrap_or_else(|| v2 * 2.0 - v1);
let v4 = points.get(i + 1).copied().unwrap_or_else(|| v3 * 2.0 - v2);
Self::catmull_subpath(path, v1, v2, v3, v4);
}
}
fn approximate_linear(path: &mut Vec<Pos2>, points: &[Pos2]) {
path.extend(points);
}
fn approximate_circular_arc(path: &mut Vec<Pos2>, a: Pos2, b: Pos2, c: Pos2) -> bool {
let Some(pr) = Self::circular_arc_properties(a, b, c) else {
return false;
};
// * We select the amount of points for the approximation by requiring the discrete curvature
// * to be smaller than the provided tolerance. The exact angle required to meet the tolerance
// * is: 2 * Math.Acos(1 - TOLERANCE / r)
// * The special case is required for extremely short sliders where the radius is smaller than
// * the tolerance. This is a pathological rather than a realistic case.
let amount_points = if 2.0 * pr.radius <= CIRCULAR_ARC_TOLERANCE {
2
} else {
let divisor = 2.0 * (1.0 - CIRCULAR_ARC_TOLERANCE / pr.radius).acos();
// In C# it holds `(int)Infinity == -2147483648` whereas in Rust it's 2147483647
// so we need to workaround this edge case, see map id 2568364
if divisor.abs() <= f32::EPSILON {
2
} else {
((pr.theta_range / f64::from(divisor)).ceil() as usize).max(2)
}
};
path.reserve_exact(amount_points);
let divisor = (amount_points - 1) as f64;
let directed_range = pr.direction * pr.theta_range;
let subpath = (0..amount_points).map(|i| {
let fract = i as f64 / divisor;
let theta = pr.theta_start + fract * directed_range;
let (sin, cos) = theta.sin_cos();
let origin = Pos2 {
x: cos as f32,
y: sin as f32,
};
pr.centre + origin * pr.radius
});
path.extend(subpath);
true
}
fn approximate_bspline(path: &mut Vec<Pos2>, points: &[Pos2], bufs: &mut BezierBuffers) {
let p = points.len();
let mut to_flatten = Vec::new();
let mut free_bufs = Vec::new();
// In osu!lazer's code, `p` is always 0 so the first big `if` can be omitted
to_flatten.push(Cow::Borrowed(points));
// * "toFlatten" contains all the curves which are not yet approximated well enough.
// * We use a stack to emulate recursion without the risk of running into a stack overflow.
// * (More specifically, we iteratively and adaptively refine our curve with a
// * <a href="https://en.wikipedia.org/wiki/Depth-first_search">Depth-first search</a>
// * over the tree resulting from the subdivisions we make.)
let BezierBuffers {
left,
right,
midpoints,
left_child,
} = bufs;
while let Some(mut parent) = to_flatten.pop() {
if Self::bezier_is_flat_enough(&parent) {
// * If the control points we currently operate on are sufficiently "flat", we use
// * an extension to De Casteljau's algorithm to obtain a piecewise-linear approximation
// * of the bezier curve represented by our control points, consisting of the same amount
// * of points as there are control points.
Self::bezier_approximate(&parent, path, left, right, midpoints);
free_bufs.push(parent);
continue;
}
// * If we do not yet have a sufficiently "flat" (in other words, detailed) approximation we keep
// * subdividing the curve we are currently operating on.
let mut right_child = free_bufs
.pop()
.unwrap_or_else(|| Cow::Owned(vec![Pos2::zero(); p]));
Self::bezier_subdivide(&parent, left_child, right_child.to_mut(), midpoints);
// * We re-use the buffer of the parent for one of the children, so that we save one allocation per iteration.
parent.to_mut().copy_from_slice(&left_child[..p]);
to_flatten.push(right_child);
to_flatten.push(parent);
}
path.push(points[p - 1]);
}
fn bezier_is_flat_enough(points: &[Pos2]) -> bool {
let limit = BEZIER_TOLERANCE * BEZIER_TOLERANCE * 4.0;
!points
.iter()
.zip(points.iter().skip(1))
.zip(points.iter().skip(2))
.any(|((&prev, &curr), &next)| (prev - curr * 2.0 + next).length_squared() > limit)
}
fn bezier_subdivide(points: &[Pos2], l: &mut [Pos2], r: &mut [Pos2], midpoints: &mut [Pos2]) {
let count = points.len();
midpoints[..count].copy_from_slice(&points[..count]);
for i in (1..count).rev() {
l[count - i - 1] = midpoints[0];
r[i] = midpoints[i];
for j in 0..i {
midpoints[j] = (midpoints[j] + midpoints[j + 1]) / 2.0;
}
}
l[count - 1] = midpoints[0];
r[0] = midpoints[0];
}
// * https://en.wikipedia.org/wiki/De_Casteljau%27s_algorithm
fn bezier_approximate(
points: &[Pos2],
path: &mut Vec<Pos2>,
l: &mut [Pos2],
r: &mut [Pos2],
midpoints: &mut [Pos2],
) {
let count = points.len();
Self::bezier_subdivide(points, l, r, midpoints);
path.push(points[0]);
let l = &l[..count];
let r = &r[1..count];
let subpath = l
.iter()
.chain(r)
.skip(1)
.zip(l.iter().chain(r).skip(2))
.zip(l.iter().chain(r).skip(3))
.step_by(2)
.map(|((&prev, &curr), &next)| (prev + curr * 2.0 + next) * 0.25);
path.extend(subpath);
}
fn catmull_subpath(path: &mut Vec<Pos2>, v1: Pos2, v2: Pos2, v3: Pos2, v4: Pos2) {
let x1 = 2.0 * v2.x;
let x2 = -v1.x + v3.x;
let x3 = 2.0 * v1.x - 5.0 * v2.x + 4.0 * v3.x - v4.x;
let x4 = -v1.x + 3.0 * (v2.x - v3.x) + v4.x;
let y1 = 2.0 * v2.y;
let y2 = -v1.y + v3.y;
let y3 = 2.0 * v1.y - 5.0 * v2.y + 4.0 * v3.y - v4.y;
let y4 = -v1.y + 3.0 * (v2.y - v3.y) + v4.y;
let catmull_detail = CATMULL_DETAIL as f32;
let subpath = (0..CATMULL_DETAIL).flat_map(|c| {
let c = c as f32;
let t1 = c / catmull_detail;
let t2 = t1 * t1;
let t3 = t2 * t1;
let pos1 = Pos2 {
x: 0.5 * (x1 + x2 * t1 + x3 * t2 + x4 * t3),
y: 0.5 * (y1 + y2 * t1 + y3 * t2 + y4 * t3),
};
let t1 = (c + 1.0) / catmull_detail;
let t2 = t1 * t1;
let t3 = t2 * t1;
let pos2 = Pos2 {
x: 0.5 * (x1 + x2 * t1 + x3 * t2 + x4 * t3),
y: 0.5 * (y1 + y2 * t1 + y3 * t2 + y4 * t3),
};
iter::once(pos1).chain(iter::once(pos2))
});
path.extend(subpath);
}
fn circular_arc_properties(a: Pos2, b: Pos2, c: Pos2) -> Option<CircularArcProperties> {
// * If we have a degenerate triangle where a side-length is almost zero,
// * then give up and fallback to a more numerically stable method.
if ((b.y - a.y) * (c.x - a.x) - (b.x - a.x) * (c.y - a.y)).abs() <= f32::EPSILON {
return None;
}
// * See: https://en.wikipedia.org/wiki/Circumscribed_circle#Cartesian_coordinates_2
let d = 2.0 * (a.x * (b - c).y + b.x * (c - a).y + c.x * (a - b).y);
let a_sq = a.length_squared();
let b_sq = b.length_squared();
let c_sq = c.length_squared();
let centre = Pos2 {
x: (a_sq * (b - c).y + b_sq * (c - a).y + c_sq * (a - b).y) / d,
y: (a_sq * (c - b).x + b_sq * (a - c).x + c_sq * (b - a).x) / d,
};
let d_a = a - centre;
let d_c = c - centre;
let radius = d_a.length();
let theta_start = f64::from(d_a.y).atan2(f64::from(d_a.x));
let mut theta_end = f64::from(d_c.y).atan2(f64::from(d_c.x));
while theta_end < theta_start {
theta_end += 2.0 * PI;
}
let mut direction = 1.0;
let mut theta_range = theta_end - theta_start;
// * Decide in which direction to draw the circle,
// * depending on which side of AC B lies.
let mut ortho_a_to_c = c - a;
ortho_a_to_c = Pos2 {
x: ortho_a_to_c.y,
y: -ortho_a_to_c.x,
};
if ortho_a_to_c.dot(b - a) < 0.0 {
direction = -direction;
theta_range = 2.0 * PI - theta_range;
}
Some(CircularArcProperties {
theta_start,
theta_range,
direction,
radius,
centre,
})
}
}
-363
View File
@@ -1,363 +0,0 @@
#![cfg(feature = "gradual")]
use crate::catch::{CatchOwnedGradualDifficulty, CatchOwnedGradualPerformance};
use crate::mania::{ManiaOwnedGradualDifficulty, ManiaOwnedGradualPerformance};
use crate::osu::OsuOwnedGradualPerformance;
use crate::taiko::TaikoOwnedGradualPerformance;
use crate::{
catch::{CatchGradualDifficulty, CatchGradualPerformance},
mania::{ManiaGradualDifficulty, ManiaGradualPerformance},
osu::{OsuGradualDifficulty, OsuGradualPerformance},
taiko::{TaikoGradualDifficulty, TaikoGradualPerformance},
Beatmap, DifficultyAttributes, GameMode, PerformanceAttributes, ScoreState,
};
/// Gradually calculate the difficulty attributes on maps of any mode.
///
/// Note that this struct implements [`Iterator`].
/// On every call of [`Iterator::next`], the map's next hit object will
/// be processed and the [`DifficultyAttributes`] will be updated and returned.
///
/// If you want to calculate performance attributes, use [`GradualPerformance`] instead.
///
/// # Example
///
/// ```no_run
/// use rosu_pp::{Beatmap, GradualDifficulty};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = GradualDifficulty::new(&map, mods);
///
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
/// let attrs2 = iter.next(); // after the second hit object
///
/// // Remaining hit objects
/// for difficulty in iter {
/// // ...
/// }
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Debug)]
pub enum GradualDifficulty<'map> {
/// Gradual osu!standard difficulty attributes.
Osu(OsuGradualDifficulty),
/// Gradual osu!taiko difficulty attributes.
Taiko(TaikoGradualDifficulty),
/// Gradual osu!catch difficulty attributes.
Catch(CatchGradualDifficulty<'map>),
/// Gradual osu!mania difficulty attributes.
Mania(ManiaGradualDifficulty<'map>),
}
impl<'map> GradualDifficulty<'map> {
// FIXME: converted catch maps will always count as osu!std since their mode is not modified
/// Create a new gradual difficulty calculator for maps of any mode.
#[inline]
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
match map.mode {
GameMode::Osu => Self::Osu(OsuGradualDifficulty::new(map, mods)),
GameMode::Taiko => Self::Taiko(TaikoGradualDifficulty::new(map, mods)),
GameMode::Catch => Self::Catch(CatchGradualDifficulty::new(map, mods)),
GameMode::Mania => Self::Mania(ManiaGradualDifficulty::new(map, mods)),
}
}
}
impl Iterator for GradualDifficulty<'_> {
type Item = DifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Osu(o) => o.next().map(DifficultyAttributes::Osu),
Self::Taiko(t) => t.next().map(DifficultyAttributes::Taiko),
Self::Catch(f) => f.next().map(DifficultyAttributes::Catch),
Self::Mania(m) => m.next().map(DifficultyAttributes::Mania),
}
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
match self {
Self::Osu(o) => o.size_hint(),
Self::Taiko(t) => t.size_hint(),
Self::Catch(f) => f.size_hint(),
Self::Mania(m) => m.size_hint(),
}
}
#[inline]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
match self {
Self::Osu(o) => o.nth(n).map(DifficultyAttributes::Osu),
Self::Taiko(t) => t.nth(n).map(DifficultyAttributes::Taiko),
Self::Catch(c) => c.nth(n).map(DifficultyAttributes::Catch),
Self::Mania(m) => m.nth(n).map(DifficultyAttributes::Mania),
}
}
}
/// Gradually calculate the difficulty attributes on maps of any mode.
///
/// Check [`GradualDifficulty`] for more information. This type does the same
/// but depending on the mode it might clone [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum OwnedGradualDifficulty {
/// Gradual osu!standard difficulty attributes.
Osu(OsuGradualDifficulty),
/// Gradual osu!taiko difficulty attributes.
Taiko(TaikoGradualDifficulty),
/// Gradual osu!catch difficulty attributes.
Catch(CatchOwnedGradualDifficulty),
/// Gradual osu!mania difficulty attributes.
Mania(ManiaOwnedGradualDifficulty),
}
impl OwnedGradualDifficulty {
// FIXME: converted catch maps will always count as osu!std since their mode is not modified
/// Create a new gradual difficulty calculator for maps of any mode.
#[inline]
pub fn new(map: &Beatmap, mods: u32) -> Self {
match map.mode {
GameMode::Osu => Self::Osu(OsuGradualDifficulty::new(map, mods)),
GameMode::Taiko => Self::Taiko(TaikoGradualDifficulty::new(map, mods)),
GameMode::Catch => Self::Catch(CatchOwnedGradualDifficulty::new(map.to_owned(), mods)),
GameMode::Mania => Self::Mania(ManiaOwnedGradualDifficulty::new(map.to_owned(), mods)),
}
}
}
impl Iterator for OwnedGradualDifficulty {
type Item = DifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Osu(o) => o.next().map(DifficultyAttributes::Osu),
Self::Taiko(t) => t.next().map(DifficultyAttributes::Taiko),
Self::Catch(f) => f.next().map(DifficultyAttributes::Catch),
Self::Mania(m) => m.next().map(DifficultyAttributes::Mania),
}
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
match self {
Self::Osu(o) => o.size_hint(),
Self::Taiko(t) => t.size_hint(),
Self::Catch(f) => f.size_hint(),
Self::Mania(m) => m.size_hint(),
}
}
#[inline]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
match self {
Self::Osu(o) => o.nth(n).map(DifficultyAttributes::Osu),
Self::Taiko(t) => t.nth(n).map(DifficultyAttributes::Taiko),
Self::Catch(c) => c.nth(n).map(DifficultyAttributes::Catch),
Self::Mania(m) => m.nth(n).map(DifficultyAttributes::Mania),
}
}
}
/// Gradually calculate the performance attributes on maps of any mode.
///
/// After each hit object you can call [`next`](`GradualPerformance::next`)
/// and it will return the resulting current [`PerformanceAttributes`].
/// To process multiple objects at once, use [`nth`](`GradualPerformance::nth`) instead.
///
/// Both methods require a [`ScoreState`] that contains the current hitresults
/// as well as the maximum combo so far or just the current score for osu!mania.
/// Since the map could have any mode, all fields of `ScoreState` could be of use
/// and should be updated properly.
///
/// Alternatively, you can match on the map's mode yourself and use the gradual
/// performance attribute struct for the corresponding mode, i.e. [`OsuGradualPerformance`],
/// [`TaikoGradualPerformance`], [`CatchGradualPerformance`], or [`ManiaGradualPerformance`].
///
/// If you only want to calculate difficulty attributes use [`GradualDifficulty`] instead.
///
/// # Example
///
/// ```no_run
/// use rosu_pp::{Beatmap, GradualPerformance, ScoreState};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = GradualPerformance::new(&map, mods);
/// let mut state = ScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are 300s
/// for _ in 0..10 {
/// state.n300 += 1;
/// state.max_combo += 1;
///
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp());
/// }
///
/// // Then comes a miss.
/// // Note that state's max combo won't be incremented for
/// // the next few objects because the combo is reset.
/// state.n_misses += 1;
///
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp());
///
/// // The next 10 objects will be a mixture of 300s, 100s, and 50s.
/// // Notice how all 10 objects will be processed in one go.
/// state.n300 += 2;
/// state.n100 += 7;
/// state.n50 += 1;
///
/// // The `nth` method takes a zero-based value.
/// let performance = gradual_perf.nth(state.clone(), 9).unwrap();
/// println!("PP: {}", performance.pp());
///
/// // Now comes another 300. Note that the max combo gets incremented again.
/// state.n300 += 1;
/// state.max_combo += 1;
///
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp());
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n300 = ...
/// ...
/// # */
/// let final_performance = gradual_perf.last(state.clone()).unwrap();
/// println!("PP: {}", performance.pp());
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.next(state).is_none());
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum GradualPerformance<'map> {
/// Gradual osu!standard performance calculator.
Osu(OsuGradualPerformance<'map>),
/// Gradual osu!taiko performance calculator.
Taiko(TaikoGradualPerformance<'map>),
/// Gradual osu!catch performance calculator.
Catch(CatchGradualPerformance<'map>),
/// Gradual osu!mania performance calculator.
Mania(ManiaGradualPerformance<'map>),
}
impl<'map> GradualPerformance<'map> {
// FIXME: converted catch maps will always count as osu!std since their mode is not modified
/// Create a new gradual performance calculator for maps of any mode.
#[inline]
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
match map.mode {
GameMode::Osu => Self::Osu(OsuGradualPerformance::new(map, mods)),
GameMode::Taiko => Self::Taiko(TaikoGradualPerformance::new(map, mods)),
GameMode::Catch => Self::Catch(CatchGradualPerformance::new(map, mods)),
GameMode::Mania => Self::Mania(ManiaGradualPerformance::new(map, mods)),
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score.
#[inline]
pub fn next(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
#[inline]
pub fn last(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
#[inline]
pub fn nth(&mut self, state: ScoreState, n: usize) -> Option<PerformanceAttributes> {
match self {
Self::Osu(o) => o.nth(state.into(), n).map(PerformanceAttributes::Osu),
Self::Taiko(t) => t.nth(state.into(), n).map(PerformanceAttributes::Taiko),
Self::Catch(f) => f.nth(state.into(), n).map(PerformanceAttributes::Catch),
Self::Mania(m) => m.nth(state.into(), n).map(PerformanceAttributes::Mania),
}
}
}
/// Gradually calculate the performance attributes on maps of any mode.
///
/// Check [`GradualPerformance`] for more information. This type does the same
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum OwnedGradualPerformance {
/// Gradual osu!standard performance calculator.
Osu(OsuOwnedGradualPerformance),
/// Gradual osu!taiko performance calculator.
Taiko(TaikoOwnedGradualPerformance),
/// Gradual osu!catch performance calculator.
Catch(CatchOwnedGradualPerformance),
/// Gradual osu!mania performance calculator.
Mania(ManiaOwnedGradualPerformance),
}
impl OwnedGradualPerformance {
// FIXME: converted catch maps will always count as osu!std since their mode is not modified
/// Create a new gradual performance calculator for maps of any mode.
#[inline]
pub fn new(map: Beatmap, mods: u32) -> Self {
match map.mode {
GameMode::Osu => Self::Osu(OsuOwnedGradualPerformance::new(map, mods)),
GameMode::Taiko => Self::Taiko(TaikoOwnedGradualPerformance::new(map, mods)),
GameMode::Catch => Self::Catch(CatchOwnedGradualPerformance::new(map, mods)),
GameMode::Mania => Self::Mania(ManiaOwnedGradualPerformance::new(map, mods)),
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score.
#[inline]
pub fn next(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
#[inline]
pub fn last(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
#[inline]
pub fn nth(&mut self, state: ScoreState, n: usize) -> Option<PerformanceAttributes> {
match self {
Self::Osu(o) => o.nth(state.into(), n).map(PerformanceAttributes::Osu),
Self::Taiko(t) => t.nth(state.into(), n).map(PerformanceAttributes::Taiko),
Self::Catch(f) => f.nth(state.into(), n).map(PerformanceAttributes::Catch),
Self::Mania(m) => m.nth(state.into(), n).map(PerformanceAttributes::Mania),
}
}
}
+32 -448
View File
@@ -1,459 +1,43 @@
#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(
clippy::all,
clippy::pedantic,
clippy::missing_const_for_fn,
nonstandard_style,
rust_2018_idioms,
unused,
warnings,
missing_debug_implementations,
missing_docs,
rustdoc::broken_intra_doc_links
)]
#![deny(rustdoc::broken_intra_doc_links)]
#![warn(clippy::missing_const_for_fn, clippy::pedantic)]
#![allow(
clippy::must_use_candidate,
clippy::inline_always,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::explicit_iter_loop,
clippy::items_after_statements,
clippy::missing_errors_doc,
clippy::module_name_repetitions
clippy::module_name_repetitions,
clippy::must_use_candidate,
clippy::struct_excessive_bools,
clippy::match_same_arms,
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::explicit_iter_loop,
clippy::similar_names,
clippy::cast_possible_wrap
)]
//! A standalone crate to calculate star ratings and performance points for all [osu!](https://osu.ppy.sh/home) gamemodes.
//!
//! Async is supported through features, see below.
//!
//! ## Usage
//!
//! ```no_run
//! use rosu_pp::{Beatmap, BeatmapExt};
//!
//! # /*
//! // Parse the map yourself
//! let map = match Beatmap::from_path("/path/to/file.osu") {
//! Ok(map) => map,
//! Err(why) => panic!("Error while parsing map: {}", why),
//! };
//! # */ let map = Beatmap::default();
//!
//! // If `BeatmapExt` is included, you can make use of some methods
//! // on `Beatmap` to make your life simpler like `BeatmapExt::pp`.
//! let result = map.pp()
//! .mods(24) // HDHR
//! .combo(1234)
//! .accuracy(99.2)
//! .n_misses(2)
//! .calculate();
//!
//! println!("PP: {}", result.pp());
//!
//! // If you want to reuse the current map-mod combination, make use of the previous result!
//! // If attributes are given, then stars & co don't have to be recalculated.
//! let next_result = map.pp()
//! .mods(24) // HDHR
//! .attributes(result) // recycle
//! .combo(543)
//! .n_misses(5)
//! .n50(3)
//! .accuracy(96.5)
//! .calculate();
//!
//! println!("Next PP: {}", next_result.pp());
//!
//! let stars = map.stars()
//! .mods(16) // HR
//! .calculate()
//! .stars();
//!
//! let max_pp = map.max_pp(16).pp();
//!
//! println!("Stars: {} | Max PP: {}", stars, max_pp);
//! ```
//!
//! ## With async
//! If either the `async_tokio` or `async_std` feature is enabled, beatmap parsing will be async.
//!
//! ```no_run
//! use rosu_pp::{Beatmap, BeatmapExt};
//!
//! # /*
//! // Parse the map asynchronously
//! let map = match Beatmap::from_path("/path/to/file.osu").await {
//! Ok(map) => map,
//! Err(why) => panic!("Error while parsing map: {}", why),
//! };
//! # */ let map = Beatmap::default();
//!
//! // The rest stays the same
//! let result = map.pp()
//! .mods(24) // HDHR
//! .combo(1234)
//! .n_misses(2)
//! .accuracy(99.2)
//! .calculate();
//!
//! println!("PP: {}", result.pp());
//! ```
//!
#![cfg_attr(
feature = "gradual",
doc = r#"
## Gradual calculation
Sometimes you might want to calculate the difficulty of a map or performance of a score after each hit object.
This could be done by using `passed_objects` as the amount of objects that were passed so far.
However, this requires to recalculate the beginning again and again, we can be more efficient than that.
Instead, you should enable the `gradual` feature and use [`GradualDifficulty`] and [`GradualPerformance`]:
```no_run
use rosu_pp::{
Beatmap, BeatmapExt, GradualDifficulty, GradualPerformance, ScoreState,
taiko::TaikoScoreState,
};
# /*
let map = match Beatmap::from_path("/path/to/file.osu") {
Ok(map) => map,
Err(why) => panic!("Error while parsing map: {}", why),
};
# */
# let map = Beatmap::default();
let mods = 8 + 64; // HDDT
// If you're only interested in the star rating or other difficulty values,
// use `GradualDifficulty`.
let gradual_difficulty = GradualDifficulty::new(&map, mods);
// Since `GradualDifficulty` implements `Iterator`, you can use
// any iterate function on it, use it in loops, collect them into a `Vec`, ...
for (i, difficulty) in gradual_difficulty.enumerate() {
println!("Stars after object {}: {}", i, difficulty.stars());
}
// Gradually calculating performance values does the same as calculating
// difficulty attributes but it goes the extra step and also evaluates
// the state of a score for these difficulty attributes.
let mut gradual_performance = GradualPerformance::new(&map, mods);
// The default score state is kinda chunky because it considers all modes.
let state = ScoreState {
max_combo: 1,
n_geki: 0, // only relevant for mania
n_katu: 0, // only relevant for mania and ctb
n300: 1,
n100: 0,
n50: 0,
n_misses: 0,
};
// Process the score state after the first object
let curr_performance = match gradual_performance.next(state) {
Some(perf) => perf,
None => panic!("the map has no hit objects"),
};
println!("PP after the first object: {}", curr_performance.pp());
// If you're only interested in maps of a specific mode, consider
// using the mode's gradual calculator instead of the general one.
// Let's assume it's a taiko map.
// Instead of starting off with `GradualPerformance` one could have
// used `TaikoGradualPerformance`.
let mut gradual_performance = match gradual_performance {
GradualPerformance::Taiko(gradual) => gradual,
_ => panic!("the map was not taiko but {:?}", map.mode),
};
// A little simpler than the general score state.
let state = TaikoScoreState {
max_combo: 11,
n300: 9,
n100: 1,
n_misses: 1,
};
// Process the next 10 objects in one go (`nth` takes a zero-based value).
let curr_performance = match gradual_performance.nth(state, 9) {
Some(perf) => perf,
None => panic!("the last `next` already processed the last object"),
};
println!("PP after the first 11 objects: {}", curr_performance.pp());
```
"#
)]
//! ## Features
//!
//! | Flag | Description |
//! |---------------|-----|
//! | `default` | Beatmap parsing will be non-async |
//! | `async_tokio` | Beatmap parsing will be async through [tokio](https://github.com/tokio-rs/tokio) |
//! | `async_std` | Beatmap parsing will be async through [async-std](https://github.com/async-rs/async-std) |
//! | `gradual` | Enable gradual difficulty and performance calculation |
//!
/// Everything about osu!catch.
pub mod catch;
/// Everything about osu!mania.
pub mod mania;
/// Everything about osu!standard.
pub mod osu;
/// Everything about osu!taiko.
pub mod taiko;
/// Beatmap parsing
pub mod parse;
/// Beatmap and contained types
pub mod beatmap;
pub use beatmap::{Beatmap, BeatmapExt, GameMode};
#[cfg(feature = "gradual")]
mod gradual;
#[cfg(feature = "gradual")]
pub use gradual::{
GradualDifficulty, GradualPerformance, OwnedGradualDifficulty, OwnedGradualPerformance,
#[doc(inline)]
pub use self::{
any::{Difficulty, ModeDifficulty, Performance},
model::beatmap::{Beatmap, Converted},
};
mod pp;
pub use pp::{AnyPP, AttributeProvider, HitResultPriority};
/// Types for calculations of any mode.
pub mod any;
mod stars;
pub use stars::AnyStars;
/// Types for osu!standard calculations.
pub mod osu;
mod score_state;
pub use score_state::*;
/// Types for osu!taiko calculations.
pub mod taiko;
/// Types for osu!catch calculations.
pub mod catch;
/// Types for osu!mania calculations.
pub mod mania;
/// Types used in and around this crate.
pub mod model;
mod curve;
mod mods;
mod util;
pub use catch::{CatchPP, CatchStars};
pub use mania::{ManiaPP, ManiaStars};
pub use osu::{OsuPP, OsuStars};
pub use taiko::{TaikoPP, TaikoStars};
pub use mods::Mods;
pub use parse::{ParseError, ParseResult};
/// The result of calculating the strains on a map.
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug)]
pub enum Strains {
/// osu!standard strain values.
Osu(osu::OsuStrains),
/// osu!taiko strain values.
Taiko(taiko::TaikoStrains),
/// osu!catch strain values.
Catch(catch::CatchStrains),
/// osu!mania strain values.
Mania(mania::ManiaStrains),
}
impl Strains {
/// Time in ms inbetween two strains.
#[inline]
pub const fn section_len(&self) -> f64 {
match self {
Strains::Osu(strains) => strains.section_len,
Strains::Taiko(strains) => strains.section_len,
Strains::Catch(strains) => strains.section_len,
Strains::Mania(strains) => strains.section_len,
}
}
/// Returns the number of strain peaks per skill.
#[inline]
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
match self {
Strains::Osu(strains) => strains.len(),
Strains::Taiko(strains) => strains.len(),
Strains::Catch(strains) => strains.len(),
Strains::Mania(strains) => strains.len(),
}
}
}
/// The result of a difficulty calculation based on the mode.
#[derive(Clone, Debug)]
pub enum DifficultyAttributes {
/// osu!standard difficulty calculation result.
Osu(osu::OsuDifficultyAttributes),
/// osu!taiko difficulty calculation result.
Taiko(taiko::TaikoDifficultyAttributes),
/// osu!catch difficulty calculation result.
Catch(catch::CatchDifficultyAttributes),
/// osu!mania difficulty calculation result.
Mania(mania::ManiaDifficultyAttributes),
}
impl DifficultyAttributes {
/// The star value.
#[inline]
pub const fn stars(&self) -> f64 {
match self {
Self::Osu(attrs) => attrs.stars,
Self::Taiko(attrs) => attrs.stars,
Self::Catch(attrs) => attrs.stars,
Self::Mania(attrs) => attrs.stars,
}
}
/// The maximum combo of the map.
#[inline]
pub const fn max_combo(&self) -> usize {
match self {
Self::Osu(attrs) => attrs.max_combo,
Self::Taiko(attrs) => attrs.max_combo,
Self::Catch(attrs) => attrs.max_combo(),
Self::Mania(attrs) => attrs.max_combo,
}
}
/// Returns a builder for performance calculation.
#[inline]
pub fn pp(self) -> AnyPP<'static> {
AnyPP::from(self)
}
}
impl From<osu::OsuDifficultyAttributes> for DifficultyAttributes {
#[inline]
fn from(attributes: osu::OsuDifficultyAttributes) -> Self {
Self::Osu(attributes)
}
}
impl From<taiko::TaikoDifficultyAttributes> for DifficultyAttributes {
#[inline]
fn from(attributes: taiko::TaikoDifficultyAttributes) -> Self {
Self::Taiko(attributes)
}
}
impl From<catch::CatchDifficultyAttributes> for DifficultyAttributes {
#[inline]
fn from(attributes: catch::CatchDifficultyAttributes) -> Self {
Self::Catch(attributes)
}
}
impl From<mania::ManiaDifficultyAttributes> for DifficultyAttributes {
#[inline]
fn from(attributes: mania::ManiaDifficultyAttributes) -> Self {
Self::Mania(attributes)
}
}
/// The result of a performance calculation based on the mode.
#[derive(Clone, Debug)]
pub enum PerformanceAttributes {
/// osu!standard performance calculation result.
Osu(osu::OsuPerformanceAttributes),
/// osu!taiko performance calculation result.
Taiko(taiko::TaikoPerformanceAttributes),
/// osu!catch performance calculation result.
Catch(catch::CatchPerformanceAttributes),
/// osu!mania performance calculation result.
Mania(mania::ManiaPerformanceAttributes),
}
impl PerformanceAttributes {
/// The pp value.
#[inline]
pub const fn pp(&self) -> f64 {
match self {
Self::Osu(attrs) => attrs.pp,
Self::Taiko(attrs) => attrs.pp,
Self::Catch(attrs) => attrs.pp,
Self::Mania(attrs) => attrs.pp,
}
}
/// The star value.
#[inline]
pub const fn stars(&self) -> f64 {
match self {
Self::Osu(attrs) => attrs.stars(),
Self::Taiko(attrs) => attrs.stars(),
Self::Catch(attrs) => attrs.stars(),
Self::Mania(attrs) => attrs.stars(),
}
}
/// Difficulty attributes that were used for the performance calculation.
#[inline]
pub fn difficulty_attributes(&self) -> DifficultyAttributes {
match self {
Self::Osu(attrs) => DifficultyAttributes::Osu(attrs.difficulty.clone()),
Self::Taiko(attrs) => DifficultyAttributes::Taiko(attrs.difficulty.clone()),
Self::Catch(attrs) => DifficultyAttributes::Catch(attrs.difficulty.clone()),
Self::Mania(attrs) => DifficultyAttributes::Mania(attrs.difficulty.clone()),
}
}
#[inline]
/// The maximum combo of the map.
pub const fn max_combo(&self) -> usize {
match self {
Self::Osu(attrs) => attrs.difficulty.max_combo,
Self::Taiko(attrs) => attrs.difficulty.max_combo,
Self::Catch(attrs) => attrs.difficulty.max_combo(),
Self::Mania(attrs) => attrs.difficulty.max_combo,
}
}
}
impl From<PerformanceAttributes> for DifficultyAttributes {
#[inline]
fn from(attributes: PerformanceAttributes) -> Self {
match attributes {
PerformanceAttributes::Osu(attrs) => Self::Osu(attrs.difficulty),
PerformanceAttributes::Taiko(attrs) => Self::Taiko(attrs.difficulty),
PerformanceAttributes::Catch(attrs) => Self::Catch(attrs.difficulty),
PerformanceAttributes::Mania(attrs) => Self::Mania(attrs.difficulty),
}
}
}
impl From<osu::OsuPerformanceAttributes> for PerformanceAttributes {
#[inline]
fn from(attributes: osu::OsuPerformanceAttributes) -> Self {
Self::Osu(attributes)
}
}
impl From<taiko::TaikoPerformanceAttributes> for PerformanceAttributes {
#[inline]
fn from(attributes: taiko::TaikoPerformanceAttributes) -> Self {
Self::Taiko(attributes)
}
}
impl From<catch::CatchPerformanceAttributes> for PerformanceAttributes {
#[inline]
fn from(attributes: catch::CatchPerformanceAttributes) -> Self {
Self::Catch(attributes)
}
}
impl From<mania::ManiaPerformanceAttributes> for PerformanceAttributes {
#[inline]
fn from(attributes: mania::ManiaPerformanceAttributes) -> Self {
Self::Mania(attributes)
}
}
#[cfg(all(feature = "async_tokio", feature = "async_std"))]
compile_error!("Only one of the features `async_tokio` and `async_std` should be enabled");
// TODO: fix very low custom clock rates
+82
View File
@@ -0,0 +1,82 @@
use crate::mania::performance::ManiaPerformance;
/// The result of a difficulty calculation on an osu!mania map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ManiaDifficultyAttributes {
/// The final star rating.
pub stars: f64,
/// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc).
pub hit_window: f64,
/// The amount of hitobjects in the map.
pub n_objects: u32,
/// The maximum achievable combo.
pub max_combo: u32,
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub is_convert: bool,
}
impl ManiaDifficultyAttributes {
/// Return the maximum combo.
pub const fn max_combo(&self) -> u32 {
self.max_combo
}
/// Return the amount of hitobjects.
pub const fn n_objects(&self) -> u32 {
self.n_objects
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub const fn is_convert(&self) -> bool {
self.is_convert
}
/// Returns a builder for performance calculation.
pub fn pp<'a>(self) -> ManiaPerformance<'a> {
self.into()
}
}
/// The result of a performance calculation on an osu!mania map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ManiaPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation.
pub difficulty: ManiaDifficultyAttributes,
/// The final performance points.
pub pp: f64,
/// The difficulty portion of the final pp.
pub pp_difficulty: f64,
}
impl ManiaPerformanceAttributes {
/// Return the star value.
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
pub const fn max_combo(&self) -> u32 {
self.difficulty.max_combo
}
/// Return the amount of hitobjects.
pub const fn n_objects(&self) -> u32 {
self.difficulty.n_objects
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub const fn is_convert(&self) -> bool {
self.difficulty.is_convert
}
}
+257
View File
@@ -0,0 +1,257 @@
use std::borrow::Cow;
use rosu_map::{
section::{
general::GameMode,
hit_objects::{BorrowedCurve, CurveBuffers},
},
util::Pos,
};
use crate::{
model::{
beatmap::{Beatmap, Converted},
hit_object::{HitObjectKind, HoldNote, Spinner},
mode::ConvertStatus,
},
util::{
float_ext::FloatExt, legacy_sort::legacy_sort, limited_queue::LimitedQueue, random::Random,
},
};
use self::{
pattern::Pattern,
pattern_generator::{
distance_object::DistanceObjectPatternGenerator,
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
},
pattern_type::PatternType,
};
use super::Mania;
mod pattern;
mod pattern_generator;
mod pattern_type;
/// A [`Beatmap`] for [`Mania`] calculations.
pub type ManiaBeatmap<'a> = Converted<'a, Mania>;
const MAX_NOTES_FOR_DENSITY: usize = 7;
pub fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
match map.mode {
GameMode::Osu => {
convert(map.to_mut());
ConvertStatus::Done
}
GameMode::Mania => ConvertStatus::Noop,
GameMode::Taiko | GameMode::Catch => ConvertStatus::Incompatible,
}
}
fn convert(map: &mut Beatmap) {
let seed = (map.hp + map.cs).round_even() as i32 * 20
+ (map.od * 41.2) as i32
+ map.ar.round_even() as i32;
let mut random = Random::new(seed);
map.cs = target_columns(map);
let mut prev_note_times = LimitedQueue::<f64, MAX_NOTES_FOR_DENSITY>::new();
let mut density = f64::from(i32::MAX);
let mut compute_density = |new_note_time: f64, d: &mut f64| {
prev_note_times.push(new_note_time);
if let ([first, ..], [.., last]) | ([], [first, .., last]) | ([first, .., last], []) =
prev_note_times.as_slices()
{
*d = (last - first) / prev_note_times.len() as f64;
}
};
let total_columns = map.cs as i32;
let mut last_values = PrevValues::default();
let mut curve_bufs = CurveBuffers::default();
// TODO: find proper default
let capacity = map.hit_objects.len() * 2;
let mut new_hit_objects = Vec::with_capacity(capacity);
for (obj, sound) in map.hit_objects.iter().zip(map.hit_sounds.iter().copied()) {
match obj.kind {
HitObjectKind::Circle => {
compute_density(obj.start_time, &mut density);
let mut gen = HitObjectPatternGenerator::new(
&mut random,
obj,
sound,
total_columns,
&last_values,
density,
map,
);
let new_pattern = gen.generate();
last_values.stair = gen.stair_type;
last_values.time = obj.start_time;
last_values.pos = obj.pos;
let new_hit_objects_iter = new_pattern.hit_objects.iter().cloned();
new_hit_objects.extend(new_hit_objects_iter);
last_values.pattern = new_pattern;
}
HitObjectKind::Slider(ref slider) => {
let curve = BorrowedCurve::new(
&slider.control_points,
slider.expected_dist,
&mut curve_bufs,
);
let mut gen = DistanceObjectPatternGenerator::new(
&mut random,
obj,
sound,
total_columns,
&last_values.pattern,
map,
slider.repeats,
&curve,
&slider.node_sounds,
);
let segment_duration = f64::from(gen.segment_duration);
for i in 0..=slider.repeats as i32 + 1 {
let time = obj.start_time + segment_duration * f64::from(i);
last_values.time = time;
last_values.pos = obj.pos;
compute_density(time, &mut density);
}
for new_pattern in gen.generate() {
new_hit_objects.extend_from_slice(&new_pattern.hit_objects);
last_values.pattern = new_pattern;
}
}
HitObjectKind::Spinner(Spinner { end_time })
| HitObjectKind::Hold(HoldNote { end_time }) => {
let mut gen = EndTimeObjectPatternGenerator::new(
&mut random,
obj,
end_time,
sound,
total_columns,
&last_values.pattern,
map,
);
last_values.time = end_time;
last_values.pos = Pos::new(256.0, 192.0);
compute_density(end_time, &mut density);
let new_pattern = gen.generate();
new_hit_objects.extend(new_pattern.hit_objects);
}
}
}
map.hit_sounds.clear();
map.hit_objects = new_hit_objects;
map.hit_objects
.sort_by(|a, b| a.start_time.total_cmp(&b.start_time));
legacy_sort(&mut map.hit_objects);
map.mode = GameMode::Mania;
}
pub struct PrevValues {
time: f64,
pos: Pos,
pattern: Pattern,
stair: PatternType,
}
impl Default for PrevValues {
fn default() -> Self {
Self {
time: 0.0,
pos: Pos::default(),
pattern: Pattern::default(),
stair: PatternType::STAIR,
}
}
}
fn target_columns(map: &Beatmap) -> f32 {
let rounded_cs = map.cs.round_even();
let rounded_od = map.od.round_even();
let slider_or_spinner_count = 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);
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)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn convert_mania() {
let converted = Beatmap::from_path("./resources/2785319.osu")
.unwrap()
.unchecked_into_converted::<Mania>();
assert!(converted.is_convert);
let map = converted.map;
assert_eq!(map.mode, GameMode::Mania);
assert_eq!(map.version, 14);
assert!(map.ar.eq(9.3), "{} != 9.3", map.ar);
assert!(map.od.eq(8.8), "{} != 8.8", map.od);
assert!(map.cs.eq(7.0), "{} != 7.0", map.cs);
assert!(map.hp.eq(5.0), "{} != 5.0", map.hp);
assert!(
map.slider_multiplier.eq(1.7),
"{} != 1.7",
map.slider_multiplier
);
assert!(
map.slider_tick_rate.eq(1.0),
"{} != 1.0",
map.slider_tick_rate
);
assert_eq!(map.hit_objects.len(), 1046);
assert_eq!(map.hit_sounds.len(), 0);
assert_eq!(map.timing_points.len(), 1);
assert_eq!(map.difficulty_points.len(), 50);
assert_eq!(map.effect_points.len(), 0);
assert!(map.stack_leniency.eq(0.5), "{} != 0.5", map.stack_leniency);
assert_eq!(map.breaks.len(), 1);
}
}
@@ -1,9 +1,6 @@
use std::collections::HashSet;
use rosu_map::util::Pos;
use crate::{
parse::{HitObject, HitObjectKind, Pos2},
util::ByteHasher,
};
use crate::model::hit_object::{HitObject, HitObjectKind, HoldNote};
use super::pattern_generator::{
distance_object::DistanceObjectPatternGenerator,
@@ -11,21 +8,21 @@ use super::pattern_generator::{
};
#[derive(Default)]
pub(crate) struct Pattern {
pub(crate) hit_objects: Vec<HitObject>,
contained_columns: HashSet<u8, ByteHasher>,
pub struct Pattern {
pub hit_objects: Vec<HitObject>,
contained_columns: ContainedColumns,
}
impl Pattern {
pub(crate) fn with_capacity(capacity: usize) -> Self {
pub fn with_capacity(capacity: usize) -> Self {
Self {
hit_objects: Vec::with_capacity(capacity),
contained_columns: HashSet::with_hasher(ByteHasher),
contained_columns: ContainedColumns::default(),
}
}
fn new_single(hit_object: HitObject, column: u8) -> Self {
let mut contained_columns = HashSet::with_capacity_and_hasher(1, ByteHasher);
let mut contained_columns = ContainedColumns::default();
contained_columns.insert(column);
let hit_objects = vec![hit_object];
@@ -35,20 +32,24 @@ impl Pattern {
}
}
pub(crate) fn new_note(generator: &HitObjectPatternGenerator<'_>, column: u8) -> Self {
pub fn new_note(generator: &HitObjectPatternGenerator<'_>, column: u8) -> Self {
let pos = column_to_pos(column, generator.inner.total_columns);
let hit_object = HitObject {
pos: Pos2::new(column_to_pos(column, generator.total_columns)),
start_time: generator.hit_object.start_time,
pos: Pos::new(pos, pos),
start_time: generator.inner.hit_object.start_time,
kind: HitObjectKind::Circle,
};
Self::new_single(hit_object, column)
}
pub(crate) fn add_note(&mut self, generator: &HitObjectPatternGenerator<'_>, column: u8) {
pub fn add_note(&mut self, generator: &HitObjectPatternGenerator<'_>, column: u8) {
let pos = column_to_pos(column, generator.inner.total_columns);
let hit_object = HitObject {
pos: Pos2::new(column_to_pos(column, generator.total_columns)),
start_time: generator.hit_object.start_time,
pos: Pos::new(pos, pos),
start_time: generator.inner.hit_object.start_time,
kind: HitObjectKind::Circle,
};
@@ -56,25 +57,26 @@ impl Pattern {
self.hit_objects.push(hit_object);
}
pub(crate) fn new_end_time_note(
pub fn new_end_time_note(
generator: &EndTimeObjectPatternGenerator<'_>,
column: u8,
hold_note: bool,
) -> Self {
let pos = Pos2::new(column_to_pos(column, generator.total_columns));
let pos = column_to_pos(column, generator.inner.total_columns);
let pos = Pos::new(pos, pos);
let hit_object = if hold_note {
HitObject {
pos,
start_time: generator.hit_object.start_time,
kind: HitObjectKind::Hold {
start_time: generator.inner.hit_object.start_time,
kind: HitObjectKind::Hold(HoldNote {
end_time: generator.end_time,
},
}),
}
} else {
HitObject {
pos,
start_time: generator.hit_object.start_time,
start_time: generator.inner.hit_object.start_time,
kind: HitObjectKind::Circle,
}
};
@@ -82,13 +84,14 @@ impl Pattern {
Self::new_single(hit_object, column)
}
pub(crate) fn new_slider_note(
pub fn new_slider_note(
generator: &DistanceObjectPatternGenerator<'_>,
column: u8,
start_time: i32,
end_time: i32,
) -> Self {
let pos = Pos2::new(column_to_pos(column, generator.total_columns));
let pos = column_to_pos(column, generator.inner.total_columns);
let pos = Pos::new(pos, pos);
let hit_object = if start_time == end_time {
HitObject {
@@ -100,23 +103,24 @@ impl Pattern {
HitObject {
pos,
start_time: f64::from(start_time),
kind: HitObjectKind::Hold {
kind: HitObjectKind::Hold(HoldNote {
end_time: f64::from(end_time),
},
}),
}
};
Self::new_single(hit_object, column)
}
pub(crate) fn add_slider_note(
pub fn add_slider_note(
&mut self,
generator: &DistanceObjectPatternGenerator<'_>,
column: u8,
start_time: i32,
end_time: i32,
) {
let pos = Pos2::new(column_to_pos(column, generator.total_columns));
let pos = column_to_pos(column, generator.inner.total_columns);
let pos = Pos::new(pos, pos);
let hit_object = if start_time == end_time {
HitObject {
@@ -128,9 +132,9 @@ impl Pattern {
HitObject {
pos,
start_time: f64::from(start_time),
kind: HitObjectKind::Hold {
kind: HitObjectKind::Hold(HoldNote {
end_time: f64::from(end_time),
},
}),
}
};
@@ -138,25 +142,24 @@ impl Pattern {
self.hit_objects.push(hit_object);
}
pub(crate) fn add_object(&mut self, obj: HitObject, column: u8) {
pub fn add_object(&mut self, obj: HitObject, column: u8) {
self.hit_objects.push(obj);
self.contained_columns.insert(column);
}
pub(crate) fn column_has_obj(&self, column: u8) -> bool {
self.contained_columns.contains(&column)
pub const fn column_has_obj(&self, column: u8) -> bool {
self.contained_columns.contains(column)
}
pub(crate) fn column_with_objs(&self) -> i32 {
pub const fn column_with_objs(&self) -> i32 {
self.contained_columns.len() as i32
}
/// Moves all values of `other` into `self`,
/// leaving `other` empty but keeps the capacities.
pub(crate) fn append(&mut self, other: &mut Self) {
pub fn append(&mut self, other: &mut Self) {
self.hit_objects.append(&mut other.hit_objects);
self.contained_columns
.extend(other.contained_columns.drain());
self.contained_columns.extend(other.contained_columns);
}
}
@@ -165,3 +168,24 @@ fn column_to_pos(column: u8, total_columns: i32) -> f32 {
(f32::from(column) * divisor).ceil()
}
#[derive(Copy, Clone, Default)]
pub struct ContainedColumns(u16);
impl ContainedColumns {
pub fn insert(&mut self, column: u8) {
self.0 |= 1 << column;
}
pub fn extend(&mut self, other: Self) {
self.0 |= other.0;
}
pub const fn len(self) -> u32 {
self.0.count_ones()
}
pub const fn contains(self, column: u8) -> bool {
self.0 & (1 << column) != 0
}
}
@@ -1,50 +1,48 @@
use rosu_map::section::hit_objects::{hit_samples::HitSoundType, BorrowedCurve};
use crate::{
beatmap::{
converts::mania::{legacy_random::Random, pattern::Pattern, pattern_type::PatternType},
EffectPoint,
mania::{
convert::{pattern::Pattern, pattern_type::PatternType},
object::ManiaObject,
},
curve::Curve,
mania::ManiaObject,
parse::{HitObject, HitSound},
util::FloatExt,
Beatmap,
model::{
beatmap::{get_precision_adjusted_beat_len_taiko_mania, Beatmap},
control_point::{EffectPoint, TimingPoint},
hit_object::HitObject,
},
util::{float_ext::FloatExt, random::Random},
};
use super::PatternGenerator;
pub(crate) struct DistanceObjectPatternGenerator<'h> {
pub(crate) hit_object: &'h HitObject,
pub(crate) segment_duration: i32,
pub(crate) total_columns: i32,
pub(crate) sample: u8,
pub struct DistanceObjectPatternGenerator<'h> {
pub segment_duration: i32,
pub sample: HitSoundType,
pub inner: PatternGenerator<'h>,
start_time: i32,
end_time: i32,
span_count: i32,
orig: &'h Beatmap,
prev_pattern: &'h Pattern,
convert_type: PatternType,
random: &'h mut Random,
edge_sounds: &'h [u8],
node_sounds: &'h [HitSoundType],
}
impl<'h> DistanceObjectPatternGenerator<'h> {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
pub fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
sample: u8,
sample: HitSoundType,
total_columns: i32,
prev_pattern: &'h Pattern,
orig: &'h Beatmap,
repeats: usize,
curve: &Curve<'_>,
edge_sounds: &'h [u8],
curve: &BorrowedCurve<'_>,
node_sounds: &'h [HitSoundType],
) -> Self {
let timing_point = orig.timing_point_at(hit_object.start_time);
let difficulty_point = orig
.difficulty_point_at(hit_object.start_time)
.unwrap_or_default();
let beat_len = orig
.timing_point_at(hit_object.start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let kiai = orig
.effect_point_at(hit_object.start_time)
@@ -56,35 +54,35 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
PatternType::LOW_PROBABILITY
};
let beat_len = timing_point.beat_len * difficulty_point.bpm_mult;
let beat_len =
get_precision_adjusted_beat_len_taiko_mania(orig, beat_len, hit_object.start_time);
let span_count = (repeats + 1) as i32;
let start_time = hit_object.start_time.round_even() as i32;
// * This matches stable's calculation.
let end_time = (f64::from(start_time)
+ curve.dist() * beat_len * f64::from(span_count) * 0.01 / orig.slider_mult)
+ curve.dist() * beat_len * f64::from(span_count) * 0.01 / orig.slider_multiplier)
.floor() as i32;
let segment_duration = (end_time - start_time) / span_count;
let inner = PatternGenerator::new(hit_object, total_columns, random, orig);
Self {
hit_object,
segment_duration,
total_columns,
sample,
inner,
start_time,
end_time,
span_count,
orig,
prev_pattern,
convert_type,
random,
edge_sounds,
node_sounds,
}
}
pub(crate) fn generate(&mut self) -> Vec<Pattern> {
pub fn generate(&mut self) -> Vec<Pattern> {
let orig_pattern = self.generate_();
if orig_pattern.hit_objects.len() == 1 {
@@ -98,7 +96,7 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
let mut end_time_pattern = Pattern::default();
for obj in orig_pattern.hit_objects {
let col = ManiaObject::column(obj.pos.x, self.total_columns as f32) as u8;
let col = ManiaObject::column(obj.pos.x, self.inner.total_columns as f32) as u8;
// Keeping it in-sync with lazer
#[allow(clippy::if_not_else)]
@@ -113,9 +111,9 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
}
fn generate_(&mut self) -> Pattern {
let conversion_diff = self.conversion_difficulty();
let conversion_diff = self.inner.conversion_difficulty();
if self.total_columns == 1 {
if self.inner.total_columns == 1 {
Pattern::new_slider_note(self, 0, self.start_time, self.end_time)
} else if self.span_count > 1 {
if self.segment_duration <= 90 {
@@ -131,14 +129,14 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
} else if self.end_time - self.start_time >= 4000 {
self.generate_n_random_notes(self.start_time, 0.23, 0.0, 0.0)
} else if self.segment_duration > 400
&& self.span_count < self.total_columns - 1 - self.random_start()
&& self.span_count < self.inner.total_columns - 1 - self.inner.random_start()
{
self.generate_tiled_hold_notes(self.start_time)
} else {
self.generate_hold_and_normal_notes(self.start_time, conversion_diff)
}
} else if self.segment_duration <= 110 {
if self.prev_pattern.column_with_objs() < self.total_columns {
if self.prev_pattern.column_with_objs() < self.inner.total_columns {
self.convert_type |= PatternType::FORCE_NOT_STACK;
} else {
self.convert_type &= !PatternType::FORCE_NOT_STACK;
@@ -180,29 +178,22 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
let mut pattern = Pattern::default();
let random_start = self.random_start();
let random_start = self.inner.random_start();
let usable_columns =
self.total_columns - random_start - self.prev_pattern.column_with_objs();
let mut next_column = PatternGenerator::get_random_column(self, None, None);
self.inner.total_columns - random_start - self.prev_pattern.column_with_objs();
let mut next_column = self.inner.get_random_column(None, None);
for _ in 0..usable_columns.min(note_count) {
// * Find available column
next_column = self.find_available_column(
next_column,
None,
None,
None,
None,
&[&pattern, self.prev_pattern],
);
next_column =
self.find_available_column(next_column, None, &[&pattern, self.prev_pattern]);
pattern.add_slider_note(self, next_column, start_time, self.end_time);
}
// * This is can't be combined with the above loop due to RNG
for _ in 0..note_count.saturating_sub(usable_columns) {
next_column =
self.find_available_column(next_column, None, None, None, None, &[&pattern]);
next_column = self.find_available_column(next_column, None, &[&pattern]);
pattern.add_slider_note(self, next_column, start_time, self.end_time);
}
@@ -217,19 +208,12 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
// * - - - x
// * x - - -
let mut next_column = self.get_column(Some(true));
let mut next_column = self.inner.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.total_columns
&& self.prev_pattern.column_with_objs() < self.inner.total_columns
{
next_column = self.find_available_column(
next_column,
None,
None,
None,
None,
&[self.prev_pattern],
);
next_column = self.find_available_column(next_column, None, &[self.prev_pattern]);
}
let mut last_column = next_column;
@@ -240,9 +224,6 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
next_column = self.find_available_column(
next_column,
None,
None,
None,
Some(&|c| c != i32::from(last_column)),
&[],
);
@@ -264,8 +245,8 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
// * - x - -
// * x - - -
let mut column = i32::from(self.get_column(Some(true)));
let mut increasing = self.random.gen_double() > 0.5;
let mut column = i32::from(self.inner.get_column(Some(true)));
let mut increasing = self.inner.random.next_double() > 0.5;
let mut pattern = Pattern::with_capacity(self.span_count as usize + 1);
for _ in 0..=self.span_count as usize {
@@ -274,13 +255,13 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
// * Check if we're at the borders of the stage, and invert the pattern if so
if increasing {
if column >= self.total_columns - 1 {
if column >= self.inner.total_columns - 1 {
increasing = false;
column -= 1;
} else {
column += 1;
}
} else if column <= self.random_start() {
} else if column <= self.inner.random_start() {
increasing = true;
column += 1;
} else {
@@ -298,14 +279,15 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
// * - - - x
// * x - x -
let legacy = (4..=8).contains(&self.total_columns);
let legacy = (4..=8).contains(&self.inner.total_columns);
let interval = self
.inner
.random
.gen_int_range(1, self.total_columns - i32::from(legacy));
.next_int_range(1, self.inner.total_columns - i32::from(legacy));
let mut next_column = i32::from(self.get_column(Some(true)));
let random_start = self.random_start();
let not_2k = self.total_columns > 2;
let mut next_column = i32::from(self.inner.get_column(Some(true)));
let random_start = self.inner.random_start();
let not_2k = self.inner.total_columns > 2;
let mut pattern =
Pattern::with_capacity((self.span_count as usize + 1) * (1 + usize::from(not_2k)));
@@ -314,8 +296,9 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
next_column += interval;
if next_column >= self.total_columns - random_start {
next_column = next_column - self.total_columns - random_start + i32::from(legacy);
if next_column >= self.inner.total_columns - random_start {
next_column =
next_column - self.inner.total_columns - random_start + i32::from(legacy);
}
next_column += random_start;
@@ -325,7 +308,7 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
pattern.add_slider_note(self, next_column as u8, start_time, start_time);
}
next_column = i32::from(PatternGenerator::get_random_column(self, None, None));
next_column = i32::from(self.inner.get_random_column(None, None));
start_time += self.segment_duration;
}
@@ -344,7 +327,7 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
// * □ - □ □
// * ■ - ■ ■
match self.total_columns {
match self.inner.total_columns {
2 => {
p2 = 0.0;
p3 = 0.0;
@@ -368,7 +351,8 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
_ => {}
}
let is_double_sample = |sample: u8| sample.clap() || sample.finish();
let is_double_sample =
|sample: HitSoundType| sample.has_flag(HitSoundType::CLAP | HitSoundType::FINISH);
let can_generate_two_notes = !self.convert_type.contains(PatternType::LOW_PROBABILITY)
&& (is_double_sample(self.sample)
@@ -378,7 +362,9 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
p2 = 1.0;
}
let note_count = self.get_random_note_count(p2, p3, Some(p4), None, None);
let note_count = self
.inner
.get_random_note_count(p2, p3, Some(p4), None, None);
self.generate_random_hold_notes(start_time, note_count)
}
@@ -393,31 +379,23 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
// * □ ■ - -
// * ■ - - -
let column_repeat = self.span_count.min(self.total_columns) as usize;
let column_repeat = self.span_count.min(self.inner.total_columns) as usize;
// * Due to integer rounding, this is not guaranteed to be the same as EndTime (the class-level variable).
let end_time = start_time + self.segment_duration * self.span_count;
let mut next_column = self.get_column(Some(true));
let mut next_column = self.inner.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.total_columns
&& self.prev_pattern.column_with_objs() < self.inner.total_columns
{
next_column = self.find_available_column(
next_column,
None,
None,
None,
None,
&[self.prev_pattern],
);
next_column = self.find_available_column(next_column, None, &[self.prev_pattern]);
}
let mut pattern = Pattern::with_capacity(column_repeat);
for _ in 0..column_repeat {
next_column =
self.find_available_column(next_column, None, None, None, None, &[&pattern]);
next_column = self.find_available_column(next_column, None, &[&pattern]);
pattern.add_slider_note(self, next_column, start_time, end_time);
start_time += self.segment_duration;
}
@@ -438,44 +416,47 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
let mut pattern = Pattern::default();
let mut hold_column = self.get_column(Some(true));
let mut hold_column = self.inner.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.total_columns
&& self.prev_pattern.column_with_objs() < self.inner.total_columns
{
hold_column = self.find_available_column(
hold_column,
None,
None,
None,
None,
&[self.prev_pattern],
);
hold_column = self.find_available_column(hold_column, None, &[self.prev_pattern]);
}
// * Create the hold note
pattern.add_slider_note(self, hold_column, start_time, self.end_time);
let mut next_column = PatternGenerator::get_random_column(self, None, None);
let mut next_column = self.inner.get_random_column(None, None);
let mut note_count = if conversion_diff > 6.5 {
self.get_random_note_count(0.63, 0.0, None, None, None)
self.inner
.get_random_note_count(0.63, 0.0, None, None, None)
} else if conversion_diff > 4.0 {
let p2 = if self.total_columns < 6 { 0.12 } else { 0.45 };
let p2 = if self.inner.total_columns < 6 {
0.12
} else {
0.45
};
self.get_random_note_count(p2, 0.0, None, None, None)
self.inner.get_random_note_count(p2, 0.0, None, None, None)
} else if conversion_diff > 2.5 {
let p2 = if self.total_columns < 6 { 0.0 } else { 0.24 };
let p2 = if self.inner.total_columns < 6 {
0.0
} else {
0.24
};
self.get_random_note_count(p2, 0.0, None, None, None)
self.inner.get_random_note_count(p2, 0.0, None, None, None)
} else {
0
};
note_count = note_count.min(self.total_columns - 1);
note_count = note_count.min(self.inner.total_columns - 1);
let sample = self.sample_info_list_at(start_time);
let ignore_head = !(sample.whistle() || sample.finish() || sample.clap());
let ignore_head =
!sample.has_flag(HitSoundType::WHISTLE | HitSoundType::FINISH | HitSoundType::CLAP);
let mut row_pattern = Pattern::default();
let hold_column = i32::from(hold_column);
@@ -485,9 +466,6 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
for _ in 0..note_count {
next_column = self.find_available_column(
next_column,
None,
None,
None,
Some(&|c| c != hold_column),
&[&row_pattern],
);
@@ -503,41 +481,61 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
pattern
}
fn sample_info_list_at(&self, time: i32) -> u8 {
fn sample_info_list_at(&self, time: i32) -> HitSoundType {
self.note_samples_at(time)
.first()
.map_or(self.sample, |sample| *sample)
}
fn note_samples_at(&self, time: i32) -> &[u8] {
fn note_samples_at(&self, time: i32) -> &[HitSoundType] {
let idx = if self.segment_duration == 0 {
0
} else {
((time - self.start_time) / self.segment_duration) as usize
};
&self.edge_sounds[idx..]
}
}
impl PatternGenerator for DistanceObjectPatternGenerator<'_> {
#[inline]
fn hit_object(&self) -> &HitObject {
self.hit_object
}
#[inline]
fn total_columns(&self) -> i32 {
self.total_columns
}
#[inline]
fn random(&mut self) -> &mut Random {
self.random
}
#[inline]
fn original_map(&self) -> &Beatmap {
self.orig
&self.node_sounds[idx..]
}
fn find_available_column(
&mut self,
mut initial_column: u8,
validation: Option<&dyn Fn(i32) -> bool>,
patterns: &[&Pattern],
) -> u8 {
let lower = self.inner.random_start();
let upper = self.inner.total_columns;
let is_valid = |column: i32| {
if let Some(fun) = validation {
if !(fun)(column) {
return false;
}
}
let column = column as u8;
patterns
.iter()
.all(|pattern| !pattern.column_has_obj(column))
};
// * Check for the initial column
if is_valid(i32::from(initial_column)) {
return initial_column;
}
// * Ensure that we have at least one free column, so that an endless loop is avoided
let has_valid_column = (lower..upper).any(is_valid);
assert!(has_valid_column);
// * Iterate until a valid column is found. This is a random iteration in the default case.
while {
initial_column = self.inner.get_random_column(Some(lower), Some(upper));
!is_valid(i32::from(initial_column))
} {}
initial_column
}
}
@@ -0,0 +1,117 @@
use rosu_map::section::hit_objects::hit_samples::HitSoundType;
use crate::{
mania::convert::{pattern::Pattern, pattern_type::PatternType},
model::hit_object::HitObject,
util::random::Random,
Beatmap,
};
use super::PatternGenerator;
pub struct EndTimeObjectPatternGenerator<'h> {
pub end_time: f64,
pub sample: HitSoundType,
pub inner: PatternGenerator<'h>,
convert_type: PatternType,
prev_pattern: &'h Pattern,
}
impl<'h> EndTimeObjectPatternGenerator<'h> {
pub fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
end_time: f64,
sample: HitSoundType,
total_columns: i32,
prev_pattern: &'h Pattern,
orig: &'h Beatmap,
) -> Self {
let convert_type = if prev_pattern.column_with_objs() == total_columns {
PatternType::default()
} else {
PatternType::FORCE_NOT_STACK
};
let inner = PatternGenerator::new(hit_object, total_columns, random, orig);
Self {
end_time,
sample,
inner,
convert_type,
prev_pattern,
}
}
pub fn generate(&mut self) -> Pattern {
let generate_hold = self.end_time - self.inner.hit_object.start_time >= 100.0;
match self.inner.total_columns {
8 if self.sample.has_flag(HitSoundType::FINISH)
&& self.end_time - self.inner.hit_object.start_time < 1000.0 =>
{
Pattern::new_end_time_note(self, 0, generate_hold)
}
8 => {
let column = self.get_random_column(self.inner.random_start());
Pattern::new_end_time_note(self, column, generate_hold)
}
_ => {
let column = self.get_random_column(0);
Pattern::new_end_time_note(self, column, generate_hold)
}
}
}
fn get_random_column(&mut self, lower: i32) -> u8 {
let column = self.inner.get_random_column(Some(lower), None);
if self.convert_type.contains(PatternType::FORCE_NOT_STACK) {
self.find_available_column(column, Some(lower), &[self.prev_pattern])
} else {
self.find_available_column(column, Some(lower), &[])
}
}
fn find_available_column(
&mut self,
mut initial_column: u8,
lower: Option<i32>,
// upper: Option<i32>,
// next_column: Option<&dyn Fn(u8) -> u8>,
// validation: Option<&dyn Fn(i32) -> bool>,
patterns: &[&Pattern],
) -> u8 {
let lower = lower.unwrap_or_else(|| self.inner.random_start());
let upper = self.inner.total_columns;
let is_valid = |column: i32| {
let column = column as u8;
patterns
.iter()
.all(|pattern| !pattern.column_has_obj(column))
};
// * Check for the initial column
if is_valid(i32::from(initial_column)) {
return initial_column;
}
// * Ensure that we have at least one free column, so that an endless loop is avoided
let has_valid_column = (lower..upper).any(is_valid);
assert!(has_valid_column);
// * Iterate until a valid column is found. This is a random iteration in the default case.
while {
initial_column = self.inner.get_random_column(Some(lower), Some(upper));
!is_valid(i32::from(initial_column))
} {}
initial_column
}
}
@@ -1,39 +1,41 @@
use rosu_map::section::hit_objects::hit_samples::HitSoundType;
use crate::{
beatmap::{
converts::mania::{
legacy_random::Random, pattern::Pattern, pattern_type::PatternType, PrevValues,
},
EffectPoint,
mania::{
convert::{pattern::Pattern, pattern_type::PatternType, PrevValues},
object::ManiaObject,
},
mania::ManiaObject,
parse::{HitObject, HitSound},
Beatmap,
model::{
beatmap::Beatmap,
control_point::{EffectPoint, TimingPoint},
hit_object::HitObject,
},
util::random::Random,
};
use super::PatternGenerator;
pub(crate) struct HitObjectPatternGenerator<'h> {
pub(crate) hit_object: &'h HitObject,
pub(crate) total_columns: i32,
pub(crate) sample: u8,
pub(crate) stair_type: PatternType,
pub struct HitObjectPatternGenerator<'h> {
pub sample: HitSoundType,
pub stair_type: PatternType,
pub inner: PatternGenerator<'h>,
convert_type: PatternType,
prev_pattern: &'h Pattern,
random: &'h mut Random,
orig: &'h Beatmap,
}
impl<'h> HitObjectPatternGenerator<'h> {
pub(crate) fn new(
pub fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
sample: u8,
sample: HitSoundType,
total_columns: i32,
prev: &'h PrevValues,
density: f64,
orig: &'h Beatmap,
) -> Self {
let timing_point = orig.timing_point_at(hit_object.start_time);
let beat_len = orig
.timing_point_at(hit_object.start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let pos_separation = (hit_object.pos - prev.pos).length();
let time_separation = hit_object.start_time - prev.time;
@@ -58,10 +60,10 @@ impl<'h> HitObjectPatternGenerator<'h> {
} else if time_separation <= 150.0 && pos_separation < 20.0 {
// * More than 100 BPM stream
convert_type |= PatternType::FORCE_STACK | PatternType::LOW_PROBABILITY;
} else if pos_separation < 20.0 && density >= timing_point.beat_len / 2.5 {
} else if pos_separation < 20.0 && density >= beat_len / 2.5 {
// * Low density stream
convert_type |= PatternType::REVERSE | PatternType::LOW_PROBABILITY;
} else if density < timing_point.beat_len / 2.5 {
} else if density < beat_len / 2.5 {
// * High density
} else {
let kiai = orig
@@ -76,36 +78,37 @@ impl<'h> HitObjectPatternGenerator<'h> {
}
if !convert_type.contains(PatternType::KEEP_SINGLE) {
if sample.finish() && total_columns != 8 {
if sample.has_flag(HitSoundType::FINISH) && total_columns != 8 {
convert_type |= PatternType::MIRROR;
} else if sample.clap() {
} else if sample.has_flag(HitSoundType::CLAP) {
convert_type |= PatternType::GATHERED;
}
}
let inner = PatternGenerator::new(hit_object, total_columns, random, orig);
Self {
hit_object,
stair_type: prev.stair,
convert_type,
total_columns,
sample,
prev_pattern: &prev.pattern,
random,
orig,
inner,
}
}
pub(crate) fn generate(&mut self) -> Pattern {
pub fn generate(&mut self) -> Pattern {
let pattern = self.generate_core();
for obj in pattern.hit_objects.iter() {
let col = ManiaObject::column(obj.pos.x, self.total_columns as f32) as i32;
let col = ManiaObject::column(obj.pos.x, self.inner.total_columns as f32) as i32;
if self.convert_type.contains(PatternType::STAIR) && col == self.total_columns - 1 {
if self.convert_type.contains(PatternType::STAIR) && col == self.inner.total_columns - 1
{
self.stair_type = PatternType::REVERSE_STAIR;
}
if self.convert_type.contains(PatternType::REVERSE_STAIR) && col == self.random_start()
if self.convert_type.contains(PatternType::REVERSE_STAIR)
&& col == self.inner.random_start()
{
self.stair_type = PatternType::STAIR;
}
@@ -115,24 +118,24 @@ impl<'h> HitObjectPatternGenerator<'h> {
}
fn generate_core(&mut self) -> Pattern {
if self.total_columns == 1 {
if self.inner.total_columns == 1 {
return Pattern::new_note(self, 0);
}
let last_column = self.prev_pattern.hit_objects.last().map_or(0, |h| {
ManiaObject::column(h.pos.x, self.total_columns as f32) as u8
ManiaObject::column(h.pos.x, self.inner.total_columns as f32) as u8
});
let random_start = self.random_start() as u8;
let random_start = self.inner.random_start() as u8;
if self.convert_type.contains(PatternType::REVERSE)
&& !self.prev_pattern.hit_objects.is_empty()
{
let mut pattern = Pattern::default();
for i in random_start..self.total_columns as u8 {
for i in random_start..self.inner.total_columns as u8 {
if self.prev_pattern.column_has_obj(i) {
pattern.add_note(self, random_start + self.total_columns as u8 - i - 1);
pattern.add_note(self, random_start + self.inner.total_columns as u8 - i - 1);
}
}
@@ -142,12 +145,12 @@ impl<'h> HitObjectPatternGenerator<'h> {
if self.convert_type.contains(PatternType::CYCLE)
&& self.prev_pattern.hit_objects.len() == 1
// * If we convert to 7K + 1, let's not overload the special key
&& (self.total_columns != 8 || last_column != 0)
&& (self.inner.total_columns != 8 || last_column != 0)
// * Make sure the last column was not the centre column
&& (self.total_columns % 2 == 0 || last_column != self.total_columns as u8 / 2)
&& (self.inner.total_columns % 2 == 0 || last_column != self.inner.total_columns as u8 / 2)
{
// * Generate a new pattern by cycling backwards (similar to Reverse but for only one hit object)
let column = random_start + self.total_columns as u8 - last_column - 1;
let column = random_start + self.inner.total_columns as u8 - last_column - 1;
return Pattern::new_note(self, column);
}
@@ -158,7 +161,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
let mut pattern = Pattern::default();
// * Generate a new pattern by placing on the already filled columns
for i in random_start..self.total_columns as u8 {
for i in random_start..self.inner.total_columns as u8 {
if self.prev_pattern.column_has_obj(i) {
pattern.add_note(self, i);
}
@@ -173,7 +176,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
// * cycling back to the start if there is no "next"
let mut target_column = last_column + 1;
if target_column == self.total_columns as u8 {
if target_column == self.inner.total_columns as u8 {
target_column = random_start;
}
@@ -186,7 +189,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
let mut target_column = last_column as i8 - 1;
if target_column == random_start as i8 - 1 {
target_column = self.total_columns as i8 - 1;
target_column = self.inner.total_columns as i8 - 1;
}
return Pattern::new_note(self, target_column as u8);
@@ -197,7 +200,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
return self.generate_random_notes(1);
}
let conversion_diff = self.conversion_difficulty();
let conversion_diff = self.inner.conversion_difficulty();
if self.convert_type.contains(PatternType::MIRROR) {
if conversion_diff > 6.5 {
@@ -236,30 +239,27 @@ impl<'h> HitObjectPatternGenerator<'h> {
let allow_stacking = !self.convert_type.contains(PatternType::FORCE_NOT_STACK);
if !allow_stacking {
note_count =
(self.total_columns - self.random_start() - self.prev_pattern.column_with_objs())
.min(note_count);
note_count = (self.inner.total_columns
- self.inner.random_start()
- self.prev_pattern.column_with_objs())
.min(note_count);
}
let mut next_column = self.get_column(Some(true));
let mut next_column = self.inner.get_column(Some(true));
for _ in 0..note_count {
next_column = if allow_stacking {
self.find_available_column(
next_column,
None,
None,
Some(Self::get_next_column),
None,
&[&pattern],
)
} else {
self.find_available_column(
next_column,
None,
None,
Some(Self::get_next_column),
None,
&[&pattern, self.prev_pattern],
)
};
@@ -274,25 +274,25 @@ impl<'h> HitObjectPatternGenerator<'h> {
if self.convert_type.contains(PatternType::GATHERED) {
last += 1;
if last == self.total_columns as u8 {
last = self.random_start() as u8;
if last == self.inner.total_columns as u8 {
last = self.inner.random_start() as u8;
}
} else {
last = PatternGenerator::get_random_column(self, None, None);
last = self.inner.get_random_column(None, None);
}
last
}
fn has_special_column(&self) -> bool {
self.sample.clap() && self.sample.finish()
const fn has_special_column(&self) -> bool {
self.sample.has_flag(HitSoundType::CLAP) && self.sample.has_flag(HitSoundType::FINISH)
}
fn generate_random_pattern(&mut self, p2: f64, p3: f64, p4: f64, p5: f64) -> Pattern {
let random_note_count = self.get_random_note_count(p2, p3, p4, p5);
let mut pattern = self.generate_random_notes(random_note_count);
if self.random_start() > 0 && self.has_special_column() {
if self.inner.random_start() > 0 && self.has_special_column() {
pattern.add_note(self, 0);
}
@@ -300,7 +300,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
}
fn get_random_note_count(&mut self, mut p2: f64, mut p3: f64, mut p4: f64, mut p5: f64) -> i32 {
match self.total_columns {
match self.inner.total_columns {
2 => {
p2 = 0.0;
p3 = 0.0;
@@ -327,11 +327,12 @@ impl<'h> HitObjectPatternGenerator<'h> {
_ => {}
}
if self.sample.clap() {
if self.sample.has_flag(HitSoundType::CLAP) {
p2 = 1.0;
}
PatternGenerator::get_random_note_count(self, p2, p3, Some(p4), Some(p5), None)
self.inner
.get_random_note_count(p2, p3, Some(p4), Some(p5), None)
}
fn generate_random_pattern_with_mirrored(
@@ -349,37 +350,32 @@ impl<'h> HitObjectPatternGenerator<'h> {
let (note_count, add_to_centre) =
self.get_random_note_count_mirrored(centre_probability, p2, p3);
let column_limit = if self.total_columns % 2 == 0 {
self.total_columns / 2
let column_limit = if self.inner.total_columns % 2 == 0 {
self.inner.total_columns / 2
} else {
(self.total_columns - 1) / 2
(self.inner.total_columns - 1) / 2
};
let mut next_column = PatternGenerator::get_random_column(self, None, Some(column_limit));
let mut next_column = self.inner.get_random_column(None, Some(column_limit));
for _ in 0..note_count {
next_column = self.find_available_column(
next_column,
None,
Some(column_limit),
None,
None,
&[&pattern],
);
next_column =
self.find_available_column(next_column, Some(column_limit), None, &[&pattern]);
// * Add normal note
pattern.add_note(self, next_column);
// * Add mirrored note
let column = (self.random_start() + self.total_columns) as u8 - next_column - 1;
let column =
(self.inner.random_start() + self.inner.total_columns) as u8 - next_column - 1;
pattern.add_note(self, column);
}
if add_to_centre {
pattern.add_note(self, self.total_columns as u8 / 2);
pattern.add_note(self, self.inner.total_columns as u8 / 2);
}
if self.random_start() > 0 && self.has_special_column() {
if self.inner.random_start() > 0 && self.has_special_column() {
pattern.add_note(self, 0);
}
@@ -392,7 +388,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
mut p2: f64,
mut p3: f64,
) -> (i32, bool) {
match self.total_columns {
match self.inner.total_columns {
2 => {
centre_probability = 0.0;
p2 = 0.0;
@@ -433,33 +429,53 @@ impl<'h> HitObjectPatternGenerator<'h> {
p2 = p2.clamp(0.0, 1.0);
p3 = p3.clamp(0.0, 1.0);
let centre_val = self.random.gen_double();
let note_count = PatternGenerator::get_random_note_count(self, p2, p3, None, None, None);
let add_to_centre =
self.total_columns % 2 != 0 && note_count != 3 && centre_val > 1.0 - centre_probability;
let centre_val = self.inner.random.next_double();
let note_count = self.inner.get_random_note_count(p2, p3, None, None, None);
let add_to_centre = self.inner.total_columns % 2 != 0
&& note_count != 3
&& centre_val > 1.0 - centre_probability;
(note_count, add_to_centre)
}
}
impl PatternGenerator for HitObjectPatternGenerator<'_> {
#[inline]
fn hit_object(&self) -> &HitObject {
self.hit_object
}
fn find_available_column(
&mut self,
mut initial_column: u8,
upper: Option<i32>,
next_column: Option<fn(&mut Self, u8) -> u8>,
patterns: &[&Pattern],
) -> u8 {
let lower = self.inner.random_start();
let upper = upper.unwrap_or(self.inner.total_columns);
#[inline]
fn total_columns(&self) -> i32 {
self.total_columns
}
let is_valid = |column: i32| {
let column = column as u8;
#[inline]
fn random(&mut self) -> &mut Random {
self.random
}
patterns
.iter()
.all(|pattern| !pattern.column_has_obj(column))
};
#[inline]
fn original_map(&self) -> &Beatmap {
self.orig
// * Check for the initial column
if is_valid(i32::from(initial_column)) {
return initial_column;
}
// * Ensure that we have at least one free column, so that an endless loop is avoided
let has_valid_column = (lower..upper).any(is_valid);
assert!(has_valid_column);
// * Iterate until a valid column is found. This is a random iteration in the default case.
while {
initial_column = if let Some(fun) = next_column {
(fun)(self, initial_column)
} else {
self.inner.get_random_column(Some(lower), Some(upper))
};
!is_valid(i32::from(initial_column))
} {}
initial_column
}
}
+106
View File
@@ -0,0 +1,106 @@
use crate::{
mania::object::ManiaObject,
model::{beatmap::Beatmap, hit_object::HitObject},
util::random::Random,
};
pub(super) mod distance_object;
pub(super) mod end_time_object;
pub(super) mod hit_object;
pub struct PatternGenerator<'a> {
pub hit_object: &'a HitObject,
pub total_columns: i32,
random: &'a mut Random,
original_map: &'a Beatmap,
}
impl<'a> PatternGenerator<'a> {
fn new(
hit_object: &'a HitObject,
total_columns: i32,
random: &'a mut Random,
original_map: &'a Beatmap,
) -> Self {
Self {
hit_object,
total_columns,
random,
original_map,
}
}
fn random_start(&self) -> i32 {
i32::from(self.total_columns == 8)
}
fn get_column(&self, allow_special: Option<bool>) -> u8 {
let allow_special = allow_special.unwrap_or(false);
if allow_special && self.total_columns == 8 {
const LOCAL_X_DIVISOR: f32 = 512.0 / 7.0;
((self.hit_object.pos.x / LOCAL_X_DIVISOR).floor() as u8).clamp(0, 6) + 1
} else {
ManiaObject::column(self.hit_object.pos.x, self.total_columns as f32) as u8
}
}
fn get_random_note_count(
&mut self,
p2: f64,
p3: f64,
p4: Option<f64>,
p5: Option<f64>,
p6: Option<f64>,
) -> i32 {
let p4 = p4.unwrap_or(0.0);
let p5 = p5.unwrap_or(0.0);
let p6 = p6.unwrap_or(0.0);
let val = self.random.next_double();
if val >= 1.0 - p6 {
6
} else if val >= 1.0 - p5 {
5
} else if val >= 1.0 - p4 {
4
} else if val >= 1.0 - p3 {
3
} else {
1 + i32::from(val >= 1.0 - p2)
}
}
fn conversion_difficulty(&self) -> f64 {
let orig = self.original_map;
let last_obj_time = orig.hit_objects.last().map_or(0.0, |h| h.start_time);
let first_obj_time = orig.hit_objects.first().map_or(0.0, |h| h.start_time);
// * Drain time in seconds
let total_break_time = orig.total_break_time();
let mut drain_time = ((last_obj_time - first_obj_time - total_break_time) / 1000.0) as i32;
if drain_time == 0 {
drain_time = 10_000;
}
let mut conversion_difficulty = 0.0;
conversion_difficulty += f64::from(orig.hp + orig.ar.clamp(4.0, 7.0)) / 1.5;
conversion_difficulty += orig.hit_objects.len() as f64 / f64::from(drain_time) * 9.0;
conversion_difficulty /= 38.0;
conversion_difficulty *= 5.0;
conversion_difficulty /= 1.15;
conversion_difficulty = conversion_difficulty.min(12.0);
conversion_difficulty
}
fn get_random_column(&mut self, lower: Option<i32>, upper: Option<i32>) -> u8 {
let lower = lower.unwrap_or_else(|| self.random_start());
let upper = upper.unwrap_or(self.total_columns);
self.random.next_int_range(lower, upper) as u8
}
}
@@ -4,23 +4,23 @@ use std::{
};
#[derive(Copy, Clone, Default)]
pub(crate) struct PatternType(u16);
pub struct PatternType(u16);
#[rustfmt::skip]
impl PatternType {
pub(crate) const FORCE_STACK: Self = Self(1 << 0);
pub(crate) const FORCE_NOT_STACK: Self = Self(1 << 1);
pub(crate) const KEEP_SINGLE: Self = Self(1 << 2);
pub(crate) const LOW_PROBABILITY: Self = Self(1 << 3);
// pub(crate) const ALTERNATE: Self = Self(1 << 4);
// pub(crate) const FORCE_SIG_SLIDER: Self = Self(1 << 5);
// pub(crate) const FORCE_NOT_SLIDER: Self = Self(1 << 6);
pub(crate) const GATHERED: Self = Self(1 << 7);
pub(crate) const MIRROR: Self = Self(1 << 8);
pub(crate) const REVERSE: Self = Self(1 << 9);
pub(crate) const CYCLE: Self = Self(1 << 10);
pub(crate) const STAIR: Self = Self(1 << 11);
pub(crate) const REVERSE_STAIR: Self = Self(1 << 12);
pub const FORCE_STACK: Self = Self(1 << 0);
pub const FORCE_NOT_STACK: Self = Self(1 << 1);
pub const KEEP_SINGLE: Self = Self(1 << 2);
pub const LOW_PROBABILITY: Self = Self(1 << 3);
// pub const ALTERNATE: Self = Self(1 << 4);
// pub const FORCE_SIG_SLIDER: Self = Self(1 << 5);
// pub const FORCE_NOT_SLIDER: Self = Self(1 << 6);
pub const GATHERED: Self = Self(1 << 7);
pub const MIRROR: Self = Self(1 << 8);
pub const REVERSE: Self = Self(1 << 9);
pub const CYCLE: Self = Self(1 << 10);
pub const STAIR: Self = Self(1 << 11);
pub const REVERSE_STAIR: Self = Self(1 << 12);
}
impl fmt::Display for PatternType {
@@ -66,7 +66,7 @@ impl fmt::Display for PatternType {
}
impl PatternType {
pub(crate) const fn contains(self, other: Self) -> bool {
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
}
@@ -74,21 +74,18 @@ impl PatternType {
impl BitOr for PatternType {
type Output = Self;
#[inline]
fn bitor(self, rhs: Self) -> Self::Output {
Self(self.0 | rhs.0)
}
}
impl BitOrAssign for PatternType {
#[inline]
fn bitor_assign(&mut self, rhs: Self) {
self.0 |= rhs.0;
}
}
impl BitAndAssign for PatternType {
#[inline]
fn bitand_assign(&mut self, rhs: Self) {
self.0 &= rhs.0;
}
@@ -97,7 +94,6 @@ impl BitAndAssign for PatternType {
impl Not for PatternType {
type Output = Self;
#[inline]
fn not(self) -> Self::Output {
Self(!self.0)
}
+89
View File
@@ -0,0 +1,89 @@
use crate::{
any::difficulty::{skills::Skill, ModeDifficulty},
mania::{
difficulty::{object::ManiaDifficultyObject, skills::strain::Strain},
object::{ManiaObject, ObjectParams},
},
util::float_ext::FloatExt,
};
use super::{attributes::ManiaDifficultyAttributes, convert::ManiaBeatmap};
mod object;
mod skills;
const STAR_SCALING_FACTOR: f64 = 0.018;
pub fn difficulty(
difficulty: &ModeDifficulty,
converted: &ManiaBeatmap<'_>,
) -> ManiaDifficultyAttributes {
let hit_window = converted
.attributes()
.mods(difficulty.get_mods())
.clock_rate(difficulty.get_clock_rate())
.hit_windows()
.od;
let n_objects = converted.map.hit_objects.len() as u32;
let values = DifficultyValues::calculate(difficulty, converted);
ManiaDifficultyAttributes {
stars: values.strain.difficulty_value() * STAR_SCALING_FACTOR,
hit_window,
max_combo: values.max_combo,
n_objects,
is_convert: converted.is_convert,
}
}
pub struct DifficultyValues {
pub strain: Strain,
pub max_combo: u32,
}
impl DifficultyValues {
pub fn calculate(difficulty: &ModeDifficulty, converted: &ManiaBeatmap<'_>) -> Self {
let take = difficulty.get_passed_objects();
let total_columns = converted.map.cs.round_even().max(1.0);
let clock_rate = difficulty.get_clock_rate();
let mut params = ObjectParams::new(converted.map.as_ref());
let mut mania_objects = converted
.map
.hit_objects
.iter()
.map(|h| ManiaObject::new(h, total_columns, &mut params))
.take(take);
let Some(first) = mania_objects.next() else {
return DifficultyValues {
strain: Strain::new(total_columns as usize),
max_combo: 0,
};
};
let n_diff_objects = mania_objects.len();
let diff_objects_iter = mania_objects.enumerate().scan(first, |last, (i, base)| {
let diff_object = ManiaDifficultyObject::new(&base, last, clock_rate, i);
*last = base;
Some(diff_object)
});
let mut diff_objects = Vec::with_capacity(n_diff_objects);
diff_objects.extend(diff_objects_iter);
let mut strain = Skill::new(Strain::new(total_columns as usize), &diff_objects);
for curr in diff_objects.iter() {
strain.process(curr);
}
Self {
strain: strain.inner,
max_combo: params.into_max_combo(),
}
}
}
+27
View File
@@ -0,0 +1,27 @@
use crate::{any::difficulty::object::IDifficultyObject, mania::object::ManiaObject};
pub struct ManiaDifficultyObject {
pub idx: usize,
pub base_column: usize,
pub delta_time: f64,
pub start_time: f64,
pub end_time: f64,
}
impl ManiaDifficultyObject {
pub fn new(base: &ManiaObject, last: &ManiaObject, clock_rate: f64, idx: usize) -> Self {
Self {
idx,
base_column: base.column,
delta_time: (base.start_time - last.start_time) / clock_rate,
start_time: base.start_time / clock_rate,
end_time: base.end_time / clock_rate,
}
}
}
impl IDifficultyObject for ManiaDifficultyObject {
fn idx(&self) -> usize {
self.idx
}
}
+1
View File
@@ -0,0 +1 @@
pub mod strain;
+186
View File
@@ -0,0 +1,186 @@
use crate::{
any::difficulty::{
object::IDifficultyObject,
skills::{strain_decay, ISkill, Skill, StrainDecaySkill},
},
mania::difficulty::object::ManiaDifficultyObject,
};
const INDIVIDUAL_DECAY_BASE: f64 = 0.125;
const OVERALL_DECAY_BASE: f64 = 0.3;
const RELEASE_THRESHOLD: f64 = 24.0;
const SKILL_MULTIPLIER: f64 = 1.0;
const STRAIN_DECAY_BASE: f64 = 1.0;
#[allow(clippy::struct_field_names)]
pub struct Strain {
start_times: Box<[f64]>,
end_times: Box<[f64]>,
individual_strains: Box<[f64]>,
individual_strain: f64,
overall_strain: f64,
inner: StrainDecaySkill,
}
impl Strain {
pub fn new(total_columns: usize) -> Self {
Self {
start_times: vec![0.0; total_columns].into_boxed_slice(),
end_times: vec![0.0; total_columns].into_boxed_slice(),
individual_strains: vec![0.0; total_columns].into_boxed_slice(),
individual_strain: 0.0,
overall_strain: 1.0,
inner: StrainDecaySkill::default(),
}
}
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub fn difficulty_value(self) -> f64 {
self.inner.difficulty_value(StrainDecaySkill::DECAY_WEIGHT)
}
const fn curr_strain(&self) -> f64 {
self.inner.curr_strain
}
fn curr_strain_mut(&mut self) -> &mut f64 {
&mut self.inner.curr_strain
}
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> 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()
}
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64 {
let mania_curr = curr;
let start_time = mania_curr.start_time;
let end_time = mania_curr.end_time;
let column = mania_curr.base_column;
let mut is_overlapping = false;
// * Lowest value we can assume with the current information
let mut closest_end_time = (end_time - start_time).abs();
// * Factor to all additional strains in case something else is held
let mut hold_factor = 1.0;
// * Addition to the current note in case it's a hold and has to be released awkwardly
let mut hold_addition = 0.0;
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;
// * We give a slight bonus to everything if something is held meanwhile
if self.end_times[i] > end_time + 1.0 {
hold_factor = 1.25;
}
closest_end_time = (end_time - self.end_times[i]).abs().min(closest_end_time);
}
// * The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
// * Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
// * holdAddition
// * ^
// * 1.0 + - - - - - -+-----------
// * | /
// * 0.5 + - - - - -/ Sigmoid Curve
// * | /|
// * 0.0 +--------+-+---------------> Release Difference / ms
// * release_threshold
if is_overlapping {
hold_addition = (1.0 + (0.5 * (RELEASE_THRESHOLD - closest_end_time)).exp()).recip();
}
// * Decay and increase individualStrains in own column
self.individual_strains[column] = apply_decay(
self.individual_strains[column],
start_time - self.start_times[column],
INDIVIDUAL_DECAY_BASE,
);
self.individual_strains[column] += 2.0 * hold_factor;
// * For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns
self.individual_strain = if mania_curr.delta_time <= 1.0 {
self.individual_strain.max(self.individual_strains[column])
} else {
self.individual_strains[column]
};
// * Decay and increase overallStrain
self.overall_strain = apply_decay(self.overall_strain, curr.delta_time, OVERALL_DECAY_BASE);
self.overall_strain += (1.0 + hold_addition) * hold_factor;
// * Update startTimes and endTimes arrays
self.start_times[column] = start_time;
self.end_times[column] = end_time;
// * By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section.
self.individual_strain + self.overall_strain - self.curr_strain()
}
}
impl ISkill for Strain {
type DifficultyObjects<'a> = [ManiaDifficultyObject];
}
impl Skill<'_, Strain> {
fn calculate_initial_strain(&mut self, offset: f64, curr: &ManiaDifficultyObject) -> f64 {
let prev_start_time = curr
.previous(0, self.diff_objects)
.map_or(0.0, |prev| prev.start_time);
let time = offset - prev_start_time;
let individual = apply_decay(self.inner.individual_strain, time, INDIVIDUAL_DECAY_BASE);
let overall = apply_decay(self.inner.overall_strain, time, OVERALL_DECAY_BASE);
individual + overall
}
const fn curr_section_peak(&self) -> f64 {
self.inner.inner.inner.curr_section_peak
}
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_peak
}
const fn curr_section_end(&self) -> f64 {
self.inner.inner.inner.curr_section_end
}
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_end
}
pub fn process(&mut self, curr: &ManiaDifficultyObject) {
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / StrainDecaySkill::SECTION_LEN).ceil()
* StrainDecaySkill::SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.inner.inner.save_curr_peak();
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
self.inner.inner.start_new_section_from(initial_strain);
*self.curr_section_end_mut() += StrainDecaySkill::SECTION_LEN;
}
let strain_value_at = self.inner.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
}
fn apply_decay(value: f64, delta_time: f64, decay_base: f64) -> f64 {
value * decay_base.powf(delta_time / 1000.0)
}
-22
View File
@@ -1,22 +0,0 @@
use super::mania_object::ManiaObject;
#[derive(Clone, Debug)]
pub(crate) struct ManiaDifficultyObject {
pub(crate) idx: usize,
pub(crate) base_column: usize,
pub(crate) delta_time: f64,
pub(crate) start_time: f64,
pub(crate) end_time: f64,
}
impl ManiaDifficultyObject {
pub(crate) fn new(base: &ManiaObject, last: &ManiaObject, clock_rate: f64, idx: usize) -> Self {
Self {
idx,
base_column: base.column,
delta_time: (base.start_time - last.start_time) / clock_rate,
start_time: base.start_time / clock_rate,
end_time: base.end_time / clock_rate,
}
}
}
-314
View File
@@ -1,314 +0,0 @@
#![cfg(feature = "gradual")]
use std::borrow::Cow;
use crate::{
beatmap::BeatmapHitWindows,
parse::{HitObject, HitObjectKind},
util::FloatExt,
Beatmap, GameMode, Mods,
};
use super::{
difficulty_object::ManiaDifficultyObject,
mania_object::ObjectParameters,
skills::{Skill, Strain},
ManiaDifficultyAttributes, ManiaObject, STAR_SCALING_FACTOR,
};
/// Gradually calculate the difficulty attributes of an osu!mania map.
///
/// Note that this struct implements [`Iterator`].
/// On every call of [`Iterator::next`], the map's next hit object will
/// be processed and the [`ManiaDifficultyAttributes`] will be updated and returned.
///
/// If you want to calculate performance attributes, use
/// [`ManiaGradualPerformance`](crate::mania::ManiaGradualPerformance) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, mania::ManiaGradualDifficulty};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = ManiaGradualDifficulty::new(&map, mods);
///
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
/// let attrs2 = iter.next(); // after the second hit object
///
/// // Remaining hit objects
/// for difficulty in iter {
/// // ...
/// }
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct ManiaGradualDifficulty<'map> {
map: Cow<'map, Beatmap>,
inner: ManiaGradualDifficultyInner,
}
impl<'map> ManiaGradualDifficulty<'map> {
/// Create a new difficulty attributes iterator for osu!mania maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let map = map.convert_mode(GameMode::Mania);
let is_convert = matches!(map, Cow::Owned(_));
let inner = ManiaGradualDifficultyInner::new(map.as_ref(), is_convert, mods);
Self { map, inner }
}
pub(crate) const fn idx(&self) -> usize {
self.inner.idx
}
}
impl Iterator for ManiaGradualDifficulty<'_> {
type Item = ManiaDifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
self.inner.next(&self.map.hit_objects)
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
#[inline]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
self.inner.nth(n, &self.map.hit_objects)
}
}
impl ExactSizeIterator for ManiaGradualDifficulty<'_> {
#[inline]
fn len(&self) -> usize {
self.inner.len()
}
}
/// Gradually calculate the difficulty attributes of an osu!mania map.
///
/// Check [`ManiaGradualDifficulty`] for more information. This struct does the same
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct ManiaOwnedGradualDifficulty {
// Technically only `Beatmap::hit_objects` are required here but storing
// the full map lets us get away with not storing the map in `ManiaOwnedGradualPerformance`.
pub(crate) map: Beatmap,
inner: ManiaGradualDifficultyInner,
}
impl ManiaOwnedGradualDifficulty {
/// Create a new owned difficulty attributes iterator for osu!mania maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let converted_map = map.convert_mode(GameMode::Mania);
let is_convert = matches!(converted_map, Cow::Owned(_));
let inner = ManiaGradualDifficultyInner::new(&converted_map, is_convert, mods);
let map = match converted_map {
Cow::Owned(map) => map,
Cow::Borrowed(_) => map,
};
Self { map, inner }
}
#[allow(unused)]
pub(crate) const fn idx(&self) -> usize {
self.inner.idx
}
}
impl Iterator for ManiaOwnedGradualDifficulty {
type Item = ManiaDifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
self.inner.next(&self.map.hit_objects)
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
#[inline]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
self.inner.nth(n, &self.map.hit_objects)
}
}
impl ExactSizeIterator for ManiaOwnedGradualDifficulty {
#[inline]
fn len(&self) -> usize {
self.inner.len()
}
}
#[derive(Clone, Debug)]
struct ManiaGradualDifficultyInner {
pub(crate) idx: usize,
hit_window: f64,
strain: Strain,
diff_objects: Box<[ManiaDifficultyObject]>,
curr_combo: usize,
clock_rate: f64,
is_convert: bool,
}
impl ManiaGradualDifficultyInner {
fn new(map: &Beatmap, is_convert: bool, mods: u32) -> Self {
let total_columns = map.cs.round_even().max(1.0);
let clock_rate = mods.clock_rate();
let strain = Strain::new(total_columns as usize);
let BeatmapHitWindows { od: hit_window, .. } = map
.attributes()
.mods(mods)
.converted(is_convert)
.clock_rate(clock_rate)
.hit_windows();
let mut params = ObjectParameters::new(map);
let mut curr_combo = 0;
let mut hit_objects = map.hit_objects.iter();
let first = match hit_objects.next() {
Some(h) => {
let hit_object = ManiaObject::new(h, total_columns, &mut params);
increment_combo_raw(
h,
hit_object.start_time,
hit_object.end_time,
&mut curr_combo,
);
hit_object
}
None => {
return Self {
idx: 0,
hit_window,
strain,
diff_objects: Box::from([]),
curr_combo: 0,
clock_rate,
is_convert,
}
}
};
let diff_objects_iter = hit_objects.enumerate().scan(first, |last, (i, h)| {
let base = ManiaObject::new(h, total_columns, &mut params);
let diff_object = ManiaDifficultyObject::new(&base, &*last, clock_rate, i);
*last = base;
Some(diff_object)
});
let mut diff_objects = Vec::with_capacity(map.hit_objects.len() - 1);
diff_objects.extend(diff_objects_iter);
debug_assert_eq!(diff_objects.len(), diff_objects.capacity());
Self {
idx: 0,
hit_window,
strain,
diff_objects: diff_objects.into_boxed_slice(),
curr_combo,
clock_rate,
is_convert,
}
}
fn next(&mut self, hit_objects: &[HitObject]) -> Option<ManiaDifficultyAttributes> {
// The first difficulty object belongs to the second note since each difficulty
// object requires the current and the last note. Hence, if we're still on the first
// object, we don't have a difficulty object yet and just skip processing.
if self.idx > 0 {
let curr = self.diff_objects.get(self.idx - 1)?;
self.strain.process(curr, &self.diff_objects);
let h = &hit_objects[self.idx];
increment_combo(h, curr, &mut self.curr_combo, self.clock_rate);
} else if hit_objects.is_empty() {
return None;
}
self.idx += 1;
Some(ManiaDifficultyAttributes {
stars: self.strain.clone().difficulty_value() * STAR_SCALING_FACTOR,
hit_window: self.hit_window,
max_combo: self.curr_combo,
is_convert: self.is_convert,
n_objects: self.diff_objects.len() + 1,
})
}
const fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
fn nth(&mut self, n: usize, hit_objects: &[HitObject]) -> Option<ManiaDifficultyAttributes> {
let skip_iter = self
.diff_objects
.iter()
.zip(hit_objects.iter().skip(1))
.skip(self.idx.saturating_sub(1));
let mut take = n.min(self.len().saturating_sub(1));
// The first note has no difficulty object
if self.idx == 0 && take > 0 {
take -= 1;
self.idx += 1;
}
for (curr, h) in skip_iter.take(take) {
increment_combo(h, curr, &mut self.curr_combo, self.clock_rate);
self.strain.process(curr, &self.diff_objects);
self.idx += 1;
}
self.next(hit_objects)
}
const fn len(&self) -> usize {
self.diff_objects.len() + 1 - self.idx
}
}
fn increment_combo(
h: &HitObject,
diff_obj: &ManiaDifficultyObject,
curr_combo: &mut usize,
clock_rate: f64,
) {
increment_combo_raw(
h,
diff_obj.start_time * clock_rate,
diff_obj.end_time * clock_rate,
curr_combo,
);
}
fn increment_combo_raw(h: &HitObject, start_time: f64, end_time: f64, curr_combo: &mut usize) {
match h.kind {
HitObjectKind::Circle => *curr_combo += 1,
_ => *curr_combo += 1 + ((end_time - start_time) / 100.0) as usize,
}
}
-178
View File
@@ -1,178 +0,0 @@
#![cfg(feature = "gradual")]
use crate::{Beatmap, ManiaPP};
use super::{
ManiaGradualDifficulty, ManiaOwnedGradualDifficulty, ManiaPerformanceAttributes,
ManiaScoreState,
};
/// Gradually calculate the performance attributes of an osu!mania map.
///
/// After each hit object you can call [`next`](`ManiaGradualPerformance::next`)
/// and it will return the resulting current [`ManiaPerformanceAttributes`].
/// To process multiple objects at once, use [`nth`](`ManiaGradualPerformance::nth`) instead.
///
/// Both methods require a play's current score so far.
/// Be sure the given score is adjusted with respect to mods.
///
/// If you only want to calculate difficulty attributes use
/// [`ManiaGradualDifficulty`] instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, mania::{ManiaGradualPerformance, ManiaScoreState}};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = ManiaGradualPerformance::new(&map, mods);
/// let mut state = ManiaScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are 320s
/// for _ in 0..10 {
/// state.n320 += 1;
///
/// # /*
/// let performance = gradual_perf.next(score).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
/// }
///
/// // Then comes a miss.
/// state.n_misses += 1;
/// # /*
/// let performance = gradual_perf.next(score).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
///
/// // The next 10 objects will be a mixture of 320s and 100s.
/// // Notice how all 10 objects will be processed in one go.
/// state.n320 += 3;
/// state.n100 += 7;
/// // The `nth` method takes a zero-based value.
/// # /*
/// let performance = gradual_perf.nth(score, 9).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.nth(state.clone(), 9);
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n300 = ...
/// state.n100 = ...
/// state.n_misses = ...
/// let final_performance = gradual_perf.nth(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.nth(state.clone(), usize::MAX);
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.next(state).is_none());
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct ManiaGradualPerformance<'map> {
difficulty: ManiaGradualDifficulty<'map>,
performance: ManiaPP<'map>,
}
impl<'map> ManiaGradualPerformance<'map> {
/// Create a new gradual performance calculator for osu!mania maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let difficulty = ManiaGradualDifficulty::new(map, mods);
let performance = ManiaPP::new(map).mods(mods).passed_objects(0);
Self {
difficulty,
performance,
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score.
pub fn next(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
pub fn nth(&mut self, state: ManiaScoreState, n: usize) -> Option<ManiaPerformanceAttributes> {
let difficulty = self.difficulty.nth(n)?;
let performance = self
.performance
.clone()
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx())
.calculate();
Some(performance)
}
}
/// Gradually calculate the performance attributes of an osu!mania map.
///
/// Check [`ManiaGradualPerformance`] for more information. This struct does the same
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct ManiaOwnedGradualPerformance {
difficulty: ManiaOwnedGradualDifficulty,
mods: u32,
}
impl ManiaOwnedGradualPerformance {
/// Create a new gradual performance calculator for osu!mania maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let difficulty = ManiaOwnedGradualDifficulty::new(map, mods);
Self { difficulty, mods }
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score.
pub fn next(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
pub fn nth(&mut self, state: ManiaScoreState, n: usize) -> Option<ManiaPerformanceAttributes> {
let difficulty = self.difficulty.nth(n)?;
let performance = ManiaPP::new(&self.difficulty.map)
.mods(self.mods)
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx())
.calculate();
Some(performance)
}
}
-103
View File
@@ -1,103 +0,0 @@
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind},
Beatmap,
};
const BASE_SCORING_DISTANCE: f64 = 100.0;
pub(crate) struct ObjectParameters<'a> {
pub(crate) map: &'a Beatmap,
pub(crate) max_combo: usize,
pub(crate) curve_bufs: CurveBuffers,
}
impl<'a> ObjectParameters<'a> {
pub(crate) fn new(map: &'a Beatmap) -> Self {
Self {
map,
max_combo: 0,
curve_bufs: CurveBuffers::default(),
}
}
}
/// A [`HitObject`] that was processed for the osu!mania gamemode.
#[derive(Clone, Debug, PartialEq)]
pub struct ManiaObject {
/// Start time of the object.
pub start_time: f64,
/// Endtime of the object.
pub end_time: f64,
/// Column of the object.
pub column: usize,
}
impl ManiaObject {
pub(crate) fn column(x: f32, total_columns: f32) -> usize {
let x_divisor = 512.0 / total_columns;
(x / x_divisor).floor().min(total_columns - 1.0) as usize
}
pub(crate) fn new(
h: &HitObject,
total_columns: f32,
params: &mut ObjectParameters<'_>,
) -> Self {
let ObjectParameters {
map,
max_combo,
curve_bufs,
} = params;
let column = Self::column(h.pos.x, total_columns);
*max_combo += 1;
match &h.kind {
HitObjectKind::Circle => Self {
start_time: h.start_time,
end_time: h.start_time,
column,
},
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
..
} => {
let span_count = *repeats as f64 + 1.0;
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
let dist = curve.dist();
let timing_point = map.timing_point_at(h.start_time);
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
let scoring_dist =
BASE_SCORING_DISTANCE * map.slider_mult * difficulty_point.slider_vel;
let vel = scoring_dist / timing_point.beat_len;
let duration = span_count * dist / vel;
let end_time = h.start_time + duration;
*max_combo += (duration / 100.0) as usize;
Self {
start_time: h.start_time,
end_time,
column,
}
}
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
*max_combo += ((*end_time - h.start_time) / 100.0) as usize;
Self {
start_time: h.start_time,
end_time: *end_time,
column,
}
}
}
}
}
+34 -329
View File
@@ -1,345 +1,50 @@
mod difficulty_object;
mod mania_object;
mod pp;
mod score_state;
mod skills;
#[cfg(feature = "gradual")]
mod gradual_difficulty;
#[cfg(feature = "gradual")]
mod gradual_performance;
use std::borrow::Cow;
use crate::{beatmap::BeatmapHitWindows, util::FloatExt, Beatmap, GameMode, Mods, OsuStars};
use crate::{
any::ModeDifficulty,
model::{
beatmap::Beatmap,
mode::{ConvertStatus, IGameMode},
},
};
pub use self::{mania_object::ManiaObject, pp::*, score_state::ManiaScoreState};
#[cfg(feature = "gradual")]
pub use self::{
gradual_difficulty::{ManiaGradualDifficulty, ManiaOwnedGradualDifficulty},
gradual_performance::{ManiaGradualPerformance, ManiaOwnedGradualPerformance},
attributes::{ManiaDifficultyAttributes, ManiaPerformanceAttributes},
convert::ManiaBeatmap,
performance::ManiaPerformance,
score_state::ManiaScoreState,
strains::ManiaStrains,
};
pub(crate) use self::mania_object::ObjectParameters;
mod attributes;
mod convert;
mod difficulty;
mod object;
mod performance;
mod score_state;
mod strains;
use self::{
difficulty_object::ManiaDifficultyObject,
skills::{Skill, Strain},
};
const SECTION_LEN: f64 = 400.0;
const STAR_SCALING_FACTOR: f64 = 0.018;
/// Difficulty calculator on osu!mania maps.
/// Marker type for [`GameMode::Mania`].
///
/// # Example
///
/// ```
/// use rosu_pp::{ManiaStars, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let difficulty_attrs = ManiaStars::new(&map)
/// .mods(8 + 64) // HDDT
/// .calculate();
///
/// println!("Stars: {}", difficulty_attrs.stars);
/// ```
#[derive(Clone, Debug)]
#[must_use]
pub struct ManiaStars<'map> {
map: Cow<'map, Beatmap>,
mods: u32,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
is_convert: bool,
}
/// [`GameMode::Mania`]: rosu_map::section::general::GameMode::Mania
pub struct Mania;
impl<'map> ManiaStars<'map> {
/// Create a new difficulty calculator for osu!mania maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
let map = map.convert_mode(GameMode::Mania);
let is_convert = matches!(map, Cow::Owned(_));
impl IGameMode for Mania {
type DifficultyAttributes = ManiaDifficultyAttributes;
type Strains = ManiaStrains;
Self {
map,
mods: 0,
passed_objects: None,
clock_rate: None,
is_convert,
}
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
convert::try_convert(map)
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
fn difficulty(
difficulty: &ModeDifficulty,
converted: &ManiaBeatmap<'_>,
) -> Self::DifficultyAttributes {
difficulty::difficulty(difficulty, converted)
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
#[cfg_attr(
feature = "gradual",
doc = "If you want to calculate the difficulty after every few objects, instead of
using [`ManiaStars`] multiple times with different `passed_objects`, you should use
[`ManiaGradualDifficultyAttributes`](crate::mania::ManiaGradualDifficulty)."
)]
#[inline]
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects = Some(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Specify whether the map is a convert i.e. an osu!standard map.
///
/// This only needs to be specified if the map was converted manually beforehand.
#[inline]
pub const fn is_convert(mut self, is_convert: bool) -> Self {
self.is_convert = is_convert;
self
}
/// Calculate all difficulty related values, including stars.
#[inline]
pub fn calculate(self) -> ManiaDifficultyAttributes {
let is_convert = self.is_convert || matches!(self.map, Cow::Owned(_));
let clock_rate = self.clock_rate.unwrap_or_else(|| self.mods.clock_rate());
let BeatmapHitWindows { od: hit_window, .. } = self
.map
.attributes()
.mods(self.mods)
.converted(is_convert)
.clock_rate(clock_rate)
.hit_windows();
let n_objects = self.map.hit_objects.len();
let ManiaResult { strain, max_combo } = calculate_result(self);
ManiaDifficultyAttributes {
stars: strain.difficulty_value() * STAR_SCALING_FACTOR,
hit_window,
max_combo,
n_objects,
is_convert,
}
}
/// Calculate the skill strains.
///
/// Suitable to plot the difficulty of a map over time.
#[inline]
pub fn strains(self) -> ManiaStrains {
let ManiaResult { strain, .. } = calculate_result(self);
ManiaStrains {
section_len: SECTION_LEN,
strains: strain.strain_peaks.to_vec(),
}
}
}
/// The result of calculating the strains on a osu!taiko map.
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug)]
pub struct ManiaStrains {
/// Time in ms inbetween two strains.
pub section_len: f64,
/// Strain peaks of the strain skill.
pub strains: Vec<f64>,
}
impl ManiaStrains {
/// Returns the number of strain peaks per skill.
#[inline]
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.strains.len()
}
}
fn calculate_result(params: ManiaStars<'_>) -> ManiaResult {
let ManiaStars {
map,
mods,
passed_objects,
clock_rate,
is_convert: _,
} = params;
let take = passed_objects.unwrap_or(map.hit_objects.len());
let total_columns = map.cs.round_even().max(1.0);
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
let mut strain = Strain::new(total_columns as usize);
let mut params = ObjectParameters::new(map.as_ref());
let mut hit_objects = map.hit_objects.iter().take(take);
let first = match hit_objects.next() {
Some(h) => ManiaObject::new(h, total_columns, &mut params),
None => {
return ManiaResult {
strain,
max_combo: 0,
}
}
};
let diff_objects_iter = hit_objects.enumerate().scan(first, |last, (i, h)| {
let base = ManiaObject::new(h, total_columns, &mut params);
let diff_object = ManiaDifficultyObject::new(&base, &*last, clock_rate, i);
*last = base;
Some(diff_object)
});
let mut diff_objects = Vec::with_capacity(map.hit_objects.len().min(take).saturating_sub(1));
diff_objects.extend(diff_objects_iter);
for curr in diff_objects.iter() {
strain.process(curr, &diff_objects);
}
ManiaResult {
strain,
max_combo: params.max_combo,
}
}
struct ManiaResult {
strain: Strain,
max_combo: usize,
}
/// The result of a difficulty calculation on an osu!mania map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ManiaDifficultyAttributes {
/// The final star rating.
pub stars: f64,
/// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc).
pub hit_window: f64,
/// The amount of hitobjects in the map.
pub n_objects: usize,
/// The maximum achievable combo.
pub max_combo: usize,
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
pub is_convert: bool,
}
impl ManiaDifficultyAttributes {
/// Return the maximum combo.
#[inline]
pub const fn max_combo(&self) -> usize {
self.max_combo
}
/// Return the amount of hitobjects.
#[inline]
pub const fn n_objects(&self) -> usize {
self.n_objects
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
#[inline]
pub const fn is_convert(&self) -> bool {
self.is_convert
}
/// Returns a builder for performance calculation.
#[inline]
pub fn pp(self) -> ManiaPP<'static> {
ManiaPP::from(self)
}
}
/// The result of a performance calculation on an osu!mania map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ManiaPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation.
pub difficulty: ManiaDifficultyAttributes,
/// The final performance points.
pub pp: f64,
/// The difficulty portion of the final pp.
pub pp_difficulty: f64,
}
impl ManiaPerformanceAttributes {
/// Return the star value.
#[inline]
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
#[inline]
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
#[inline]
pub const fn max_combo(&self) -> usize {
self.difficulty.max_combo
}
/// Return the amount of hitobjects.
#[inline]
pub const fn n_objects(&self) -> usize {
self.difficulty.n_objects
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
#[inline]
pub const fn is_convert(&self) -> bool {
self.difficulty.is_convert
}
}
impl From<ManiaPerformanceAttributes> for ManiaDifficultyAttributes {
#[inline]
fn from(attributes: ManiaPerformanceAttributes) -> Self {
attributes.difficulty
}
}
impl<'map> From<OsuStars<'map>> for ManiaStars<'map> {
#[inline]
fn from(osu: OsuStars<'map>) -> Self {
let OsuStars {
map,
mods,
passed_objects,
clock_rate,
} = osu;
Self {
map: map.convert_mode(GameMode::Mania),
mods,
passed_objects,
clock_rate,
is_convert: true,
}
fn strains(difficulty: &ModeDifficulty, converted: &ManiaBeatmap<'_>) -> Self::Strains {
strains::strains(difficulty, converted)
}
}
+95
View File
@@ -0,0 +1,95 @@
use rosu_map::section::hit_objects::CurveBuffers;
use crate::model::{
beatmap::Beatmap,
control_point::{DifficultyPoint, TimingPoint},
hit_object::{HitObject, HitObjectKind, HoldNote, Spinner},
};
pub struct ManiaObject {
pub start_time: f64,
pub end_time: f64,
pub column: usize,
}
impl ManiaObject {
pub fn new(h: &HitObject, total_columns: f32, params: &mut ObjectParams<'_>) -> Self {
let column = Self::column(h.pos.x, total_columns);
params.max_combo += 1;
match h.kind {
HitObjectKind::Circle => Self {
start_time: h.start_time,
end_time: h.start_time,
column,
},
HitObjectKind::Slider(ref slider) => {
const BASE_SCORING_DIST: f32 = 100.0;
let dist = slider.curve(&mut params.curve_bufs).dist();
let beat_len = params
.map
.timing_point_at(h.start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let slider_velocity = params
.map
.difficulty_point_at(h.start_time)
.map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
point.slider_velocity
});
let scoring_dist =
f64::from(BASE_SCORING_DIST) * params.map.slider_multiplier * slider_velocity;
let velocity = scoring_dist / beat_len;
let duration = (slider.span_count() as f64) * dist / velocity;
params.max_combo += (duration / 100.0) as u32;
Self {
start_time: h.start_time,
end_time: h.start_time + duration,
column,
}
}
HitObjectKind::Spinner(Spinner { end_time })
| HitObjectKind::Hold(HoldNote { end_time }) => {
params.max_combo += ((end_time - h.start_time) / 100.0) as u32;
Self {
start_time: h.start_time,
end_time,
column,
}
}
}
}
pub fn column(x: f32, total_columns: f32) -> usize {
let x_divisor = 512.0 / total_columns;
(x / x_divisor).floor().min(total_columns - 1.0) as usize
}
}
pub struct ObjectParams<'a> {
map: &'a Beatmap,
max_combo: u32,
curve_bufs: CurveBuffers,
}
impl<'a> ObjectParams<'a> {
pub fn new(map: &'a Beatmap) -> Self {
Self {
map,
max_combo: 0,
curve_bufs: CurveBuffers::default(),
}
}
pub fn into_max_combo(self) -> u32 {
self.max_combo
}
}
File diff suppressed because it is too large Load Diff
+10 -13
View File
@@ -1,35 +1,32 @@
/// Aggregation for a score's current state i.e. what are the current hitresults.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
/// Aggregation for a score's current state.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ManiaScoreState {
/// Amount of current 320s.
pub n320: usize,
pub n320: u32,
/// Amount of current 300s.
pub n300: usize,
pub n300: u32,
/// Amount of current 200s.
pub n200: usize,
pub n200: u32,
/// Amount of current 100s.
pub n100: usize,
pub n100: u32,
/// Amount of current 50s.
pub n50: usize,
pub n50: u32,
/// Amount of current misses.
pub n_misses: usize,
pub n_misses: u32,
}
impl ManiaScoreState {
/// Create a new empty score state.
#[inline]
pub fn new() -> Self {
Self::default()
}
/// Return the total amount of hits by adding everything up.
#[inline]
pub const fn total_hits(&self) -> usize {
pub const fn total_hits(&self) -> u32 {
self.n320 + self.n300 + self.n200 + self.n100 + self.n50 + self.n_misses
}
/// Calculate the accuracy between `0.0` and `1.0` for this state.
#[inline]
pub fn accuracy(&self) -> f64 {
let total_hits = self.total_hits();
@@ -40,6 +37,6 @@ impl ManiaScoreState {
let numerator = 6 * (self.n320 + self.n300) + 4 * self.n200 + 2 * self.n100 + self.n50;
let denominator = 6 * total_hits;
numerator as f64 / denominator as f64
f64::from(numerator) / f64::from(denominator)
}
}
-18
View File
@@ -1,18 +0,0 @@
mod strain;
mod traits;
pub(crate) use self::{
strain::Strain,
traits::{Skill, StrainDecaySkill, StrainSkill},
};
use super::difficulty_object::ManiaDifficultyObject;
fn previous(
diff_objects: &[ManiaDifficultyObject],
curr: usize,
backwards_idx: usize,
) -> Option<&ManiaDifficultyObject> {
curr.checked_sub(backwards_idx + 1)
.and_then(|idx| diff_objects.get(idx))
}
-211
View File
@@ -1,211 +0,0 @@
use crate::{mania::difficulty_object::ManiaDifficultyObject, util::CompactVec};
use super::{previous, Skill, StrainDecaySkill, StrainSkill};
#[derive(Clone, Debug)]
#[allow(clippy::struct_field_names)]
pub(crate) struct Strain {
start_times: Vec<f64>,
end_times: Vec<f64>,
individual_strains: Vec<f64>,
individual_strain: f64,
overall_strain: f64,
curr_strain: f64,
curr_section_peak: f64,
curr_section_end: f64,
pub(crate) strain_peaks: CompactVec,
}
impl Strain {
const INDIVIDUAL_DECAY_BASE: f64 = 0.125;
const OVERALL_DECAY_BASE: f64 = 0.3;
const RELEASE_THRESHOLD: f64 = 24.0;
pub(crate) fn new(total_columns: usize) -> Self {
Self {
start_times: vec![0.0; total_columns],
end_times: vec![0.0; total_columns],
individual_strains: vec![0.0; total_columns],
individual_strain: 0.0,
overall_strain: 1.0,
curr_strain: 0.0,
curr_section_peak: 0.0,
curr_section_end: 0.0,
strain_peaks: CompactVec::new(),
}
}
fn apply_decay(value: f64, delta_time: f64, decay_base: f64) -> f64 {
value * decay_base.powf(delta_time / 1000.0)
}
}
impl Skill for Strain {
#[inline]
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]) {
<Self as StrainSkill>::process(self, curr, diff_objects);
}
#[inline]
fn difficulty_value(self) -> f64 {
<Self as StrainSkill>::difficulty_value(self)
}
}
impl StrainSkill for Strain {
const DECAY_WEIGHT: f64 = 0.9;
#[inline]
fn curr_section_end(&self) -> f64 {
self.curr_section_end
}
#[inline]
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.curr_section_end
}
#[inline]
fn curr_section_peak(&self) -> f64 {
self.curr_section_peak
}
#[inline]
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.curr_section_peak
}
#[inline]
fn strain_peaks_mut(&mut self) -> &mut CompactVec {
&mut self.strain_peaks
}
#[inline]
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64 {
<Self as StrainDecaySkill>::strain_value_at(self, curr)
}
#[inline]
fn calculate_initial_strain(
&self,
time: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) -> f64 {
<Self as StrainDecaySkill>::calculate_initial_strain(self, time, curr, diff_objects)
}
}
impl StrainDecaySkill for Strain {
const SKILL_MULTIPLIER: f64 = 1.0;
const STRAIN_DECAY_BASE: f64 = 1.0;
#[inline]
fn curr_strain(&self) -> f64 {
self.curr_strain
}
#[inline]
fn curr_strain_mut(&mut self) -> &mut f64 {
&mut self.curr_strain
}
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64 {
let mania_curr = curr;
let start_time = mania_curr.start_time;
let end_time = mania_curr.end_time;
let col = mania_curr.base_column;
let mut is_overlapping = false;
// * Lowest value we can assume with the current information
let mut closest_end_time = (end_time - start_time).abs();
// * Factor to all additional strains in case something else is held
let mut hold_factor = 1.0;
// * Addition to the current note in case it's a hold and has to be released awkwardly
let mut hold_addition = 0.0;
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;
// * We give a slight bonus to everything if something is held meanwhile
if self.end_times[i] > end_time + 1.0 {
hold_factor = 1.25;
}
closest_end_time = (end_time - self.end_times[i]).abs().min(closest_end_time);
}
// * The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
// * Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
// * holdAddition
// * ^
// * 1.0 + - - - - - -+-----------
// * | /
// * 0.5 + - - - - -/ Sigmoid Curve
// * | /|
// * 0.0 +--------+-+---------------> Release Difference / ms
// * release_threshold
if is_overlapping {
hold_addition =
(1.0 + (0.5 * (Self::RELEASE_THRESHOLD - closest_end_time)).exp()).recip();
}
// * Decay and increase individualStrains in own column
self.individual_strains[col] = Self::apply_decay(
self.individual_strains[col],
start_time - self.start_times[col],
Self::INDIVIDUAL_DECAY_BASE,
);
self.individual_strains[col] += 2.0 * hold_factor;
// * For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns
self.individual_strain = if mania_curr.delta_time <= 1.0 {
self.individual_strain.max(self.individual_strains[col])
} else {
self.individual_strains[col]
};
// * Decay and increase overallStrain
self.overall_strain = Self::apply_decay(
self.overall_strain,
curr.delta_time,
Self::OVERALL_DECAY_BASE,
);
self.overall_strain += (1.0 + hold_addition) * hold_factor;
// * Update startTimes and endTimes arrays
self.start_times[col] = start_time;
self.end_times[col] = end_time;
// * By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section.
self.individual_strain + self.overall_strain - self.curr_strain
}
fn calculate_initial_strain(
&self,
offset: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) -> f64 {
let prev_start = previous(diff_objects, curr.idx, 0).map_or(0.0, |h| h.start_time);
let individual_decay = Self::apply_decay(
self.individual_strain,
offset - prev_start,
Self::INDIVIDUAL_DECAY_BASE,
);
let overall_decay = Self::apply_decay(
self.overall_strain,
offset - prev_start,
Self::OVERALL_DECAY_BASE,
);
individual_decay + overall_decay
}
}
-129
View File
@@ -1,129 +0,0 @@
use std::{cmp::Ordering, mem};
use crate::{
mania::{difficulty_object::ManiaDifficultyObject, SECTION_LEN},
util::CompactVec,
};
pub(crate) trait Skill {
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]);
fn difficulty_value(self) -> f64;
}
pub(crate) trait StrainSkill: Sized + Skill {
const DECAY_WEIGHT: f64 = 0.9;
fn curr_section_end(&self) -> f64;
fn curr_section_end_mut(&mut self) -> &mut f64;
fn curr_section_peak(&self) -> f64;
fn curr_section_peak_mut(&mut self) -> &mut f64;
fn strain_peaks_mut(&mut self) -> &mut CompactVec;
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64;
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]) {
// * The first object doesn't generate a strain, so we begin with an incremented section end
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / SECTION_LEN).ceil() * SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.save_curr_peak();
self.start_new_section_from(self.curr_section_end(), curr, diff_objects);
*self.curr_section_end_mut() += SECTION_LEN;
// Optimization to finish the loop early if
// the current peak is 0.0 i.e. it can't decay further.
// If final values don't coincide perfectly anymore,
// this should be looked at and maybe adjusted.
if self.curr_section_peak().abs() <= f64::EPSILON
&& curr.start_time > self.curr_section_end()
{
let remaining_time = curr.start_time - self.curr_section_end();
let remaining_iters = (remaining_time / SECTION_LEN).ceil();
*self.curr_section_end_mut() += remaining_iters * SECTION_LEN;
}
}
*self.curr_section_peak_mut() = self.strain_value_at(curr).max(self.curr_section_peak());
}
fn save_curr_peak(&mut self) {
let curr_section_peak = self.curr_section_peak();
self.strain_peaks_mut().push(curr_section_peak);
}
fn start_new_section_from(
&mut self,
time: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) {
*self.curr_section_peak_mut() = self.calculate_initial_strain(time, curr, diff_objects);
}
fn calculate_initial_strain(
&self,
time: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) -> f64;
fn get_curr_strain_peaks(mut self) -> CompactVec {
let mut peaks = mem::take(self.strain_peaks_mut());
peaks.push(self.curr_section_peak());
peaks
}
fn difficulty_value(self) -> 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 mut peaks = self.get_curr_strain_peaks();
peaks.retain(|peak| peak > 0.0);
let mut peaks = peaks.to_vec();
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
// * Difficulty is the weighted sum of the highest strains from every section.
// * We're sorting from highest to lowest strain.
for strain in peaks {
difficulty += strain * weight;
weight *= Self::DECAY_WEIGHT;
}
difficulty
}
}
pub(crate) trait StrainDecaySkill: StrainSkill {
const SKILL_MULTIPLIER: f64;
const STRAIN_DECAY_BASE: f64;
fn curr_strain(&self) -> f64;
fn curr_strain_mut(&mut self) -> &mut f64;
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64;
fn calculate_initial_strain(
&self,
time: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) -> f64;
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64 {
*self.curr_strain_mut() *= self.strain_decay(curr.delta_time);
*self.curr_strain_mut() += self.strain_value_of(curr) * Self::SKILL_MULTIPLIER;
self.curr_strain()
}
fn strain_decay(&self, ms: f64) -> f64 {
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
}
}
+25
View File
@@ -0,0 +1,25 @@
use crate::{any::ModeDifficulty, mania::difficulty::DifficultyValues};
use super::convert::ManiaBeatmap;
/// The result of calculating the strains on a osu!mania map.
///
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug, PartialEq)]
pub struct ManiaStrains {
/// Strain peaks of the strain skill.
pub strains: Vec<f64>,
}
impl ManiaStrains {
/// Time between two strains in ms.
pub const SECTION_LEN: f64 = 400.0;
}
pub fn strains(difficulty: &ModeDifficulty, converted: &ManiaBeatmap<'_>) -> ManiaStrains {
let values = DifficultyValues::calculate(difficulty, converted);
ManiaStrains {
strains: values.strain.get_curr_strain_peaks(),
}
}
@@ -1,4 +1,8 @@
use crate::{Beatmap, GameMode, Mods};
use rosu_map::section::general::GameMode;
use crate::util::mods::Mods;
use super::{converted::Converted, Beatmap};
/// Summary struct for a [`Beatmap`]'s attributes.
#[derive(Clone, Debug, PartialEq)]
@@ -14,30 +18,29 @@ pub struct BeatmapAttributes {
/// The clock rate with respect to mods.
pub clock_rate: f64,
/// The hit windows for approach rate and overall difficulty.
pub hit_windows: BeatmapHitWindows,
pub hit_windows: HitWindows,
}
#[derive(Copy, Clone, Debug, PartialEq)]
/// AR and OD hit windows
pub struct BeatmapHitWindows {
#[derive(Copy, Clone, Debug, PartialEq)]
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,
}
/// A builder for [`BeatmapAttributes`] and [`HitWindows`].
#[derive(Clone, Debug, Default, PartialEq)]
/// Specify values for this builder to get [`BeatmapAttributes`] or [`BeatmapHitWindows`] based on
/// mods & co.
pub struct BeatmapAttributesBuilder {
mode: GameMode,
ar: f32,
od: f32,
cs: f32,
hp: f32,
mods: Option<u32>,
mods: u32,
clock_rate: Option<f64>,
converted: bool,
is_convert: bool,
}
impl BeatmapAttributesBuilder {
@@ -49,21 +52,24 @@ impl BeatmapAttributesBuilder {
const TAIKO_AVG: f64 = 35.0;
const TAIKO_MAX: f64 = 20.0;
#[inline]
/// Create a new [`BeatmapAttributesBuilder`].
pub fn new(map: &Beatmap) -> Self {
Self::from(map)
map.into()
}
#[inline]
/// Specify the mode.
pub fn mode(&mut self, mode: GameMode) -> &mut Self {
self.mode = mode;
self
const fn new_internal(map: &Beatmap, is_convert: bool) -> Self {
Self {
mode: map.mode,
ar: map.ar,
od: map.od,
cs: map.cs,
hp: map.hp,
mods: 0,
clock_rate: None,
is_convert,
}
}
#[inline]
/// Specify the approach rate.
pub fn ar(&mut self, ar: f32) -> &mut Self {
self.ar = ar;
@@ -71,7 +77,6 @@ impl BeatmapAttributesBuilder {
self
}
#[inline]
/// Specify the overall difficulty.
pub fn od(&mut self, od: f32) -> &mut Self {
self.od = od;
@@ -79,7 +84,6 @@ impl BeatmapAttributesBuilder {
self
}
#[inline]
/// Specify the circle size.
pub fn cs(&mut self, cs: f32) -> &mut Self {
self.cs = cs;
@@ -87,7 +91,6 @@ impl BeatmapAttributesBuilder {
self
}
#[inline]
/// Specify the drain rate.
pub fn hp(&mut self, hp: f32) -> &mut Self {
self.hp = hp;
@@ -95,15 +98,13 @@ impl BeatmapAttributesBuilder {
self
}
#[inline]
/// Specify the mods.
pub fn mods(&mut self, mods: u32) -> &mut Self {
self.mods = Some(mods);
self.mods = mods;
self
}
#[inline]
/// Specify a custom clock rate.
pub fn clock_rate(&mut self, clock_rate: f64) -> &mut Self {
self.clock_rate = Some(clock_rate);
@@ -111,19 +112,9 @@ impl BeatmapAttributesBuilder {
self
}
#[inline]
/// Specify whether it's a converted map.
/// Only relevant for mania.
pub fn converted(&mut self, converted: bool) -> &mut Self {
self.converted = converted;
self
}
#[inline]
/// Calculate the AR and OD hit windows.
pub fn hit_windows(&self) -> BeatmapHitWindows {
let mods = self.mods.unwrap_or(0);
pub fn hit_windows(&self) -> HitWindows {
let mods = self.mods;
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
let mod_mult = |val: f32| {
@@ -164,7 +155,7 @@ impl BeatmapAttributesBuilder {
diff_range / clock_rate
}
GameMode::Mania => {
let mut value = if !self.converted {
let mut value = if !self.is_convert {
34.0 + 3.0 * (10.0 - self.od).clamp(0.0, 10.0)
} else if self.od > 4.0 {
34.0
@@ -182,7 +173,7 @@ impl BeatmapAttributesBuilder {
}
};
BeatmapHitWindows {
HitWindows {
ar: preempt,
od: hit_window,
}
@@ -190,7 +181,7 @@ impl BeatmapAttributesBuilder {
/// Calculate the [`BeatmapAttributes`].
pub fn build(&self) -> BeatmapAttributes {
let mods = self.mods.unwrap_or(0);
let mods = self.mods;
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
// HP
@@ -206,7 +197,7 @@ impl BeatmapAttributesBuilder {
}
let hit_windows = self.hit_windows();
let BeatmapHitWindows { ar, od } = hit_windows;
let HitWindows { ar, od } = hit_windows;
// AR
let ar = if ar > 1200.0 {
@@ -234,22 +225,17 @@ impl BeatmapAttributesBuilder {
}
impl From<&Beatmap> for BeatmapAttributesBuilder {
#[inline]
fn from(map: &Beatmap) -> Self {
Self {
mode: map.mode,
ar: map.ar,
od: map.od,
cs: map.cs,
hp: map.hp,
mods: None,
clock_rate: None,
converted: false,
}
Self::new_internal(map, false)
}
}
impl<M> From<&Converted<'_, M>> for BeatmapAttributesBuilder {
fn from(converted: &Converted<'_, M>) -> Self {
Self::new_internal(converted.map.as_ref(), converted.is_convert)
}
}
#[allow(clippy::similar_names)]
fn difficulty_range(difficulty: f64, min: f64, mid: f64, max: f64) -> f64 {
if difficulty > 5.0 {
mid + (max - mid) * (difficulty - 5.0) / 5.0
+66
View File
@@ -0,0 +1,66 @@
use std::collections::HashMap;
use crate::model::{control_point::TimingPoint, hit_object::HitObject};
pub fn bpm(last_hit_object: Option<&HitObject>, timing_points: &[TimingPoint]) -> f64 {
// This is incorrect if the last object is a slider since there
// is no reasonable way to get the slider end time at this point.
let last_time = last_hit_object
.map(HitObject::end_time)
.or_else(|| timing_points.last().map(|t| t.time))
.unwrap_or(0.0);
let mut bpm_points = BeatLenDuration::new(last_time);
// * osu-stable forced the first control point to start at 0.
// * This is reproduced here to maintain compatibility around
// * osu!mania scroll speed and song select display.
match timing_points {
[curr] => bpm_points.add(curr.beat_len, 0.0, last_time),
[curr, next, ..] => bpm_points.add(curr.beat_len, 0.0, next.time),
[] => {}
}
timing_points
.iter()
.skip(1)
.zip(timing_points.iter().skip(2).map(|t| t.time))
.for_each(|(curr, next_time)| bpm_points.add(curr.beat_len, curr.time, next_time));
if let [.., _, curr] = timing_points {
bpm_points.add(curr.beat_len, curr.time, last_time);
}
let most_common_beat_len = bpm_points
.map
.into_iter()
// * Get the most common one, or 0 as a suitable default
.max_by(|(_, a), (_, b)| a.total_cmp(b))
.map_or(0.0, |(beat_len, _)| f64::from_bits(beat_len));
60_000.0 / most_common_beat_len
}
/// Maps `beat_len` to a cumulative duration
struct BeatLenDuration {
last_time: f64,
map: HashMap<u64, f64>,
}
impl BeatLenDuration {
fn new(last_time: f64) -> Self {
Self {
last_time,
map: HashMap::default(),
}
}
fn add(&mut self, beat_len: f64, curr_time: f64, next_time: f64) {
let beat_len = (1000.0 * beat_len).round() / 1000.0;
let entry = self.map.entry(beat_len.to_bits()).or_default();
if curr_time <= self.last_time {
*entry += next_time - curr_time;
}
}
}
+186
View File
@@ -0,0 +1,186 @@
use std::{
any,
borrow::Cow,
fmt::{Debug, Formatter, Result as FmtResult},
marker::PhantomData,
};
use crate::model::mode::{ConvertStatus, IGameMode};
use super::{attributes::BeatmapAttributesBuilder, Beatmap};
const INCOMPATIBLE_MODES: &str = "the gamemodes were incompatible";
/// A [`Beatmap`] that is attached to a mode.
///
/// # Incompatibility
///
/// The following conversions are compatible:
/// - `Osu` → `Osu`
/// - `Taiko` → `Taiko`
/// - `Catch` → `Catch`
/// - `Mania` → `Mania`
/// - `Osu` → `Taiko`
/// - `Osu` → `Catch`
/// - `Osu` → `Mania`
///
/// All other conversions are incompatible.
pub struct Converted<'a, M> {
pub(crate) map: Cow<'a, Beatmap>,
pub(crate) is_convert: bool,
mode: PhantomData<M>,
}
impl<'a, M> Converted<'a, M> {
/// Initialize a [`Converted`] beatmap by promising the given map's mode
/// matches the generic type `M`.
pub(crate) const fn new(map: Cow<'a, Beatmap>, is_convert: bool) -> Self {
Self {
map,
is_convert,
mode: PhantomData,
}
}
}
impl<M: IGameMode> Converted<'_, M> {
/// Attempt to convert a [`Beatmap`] to the specified mode.
///
/// If the conversion is incompatible the [`Beatmap`] will be returned
/// unchanged as `Err`.
#[allow(clippy::result_large_err)]
pub fn try_from_owned(map: Beatmap) -> Result<Self, Beatmap> {
let mut map = Cow::Owned(map);
match M::try_convert(&mut map) {
ConvertStatus::Noop => Ok(Self::new(map, false)),
ConvertStatus::Done => Ok(Self::new(map, true)),
ConvertStatus::Incompatible => {
let Cow::Owned(map) = map else { unreachable!() };
Err(map)
}
}
}
/// Convert a [`Beatmap`] to the specified mode.
///
/// # Panics
///
/// Panics if the conversion is incompatible.
pub fn unchecked_from_owned(map: Beatmap) -> Self {
Self::try_from_owned(map).unwrap_or_else(|_| panic!("{}", INCOMPATIBLE_MODES))
}
/// Sum up the duration of all breaks (in milliseconds).
pub fn total_break_time(&self) -> f64 {
self.map.total_break_time()
}
/// Returns a [`BeatmapAttributesBuilder`] to calculate modified beatmap
/// attributes.
pub fn attributes(&self) -> BeatmapAttributesBuilder {
self.into()
}
/// The beats per minute of the map.
pub fn bpm(&self) -> f64 {
self.map.bpm()
}
}
impl<'a, M: IGameMode> Converted<'a, M> {
/// Borrow the contained [`Beatmap`] to cheaply create a new owned
/// [`Converted`].
///
/// This is the same as `.clone()` except cheap but its lifetime might be
/// shorter.
#[must_use]
pub fn as_owned(&'a self) -> Self {
Self::new(Cow::Borrowed(self.map.as_ref()), self.is_convert)
}
/// Attempt to convert a [`&Beatmap`] to the specified mode.
///
/// If the conversion is incompatible, `None` is returned.
///
/// [`&Beatmap`]: Beatmap
pub fn try_from_ref(map: &'a Beatmap) -> Option<Self> {
let mut map = Cow::Borrowed(map);
match M::try_convert(&mut map) {
ConvertStatus::Noop => Some(Self::new(map, false)),
ConvertStatus::Done => Some(Self::new(map, true)),
ConvertStatus::Incompatible => None,
}
}
/// Convert a [`&Beatmap`] to the specified mode.
///
/// # Panics
///
/// Panics if the conversion is incompatible.
///
/// [`&Beatmap`]: Beatmap
pub fn unchecked_from_ref(map: &'a Beatmap) -> Self {
Self::try_from_ref(map).expect(INCOMPATIBLE_MODES)
}
/// Attempt to convert a [`Converted`] from mode `M` to mode `N`.
///
/// If the conversion is incompatible the [`Converted`] will be returned
/// unchanged as `Err`.
#[allow(clippy::result_large_err)]
pub fn try_convert<N: IGameMode>(self) -> Result<Converted<'a, N>, Self> {
match self.map {
Cow::Borrowed(map) => Converted::<N>::try_from_ref(map).ok_or(self),
Cow::Owned(map) => Converted::<N>::try_from_owned(map)
.map_err(|map| Self::new(Cow::Owned(map), self.is_convert)),
}
}
}
impl<M> Clone for Converted<'_, M> {
fn clone(&self) -> Self {
Self::new(self.map.clone(), self.is_convert)
}
}
impl<M> Debug for Converted<'_, M> {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
struct GenericFormatter<T>(PhantomData<T>);
impl<T> Default for GenericFormatter<T> {
fn default() -> Self {
Self(PhantomData)
}
}
impl<T> Debug for GenericFormatter<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
let full_type_name = any::type_name::<T>();
// Strip fully qualified syntax
if let Some(position) = full_type_name.rfind("::") {
if let Some(type_name) = full_type_name.get(position + 2..) {
f.write_str(type_name)?;
}
}
Ok(())
}
}
f.debug_struct("Converted")
.field("map", &self.map)
.field("is_convert", &self.is_convert)
.field("mode", &GenericFormatter::<M>::default())
.finish()
}
}
impl<M> PartialEq for Converted<'_, M> {
fn eq(&self, other: &Self) -> bool {
self.map == other.map && self.is_convert == other.is_convert
}
}
+706
View File
@@ -0,0 +1,706 @@
use std::{cmp, slice};
use rosu_map::{
section::{
difficulty::{Difficulty, DifficultyState, ParseDifficultyError},
events::{BreakPeriod, EventType, ParseEventTypeError},
general::{GameMode, GeneralKey, ParseGameModeError},
hit_objects::{
hit_samples::{HitSoundType, ParseHitSoundTypeError},
HitObjectType, ParseHitObjectTypeError, PathControlPoint, PathType,
},
timing_points::{ControlPoint, EffectFlags, ParseEffectFlagsError},
},
util::{KeyValue, ParseNumber, ParseNumberError, Pos, StrExt, MAX_PARSE_VALUE},
DecodeBeatmap, DecodeState,
};
use crate::{
model::{
control_point::{
difficulty_point_at, effect_point_at, DifficultyPoint, EffectPoint, TimingPoint,
},
hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner},
},
util::{float_ext::FloatExt, legacy_sort::legacy_sort, tandem_sort::TandemSorter},
};
use super::{Beatmap, DEFAULT_SLIDER_LENIENCY};
/// The state of a [`Beatmap`] for [`DecodeBeatmap`].
pub struct BeatmapState {
version: i32,
stack_leniency: f32,
mode: GameMode,
difficulty: DifficultyState,
breaks: Vec<BreakPeriod>,
timing_points: Vec<TimingPoint>,
difficulty_points: Vec<DifficultyPoint>,
effect_points: Vec<EffectPoint>,
hit_objects: Vec<HitObject>,
hit_sounds: Vec<HitSoundType>,
pending_control_points_time: f64,
pending_timing_point: Option<TimingPoint>,
pending_difficulty_point: Option<DifficultyPoint>,
pending_effect_point: Option<EffectPoint>,
curve_points: Vec<PathControlPoint>,
vertices: Vec<PathControlPoint>,
point_split: Vec<*const str>,
}
impl BeatmapState {
fn add_pending_point<P: Pending>(&mut self, time: f64, point: P, timing_change: bool) {
if time.not_eq(self.pending_control_points_time) {
self.flush_pending_points();
}
if timing_change {
point.push_front(self);
} else {
point.push_back(self);
}
self.pending_control_points_time = time;
}
fn flush_pending_points(&mut self) {
if let Some(point) = self.pending_timing_point.take() {
self.add_control_point(point);
}
if let Some(point) = self.pending_difficulty_point.take() {
self.add_control_point(point);
}
if let Some(point) = self.pending_effect_point.take() {
self.add_control_point(point);
}
}
fn add_control_point<P: ControlPoint<Self>>(&mut self, point: P) {
if !point.check_already_existing(self) {
point.add(self);
}
}
fn convert_path_str(&mut self, point_str: &str, offset: Pos) -> Result<(), ParseBeatmapError> {
let f = |this: &mut Self, point_split: &[&str]| {
let mut start_idx = 0;
let mut end_idx = 0;
let mut first = true;
while {
end_idx += 1;
end_idx < point_split.len()
} {
let is_letter = point_split[end_idx]
.chars()
.next()
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?
.is_ascii_alphabetic();
if !is_letter {
continue;
}
let end_point = point_split.get(end_idx + 1).copied();
this.convert_points(&point_split[start_idx..end_idx], end_point, first, offset)?;
start_idx = end_idx;
first = false;
}
if end_idx > start_idx {
this.convert_points(&point_split[start_idx..end_idx], None, first, offset)?;
}
Ok(())
};
self.point_split(point_str.split('|'), f)
}
fn convert_points(
&mut self,
points: &[&str],
end_point: Option<&str>,
first: bool,
offset: Pos,
) -> Result<(), ParseBeatmapError> {
fn read_point(value: &str, start_pos: Pos) -> Result<PathControlPoint, ParseBeatmapError> {
let mut v = value
.split(':')
.map(|s| s.parse_with_limits(f64::from(MAX_COORDINATE_VALUE)));
let (x, y) = v
.next()
.zip(v.next())
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
let pos = Pos::new(x? as i32 as f32, y? as i32 as f32);
Ok(PathControlPoint::new(pos - start_pos))
}
fn is_linear(p0: Pos, p1: Pos, p2: Pos) -> bool {
((p1.y - p0.y) * (p2.x - p0.x)).eq((p1.x - p0.x) * (p2.y - p0.y))
}
let mut path_type = points
.first()
.copied()
.map(PathType::new_from_str)
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
let read_offset = usize::from(first);
let readable_points = points.len() - 1;
let end_point_len = usize::from(end_point.is_some());
self.vertices.clear();
self.vertices
.reserve(read_offset + readable_points + end_point_len);
if first {
self.vertices.push(PathControlPoint::default());
}
for &point in points.iter().skip(1) {
self.vertices.push(read_point(point, offset)?);
}
if let Some(end_point) = end_point {
self.vertices.push(read_point(end_point, offset)?);
}
if path_type == PathType::PERFECT_CURVE {
if let [a, b, c] = self.vertices.as_slice() {
if is_linear(a.pos, b.pos, c.pos) {
path_type = PathType::LINEAR;
}
} else {
path_type = PathType::BEZIER;
}
}
self.vertices[0].path_type = Some(path_type);
let mut start_idx = 0;
let mut end_idx = 0;
while {
end_idx += 1;
end_idx < self.vertices.len() - end_point_len
} {
if self.vertices[end_idx].pos != self.vertices[end_idx - 1].pos {
continue;
}
if path_type == PathType::CATMULL && end_idx > 1 {
continue;
}
if end_idx == self.vertices.len() - end_point_len - 1 {
continue;
}
self.vertices[end_idx - 1].path_type = Some(path_type);
self.curve_points.extend(&self.vertices[start_idx..end_idx]);
start_idx = end_idx + 1;
}
if end_idx > start_idx {
self.curve_points.extend(&self.vertices[start_idx..end_idx]);
}
Ok(())
}
fn point_split<'a, I, F, O>(&mut self, point_split: I, f: F) -> O
where
I: Iterator<Item = &'a str>,
F: FnOnce(&mut Self, &[&'a str]) -> O,
{
self.point_split.extend(point_split.map(|s| s as *const _));
let ptr = self.point_split.as_ptr();
let len = self.point_split.len();
// SAFETY:
// - *const str and &str have the same layout.
// - `self.point_split` is cleared after every use, ensuring that it
// does not contain any invalid pointers.
let point_split = unsafe { slice::from_raw_parts(ptr.cast(), len) };
let res = f(self, point_split);
self.point_split.clear();
res
}
}
impl DecodeState for BeatmapState {
fn create(version: i32) -> Self {
Self {
version,
stack_leniency: DEFAULT_SLIDER_LENIENCY,
mode: GameMode::Osu,
difficulty: DifficultyState::create(version),
breaks: Vec::new(),
timing_points: Vec::with_capacity(1),
difficulty_points: Vec::new(),
effect_points: Vec::with_capacity(32),
hit_objects: Vec::with_capacity(512),
hit_sounds: Vec::with_capacity(512),
pending_control_points_time: 0.0,
pending_timing_point: None,
pending_difficulty_point: None,
pending_effect_point: None,
curve_points: Vec::new(), // TODO: check for default size
vertices: Vec::new(), // TODO: check for default size
point_split: Vec::new(), // TODO: check for default size
}
}
}
impl From<BeatmapState> for Beatmap {
fn from(mut state: BeatmapState) -> Self {
state.flush_pending_points();
let Difficulty {
hp_drain_rate,
circle_size,
overall_difficulty,
approach_rate,
slider_multiplier,
slider_tick_rate,
} = state.difficulty.into();
let mut sorter = TandemSorter::new(
&state.hit_objects,
|a, b| a.start_time.total_cmp(&b.start_time),
true,
);
sorter.sort(&mut state.hit_objects);
sorter.sort(&mut state.hit_sounds);
if state.mode == GameMode::Mania {
legacy_sort(&mut state.hit_objects);
}
Beatmap {
version: state.version,
stack_leniency: state.stack_leniency,
mode: state.mode,
ar: approach_rate,
cs: circle_size,
hp: hp_drain_rate,
od: overall_difficulty,
slider_multiplier,
slider_tick_rate,
breaks: state.breaks,
timing_points: state.timing_points,
difficulty_points: state.difficulty_points,
effect_points: state.effect_points,
hit_objects: state.hit_objects,
hit_sounds: state.hit_sounds,
}
}
}
/// All the ways that parsing a [`Beatmap`] can fail.
#[derive(Debug, thiserror::Error)]
pub enum ParseBeatmapError {
#[error("failed to parse effect flags")]
EffectFlags(#[from] ParseEffectFlagsError),
#[error("failed to parse event type")]
EventType(#[from] ParseEventTypeError),
#[error("failed to parse hit object type")]
HitObjectType(#[from] ParseHitObjectTypeError),
#[error("failed to parse hit sound type")]
HitSoundType(#[from] ParseHitSoundTypeError),
#[error("invalid event line")]
InvalidEventLine,
#[error("repeat count is way too high")]
InvalidRepeatCount,
#[error("invalid timing point line")]
InvalidTimingPointLine,
#[error("invalid hit object line")]
InvalidHitObjectLine,
#[error("failed to parse mode")]
Mode(#[from] ParseGameModeError),
#[error("failed to parse number")]
Number(#[from] ParseNumberError),
#[error("beat length cannot be NaN in a timing control point")]
TimingControlPointNaN,
#[error("unknown hit object type")]
UnknownHitObjectType,
}
impl From<ParseDifficultyError> for ParseBeatmapError {
fn from(e: ParseDifficultyError) -> Self {
match e {
ParseDifficultyError::Number(e) => Self::Number(e),
}
}
}
const MAX_COORDINATE_VALUE: i32 = 131_072;
impl DecodeBeatmap for Beatmap {
type Error = ParseBeatmapError;
type State = BeatmapState;
fn parse_general(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
let Ok(KeyValue { key, value }) = KeyValue::parse(line.trim_comment()) else {
return Ok(());
};
match key {
GeneralKey::StackLeniency => state.stack_leniency = value.parse_num()?,
GeneralKey::Mode => state.mode = value.parse()?,
_ => {}
}
Ok(())
}
fn parse_editor(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
Ok(())
}
fn parse_metadata(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
Ok(())
}
fn parse_difficulty(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
Difficulty::parse_difficulty(&mut state.difficulty, line).map_err(ParseBeatmapError::from)
}
fn parse_events(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
let mut split = line.trim_comment().split(',');
let event_type: EventType = split
.next()
.ok_or(ParseBeatmapError::InvalidEventLine)?
.parse()?;
if event_type == EventType::Break {
let Some((start_time, end_time)) = split.next().zip(split.next()) else {
return Err(ParseBeatmapError::InvalidEventLine);
};
let start_time = f64::parse(start_time)?;
let end_time = start_time.max(f64::parse(end_time)?);
state.breaks.push(BreakPeriod {
start_time,
end_time,
});
}
Ok(())
}
fn parse_timing_points(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
let mut split = line.trim_comment().split(',');
let (time, beat_len) = split
.next()
.zip(split.next())
.ok_or(ParseBeatmapError::InvalidTimingPointLine)?;
let time = time.parse_num::<f64>()?;
// Manual `str::parse_num::<f64>` so that NaN does not cause an error
let beat_len = beat_len
.trim()
.parse::<f64>()
.map_err(ParseNumberError::InvalidFloat)?;
if beat_len < f64::from(-MAX_PARSE_VALUE) {
return Err(ParseNumberError::NumberUnderflow.into());
} else if beat_len > f64::from(MAX_PARSE_VALUE) {
return Err(ParseNumberError::NumberOverflow.into());
}
let speed_multiplier = if beat_len < 0.0 {
100.0 / -beat_len
} else {
1.0
};
let _ = split.next(); // timing signature
let _ = split.next(); // sample set
let _ = split.next(); // custom sample bank
let _ = split.next(); // sample volume
let timing_change = split
.next()
.map_or(true, |next| matches!(next.chars().next(), Some('1')));
let kiai = split
.next()
.map(str::parse::<EffectFlags>)
.transpose()?
.is_some_and(|flags| flags.has_flag(EffectFlags::KIAI));
if timing_change {
if beat_len.is_nan() {
return Err(ParseBeatmapError::TimingControlPointNaN);
}
let timing = TimingPoint::new(time, beat_len);
state.add_pending_point(time, timing, timing_change);
}
let difficulty = DifficultyPoint::new(time, beat_len, speed_multiplier);
state.add_pending_point(time, difficulty, timing_change);
let effect = EffectPoint::new(time, kiai);
state.add_pending_point(time, effect, timing_change);
state.pending_control_points_time = time;
Ok(())
}
fn parse_colors(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
Ok(())
}
fn parse_hit_objects(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
let mut split = line.trim_comment().split(',');
let (Some(x), Some(y), Some(start_time), Some(kind), Some(sound_type)) = (
split.next(),
split.next(),
split.next(),
split.next(),
split.next(),
) else {
return Err(ParseBeatmapError::InvalidHitObjectLine);
};
let pos = Pos {
x: x.parse_with_limits(MAX_COORDINATE_VALUE as f32)? as i32 as f32,
y: y.parse_with_limits(MAX_COORDINATE_VALUE as f32)? as i32 as f32,
};
let start_time = f64::parse(start_time)?;
let hit_object_type: HitObjectType = kind.parse()?;
let mut sound: HitSoundType = sound_type.parse()?;
let mut parse_custom_sound = |bank_info: Option<&str>| {
let mut split = match bank_info {
Some(s) if !s.is_empty() => s.split(':'),
_ => return Ok::<_, ParseNumberError>(()),
};
let _ = split.next().map(i32::parse).transpose()?; // normal bank
let _ = split.next().map(i32::parse).transpose()?; // additional bank
let _ = split.next().map(i32::parse).transpose()?; // custom sample bank
let _ = split.next().map(i32::parse).transpose()?; // volume
// filename
match split.next() {
None | Some("") => {}
Some(_) => sound = HitSoundType::default(),
}
Ok(())
};
let kind = if hit_object_type.has_flag(HitObjectType::CIRCLE) {
parse_custom_sound(split.next())?;
HitObjectKind::Circle
} else if hit_object_type.has_flag(HitObjectType::SLIDER) {
let (point_str, repeat_count) = split
.next()
.zip(split.next())
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
let mut len = None;
let mut repeats = repeat_count.parse_num::<i32>()?;
if repeats > 9000 {
return Err(ParseBeatmapError::InvalidRepeatCount);
}
repeats = cmp::max(0, repeats - 1);
if let Some(next) = split.next() {
let new_len = next
.parse_with_limits(f64::from(MAX_COORDINATE_VALUE))?
.max(0.0);
if new_len.not_eq(0.0) {
len = Some(new_len);
}
}
let node_sounds = if let Some(sounds) = split.next().map(|sounds| sounds.split('|')) {
sounds.map(|s| s.parse().unwrap_or_default()).collect()
} else {
Box::default()
};
let _ = split.next(); // node banks
parse_custom_sound(split.next())?;
state.convert_path_str(point_str, pos)?;
let mut control_points = Vec::with_capacity(state.curve_points.len());
control_points.append(&mut state.curve_points);
let slider = Slider {
expected_dist: len,
repeats: repeats as usize,
control_points: control_points.into_boxed_slice(),
node_sounds,
};
HitObjectKind::Slider(slider)
} else if hit_object_type.has_flag(HitObjectType::SPINNER) {
let end_time = split
.next()
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?
.parse_num::<f64>()?;
parse_custom_sound(split.next())?;
HitObjectKind::Spinner(Spinner { end_time })
} else if hit_object_type.has_flag(HitObjectType::HOLD) {
let end_time = if let Some(s) = split.next().filter(|s| !s.is_empty()) {
let (end_time, bank_info) = s
.split_once(':')
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
parse_custom_sound(Some(bank_info))?;
end_time.parse_num::<f64>()?.max(start_time)
} else {
start_time
};
HitObjectKind::Hold(HoldNote { end_time })
} else {
return Err(ParseBeatmapError::UnknownHitObjectType);
};
state.hit_objects.push(HitObject {
pos,
start_time,
kind,
});
state.hit_sounds.push(sound);
Ok(())
}
fn parse_variables(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
Ok(())
}
fn parse_catch_the_beat(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
Ok(())
}
fn parse_mania(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
Ok(())
}
}
trait Pending: Sized {
fn pending(state: &mut BeatmapState) -> &mut Option<Self>;
fn push_front(self, state: &mut BeatmapState) {
let pending = Self::pending(state);
if pending.is_none() {
*pending = Some(self);
}
}
fn push_back(self, state: &mut BeatmapState) {
*Self::pending(state) = Some(self);
}
}
impl Pending for TimingPoint {
fn pending(state: &mut BeatmapState) -> &mut Option<Self> {
&mut state.pending_timing_point
}
}
impl Pending for DifficultyPoint {
fn pending(state: &mut BeatmapState) -> &mut Option<Self> {
&mut state.pending_difficulty_point
}
}
impl Pending for EffectPoint {
fn pending(state: &mut BeatmapState) -> &mut Option<Self> {
&mut state.pending_effect_point
}
}
impl ControlPoint<BeatmapState> for TimingPoint {
fn check_already_existing(&self, _: &BeatmapState) -> bool {
false
}
fn add(self, state: &mut BeatmapState) {
match state
.timing_points
.binary_search_by(|probe| probe.time.total_cmp(&self.time))
{
Err(i) => state.timing_points.insert(i, self),
Ok(i) => state.timing_points[i] = self,
}
}
}
impl ControlPoint<BeatmapState> for DifficultyPoint {
fn check_already_existing(&self, state: &BeatmapState) -> bool {
match difficulty_point_at(&state.difficulty_points, self.time) {
Some(existing) => self.is_redundant(existing),
None => self.is_redundant(&DifficultyPoint::default()),
}
}
fn add(self, state: &mut BeatmapState) {
match state
.difficulty_points
.binary_search_by(|probe| probe.time.total_cmp(&self.time))
{
Err(i) => state.difficulty_points.insert(i, self),
Ok(i) => state.difficulty_points[i] = self,
}
}
}
impl ControlPoint<BeatmapState> for EffectPoint {
fn check_already_existing(&self, state: &BeatmapState) -> bool {
match effect_point_at(&state.effect_points, self.time) {
Some(existing) => self.is_redundant(existing),
None => self.is_redundant(&EffectPoint::default()),
}
}
fn add(self, state: &mut BeatmapState) {
match state
.effect_points
.binary_search_by(|probe| probe.time.total_cmp(&self.time))
{
Err(i) => state.effect_points.insert(i, self),
Ok(i) => state.effect_points[i] = self,
}
}
}
+199
View File
@@ -0,0 +1,199 @@
use std::{io, path::Path, str::FromStr};
use rosu_map::{
section::{events::BreakPeriod, general::GameMode, hit_objects::hit_samples::HitSoundType},
LATEST_FORMAT_VERSION,
};
pub use self::{
attributes::{BeatmapAttributes, BeatmapAttributesBuilder, HitWindows},
converted::Converted,
decode::{BeatmapState, ParseBeatmapError},
};
use super::{
control_point::{
difficulty_point_at, effect_point_at, timing_point_at, DifficultyPoint, EffectPoint,
TimingPoint,
},
hit_object::HitObject,
mode::IGameMode,
};
mod attributes;
mod bpm;
mod converted;
mod decode;
/// All beatmap data that is relevant for difficulty and performance
/// calculation.
#[derive(Clone, Debug, PartialEq)]
pub struct Beatmap {
pub version: i32,
// General
pub stack_leniency: f32,
pub mode: GameMode,
// Difficulty
pub ar: f32,
pub cs: f32,
pub hp: f32,
pub od: f32,
pub slider_multiplier: f64,
pub slider_tick_rate: f64,
// Events
pub breaks: Vec<BreakPeriod>,
// TimingPoints
pub timing_points: Vec<TimingPoint>,
pub difficulty_points: Vec<DifficultyPoint>,
pub effect_points: Vec<EffectPoint>,
// HitObjects
pub hit_objects: Vec<HitObject>,
pub hit_sounds: Vec<HitSoundType>,
}
impl Beatmap {
/// Parse a [`Beatmap`] by providing a path to a `.osu` file.
pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, io::Error> {
rosu_map::from_path(path)
}
/// Parse a [`Beatmap`] by providing the content of a `.osu` file as a
/// slice of bytes.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, io::Error> {
rosu_map::from_bytes(bytes)
}
/// Finds the [`TimingPoint`] that is active at the given time.
pub(crate) fn timing_point_at(&self, time: f64) -> Option<&TimingPoint> {
timing_point_at(&self.timing_points, time)
}
/// Finds the [`DifficultyPoint`] that is active at the given time.
pub(crate) fn difficulty_point_at(&self, time: f64) -> Option<&DifficultyPoint> {
difficulty_point_at(&self.difficulty_points, time)
}
/// Finds the [`EffectPoint`] that is active at the given time.
pub(crate) fn effect_point_at(&self, time: f64) -> Option<&EffectPoint> {
effect_point_at(&self.effect_points, time)
}
/// Sum up the duration of all breaks (in milliseconds).
pub fn total_break_time(&self) -> f64 {
self.breaks.iter().map(BreakPeriod::duration).sum()
}
/// Attempt to convert a [`&Beatmap`] to the specified mode.
///
/// If the conversion is incompatible, `None` is returned.
///
/// [`&Beatmap`]: Beatmap
pub fn try_as_converted<M: IGameMode>(&self) -> Option<Converted<'_, M>> {
Converted::try_from_ref(self)
}
/// Convert a [`&Beatmap`] to the specified mode.
///
/// # Panics
///
/// Panics if the conversion is incompatible.
///
/// [`&Beatmap`]: Beatmap
pub fn unchecked_as_converted<M: IGameMode>(&self) -> Converted<'_, M> {
Converted::unchecked_from_ref(self)
}
/// Attempt to convert a [`Beatmap`] to the specified mode.
///
/// If the conversion is incompatible the [`Beatmap`] will be returned
/// unchanged as `Err`.
#[allow(clippy::result_large_err)]
pub fn try_into_converted<'a, M: IGameMode>(self) -> Result<Converted<'a, M>, Self> {
Converted::try_from_owned(self)
}
/// Convert a [`Beatmap`] to the specified mode.
///
/// # Panics
///
/// Panics if the conversion is incompatible.
pub fn unchecked_into_converted<'a, M: IGameMode>(self) -> Converted<'a, M> {
Converted::unchecked_from_owned(self)
}
/// Returns a [`BeatmapAttributesBuilder`] to calculate modified beatmap
/// attributes.
pub fn attributes(&self) -> BeatmapAttributesBuilder {
self.into()
}
/// The beats per minute of the map.
pub fn bpm(&self) -> f64 {
bpm::bpm(self.hit_objects.last(), &self.timing_points)
}
}
impl FromStr for Beatmap {
type Err = io::Error;
/// Parse a [`Beatmap`] by providing the content of a `.osu` file as a
/// string.
fn from_str(s: &str) -> Result<Self, Self::Err> {
rosu_map::from_str(s)
}
}
const DEFAULT_SLIDER_LENIENCY: f32 = 0.7;
impl Default for Beatmap {
fn default() -> Self {
Self {
version: LATEST_FORMAT_VERSION,
stack_leniency: DEFAULT_SLIDER_LENIENCY,
mode: GameMode::default(),
ar: 5.0,
cs: 5.0,
hp: 5.0,
od: 5.0,
slider_multiplier: 1.4,
slider_tick_rate: 1.0,
breaks: Vec::default(),
timing_points: Vec::default(),
difficulty_points: Vec::default(),
effect_points: Vec::default(),
hit_objects: Vec::default(),
hit_sounds: Vec::default(),
}
}
}
macro_rules! define_beat_len_fn {
( $fn_name:ident, $clamp:literal) => {
#[allow(unused)]
pub(crate) fn $fn_name(map: &Beatmap, beat_len: f64, time: f64) -> f64 {
let slider_velocity = map
.difficulty_point_at(time)
.map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
point.slider_velocity
});
let slider_velocity_as_beat_len = -100.0 / slider_velocity;
let bpm_mult = if slider_velocity_as_beat_len < 0.0 {
(((-slider_velocity_as_beat_len) as f32).clamp(10.0, $clamp) / 100.0) as f64
} else {
1.0
};
beat_len * bpm_mult
}
};
}
define_beat_len_fn!(get_precision_adjusted_beat_len_taiko_mania, 10_000.0);
define_beat_len_fn!(get_precision_adjusted_beat_len_osu_catch, 1000.0);
+8
View File
@@ -0,0 +1,8 @@
pub use rosu_map::section::timing_points::DifficultyPoint;
pub fn difficulty_point_at(points: &[DifficultyPoint], time: f64) -> Option<&DifficultyPoint> {
points
.binary_search_by(|probe| probe.time.total_cmp(&time))
.map_or_else(|i| i.checked_sub(1), Some)
.map(|i| &points[i])
}
+34
View File
@@ -0,0 +1,34 @@
/// Effect-related info about this control point.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct EffectPoint {
pub time: f64,
pub kiai: bool,
}
impl EffectPoint {
pub const DEFAULT_KIAI: bool = rosu_map::section::timing_points::EffectPoint::DEFAULT_KIAI;
pub const fn new(time: f64, kiai: bool) -> Self {
Self { time, kiai }
}
pub const fn is_redundant(&self, existing: &Self) -> bool {
self.kiai == existing.kiai
}
}
impl Default for EffectPoint {
fn default() -> Self {
Self {
time: 0.0,
kiai: Self::DEFAULT_KIAI,
}
}
}
pub fn effect_point_at(points: &[EffectPoint], time: f64) -> Option<&EffectPoint> {
points
.binary_search_by(|probe| probe.time.total_cmp(&time))
.map_or_else(|i| i.checked_sub(1), Some)
.map(|i| &points[i])
}
+9
View File
@@ -0,0 +1,9 @@
pub use self::{difficulty::DifficultyPoint, effect::EffectPoint, timing::TimingPoint};
pub(crate) use self::{
difficulty::difficulty_point_at, effect::effect_point_at, timing::timing_point_at,
};
mod difficulty;
mod effect;
mod timing;
+35
View File
@@ -0,0 +1,35 @@
/// Timing-related info about this control point.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct TimingPoint {
pub time: f64,
pub beat_len: f64,
}
impl TimingPoint {
pub const DEFAULT_BEAT_LEN: f64 =
rosu_map::section::timing_points::TimingPoint::DEFAULT_BEAT_LEN;
pub fn new(time: f64, beat_len: f64) -> Self {
Self {
time,
beat_len: beat_len.clamp(6.0, 60_000.0),
}
}
}
impl Default for TimingPoint {
fn default() -> Self {
Self {
time: 0.0,
beat_len: Self::DEFAULT_BEAT_LEN,
}
}
}
pub fn timing_point_at(points: &[TimingPoint], time: f64) -> Option<&TimingPoint> {
let i = points
.binary_search_by(|probe| probe.time.total_cmp(&time))
.unwrap_or_else(|i| i.saturating_sub(1));
points.get(i)
}
+94
View File
@@ -0,0 +1,94 @@
use std::cmp::Ordering;
use rosu_map::section::hit_objects::{BorrowedCurve, CurveBuffers};
pub use rosu_map::{
section::hit_objects::{hit_samples::HitSoundType, PathControlPoint},
util::Pos,
};
/// All hitobject related data required for difficulty and performance
/// calculation except for the [`HitSoundType`].
#[derive(Clone, Debug, PartialEq)]
pub struct HitObject {
pub pos: Pos,
pub start_time: f64,
pub kind: HitObjectKind,
}
impl HitObject {
/// Whether the hitobject is a circle.
pub const fn is_circle(&self) -> bool {
matches!(&self.kind, HitObjectKind::Circle)
}
/// Whether the hitobject is a slider.
pub const fn is_slider(&self) -> bool {
matches!(&self.kind, HitObjectKind::Slider(_))
}
/// Whether the hitobject is a spinner.
pub const fn is_spinner(&self) -> bool {
matches!(&self.kind, HitObjectKind::Spinner(_))
}
/// The end time of the object.
///
/// Note that this will not return the correct value for sliders.
pub(crate) const fn end_time(&self) -> f64 {
match &self.kind {
HitObjectKind::Circle | HitObjectKind::Slider { .. } => self.start_time,
HitObjectKind::Spinner(Spinner { end_time })
| HitObjectKind::Hold(HoldNote { end_time }) => *end_time,
}
}
}
impl PartialOrd for HitObject {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.start_time.partial_cmp(&other.start_time)
}
}
/// Additional data for a [`HitObject`].
///
/// Note that each mode handles hit objects differently.
#[derive(Clone, Debug, PartialEq)]
pub enum HitObjectKind {
Circle,
Slider(Slider),
Spinner(Spinner),
Hold(HoldNote),
}
/// A slider.
#[derive(Clone, Debug, PartialEq)]
pub struct Slider {
pub expected_dist: Option<f64>,
pub repeats: usize,
pub control_points: Box<[PathControlPoint]>,
pub node_sounds: Box<[HitSoundType]>,
}
impl Slider {
/// The amount of spans of the slider.
pub const fn span_count(&self) -> usize {
self.repeats + 1
}
pub(crate) fn curve<'a>(&self, bufs: &'a mut CurveBuffers) -> BorrowedCurve<'a> {
BorrowedCurve::new(&self.control_points, self.expected_dist, bufs)
}
}
/// A spinner.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct Spinner {
pub end_time: f64,
}
/// A hold note.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct HoldNote {
pub end_time: f64,
}
+11
View File
@@ -0,0 +1,11 @@
/// Beatmap related types.
pub mod beatmap;
/// Control point related types.
pub mod control_point;
/// Hitobject related types.
pub mod hit_object;
/// Gamemode related types.
pub mod mode;
+52
View File
@@ -0,0 +1,52 @@
use std::borrow::Cow;
pub use rosu_map::section::general::GameMode;
use crate::any::ModeDifficulty;
use super::beatmap::{Beatmap, Converted};
/// A way to specify a gamemode at compile-time.
///
/// Notably, this is implemented for the marker types [`Osu`], [`Taiko`],
/// [`Catch`], and [`Mania`].
///
/// [`Osu`]: crate::osu::Osu
/// [`Taiko`]: crate::taiko::Taiko
/// [`Catch`]: crate::catch::Catch
/// [`Mania`]: crate::mania::Mania
pub trait IGameMode: Sized {
/// The resulting type of a difficulty calculation.
type DifficultyAttributes;
/// The resulting type of a strain calculation.
type Strains;
/// Attempt to convert a beatmap.
///
/// In case [`ConvertStatus::Incompatible`] is returned, the map should
/// **not** be modified.
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus;
/// Perform a difficulty calculation for a [`Converted`] beatmap and
/// process the final skill values.
fn difficulty(
difficulty: &ModeDifficulty,
map: &Converted<'_, Self>,
) -> Self::DifficultyAttributes;
/// Perform a difficulty calculation for a [`Converted`] beatmap without
/// processing the final skill values.
fn strains(difficulty: &ModeDifficulty, map: &Converted<'_, Self>) -> Self::Strains;
}
/// The status of a conversion through [`IGameMode::try_convert`].
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ConvertStatus {
/// Conversion was not necessary.
Noop,
/// Conversion was successful.
Done,
/// Conversion was not possible.
Incompatible,
}
-87
View File
@@ -1,87 +0,0 @@
macro_rules! impl_mods {
($func_name:ident, $const_name:ident) => {
#[inline]
fn $func_name(self) -> bool {
self & Self::$const_name > 0
}
};
}
/// Abstract type to define mods.
#[allow(missing_docs)]
pub trait Mods: Copy {
const NF: u32 = 1 << 0;
const EZ: u32 = 1 << 1;
const TD: u32 = 1 << 2;
const HD: u32 = 1 << 3;
const HR: u32 = 1 << 4;
const DT: u32 = 1 << 6;
const RX: u32 = 1 << 7;
const HT: u32 = 1 << 8;
const FL: u32 = 1 << 10;
const SO: u32 = 1 << 12;
/// If the clock rate is affected by the mods.
fn change_speed(self) -> bool;
/// If object time's or positions are affected by the mods.
fn change_map(self) -> bool;
/// The clock rate with the mods.
fn clock_rate(self) -> f64;
/// Multiplier for beatmap attributes with respect to the mods.
fn od_ar_hp_multiplier(self) -> f64;
fn nf(self) -> bool;
fn ez(self) -> bool;
fn td(self) -> bool;
fn hd(self) -> bool;
fn hr(self) -> bool;
fn dt(self) -> bool;
fn rx(self) -> bool;
fn ht(self) -> bool;
fn fl(self) -> bool;
fn so(self) -> bool;
}
impl Mods for u32 {
#[inline]
fn change_speed(self) -> bool {
self & (Self::HT | Self::DT) > 0
}
#[inline]
fn change_map(self) -> bool {
self & (Self::HT | Self::DT | Self::HR | Self::EZ) > 0
}
#[inline]
fn clock_rate(self) -> f64 {
if self & Self::DT > 0 {
1.5
} else if self & Self::HT > 0 {
0.75
} else {
1.0
}
}
#[inline]
fn od_ar_hp_multiplier(self) -> f64 {
if self & Self::HR > 0 {
1.4
} else if self & Self::EZ > 0 {
0.5
} else {
1.0
}
}
impl_mods!(nf, NF);
impl_mods!(ez, EZ);
impl_mods!(td, TD);
impl_mods!(hd, HD);
impl_mods!(hr, HR);
impl_mods!(dt, DT);
impl_mods!(rx, RX);
impl_mods!(ht, HT);
impl_mods!(fl, FL);
impl_mods!(so, SO);
}
+89
View File
@@ -0,0 +1,89 @@
use crate::osu::performance::OsuPerformance;
/// The result of a difficulty calculation on an osu!standard map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OsuDifficultyAttributes {
/// The difficulty of the aim skill.
pub aim: f64,
/// The difficulty of the speed skill.
pub speed: f64,
/// The difficulty of the flashlight skill.
pub flashlight: f64,
/// The ratio of the aim strain with and without considering sliders
pub slider_factor: f64,
/// The number of clickable objects weighted by difficulty.
pub speed_note_count: f64,
/// The approach rate.
pub ar: f64,
/// The overall difficulty
pub od: f64,
/// The health drain rate.
pub hp: f64,
/// The amount of circles.
pub n_circles: u32,
/// The amount of sliders.
pub n_sliders: u32,
/// The amount of spinners.
pub n_spinners: u32,
/// The final star rating
pub stars: f64,
/// The maximum combo.
pub max_combo: u32,
}
impl OsuDifficultyAttributes {
/// Return the maximum combo.
pub const fn max_combo(&self) -> u32 {
self.max_combo
}
/// Return the amount of hitobjects.
pub const fn n_objects(&self) -> u32 {
self.n_circles + self.n_sliders + self.n_spinners
}
/// Returns a builder for performance calculation.
pub fn pp<'a>(self) -> OsuPerformance<'a> {
self.into()
}
}
/// The result of a performance calculation on an osu!standard map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OsuPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation
pub difficulty: OsuDifficultyAttributes,
/// The final performance points.
pub pp: f64,
/// The accuracy portion of the final pp.
pub pp_acc: f64,
/// The aim portion of the final pp.
pub pp_aim: f64,
/// The flashlight portion of the final pp.
pub pp_flashlight: f64,
/// The speed portion of the final pp.
pub pp_speed: f64,
/// Misses including an approximated amount of slider breaks
pub effective_miss_count: f64,
}
impl OsuPerformanceAttributes {
/// Return the star value.
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
pub const fn max_combo(&self) -> u32 {
self.difficulty.max_combo
}
/// Return the amount of hitobjects.
pub const fn n_objects(&self) -> u32 {
self.difficulty.n_objects()
}
}
+235
View File
@@ -0,0 +1,235 @@
use std::borrow::Cow;
use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers};
use crate::model::{
beatmap::{Beatmap, Converted},
mode::ConvertStatus,
};
use super::{
attributes::OsuDifficultyAttributes,
difficulty::scaling_factor::ScalingFactor,
object::{OsuObject, OsuObjectKind},
Osu,
};
/// A [`Beatmap`] for [`Osu`] calculations.
pub type OsuBeatmap<'a> = Converted<'a, Osu>;
pub fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
if map.mode == GameMode::Osu {
ConvertStatus::Noop
} else {
ConvertStatus::Incompatible
}
}
pub fn convert_objects(
converted: &OsuBeatmap<'_>,
scaling_factor: &ScalingFactor,
hr: bool,
time_preempt: f64,
mut take: usize,
attrs: &mut OsuDifficultyAttributes,
) -> Vec<OsuObject> {
let mut curve_bufs = CurveBuffers::default();
let mut ticks_buf = Vec::new(); // TODO: default capacity
let mut osu_objects: Vec<_> = converted
.map
.hit_objects
.iter()
.map(|h| OsuObject::new(h, converted, &mut curve_bufs, &mut ticks_buf))
.inspect(|h| {
if take == 0 {
return;
}
take -= 1;
attrs.max_combo += 1;
match h.kind {
OsuObjectKind::Circle => attrs.n_circles += 1,
OsuObjectKind::Slider(ref slider) => {
attrs.n_sliders += 1;
attrs.max_combo += slider.nested_objects.len() as u32;
}
OsuObjectKind::Spinner(_) => attrs.n_spinners += 1,
}
})
.collect();
if hr {
osu_objects
.iter_mut()
.for_each(OsuObject::reflect_vertically);
} else {
osu_objects.iter_mut().for_each(OsuObject::finalize_tail);
}
let stack_threshold = time_preempt * f64::from(converted.map.stack_leniency);
if converted.map.version >= 6 {
stacking(&mut osu_objects, stack_threshold);
} else {
old_stacking(&mut osu_objects, stack_threshold);
}
for h in osu_objects.iter_mut() {
h.stack_offset = scaling_factor.stack_offset(h.stack_height);
if let OsuObjectKind::Slider(ref mut slider) = h.kind {
slider.lazy_end_pos += h.pos + h.stack_offset;
}
}
osu_objects
}
const STACK_DISTANCE: f32 = 3.0;
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
let mut extended_start_idx = 0;
let Some(extended_end_idx) = hit_objects.len().checked_sub(1) else {
return;
};
// First big `if` in osu!lazer's function can be skipped
for i in (1..=extended_end_idx).rev() {
let mut n = i;
let mut obj_i_idx = i;
// * We should check every note which has not yet got a stack.
// * Consider the case we have two interwound stacks and this will make sense.
// * o <-1 o <-2
// * o <-3 o <-4
// * We first process starting from 4 and handle 2,
// * then we come backwards on the i loop iteration until we reach 3 and handle 1.
// * 2 and 1 will be ignored in the i loop because they already have a stack value.
if hit_objects[obj_i_idx].stack_height != 0 || hit_objects[obj_i_idx].is_spinner() {
continue;
}
// * If this object is a hitcircle, then we enter this "special" case.
// * It either ends with a stack of hitcircles only,
// * or a stack of hitcircles that are underneath a slider.
// * Any other case is handled by the "is_slider" code below this.
if hit_objects[obj_i_idx].is_circle() {
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
}
if hit_objects[obj_i_idx].start_time - hit_objects[n].end_time() > stack_threshold {
break; // * We are no longer within stacking range of the previous object.
}
// * HitObjects before the specified update range haven't been reset yet
if n < extended_start_idx {
hit_objects[n].stack_height = 0;
extended_start_idx = n;
}
// * This is a special case where hticircles are moved DOWN and RIGHT (negative stacking)
// * if they are under the *last* slider in a stacked pattern.
// * o==o <- slider is at original location
// * o <- hitCircle has stack of -1
// * o <- hitCircle has stack of -2
if hit_objects[n].is_slider()
&& hit_objects[n]
.end_pos()
.distance(hit_objects[obj_i_idx].pos)
< STACK_DISTANCE
{
let offset =
hit_objects[obj_i_idx].stack_height - hit_objects[n].stack_height + 1;
for j in n + 1..=i {
// * For each object which was declared under this slider, we will offset
// * it to appear *below* the slider end (rather than above).
if hit_objects[n].end_pos().distance(hit_objects[j].pos) < STACK_DISTANCE {
hit_objects[j].stack_height -= offset;
}
}
// * We have hit a slider. We should restart calculation using this as the new base.
// * Breaking here will mean that the slider still has StackCount of 0,
// * so will be handled in the i-outer-loop.
break;
}
if hit_objects[n].pos.distance(hit_objects[obj_i_idx].pos) < STACK_DISTANCE {
// * Keep processing as if there are no sliders.
// * If we come across a slider, this gets cancelled out.
// * NOTE: Sliders with start positions stacking
// * are a special case that is also handled here.
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1;
obj_i_idx = n;
}
}
} else if hit_objects[obj_i_idx].is_slider() {
// * We have hit the first slider in a possible stack.
// * From this point on, we ALWAYS stack positive regardless.
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
}
if hit_objects[obj_i_idx].start_time - hit_objects[n].start_time > stack_threshold {
break; // * We are no longer within stacking range of the previous object.
}
if hit_objects[n]
.end_pos()
.distance(hit_objects[obj_i_idx].pos)
< STACK_DISTANCE
{
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1;
obj_i_idx = n;
}
}
}
}
}
fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
for i in 0..hit_objects.len() {
if hit_objects[i].stack_height != 0 && !hit_objects[i].is_slider() {
continue;
}
let mut start_time = hit_objects[i].end_time();
let pos2 = hit_objects[i].end_pos();
let mut slider_stack = 0;
for j in i + 1..hit_objects.len() {
if hit_objects[j].start_time - stack_threshold > start_time {
break;
}
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();
} 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();
}
}
}
}
+210
View File
@@ -0,0 +1,210 @@
use std::cmp;
use crate::{
any::difficulty::{skills::Skill, ModeDifficulty},
osu::{
convert::convert_objects,
difficulty::{object::OsuDifficultyObject, scaling_factor::ScalingFactor},
object::OsuObject,
performance::PERFORMANCE_BASE_MULTIPLIER,
},
util::mods::Mods,
};
use self::skills::{aim::Aim, flashlight::Flashlight, speed::Speed};
use super::{attributes::OsuDifficultyAttributes, convert::OsuBeatmap};
mod object;
pub mod scaling_factor;
mod skills;
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
const HD_FADE_IN_DURATION_MULTIPLIER: f64 = 0.4;
const HD_FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3;
pub fn difficulty(
difficulty: &ModeDifficulty,
converted: &OsuBeatmap<'_>,
) -> OsuDifficultyAttributes {
let DifficultyValues {
aim,
aim_no_sliders,
speed,
flashlight,
mut attrs,
} = DifficultyValues::calculate(difficulty, converted);
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let aim_rating_no_sliders = aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_notes = speed.relevant_note_count();
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 {
aim_rating_no_sliders / aim_rating
} else {
1.0
};
let mods = difficulty.get_mods();
if mods.td() {
aim_rating = aim_rating.powf(0.8);
flashlight_rating = flashlight_rating.powf(0.8);
}
if mods.rx() {
aim_rating *= 0.9;
speed_rating = 0.0;
flashlight_rating *= 0.7;
}
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_speed_performance = (5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_flashlight_performance = if mods.fl() {
flashlight_rating * flashlight_rating * 25.0
} else {
0.0
};
let base_performance = ((base_aim_performance).powf(1.1)
+ (base_speed_performance).powf(1.1)
+ (base_flashlight_performance).powf(1.1))
.powf(1.0 / 1.1);
let star_rating = if base_performance > 0.00001 {
PERFORMANCE_BASE_MULTIPLIER.cbrt()
* 0.027
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
} else {
0.0
};
attrs.aim = aim_rating;
attrs.speed = speed_rating;
attrs.flashlight = flashlight_rating;
attrs.slider_factor = slider_factor;
attrs.stars = star_rating;
attrs.speed_note_count = speed_notes;
attrs
}
pub struct DifficultyValues {
pub aim: Aim,
pub aim_no_sliders: Aim,
pub speed: Speed,
pub flashlight: Flashlight,
pub attrs: OsuDifficultyAttributes,
}
impl DifficultyValues {
pub fn calculate(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> Self {
let mods = difficulty.get_mods();
let take = difficulty.get_passed_objects();
let clock_rate = difficulty.get_clock_rate();
let map_attrs = converted
.attributes()
.mods(mods)
.clock_rate(clock_rate)
.build();
let scaling_factor = ScalingFactor::new(map_attrs.cs);
let hr = mods.hr();
let hit_window = 2.0 * map_attrs.hit_windows.od;
let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32);
// * 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.
// * This uniform speedup is hard to match 1:1, however we can at least make
// * AR>10 (via mods) feel good by extending the upper linear function above.
// * Note that this doesn't exactly match the AR>10 visuals as they're
// * classically known, but it feels good.
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
// * become smaller leading to hitcircles not fully fading in.
let time_fade_in = if mods.hd() {
time_preempt * HD_FADE_IN_DURATION_MULTIPLIER
} else {
400.0 * (time_preempt / OsuObject::PREEMPT_MIN).min(1.0)
};
let mut attrs = OsuDifficultyAttributes {
ar: map_attrs.ar,
hp: map_attrs.hp,
od: map_attrs.od,
..Default::default()
};
let mut osu_objects = convert_objects(
converted,
&scaling_factor,
hr,
time_preempt,
take,
&mut attrs,
);
let mut osu_objects_iter = osu_objects
.iter_mut()
.map(|h| OsuDifficultyObject::compute_slider_cursor_pos(h, scaling_factor.radius));
let aim = Aim::new(true);
let aim_no_sliders = Aim::new(false);
let speed = Speed::new(hit_window);
let flashlight = Flashlight::new(mods, scaling_factor.radius, time_preempt, time_fade_in);
let Some(mut last) = osu_objects_iter.next().filter(|_| take > 0) else {
return Self {
aim,
aim_no_sliders,
speed,
flashlight,
attrs,
};
};
let mut last_last = None;
let diff_objects: Vec<_> = osu_objects_iter
.enumerate()
.map(|(idx, h)| {
let diff_object =
OsuDifficultyObject::new(h, last, last_last, clock_rate, idx, &scaling_factor);
last_last = Some(last);
last = h;
diff_object
})
.collect();
let mut aim = Skill::new(aim, &diff_objects);
let mut aim_no_sliders = Skill::new(aim_no_sliders, &diff_objects);
let mut speed = Skill::new(speed, &diff_objects);
let mut flashlight = Skill::new(flashlight, &diff_objects);
// The first hit object has no difficulty object
let take_diff_objects = cmp::min(converted.map.hit_objects.len(), take) - 1;
for hit_object in diff_objects.iter().take(take_diff_objects) {
aim.process(hit_object);
aim_no_sliders.process(hit_object);
speed.process(hit_object);
flashlight.process(hit_object);
}
Self {
aim: aim.inner,
aim_no_sliders: aim_no_sliders.inner,
speed: speed.inner,
flashlight: flashlight.inner,
attrs,
}
}
}
+212
View File
@@ -0,0 +1,212 @@
use rosu_map::util::Pos;
use crate::{
any::difficulty::object::IDifficultyObject,
osu::object::{OsuObject, OsuObjectKind},
};
use super::{scaling_factor::ScalingFactor, HD_FADE_OUT_DURATION_MULTIPLIER};
pub struct OsuDifficultyObject<'a> {
pub idx: usize,
pub base: &'a OsuObject,
pub start_time: f64,
pub delta_time: f64,
pub strain_time: f64,
pub lazy_jump_dist: f64,
pub min_jump_dist: f64,
pub min_jump_time: f64,
pub travel_dist: f64,
pub travel_time: f64,
pub angle: Option<f64>,
}
impl<'a> OsuDifficultyObject<'a> {
pub const NORMALIZED_RADIUS: f32 = 50.0;
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;
pub fn new(
hit_object: &'a OsuObject,
last_object: &'a OsuObject,
last_last_object: Option<&OsuObject>,
clock_rate: f64,
idx: usize,
scaling_factor: &ScalingFactor,
) -> Self {
let delta_time = (hit_object.start_time - last_object.start_time) / clock_rate;
let start_time = hit_object.start_time / clock_rate;
let strain_time = delta_time.max(Self::MIN_DELTA_TIME);
let mut this = Self {
idx,
base: hit_object,
start_time,
delta_time,
strain_time,
lazy_jump_dist: 0.0,
min_jump_dist: 0.0,
min_jump_time: 0.0,
travel_dist: 0.0,
travel_time: 0.0,
angle: None,
};
this.set_distances(last_object, last_last_object, clock_rate, scaling_factor);
this
}
pub fn opacity_at(&self, time: f64, hidden: bool, time_preempt: f64, time_fade_in: f64) -> f64 {
if time > self.base.start_time {
// * Consider a hitobject as being invisible when its start time is passed.
// * In reality the hitobject will be visible beyond its start time up until its hittable window has passed,
// * but this is an approximation and such a case is unlikely to be hit where this function is used.
return 0.0;
}
let fade_in_start_time = self.base.start_time - time_preempt;
let fade_in_duration = time_fade_in;
if hidden {
// * Taken from OsuModHidden.
let fade_out_start_time = self.base.start_time - time_preempt + time_fade_in;
let fade_out_duration = time_preempt * HD_FADE_OUT_DURATION_MULTIPLIER;
(((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0))
.min(1.0 - ((time - fade_out_start_time) / fade_out_duration).clamp(0.0, 1.0))
} else {
((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0)
}
}
fn set_distances(
&mut self,
last_object: &OsuObject,
last_last_object: Option<&OsuObject>,
clock_rate: f64,
scaling_factor: &ScalingFactor,
) {
if let OsuObjectKind::Slider(ref slider) = self.base.kind {
self.travel_dist = f64::from(
slider.lazy_travel_dist
* ((1.0 + slider.repeat_count() as f64 / 2.5).powf(1.0 / 2.5)) as f32,
);
self.travel_time = (self.base.lazy_travel_time() / clock_rate)
.max(OsuDifficultyObject::MIN_DELTA_TIME);
}
if self.base.is_spinner() || last_object.is_spinner() {
return;
}
let scaling_factor = scaling_factor.factor_with_small_circle_bonus;
let last_cursor_pos = Self::get_end_cursor_pos(last_object);
self.lazy_jump_dist = f64::from(
(self.base.stacked_pos() * scaling_factor - last_cursor_pos * scaling_factor).length(),
);
self.min_jump_time = self.strain_time;
self.min_jump_dist = self.lazy_jump_dist;
if let OsuObjectKind::Slider(ref last_slider) = last_object.kind {
let last_travel_time = (last_object.lazy_travel_time() / clock_rate)
.max(OsuDifficultyObject::MIN_DELTA_TIME);
self.min_jump_time =
(self.strain_time - last_travel_time).max(OsuDifficultyObject::MIN_DELTA_TIME);
let tail_pos = last_slider
.nested_objects
.last()
.map_or(last_object.pos, |nested| nested.pos);
let stacked_tail_pos = tail_pos + last_object.stack_offset;
let tail_jump_dist =
(stacked_tail_pos - self.base.stacked_pos()).length() * scaling_factor;
let diff = f64::from(
OsuDifficultyObject::MAX_SLIDER_RADIUS - OsuDifficultyObject::ASSUMED_SLIDER_RADIUS,
);
let min = f64::from(tail_jump_dist - OsuDifficultyObject::MAX_SLIDER_RADIUS);
self.min_jump_dist = ((self.lazy_jump_dist - diff).min(min)).max(0.0);
}
if let Some(last_last_object) = last_last_object.filter(|h| !h.is_spinner()) {
let last_last_cursor_pos = Self::get_end_cursor_pos(last_last_object);
let v1 = last_last_cursor_pos - last_object.stacked_pos();
let v2 = self.base.stacked_pos() - last_cursor_pos;
let dot = v1.dot(v2);
let det = v1.x * v2.y - v1.y * v2.x;
self.angle = Some((f64::from(det).atan2(f64::from(dot))).abs());
}
}
pub fn compute_slider_cursor_pos(h: &mut OsuObject, radius: f64) -> &OsuObject {
let stack_offset = h.stack_offset;
let OsuObjectKind::Slider(ref mut slider) = h.kind else {
return h;
};
let mut curr_cursor_pos = h.pos + stack_offset;
let scaling_factor = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS) / radius;
for (curr_movement_obj, i) in slider.nested_objects.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() {
let lazy_movement = slider.lazy_end_pos - curr_cursor_pos;
if lazy_movement.length() < curr_movement.length() {
curr_movement = lazy_movement;
}
curr_movement_len = scaling_factor * f64::from(curr_movement.length());
} else if curr_movement_obj.is_repeat() {
required_movement = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS);
}
if curr_movement_len > required_movement {
curr_cursor_pos += curr_movement
* ((curr_movement_len - required_movement) / curr_movement_len) as f32;
curr_movement_len *= (curr_movement_len - required_movement) / curr_movement_len;
slider.lazy_travel_dist += curr_movement_len as f32;
}
if i == slider.nested_objects.len() {
slider.lazy_end_pos = curr_cursor_pos;
}
}
h
}
fn get_end_cursor_pos(hit_object: &OsuObject) -> Pos {
if let OsuObjectKind::Slider(ref slider) = hit_object.kind {
// We don't have access to the slider's curve at this point so we
// take the pre-computed value.
slider.lazy_end_pos
} else {
hit_object.stacked_pos()
}
}
}
impl IDifficultyObject for OsuDifficultyObject<'_> {
fn idx(&self) -> usize {
self.idx
}
}
+46
View File
@@ -0,0 +1,46 @@
use rosu_map::util::Pos;
use crate::osu::object::OsuObject;
use super::object::OsuDifficultyObject;
/// Fields around the scaling of hit objects.
///
/// osu!lazer stores these in each hit object but since all objects share the
/// same scaling (w.r.t. difficulty & performance), we store them only once.
pub struct ScalingFactor {
/// `NORMALIZED_RADIUS / Radius`
pub factor: f32,
/// `NORMALIZED_RADIUS / Radius` and then adjusted if `Radius < 30`
pub factor_with_small_circle_bonus: f32,
pub radius: f64,
pub scale: f32,
}
impl ScalingFactor {
pub fn new(cs: f64) -> Self {
let scale = (1.0 - 0.7 * (cs as f32 - 5.0) / 5.0) / 2.0;
let radius = f64::from(OsuObject::OBJECT_RADIUS * scale);
let factor = OsuDifficultyObject::NORMALIZED_RADIUS / radius as f32;
let factor_with_small_circle_bonus = if radius < 30.0 {
factor * (1.0 + (30.0 - radius as f32).min(5.0) / 50.0)
} else {
factor
};
Self {
factor,
factor_with_small_circle_bonus,
radius,
scale,
}
}
pub fn stack_offset(&self, stack_height: i32) -> Pos {
let stack_offset = stack_height as f32 * self.scale * -6.4;
Pos::new(stack_offset, stack_offset)
}
}
@@ -1,100 +1,104 @@
use std::f64::consts::{FRAC_PI_2, PI};
use crate::{osu::difficulty_object::OsuDifficultyObject, util::CompactVec};
use crate::{
any::difficulty::{
object::IDifficultyObject,
skills::{strain_decay, ISkill, Skill},
},
osu::difficulty::object::OsuDifficultyObject,
util::float_ext::FloatExt,
};
use super::{previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
use super::strain::OsuStrainSkill;
#[derive(Clone, Debug)]
pub(crate) struct Aim {
curr_strain: f64,
curr_section_peak: f64,
curr_section_end: f64,
pub(crate) strain_peaks: CompactVec,
const SKILL_MULTIPLIER: f64 = 23.55;
const STRAIN_DECAY_BASE: f64 = 0.15;
pub struct Aim {
with_sliders: bool,
curr_strain: f64,
inner: OsuStrainSkill,
}
impl Aim {
const SKILL_MULTIPLIER: f64 = 23.55;
const STRAIN_DECAY_BASE: f64 = 0.15;
pub(crate) fn new(with_sliders: bool) -> Self {
pub fn new(with_sliders: bool) -> Self {
Self {
curr_strain: 0.0,
curr_section_peak: 0.0,
curr_section_end: 0.0,
strain_peaks: CompactVec::new(),
with_sliders,
curr_strain: 0.0,
inner: OsuStrainSkill::default(),
}
}
fn strain_decay(ms: f64) -> f64 {
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub fn difficulty_value(self) -> f64 {
self.inner.difficulty_value(
OsuStrainSkill::REDUCED_SECTION_COUNT,
OsuStrainSkill::REDUCED_STRAIN_BASELINE,
OsuStrainSkill::DECAY_WEIGHT,
OsuStrainSkill::DIFFICULTY_MULTIPLER,
)
}
}
impl Skill for Aim {
#[inline]
fn process(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
<Self as StrainSkill>::process(self, curr, diff_objects);
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as OsuStrainSkill>::difficulty_value(self)
}
impl ISkill for Aim {
type DifficultyObjects<'a> = [OsuDifficultyObject<'a>];
}
impl StrainSkill for Aim {
#[inline]
fn strain_peaks_mut(&mut self) -> &mut CompactVec {
&mut self.strain_peaks
impl<'a> Skill<'a, Aim> {
fn calculate_initial_strain(&mut self, time: f64, curr: &'a OsuDifficultyObject<'a>) -> f64 {
let prev_start_time = curr
.previous(0, self.diff_objects)
.map_or(0.0, |prev| prev.start_time);
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
}
#[inline]
fn curr_section_peak(&mut self) -> &mut f64 {
&mut self.curr_section_peak
const fn curr_section_peak(&self) -> f64 {
self.inner.inner.inner.curr_section_peak
}
#[inline]
fn curr_section_end(&mut self) -> &mut f64 {
&mut self.curr_section_end
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_peak
}
#[inline]
fn strain_value_at(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
self.curr_strain *= Self::strain_decay(curr.delta_time);
self.curr_strain += AimEvaluator::evaluate_diff_of(curr, diff_objects, self.with_sliders)
* Self::SKILL_MULTIPLIER;
self.curr_strain
const fn curr_section_end(&self) -> f64 {
self.inner.inner.inner.curr_section_end
}
#[inline]
fn calculate_initial_strain(
&self,
time: f64,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
self.curr_strain * Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_end
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as OsuStrainSkill>::difficulty_value(self)
pub fn process(&mut self, curr: &'a OsuDifficultyObject<'a>) {
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / OsuStrainSkill::SECTION_LEN).ceil()
* OsuStrainSkill::SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.inner.inner.save_curr_peak();
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
self.inner.inner.start_new_section_from(initial_strain);
*self.curr_section_end_mut() += OsuStrainSkill::SECTION_LEN;
}
let strain_value_at = self.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
fn strain_value_at(&mut self, curr: &'a OsuDifficultyObject<'a>) -> f64 {
self.inner.curr_strain *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE);
self.inner.curr_strain +=
AimEvaluator::evaluate_diff_of(curr, self.diff_objects, self.inner.with_sliders)
* SKILL_MULTIPLIER;
self.inner.curr_strain
}
}
impl OsuStrainSkill for Aim {}
struct AimEvaluator;
impl AimEvaluator {
@@ -103,15 +107,16 @@ impl AimEvaluator {
const SLIDER_MULTIPLIER: f64 = 1.35;
const VELOCITY_CHANGE_MULTIPLIER: f64 = 0.75;
fn evaluate_diff_of(
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
fn evaluate_diff_of<'a>(
curr: &'a OsuDifficultyObject<'a>,
diff_objects: &'a [OsuDifficultyObject<'a>],
with_sliders: bool,
) -> f64 {
let osu_curr_obj = curr;
let Some((osu_last_last_obj, osu_last_obj)) = previous(diff_objects, curr.idx, 1)
.zip(previous(diff_objects, curr.idx, 0))
let Some((osu_last_last_obj, osu_last_obj)) = curr
.previous(1, diff_objects)
.zip(curr.previous(0, diff_objects))
.filter(|(_, last)| !(curr.base.is_spinner() || last.base.is_spinner()))
else {
return 0.0;
@@ -119,27 +124,26 @@ impl AimEvaluator {
// * Calculate the velocity to the current hitobject, which starts
// * with a base distance / time assuming the last object is a hitcircle.
let mut curr_vel = osu_curr_obj.dists.lazy_jump_dist / osu_curr_obj.strain_time;
let mut curr_vel = osu_curr_obj.lazy_jump_dist / osu_curr_obj.strain_time;
// * 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 {
// * calculate the slider velocity from slider head to slider end.
let travel_vel = osu_last_obj.dists.travel_dist / osu_last_obj.dists.travel_time;
let travel_vel = osu_last_obj.travel_dist / osu_last_obj.travel_time;
// * calculate the movement velocity from slider end to current object
let movement_vel = osu_curr_obj.dists.min_jump_dist / osu_curr_obj.dists.min_jump_time;
let movement_vel = osu_curr_obj.min_jump_dist / osu_curr_obj.min_jump_time;
// * take the larger total combined velocity.
curr_vel = curr_vel.max(movement_vel + travel_vel);
}
// * As above, do the same for the previous hitobject.
let mut prev_vel = osu_last_obj.dists.lazy_jump_dist / osu_last_obj.strain_time;
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 {
let travel_vel =
osu_last_last_obj.dists.travel_dist / osu_last_last_obj.dists.travel_time;
let movement_vel = osu_last_obj.dists.min_jump_dist / osu_last_obj.dists.min_jump_time;
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;
prev_vel = prev_vel.max(movement_vel + travel_vel);
}
@@ -157,10 +161,9 @@ impl AimEvaluator {
< 1.25 * osu_curr_obj.strain_time.min(osu_last_obj.strain_time)
{
if let Some(((curr_angle, last_angle), last_last_angle)) = osu_curr_obj
.dists
.angle
.zip(osu_last_obj.dists.angle)
.zip(osu_last_last_obj.dists.angle)
.zip(osu_last_obj.angle)
.zip(osu_last_last_obj.angle)
{
// * Rewarding angles, take the smaller velocity as base.
let angle_bonus = curr_vel.min(prev_vel);
@@ -176,7 +179,7 @@ impl AimEvaluator {
(FRAC_PI_2 * ((100.0 - osu_curr_obj.strain_time) / 25.0).min(1.0)).sin();
let base2 = (FRAC_PI_2
* ((osu_curr_obj.dists.lazy_jump_dist).clamp(50.0, 100.0) - 50.0)
* ((osu_curr_obj.lazy_jump_dist).clamp(50.0, 100.0) - 50.0)
/ 50.0)
.sin();
@@ -204,13 +207,13 @@ impl AimEvaluator {
}
}
if prev_vel.max(curr_vel).abs() > f64::EPSILON {
if prev_vel.max(curr_vel).not_eq(0.0) {
// * We want to use the average velocity over the whole object when awarding
// * differences, not the individual jump and slider path velocities.
prev_vel = (osu_last_obj.dists.lazy_jump_dist + osu_last_last_obj.dists.travel_dist)
prev_vel = (osu_last_obj.lazy_jump_dist + osu_last_last_obj.travel_dist)
/ osu_last_obj.strain_time;
curr_vel = (osu_curr_obj.dists.lazy_jump_dist + osu_last_obj.dists.travel_dist)
/ osu_curr_obj.strain_time;
curr_vel =
(osu_curr_obj.lazy_jump_dist + osu_last_obj.travel_dist) / osu_curr_obj.strain_time;
// * Scale with ratio of difference compared to 0.5 * max dist.
let dist_ratio_base =
@@ -231,7 +234,7 @@ impl AimEvaluator {
if osu_last_obj.base.is_slider() {
// * Reward sliders based on velocity.
slider_bonus = osu_last_obj.dists.travel_dist / osu_last_obj.dists.travel_time;
slider_bonus = osu_last_obj.travel_dist / osu_last_obj.travel_time;
}
// * Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
+230
View File
@@ -0,0 +1,230 @@
use crate::{
any::difficulty::{
object::IDifficultyObject,
skills::{strain_decay, ISkill, Skill, StrainSkill},
},
osu::{difficulty::object::OsuDifficultyObject, object::OsuObjectKind},
util::mods::Mods,
};
use super::strain::OsuStrainSkill;
const SKILL_MULTIPLIER: f64 = 0.052;
const STRAIN_DECAY_BASE: f64 = 0.15;
pub struct Flashlight {
curr_strain: f64,
has_hidden_mod: bool,
inner: StrainSkill,
evaluator: FlashlightEvaluator,
}
impl Flashlight {
pub fn new(mods: u32, radius: f64, time_preempt: f64, time_fade_in: f64) -> Self {
let scaling_factor = 52.0 / radius;
Self {
curr_strain: 0.0,
has_hidden_mod: mods.hd(),
inner: StrainSkill::default(),
evaluator: FlashlightEvaluator::new(scaling_factor, time_preempt, time_fade_in),
}
}
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub fn difficulty_value(self) -> f64 {
self.get_curr_strain_peaks().into_iter().sum::<f64>() * OsuStrainSkill::DIFFICULTY_MULTIPLER
}
}
impl ISkill for Flashlight {
type DifficultyObjects<'a> = [OsuDifficultyObject<'a>];
}
impl<'a> Skill<'a, Flashlight> {
fn calculate_initial_strain(&mut self, time: f64, curr: &'a OsuDifficultyObject<'a>) -> f64 {
let prev_start_time = curr
.previous(0, self.diff_objects)
.map_or(0.0, |prev| prev.start_time);
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
}
const fn curr_section_peak(&self) -> f64 {
self.inner.inner.curr_section_peak
}
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.curr_section_peak
}
const fn curr_section_end(&self) -> f64 {
self.inner.inner.curr_section_end
}
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.curr_section_end
}
pub fn process(&mut self, curr: &'a OsuDifficultyObject<'a>) {
if curr.idx == 0 {
*self.curr_section_end_mut() =
(curr.start_time / StrainSkill::SECTION_LEN).ceil() * StrainSkill::SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.inner.inner.save_curr_peak();
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
self.inner.inner.start_new_section_from(initial_strain);
*self.curr_section_end_mut() += StrainSkill::SECTION_LEN;
}
let strain_value_at = self.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
fn strain_value_at(&mut self, curr: &'a OsuDifficultyObject<'a>) -> f64 {
self.inner.curr_strain *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE);
self.inner.curr_strain += self.inner.evaluator.evaluate_diff_of(
curr,
self.diff_objects,
self.inner.has_hidden_mod,
) * SKILL_MULTIPLIER;
self.inner.curr_strain
}
}
struct FlashlightEvaluator {
scaling_factor: f64,
time_preempt: f64,
time_fade_in: f64,
}
impl FlashlightEvaluator {
const MAX_OPACITY_BONUS: f64 = 0.4;
const HIDDEN_BONUS: f64 = 0.2;
const MIN_VELOCITY: f64 = 0.5;
const SLIDER_MULTIPLIER: f64 = 1.3;
const MIN_ANGLE_MULTIPLIER: f64 = 0.2;
const fn new(scaling_factor: f64, time_preempt: f64, time_fade_in: f64) -> Self {
Self {
scaling_factor,
time_preempt,
time_fade_in,
}
}
fn evaluate_diff_of<'a>(
&self,
curr: &'a OsuDifficultyObject<'a>,
diff_objects: &'a [OsuDifficultyObject<'a>],
hidden: bool,
) -> f64 {
if curr.base.is_spinner() {
return 0.0;
}
let osu_curr = curr;
let osu_hit_obj = curr.base;
let mut small_dist_nerf = 1.0;
let mut cumulative_strain_time = 0.0;
let mut result = 0.0;
let mut last_obj = osu_curr;
let mut angle_repeat_count = 0.0;
// * This is iterating backwards in time from the current object.
for i in 0..curr.idx.min(10) {
let Some(curr_obj) = curr.previous(i, diff_objects) else {
break;
};
let curr_hit_obj = curr_obj.base;
if !curr_obj.base.is_spinner() {
let jump_dist = f64::from(
(osu_hit_obj.stacked_pos() - curr_hit_obj.stacked_end_pos()).length(),
);
cumulative_strain_time += last_obj.strain_time;
// * We want to nerf objects that can be easily seen within the Flashlight circle radius.
if i == 0 {
small_dist_nerf = (jump_dist / 75.0).min(1.0);
}
// * We also want to nerf stacks so that only the first object of the stack is accounted for.
let stack_nerf = ((curr_obj.lazy_jump_dist / self.scaling_factor) / 25.0).min(1.0);
// * Bonus based on how visible the object is.
let opacity_bonus = 1.0
+ Self::MAX_OPACITY_BONUS
* (1.0
- osu_curr.opacity_at(
curr_hit_obj.start_time,
hidden,
self.time_preempt,
self.time_fade_in,
));
result += stack_nerf * opacity_bonus * self.scaling_factor * jump_dist
/ cumulative_strain_time;
if let Some((curr_obj_angle, osu_curr_angle)) = curr_obj.angle.zip(osu_curr.angle) {
// * Objects further back in time should count less for the nerf.
if (curr_obj_angle - osu_curr_angle).abs() < 0.02 {
angle_repeat_count += (1.0 - 0.1 * i as f64).max(0.0);
}
}
}
last_obj = curr_obj;
}
let base = small_dist_nerf * result;
result = base * base;
// * Additional bonus for Hidden due to there being no approach circles.
if hidden {
result *= 1.0 + Self::HIDDEN_BONUS;
}
// * Nerf patterns with repeated angles.
result *= Self::MIN_ANGLE_MULTIPLIER
+ (1.0 - Self::MIN_ANGLE_MULTIPLIER) / (angle_repeat_count + 1.0);
let mut slider_bonus = 0.0;
if let OsuObjectKind::Slider(slider) = &osu_curr.base.kind {
// * Invert the scaling factor to determine the true travel distance independent of circle size.
let pixel_travel_dist = f64::from(slider.lazy_travel_dist) / self.scaling_factor;
// * Reward sliders based on velocity.
slider_bonus =
((pixel_travel_dist / osu_curr.travel_time - Self::MIN_VELOCITY).max(0.0)).sqrt();
// * Longer sliders require more memorisation.
slider_bonus *= pixel_travel_dist;
// * Nerf sliders with repeats, as less memorisation is required.
let repeat_count = slider.repeat_count();
if repeat_count > 0 {
slider_bonus /= (repeat_count + 1) as f64;
}
}
result += slider_bonus * Self::SLIDER_MULTIPLIER;
result
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod aim;
pub mod flashlight;
pub mod speed;
pub mod strain;
@@ -1,45 +1,58 @@
use std::{cmp::Ordering, f64::consts::PI};
use std::f64::consts::PI;
use crate::{osu::difficulty_object::OsuDifficultyObject, util::CompactVec};
use crate::{
any::difficulty::{
object::IDifficultyObject,
skills::{strain_decay, ISkill, Skill},
},
osu::difficulty::object::OsuDifficultyObject,
};
use super::{next, previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
use super::strain::OsuStrainSkill;
#[derive(Clone, Debug)]
pub(crate) struct Speed {
const SKILL_MULTIPLIER: f64 = 1375.0;
const STRAIN_DECAY_BASE: f64 = 0.3;
const DIFFICULTY_MULTIPLER: f64 = 1.04;
const REDUCED_SECTION_COUNT: usize = 5;
pub struct Speed {
curr_strain: f64,
curr_section_peak: f64,
curr_section_end: f64,
curr_rhythm: f64,
pub(crate) strain_peaks: CompactVec,
object_strains: Vec<f64>,
hit_window: f64,
inner: OsuStrainSkill,
}
impl Speed {
const SKILL_MULTIPLIER: f64 = 1375.0;
const STRAIN_DECAY_BASE: f64 = 0.3;
pub(crate) fn new(hit_window: f64) -> Self {
pub fn new(hit_window: f64) -> Self {
Self {
curr_strain: 0.0,
curr_section_peak: 0.0,
curr_section_end: 0.0,
curr_rhythm: 0.0,
strain_peaks: CompactVec::new(),
object_strains: Vec::new(),
object_strains: Vec::new(), // TODO: default capacity
hit_window,
inner: OsuStrainSkill::default(),
}
}
fn strain_decay(ms: f64) -> f64 {
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub(crate) fn relevant_note_count(&self) -> f64 {
pub fn difficulty_value(self) -> f64 {
self.inner.difficulty_value(
REDUCED_SECTION_COUNT,
OsuStrainSkill::REDUCED_STRAIN_BASELINE,
OsuStrainSkill::DECAY_WEIGHT,
DIFFICULTY_MULTIPLER,
)
}
pub fn relevant_note_count(&self) -> f64 {
self.object_strains
.iter()
.max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
.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| {
@@ -49,75 +62,66 @@ impl Speed {
}
}
impl Skill for Speed {
#[inline]
fn process(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
<Self as StrainSkill>::process(self, curr, diff_objects);
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as OsuStrainSkill>::difficulty_value(self)
}
impl ISkill for Speed {
type DifficultyObjects<'a> = [OsuDifficultyObject<'a>];
}
impl StrainSkill for Speed {
#[inline]
fn strain_peaks_mut(&mut self) -> &mut CompactVec {
&mut self.strain_peaks
impl<'a> Skill<'a, Speed> {
fn calculate_initial_strain(&mut self, time: f64, curr: &'a OsuDifficultyObject<'a>) -> f64 {
let prev_start_time = curr
.previous(0, self.diff_objects)
.map_or(0.0, |prev| prev.start_time);
(self.inner.curr_strain * self.inner.curr_rhythm)
* strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
}
#[inline]
fn curr_section_peak(&mut self) -> &mut f64 {
&mut self.curr_section_peak
const fn curr_section_peak(&self) -> f64 {
self.inner.inner.inner.curr_section_peak
}
#[inline]
fn curr_section_end(&mut self) -> &mut f64 {
&mut self.curr_section_end
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_peak
}
#[inline]
fn strain_value_at(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
self.curr_strain *= Self::strain_decay(curr.strain_time);
self.curr_strain += SpeedEvaluator::evaluate_diff_of(curr, diff_objects, self.hit_window)
* Self::SKILL_MULTIPLIER;
self.curr_rhythm = RhythmEvaluator::evaluate_diff_of(curr, diff_objects, self.hit_window);
const fn curr_section_end(&self) -> f64 {
self.inner.inner.inner.curr_section_end
}
let total_strain = self.curr_strain * self.curr_rhythm;
self.object_strains.push(total_strain);
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_end
}
pub fn process(&mut self, curr: &'a OsuDifficultyObject<'a>) {
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / OsuStrainSkill::SECTION_LEN).ceil()
* OsuStrainSkill::SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.inner.inner.save_curr_peak();
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
self.inner.inner.start_new_section_from(initial_strain);
*self.curr_section_end_mut() += OsuStrainSkill::SECTION_LEN;
}
let strain_value_at = self.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
fn strain_value_at(&mut self, curr: &'a OsuDifficultyObject<'a>) -> f64 {
self.inner.curr_strain *= strain_decay(curr.strain_time, STRAIN_DECAY_BASE);
self.inner.curr_strain +=
SpeedEvaluator::evaluate_diff_of(curr, self.diff_objects, self.inner.hit_window)
* SKILL_MULTIPLIER;
self.inner.curr_rhythm =
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);
total_strain
}
#[inline]
fn calculate_initial_strain(
&self,
time: f64,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
(self.curr_strain * self.curr_rhythm)
* Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as OsuStrainSkill>::difficulty_value(self)
}
}
impl OsuStrainSkill for Speed {
const REDUCED_SECTION_COUNT: usize = 5;
const DIFFICULTY_MULTIPLER: f64 = 1.04;
}
struct SpeedEvaluator;
@@ -127,9 +131,9 @@ impl SpeedEvaluator {
const MIN_SPEED_BONUS: f64 = 75.0; // ~200BPM
const SPEED_BALANCING_FACTOR: f64 = 40.;
fn evaluate_diff_of(
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
fn evaluate_diff_of<'a>(
curr: &'a OsuDifficultyObject<'a>,
diff_objects: &'a [OsuDifficultyObject<'a>],
hit_window: f64,
) -> f64 {
if curr.base.is_spinner() {
@@ -138,8 +142,8 @@ impl SpeedEvaluator {
// * derive strainTime for calculation
let osu_curr_obj = curr;
let osu_prev_obj = previous(diff_objects, curr.idx, 0);
let osu_next_obj = next(diff_objects, curr.idx, 0);
let osu_prev_obj = curr.previous(0, diff_objects);
let osu_next_obj = curr.next(0, diff_objects);
let mut strain_time = curr.strain_time;
let mut doubletapness = 1.0;
@@ -168,9 +172,8 @@ impl SpeedEvaluator {
1.0
};
let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.dists.travel_dist);
let dist =
Self::SINGLE_SPACING_THRESHOLD.min(travel_dist + osu_curr_obj.dists.min_jump_dist);
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);
(speed_bonus + speed_bonus * (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.5))
* doubletapness
@@ -185,9 +188,9 @@ impl RhythmEvaluator {
const HISTORY_TIME_MAX: u32 = 5000;
const RHYTHM_MULTIPLIER: f64 = 0.75;
fn evaluate_diff_of(
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
fn evaluate_diff_of<'a>(
curr: &'a OsuDifficultyObject<'a>,
diff_objects: &'a [OsuDifficultyObject<'a>],
hit_window: f64,
) -> f64 {
if curr.base.is_spinner() {
@@ -207,7 +210,8 @@ impl RhythmEvaluator {
let mut rhythm_start = 0;
while previous(diff_objects, curr.idx, rhythm_start)
while curr
.previous(rhythm_start, diff_objects)
.filter(|prev| {
rhythm_start + 2 < historical_note_count
&& curr.start_time - prev.start_time < f64::from(Self::HISTORY_TIME_MAX)
@@ -218,9 +222,10 @@ impl RhythmEvaluator {
}
for i in (1..=rhythm_start).rev() {
let Some(((curr_obj, prev_obj), last_obj)) = previous(diff_objects, curr.idx, i - 1)
.zip(previous(diff_objects, curr.idx, i))
.zip(previous(diff_objects, curr.idx, i + 1))
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;
};
+63
View File
@@ -0,0 +1,63 @@
use crate::any::difficulty::skills::StrainSkill;
#[derive(Default)]
pub struct OsuStrainSkill {
pub inner: StrainSkill,
}
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;
pub fn save_curr_peak(&mut self) {
self.inner.save_curr_peak();
}
pub fn start_new_section_from(&mut self, initial_strain: f64) {
self.inner.start_new_section_from(initial_strain);
}
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub fn difficulty_value(
self,
reduced_section_count: usize,
reduced_strain_baseline: f64,
decay_weight: f64,
difficulty_multiplier: f64,
) -> f64 {
let mut difficulty = 0.0;
let mut weight = 1.0;
let mut peaks = self.get_curr_strain_peaks();
peaks.retain(|&strain| strain > 0.0);
peaks.sort_by(|a, b| b.total_cmp(a));
let peaks_iter = peaks.iter_mut().take(reduced_section_count);
for (i, strain) in peaks_iter.enumerate() {
let clamped = f64::from((i as f32 / reduced_section_count as f32).clamp(0.0, 1.0));
let scale = (lerp(1.0, 10.0, clamped)).log10();
*strain *= lerp(reduced_strain_baseline, 1.0, scale);
}
peaks.sort_by(|a, b| b.total_cmp(a));
for strain in peaks {
difficulty += strain * weight;
weight *= decay_weight;
}
difficulty * difficulty_multiplier
}
}
fn lerp(start: f64, end: f64, amount: f64) -> f64 {
start + (end - start) * amount
}
-261
View File
@@ -1,261 +0,0 @@
use crate::{
osu::osu_object::{NestedObjectKind, OsuObjectKind},
parse::Pos2,
};
use std::pin::Pin;
use super::{osu_object::OsuSlider, OsuObject, ScalingFactor};
#[derive(Clone, Debug)]
pub(crate) struct OsuDifficultyObject<'h> {
pub(crate) start_time: f64,
pub(crate) delta_time: f64,
pub(crate) base: Pin<&'h OsuObject>,
pub(crate) strain_time: f64,
pub(crate) dists: Distances,
pub(crate) idx: usize,
}
impl<'h> OsuDifficultyObject<'h> {
pub(crate) const MIN_DELTA_TIME: u32 = 25;
pub(crate) fn new(
base: Pin<&'h OsuObject>,
last: &'h OsuObject,
clock_rate: f64,
idx: usize,
dists: Distances,
) -> Self {
let start_time = base.start_time / clock_rate;
let delta_time = (base.start_time - last.start_time) / clock_rate;
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
let strain_time = delta_time.max(f64::from(Self::MIN_DELTA_TIME));
Self {
start_time,
delta_time,
base,
strain_time,
dists,
idx,
}
}
pub(crate) fn opacity_at(
&self,
time: f64,
hidden: bool,
time_preempt: f64,
time_fade_in: f64,
) -> f64 {
if time > self.base.start_time {
// * Consider a hitobject as being invisible when its start time is passed.
// * In reality the hitobject will be visible beyond its start time up until its hittable window has passed,
// * but this is an approximation and such a case is unlikely to be hit where this function is used.
return 0.0;
}
let fade_in_start_time = self.base.start_time - time_preempt;
let fade_in_duration = time_fade_in;
if hidden {
// * Taken from OsuModHidden.
let fade_out_start_time = self.base.start_time - time_preempt + time_fade_in;
const FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3;
let fade_out_duration = time_preempt * FADE_OUT_DURATION_MULTIPLIER;
(((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0))
.min(1.0 - ((time - fade_out_start_time) / fade_out_duration).clamp(0.0, 1.0))
} else {
((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0)
}
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Distances {
pub(crate) lazy_jump_dist: f64,
pub(crate) lazy_travel_dist: f32,
pub(crate) min_jump_dist: f64,
pub(crate) min_jump_time: f64,
pub(crate) travel_dist: f64,
pub(crate) travel_time: f64,
pub(crate) angle: Option<f64>,
}
impl Distances {
pub(crate) const NORMALISED_RADIUS: f32 = 50.0;
const MAXIMUM_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 2.4;
const ASSUMED_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 1.8;
/// Create a new instance of [`Distances`].
///
/// By taking in [`Pin<&mut OsuObject>`](Pin), we imply that the argument will be
/// modified but it won't be moved.
#[allow(clippy::similar_names)]
pub(crate) fn new(
base: &mut Pin<&mut OsuObject>,
last: &OsuObject,
last_last: Option<&OsuObject>,
clock_rate: f64,
strain_time: f64,
scaling_factor_: &ScalingFactor,
) -> Self {
let pos = base.pos();
let stack_offset = base.stack_offset;
let mut this = if let OsuObjectKind::Slider(ref mut slider) = base.kind {
let lazy_travel_dist =
Self::compute_slider_travel_dist(pos, stack_offset, slider, scaling_factor_);
let repeat_count = slider.repeat_count();
Self {
// * Bonus for repeat sliders until a better per nested object strain system can be achieved.
travel_dist: f64::from(
lazy_travel_dist * (1.0 + repeat_count as f64 / 2.5).powf(1.0 / 2.5) as f32,
),
travel_time: (base.lazy_travel_time() / clock_rate)
.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME)),
lazy_travel_dist,
..Default::default()
}
} else {
Self::default()
};
// * We don't need to calculate either angle or distance when
// * one of the last->curr objects is a spinner
if base.is_spinner() || last.is_spinner() {
return this;
}
// * We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
let scaling_factor = scaling_factor_.factor;
let last_cursor_pos = Self::get_end_cursor_pos(last);
this.lazy_jump_dist = f64::from(
(base.stacked_pos() * scaling_factor - last_cursor_pos * scaling_factor).length(),
);
this.min_jump_time = strain_time;
this.min_jump_dist = this.lazy_jump_dist;
if let OsuObjectKind::Slider(slider) = &last.kind {
let last_travel_time = (last.lazy_travel_time() / clock_rate)
.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME));
this.min_jump_time = (strain_time - last_travel_time)
.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME));
// * There are two types of slider-to-object patterns to consider in order
// * to better approximate the real movement a player will take to jump between the hitobjects.
// *
// * 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
// *
// * <======o==> ← slider
// * | ← most natural jump path
// * o ← a follow-up hitcircle
// *
// * In this case the most natural jump path is approximated by LazyJumpDistance.
// *
// * 2. The flow pattern, where players follow through the slider to its
// * visual extent into the next hitobject.
// *
// * <======o==>---o
// * ↑
// * most natural jump path
// *
// * In this case the most natural jump path is better approximated by a new distance
// * called "tailJumpDistance" - the distance between the slider's tail and the next hitobject.
// *
// * Thus, the player is assumed to jump the minimum of these two distances in all cases.
let stacked_tail_pos =
slider.tail().map_or_else(|| last.pos(), |tail| tail.pos) + last.stack_offset;
let tail_jump_dist = (stacked_tail_pos - base.stacked_pos()).length() * scaling_factor;
let diff = f64::from(Self::MAXIMUM_SLIDER_RADIUS - Self::ASSUMED_SLIDER_RADIUS);
let min = f64::from(tail_jump_dist - Self::MAXIMUM_SLIDER_RADIUS);
// "attributes on expressions are experimental see issue #15701 https://github.com/rust-lang/rust/issues/15701"
// rust pls...
#[allow(clippy::manual_clamp)]
let tmp = (this.lazy_jump_dist - diff).min(min).max(0.0);
this.min_jump_dist = tmp;
}
if let Some(last_last) = last_last.filter(|obj| !obj.is_spinner()) {
let last_last_cursor_pos = Self::get_end_cursor_pos(last_last);
let v1 = last_last_cursor_pos - last.stacked_pos();
let v2 = base.stacked_pos() - last_cursor_pos;
let dot = f64::from(v1.dot(v2));
let det = f64::from(v1.x * v2.y - v1.y * v2.x);
this.angle = Some(det.atan2(dot).abs());
}
this
}
pub(crate) fn compute_slider_travel_dist(
pos: Pos2,
stack_offset: Pos2,
slider: &mut OsuSlider,
scaling_factor_: &ScalingFactor,
) -> f32 {
let mut curr_cursor_pos = pos + stack_offset;
let scaling_factor = f64::from(Self::NORMALISED_RADIUS) / f64::from(scaling_factor_.radius);
let mut lazy_travel_dist: f32 = 0.0;
for (curr_movement_obj, i) in slider.nested_objects.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());
// * Amount of movement required so that the cursor position needs to be updated.
let mut required_movement = f64::from(Self::ASSUMED_SLIDER_RADIUS);
if i == slider.nested_objects.len() {
// * The end of a slider has special aim rules due
// * to the relaxed time constraint on position.
// * There is both a lazy end position as well as the actual end slider position.
// * We assume the player takes the simpler movement.
// * For sliders that are circular, the lazy end position
// * may actually be farther away than the sliders true end.
// * This code is designed to prevent buffing situations
// * where lazy end is actually a less efficient movement.
let lazy_movement = slider.lazy_end_pos - curr_cursor_pos;
if lazy_movement.length() < curr_movement.length() {
curr_movement = lazy_movement;
}
curr_movement_len = scaling_factor * f64::from(curr_movement.length());
} else if let NestedObjectKind::Repeat = curr_movement_obj.kind {
// * For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
required_movement = f64::from(Self::NORMALISED_RADIUS);
}
if curr_movement_len > required_movement {
// * this finds the positional delta from the required radius and the current position, and updates the currCursorPosition accordingly, as well as rewarding distance.
curr_cursor_pos += curr_movement
* ((curr_movement_len - required_movement) / curr_movement_len) as f32;
curr_movement_len *= (curr_movement_len - required_movement) / curr_movement_len;
lazy_travel_dist += curr_movement_len as f32;
}
}
slider.lazy_end_pos = curr_cursor_pos;
lazy_travel_dist
}
fn get_end_cursor_pos(hit_object: &OsuObject) -> Pos2 {
hit_object.lazy_end_pos()
}
}
-375
View File
@@ -1,375 +0,0 @@
#![cfg(feature = "gradual")]
use std::{
fmt::{Debug, Formatter, Result as FmtResult},
mem,
pin::Pin,
};
use crate::{Beatmap, Mods};
use self::osu_objects::OsuObjects;
use super::{
difficulty_object::{Distances, OsuDifficultyObject},
osu_object::{OsuObject, OsuObjectKind},
scaling_factor::ScalingFactor,
skills::{Skill, Skills},
OsuDifficultyAttributes, DIFFICULTY_MULTIPLIER, FADE_IN_DURATION_MULTIPLIER,
PERFORMANCE_BASE_MULTIPLIER, PREEMPT_MIN,
};
/// Gradually calculate the difficulty attributes of an osu!standard map.
///
/// Note that this struct implements [`Iterator`].
/// On every call of [`Iterator::next`], the map's next hit object will
/// be processed and the [`OsuDifficultyAttributes`] will be updated and returned.
///
/// If you want to calculate performance attributes, use
/// [`OsuGradualPerformance`](crate::osu::OsuGradualPerformance) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, osu::OsuGradualDifficulty};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = OsuGradualDifficulty::new(&map, mods);
///
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
/// let attrs2 = iter.next(); // after the second hit object
///
/// // Remaining hit objects
/// for difficulty in iter {
/// // ...
/// }
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
pub struct OsuGradualDifficulty {
pub(crate) idx: usize,
mods: u32,
attrs: OsuDifficultyAttributes,
skills: Skills,
// Lifetimes actually depend on `osu_objects` so this type is self-referential.
// This field must be treated with great caution, moving `osu_objects` will immediately
// invalidate `diff_objects`.
diff_objects: Vec<OsuDifficultyObject<'static>>,
osu_objects: OsuObjects,
// Additional safety measure that this type can't be cloned which would invalidate
// `diff_objects`.
_not_clonable: NotClonable,
}
struct NotClonable;
impl Debug for OsuGradualDifficulty {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("OsuGradualDifficulty")
.field("mods", &self.mods)
.field("idx", &self.idx)
.field("attrs", &self.attrs)
.field("diff_objects", &self.diff_objects)
.field("osu_objects", &"...")
.field("skills", &self.skills)
.field("_not_clonable", &"...")
.finish()
}
}
impl OsuGradualDifficulty {
/// Create a new difficulty attributes iterator for osu!standard maps.
pub fn new(map: &Beatmap, mods: u32) -> Self {
let clock_rate = mods.clock_rate();
let map_attrs = map.attributes().mods(mods).build();
let scaling_factor = ScalingFactor::new(map_attrs.cs);
let hr = mods.hr();
let hit_window = 2.0 * map_attrs.hit_windows.od;
let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32);
// * 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.
// * This uniform speedup is hard to match 1:1, however we can at least make
// * AR>10 (via mods) feel good by extending the upper linear function above.
// * Note that this doesn't exactly match the AR>10 visuals as they're
// * classically known, but it feels good.
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
// * become smaller leading to hitcircles not fully fading in.
let time_fade_in = if mods.hd() {
time_preempt * FADE_IN_DURATION_MULTIPLIER
} else {
400.0 * (time_preempt / PREEMPT_MIN).min(1.0)
};
let mut attrs = OsuDifficultyAttributes {
ar: map_attrs.ar,
hp: map_attrs.hp,
od: map_attrs.od,
..Default::default()
};
let hit_objects = crate::osu::create_osu_objects(
map,
&mut attrs,
&scaling_factor,
map.hit_objects.len(),
hr,
time_preempt,
);
let mut osu_objects = OsuObjects::new(hit_objects);
attrs.n_circles = 0;
attrs.n_sliders = 0;
attrs.n_spinners = 0;
attrs.max_combo = 0;
let skills = Skills::new(
mods,
scaling_factor.radius,
time_preempt,
time_fade_in,
hit_window,
);
let mut osu_objects_iter = osu_objects.iter_mut();
let Some(mut last) = osu_objects_iter.next() else {
return Self {
idx: 0,
mods,
attrs,
skills,
diff_objects: Vec::new(),
osu_objects: OsuObjects::new(Vec::new()),
_not_clonable: NotClonable,
};
};
Self::increment_combo(last.as_ref().get_ref(), &mut attrs);
let mut last_last = None;
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
let last_pos = last.pos();
let last_stack_offset = last.stack_offset;
if let OsuObjectKind::Slider(ref mut slider) = last.kind {
Distances::compute_slider_travel_dist(
last_pos,
last_stack_offset,
slider,
&scaling_factor,
);
}
let mut last = last.into_ref();
let mut diff_objects = Vec::with_capacity(map.hit_objects.len().saturating_sub(2));
for (i, mut curr) in osu_objects_iter.enumerate() {
let delta_time = (curr.start_time - last.start_time) / clock_rate;
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
let strain_time = delta_time.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME));
let dists = Distances::new(
&mut curr,
last.get_ref(),
last_last.map(Pin::get_ref),
clock_rate,
strain_time,
&scaling_factor,
);
let curr = curr.into_ref();
let diff_obj = OsuDifficultyObject::new(curr, last.get_ref(), clock_rate, i, dists);
diff_objects.push(diff_obj);
last_last = Some(last);
last = curr;
}
Self {
idx: 0,
mods,
attrs,
skills,
diff_objects: extend_lifetime(diff_objects),
osu_objects,
_not_clonable: NotClonable,
}
}
fn increment_combo(h: &OsuObject, attrs: &mut OsuDifficultyAttributes) {
attrs.max_combo += 1;
match &h.kind {
OsuObjectKind::Circle => attrs.n_circles += 1,
OsuObjectKind::Slider(slider) => {
attrs.n_sliders += 1;
attrs.max_combo += slider.nested_objects.len();
}
OsuObjectKind::Spinner { .. } => attrs.n_spinners += 1,
}
}
}
fn extend_lifetime(
diff_objects: Vec<OsuDifficultyObject<'_>>,
) -> Vec<OsuDifficultyObject<'static>> {
// SAFETY: Owned values of the references will be contained in the same struct (same lifetime).
// Also, the only mutable access wraps them in `Pin` to ensure that they won't move.
unsafe { mem::transmute(diff_objects) }
}
impl Iterator for OsuGradualDifficulty {
type Item = OsuDifficultyAttributes;
fn next(&mut self) -> Option<Self::Item> {
// The first difficulty object belongs to the second note since each difficulty
// object requires the current and the last note. Hence, if we're still on the first
// object, we don't have a difficulty object yet and just skip processing.
if self.idx > 0 {
let curr = self.diff_objects.get(self.idx - 1)?;
self.skills.process(curr, &self.diff_objects);
Self::increment_combo(curr.base.get_ref(), &mut self.attrs);
} else if self.osu_objects.is_empty() {
return None;
}
self.idx += 1;
let Skills {
mut aim,
mut aim_no_sliders,
mut speed,
mut flashlight,
} = self.skills.clone();
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let aim_rating_no_sliders =
aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_notes = speed.relevant_note_count();
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 {
aim_rating_no_sliders / aim_rating
} else {
1.0
};
if self.mods.td() {
aim_rating = aim_rating.powf(0.8);
flashlight_rating = flashlight_rating.powf(0.8);
}
if self.mods.rx() {
aim_rating *= 0.9;
speed_rating = 0.0;
flashlight_rating *= 0.7;
}
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_speed_performance =
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_flashlight_performance = if self.mods.fl() {
flashlight_rating * flashlight_rating * 25.0
} else {
0.0
};
let base_performance = ((base_aim_performance).powf(1.1)
+ (base_speed_performance).powf(1.1)
+ (base_flashlight_performance).powf(1.1))
.powf(1.0 / 1.1);
let star_rating = if base_performance > 0.00001 {
PERFORMANCE_BASE_MULTIPLIER.cbrt()
* 0.027
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
} else {
0.0
};
let attrs = OsuDifficultyAttributes {
aim: aim_rating,
speed: speed_rating,
flashlight: flashlight_rating,
slider_factor,
stars: star_rating,
speed_note_count: speed_notes,
..self.attrs.clone()
};
Some(attrs)
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
let skip_iter = self.diff_objects.iter().skip(self.idx.saturating_sub(1));
let mut take = n.min(self.len().saturating_sub(1));
// The first note has no difficulty object
if self.idx == 0 && take > 0 {
take -= 1;
self.idx += 1;
}
for curr in skip_iter.take(take) {
self.skills.process(curr, &self.diff_objects);
Self::increment_combo(curr.base.get_ref(), &mut self.attrs);
self.idx += 1;
}
self.next()
}
}
impl ExactSizeIterator for OsuGradualDifficulty {
#[inline]
fn len(&self) -> usize {
self.diff_objects.len() + 1 - self.idx
}
}
mod osu_objects {
use crate::osu::OsuObject;
use std::pin::Pin;
// Wrapper to ensure that the data will not be moved
pub(super) struct OsuObjects {
objects: Box<[OsuObject]>,
}
impl OsuObjects {
pub(super) fn new(objects: Vec<OsuObject>) -> Self {
Self {
objects: objects.into_boxed_slice(),
}
}
pub(super) const fn is_empty(&self) -> bool {
self.objects.is_empty()
}
pub(super) fn iter_mut(&mut self) -> impl Iterator<Item = Pin<&mut OsuObject>> {
self.objects.iter_mut().map(Pin::new)
}
}
}
-194
View File
@@ -1,194 +0,0 @@
#![cfg(feature = "gradual")]
use crate::{Beatmap, OsuPP};
use super::{OsuGradualDifficulty, OsuPerformanceAttributes, OsuScoreState};
/// Gradually calculate the performance attributes of an osu!standard map.
///
/// After each hit object you can call [`next`](`OsuGradualPerformance::next`)
/// and it will return the resulting current [`OsuPerformanceAttributes`].
/// To process multiple objects at once, use [`nth`](`OsuGradualPerformance::nth`) instead.
///
/// Both methods require an [`OsuScoreState`] that contains the current
/// hitresults as well as the maximum combo so far.
///
/// If you only want to calculate difficulty attributes use
/// [`OsuGradualDifficulty`] instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, osu::{OsuGradualPerformance, OsuScoreState}};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = OsuGradualPerformance::new(&map, mods);
/// let mut state = OsuScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are 300s and there are no sliders for additional combo
/// for _ in 0..10 {
/// state.n300 += 1;
/// state.max_combo += 1;
///
/// # /*
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
/// }
///
/// // Then comes a miss.
/// // Note that state's max combo won't be incremented for
/// // the next few objects because the combo is reset.
/// state.n_misses += 1;
/// # /*
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
///
/// // The next 10 objects will be a mixture of 300s, 100s, and 50s.
/// // Notice how all 10 objects will be processed in one go.
/// state.n300 += 2;
/// state.n100 += 7;
/// state.n50 += 1;
/// // The `nth` method takes a zero-based value.
/// # /*
/// let performance = gradual_perf.nth(state.clone(), 9).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.nth(state.clone(), 9);
///
/// // Now comes another 300. Note that the max combo gets incremented again.
/// state.n300 += 1;
/// state.max_combo += 1;
/// # /*
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n300 = ...
/// state.n100 = ...
/// state.n50 = ...
/// state.n_misses = ...
/// let final_performance = gradual_perf.nth(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.nth(state.clone(), usize::MAX);
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.next(state).is_none());
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Debug)]
pub struct OsuGradualPerformance<'map> {
difficulty: OsuGradualDifficulty,
performance: OsuPP<'map>,
}
impl<'map> OsuGradualPerformance<'map> {
/// Create a new gradual performance calculator for osu!standard maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let difficulty = OsuGradualDifficulty::new(map, mods);
let performance = OsuPP::new(map).mods(mods).passed_objects(0);
Self {
difficulty,
performance,
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score state.
pub fn next(&mut self, state: OsuScoreState) -> Option<OsuPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: OsuScoreState) -> Option<OsuPerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
pub fn nth(&mut self, state: OsuScoreState, n: usize) -> Option<OsuPerformanceAttributes> {
let difficulty = self.difficulty.nth(n)?;
let performance = self
.performance
.clone()
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx)
.calculate();
Some(performance)
}
}
/// Gradually calculate the performance attributes of an osu!standard map.
///
/// Check [`OsuGradualPerformance`] for more information. This struct does the same
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Debug)]
pub struct OsuOwnedGradualPerformance {
difficulty: OsuGradualDifficulty,
map: Beatmap,
mods: u32,
}
impl OsuOwnedGradualPerformance {
/// Create a new gradual performance calculator for osu!standard maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let difficulty = OsuGradualDifficulty::new(&map, mods);
Self {
difficulty,
map,
mods,
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score state.
pub fn next(&mut self, state: OsuScoreState) -> Option<OsuPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: OsuScoreState) -> Option<OsuPerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
pub fn nth(&mut self, state: OsuScoreState, n: usize) -> Option<OsuPerformanceAttributes> {
let difficulty = self.difficulty.nth(n)?;
let performance = OsuPP::new(&self.map)
.mods(self.mods)
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx)
.calculate();
Some(performance)
}
}
+37 -643
View File
@@ -1,660 +1,54 @@
mod difficulty_object;
mod osu_object;
mod pp;
mod scaling_factor;
mod score_state;
mod skills;
use std::borrow::Cow;
#[cfg(feature = "gradual")]
mod gradual_difficulty;
#[cfg(feature = "gradual")]
mod gradual_performance;
use rosu_map::util::Pos;
use crate::{curve::CurveBuffers, parse::Pos2, AnyStars, Beatmap, GameMode, Mods};
use std::pin::Pin;
use self::{
difficulty_object::{Distances, OsuDifficultyObject},
skills::{Skill, Skills},
use crate::{
any::ModeDifficulty,
model::{
beatmap::Beatmap,
mode::{ConvertStatus, IGameMode},
},
};
pub use self::{osu_object::*, pp::*, score_state::OsuScoreState};
#[cfg(feature = "gradual")]
pub use self::{
gradual_difficulty::OsuGradualDifficulty,
gradual_performance::{OsuGradualPerformance, OsuOwnedGradualPerformance},
attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes},
convert::OsuBeatmap,
performance::OsuPerformance,
score_state::OsuScoreState,
strains::OsuStrains,
};
pub(crate) use self::scaling_factor::ScalingFactor;
mod attributes;
mod convert;
mod difficulty;
mod object;
mod performance;
mod score_state;
mod strains;
const SECTION_LEN: f64 = 400.0;
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
// * Change radius to 50 to make 100 the diameter. Easier for mental maths.
const NORMALIZED_RADIUS: f32 = 50.0;
const STACK_DISTANCE: f32 = 3.0;
// * This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14;
const PREEMPT_MIN: f64 = 450.0;
const FADE_IN_DURATION_MULTIPLIER: f64 = 0.4;
const PLAYFIELD_BASE_SIZE: Pos2 = Pos2 { x: 512.0, y: 384.0 };
const PLAYFIELD_BASE_SIZE: Pos = Pos::new(512.0, 384.0);
/// Difficulty calculator on osu!standard maps.
/// Marker type for [`GameMode::Osu`].
///
/// # Example
///
/// ```
/// use rosu_pp::{OsuStars, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let difficulty_attrs = OsuStars::new(&map)
/// .mods(8 + 64) // HDDT
/// .calculate();
///
/// println!("Stars: {}", difficulty_attrs.stars);
/// ```
#[derive(Clone, Debug)]
#[must_use]
pub struct OsuStars<'map> {
pub(crate) map: &'map Beatmap,
pub(crate) mods: u32,
pub(crate) passed_objects: Option<usize>,
pub(crate) clock_rate: Option<f64>,
}
/// [`GameMode::Osu`]: rosu_map::section::general::GameMode::Osu
pub struct Osu;
impl<'map> OsuStars<'map> {
/// Create a new difficulty calculator for osu!standard maps.
#[inline]
pub const fn new(map: &'map Beatmap) -> Self {
Self {
map,
mods: 0,
passed_objects: None,
clock_rate: None,
}
impl IGameMode for Osu {
type DifficultyAttributes = OsuDifficultyAttributes;
type Strains = OsuStrains;
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
convert::try_convert(map)
}
/// Convert the map into another mode.
#[inline]
pub fn mode(self, mode: GameMode) -> AnyStars<'map> {
match mode {
GameMode::Osu => AnyStars::Osu(self),
GameMode::Taiko => AnyStars::Taiko(self.into()),
GameMode::Catch => AnyStars::Catch(self.into()),
GameMode::Mania => AnyStars::Mania(self.into()),
}
fn difficulty(
difficulty: &ModeDifficulty,
converted: &OsuBeatmap<'_>,
) -> Self::DifficultyAttributes {
difficulty::difficulty(difficulty, converted)
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
#[cfg_attr(
feature = "gradual",
doc = "If you want to calculate the difficulty after every few objects, instead of
using [`OsuStars`] multiple times with different `passed_objects`, you should use
[`OsuGradualDifficulty`]."
)]
#[inline]
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects = Some(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Calculate all difficulty related values, including stars.
#[inline]
pub fn calculate(self) -> OsuDifficultyAttributes {
let mods = self.mods;
let (skills, mut attrs) = calculate_skills(self);
let Skills {
mut aim,
mut aim_no_sliders,
mut speed,
mut flashlight,
} = skills;
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let aim_rating_no_sliders =
aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_notes = speed.relevant_note_count();
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 {
aim_rating_no_sliders / aim_rating
} else {
1.0
};
if mods.td() {
aim_rating = aim_rating.powf(0.8);
flashlight_rating = flashlight_rating.powf(0.8);
}
if mods.rx() {
aim_rating *= 0.9;
speed_rating = 0.0;
flashlight_rating *= 0.7;
}
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_speed_performance =
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_flashlight_performance = if mods.fl() {
flashlight_rating * flashlight_rating * 25.0
} else {
0.0
};
let base_performance = ((base_aim_performance).powf(1.1)
+ (base_speed_performance).powf(1.1)
+ (base_flashlight_performance).powf(1.1))
.powf(1.0 / 1.1);
let star_rating = if base_performance > 0.00001 {
PERFORMANCE_BASE_MULTIPLIER.cbrt()
* 0.027
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
} else {
0.0
};
attrs.aim = aim_rating;
attrs.speed = speed_rating;
attrs.flashlight = flashlight_rating;
attrs.slider_factor = slider_factor;
attrs.stars = star_rating;
attrs.speed_note_count = speed_notes;
attrs
}
/// Calculate the skill strains.
///
/// Suitable to plot the difficulty of a map over time.
#[inline]
pub fn strains(self) -> OsuStrains {
let (skills, _) = calculate_skills(self);
let Skills {
aim,
aim_no_sliders,
speed,
flashlight,
} = skills;
OsuStrains {
section_len: SECTION_LEN,
aim: aim.strain_peaks.to_vec(),
aim_no_sliders: aim_no_sliders.strain_peaks.to_vec(),
speed: speed.strain_peaks.to_vec(),
flashlight: flashlight.strain_peaks.to_vec(),
}
}
}
/// The result of calculating the strains on a osu! map.
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug)]
pub struct OsuStrains {
/// Time in ms inbetween two strains.
pub section_len: f64, // TODO: remove field, make it a method
/// Strain peaks of the aim skill.
pub aim: Vec<f64>,
/// Strain peaks of the aim skill without sliders.
pub aim_no_sliders: Vec<f64>,
/// Strain peaks of the speed skill.
pub speed: Vec<f64>,
/// Strain peaks of the flashlight skill.
pub flashlight: Vec<f64>,
}
impl OsuStrains {
/// Returns the number of strain peaks per skill.
#[inline]
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.aim.len()
}
}
#[allow(clippy::needless_pass_by_value)]
fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
let OsuStars {
map,
mods,
passed_objects,
clock_rate,
} = params;
let take = passed_objects.unwrap_or(map.hit_objects.len());
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
let map_attrs = map.attributes().mods(mods).clock_rate(clock_rate).build();
let scaling_factor = ScalingFactor::new(map_attrs.cs);
let hr = mods.hr();
let hit_window = 2.0 * map_attrs.hit_windows.od;
let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32);
// * 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.
// * This uniform speedup is hard to match 1:1, however we can at least make
// * AR>10 (via mods) feel good by extending the upper linear function above.
// * Note that this doesn't exactly match the AR>10 visuals as they're
// * classically known, but it feels good.
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
// * become smaller leading to hitcircles not fully fading in.
let time_fade_in = if mods.hd() {
time_preempt * FADE_IN_DURATION_MULTIPLIER
} else {
400.0 * (time_preempt / PREEMPT_MIN).min(1.0)
};
let mut attrs = OsuDifficultyAttributes {
ar: map_attrs.ar,
hp: map_attrs.hp,
od: map_attrs.od,
..Default::default()
};
let mut hit_objects =
create_osu_objects(map, &mut attrs, &scaling_factor, take, hr, time_preempt);
let mut hit_objects_iter = hit_objects.iter_mut().map(Pin::new);
let mut skills = Skills::new(
mods,
scaling_factor.radius,
time_preempt,
time_fade_in,
hit_window,
);
let Some(mut last) = hit_objects_iter.next() else {
return (skills, attrs);
};
let mut last_last = None;
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
let last_pos = last.pos();
let last_stack_offset = last.stack_offset;
if let OsuObjectKind::Slider(ref mut slider) = last.kind {
Distances::compute_slider_travel_dist(last_pos, last_stack_offset, slider, &scaling_factor);
}
let mut last = last.into_ref();
let mut diff_objects = Vec::with_capacity(hit_objects_iter.len());
for (i, mut curr) in hit_objects_iter.enumerate() {
let delta_time = (curr.start_time - last.start_time) / clock_rate;
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
let strain_time = delta_time.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME));
let dists = Distances::new(
&mut curr,
last.get_ref(),
last_last.map(Pin::get_ref),
clock_rate,
strain_time,
&scaling_factor,
);
let curr = curr.into_ref();
let diff_obj = OsuDifficultyObject::new(curr, last.get_ref(), clock_rate, i, dists);
diff_objects.push(diff_obj);
last_last = Some(last);
last = curr;
}
for curr in diff_objects.iter() {
skills.process(curr, &diff_objects);
}
(skills, attrs)
}
pub(crate) fn create_osu_objects(
map: &Beatmap,
attrs: &mut OsuDifficultyAttributes,
scaling_factor: &ScalingFactor,
take: usize,
hr: bool,
time_preempt: f64,
) -> Vec<OsuObject> {
let mut params = ObjectParameters {
map,
attrs,
ticks: Vec::new(),
curve_bufs: CurveBuffers::default(),
};
let mut hit_objects = Vec::with_capacity(map.hit_objects.len());
let mut iter = map.hit_objects.iter();
let hit_object_iter = iter
.by_ref()
.take(take)
.map(|h| OsuObject::new(h, &mut params));
hit_objects.extend(hit_object_iter);
// remaining objects unfortunately still need to be processed since they might influence stacking
if take < map.hit_objects.len() {
let ObjectParameters {
map,
attrs,
ticks,
curve_bufs,
} = params;
// use a new instance for attributes so that max combo & co remain correct in the original
let mut throw_away_attrs = attrs.clone();
let mut params = ObjectParameters {
map,
attrs: &mut throw_away_attrs,
ticks,
curve_bufs,
};
hit_objects.extend(iter.map(|h| OsuObject::new(h, &mut params)));
}
let stack_threshold = time_preempt * f64::from(map.stack_leniency);
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
} else {
old_stacking(&mut hit_objects, stack_threshold);
}
hit_objects.truncate(take);
hit_objects
.iter_mut()
.for_each(|h| h.post_process(hr, scaling_factor));
hit_objects
}
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
let mut extended_start_idx = 0;
let Some(extended_end_idx) = hit_objects.len().checked_sub(1) else {
return;
};
// First big `if` in osu!lazer's function can be skipped
for i in (1..=extended_end_idx).rev() {
let mut n = i;
let mut obj_i_idx = i;
// * We should check every note which has not yet got a stack.
// * Consider the case we have two interwound stacks and this will make sense.
// * o <-1 o <-2
// * o <-3 o <-4
// * We first process starting from 4 and handle 2,
// * then we come backwards on the i loop iteration until we reach 3 and handle 1.
// * 2 and 1 will be ignored in the i loop because they already have a stack value.
if hit_objects[obj_i_idx].stack_height.abs() > 0.0 || hit_objects[obj_i_idx].is_spinner() {
continue;
}
// * If this object is a hitcircle, then we enter this "special" case.
// * It either ends with a stack of hitcircles only,
// * or a stack of hitcircles that are underneath a slider.
// * Any other case is handled by the "is_slider" code below this.
if hit_objects[obj_i_idx].is_circle() {
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
}
if hit_objects[obj_i_idx].start_time - hit_objects[n].end_time() > stack_threshold {
break; // * We are no longer within stacking range of the previous object.
}
// * HitObjects before the specified update range haven't been reset yet
if n < extended_start_idx {
hit_objects[n].stack_height = 0.0;
extended_start_idx = n;
}
// * This is a special case where hticircles are moved DOWN and RIGHT (negative stacking)
// * if they are under the *last* slider in a stacked pattern.
// * o==o <- slider is at original location
// * o <- hitCircle has stack of -1
// * o <- hitCircle has stack of -2
if hit_objects[n].is_slider()
&& hit_objects[n]
.pre_stacked_end_pos()
.distance(hit_objects[obj_i_idx].pos())
< STACK_DISTANCE
{
let offset =
hit_objects[obj_i_idx].stack_height - hit_objects[n].stack_height + 1.0;
for j in n + 1..=i {
// * For each object which was declared under this slider, we will offset
// * it to appear *below* the slider end (rather than above).
if hit_objects[n]
.pre_stacked_end_pos()
.distance(hit_objects[j].pos())
< STACK_DISTANCE
{
hit_objects[j].stack_height -= offset;
}
}
// * We have hit a slider. We should restart calculation using this as the new base.
// * Breaking here will mean that the slider still has StackCount of 0,
// * so will be handled in the i-outer-loop.
break;
}
if hit_objects[n].pos().distance(hit_objects[obj_i_idx].pos()) < STACK_DISTANCE {
// * Keep processing as if there are no sliders.
// * If we come across a slider, this gets cancelled out.
// * NOTE: Sliders with start positions stacking
// * are a special case that is also handled here.
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1.0;
obj_i_idx = n;
}
}
} else if hit_objects[obj_i_idx].is_slider() {
// * We have hit the first slider in a possible stack.
// * From this point on, we ALWAYS stack positive regardless.
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
}
if hit_objects[obj_i_idx].start_time - hit_objects[n].start_time > stack_threshold {
break; // * We are no longer within stacking range of the previous object.
}
if hit_objects[n]
.pre_stacked_end_pos()
.distance(hit_objects[obj_i_idx].pos())
< STACK_DISTANCE
{
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1.0;
obj_i_idx = n;
}
}
}
}
}
fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
for i in 0..hit_objects.len() {
if hit_objects[i].stack_height != 0.0 && !hit_objects[i].is_slider() {
continue;
}
let mut start_time = hit_objects[i].end_time();
let pos2 = hit_objects[i].old_stacking_pos2();
let mut slider_stack = 0.0;
for j in i + 1..hit_objects.len() {
if hit_objects[j].start_time - stack_threshold > start_time {
break;
}
if hit_objects[j].pos().distance(hit_objects[i].pos()) < STACK_DISTANCE {
hit_objects[i].stack_height += 1.0;
start_time = hit_objects[j].end_time();
} else if hit_objects[j].pos().distance(pos2) < STACK_DISTANCE {
slider_stack += 1.0;
hit_objects[j].stack_height -= slider_stack;
start_time = hit_objects[j].end_time();
}
}
}
}
/// The result of a difficulty calculation on an osu!standard map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OsuDifficultyAttributes {
/// The aim portion of the total strain.
pub aim: f64,
/// The speed portion of the total strain.
pub speed: f64,
/// The flashlight portion of the total strain.
pub flashlight: f64,
/// The ratio of the aim strain with and without considering sliders
pub slider_factor: f64,
/// The number of clickable objects weighted by difficulty.
pub speed_note_count: f64,
/// The approach rate.
pub ar: f64,
/// The overall difficulty
pub od: f64,
/// The health drain rate.
pub hp: f64,
/// The amount of circles.
pub n_circles: usize,
/// The amount of sliders.
pub n_sliders: usize,
/// The amount of spinners.
pub n_spinners: usize,
/// The final star rating
pub stars: f64,
/// The maximum combo.
pub max_combo: usize,
}
impl OsuDifficultyAttributes {
/// Return the maximum combo.
#[inline]
pub const fn max_combo(&self) -> usize {
self.max_combo
}
/// Return the amount of hitobjects.
#[inline]
pub const fn n_objects(&self) -> usize {
self.n_circles + self.n_sliders + self.n_spinners
}
/// Returns a builder for performance calculation.
#[inline]
pub fn pp(self) -> OsuPP<'static> {
OsuPP::from(self)
}
}
/// The result of a performance calculation on an osu!standard map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OsuPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation
pub difficulty: OsuDifficultyAttributes,
/// The final performance points.
pub pp: f64,
/// The accuracy portion of the final pp.
pub pp_acc: f64,
/// The aim portion of the final pp.
pub pp_aim: f64,
/// The flashlight portion of the final pp.
pub pp_flashlight: f64,
/// The speed portion of the final pp.
pub pp_speed: f64,
/// Misses including an approximated amount of slider breaks
pub effective_miss_count: f64,
}
impl OsuPerformanceAttributes {
/// Return the star value.
#[inline]
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
#[inline]
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
#[inline]
pub const fn max_combo(&self) -> usize {
self.difficulty.max_combo
}
/// Return the amount of hitobjects.
#[inline]
pub const fn n_objects(&self) -> usize {
self.difficulty.n_objects()
}
}
impl From<OsuPerformanceAttributes> for OsuDifficultyAttributes {
#[inline]
fn from(attributes: OsuPerformanceAttributes) -> Self {
attributes.difficulty
fn strains(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> Self::Strains {
strains::strains(difficulty, converted)
}
}
+300
View File
@@ -0,0 +1,300 @@
use rosu_map::{
section::hit_objects::{
BorrowedCurve, CurveBuffers, SliderEvent, SliderEventType, SliderEventsIter,
},
util::Pos,
};
use crate::model::{
control_point::{DifficultyPoint, TimingPoint},
hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner},
};
use super::{convert::OsuBeatmap, PLAYFIELD_BASE_SIZE};
pub struct OsuObject {
pub pos: Pos,
pub start_time: f64,
pub stack_height: i32,
pub stack_offset: Pos,
pub kind: OsuObjectKind,
}
impl OsuObject {
pub const OBJECT_RADIUS: f32 = 64.0;
pub const PREEMPT_MIN: f64 = 450.0;
const BASE_SCORING_DIST: f32 = 100.0;
pub fn new(
h: &HitObject,
converted: &OsuBeatmap<'_>,
curve_bufs: &mut CurveBuffers,
ticks_buf: &mut Vec<SliderEvent>,
) -> Self {
let kind = match h.kind {
HitObjectKind::Circle => OsuObjectKind::Circle,
HitObjectKind::Slider(ref slider) => {
OsuObjectKind::Slider(OsuSlider::new(h, slider, converted, curve_bufs, ticks_buf))
}
HitObjectKind::Spinner(spinner) => OsuObjectKind::Spinner(spinner),
HitObjectKind::Hold(HoldNote { end_time }) => {
OsuObjectKind::Spinner(Spinner { end_time })
}
};
Self {
pos: h.pos,
start_time: h.start_time,
stack_height: 0,
stack_offset: Pos::default(),
kind,
}
}
pub fn reflect_vertically(&mut self) {
fn reflect_y(y: &mut f32) {
*y = PLAYFIELD_BASE_SIZE.y - *y;
}
reflect_y(&mut self.pos.y);
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.y = -slider.lazy_end_pos.y;
let mut nested_iter = slider.nested_objects.iter_mut();
// The tail is adjusted differently than ticks and repeats
if let Some(tail) = nested_iter.next_back() {
let mut tail_pos = self.pos; // already reflected at this point
tail_pos += Pos::new(tail.pos.x, -tail.pos.y);
tail.pos = tail_pos;
for nested in nested_iter {
reflect_y(&mut nested.pos.y);
}
}
}
}
pub fn finalize_tail(&mut self) {
if let OsuObjectKind::Slider(ref mut slider) = self.kind {
if let Some(tail) = slider.nested_objects.last_mut() {
tail.pos += self.pos;
}
}
}
pub const fn end_time(&self) -> f64 {
match self.kind {
OsuObjectKind::Circle => self.start_time,
OsuObjectKind::Slider(ref slider) => slider.end_time,
OsuObjectKind::Spinner(ref spinner) => spinner.end_time,
}
}
pub fn stacked_pos(&self) -> Pos {
self.pos + self.stack_offset
}
pub fn end_pos(&self) -> Pos {
match self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => self.pos,
OsuObjectKind::Slider(ref slider) => {
slider.nested_objects[slider.nested_objects.len() - 1].pos
}
}
}
pub fn stacked_end_pos(&self) -> Pos {
self.end_pos() + self.stack_offset
}
pub fn lazy_travel_time(&self) -> f64 {
match self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => 0.0,
OsuObjectKind::Slider(ref slider) => {
slider
.nested_objects
.last()
.map_or(0.0, |nested| nested.start_time)
- self.start_time
}
}
}
pub const fn is_circle(&self) -> bool {
matches!(self.kind, OsuObjectKind::Circle)
}
pub const fn is_slider(&self) -> bool {
matches!(self.kind, OsuObjectKind::Slider { .. })
}
pub const fn is_spinner(&self) -> bool {
matches!(self.kind, OsuObjectKind::Spinner(_))
}
}
pub enum OsuObjectKind {
Circle,
Slider(OsuSlider),
Spinner(Spinner),
}
pub struct OsuSlider {
pub end_time: f64,
pub lazy_end_pos: Pos,
pub lazy_travel_dist: f32,
pub nested_objects: Vec<NestedSliderObject>,
}
impl OsuSlider {
fn new(
h: &HitObject,
slider: &Slider,
converted: &OsuBeatmap<'_>,
curve_bufs: &mut CurveBuffers,
ticks_buf: &mut Vec<SliderEvent>,
) -> Self {
let start_time = h.start_time;
let slider_multiplier = converted.map.slider_multiplier;
let slider_tick_rate = converted.map.slider_tick_rate;
let beat_len = converted
.map
.timing_point_at(start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let (slider_velocity, generate_ticks) =
converted.map.difficulty_point_at(start_time).map_or(
(
DifficultyPoint::DEFAULT_SLIDER_VELOCITY,
DifficultyPoint::DEFAULT_GENERATE_TICKS,
),
|point| (point.slider_velocity, point.generate_ticks),
);
let path = BorrowedCurve::new(&slider.control_points, slider.expected_dist, 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 end_time = start_time + span_count * path.dist() / velocity;
let duration = end_time - start_time;
let span_duration = duration / span_count;
let tick_dist_multiplier = if converted.map.version < 8 {
slider_velocity.recip()
} else {
1.0
};
let tick_dist = if generate_ticks {
scoring_dist / slider_tick_rate * tick_dist_multiplier
} else {
f64::INFINITY
};
let events = SliderEventsIter::new(
start_time,
span_duration,
velocity,
tick_dist,
path.dist(),
slider.span_count() as i32,
ticks_buf,
);
let span_at = |progress: f64| (progress * span_count) as i32;
let obj_progress_at = |progress: f64| {
let p = progress * span_count % 1.0;
if span_at(progress) % 2 == 1 {
1.0 - p
} else {
p
}
};
let end_path_pos = path.position_at(obj_progress_at(1.0));
let nested_objects: Vec<_> = events
.filter_map(|e| {
let obj = match e.kind {
SliderEventType::Tick => NestedSliderObject {
pos: h.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),
start_time: start_time + f64::from(e.span_idx + 1) * span_duration,
kind: NestedSliderObjectKind::Repeat,
},
SliderEventType::LastTick => 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,
};
Some(obj)
})
.collect();
let lazy_travel_time = nested_objects
.last()
.map_or(0.0, |nested| nested.start_time - h.start_time);
let mut end_time_min = lazy_travel_time / span_duration;
if end_time_min % 2.0 >= 1.0 {
end_time_min = 1.0 - end_time_min % 1.0;
} else {
end_time_min %= 1.0;
}
let lazy_end_pos = path.position_at(end_time_min);
Self {
end_time,
lazy_end_pos,
lazy_travel_dist: 0.0,
nested_objects,
}
}
pub fn repeat_count(&self) -> usize {
self.nested_objects
.iter()
.filter(|nested| matches!(nested.kind, NestedSliderObjectKind::Repeat))
.count()
}
}
pub struct NestedSliderObject {
pub pos: Pos,
pub start_time: f64,
pub kind: NestedSliderObjectKind,
}
impl NestedSliderObject {
pub const fn is_repeat(&self) -> bool {
matches!(self.kind, NestedSliderObjectKind::Repeat)
}
}
#[derive(Debug)]
pub enum NestedSliderObjectKind {
Repeat,
Tail,
Tick,
}
-509
View File
@@ -1,509 +0,0 @@
use super::{scaling_factor::ScalingFactor, OsuDifficultyAttributes, PLAYFIELD_BASE_SIZE};
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind, Pos2},
Beatmap,
};
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
const BASE_SCORING_DISTANCE: f64 = 100.0;
/// A [`HitObject`] that was processed for the osu! gamemode.
#[derive(Clone, Debug, PartialEq)]
pub struct OsuObject {
/// Position of the object.
///
/// Note that stacking is **not** applied, use the [`stacked_pos`](OsuObject::stacked_pos) method instead.
pub pos: Pos2,
/// Start time of the object.
pub start_time: f64,
/// The positional offset due to stacking.
pub stack_offset: Pos2,
/// The height of the stacking offset.
pub stack_height: f32,
/// Type of the object.
pub kind: OsuObjectKind,
}
/// The type of an [`OsuObject`].
#[derive(Clone, Debug, PartialEq)]
pub enum OsuObjectKind {
/// A hitcircle object.
Circle,
/// A slider object.
Slider(OsuSlider),
/// A spinner object.
Spinner {
/// The endtime of the spinner.
end_time: f64,
},
}
/// A [`HitObject`] that was processed as a slider for the osu! gamemode.
#[derive(Clone, Debug, PartialEq)]
pub struct OsuSlider {
/// The endtime of the slider.
pub end_time: f64,
/// The lazy end position of the cursor.
pub lazy_end_pos: Pos2,
/// All nested objects of the slider except for the slider head.
pub nested_objects: Vec<NestedObject>,
}
impl OsuSlider {
/// The amount of repeat points.
pub fn repeat_count(&self) -> usize {
self.nested_objects.iter().fold(0, |count, nested| {
count + usize::from(matches!(nested.kind, NestedObjectKind::Repeat))
})
}
/// The position of the slider tail.
///
/// This is usually but not necessarily the position of the last nested object.
pub fn end_pos(&self) -> Option<Pos2> {
self.tail().map(|tail| tail.pos)
}
/// A shared reference to the slider tail.
///
/// This is usually but not necessarily the last nested object.
pub fn tail(&self) -> Option<&NestedObject> {
self.nested_objects
.iter()
.rev()
.find(|nested| matches!(nested.kind, NestedObjectKind::Tail))
}
/// An exclusive reference to the slider tail and its index in the nested object list.
///
/// This is usually but not necessarily the last nested object.
pub fn tail_mut(&mut self) -> Option<(usize, &mut NestedObject)> {
self.nested_objects
.iter_mut()
.enumerate()
.rev()
.find(|(_, nested)| matches!(nested.kind, NestedObjectKind::Tail))
}
}
/// A nested object within a slider.
#[derive(Clone, Debug, PartialEq)]
pub struct NestedObject {
/// Position of the object.
///
/// Note: `pos` does not include stacking.
pub pos: Pos2,
/// Start time of the object.
pub start_time: f64,
/// Type of the object.
pub kind: NestedObjectKind,
}
/// Type of a [`NestedObject`].
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum NestedObjectKind {
/// A repeat point.
Repeat,
/// The logical slider tail i.e. the legacy last tick.
Tail,
/// A regular slider tick.
Tick,
}
pub(crate) struct ObjectParameters<'a> {
pub(crate) map: &'a Beatmap,
pub(crate) attrs: &'a mut OsuDifficultyAttributes,
pub(crate) ticks: Vec<(Pos2, f64)>,
pub(crate) curve_bufs: CurveBuffers,
}
impl OsuObject {
#[allow(clippy::too_many_lines)]
pub(crate) fn new(h: &HitObject, params: &mut ObjectParameters<'_>) -> Self {
let ObjectParameters {
map,
attrs,
ticks,
curve_bufs,
} = params;
attrs.max_combo += 1; // hitcircle, slider head, or spinner
let pos = h.pos;
match &h.kind {
HitObjectKind::Circle => {
attrs.n_circles += 1;
Self {
start_time: h.start_time,
pos,
stack_offset: Pos2::default(),
stack_height: 0.0,
kind: OsuObjectKind::Circle,
}
}
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
..
} => {
attrs.n_sliders += 1;
let timing_point = map.timing_point_at(h.start_time);
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
let scoring_dist =
BASE_SCORING_DISTANCE * map.slider_mult * difficulty_point.slider_vel;
let vel = scoring_dist / timing_point.beat_len;
// * prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
// * this results in more (or less) ticks being generated in <v8 maps for the same time duration.
let tick_dist_mult = if map.version < 8 {
difficulty_point.slider_vel.recip()
} else {
1.0
};
let mut tick_dist = if difficulty_point.generate_ticks {
scoring_dist / map.tick_rate * tick_dist_mult
} else {
f64::INFINITY
};
let span_count = (*repeats + 1) as f64;
// Build the curve w.r.t. the control points
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
let end_time = h.start_time + span_count * curve.dist() / vel;
let total_duration = end_time - h.start_time;
let span_duration = total_duration / span_count;
// * A very lenient maximum length of a slider for ticks to be generated.
// * This exists for edge cases such as /b/1573664 where the beatmap has
// * been edited by the user, and should never be reached in normal usage.
let max_len = 100_000.0;
let len = curve.dist().min(max_len);
tick_dist = tick_dist.clamp(0.0, len);
let min_dist_from_end = vel * 10.0;
let mut curr_dist = tick_dist;
ticks.clear();
#[allow(clippy::if_not_else)]
let mut nested_objects = if tick_dist != 0.0 {
ticks.reserve((len / tick_dist) as usize);
let mut nested_objects =
Vec::with_capacity((len * span_count / tick_dist) as usize);
// Ticks of the first span
while curr_dist < len - min_dist_from_end {
let progress = curr_dist / len;
let curr_time = h.start_time + progress * span_duration;
let curr_pos = h.pos + curve.position_at(progress);
let tick = NestedObject {
pos: curr_pos,
start_time: curr_time,
kind: NestedObjectKind::Tick,
};
nested_objects.push(tick);
ticks.push((curr_pos, curr_time));
curr_dist += tick_dist;
}
// Other spans
for span_idx in 1..=*repeats {
let progress = f64::from(u8::from(span_idx % 2 == 1));
let span_idx_f64 = span_idx as f64;
// Repeat point
let curr_time = h.start_time + span_duration * span_idx_f64;
let curr_pos = h.pos + curve.position_at(progress);
let repeat = NestedObject {
pos: curr_pos,
start_time: curr_time,
kind: NestedObjectKind::Repeat,
};
nested_objects.push(repeat);
let span_offset = span_idx_f64 * span_duration;
// Ticks
if span_idx & 1 == 1 {
let base = h.start_time + h.start_time + span_duration;
let tick_iter = ticks.iter().rev().map(|(pos, time)| NestedObject {
pos: *pos,
start_time: span_offset + base - time,
kind: NestedObjectKind::Tick,
});
nested_objects.extend(tick_iter);
} else {
let tick_iter = ticks.iter().map(|(pos, time)| NestedObject {
pos: *pos,
start_time: time + span_offset,
kind: NestedObjectKind::Tick,
});
nested_objects.extend(tick_iter);
}
}
nested_objects
} else {
Vec::new()
};
// Slider tail
let final_span_start_time = h.start_time + *repeats as f64 * span_duration;
let final_span_end_time = (h.start_time + total_duration / 2.0)
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
let progress = f64::from(u8::from(*repeats % 2 == 0));
let end_pos = curve.position_at(progress);
// * we need to use the LegacyLastTick here for compatibility reasons (difficulty).
// * it is *okay* to use this because the TailCircle is not used for any meaningful purpose in gameplay.
// * if this is to change, we should revisit this.
let legacy_last_tick = NestedObject {
pos: end_pos,
start_time: final_span_end_time,
kind: NestedObjectKind::Tail,
};
// On very short buzz sliders it can happen that the
// legacy last tick is not the last object time-wise
match nested_objects.last() {
Some(last) if last.start_time > final_span_end_time => {
let idx = nested_objects
.iter()
.rev()
.position(|nested| nested.start_time <= final_span_end_time)
.map_or(0, |i| nested_objects.len() - i);
nested_objects.insert(idx, legacy_last_tick);
}
_ => nested_objects.push(legacy_last_tick),
};
attrs.max_combo += nested_objects.len();
let last_time = nested_objects
.last()
.map_or(final_span_end_time, |nested| nested.start_time);
let lazy_travel_time = last_time - h.start_time;
let mut end_time_min = lazy_travel_time / span_duration;
if end_time_min % 2.0 >= 1.0 {
end_time_min = 1.0 - end_time_min % 1.0;
} else {
end_time_min %= 1.0;
}
// * temporary lazy end position until a real result can be derived.
// The position is added after the stacking for the correct order of
// floating point operations.
let lazy_end_pos = curve.position_at(end_time_min);
let slider = OsuSlider {
end_time,
lazy_end_pos,
nested_objects,
};
Self {
start_time: h.start_time,
pos,
stack_offset: Pos2::default(),
stack_height: 0.0,
kind: OsuObjectKind::Slider(slider),
}
}
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
attrs.n_spinners += 1;
Self {
start_time: h.start_time,
pos,
stack_offset: Pos2::default(),
stack_height: 0.0,
kind: OsuObjectKind::Spinner {
end_time: *end_time,
},
}
}
}
}
/// Endtime of the object.
pub const fn end_time(&self) -> f64 {
match &self.kind {
OsuObjectKind::Circle => self.start_time,
OsuObjectKind::Slider(slider) => slider.end_time,
OsuObjectKind::Spinner { end_time } => *end_time,
}
}
/// End position of the object.
pub fn end_pos(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
OsuObjectKind::Slider(slider) => slider.end_pos().unwrap_or(self.pos),
}
}
pub(crate) fn pre_stacked_end_pos(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
OsuObjectKind::Slider(slider) => slider
.end_pos()
.map_or(self.pos, |end_pos| self.pos + end_pos),
}
}
pub(crate) fn old_stacking_pos2(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
OsuObjectKind::Slider(slider) => {
// Old stacking requires the path end position
// instead of slider end position
let repeat_count = slider.repeat_count();
if repeat_count % 2 == 0 {
slider
.end_pos()
.map_or(self.pos, |end_pos| self.pos + end_pos)
} else {
slider
.nested_objects
.iter()
.find(|nested| matches!(nested.kind, NestedObjectKind::Repeat))
.map_or(self.pos, |repeat| repeat.pos)
}
}
}
}
/// Position of the object without stacking.
pub const fn pos(&self) -> Pos2 {
self.pos
}
/// Stacked position of the object.
pub fn stacked_pos(&self) -> Pos2 {
self.pos + self.stack_offset
}
/// Stacked end position of the object.
pub fn stacked_end_pos(&self) -> Pos2 {
self.end_pos() + self.stack_offset
}
/// Lazy end position of the object. Stacking is included.
pub fn lazy_end_pos(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.stacked_pos(),
OsuObjectKind::Slider(slider) => slider.lazy_end_pos,
}
}
/// Lazy travel time of the object.
pub fn lazy_travel_time(&self) -> f64 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => 0.0,
OsuObjectKind::Slider(slider) => slider
.nested_objects
.last()
.map_or(0.0, |nested| nested.start_time - self.start_time),
}
}
/// Whether the object is a hitcircle.
pub const fn is_circle(&self) -> bool {
matches!(self.kind, OsuObjectKind::Circle)
}
/// Whether the object is a slider.
pub const fn is_slider(&self) -> bool {
matches!(self.kind, OsuObjectKind::Slider { .. })
}
/// Whether the object is a spinner.
pub const fn is_spinner(&self) -> bool {
matches!(self.kind, OsuObjectKind::Spinner { .. })
}
/// Applies stack offset, flips playfield for HR,
/// and adjusts slider tails and lazy end positions.
pub(crate) fn post_process(&mut self, hr: bool, scaling_factor: &ScalingFactor) {
self.stack_offset = scaling_factor.stack_offset(self.stack_height);
let pos = self.pos();
if let OsuObjectKind::Slider(slider) = &mut self.kind {
if hr {
let mut lazy_end_pos = pos;
lazy_end_pos.y = PLAYFIELD_BASE_SIZE.y - lazy_end_pos.y;
lazy_end_pos += self.stack_offset;
lazy_end_pos += Pos2 {
x: slider.lazy_end_pos.x,
y: -slider.lazy_end_pos.y,
};
slider.lazy_end_pos = lazy_end_pos;
let tail_idx = slider.tail_mut().map(|(tail_idx, tail)| {
let mut tail_pos = pos;
tail_pos.y = PLAYFIELD_BASE_SIZE.y - tail_pos.y;
tail_pos += Pos2 {
x: tail.pos.x,
y: -tail.pos.y,
};
tail.pos = tail_pos;
tail_idx
});
if let Some(tail_idx) = tail_idx {
for nested in slider.nested_objects[..tail_idx].iter_mut() {
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
}
for nested in slider.nested_objects[tail_idx + 1..].iter_mut() {
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
}
} else {
// Should never happen since sliders are bound to have a tail
for nested in slider.nested_objects.iter_mut() {
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
}
}
} else {
slider.lazy_end_pos += pos + self.stack_offset;
if let Some((_, tail)) = slider.tail_mut() {
tail.pos += pos;
}
}
}
if hr {
self.pos.y = PLAYFIELD_BASE_SIZE.y - pos.y;
}
}
}
+213 -504
View File
@@ -1,101 +1,102 @@
use super::{
OsuDifficultyAttributes, OsuPerformanceAttributes, OsuScoreState, PERFORMANCE_BASE_MULTIPLIER,
};
use std::cmp;
use rosu_map::section::general::GameMode;
use crate::{
util::{MapOrElse, MapRef},
AnyPP, Beatmap, CatchPP, DifficultyAttributes, GameMode, HitResultPriority, ManiaPP, Mods,
OsuStars, PerformanceAttributes, TaikoPP,
any::ModeDifficulty,
any::{HitResultPriority, ModeAttributeProvider, Performance},
catch::CatchPerformance,
mania::ManiaPerformance,
taiko::TaikoPerformance,
util::{float_ext::FloatExt, map_or_attrs::MapOrAttrs, mods::Mods},
};
use super::{
attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes},
convert::OsuBeatmap,
score_state::OsuScoreState,
Osu,
};
/// Performance calculator on osu!standard maps.
///
/// # Example
///
/// ```
/// use rosu_pp::{OsuPP, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let pp_result = OsuPP::new(&map)
/// .mods(8 + 64) // HDDT
/// .combo(1234)
/// .accuracy(98.5)
/// .n_misses(1)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", pp_result.pp(), pp_result.stars());
///
/// let next_result = OsuPP::new(&map)
/// .attributes(pp_result) // reusing previous results for performance
/// .mods(8 + 64) // has to be the same to reuse attributes
/// .accuracy(99.5)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
/// ```
#[derive(Clone, Debug)]
#[allow(clippy::upper_case_acronyms)]
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct OsuPP<'map> {
pub(crate) map_or_attrs: MapOrElse<MapRef<'map>, OsuDifficultyAttributes>,
pub struct OsuPerformance<'map> {
pub(crate) map_or_attrs: MapOrAttrs<'map, Osu>,
pub(crate) mods: u32,
pub(crate) acc: Option<f64>,
pub(crate) combo: Option<usize>,
pub(crate) combo: Option<u32>,
pub(crate) n300: Option<usize>,
pub(crate) n100: Option<usize>,
pub(crate) n50: Option<usize>,
pub(crate) n_misses: Option<usize>,
pub(crate) passed_objects: Option<usize>,
pub(crate) n300: Option<u32>,
pub(crate) n100: Option<u32>,
pub(crate) n50: Option<u32>,
pub(crate) n_misses: Option<u32>,
pub(crate) passed_objects: Option<u32>,
pub(crate) clock_rate: Option<f64>,
pub(crate) hitresult_priority: Option<HitResultPriority>,
pub(crate) hitresult_priority: HitResultPriority,
}
impl<'map> OsuPP<'map> {
impl<'map> OsuPerformance<'map> {
/// Create a new performance calculator for osu!standard maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map_or_attrs: MapOrElse::from(map),
mods: 0,
acc: None,
combo: None,
pub fn new(map: OsuBeatmap<'map>) -> Self {
map.into()
}
n300: None,
n100: None,
n50: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
hitresult_priority: None,
/// Attempt to convert the map to the specified mode.
///
/// Returns `None` if the internal beatmap was already replaced with
/// [`OsuDifficultyAttributes`], i.e. if [`OsuPerformance::attributes`] or
/// [`OsuPerformance::generate_state`] was called.
///
/// If the given mode should be ignored in case the internal beatmap was
/// replaced, use [`mode_or_ignore`] instead.
///
/// [`mode_or_ignore`]: Self::mode_or_ignore
pub fn try_mode(self, mode: GameMode) -> Option<Performance<'map>> {
match mode {
GameMode::Osu => Some(Performance::Osu(self)),
GameMode::Taiko => TaikoPerformance::try_from(self)
.map(Performance::Taiko)
.ok(),
GameMode::Catch => CatchPerformance::try_from(self)
.map(Performance::Catch)
.ok(),
GameMode::Mania => ManiaPerformance::try_from(self)
.map(Performance::Mania)
.ok(),
}
}
/// Convert the map into another mode.
/// Attempt to convert the map to the specified mode.
///
/// Returns `None` if `self` already replaced it's internal [`Beatmap`]
/// with [`OsuDifficultyAttributes`], i.e. if [`OsuPP::attributes`]
/// or [`OsuPP::generate_state`] was called.
#[inline]
pub fn try_mode(self, mode: GameMode) -> Option<AnyPP<'map>> {
/// If the internal beatmap was already replaced with difficulty
/// attributes, the map won't be modified.
///
/// To see whether the internal beatmap was replaced, use [`try_mode`]
/// instead.
///
/// [`try_mode`]: Self::try_mode
pub fn mode_or_ignore(self, mode: GameMode) -> Performance<'map> {
match mode {
GameMode::Osu => Some(AnyPP::Osu(self)),
GameMode::Taiko => TaikoPP::try_from_osu(self).map(AnyPP::Taiko),
GameMode::Catch => CatchPP::try_from_osu(self).map(AnyPP::Catch),
GameMode::Mania => ManiaPP::try_from_osu(self).map(AnyPP::Mania),
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)
}
}
}
/// Provide the result of a previous difficulty or performance calculation.
/// If you already calculated the attributes for the current map-mod combination,
/// be sure to put them in here so that they don't have to be recalculated.
#[inline]
pub fn attributes(mut self, attributes: impl OsuAttributeProvider) -> Self {
pub fn attributes(mut self, attributes: impl ModeAttributeProvider<Osu>) -> Self {
if let Some(attrs) = attributes.attributes() {
self.map_or_attrs = MapOrElse::Else(attrs);
self.map_or_attrs = MapOrAttrs::Attrs(attrs);
}
self
@@ -104,7 +105,6 @@ impl<'map> OsuPP<'map> {
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
@@ -112,8 +112,7 @@ impl<'map> OsuPP<'map> {
}
/// Specify the max combo of the play.
#[inline]
pub const fn combo(mut self, combo: usize) -> Self {
pub const fn combo(mut self, combo: u32) -> Self {
self.combo = Some(combo);
self
@@ -122,40 +121,35 @@ impl<'map> OsuPP<'map> {
/// Specify how hitresults should be generated.
///
/// Defauls to [`HitResultPriority::BestCase`].
#[inline]
pub const fn hitresult_priority(mut self, priority: HitResultPriority) -> Self {
self.hitresult_priority = Some(priority);
self.hitresult_priority = priority;
self
}
/// Specify the amount of 300s of a play.
#[inline]
pub const fn n300(mut self, n300: usize) -> Self {
pub const fn n300(mut self, n300: u32) -> Self {
self.n300 = Some(n300);
self
}
/// Specify the amount of 100s of a play.
#[inline]
pub const fn n100(mut self, n100: usize) -> Self {
pub const fn n100(mut self, n100: u32) -> Self {
self.n100 = Some(n100);
self
}
/// Specify the amount of 50s of a play.
#[inline]
pub const fn n50(mut self, n50: usize) -> Self {
pub const fn n50(mut self, n50: u32) -> Self {
self.n50 = Some(n50);
self
}
/// Specify the amount of misses of a play.
#[inline]
pub const fn n_misses(mut self, n_misses: usize) -> Self {
pub const fn n_misses(mut self, n_misses: u32) -> Self {
self.n_misses = Some(n_misses);
self
@@ -169,8 +163,7 @@ impl<'map> OsuPP<'map> {
using [`OsuPP`] multiple times with different `passed_objects`, you should use
[`OsuGradualPerformanceAttributes`](crate::osu::OsuGradualPerformance)."
)]
#[inline]
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
pub const fn passed_objects(mut self, passed_objects: u32) -> Self {
self.passed_objects = Some(passed_objects);
self
@@ -179,7 +172,6 @@ impl<'map> OsuPP<'map> {
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
@@ -187,7 +179,6 @@ impl<'map> OsuPP<'map> {
}
/// Provide parameters through an [`OsuScoreState`].
#[inline]
#[allow(clippy::needless_pass_by_value)]
pub const fn state(mut self, state: OsuScoreState) -> Self {
let OsuScoreState {
@@ -209,7 +200,6 @@ impl<'map> OsuPP<'map> {
/// Specify the accuracy of a play between `0.0` and `100.0`.
/// This will be used to generate matching hitresults.
#[inline]
pub fn accuracy(mut self, acc: f64) -> Self {
self.acc = Some(acc / 100.0);
@@ -220,17 +210,17 @@ impl<'map> OsuPP<'map> {
#[allow(clippy::too_many_lines)]
pub fn generate_state(&mut self) -> OsuScoreState {
let attrs = match self.map_or_attrs {
MapOrElse::Map(ref map) => {
let attrs = self.generate_attributes(map.as_ref());
MapOrAttrs::Map(ref map) => {
let attrs = self.generate_attributes(map);
self.map_or_attrs.else_or_insert(attrs)
self.map_or_attrs.attrs_or_insert(attrs)
}
MapOrElse::Else(ref attrs) => attrs,
MapOrAttrs::Attrs(ref attrs) => attrs,
};
let max_combo = attrs.max_combo;
let n_objects = self.passed_objects.unwrap_or(attrs.n_objects());
let priority = self.hitresult_priority.unwrap_or_default();
let priority = self.hitresult_priority;
let n_misses = self.n_misses.map_or(0, |n| n.min(n_objects));
let n_remaining = n_objects - n_misses;
@@ -240,7 +230,7 @@ impl<'map> OsuPP<'map> {
let mut n50 = self.n50.map_or(0, |n| n.min(n_remaining));
if let Some(acc) = self.acc {
let target_total = acc * (6 * n_objects) as f64;
let target_total = acc * f64::from(6 * n_objects);
match (self.n300, self.n100, self.n50) {
(Some(_), Some(_), Some(_)) => {
@@ -260,9 +250,9 @@ impl<'map> OsuPP<'map> {
n300 = n300.min(n_remaining);
let n_remaining = n_remaining - n300;
let raw_n100 = target_total - (n_remaining + 6 * n300) as f64;
let min_n100 = n_remaining.min(raw_n100.floor() as usize);
let max_n100 = n_remaining.min(raw_n100.ceil() as usize);
let raw_n100 = target_total - f64::from(n_remaining + 6 * n300);
let min_n100 = n_remaining.min(raw_n100.floor() as u32);
let max_n100 = n_remaining.min(raw_n100.ceil() as u32);
for new100 in min_n100..=max_n100 {
let new50 = n_remaining - new100;
@@ -281,9 +271,9 @@ impl<'map> OsuPP<'map> {
n100 = n100.min(n_remaining);
let n_remaining = n_remaining - n100;
let raw_n300 = (target_total - (n_remaining + 2 * n100) as f64) / 5.0;
let min_n300 = n_remaining.min(raw_n300.floor() as usize);
let max_n300 = n_remaining.min(raw_n300.ceil() as usize);
let raw_n300 = (target_total - f64::from(n_remaining + 2 * n100)) / 5.0;
let min_n300 = n_remaining.min(raw_n300.floor() as u32);
let max_n300 = n_remaining.min(raw_n300.ceil() as u32);
for new300 in min_n300..=max_n300 {
let new50 = n_remaining - new300;
@@ -302,11 +292,12 @@ impl<'map> OsuPP<'map> {
n50 = n50.min(n_remaining);
let n_remaining = n_remaining - n50;
let raw_n300 =
(target_total + (2 * n_misses + n50) as f64 - (2 * n_objects) as f64) / 4.0;
let raw_n300 = (target_total + f64::from(2 * n_misses + n50)
- f64::from(2 * n_objects))
/ 4.0;
let min_n300 = n_remaining.min(raw_n300.floor() as usize);
let max_n300 = n_remaining.min(raw_n300.ceil() as usize);
let min_n300 = n_remaining.min(raw_n300.floor() as u32);
let max_n300 = n_remaining.min(raw_n300.ceil() as u32);
for new300 in min_n300..=max_n300 {
let new100 = n_remaining - new300;
@@ -322,14 +313,14 @@ impl<'map> OsuPP<'map> {
(None, None, None) => {
let mut best_dist = f64::MAX;
let raw_n300 = (target_total - n_remaining as f64) / 5.0;
let min_n300 = n_remaining.min(raw_n300.floor() as usize);
let max_n300 = n_remaining.min(raw_n300.ceil() as usize);
let raw_n300 = (target_total - f64::from(n_remaining)) / 5.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 - (n_remaining + 5 * new300) as f64;
let min_n100 = (raw_n100.floor() as usize).min(n_remaining - new300);
let max_n100 = (raw_n100.ceil() as usize).min(n_remaining - new300);
let raw_n100 = target_total - f64::from(n_remaining + 5 * new300);
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;
@@ -401,13 +392,13 @@ impl<'map> OsuPP<'map> {
let state = self.generate_state();
let attrs = match self.map_or_attrs {
MapOrElse::Map(ref map) => self.generate_attributes(map.as_ref()),
MapOrElse::Else(attrs) => attrs,
MapOrAttrs::Map(ref map) => self.generate_attributes(map),
MapOrAttrs::Attrs(attrs) => attrs,
};
let effective_miss_count = calculate_effective_misses(&attrs, &state);
let inner = OsuPpInner {
let inner = OsuPerformanceInner {
attrs,
mods: self.mods,
acc: state.accuracy(),
@@ -418,36 +409,100 @@ impl<'map> OsuPP<'map> {
inner.calculate()
}
fn generate_attributes(&self, map: &Beatmap) -> OsuDifficultyAttributes {
let mut calculator = OsuStars::new(map).mods(self.mods);
fn generate_attributes(&self, map: &OsuBeatmap<'_>) -> OsuDifficultyAttributes {
let mut calculator = ModeDifficulty::new();
if let Some(passed_objects) = self.passed_objects {
calculator = calculator.passed_objects(passed_objects);
calculator.passed_objects(passed_objects);
}
if let Some(clock_rate) = self.clock_rate {
calculator = calculator.clock_rate(clock_rate);
calculator.clock_rate(clock_rate);
}
calculator.calculate()
calculator.mods(self.mods).calculate(map)
}
/// Try to create [`OsuPP`] through a [`OsuAttributeProvider`].
/// Try to create [`OsuPerformance`] through a [`ModeAttributeProvider`].
///
/// If you already calculated the attributes for the current map-mod
/// combination, the [`Beatmap`] is no longer necessary to calculate
/// combination, the [`OsuBeatmap`] is no longer necessary to calculate
/// performance attributes so this method can be used instead of
/// [`OsuPP::new`].
/// [`OsuPerformance::new`].
///
/// Returns `None` only if the [`OsuAttributeProvider`] did not contain
/// Returns `None` only if the [`ModeAttributeProvider`] did not contain
/// attributes for osu e.g. if it's [`DifficultyAttributes::Taiko`].
#[inline]
pub fn try_from_attributes(attributes: impl OsuAttributeProvider) -> Option<Self> {
///
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
pub fn try_from_attributes(attributes: impl ModeAttributeProvider<Osu>) -> Option<Self> {
attributes.attributes().map(Self::from)
}
/// Create [`OsuPerformance`] through a [`ModeAttributeProvider`].
///
/// If you already calculated the attributes for the current map-mod
/// combination, the [`OsuBeatmap`] is no longer necessary to calculate
/// performance attributes so this method can be used instead of
/// [`OsuPerformance::new`].
///
/// # Panics
///
/// Panics if the [`ModeAttributeProvider`] did not contain attributes for
/// osu e.g. if it's [`DifficultyAttributes::Taiko`].
///
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
pub fn unchecked_from_attributes(attributes: impl ModeAttributeProvider<Osu>) -> Self {
Self::try_from_attributes(attributes).expect("invalid osu attributes")
}
}
struct OsuPpInner {
impl<'map> From<OsuBeatmap<'map>> for OsuPerformance<'map> {
fn from(map: OsuBeatmap<'map>) -> Self {
Self {
map_or_attrs: MapOrAttrs::Map(map),
mods: 0,
acc: None,
combo: None,
n300: None,
n100: None,
n50: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
hitresult_priority: HitResultPriority::default(),
}
}
}
impl From<OsuDifficultyAttributes> for OsuPerformance<'_> {
fn from(attrs: OsuDifficultyAttributes) -> Self {
Self {
map_or_attrs: MapOrAttrs::Attrs(attrs),
mods: 0,
acc: None,
combo: None,
n300: None,
n100: None,
n50: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
hitresult_priority: HitResultPriority::default(),
}
}
}
impl From<OsuPerformanceAttributes> for OsuPerformance<'_> {
fn from(attrs: OsuPerformanceAttributes) -> Self {
attrs.difficulty.into()
}
}
pub const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14;
struct OsuPerformanceInner {
attrs: OsuDifficultyAttributes,
mods: u32,
acc: f64,
@@ -455,7 +510,7 @@ struct OsuPpInner {
effective_miss_count: f64,
}
impl OsuPpInner {
impl OsuPerformanceInner {
fn calculate(mut self) -> OsuPerformanceAttributes {
let total_hits = self.state.total_hits();
@@ -466,7 +521,7 @@ impl OsuPpInner {
};
}
let total_hits = total_hits as f64;
let total_hits = f64::from(total_hits);
let mut multiplier = PERFORMANCE_BASE_MULTIPLIER;
@@ -475,7 +530,7 @@ impl OsuPpInner {
}
if self.mods.so() && total_hits > 0.0 {
multiplier *= 1.0 - (self.attrs.n_spinners as f64 / total_hits).powf(0.85);
multiplier *= 1.0 - (f64::from(self.attrs.n_spinners) / total_hits).powf(0.85);
}
if self.mods.rx() {
@@ -494,9 +549,9 @@ impl OsuPpInner {
// * 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
+ self.state.n100 as f64
+ f64::from(self.state.n100)
+ n100_mult
+ self.state.n50 as f64 * n50_mult)
+ f64::from(self.state.n50) * n50_mult)
.min(total_hits);
}
@@ -563,14 +618,14 @@ impl OsuPpInner {
}
// * We assume 15% of sliders in a map are difficult since there's no way to tell from the performance calculator.
let estimate_diff_sliders = self.attrs.n_sliders as f64 * 0.15;
let estimate_diff_sliders = f64::from(self.attrs.n_sliders) * 0.15;
if self.attrs.n_sliders > 0 {
let estimate_slider_ends_dropped =
((self.state.n100 + self.state.n50 + self.state.n_misses)
.min(self.attrs.max_combo.saturating_sub(self.state.max_combo))
as f64)
.clamp(0.0, estimate_diff_sliders);
let estimate_slider_ends_dropped = f64::from(
(self.state.n100 + self.state.n50 + self.state.n_misses)
.min(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).powi(3)
+ self.attrs.slider_factor;
@@ -628,15 +683,15 @@ impl OsuPpInner {
// * Calculate accuracy assuming the worst case scenario
let relevant_total_diff = total_hits - self.attrs.speed_note_count;
let relevant_n300 = (self.state.n300 as f64 - relevant_total_diff).max(0.0);
let relevant_n100 = (self.state.n100 as f64
- (relevant_total_diff - self.state.n300 as f64).max(0.0))
let relevant_n300 = (f64::from(self.state.n300) - relevant_total_diff).max(0.0);
let relevant_n100 = (f64::from(self.state.n100)
- (relevant_total_diff - f64::from(self.state.n300)).max(0.0))
.max(0.0);
let relevant_n50 = (self.state.n50 as f64
- (relevant_total_diff - (self.state.n300 + self.state.n100) as f64).max(0.0))
let relevant_n50 = (f64::from(self.state.n50)
- (relevant_total_diff - f64::from(self.state.n300 + self.state.n100)).max(0.0))
.max(0.0);
let relevant_acc = if self.attrs.speed_note_count.abs() <= f64::EPSILON {
let relevant_acc = if self.attrs.speed_note_count.eq(0.0) {
0.0
} else {
(relevant_n300 * 6.0 + relevant_n100 * 2.0 + relevant_n50)
@@ -649,8 +704,8 @@ impl OsuPpInner {
// * Scale the speed value with # of 50s to punish doubletapping.
speed_value *= 0.99_f64.powf(
f64::from(u8::from(self.state.n50 as f64 >= total_hits / 500.0))
* (self.state.n50 as f64 - total_hits / 500.0),
f64::from(u8::from(f64::from(self.state.n50) >= total_hits / 500.0))
* (f64::from(self.state.n50) - total_hits / 500.0),
);
speed_value
@@ -672,8 +727,8 @@ impl OsuPpInner {
if self.state.n300 < sub {
0.0
} else {
((self.state.n300 - sub) * 6 + self.state.n100 * 2 + self.state.n50) as f64
/ (amount_hit_objects_with_acc * 6) as f64
f64::from((self.state.n300 - sub) * 6 + self.state.n100 * 2 + self.state.n50)
/ f64::from(amount_hit_objects_with_acc * 6)
}
} else {
0.0
@@ -684,7 +739,7 @@ impl OsuPpInner {
let mut acc_value = 1.52163_f64.powf(self.attrs.od) * better_acc_percentage.powi(24) * 2.83;
// * Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
acc_value *= (amount_hit_objects_with_acc as f64 / 1000.0)
acc_value *= (f64::from(amount_hit_objects_with_acc) / 1000.0)
.powf(0.3)
.min(1.15);
@@ -737,7 +792,7 @@ impl OsuPpInner {
if self.attrs.max_combo == 0 {
1.0
} else {
((self.state.max_combo as f64).powf(0.8) / (self.attrs.max_combo as f64).powf(0.8))
(f64::from(self.state.max_combo).powf(0.8) / f64::from(self.attrs.max_combo).powf(0.8))
.min(1.0)
}
}
@@ -752,21 +807,21 @@ fn calculate_effective_misses(attrs: &OsuDifficultyAttributes, state: &OsuScoreS
let mut combo_based_miss_count = 0.0;
if attrs.n_sliders > 0 {
let full_combo_threshold = attrs.max_combo as f64 - 0.1 * attrs.n_sliders as f64;
let full_combo_threshold = f64::from(attrs.max_combo) - 0.1 * f64::from(attrs.n_sliders);
if (state.max_combo as f64) < full_combo_threshold {
combo_based_miss_count = full_combo_threshold / (state.max_combo as f64).max(1.0);
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((state.n100 + state.n50 + state.n_misses) as f64);
combo_based_miss_count.min(f64::from(state.n100 + state.n50 + state.n_misses));
combo_based_miss_count.max(state.n_misses as f64)
combo_based_miss_count.max(f64::from(state.n_misses))
}
fn accuracy(n300: usize, n100: usize, n50: usize, n_misses: usize) -> f64 {
fn accuracy(n300: u32, n100: u32, n50: u32, n_misses: u32) -> f64 {
if n300 + n100 + n50 + n_misses == 0 {
return 0.0;
}
@@ -774,351 +829,5 @@ fn accuracy(n300: usize, n100: usize, n50: usize, n_misses: usize) -> f64 {
let numerator = 6 * n300 + 2 * n100 + n50;
let denominator = 6 * (n300 + n100 + n50 + n_misses);
numerator as f64 / denominator as f64
}
impl From<OsuDifficultyAttributes> for OsuPP<'_> {
#[inline]
fn from(attrs: OsuDifficultyAttributes) -> Self {
Self {
map_or_attrs: MapOrElse::Else(attrs),
mods: 0,
acc: None,
combo: None,
n300: None,
n100: None,
n50: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
hitresult_priority: None,
}
}
}
impl From<OsuPerformanceAttributes> for OsuPP<'_> {
#[inline]
fn from(attrs: OsuPerformanceAttributes) -> Self {
attrs.difficulty.into()
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait OsuAttributeProvider {
/// Provide the actual difficulty attributes.
fn attributes(self) -> Option<OsuDifficultyAttributes>;
}
impl OsuAttributeProvider for OsuDifficultyAttributes {
#[inline]
fn attributes(self) -> Option<OsuDifficultyAttributes> {
Some(self)
}
}
impl OsuAttributeProvider for OsuPerformanceAttributes {
#[inline]
fn attributes(self) -> Option<OsuDifficultyAttributes> {
Some(self.difficulty)
}
}
impl OsuAttributeProvider for DifficultyAttributes {
#[inline]
fn attributes(self) -> Option<OsuDifficultyAttributes> {
if let Self::Osu(attributes) = self {
Some(attributes)
} else {
None
}
}
}
impl OsuAttributeProvider for PerformanceAttributes {
#[inline]
fn attributes(self) -> Option<OsuDifficultyAttributes> {
if let Self::Osu(attributes) = self {
Some(attributes.difficulty)
} else {
None
}
}
}
#[cfg(not(any(feature = "async_tokio", feature = "async_std")))]
#[cfg(test)]
mod test {
use super::*;
use crate::Beatmap;
use proptest::{option, prelude::*};
use std::sync::OnceLock;
static DATA: OnceLock<(Beatmap, OsuDifficultyAttributes)> = OnceLock::new();
const N_OBJECTS: usize = 601;
fn test_data() -> (&'static Beatmap, OsuDifficultyAttributes) {
let (map, attrs) = DATA.get_or_init(|| {
let path = "./maps/2785319.osu";
let map = Beatmap::from_path(path).unwrap();
let attrs = OsuStars::new(&map).calculate();
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,
);
(map, attrs)
});
(map, attrs.to_owned())
}
/// Checks all remaining hitresult combinations w.r.t. the given parameters
/// and returns the [`OsuScoreState`] that matches `acc` the best.
///
/// Very slow but accurate.
fn brute_force_best(
acc: f64,
n300: Option<usize>,
n100: Option<usize>,
n50: Option<usize>,
n_misses: usize,
best_case: bool,
) -> OsuScoreState {
let n_misses = n_misses.min(N_OBJECTS);
let mut best_state = OsuScoreState {
n_misses,
..Default::default()
};
let mut best_dist = f64::INFINITY;
let n_remaining = N_OBJECTS - n_misses;
let (min_n300, max_n300) = match (n300, n100, n50) {
(Some(n300), ..) => (n_remaining.min(n300), n_remaining.min(n300)),
(None, Some(n100), Some(n50)) => (
n_remaining.saturating_sub(n100 + n50),
n_remaining.saturating_sub(n100 + n50),
),
(None, ..) => (
0,
n_remaining.saturating_sub(n100.unwrap_or(0) + n50.unwrap_or(0)),
),
};
for new300 in min_n300..=max_n300 {
let (min_n100, max_n100) = match (n100, n50) {
(Some(n100), _) => (n_remaining.min(n100), n_remaining.min(n100)),
(None, Some(n50)) => (
n_remaining.saturating_sub(new300 + n50),
n_remaining.saturating_sub(new300 + n50),
),
(None, None) => (0, n_remaining - new300),
};
for new100 in min_n100..=max_n100 {
let new50 = match n50 {
Some(n50) => n_remaining.min(n50),
None => n_remaining.saturating_sub(new300 + new100),
};
let curr_acc = accuracy(new300, new100, new50, n_misses);
let curr_dist = (acc - curr_acc).abs();
if curr_dist < best_dist {
best_dist = curr_dist;
best_state.n300 = new300;
best_state.n100 = new100;
best_state.n50 = new50;
}
}
}
if best_state.n300 + best_state.n100 + best_state.n50 < n_remaining {
let remaining = n_remaining - (best_state.n300 + best_state.n100 + best_state.n50);
if best_case {
best_state.n300 += remaining;
} else {
best_state.n50 += remaining;
}
}
if n300.is_none() && n100.is_none() && n50.is_none() {
if best_case {
let n = best_state.n300.min(best_state.n50 / 4);
best_state.n300 -= n;
best_state.n100 += 5 * n;
best_state.n50 -= 4 * n;
} else {
let n = best_state.n100 / 5;
best_state.n300 += n;
best_state.n100 -= 5 * n;
best_state.n50 += 4 * n;
}
}
best_state
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(10_000))]
#[test]
fn osu_hitresults(
acc in 0.0..=1.0,
n300 in option::weighted(0.10, 0_usize..=N_OBJECTS + 10),
n100 in option::weighted(0.10, 0_usize..=N_OBJECTS + 10),
n50 in option::weighted(0.10, 0_usize..=N_OBJECTS + 10),
n_misses in option::weighted(0.15, 0_usize..=N_OBJECTS + 10),
best_case in prop::bool::ANY,
) {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let priority = if best_case {
HitResultPriority::BestCase
} else {
HitResultPriority::WorstCase
};
let mut state = OsuPP::new(map)
.attributes(attrs)
.accuracy(acc * 100.0)
.hitresult_priority(priority);
if let Some(n300) = n300 {
state = state.n300(n300);
}
if let Some(n100) = n100 {
state = state.n100(n100);
}
if let Some(n50) = n50 {
state = state.n50(n50);
}
if let Some(n_misses) = n_misses {
state = state.n_misses(n_misses);
}
let state = state.generate_state();
let mut expected = brute_force_best(
acc,
n300,
n100,
n50,
n_misses.unwrap_or(0),
best_case,
);
expected.max_combo = max_combo.saturating_sub(n_misses.map_or(0, |n| n.min(N_OBJECTS)));
assert_eq!(state, expected);
}
}
#[test]
fn hitresults_n300_n100_n_misses_best() {
let (map, attrs) = test_data();
let state = OsuPP::new(map)
.attributes(attrs)
.combo(500)
.n300(300)
.n100(20)
.n_misses(2)
.hitresult_priority(HitResultPriority::BestCase)
.generate_state();
let expected = OsuScoreState {
max_combo: 500,
n300: 300,
n100: 20,
n50: 279,
n_misses: 2,
};
assert_eq!(state, expected);
}
#[test]
fn hitresults_n300_n50_n_misses_best() {
let (map, attrs) = test_data();
let state = OsuPP::new(map)
.attributes(attrs)
.combo(500)
.n300(300)
.n50(10)
.n_misses(2)
.hitresult_priority(HitResultPriority::BestCase)
.generate_state();
let expected = OsuScoreState {
max_combo: 500,
n300: 300,
n100: 289,
n50: 10,
n_misses: 2,
};
assert_eq!(state, expected);
}
#[test]
fn hitresults_n50_n_misses_worst() {
let (map, attrs) = test_data();
let state = OsuPP::new(map)
.attributes(attrs)
.combo(500)
.n50(10)
.n_misses(2)
.hitresult_priority(HitResultPriority::WorstCase)
.generate_state();
let expected = OsuScoreState {
max_combo: 500,
n300: 0,
n100: 589,
n50: 10,
n_misses: 2,
};
assert_eq!(state, expected);
}
#[test]
fn hitresults_n300_n100_n50_n_misses_worst() {
let (map, attrs) = test_data();
let state = OsuPP::new(map)
.attributes(attrs)
.combo(500)
.n300(300)
.n100(50)
.n50(10)
.n_misses(2)
.hitresult_priority(HitResultPriority::WorstCase)
.generate_state();
let expected = OsuScoreState {
max_combo: 500,
n300: 300,
n100: 50,
n50: 249,
n_misses: 2,
};
assert_eq!(state, expected);
}
f64::from(numerator) / f64::from(denominator)
}

Some files were not shown because too many files have changed in this diff Show More