rosu_pp/model/beatmap/attributes/
mod.rs1use 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#[derive(Clone, Debug, PartialEq)]
24pub struct AdjustedBeatmapAttributes {
25 pub ar: f64,
27 pub cs: f32,
29 pub hp: f32,
31 pub od: f64,
33}
34
35#[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 pub const fn builder() -> BeatmapAttributesBuilder {
72 BeatmapAttributesBuilder::new()
73 }
74
75 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 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 pub const fn cs(&self) -> f32 {
119 self.difficulty.cs.get_raw()
120 }
121
122 pub const fn hp(&self) -> f32 {
124 self.difficulty.hp.get_raw()
125 }
126
127 pub const fn clock_rate(&self) -> f64 {
129 self.clock_rate
130 }
131
132 pub fn hit_windows(&self) -> HitWindows {
134 let clock_rate = self.clock_rate;
135
136 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 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 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 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 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 (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}