Skip to main content

rosu_pp/any/performance/
mod.rs

1use rosu_map::section::general::GameMode;
2
3use crate::{
4    Difficulty, GameMods,
5    any::{CalculateError, HitResultGenerator},
6    catch::{Catch, CatchPerformance},
7    mania::{Mania, ManiaPerformance},
8    model::beatmap::TooSuspicious,
9    osu::{Osu, OsuPerformance},
10    taiko::{Taiko, TaikoPerformance},
11};
12
13use self::into::IntoPerformance;
14
15use super::{attributes::PerformanceAttributes, score_state::ScoreState};
16
17pub mod gradual;
18pub mod inspectable;
19pub mod into;
20
21const NO_CONVERSION_REQUIRED: &str = "no conversion required";
22
23macro_rules! forward_to_variants {
24    ( $self:ident => |$perf:ident| $enum:ident($expr:expr) ) => {
25        match $self {
26            Self::Osu($perf) => $enum::Osu($expr),
27            Self::Taiko($perf) => $enum::Taiko($expr),
28            Self::Catch($perf) => $enum::Catch($expr),
29            Self::Mania($perf) => $enum::Mania($expr),
30        }
31    };
32}
33
34/// Performance calculator on maps of any mode.
35#[derive(Clone, Debug, PartialEq)]
36#[must_use]
37pub enum Performance<'map> {
38    Osu(OsuPerformance<'map>),
39    Taiko(TaikoPerformance<'map>),
40    Catch(CatchPerformance<'map>),
41    Mania(ManiaPerformance<'map>),
42}
43
44impl<'map> Performance<'map> {
45    /// Create a new performance calculator for any mode.
46    ///
47    /// The argument `map_or_attrs` must be either
48    /// - previously calculated attributes ([`DifficultyAttributes`],
49    ///   [`PerformanceAttributes`], or mode-specific attributes like
50    ///   [`TaikoDifficultyAttributes`], [`ManiaPerformanceAttributes`], ...)
51    /// - a [`Beatmap`] (by reference or value)
52    ///
53    /// If a map is given, difficulty attributes will need to be calculated
54    /// internally which is a costly operation. Hence, passing attributes
55    /// should be prefered.
56    ///
57    /// However, when passing previously calculated attributes, make sure they
58    /// have been calculated for the same map and [`Difficulty`] settings.
59    /// Otherwise, the final attributes will be incorrect.
60    ///
61    /// [`Beatmap`]: crate::model::beatmap::Beatmap
62    /// [`DifficultyAttributes`]: crate::any::DifficultyAttributes
63    /// [`TaikoDifficultyAttributes`]: crate::taiko::TaikoDifficultyAttributes
64    /// [`ManiaPerformanceAttributes`]: crate::mania::ManiaPerformanceAttributes
65    pub fn new(map_or_attrs: impl IntoPerformance<'map>) -> Self {
66        map_or_attrs.into_performance()
67    }
68
69    /// Consume the performance calculator and calculate
70    /// performance attributes for the given parameters.
71    #[expect(clippy::missing_panics_doc, reason = "unreachable")]
72    pub fn calculate(self) -> PerformanceAttributes {
73        forward_to_variants!(self => |perf| PerformanceAttributes(
74            perf.calculate().expect(NO_CONVERSION_REQUIRED)
75        ))
76    }
77
78    /// Same as [`Performance::calculate`] but verifies that the map is not too
79    /// suspicious.
80    pub fn checked_calculate(self) -> Result<PerformanceAttributes, TooSuspicious> {
81        let map_err = |err| match err {
82            CalculateError::Suspicion(err) => err,
83            CalculateError::Convert(_) => unreachable!("{}", NO_CONVERSION_REQUIRED),
84        };
85
86        let this = match self {
87            Self::Osu(o) => PerformanceAttributes::Osu(o.checked_calculate().map_err(map_err)?),
88            Self::Taiko(t) => PerformanceAttributes::Taiko(t.checked_calculate().map_err(map_err)?),
89            Self::Catch(f) => PerformanceAttributes::Catch(f.checked_calculate().map_err(map_err)?),
90            Self::Mania(m) => PerformanceAttributes::Mania(m.checked_calculate().map_err(map_err)?),
91        };
92
93        Ok(this)
94    }
95
96    /// Attempt to convert the map to the specified mode.
97    ///
98    /// Returns `Err(self)` if the conversion is incompatible or no beatmap is
99    /// contained, i.e. if this [`Performance`] was created through attributes
100    /// or [`Performance::generate_state`] was called.
101    ///
102    /// If the given mode should be ignored in case of an error, use
103    /// [`mode_or_ignore`] instead.
104    ///
105    /// [`mode_or_ignore`]: Self::mode_or_ignore
106    #[expect(clippy::result_large_err, reason = "both variants have the same size")]
107    pub fn try_mode(self, mode: GameMode) -> Result<Self, Self> {
108        match (self, mode) {
109            (Self::Osu(o), _) => o.try_mode(mode).map_err(Self::Osu),
110            (this @ Self::Taiko(_), GameMode::Taiko)
111            | (this @ Self::Catch(_), GameMode::Catch)
112            | (this @ Self::Mania(_), GameMode::Mania) => Ok(this),
113            (this, _) => Err(this),
114        }
115    }
116
117    /// Attempt to convert the map to the specified mode.
118    ///
119    /// If the conversion is incompatible or if the internal beatmap was
120    /// already replaced with difficulty attributes, the map won't be modified.
121    ///
122    /// To see whether the given mode is incompatible or the internal beatmap
123    /// was replaced, use [`try_mode`] instead.
124    ///
125    /// [`try_mode`]: Self::try_mode
126    pub fn mode_or_ignore(self, mode: GameMode) -> Self {
127        if let Self::Osu(osu) = self {
128            osu.mode_or_ignore(mode)
129        } else {
130            self
131        }
132    }
133
134    /// Specify mods.
135    ///
136    /// Accepted types are
137    /// - `u32`
138    /// - [`rosu_mods::GameModsLegacy`]
139    /// - [`rosu_mods::GameMods`]
140    /// - [`rosu_mods::GameModsIntermode`]
141    /// - [`&rosu_mods::GameModsIntermode`](rosu_mods::GameModsIntermode)
142    ///
143    /// See <https://github.com/ppy/osu-api/wiki#mods>
144    pub fn mods(self, mods: impl Into<GameMods>) -> Self {
145        forward_to_variants!(self => |perf| Self(perf.mods(mods)))
146    }
147
148    /// Use the specified settings of the given [`Difficulty`].
149    pub fn difficulty(self, difficulty: Difficulty) -> Self {
150        forward_to_variants!(self => |perf| Self(perf.difficulty(difficulty)))
151    }
152
153    /// Amount of passed objects for partial plays, e.g. a fail.
154    ///
155    /// If you want to calculate the performance after every few objects,
156    /// instead of using [`Performance`] multiple times with different
157    /// `passed_objects`, you should use [`GradualPerformance`].
158    ///
159    /// [`GradualPerformance`]: crate::GradualPerformance
160    pub fn passed_objects(self, passed_objects: u32) -> Self {
161        forward_to_variants!(self => |perf| Self(perf.passed_objects(passed_objects)))
162    }
163
164    /// Adjust the clock rate used in the calculation.
165    ///
166    /// If none is specified, it will take the clock rate based on the mods
167    /// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
168    ///
169    /// | Minimum | Maximum |
170    /// | :-----: | :-----: |
171    /// | 0.01    | 100     |
172    pub fn clock_rate(self, clock_rate: f64) -> Self {
173        forward_to_variants!(self => |perf| Self(perf.clock_rate(clock_rate)))
174    }
175
176    /// Override a beatmap's set AR.
177    ///
178    /// Only relevant for osu! and osu!catch.
179    ///
180    /// `fixed` 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 ar(self, ar: f32, fixed: bool) -> Self {
188        match self {
189            Self::Osu(o) => Self::Osu(o.ar(ar, fixed)),
190            Self::Catch(c) => Self::Catch(c.ar(ar, fixed)),
191            Self::Taiko(_) | Self::Mania(_) => self,
192        }
193    }
194
195    /// Override a beatmap's set CS.
196    ///
197    /// Only relevant for osu! and osu!catch.
198    ///
199    /// `fixed` determines if the given value should be used before
200    /// or after accounting for mods, e.g. on `true` the value will be
201    /// used as is and on `false` it will be modified based on the mods.
202    ///
203    /// | Minimum | Maximum |
204    /// | :-----: | :-----: |
205    /// | -20     | 20      |
206    pub fn cs(self, cs: f32, fixed: bool) -> Self {
207        match self {
208            Self::Osu(o) => Self::Osu(o.cs(cs, fixed)),
209            Self::Catch(c) => Self::Catch(c.cs(cs, fixed)),
210            Self::Taiko(_) | Self::Mania(_) => self,
211        }
212    }
213
214    /// Override a beatmap's set HP.
215    ///
216    /// `fixed` determines if the given value should be used before
217    /// or after accounting for mods, e.g. on `true` the value will be
218    /// used as is and on `false` it will be modified based on the mods.
219    ///
220    /// | Minimum | Maximum |
221    /// | :-----: | :-----: |
222    /// | -20     | 20      |
223    pub fn hp(self, hp: f32, fixed: bool) -> Self {
224        forward_to_variants!(self => |perf| Self(perf.hp(hp, fixed)))
225    }
226
227    /// Override a beatmap's set OD.
228    ///
229    /// `fixed` determines if the given value should be used before
230    /// or after accounting for mods, e.g. on `true` the value will be
231    /// used as is and on `false` it will be modified based on the mods.
232    ///
233    /// | Minimum | Maximum |
234    /// | :-----: | :-----: |
235    /// | -20     | 20      |
236    pub fn od(self, od: f32, fixed: bool) -> Self {
237        forward_to_variants!(self => |perf| Self(perf.od(od, fixed)))
238    }
239
240    /// Adjust patterns as if the HR mod is enabled.
241    ///
242    /// Only relevant for osu!catch.
243    pub fn hardrock_offsets(self, hardrock_offsets: bool) -> Self {
244        if let Self::Catch(catch) = self {
245            Self::Catch(catch.hardrock_offsets(hardrock_offsets))
246        } else {
247            self
248        }
249    }
250
251    /// Provide parameters through a [`ScoreState`].
252    pub fn state(self, state: ScoreState) -> Self {
253        forward_to_variants!(self => |perf| Self(perf.state(state.into())))
254    }
255
256    /// Set the accuracy between `0.0` and `100.0`.
257    pub fn accuracy(self, acc: f64) -> Self {
258        forward_to_variants!(self => |perf| Self(perf.accuracy(acc)))
259    }
260
261    /// Specify the amount of misses of a play.
262    pub fn misses(self, n_misses: u32) -> Self {
263        forward_to_variants!(self => |perf| Self(perf.misses(n_misses)))
264    }
265
266    /// Specify the max combo of the play.
267    ///
268    /// Irrelevant for osu!mania.
269    pub fn combo(self, combo: u32) -> Self {
270        match self {
271            Self::Osu(o) => Self::Osu(o.combo(combo)),
272            Self::Taiko(t) => Self::Taiko(t.combo(combo)),
273            Self::Catch(f) => Self::Catch(f.combo(combo)),
274            Self::Mania(_) => self,
275        }
276    }
277
278    /// Specify the priority of hitresults.
279    pub fn hitresult_priority(self, priority: HitResultPriority) -> Self {
280        match self {
281            Self::Osu(o) => Self::Osu(o.hitresult_priority(priority)),
282            Self::Taiko(t) => Self::Taiko(t.hitresult_priority(priority)),
283            Self::Catch(_) => self,
284            Self::Mania(m) => Self::Mania(m.hitresult_priority(priority)),
285        }
286    }
287
288    /// Specify how hitresults should be generated.
289    ///
290    /// # Example
291    /// ```rust
292    /// use rosu_pp::any::hitresult_generator::{Closest, Composable, Fast};
293    /// # use rosu_pp::Performance;
294    ///
295    /// # let map = rosu_pp::catch::CatchDifficultyAttributes::default();
296    /// let attrs = Performance::new(map)
297    ///     // Use `Closest` for osu!, taiko, and catch, and `Fast` for mania
298    ///     .hitresult_generator::<Composable<Closest, Closest, Closest, Fast>>()
299    ///     .calculate();
300    /// ```
301    pub fn hitresult_generator<H>(self) -> Self
302    where
303        H: HitResultGenerator<Osu>
304            + HitResultGenerator<Taiko>
305            + HitResultGenerator<Catch>
306            + HitResultGenerator<Mania>,
307    {
308        forward_to_variants!(self => |perf| Self(perf.hitresult_generator::<H>()))
309    }
310
311    /// Whether the calculated attributes belong to an osu!lazer or osu!stable
312    /// score.
313    ///
314    /// Defaults to `true`.
315    ///
316    /// This affects internal accuracy calculation because lazer considers
317    /// slider heads for accuracy whereas stable does not.
318    ///
319    /// Only relevant for osu!standard and osu!mania.
320    pub fn lazer(self, lazer: bool) -> Self {
321        match self {
322            Self::Osu(o) => Self::Osu(o.lazer(lazer)),
323            Self::Taiko(_) | Self::Catch(_) => self,
324            Self::Mania(m) => Self::Mania(m.lazer(lazer)),
325        }
326    }
327
328    /// Specify the amount of "large tick" hits.
329    ///
330    /// Only relevant for osu!standard.
331    ///
332    /// The meaning depends on the kind of score:
333    /// - if set on osu!stable, this value is irrelevant and can be `0`
334    /// - if set on osu!lazer *with* slider accuracy, this value is the amount
335    ///   of hit slider ticks and repeats
336    /// - if set on osu!lazer *without* slider accuracy, this value is the
337    ///   amount of hit slider heads, ticks, and repeats
338    pub fn large_tick_hits(self, large_tick_hits: u32) -> Self {
339        if let Self::Osu(osu) = self {
340            Self::Osu(osu.large_tick_hits(large_tick_hits))
341        } else {
342            self
343        }
344    }
345
346    /// Specify the amount of "small tick" hits.
347    ///
348    /// Only relevant for osu!standard lazer scores without slider accuracy. In
349    /// that case, this value is the amount of slider tail hits.
350    pub fn small_tick_hits(self, small_tick_hits: u32) -> Self {
351        if let Self::Osu(osu) = self {
352            Self::Osu(osu.small_tick_hits(small_tick_hits))
353        } else {
354            self
355        }
356    }
357
358    /// Specify the amount of hit slider ends.
359    ///
360    /// Only relevant for osu!standard lazer scores with slider accuracy.
361    pub fn slider_end_hits(self, slider_end_hits: u32) -> Self {
362        if let Self::Osu(osu) = self {
363            Self::Osu(osu.slider_end_hits(slider_end_hits))
364        } else {
365            self
366        }
367    }
368
369    /// Specify the amount of 300s of a play.
370    pub fn n300(self, n300: u32) -> Self {
371        match self {
372            Self::Osu(o) => Self::Osu(o.n300(n300)),
373            Self::Taiko(t) => Self::Taiko(t.n300(n300)),
374            Self::Catch(f) => Self::Catch(f.fruits(n300)),
375            Self::Mania(m) => Self::Mania(m.n300(n300)),
376        }
377    }
378
379    /// Specify the amount of 100s of a play.
380    pub fn n100(self, n100: u32) -> Self {
381        match self {
382            Self::Osu(o) => Self::Osu(o.n100(n100)),
383            Self::Taiko(t) => Self::Taiko(t.n100(n100)),
384            Self::Catch(f) => Self::Catch(f.droplets(n100)),
385            Self::Mania(m) => Self::Mania(m.n100(n100)),
386        }
387    }
388
389    /// Specify the amount of 50s of a play.
390    ///
391    /// Irrelevant for osu!taiko.
392    pub fn n50(self, n50: u32) -> Self {
393        match self {
394            Self::Osu(o) => Self::Osu(o.n50(n50)),
395            Self::Taiko(_) => self,
396            Self::Catch(f) => Self::Catch(f.tiny_droplets(n50)),
397            Self::Mania(m) => Self::Mania(m.n50(n50)),
398        }
399    }
400
401    /// Specify the amount of katus of a play.
402    ///
403    /// Only relevant for osu!catch for which it represents the amount of tiny
404    /// droplet misses and osu!mania for which it repesents the amount of n200.
405    pub fn n_katu(self, n_katu: u32) -> Self {
406        match self {
407            Self::Osu(_) | Self::Taiko(_) => self,
408            Self::Catch(f) => Self::Catch(f.tiny_droplet_misses(n_katu)),
409            Self::Mania(m) => Self::Mania(m.n200(n_katu)),
410        }
411    }
412
413    /// Specify the amount of gekis of a play.
414    ///
415    /// Only relevant for osu!mania for which it repesents the
416    /// amount of n320.
417    pub fn n_geki(self, n_geki: u32) -> Self {
418        match self {
419            Self::Osu(_) | Self::Taiko(_) | Self::Catch(_) => self,
420            Self::Mania(m) => Self::Mania(m.n320(n_geki)),
421        }
422    }
423
424    /// Specify the legacy total score.
425    ///
426    /// Only relevant for osu!standard.
427    pub fn legacy_total_score(self, legacy_total_score: u32) -> Self {
428        match self {
429            Self::Osu(o) => Self::Osu(o.legacy_total_score(legacy_total_score)),
430            _ => self,
431        }
432    }
433
434    /// Create the [`ScoreState`] that will be used for performance calculation.
435    ///
436    /// If this [`Performance`] contained a [`Beatmap`], it will be replaced
437    /// by the difficulty attributes of the mode.
438    ///
439    /// [`Beatmap`]: crate::Beatmap
440    #[expect(clippy::missing_panics_doc, reason = "unreachable")]
441    pub fn generate_state(&mut self) -> ScoreState {
442        match self {
443            Self::Osu(o) => o.generate_state().expect(NO_CONVERSION_REQUIRED).into(),
444            Self::Taiko(t) => t.generate_state().expect(NO_CONVERSION_REQUIRED).into(),
445            Self::Catch(f) => f.generate_state().expect(NO_CONVERSION_REQUIRED).into(),
446            Self::Mania(m) => m.generate_state().expect(NO_CONVERSION_REQUIRED).into(),
447        }
448    }
449
450    /// Same as [`Performance::generate_state`] but verifies that the map was
451    /// not suspicious.
452    pub fn checked_generate_state(&mut self) -> Result<ScoreState, TooSuspicious> {
453        let map_err = |err| match err {
454            CalculateError::Suspicion(err) => err,
455            CalculateError::Convert(_) => unreachable!("{}", NO_CONVERSION_REQUIRED),
456        };
457
458        match self {
459            Self::Osu(o) => o.checked_generate_state().map(From::from).map_err(map_err),
460            Self::Taiko(t) => t.checked_generate_state().map(From::from).map_err(map_err),
461            Self::Catch(f) => f.checked_generate_state().map(From::from).map_err(map_err),
462            Self::Mania(m) => m.checked_generate_state().map(From::from).map_err(map_err),
463        }
464    }
465}
466
467/// While generating remaining hitresults, decide how they should be distributed.
468#[derive(Copy, Clone, Debug, Eq, PartialEq)]
469pub enum HitResultPriority {
470    /// Prioritize good hitresults over bad ones
471    BestCase,
472    /// Prioritize bad hitresults over good ones
473    WorstCase,
474}
475
476impl HitResultPriority {
477    pub(crate) const DEFAULT: Self = Self::BestCase;
478}
479
480impl Default for HitResultPriority {
481    fn default() -> Self {
482        Self::DEFAULT
483    }
484}
485
486impl<'a, T: IntoPerformance<'a>> From<T> for Performance<'a> {
487    fn from(into: T) -> Self {
488        into.into_performance()
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use crate::{
495        Beatmap,
496        any::DifficultyAttributes,
497        catch::{CatchDifficultyAttributes, CatchPerformanceAttributes},
498        mania::{ManiaDifficultyAttributes, ManiaPerformanceAttributes},
499        osu::{OsuDifficultyAttributes, OsuPerformanceAttributes},
500        taiko::{TaikoDifficultyAttributes, TaikoPerformanceAttributes},
501    };
502
503    use super::*;
504
505    #[test]
506    fn create() {
507        let map = Beatmap::from_path("./resources/1028484.osu").unwrap();
508
509        let _ = Performance::new(&map);
510        let _ = Performance::new(map.clone());
511
512        let _ = Performance::new(OsuDifficultyAttributes::default());
513        let _ = Performance::new(TaikoDifficultyAttributes::default());
514        let _ = Performance::new(CatchDifficultyAttributes::default());
515        let _ = Performance::new(ManiaDifficultyAttributes::default());
516
517        let _ = Performance::new(OsuPerformanceAttributes::default());
518        let _ = Performance::new(TaikoPerformanceAttributes::default());
519        let _ = Performance::new(CatchPerformanceAttributes::default());
520        let _ = Performance::new(ManiaPerformanceAttributes::default());
521
522        let _ = Performance::new(DifficultyAttributes::Osu(OsuDifficultyAttributes::default()));
523        let _ = Performance::new(PerformanceAttributes::Taiko(
524            TaikoPerformanceAttributes::default(),
525        ));
526
527        let _ = Performance::from(&map);
528        let _ = Performance::from(map);
529
530        let _ = Performance::from(OsuDifficultyAttributes::default());
531        let _ = Performance::from(TaikoDifficultyAttributes::default());
532        let _ = Performance::from(CatchDifficultyAttributes::default());
533        let _ = Performance::from(ManiaDifficultyAttributes::default());
534
535        let _ = Performance::from(OsuPerformanceAttributes::default());
536        let _ = Performance::from(TaikoPerformanceAttributes::default());
537        let _ = Performance::from(CatchPerformanceAttributes::default());
538        let _ = Performance::from(ManiaPerformanceAttributes::default());
539
540        let _ = Performance::from(DifficultyAttributes::Osu(OsuDifficultyAttributes::default()));
541        let _ = Performance::from(PerformanceAttributes::Taiko(
542            TaikoPerformanceAttributes::default(),
543        ));
544
545        let _ = DifficultyAttributes::Osu(OsuDifficultyAttributes::default()).performance();
546        let _ = PerformanceAttributes::Taiko(TaikoPerformanceAttributes::default()).performance();
547    }
548}