peace_performance/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg), deny(broken_intra_doc_links))]
2
3//! A standalone crate to calculate star ratings and performance points for all [osu!](https://osu.ppy.sh/home) gamemodes.
4//!
5//! Conversions between gamemodes are generally not supported.
6//!
7//! Async is supported through features, see below.
8//!
9//! ## Usage
10//!
11//! ```no_run
12//! use std::fs::File;
13//! use peace_performance::{Beatmap, BeatmapExt};
14//!
15//! # /*
16//! let file = match File::open("/path/to/file.osu") {
17//!     Ok(file) => file,
18//!     Err(why) => panic!("Could not open file: {}", why),
19//! };
20//!
21//! // Parse the map yourself
22//! let map = match Beatmap::parse(file) {
23//!     Ok(map) => map,
24//!     Err(why) => panic!("Error while parsing map: {}", why),
25//! };
26//! # */ let map = Beatmap::default();
27//!
28//! // If `BeatmapExt` is included, you can make use of
29//! // some methods on `Beatmap` to make your life simpler.
30//! // If the mode is known, it is recommended to use the
31//! // mode's pp calculator, e.g. `TaikoPP`, manually.
32//! let result = map.pp()
33//!     .mods(24) // HDHR
34//!     .combo(1234)
35//!     .misses(2)
36//!     .accuracy(99.2)
37//!     .calculate();
38//!
39//! println!("PP: {}", result.pp());
40//!
41//! // If you intend to reuse the current map-mod combination,
42//! // make use of the previous result!
43//! // If attributes are given, then stars & co don't have to be recalculated.
44//! let next_result = map.pp()
45//!     .mods(24) // HDHR
46//!     .attributes(result) // recycle
47//!     .combo(543)
48//!     .misses(5)
49//!     .n50(3)
50//!     .passed_objects(600)
51//!     .accuracy(96.5)
52//!     .calculate();
53//!
54//! println!("Next PP: {}", next_result.pp());
55//!
56//! let stars = map.stars(16, None).stars(); // HR
57//! let max_pp = map.max_pp(16).pp();
58//!
59//! println!("Stars: {} | Max PP: {}", stars, max_pp);
60//! ```
61//!
62//! ## With async
63//! If either the `async_tokio` or `async_std` feature is enabled, beatmap parsing will be async.
64//!
65//! ```no_run
66//! use peace_performance::{Beatmap, BeatmapExt};
67//! # /*
68//! use async_std::fs::File;
69//! # */
70//! // use tokio::fs::File;
71//!
72//! # /*
73//! let file = match File::open("/path/to/file.osu").await {
74//!     Ok(file) => file,
75//!     Err(why) => panic!("Could not open file: {}", why),
76//! };
77//!
78//! // Parse the map asynchronously
79//! let map = match Beatmap::parse(file).await {
80//!     Ok(map) => map,
81//!     Err(why) => panic!("Error while parsing map: {}", why),
82//! };
83//! # */ let map = Beatmap::default();
84//!
85//! // The rest stays the same
86//! let result = map.pp()
87//!     .mods(24) // HDHR
88//!     .combo(1234)
89//!     .misses(2)
90//!     .accuracy(99.2)
91//!     .calculate();
92//!
93//! println!("PP: {}", result.pp());
94//! ```
95//!
96//! ## osu!standard versions
97//!
98//! - `all_included`: Both stack leniency & slider paths are considered so that the difficulty and pp calculation immitates osu! as close as possible. Pro: Most precise; Con: Least performant.
99//! - `no_leniency`: The positional offset of notes created by stack leniency is not considered. This means the jump distance inbetween notes might be slightly off, resulting in small inaccuracies. Since calculating these offsets is relatively expensive though, this version is considerably faster than `all_included`.
100//! - `no_slider_no_leniency` (i.e. [oppai](https://github.com/Francesco149/oppai-ng)): In addition to not considering the positional offset caused by stack leniency, slider paths are also ignored. This means the travel distance of notes is completely omitted which may cause further inaccuracies. Since the slider paths don't have to be computed though, it is generally faster than `no_leniency`.
101//!
102//! **Note**: If the `fruits` feature is enabled, sliders will be parsed regardless, resulting in a reduced performance advantage of `no_sliders_no_leniency`.
103//!
104//! ## Features
105//!
106//! | Flag | Description |
107//! |-----|-----|
108//! | `default` | Enable all modes and choose the `no_leniency` version for osu!standard. |
109//! | `taiko` | Enable osu!taiko. |
110//! | `fruits` | Enable osu!ctb. |
111//! | `mania` | Enable osu!mania. |
112//! | `osu` | Enable osu!standard. Requires to also enable exactly one of the features `no_leniency`, `no_sliders_no_leniency`, or `all_included`. |
113//! | `no_leniency` | When calculating difficulty attributes in osu!standard, ignore stack leniency but consider sliders. Solid middleground between performance and precision, hence the default version. |
114//! | `no_sliders_no_leniency` | When calculating difficulty attributes in osu!standard, ignore stack leniency and sliders. Best performance but slightly less precision than `no_leniency`. |
115//! | `all_included` | When calculating difficulty attributes in osu!standard, consider both stack leniency and sliders. Best precision but significantly worse performance than `no_leniency`. |
116//! | `async_tokio` | Beatmap parsing will be async through [tokio](https://github.com/tokio-rs/tokio) |
117//! | `async_std` | Beatmap parsing will be async through [async-std](https://github.com/async-rs/async-std) |
118//!
119//! ## Roadmap
120//!
121//! - \[x\] osu sr versions
122//!   - \[x\] all included
123//!   - \[x\] no_leniency
124//!   - \[x\] no_sliders_no_leniency
125//! - \[x\] taiko sr
126//! - \[x\] ctb sr
127//! - \[x\] mania sr
128//! ---
129//! - \[x\] osu pp
130//! - \[x\] taiko pp
131//! - \[x\] ctb pp
132//! - \[x\] mania pp
133//! ---
134//! - \[x\] refactoring
135//! - \[x\] benchmarking
136//! - \[x\] async parsing
137
138#[cfg(feature = "fruits")]
139#[cfg_attr(docsrs, doc(cfg(feature = "fruits")))]
140pub mod fruits;
141
142#[cfg(feature = "mania")]
143#[cfg_attr(docsrs, doc(cfg(feature = "mania")))]
144pub mod mania;
145
146#[cfg(feature = "osu")]
147#[cfg_attr(docsrs, doc(cfg(feature = "osu")))]
148pub mod osu;
149
150#[cfg(feature = "taiko")]
151#[cfg_attr(docsrs, doc(cfg(feature = "taiko")))]
152pub mod taiko;
153
154pub mod parse;
155
156mod pp;
157pub use pp::{AnyPP, AttributeProvider};
158
159mod curve;
160mod math_util;
161mod mods;
162
163#[cfg(any(feature = "osu", feature = "fruits"))]
164pub(crate) mod control_point_iter;
165
166#[cfg(any(feature = "osu", feature = "fruits"))]
167pub(crate) use control_point_iter::{ControlPoint, ControlPointIter};
168
169#[cfg(feature = "fruits")]
170pub use fruits::FruitsPP;
171
172#[cfg(feature = "mania")]
173pub use mania::ManiaPP;
174
175#[cfg(feature = "osu")]
176pub use osu::OsuPP;
177
178#[cfg(feature = "taiko")]
179pub use taiko::TaikoPP;
180
181pub use mods::Mods;
182pub use parse::{Beatmap, BeatmapAttributes, GameMode, ParseError, ParseResult};
183
184pub trait BeatmapExt {
185    /// Calculate the stars and other attributes of a beatmap which are required for pp calculation.
186    fn stars(&self, mods: impl Mods, passed_objects: Option<usize>) -> StarResult;
187
188    /// Calculate the max pp of a beatmap.
189    ///
190    /// If you seek more fine-tuning and options you need to match on the map's
191    /// mode and use the mode's corresponding calculator, e.g. [`TaikoPP`](crate::TaikoPP) for taiko.
192    fn max_pp(&self, mods: u32) -> PpResult;
193
194    /// Returns a builder to calculate pp and difficulty values.
195    ///
196    /// Convenient method that matches on the map's mode to choose the appropriate calculator.
197    fn pp(&self) -> AnyPP;
198
199    /// Calculate the strains of a map.
200    /// This essentially performs the same calculation as a `stars` function but
201    /// instead of evaluating the final strains, they are just returned as is.
202    ///
203    /// Suitable to plot the difficulty of a map over time.
204    fn strains(&self, mods: impl Mods) -> Strains;
205}
206
207impl BeatmapExt for Beatmap {
208    fn stars(&self, mods: impl Mods, passed_objects: Option<usize>) -> StarResult {
209        match self.mode {
210            GameMode::STD => {
211                #[cfg(not(feature = "osu"))]
212                panic!("`osu` feature is not enabled");
213
214                #[cfg(feature = "osu")]
215                {
216                    #[cfg(feature = "no_leniency")]
217                    {
218                        osu::no_leniency::stars(self, mods, passed_objects)
219                    }
220
221                    #[cfg(all(not(feature = "no_leniency"), feature = "no_sliders_no_leniency"))]
222                    {
223                        osu::no_sliders_no_leniency::stars(self, mods, passed_objects)
224                    }
225
226                    #[cfg(all(
227                        not(feature = "no_leniency"),
228                        not(feature = "no_sliders_no_leniency"),
229                        feature = "all_included"
230                    ))]
231                    {
232                        osu::all_included::stars(self, mods, passed_objects)
233                    }
234
235                    #[cfg(not(any(
236                        feature = "no_leniency",
237                        feature = "no_sliders_no_leniency",
238                        feature = "all_included"
239                    )))]
240                    panic!("either of the features `no_leniency`, `no_sliders_no_leniency`, or `all_included` must be enabled");
241                }
242            }
243            GameMode::MNA => {
244                #[cfg(not(feature = "mania"))]
245                panic!("`mania` feature is not enabled");
246
247                #[cfg(feature = "mania")]
248                mania::stars(self, mods, passed_objects)
249            }
250            GameMode::TKO => {
251                #[cfg(not(feature = "taiko"))]
252                panic!("`osu` feature is not enabled");
253
254                #[cfg(feature = "taiko")]
255                taiko::stars(self, mods, passed_objects)
256            }
257            GameMode::CTB => {
258                #[cfg(not(feature = "fruits"))]
259                panic!("`fruits` feature is not enabled");
260
261                #[cfg(feature = "fruits")]
262                fruits::stars(self, mods, passed_objects)
263            }
264        }
265    }
266
267    fn max_pp(&self, mods: u32) -> PpResult {
268        match self.mode {
269            GameMode::STD => {
270                #[cfg(not(feature = "osu"))]
271                panic!("`osu` feature is not enabled");
272
273                #[cfg(feature = "osu")]
274                OsuPP::new(self).mods(mods).calculate()
275            }
276            GameMode::MNA => {
277                #[cfg(not(feature = "mania"))]
278                panic!("`mania` feature is not enabled");
279
280                #[cfg(feature = "mania")]
281                ManiaPP::new(self).mods(mods).calculate()
282            }
283            GameMode::TKO => {
284                #[cfg(not(feature = "taiko"))]
285                panic!("`osu` feature is not enabled");
286
287                #[cfg(feature = "taiko")]
288                TaikoPP::new(self).mods(mods).calculate()
289            }
290            GameMode::CTB => {
291                #[cfg(not(feature = "fruits"))]
292                panic!("`fruits` feature is not enabled");
293
294                #[cfg(feature = "fruits")]
295                FruitsPP::new(self).mods(mods).calculate()
296            }
297        }
298    }
299
300    #[inline]
301    fn pp(&self) -> AnyPP {
302        AnyPP::new(self)
303    }
304
305    fn strains(&self, mods: impl Mods) -> Strains {
306        match self.mode {
307            GameMode::STD => {
308                #[cfg(not(feature = "osu"))]
309                panic!("`osu` feature is not enabled");
310
311                #[cfg(feature = "osu")]
312                {
313                    #[cfg(feature = "no_leniency")]
314                    {
315                        osu::no_leniency::strains(self, mods)
316                    }
317
318                    #[cfg(all(not(feature = "no_leniency"), feature = "no_sliders_no_leniency"))]
319                    {
320                        osu::no_sliders_no_leniency::strains(self, mods)
321                    }
322
323                    #[cfg(all(
324                        not(feature = "no_leniency"),
325                        not(feature = "no_sliders_no_leniency"),
326                        feature = "all_included"
327                    ))]
328                    {
329                        osu::all_included::strains(self, mods)
330                    }
331
332                    #[cfg(not(any(
333                        feature = "no_leniency",
334                        feature = "no_sliders_no_leniency",
335                        feature = "all_included"
336                    )))]
337                    panic!("either of the features `no_leniency`, `no_sliders_no_leniency`, or `all_included` must be enabled");
338                }
339            }
340            GameMode::MNA => {
341                #[cfg(not(feature = "mania"))]
342                panic!("`mania` feature is not enabled");
343
344                #[cfg(feature = "mania")]
345                mania::strains(self, mods)
346            }
347            GameMode::TKO => {
348                #[cfg(not(feature = "taiko"))]
349                panic!("`osu` feature is not enabled");
350
351                #[cfg(feature = "taiko")]
352                taiko::strains(self, mods)
353            }
354            GameMode::CTB => {
355                #[cfg(not(feature = "fruits"))]
356                panic!("`fruits` feature is not enabled");
357
358                #[cfg(feature = "fruits")]
359                fruits::strains(self, mods)
360            }
361        }
362    }
363}
364
365/// The result of calculating the strains on a map.
366/// Suitable to plot the difficulty of a map over time.
367///
368/// `strains` will be the summed strains for each skill of the map's mode.
369///
370/// `section_length` is the time in ms inbetween two strains.
371#[derive(Clone, Debug, Default)]
372pub struct Strains {
373    pub section_length: f32,
374    pub strains: Vec<f32>,
375}
376
377/// Basic enum containing the result of a star calculation based on the mode.
378#[derive(Clone, Debug)]
379pub enum StarResult {
380    #[cfg(feature = "fruits")]
381    Fruits(fruits::DifficultyAttributes),
382    #[cfg(feature = "mania")]
383    Mania(mania::DifficultyAttributes),
384    #[cfg(feature = "osu")]
385    Osu(osu::DifficultyAttributes),
386    #[cfg(feature = "taiko")]
387    Taiko(taiko::DifficultyAttributes),
388}
389
390impl StarResult {
391    /// The final star value.
392    #[inline]
393    pub fn stars(&self) -> f32 {
394        match self {
395            #[cfg(feature = "fruits")]
396            Self::Fruits(attributes) => attributes.stars,
397            #[cfg(feature = "mania")]
398            Self::Mania(attributes) => attributes.stars,
399            #[cfg(feature = "osu")]
400            Self::Osu(attributes) => attributes.stars,
401            #[cfg(feature = "taiko")]
402            Self::Taiko(attributes) => attributes.stars,
403        }
404    }
405}
406
407#[derive(Clone, Debug)]
408pub struct PpRaw {
409    pub aim: Option<f32>,
410    pub spd: Option<f32>,
411    pub str: Option<f32>,
412    pub acc: Option<f32>,
413    pub total: f32,
414}
415
416impl PpRaw {
417    #[inline(always)]
418    pub fn new(
419        aim: Option<f32>,
420        spd: Option<f32>,
421        str: Option<f32>,
422        acc: Option<f32>,
423        total: f32,
424    ) -> Self {
425        Self {
426            aim,
427            spd,
428            str,
429            acc,
430            total,
431        }
432    }
433}
434
435/// Basic struct containing the result of a PP calculation.
436#[derive(Clone, Debug)]
437pub struct PpResult {
438    pub mode: u8,
439    pub mods: u32,
440    pub pp: f32,
441    pub raw: PpRaw,
442    pub attributes: StarResult,
443}
444
445impl PpResult {
446    /// The final pp value.
447    #[inline]
448    pub fn pp(&self) -> f32 {
449        self.pp
450    }
451
452    /// The final star value.
453    #[inline]
454    pub fn stars(&self) -> f32 {
455        self.attributes.stars()
456    }
457}
458
459#[cfg(any(feature = "osu", feature = "taiko"))]
460#[inline]
461fn difficulty_range(val: f32, max: f32, avg: f32, min: f32) -> f32 {
462    if val > 5.0 {
463        avg + (max - avg) * (val - 5.0) / 5.0
464    } else if val < 5.0 {
465        avg - (avg - min) * (5.0 - val) / 5.0
466    } else {
467        avg
468    }
469}
470
471#[cfg(not(any(
472    feature = "osu",
473    feature = "taiko",
474    feature = "fruits",
475    feature = "mania"
476)))]
477compile_error!("At least one of the features `osu`, `taiko`, `fruits`, `mania` must be enabled");
478
479#[cfg(all(
480    feature = "osu",
481    not(any(
482        feature = "all_included",
483        feature = "no_leniency",
484        feature = "no_sliders_no_leniency"
485    ))
486))]
487compile_error!("Since the `osu` feature is enabled, either `no_leniency`, `no_sliders_no_leniency`, or `all_included` must be enabled aswell");
488
489#[cfg(any(
490    all(feature = "no_leniency", feature = "no_sliders_no_leniency"),
491    all(feature = "no_leniency", feature = "all_included"),
492    all(feature = "all_included", feature = "no_sliders_no_leniency"),
493))]
494compile_error!("Only one of the features `no_leniency`, `no_sliders_no_leniency`, `all_included` can be enabled");
495
496#[cfg(all(
497    not(feature = "osu"),
498    any(
499        feature = "no_leniency",
500        feature = "no_sliders_no_leniency",
501        feature = "all_included"
502    )
503))]
504compile_error!("The features `no_leniency`, `no_sliders_no_leniency`, and `all_included` should only be enabled in combination with the `osu` feature");
505
506#[cfg(all(feature = "async_tokio", feature = "async_std"))]
507compile_error!("Only one of the features `async_tokio` and `async_std` should be enabled");