Skip to main content

rosu_pp/catch/performance/
mod.rs

1use rosu_map::section::general::GameMode;
2
3use self::calculator::CatchPerformanceCalculator;
4
5use crate::{
6    Performance,
7    any::{
8        CalculateError, Difficulty, HitResultGenerator, InspectablePerformance,
9        IntoModePerformance, IntoPerformance, hitresult_generator::Fast,
10    },
11    catch::{CatchHitResults, performance::inspect::InspectCatchPerformance},
12    model::{mode::ConvertError, mods::GameMods},
13    osu::OsuPerformance,
14    util::map_or_attrs::MapOrAttrs,
15};
16
17use super::{Catch, attributes::CatchPerformanceAttributes, score_state::CatchScoreState};
18
19mod calculator;
20pub mod gradual;
21mod hitresult_generator;
22mod inspect;
23
24/// Performance calculator on osu!catch maps.
25#[derive(Clone, Debug)]
26#[must_use]
27pub struct CatchPerformance<'map> {
28    map_or_attrs: MapOrAttrs<'map, Catch>,
29    difficulty: Difficulty,
30    acc: Option<f64>,
31    combo: Option<u32>,
32    fruits: Option<u32>,
33    droplets: Option<u32>,
34    tiny_droplets: Option<u32>,
35    tiny_droplet_misses: Option<u32>,
36    misses: Option<u32>,
37    hitresult_generator: Option<fn(InspectCatchPerformance<'_>) -> CatchHitResults>,
38}
39
40// Manual implementation because of the `hitresult_generator` function pointer
41impl PartialEq for CatchPerformance<'_> {
42    fn eq(&self, other: &Self) -> bool {
43        let Self {
44            map_or_attrs,
45            difficulty,
46            acc,
47            combo,
48            fruits,
49            droplets,
50            tiny_droplets,
51            tiny_droplet_misses,
52            misses,
53            hitresult_generator: _,
54        } = self;
55
56        map_or_attrs == &other.map_or_attrs
57            && difficulty == &other.difficulty
58            && acc == &other.acc
59            && combo == &other.combo
60            && fruits == &other.fruits
61            && droplets == &other.droplets
62            && tiny_droplets == &other.tiny_droplets
63            && tiny_droplet_misses == &other.tiny_droplet_misses
64            && misses == &other.misses
65    }
66}
67
68impl<'map> CatchPerformance<'map> {
69    /// Create a new performance calculator for osu!catch maps.
70    ///
71    /// The argument `map_or_attrs` must be either
72    /// - previously calculated attributes ([`CatchDifficultyAttributes`]
73    ///   or [`CatchPerformanceAttributes`])
74    /// - a [`Beatmap`] (by reference or value)
75    ///
76    /// If a map is given, difficulty attributes will need to be calculated
77    /// internally which is a costly operation. Hence, passing attributes
78    /// should be prefered.
79    ///
80    /// However, when passing previously calculated attributes, make sure they
81    /// have been calculated for the same map and [`Difficulty`] settings.
82    /// Otherwise, the final attributes will be incorrect.
83    ///
84    /// [`Beatmap`]: crate::model::beatmap::Beatmap
85    /// [`CatchDifficultyAttributes`]: crate::catch::CatchDifficultyAttributes
86    pub fn new(map_or_attrs: impl IntoModePerformance<'map, Catch>) -> Self {
87        map_or_attrs.into_performance()
88    }
89
90    /// Try to create a new performance calculator for osu!catch maps.
91    ///
92    /// Returns `None` if `map_or_attrs` does not belong to osu!catch i.e.
93    /// a [`DifficultyAttributes`] or [`PerformanceAttributes`] of a different
94    /// mode.
95    ///
96    /// See [`CatchPerformance::new`] for more information.
97    ///
98    /// [`DifficultyAttributes`]: crate::any::DifficultyAttributes
99    /// [`PerformanceAttributes`]: crate::any::PerformanceAttributes
100    pub fn try_new(map_or_attrs: impl IntoPerformance<'map>) -> Option<Self> {
101        if let Performance::Catch(calc) = map_or_attrs.into_performance() {
102            Some(calc)
103        } else {
104            None
105        }
106    }
107
108    /// Specify mods.
109    ///
110    /// Accepted types are
111    /// - `u32`
112    /// - [`rosu_mods::GameModsLegacy`]
113    /// - [`rosu_mods::GameMods`]
114    /// - [`rosu_mods::GameModsIntermode`]
115    /// - [`&rosu_mods::GameModsIntermode`](rosu_mods::GameModsIntermode)
116    ///
117    /// See <https://github.com/ppy/osu-api/wiki#mods>
118    pub fn mods(mut self, mods: impl Into<GameMods>) -> Self {
119        self.difficulty = self.difficulty.mods(mods);
120
121        self
122    }
123
124    /// Specify the max combo of the play.
125    pub const fn combo(mut self, combo: u32) -> Self {
126        self.combo = Some(combo);
127
128        self
129    }
130
131    /// Specify the amount of fruits of a play i.e. n300.
132    pub const fn fruits(mut self, n_fruits: u32) -> Self {
133        self.fruits = Some(n_fruits);
134
135        self
136    }
137
138    /// Specify the amount of droplets of a play i.e. n100.
139    pub const fn droplets(mut self, n_droplets: u32) -> Self {
140        self.droplets = Some(n_droplets);
141
142        self
143    }
144
145    /// Specify the amount of tiny droplets of a play i.e. n50.
146    pub const fn tiny_droplets(mut self, n_tiny_droplets: u32) -> Self {
147        self.tiny_droplets = Some(n_tiny_droplets);
148
149        self
150    }
151
152    /// Specify the amount of tiny droplet misses of a play i.e. `n_katu`.
153    pub const fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: u32) -> Self {
154        self.tiny_droplet_misses = Some(n_tiny_droplet_misses);
155
156        self
157    }
158
159    /// Specify the amount of fruit / droplet misses of the play.
160    pub const fn misses(mut self, n_misses: u32) -> Self {
161        self.misses = Some(n_misses);
162
163        self
164    }
165
166    /// Use the specified settings of the given [`Difficulty`].
167    pub fn difficulty(mut self, difficulty: Difficulty) -> Self {
168        self.difficulty = difficulty;
169
170        self
171    }
172
173    /// Amount of passed objects for partial plays, e.g. a fail.
174    ///
175    /// If you want to calculate the performance after every few objects,
176    /// instead of using [`CatchPerformance`] multiple times with different
177    /// `passed_objects`, you should use [`CatchGradualPerformance`].
178    ///
179    /// [`CatchGradualPerformance`]: crate::catch::CatchGradualPerformance
180    pub fn passed_objects(mut self, passed_objects: u32) -> Self {
181        self.difficulty = self.difficulty.passed_objects(passed_objects);
182
183        self
184    }
185
186    /// Adjust the clock rate used in the calculation.
187    ///
188    /// If none is specified, it will take the clock rate based on the mods
189    /// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
190    ///
191    /// | Minimum | Maximum |
192    /// | :-----: | :-----: |
193    /// | 0.01    | 100     |
194    pub fn clock_rate(mut self, clock_rate: f64) -> Self {
195        self.difficulty = self.difficulty.clock_rate(clock_rate);
196
197        self
198    }
199
200    /// Override a beatmap's set AR.
201    ///
202    /// `fixed` determines if the given value should be used before
203    /// or after accounting for mods, e.g. on `true` the value will be
204    /// used as is and on `false` it will be modified based on the mods.
205    ///
206    /// | Minimum | Maximum |
207    /// | :-----: | :-----: |
208    /// | -20     | 20      |
209    pub fn ar(mut self, ar: f32, fixed: bool) -> Self {
210        self.difficulty = self.difficulty.ar(ar, fixed);
211
212        self
213    }
214
215    /// Override a beatmap's set CS.
216    ///
217    /// `fixed` determines if the given value should be used before
218    /// or after accounting for mods, e.g. on `true` the value will be
219    /// used as is and on `false` it will be modified based on the mods.
220    ///
221    /// | Minimum | Maximum |
222    /// | :-----: | :-----: |
223    /// | -20     | 20      |
224    pub fn cs(mut self, cs: f32, fixed: bool) -> Self {
225        self.difficulty = self.difficulty.cs(cs, fixed);
226
227        self
228    }
229
230    /// Override a beatmap's set HP.
231    ///
232    /// `fixed` 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 hp(mut self, hp: f32, fixed: bool) -> Self {
240        self.difficulty = self.difficulty.hp(hp, fixed);
241
242        self
243    }
244
245    /// Override a beatmap's set OD.
246    ///
247    /// `fixed` determines if the given value should be used before
248    /// or after accounting for mods, e.g. on `true` the value will be
249    /// used as is and on `false` it will be modified based on the mods.
250    ///
251    /// | Minimum | Maximum |
252    /// | :-----: | :-----: |
253    /// | -20     | 20      |
254    pub fn od(mut self, od: f32, fixed: bool) -> Self {
255        self.difficulty = self.difficulty.od(od, fixed);
256
257        self
258    }
259
260    /// Adjust patterns as if the HR mod is enabled.
261    pub fn hardrock_offsets(mut self, hardrock_offsets: bool) -> Self {
262        self.difficulty = self.difficulty.hardrock_offsets(hardrock_offsets);
263
264        self
265    }
266
267    /// Specify how hitresults should be generated.
268    pub fn hitresult_generator<H: HitResultGenerator<Catch>>(self) -> CatchPerformance<'map> {
269        CatchPerformance {
270            map_or_attrs: self.map_or_attrs,
271            difficulty: self.difficulty,
272            acc: self.acc,
273            combo: self.combo,
274            fruits: self.fruits,
275            droplets: self.droplets,
276            tiny_droplets: self.tiny_droplets,
277            tiny_droplet_misses: self.tiny_droplet_misses,
278            misses: self.misses,
279            hitresult_generator: Some(H::generate_hitresults),
280        }
281    }
282
283    /// Provide parameters through an [`CatchScoreState`].
284    #[expect(clippy::needless_pass_by_value, reason = "more sensible")]
285    pub const fn state(mut self, state: CatchScoreState) -> Self {
286        let CatchScoreState {
287            max_combo,
288            hitresults,
289        } = state;
290
291        self.combo = Some(max_combo);
292
293        self.hitresults(hitresults)
294    }
295
296    /// Provide parameters through [`CatchHitResults`].
297    pub const fn hitresults(mut self, hitresults: CatchHitResults) -> Self {
298        let CatchHitResults {
299            fruits: n_fruits,
300            droplets: n_droplets,
301            tiny_droplets: n_tiny_droplets,
302            tiny_droplet_misses: n_tiny_droplet_misses,
303            misses,
304        } = hitresults;
305
306        self.fruits = Some(n_fruits);
307        self.droplets = Some(n_droplets);
308        self.tiny_droplets = Some(n_tiny_droplets);
309        self.tiny_droplet_misses = Some(n_tiny_droplet_misses);
310        self.misses = Some(misses);
311
312        self
313    }
314
315    /// Specify the accuracy of a play between `0.0` and `100.0`.
316    /// This will be used to generate matching hitresults.
317    pub fn accuracy(mut self, acc: f64) -> Self {
318        self.acc = Some(acc.clamp(0.0, 100.0) / 100.0);
319
320        self
321    }
322
323    /// Create the [`CatchScoreState`] that will be used for performance
324    /// calculation.
325    ///
326    /// If this [`CatchPerformance`] contained a [`Beatmap`], it will be
327    /// replaced by [`CatchDifficultyAttributes`].
328    ///
329    /// [`Beatmap`]: crate::Beatmap
330    /// [`CatchDifficultyAttributes`]: crate::catch::CatchDifficultyAttributes
331    pub fn generate_state(&mut self) -> Result<CatchScoreState, ConvertError> {
332        self.map_or_attrs.insert_attrs(&self.difficulty)?;
333
334        // SAFETY: We just calculated and inserted the attributes.
335        let state = unsafe { generate_state(self) };
336
337        Ok(state)
338    }
339
340    /// Same as [`CatchPerformance::generate_state`] but verifies that the map
341    /// was not suspicious.
342    pub fn checked_generate_state(&mut self) -> Result<CatchScoreState, CalculateError> {
343        self.map_or_attrs.checked_insert_attrs(&self.difficulty)?;
344
345        // SAFETY: We just calculated and inserted the attributes.
346        let state = unsafe { generate_state(self) };
347
348        Ok(state)
349    }
350
351    /// Calculate all performance related values, including pp and stars.
352    pub fn calculate(mut self) -> Result<CatchPerformanceAttributes, ConvertError> {
353        let state = self.generate_state()?;
354
355        // SAFETY: Attributes are calculated in `generate_state`.
356        let attrs = unsafe { self.map_or_attrs.into_attrs() };
357
358        Ok(CatchPerformanceCalculator::new(attrs, self.difficulty.get_mods(), state).calculate())
359    }
360
361    /// Same as [`CatchPerformance::calculate`] but verifies that the map was
362    /// not suspicious.
363    pub fn checked_calculate(mut self) -> Result<CatchPerformanceAttributes, CalculateError> {
364        let state = self.checked_generate_state()?;
365
366        // SAFETY: Attributes are calculated in `checked_generate_state`.
367        let attrs = unsafe { self.map_or_attrs.into_attrs() };
368
369        Ok(CatchPerformanceCalculator::new(attrs, self.difficulty.get_mods(), state).calculate())
370    }
371
372    pub(crate) const fn from_map_or_attrs(map_or_attrs: MapOrAttrs<'map, Catch>) -> Self {
373        Self {
374            map_or_attrs,
375            difficulty: Difficulty::new(),
376            acc: None,
377            combo: None,
378            fruits: None,
379            droplets: None,
380            tiny_droplets: None,
381            tiny_droplet_misses: None,
382            misses: None,
383            hitresult_generator: None,
384        }
385    }
386}
387
388impl<'map> TryFrom<OsuPerformance<'map>> for CatchPerformance<'map> {
389    type Error = OsuPerformance<'map>;
390
391    /// Try to create [`CatchPerformance`] through [`OsuPerformance`].
392    ///
393    /// Returns `None` if [`OsuPerformance`] does not contain a beatmap, i.e.
394    /// if it was constructed through attributes or
395    /// [`OsuPerformance::generate_state`] was called.
396    fn try_from(mut osu: OsuPerformance<'map>) -> Result<Self, Self::Error> {
397        let mods = osu.difficulty.get_mods();
398
399        let map = match OsuPerformance::try_convert_map(osu.map_or_attrs, GameMode::Catch, mods) {
400            Ok(map) => map,
401            Err(map_or_attrs) => {
402                osu.map_or_attrs = map_or_attrs;
403
404                return Err(osu);
405            }
406        };
407
408        let OsuPerformance {
409            map_or_attrs: _,
410            difficulty,
411            acc,
412            combo,
413            large_tick_hits: _,
414            small_tick_hits: _,
415            slider_end_hits: _,
416            n300,
417            n100,
418            n50,
419            misses,
420            hitresult_priority: _,
421            hitresult_generator: _,
422            legacy_total_score: _,
423        } = osu;
424
425        Ok(Self {
426            map_or_attrs: MapOrAttrs::Map(map),
427            difficulty,
428            acc,
429            combo,
430            fruits: n300,
431            droplets: n100,
432            tiny_droplets: n50,
433            tiny_droplet_misses: None,
434            misses,
435            hitresult_generator: None,
436        })
437    }
438}
439
440impl<'map, T: IntoModePerformance<'map, Catch>> From<T> for CatchPerformance<'map> {
441    fn from(into: T) -> Self {
442        into.into_performance()
443    }
444}
445
446/// # Safety
447/// Caller must ensure that the internal [`MapOrAttrs`] contains attributes.
448unsafe fn generate_state(perf: &mut CatchPerformance) -> CatchScoreState {
449    // SAFETY: Ensured by caller
450    let attrs = unsafe { perf.map_or_attrs.get_attrs() };
451
452    let inspect = Catch::inspect_performance(perf, attrs);
453
454    let misses = inspect.misses();
455    let max_combo = perf.combo.unwrap_or_else(|| attrs.max_combo() - misses);
456
457    let hitresults = match perf.hitresult_generator {
458        Some(generator) => generator(inspect),
459        None => <Fast as HitResultGenerator<Catch>>::generate_hitresults(inspect),
460    };
461
462    let CatchHitResults {
463        fruits,
464        droplets,
465        tiny_droplets,
466        tiny_droplet_misses,
467        misses,
468    } = hitresults;
469
470    perf.combo = Some(max_combo);
471    perf.fruits = Some(fruits);
472    perf.droplets = Some(droplets);
473    perf.tiny_droplets = Some(tiny_droplets);
474    perf.tiny_droplet_misses = Some(tiny_droplet_misses);
475    perf.misses = Some(misses);
476
477    CatchScoreState {
478        max_combo,
479        hitresults,
480    }
481}
482
483#[cfg(test)]
484mod test {
485    use std::sync::OnceLock;
486
487    use rosu_map::section::general::GameMode;
488
489    use crate::{
490        Beatmap,
491        any::{DifficultyAttributes, PerformanceAttributes},
492        catch::CatchDifficultyAttributes,
493        osu::{OsuDifficultyAttributes, OsuPerformanceAttributes},
494    };
495
496    use super::*;
497
498    static ATTRS: OnceLock<CatchDifficultyAttributes> = OnceLock::new();
499
500    const N_FRUITS: u32 = 728;
501    const N_DROPLETS: u32 = 2;
502    const N_TINY_DROPLETS: u32 = 263;
503
504    fn beatmap() -> Beatmap {
505        Beatmap::from_path("./resources/2118524.osu").unwrap()
506    }
507
508    fn attrs() -> CatchDifficultyAttributes {
509        ATTRS
510            .get_or_init(|| {
511                let map = beatmap();
512                let attrs = Difficulty::new().calculate_for_mode::<Catch>(&map).unwrap();
513
514                assert_eq!(N_FRUITS, attrs.n_fruits);
515                assert_eq!(N_DROPLETS, attrs.n_droplets);
516                assert_eq!(N_TINY_DROPLETS, attrs.n_tiny_droplets);
517
518                attrs
519            })
520            .to_owned()
521    }
522
523    #[test]
524    fn fruits_missing_objects() {
525        let state = CatchPerformance::from(attrs())
526            .fruits(N_FRUITS - 10)
527            .droplets(N_DROPLETS - 1)
528            .tiny_droplets(N_TINY_DROPLETS - 50)
529            .tiny_droplet_misses(20)
530            .misses(2)
531            .generate_state()
532            .unwrap();
533
534        let expected = CatchScoreState {
535            max_combo: N_FRUITS + N_DROPLETS - 2,
536            hitresults: {
537                CatchHitResults {
538                    fruits: N_FRUITS - 2,
539                    droplets: N_DROPLETS,
540                    tiny_droplets: N_TINY_DROPLETS - 20,
541                    tiny_droplet_misses: 20,
542                    misses: 2,
543                }
544            },
545        };
546
547        assert_eq!(state, expected);
548    }
549
550    #[test]
551    fn create() {
552        let mut map = beatmap();
553
554        let _ = CatchPerformance::new(CatchDifficultyAttributes::default());
555        let _ = CatchPerformance::new(CatchPerformanceAttributes::default());
556        let _ = CatchPerformance::new(&map);
557        let _ = CatchPerformance::new(map.clone());
558
559        let _ = CatchPerformance::try_new(CatchDifficultyAttributes::default()).unwrap();
560        let _ = CatchPerformance::try_new(CatchPerformanceAttributes::default()).unwrap();
561        let _ = CatchPerformance::try_new(DifficultyAttributes::Catch(
562            CatchDifficultyAttributes::default(),
563        ))
564        .unwrap();
565        let _ = CatchPerformance::try_new(PerformanceAttributes::Catch(
566            CatchPerformanceAttributes::default(),
567        ))
568        .unwrap();
569        let _ = CatchPerformance::try_new(&map).unwrap();
570        let _ = CatchPerformance::try_new(map.clone()).unwrap();
571
572        let _ = CatchPerformance::from(CatchDifficultyAttributes::default());
573        let _ = CatchPerformance::from(CatchPerformanceAttributes::default());
574        let _ = CatchPerformance::from(&map);
575        let _ = CatchPerformance::from(map.clone());
576
577        let _ = CatchDifficultyAttributes::default().performance();
578        let _ = CatchPerformanceAttributes::default().performance();
579
580        assert!(
581            map.convert_mut(GameMode::Osu, &GameMods::default())
582                .is_err()
583        );
584
585        assert!(CatchPerformance::try_new(OsuDifficultyAttributes::default()).is_none());
586        assert!(CatchPerformance::try_new(OsuPerformanceAttributes::default()).is_none());
587        assert!(
588            CatchPerformance::try_new(
589                DifficultyAttributes::Osu(OsuDifficultyAttributes::default())
590            )
591            .is_none()
592        );
593        assert!(
594            CatchPerformance::try_new(PerformanceAttributes::Osu(
595                OsuPerformanceAttributes::default()
596            ))
597            .is_none()
598        );
599    }
600}