rosu_pp/any/difficulty/
mod.rs

1use std::{
2    fmt::{Debug, Formatter, Result as FmtResult},
3    num::NonZeroU64,
4};
5
6use rosu_map::section::general::GameMode;
7
8use crate::{
9    catch::Catch,
10    mania::Mania,
11    model::{beatmap::Beatmap, mode::ConvertError, mods::GameMods},
12    osu::Osu,
13    taiko::Taiko,
14    GradualDifficulty, GradualPerformance,
15};
16
17use super::{attributes::DifficultyAttributes, InspectDifficulty, Strains};
18
19pub mod gradual;
20pub mod inspect;
21pub mod object;
22pub mod skills;
23
24use crate::model::mode::IGameMode;
25
26/// Difficulty calculator on maps of any mode.
27///
28/// # Example
29///
30/// ```
31/// use rosu_pp::{Beatmap, Difficulty, any::DifficultyAttributes};
32///
33/// let map = Beatmap::from_path("./resources/2118524.osu").unwrap();
34///
35/// let attrs: DifficultyAttributes = Difficulty::new()
36///     .mods(8 + 1024) // HDFL
37///     .calculate(&map);
38/// ```
39#[derive(Clone, PartialEq)]
40#[must_use]
41pub struct Difficulty {
42    mods: GameMods,
43    passed_objects: Option<u32>,
44    /// Clock rate will be clamped internally between 0.01 and 100.0.
45    ///
46    /// Since its minimum value is 0.01, its bits are never zero.
47    ///
48    /// This allows for an optimization to reduce the struct size by storing its
49    /// bits as a [`NonZeroU64`].
50    clock_rate: Option<NonZeroU64>,
51    ar: Option<ModsDependent>,
52    cs: Option<ModsDependent>,
53    hp: Option<ModsDependent>,
54    od: Option<ModsDependent>,
55    hardrock_offsets: Option<bool>,
56    lazer: Option<bool>,
57}
58
59/// Wrapper for beatmap attributes in [`Difficulty`].
60#[derive(Copy, Clone, Debug, Default, PartialEq)]
61pub struct ModsDependent {
62    /// Value of the beatmap attribute.
63    pub value: f32,
64    /// Whether `value` should be used as is or modified based on mods.
65    ///
66    /// `true` means "value already considers mods" i.e. use as is;
67    /// `false` means modify with mods.
68    pub with_mods: bool,
69}
70
71impl ModsDependent {
72    pub const fn new(value: f32) -> Self {
73        Self {
74            value,
75            with_mods: false,
76        }
77    }
78}
79
80impl Difficulty {
81    /// Create a new difficulty calculator.
82    pub const fn new() -> Self {
83        Self {
84            mods: GameMods::DEFAULT,
85            passed_objects: None,
86            clock_rate: None,
87            ar: None,
88            cs: None,
89            hp: None,
90            od: None,
91            hardrock_offsets: None,
92            lazer: None,
93        }
94    }
95
96    /// Turn this [`Difficulty`] into a [`InspectDifficulty`] to inspect its
97    /// configured values.
98    pub fn inspect(self) -> InspectDifficulty {
99        let Self {
100            mods,
101            passed_objects,
102            clock_rate,
103            ar,
104            cs,
105            hp,
106            od,
107            hardrock_offsets,
108            lazer,
109        } = self;
110
111        InspectDifficulty {
112            mods,
113            passed_objects,
114            clock_rate: clock_rate.map(non_zero_u64_to_f64),
115            ar,
116            cs,
117            hp,
118            od,
119            hardrock_offsets,
120            lazer,
121        }
122    }
123
124    /// Specify mods.
125    ///
126    /// Accepted types are
127    /// - `u32`
128    /// - [`rosu_mods::GameModsLegacy`]
129    /// - [`rosu_mods::GameMods`]
130    /// - [`rosu_mods::GameModsIntermode`]
131    /// - [`&rosu_mods::GameModsIntermode`](rosu_mods::GameModsIntermode)
132    ///
133    /// See <https://github.com/ppy/osu-api/wiki#mods>
134    pub fn mods(self, mods: impl Into<GameMods>) -> Self {
135        Self {
136            mods: mods.into(),
137            ..self
138        }
139    }
140
141    /// Amount of passed objects for partial plays, e.g. a fail.
142    pub const fn passed_objects(mut self, passed_objects: u32) -> Self {
143        self.passed_objects = Some(passed_objects);
144
145        self
146    }
147
148    /// Adjust the clock rate used in the calculation.
149    ///
150    /// If none is specified, it will take the clock rate based on the mods
151    /// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
152    ///
153    /// | Minimum | Maximum |
154    /// | :-----: | :-----: |
155    /// | 0.01    | 100     |
156    pub fn clock_rate(self, clock_rate: f64) -> Self {
157        let clock_rate = clock_rate.clamp(0.01, 100.0).to_bits();
158
159        // SAFETY: The minimum value is 0.01 so its bits can never be fully
160        // zero.
161        let non_zero = unsafe { NonZeroU64::new_unchecked(clock_rate) };
162
163        Self {
164            clock_rate: Some(non_zero),
165            ..self
166        }
167    }
168
169    /// Override a beatmap's set AR.
170    ///
171    /// Only relevant for osu! and osu!catch.
172    ///
173    /// `with_mods` determines if the given value should be used before
174    /// or after accounting for mods, e.g. on `true` the value will be
175    /// used as is and on `false` it will be modified based on the mods.
176    ///
177    /// | Minimum | Maximum |
178    /// | :-----: | :-----: |
179    /// | -20     | 20      |
180    pub fn ar(self, ar: f32, with_mods: bool) -> Self {
181        Self {
182            ar: Some(ModsDependent {
183                value: ar.clamp(-20.0, 20.0),
184                with_mods,
185            }),
186            ..self
187        }
188    }
189
190    /// Override a beatmap's set CS.
191    ///
192    /// Only relevant for osu! and osu!catch.
193    ///
194    /// `with_mods` determines if the given value should be used before
195    /// or after accounting for mods, e.g. on `true` the value will be
196    /// used as is and on `false` it will be modified based on the mods.
197    ///
198    /// | Minimum | Maximum |
199    /// | :-----: | :-----: |
200    /// | -20     | 20      |
201    pub fn cs(self, cs: f32, with_mods: bool) -> Self {
202        Self {
203            cs: Some(ModsDependent {
204                value: cs.clamp(-20.0, 20.0),
205                with_mods,
206            }),
207            ..self
208        }
209    }
210
211    /// Override a beatmap's set HP.
212    ///
213    /// `with_mods` determines if the given value should be used before
214    /// or after accounting for mods, e.g. on `true` the value will be
215    /// used as is and on `false` it will be modified based on the mods.
216    ///
217    /// | Minimum | Maximum |
218    /// | :-----: | :-----: |
219    /// | -20     | 20      |
220    pub fn hp(self, hp: f32, with_mods: bool) -> Self {
221        Self {
222            hp: Some(ModsDependent {
223                value: hp.clamp(-20.0, 20.0),
224                with_mods,
225            }),
226            ..self
227        }
228    }
229
230    /// Override a beatmap's set OD.
231    ///
232    /// `with_mods` determines if the given value should be used before
233    /// or after accounting for mods, e.g. on `true` the value will be
234    /// used as is and on `false` it will be modified based on the mods.
235    ///
236    /// | Minimum | Maximum |
237    /// | :-----: | :-----: |
238    /// | -20     | 20      |
239    pub fn od(self, od: f32, with_mods: bool) -> Self {
240        Self {
241            od: Some(ModsDependent {
242                value: od.clamp(-20.0, 20.0),
243                with_mods,
244            }),
245            ..self
246        }
247    }
248
249    /// Adjust patterns as if the HR mod is enabled.
250    ///
251    /// Only relevant for osu!catch.
252    pub const fn hardrock_offsets(mut self, hardrock_offsets: bool) -> Self {
253        self.hardrock_offsets = Some(hardrock_offsets);
254
255        self
256    }
257
258    /// Whether the calculated attributes belong to an osu!lazer or osu!stable
259    /// score.
260    ///
261    /// Defaults to `true`.
262    pub const fn lazer(mut self, lazer: bool) -> Self {
263        self.lazer = Some(lazer);
264
265        self
266    }
267
268    /// Perform the difficulty calculation.
269    #[allow(clippy::missing_panics_doc)]
270    pub fn calculate(&self, map: &Beatmap) -> DifficultyAttributes {
271        match map.mode {
272            GameMode::Osu => DifficultyAttributes::Osu(
273                Osu::difficulty(self, map).expect("no conversion required"),
274            ),
275            GameMode::Taiko => DifficultyAttributes::Taiko(
276                Taiko::difficulty(self, map).expect("no conversion required"),
277            ),
278            GameMode::Catch => DifficultyAttributes::Catch(
279                Catch::difficulty(self, map).expect("no conversion required"),
280            ),
281            GameMode::Mania => DifficultyAttributes::Mania(
282                Mania::difficulty(self, map).expect("no conversion required"),
283            ),
284        }
285    }
286
287    /// Perform the difficulty calculation for a specific [`IGameMode`].
288    pub fn calculate_for_mode<M: IGameMode>(
289        &self,
290        map: &Beatmap,
291    ) -> Result<M::DifficultyAttributes, ConvertError> {
292        M::difficulty(self, map)
293    }
294
295    /// Perform the difficulty calculation but instead of evaluating the skill
296    /// strains, return them as is.
297    ///
298    /// Suitable to plot the difficulty of a map over time.
299    #[allow(clippy::missing_panics_doc)]
300    pub fn strains(&self, map: &Beatmap) -> Strains {
301        match map.mode {
302            GameMode::Osu => Strains::Osu(Osu::strains(self, map).expect("no conversion required")),
303            GameMode::Taiko => {
304                Strains::Taiko(Taiko::strains(self, map).expect("no conversion required"))
305            }
306            GameMode::Catch => {
307                Strains::Catch(Catch::strains(self, map).expect("no conversion required"))
308            }
309            GameMode::Mania => {
310                Strains::Mania(Mania::strains(self, map).expect("no conversion required"))
311            }
312        }
313    }
314
315    /// Perform the strain calculation for a specific [`IGameMode`].
316    pub fn strains_for_mode<M: IGameMode>(
317        &self,
318        map: &Beatmap,
319    ) -> Result<M::Strains, ConvertError> {
320        M::strains(self, map)
321    }
322
323    /// Create a gradual difficulty calculator for a [`Beatmap`].
324    pub fn gradual_difficulty(self, map: &Beatmap) -> GradualDifficulty {
325        GradualDifficulty::new(self, map)
326    }
327
328    /// Create a gradual difficulty calculator for a [`Beatmap`] on a specific [`IGameMode`].
329    pub fn gradual_difficulty_for_mode<M: IGameMode>(
330        self,
331        map: &Beatmap,
332    ) -> Result<M::GradualDifficulty, ConvertError> {
333        M::gradual_difficulty(self, map)
334    }
335
336    /// Create a gradual performance calculator for a [`Beatmap`].
337    pub fn gradual_performance(self, map: &Beatmap) -> GradualPerformance {
338        GradualPerformance::new(self, map)
339    }
340
341    /// Create a gradual performance calculator for a [`Beatmap`] on a specific [`IGameMode`].
342    pub fn gradual_performance_for_mode<M: IGameMode>(
343        self,
344        map: &Beatmap,
345    ) -> Result<M::GradualPerformance, ConvertError> {
346        M::gradual_performance(self, map)
347    }
348
349    pub(crate) const fn get_mods(&self) -> &GameMods {
350        &self.mods
351    }
352
353    pub(crate) fn get_clock_rate(&self) -> f64 {
354        self.clock_rate
355            .map_or(self.mods.clock_rate(), non_zero_u64_to_f64)
356    }
357
358    pub(crate) fn get_passed_objects(&self) -> usize {
359        self.passed_objects.map_or(usize::MAX, |n| n as usize)
360    }
361
362    pub(crate) const fn get_ar(&self) -> Option<ModsDependent> {
363        self.ar
364    }
365
366    pub(crate) const fn get_cs(&self) -> Option<ModsDependent> {
367        self.cs
368    }
369
370    pub(crate) const fn get_hp(&self) -> Option<ModsDependent> {
371        self.hp
372    }
373
374    pub(crate) const fn get_od(&self) -> Option<ModsDependent> {
375        self.od
376    }
377
378    pub(crate) fn get_hardrock_offsets(&self) -> bool {
379        self.hardrock_offsets
380            .unwrap_or_else(|| self.mods.hardrock_offsets())
381    }
382
383    pub(crate) fn get_lazer(&self) -> bool {
384        self.lazer.unwrap_or(true)
385    }
386}
387
388const fn non_zero_u64_to_f64(n: NonZeroU64) -> f64 {
389    f64::from_bits(n.get())
390}
391
392impl Debug for Difficulty {
393    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
394        let Self {
395            mods,
396            passed_objects,
397            clock_rate,
398            ar,
399            cs,
400            hp,
401            od,
402            hardrock_offsets,
403            lazer,
404        } = self;
405
406        f.debug_struct("Difficulty")
407            .field("mods", mods)
408            .field("passed_objects", passed_objects)
409            .field("clock_rate", &clock_rate.map(non_zero_u64_to_f64))
410            .field("ar", ar)
411            .field("cs", cs)
412            .field("hp", hp)
413            .field("od", od)
414            .field("hardrock_offsets", hardrock_offsets)
415            .field("lazer", lazer)
416            .finish()
417    }
418}
419
420impl Default for Difficulty {
421    fn default() -> Self {
422        Self::new()
423    }
424}