Skip to main content

rosu_pp/mania/performance/
mod.rs

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