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");