Skip to main content

rosu_pp/model/beatmap/attributes/
mod.rs

1use rosu_map::section::general::GameMode;
2
3pub use self::{
4    attribute::BeatmapAttribute, builder::BeatmapAttributesBuilder, hit_windows::HitWindows,
5};
6
7pub(crate) use self::{difficulty::BeatmapDifficulty, ext::BeatmapAttributesExt};
8
9use crate::{GameMods, model::beatmap::attributes::hit_windows::GameModeHitWindows};
10
11mod attribute;
12mod builder;
13mod difficulty;
14mod ext;
15mod hit_windows;
16
17/// Summary struct for a [`Beatmap`]'s attributes.
18///
19/// The difference between this and [`BeatmapAttributes`] is that this struct
20/// considers the clock rate in its attribute values.
21///
22/// [`Beatmap`]: crate::Beatmap
23#[derive(Clone, Debug, PartialEq)]
24pub struct AdjustedBeatmapAttributes {
25    /// The approach rate.
26    pub ar: f64,
27    /// The circle size.
28    pub cs: f32,
29    /// The health drain rate.
30    pub hp: f32,
31    /// The overall difficulty.
32    pub od: f64,
33}
34
35/// Summary struct for a [`Beatmap`]'s attributes.
36///
37/// Clock rate is *not* considered in attribute values.
38///
39/// [`Beatmap`]: crate::Beatmap
40#[derive(Clone, Debug, PartialEq)]
41pub struct BeatmapAttributes {
42    difficulty: BeatmapDifficulty,
43    mode: GameMode,
44    clock_rate: f64,
45    is_convert: bool,
46    classic_and_not_v2: bool,
47    mod_status: ModStatus,
48}
49
50#[derive(Copy, Clone, Debug, PartialEq)]
51enum ModStatus {
52    Neither,
53    Easy,
54    HardRock,
55}
56
57impl ModStatus {
58    fn new(mods: &GameMods) -> Self {
59        if mods.hr() {
60            Self::HardRock
61        } else if mods.ez() {
62            Self::Easy
63        } else {
64            Self::Neither
65        }
66    }
67}
68
69impl BeatmapAttributes {
70    /// Create a new [`BeatmapAttributesBuilder`].
71    pub const fn builder() -> BeatmapAttributesBuilder {
72        BeatmapAttributesBuilder::new()
73    }
74
75    /// The approach rate.
76    pub fn ar(&self) -> f32 {
77        match self.difficulty.ar {
78            BeatmapAttribute::None => BeatmapAttribute::DEFAULT,
79            BeatmapAttribute::Given(value) | BeatmapAttribute::Value(value) => value,
80            BeatmapAttribute::Fixed(fixed) => match self.mode {
81                GameMode::Osu | GameMode::Catch => hit_windows::AR.inverse_difficulty_range(
82                    hit_windows::AR.difficulty_range(f64::from(fixed)) * self.clock_rate,
83                ) as f32,
84                GameMode::Taiko | GameMode::Mania => fixed,
85            },
86        }
87    }
88
89    /// The overall difficulty.
90    pub fn od(&self) -> f32 {
91        match self.difficulty.od {
92            BeatmapAttribute::None => BeatmapAttribute::DEFAULT,
93            BeatmapAttribute::Given(value) | BeatmapAttribute::Value(value) => value,
94            BeatmapAttribute::Fixed(fixed) => match self.mode {
95                GameMode::Osu => hit_windows::osu::GREAT.inverse_difficulty_range(
96                    hit_windows::osu::GREAT.difficulty_range(f64::from(fixed)) * self.clock_rate,
97                ) as f32,
98                GameMode::Taiko => hit_windows::taiko::GREAT.inverse_difficulty_range(
99                    hit_windows::taiko::GREAT.difficulty_range(f64::from(fixed)) * self.clock_rate,
100                ) as f32,
101                GameMode::Mania => {
102                    let factor = match self.mod_status {
103                        ModStatus::Neither => 1.0,
104                        ModStatus::Easy => 1.0 / 1.4,
105                        ModStatus::HardRock => 1.4,
106                    };
107
108                    hit_windows::mania::PERFECT.inverse_difficulty_range(
109                        hit_windows::mania::PERFECT.difficulty_range(f64::from(fixed)) * factor,
110                    ) as f32
111                }
112                GameMode::Catch => fixed,
113            },
114        }
115    }
116
117    /// The circle size.
118    pub const fn cs(&self) -> f32 {
119        self.difficulty.cs.get_raw()
120    }
121
122    /// The health drain rate.
123    pub const fn hp(&self) -> f32 {
124        self.difficulty.hp.get_raw()
125    }
126
127    /// The clock rate.
128    pub const fn clock_rate(&self) -> f64 {
129        self.clock_rate
130    }
131
132    /// Calculate the AR and OD hit windows.
133    pub fn hit_windows(&self) -> HitWindows {
134        let clock_rate = self.clock_rate;
135
136        // Same for osu! and osu!catch (?)
137        let ar = || {
138            let value = match self.difficulty.ar {
139                BeatmapAttribute::None => BeatmapAttribute::DEFAULT,
140                BeatmapAttribute::Value(value) | BeatmapAttribute::Given(value) => value,
141                BeatmapAttribute::Fixed(fixed) => {
142                    return hit_windows::AR.difficulty_range(f64::from(fixed));
143                }
144            };
145
146            hit_windows::AR.difficulty_range(f64::from(value)) / clock_rate
147        };
148
149        // See `{OsuHitWindows,TaikoHitWindows}.SetDifficulty`
150        let set_difficulty = |hit_windows: &GameModeHitWindows| {
151            let value = match self.difficulty.od {
152                BeatmapAttribute::None => BeatmapAttribute::DEFAULT,
153                BeatmapAttribute::Value(value) | BeatmapAttribute::Given(value) => value,
154                BeatmapAttribute::Fixed(fixed) => {
155                    //     Fixed           = f^-1(f(Value) / C)
156                    // <=> f(Fixed)        = f(Value) / C
157                    // <=> f(Fixed) * C    = f(Value)
158                    let f_value = hit_windows.difficulty_range(f64::from(fixed)) * clock_rate;
159
160                    return (f64::floor(f_value) - 0.5) / clock_rate;
161                }
162            };
163
164            (f64::floor(hit_windows.difficulty_range(f64::from(value))) - 0.5) / clock_rate
165        };
166
167        match self.mode {
168            GameMode::Osu => HitWindows {
169                ar: Some(ar()),
170                od_great: Some(set_difficulty(&hit_windows::osu::GREAT)),
171                od_ok: Some(set_difficulty(&hit_windows::osu::OK)),
172                od_meh: Some(set_difficulty(&hit_windows::osu::MEH)),
173                ..Default::default()
174            },
175            GameMode::Taiko => HitWindows {
176                od_great: Some(set_difficulty(&hit_windows::taiko::GREAT)),
177                od_ok: Some(set_difficulty(&hit_windows::taiko::OK)),
178                ..Default::default()
179            },
180            GameMode::Catch => HitWindows {
181                ar: Some(ar()),
182                ..Default::default()
183            },
184            GameMode::Mania => {
185                let speed_multiplier: f64 = 1.0;
186                let difficulty_multiplier: f64 = 1.0;
187                let total_multiplier = speed_multiplier / difficulty_multiplier;
188
189                // Clock rate is irrelevant, apparently
190                let od = f64::from(self.difficulty.od.get_raw());
191
192                let (perfect, great, good, ok, meh) = if self.classic_and_not_v2 {
193                    if self.is_convert {
194                        (
195                            f64::floor(16.0 * total_multiplier) + 0.5,
196                            f64::floor(
197                                (if f64::round_ties_even(od) > 4.0 {
198                                    34.0
199                                } else {
200                                    47.0
201                                }) * total_multiplier,
202                            ) + 0.5,
203                            f64::floor(
204                                (if f64::round_ties_even(od) > 4.0 {
205                                    67.0
206                                } else {
207                                    77.0
208                                }) * total_multiplier,
209                            ) + 0.5,
210                            f64::floor(97.0 * total_multiplier) + 0.5,
211                            f64::floor(121.0 * total_multiplier) + 0.5,
212                        )
213                    } else {
214                        let inverted_od = f64::clamp(10.0 - od, 0.0, 10.0);
215
216                        let hit_window = |add: f64| {
217                            f64::floor((add + 3.0 * inverted_od) * total_multiplier) + 0.5
218                        };
219
220                        (
221                            f64::floor(16.0 * total_multiplier) + 0.5,
222                            hit_window(34.0),
223                            hit_window(67.0),
224                            hit_window(97.0),
225                            hit_window(121.0),
226                        )
227                    }
228                } else {
229                    let hit_window = |hit_windows: &GameModeHitWindows| {
230                        f64::floor(hit_windows.difficulty_range(od) * total_multiplier) + 0.5
231                    };
232
233                    (
234                        hit_window(&hit_windows::mania::PERFECT),
235                        hit_window(&hit_windows::mania::GREAT),
236                        hit_window(&hit_windows::mania::GOOD),
237                        hit_window(&hit_windows::mania::OK),
238                        hit_window(&hit_windows::mania::MEH),
239                    )
240                };
241
242                HitWindows {
243                    ar: None,
244                    od_perfect: Some(perfect),
245                    od_great: Some(great),
246                    od_good: Some(good),
247                    od_ok: Some(ok),
248                    od_meh: Some(meh),
249                }
250            }
251        }
252    }
253
254    /// Convert [`BeatmapAttributes`] into [`AdjustedBeatmapAttributes`] by
255    /// applying the clock rate to the attribute values.
256    pub fn apply_clock_rate(&self) -> AdjustedBeatmapAttributes {
257        let clock_rate = self.clock_rate;
258
259        let (ar, od) = match self.mode {
260            GameMode::Osu => {
261                let ar = self.difficulty.ar.map_or_else(f64::from, |ar| {
262                    let mut preempt = hit_windows::AR.difficulty_range(f64::from(ar));
263                    preempt /= clock_rate;
264
265                    hit_windows::AR.inverse_difficulty_range(preempt)
266                });
267
268                let od = self.difficulty.od.map_or_else(f64::from, |od| {
269                    let mut great_hit_window =
270                        hit_windows::osu::GREAT.difficulty_range(f64::from(od));
271                    great_hit_window /= clock_rate;
272
273                    hit_windows::osu::GREAT.inverse_difficulty_range(great_hit_window)
274                });
275
276                (ar, od)
277            }
278            GameMode::Taiko => {
279                let od = self.difficulty.od.map_or_else(f64::from, |od| {
280                    let mut great_hit_window =
281                        hit_windows::taiko::GREAT.difficulty_range(f64::from(od));
282                    great_hit_window /= clock_rate;
283
284                    hit_windows::taiko::GREAT.inverse_difficulty_range(great_hit_window)
285                });
286
287                (f64::from(self.difficulty.ar.get_raw()), od)
288            }
289            GameMode::Catch => {
290                let ar = self.difficulty.ar.map_or_else(f64::from, |ar| {
291                    let mut preempt = hit_windows::AR.difficulty_range(f64::from(ar));
292                    preempt /= clock_rate;
293
294                    hit_windows::AR.inverse_difficulty_range(preempt)
295                });
296
297                (ar, f64::from(self.difficulty.od.get_raw()))
298            }
299            GameMode::Mania => {
300                let od = self.difficulty.od.map_or_else(f64::from, |od| {
301                    let mut perfect_hit_window =
302                        hit_windows::mania::PERFECT.difficulty_range(f64::from(od));
303
304                    match self.mod_status {
305                        ModStatus::Neither => {}
306                        ModStatus::Easy => perfect_hit_window /= 1.0 / 1.4,
307                        ModStatus::HardRock => perfect_hit_window /= 1.4,
308                    }
309
310                    hit_windows::mania::PERFECT.inverse_difficulty_range(perfect_hit_window)
311                });
312
313                // Ignoring CS
314
315                (f64::from(self.difficulty.ar.get_raw()), od)
316            }
317        };
318
319        AdjustedBeatmapAttributes {
320            ar,
321            cs: self.difficulty.cs.get_raw(),
322            hp: self.difficulty.hp.get_raw(),
323            od,
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    #![expect(clippy::float_cmp, reason = "we're just testing here")]
331
332    use rosu_mods::{
333        GameMod, GameMods,
334        generated_mods::{DifficultyAdjustOsu, DoubleTimeCatch, DoubleTimeOsu, HiddenOsu},
335    };
336
337    use crate::Difficulty;
338
339    use super::*;
340
341    #[test]
342    fn default_ar() {
343        let gamemod = GameMod::HiddenOsu(HiddenOsu::default());
344        let diff = Difficulty::new().mods(GameMods::from(gamemod));
345        let attrs = BeatmapAttributes::builder().difficulty(&diff).build();
346
347        assert_eq!(attrs.ar(), 5.0);
348    }
349
350    #[test]
351    fn ar_without_mods() {
352        let gamemod = GameMod::DoubleTimeOsu(DoubleTimeOsu::default());
353        let diff = Difficulty::new().mods(GameMods::from(gamemod));
354        let attrs = BeatmapAttributes::builder()
355            .ar(8.5, false)
356            .difficulty(&diff)
357            .build()
358            .apply_clock_rate();
359
360        assert_eq!(attrs.ar, 10.0);
361    }
362
363    #[test]
364    fn ar_with_mods() {
365        let gamemod = GameMod::DoubleTimeOsu(DoubleTimeOsu::default());
366        let diff = Difficulty::new().mods(GameMods::from(gamemod));
367        let attrs = BeatmapAttributes::builder()
368            .ar(8.5, true)
369            .difficulty(&diff)
370            .build()
371            .apply_clock_rate();
372
373        assert_eq!(attrs.ar, 8.5);
374    }
375
376    #[test]
377    fn mods_ar() {
378        let mut mods = GameMods::new();
379        mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default()));
380        mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu {
381            approach_rate: Some(7.0),
382            ..DifficultyAdjustOsu::default()
383        }));
384        let diff = Difficulty::new().mods(mods);
385
386        let attrs = BeatmapAttributes::builder()
387            .difficulty(&diff)
388            .build()
389            .apply_clock_rate();
390
391        assert_eq!(attrs.ar, 9.0);
392    }
393
394    #[test]
395    fn ar_mods_ar_without_mods() {
396        let mut mods = GameMods::new();
397        mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default()));
398        mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu {
399            approach_rate: Some(9.0),
400            ..DifficultyAdjustOsu::default()
401        }));
402
403        let diff = Difficulty::new().mods(mods).ar(8.5, false);
404
405        let attrs = BeatmapAttributes::builder()
406            .difficulty(&diff)
407            .build()
408            .apply_clock_rate();
409
410        assert_eq!(attrs.ar, 10.0);
411    }
412
413    #[test]
414    fn ar_mods_ar_with_mods() {
415        let mut mods = GameMods::new();
416        mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default()));
417        mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu {
418            approach_rate: Some(9.0),
419            ..DifficultyAdjustOsu::default()
420        }));
421
422        let diff = Difficulty::new().mods(mods).ar(8.5, true);
423
424        let attrs = BeatmapAttributes::builder()
425            .difficulty(&diff)
426            .build()
427            .apply_clock_rate();
428
429        assert_eq!(attrs.ar, 8.5);
430    }
431
432    #[test]
433    fn set_od_before_applying_hr() {
434        let mut hr = GameMods::new();
435        hr.insert(GameMod::HardRockOsu(Default::default()));
436
437        let attrs = BeatmapAttributes::builder()
438            .ar(5.0, false)
439            .mods(hr)
440            .build()
441            .apply_clock_rate();
442
443        assert_eq!(attrs.od, 7.0);
444
445        let mut hrda = GameMods::new();
446        hrda.insert(GameMod::HardRockOsu(Default::default()));
447        hrda.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu {
448            overall_difficulty: Some(7.0),
449            ..Default::default()
450        }));
451
452        let attrs = BeatmapAttributes::builder()
453            .ar(5.0, false)
454            .mods(hrda)
455            .build()
456            .apply_clock_rate();
457
458        assert_eq!(attrs.od, 9.800000190734863);
459    }
460
461    #[test]
462    fn same_hit_windows_fixed_vs_given() {
463        for mode in [
464            GameMode::Osu,
465            GameMode::Taiko,
466            GameMode::Catch,
467            GameMode::Mania,
468        ] {
469            let fixed = BeatmapAttributes::builder()
470                .mode(mode, false)
471                .ar(6.0, true)
472                .od(6.0, true)
473                .build()
474                .hit_windows();
475
476            let given = BeatmapAttributes::builder()
477                .mode(mode, false)
478                .ar(6.0, false)
479                .od(6.0, false)
480                .build()
481                .hit_windows();
482
483            assert_eq!(fixed, given, "{mode:?}");
484        }
485    }
486
487    #[test]
488    fn getter_fixed_vs_given() {
489        for mode in [
490            GameMode::Osu,
491            GameMode::Taiko,
492            GameMode::Catch,
493            GameMode::Mania,
494        ] {
495            let fixed = BeatmapAttributes::builder()
496                .mode(mode, false)
497                .ar(7.1, true)
498                .od(7.1, true)
499                .build();
500
501            let given = BeatmapAttributes::builder()
502                .mode(mode, false)
503                .ar(7.1, false)
504                .od(7.1, false)
505                .build();
506
507            assert_eq!(fixed.ar(), given.ar(), "{mode:?}");
508            assert_eq!(fixed.od(), given.od(), "{mode:?}");
509        }
510    }
511}