Skip to main content

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