added convert support (#12)

* added taiko conversion

* finished converts

* made clippy happy

* optimized allocations for patterns

* taiko convert fixes

* added mode methods for osu calculators

* from CRLF back to LF

* updated changelog

* updated readme

* undo normal hitsound change

* added edge_sounds to changelog
This commit is contained in:
Max
2022-07-04 14:17:15 +02:00
committed by GitHub
parent c2e96ce95b
commit 7ed1ac5226
38 changed files with 2636 additions and 284 deletions
+9
View File
@@ -2,6 +2,12 @@
- __Additions__:
- Added the `ControlPoint` and `ControlPointerIter` types to the public interface, aswell as the `Beatmap::control_points` method
- `TimingPoint` and `DifficultyPoint` now implement `Default`
- Added new methods to `Beatmap`:
- `convert_mode`: Convert a map into another mode. (doesn't do anything if the starting map is not osu!standard)
- `total_break_time`: Return the accumulated break time in milliseconds
- `timing_point_at`: Return the timing point for the given timestamp
- `difficulty_point_at`: Return the difficulty point for the given timestamp if available
- __Breaking changes:__
- Moved some types to a different module. The following types can now be found in `rosu_pp::beatmap`:
- `Beatmap`
@@ -11,6 +17,9 @@
- `DifficultyPoint`
- `GameMode`
- `TimingPoint`
- Added a new field `kiai: bool` to both `TimingPoint` and `DifficultyPoint` to denote whether the current timing section is in kiai mode
- Added a new field `breaks: Vec<Break>` to `Beatmap` that contains all breaks throughout the map
- Added a new field `edge_sounds: Vec<u8>` to the `Slider` variant of `HitObjectKind` to denote the sample played on slider heads, ends, and repeats
# v0.5.2 (2022-06-14)
-2
View File
@@ -4,8 +4,6 @@
A standalone crate to calculate star ratings and performance points for all [osu!](https://osu.ppy.sh/home) gamemodes.
Conversions between game modes (i.e. "converts") are generally not supported.
Async is supported through features, see below.
### Usage
+16
View File
@@ -0,0 +1,16 @@
/// A break point of a [`Beatmap`](crate::beatmap::Beatmap).
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct Break {
/// Start timestamp of the break.
pub start_time: f64,
/// End timestamp of the break.
pub end_time: f64,
}
impl Break {
/// Duration of the break.
#[inline]
pub fn duration(&self) -> f64 {
self.end_time - self.start_time
}
}
+31
View File
@@ -9,6 +9,9 @@ pub struct TimingPoint {
pub beat_len: f64,
/// The start time of this timing section
pub time: f64,
/// Whether the section between this and the
/// next timing points is a kiai section
pub kiai: bool,
}
impl PartialOrd for TimingPoint {
@@ -17,6 +20,16 @@ impl PartialOrd for TimingPoint {
}
}
impl Default for TimingPoint {
fn default() -> Self {
Self {
beat_len: 60_000.0 / 60.0,
time: 0.0,
kiai: false,
}
}
}
/// [`TimingPoint`] that depends on a previous one.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct DifficultyPoint {
@@ -24,6 +37,9 @@ pub struct DifficultyPoint {
pub time: f64,
/// The speed multiplier until the next timing point
pub speed_multiplier: f64,
/// Whether the section between this and the
/// next timing points is a kiai section
pub kiai: bool,
}
impl PartialOrd for DifficultyPoint {
@@ -32,6 +48,16 @@ impl PartialOrd for DifficultyPoint {
}
}
impl Default for DifficultyPoint {
fn default() -> Self {
Self {
time: 0.0,
speed_multiplier: 1.0,
kiai: false,
}
}
}
/// Control point for a [`Beatmap`].
#[derive(Copy, Clone, Debug)]
pub enum ControlPoint {
@@ -118,24 +144,29 @@ mod test {
TimingPoint {
time: 1.0,
beat_len: 10.0,
kiai: false,
},
TimingPoint {
time: 3.0,
beat_len: 10.0,
kiai: false,
},
TimingPoint {
time: 4.0,
beat_len: 10.0,
kiai: false,
},
],
difficulty_points: vec![
DifficultyPoint {
time: 2.0,
speed_multiplier: 10.0,
kiai: false,
},
DifficultyPoint {
time: 5.0,
speed_multiplier: 10.0,
kiai: false,
},
],
..Default::default()
+36
View File
@@ -0,0 +1,36 @@
use std::hash::{BuildHasher, Hasher};
#[derive(Copy, Clone, Default)]
pub(crate) struct BuildByteHasher;
impl BuildHasher for BuildByteHasher {
type Hasher = ByteHasher;
#[inline]
fn build_hasher(&self) -> Self::Hasher {
ByteHasher { byte: 0 }
}
}
pub(crate) struct ByteHasher {
byte: u8,
}
impl Hasher for ByteHasher {
#[inline]
fn finish(&self) -> u64 {
self.byte as u64
}
#[inline]
fn write(&mut self, bytes: &[u8]) {
// Only use this hasher for single bytes
debug_assert_eq!(bytes.len(), 1);
self.byte = bytes[0];
}
#[inline]
fn write_u8(&mut self, byte: u8) {
self.byte = byte;
}
}
@@ -0,0 +1,42 @@
const INT_TO_REAL: f64 = 1.0 / (i32::MAX as f64 + 1.0);
const INT_MASK: u32 = 0x7F_FF_FF_FF;
pub(crate) struct Random {
x: u32,
y: u32,
z: u32,
w: u32,
}
impl Random {
pub(crate) fn new(seed: i32) -> Self {
Self {
x: seed as u32,
y: 842_502_087,
z: 3_579_807_591,
w: 273_326_509,
}
}
pub(crate) fn gen_unsigned(&mut self) -> u32 {
let t = self.x ^ (self.x << 11);
self.x = self.y;
self.y = self.z;
self.z = self.w;
self.w = self.w ^ (self.w >> 19) ^ t ^ (t >> 8);
self.w
}
pub(crate) fn gen_signed(&mut self) -> i32 {
(INT_MASK & self.gen_unsigned()) as i32
}
pub(crate) fn gen_double(&mut self) -> f64 {
INT_TO_REAL * self.gen_signed() as f64
}
pub(crate) fn gen_int_range(&mut self, min: i32, max: i32) -> i32 {
(min as f64 + self.gen_double() * (max - min) as f64) as i32
}
}
+218
View File
@@ -0,0 +1,218 @@
use std::cmp::Ordering;
use crate::{
curve::{Curve, CurveBuffers},
limited_queue::LimitedQueue,
parse::{legacy_sort, HitObjectKind, Pos2},
Beatmap,
};
use self::{
legacy_random::Random,
pattern::Pattern,
pattern_generator::{
distance_object::DistanceObjectPatternGenerator,
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
},
pattern_type::PatternType,
};
mod byte_hasher;
mod legacy_random;
mod pattern;
mod pattern_generator;
mod pattern_type;
const MAX_NOTES_FOR_DENSITY: usize = 7;
impl Beatmap {
pub(in crate::beatmap) fn convert_to_mania(&self) -> Self {
let mut map = self.clone_without_hit_objects(false);
let mut n_circles = 0;
let mut n_sliders = 0;
let seed =
(map.hp + map.cs).round() as i32 * 20 + (map.od * 41.2) as i32 + map.ar.round() as i32;
let mut random = Random::new(seed);
let rounded_cs = map.cs.round();
let rounded_od = map.od.round();
let slider_or_spinner_count = self
.hit_objects
.iter()
.filter(|h| {
matches!(
h.kind,
HitObjectKind::Slider { .. } | HitObjectKind::Spinner { .. }
)
})
.count();
let percent_slider_or_spinner =
slider_or_spinner_count as f32 / self.hit_objects.len() as f32;
let target_columns = if percent_slider_or_spinner < 0.2 {
7.0
} else if percent_slider_or_spinner < 0.3 || rounded_cs >= 5.0 {
(6 + (rounded_od > 5.0) as u8) as f32
} else if percent_slider_or_spinner > 0.6 {
(4 + (rounded_od > 4.0) as u8) as f32
} else {
(rounded_od + 1.0).clamp(4.0, 7.0)
};
map.cs = target_columns;
let mut prev_note_times = LimitedQueue::new(MAX_NOTES_FOR_DENSITY);
let mut density = i32::MAX as f64;
let mut compute_density = |new_note_time: f64, d: &mut f64| {
prev_note_times.push(new_note_time);
if prev_note_times.len() >= 2 {
*d = (prev_note_times.last().unwrap() - prev_note_times[0])
/ prev_note_times.len() as f64;
}
};
let total_columns = map.cs as i32;
let mut last_values = PrevValues::default();
let mut curve_bufs = CurveBuffers::default();
for (obj, sound) in self.hit_objects.iter().zip(self.sounds.iter()) {
match obj.kind {
HitObjectKind::Circle => {
compute_density(obj.start_time, &mut density);
let mut gen = HitObjectPatternGenerator::new(
&mut random,
obj,
*sound,
total_columns,
&last_values,
density,
self,
);
let new_pattern = gen.generate();
last_values.stair = gen.stair_type;
last_values.time = obj.start_time;
last_values.pos = obj.pos;
map.hit_objects
.extend(new_pattern.hit_objects.iter().cloned());
n_circles += new_pattern.hit_objects.len();
last_values.pattern = new_pattern;
}
HitObjectKind::Slider {
pixel_len,
repeats,
ref control_points,
ref edge_sounds,
} => {
let curve = Curve::new(control_points, pixel_len, &mut curve_bufs);
let mut gen = DistanceObjectPatternGenerator::new(
&mut random,
obj,
*sound,
total_columns,
&last_values.pattern,
self,
repeats,
&curve,
edge_sounds,
);
let segment_duration = gen.segment_duration as f64;
for i in 0..=repeats as i32 + 1 {
let time = obj.start_time + segment_duration * i as f64;
last_values.time = time;
last_values.pos = obj.pos;
compute_density(time, &mut density);
}
for new_pattern in gen.generate() {
let new_objects = new_pattern.hit_objects.iter().map(|h| {
if obj.is_circle() {
n_circles += 1;
} else {
n_sliders += 1;
}
h.to_owned()
});
map.hit_objects.extend(new_objects);
last_values.pattern = new_pattern;
}
}
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
let mut gen = EndTimeObjectPatternGenerator::new(
&mut random,
obj,
end_time,
*sound,
total_columns,
&last_values.pattern,
);
last_values.time = obj.start_time;
last_values.pos = obj.pos;
compute_density(end_time, &mut density);
let new_pattern = gen.generate();
let new_objects = new_pattern.hit_objects.into_iter().inspect(|h| {
if h.is_circle() {
n_circles += 1;
} else {
n_sliders += 1;
}
});
map.hit_objects.extend(new_objects);
}
}
}
map.n_circles = n_circles as u32;
map.n_sliders = n_sliders;
map.hit_objects
.sort_by(|p1, p2| p1.partial_cmp(p2).unwrap_or(Ordering::Equal));
legacy_sort(&mut map.hit_objects);
map
}
}
pub(crate) struct PrevValues {
time: f64,
pos: Pos2,
pattern: Pattern,
stair: PatternType,
}
impl Default for PrevValues {
fn default() -> Self {
Self {
time: 0.0,
pos: Pos2::default(),
pattern: Pattern::default(),
stair: PatternType::STAIR,
}
}
}
+167
View File
@@ -0,0 +1,167 @@
use std::collections::HashSet;
use crate::parse::{HitObject, HitObjectKind, Pos2};
use super::{
byte_hasher::BuildByteHasher,
pattern_generator::{
distance_object::DistanceObjectPatternGenerator,
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
},
};
#[derive(Default)]
pub(crate) struct Pattern {
pub(crate) hit_objects: Vec<HitObject>,
contained_columns: HashSet<u8, BuildByteHasher>,
}
impl Pattern {
pub(crate) fn with_capacity(capacity: usize) -> Self {
Self {
hit_objects: Vec::with_capacity(capacity),
contained_columns: HashSet::with_hasher(BuildByteHasher),
}
}
fn new_single(hit_object: HitObject, column: u8) -> Self {
let mut contained_columns = HashSet::with_capacity_and_hasher(1, BuildByteHasher);
contained_columns.insert(column);
let hit_objects = vec![hit_object];
Self {
hit_objects,
contained_columns,
}
}
pub(crate) fn new_note(generator: &HitObjectPatternGenerator<'_>, column: u8) -> Self {
let hit_object = HitObject {
pos: Pos2::new(column_to_pos(column, generator.total_columns)),
start_time: generator.hit_object.start_time,
kind: HitObjectKind::Circle,
};
Self::new_single(hit_object, column)
}
pub(crate) fn add_note(&mut self, generator: &HitObjectPatternGenerator<'_>, column: u8) {
let hit_object = HitObject {
pos: Pos2::new(column_to_pos(column, generator.total_columns)),
start_time: generator.hit_object.start_time,
kind: HitObjectKind::Circle,
};
self.contained_columns.insert(column);
self.hit_objects.push(hit_object);
}
pub(crate) fn new_end_time_note(
generator: &EndTimeObjectPatternGenerator<'_>,
column: u8,
hold_note: bool,
) -> Self {
let pos = Pos2::new(column_to_pos(column, generator.total_columns));
let hit_object = if hold_note {
HitObject {
pos,
start_time: generator.hit_object.start_time,
kind: HitObjectKind::Hold {
end_time: generator.end_time,
},
}
} else {
HitObject {
pos,
start_time: generator.hit_object.start_time,
kind: HitObjectKind::Circle,
}
};
Self::new_single(hit_object, column)
}
pub(crate) fn new_slider_note(
generator: &DistanceObjectPatternGenerator<'_>,
column: u8,
start_time: i32,
end_time: i32,
) -> Self {
let pos = Pos2::new(column_to_pos(column, generator.total_columns));
let hit_object = if start_time == end_time {
HitObject {
pos,
start_time: start_time as f64,
kind: HitObjectKind::Circle,
}
} else {
HitObject {
pos,
start_time: start_time as f64,
kind: HitObjectKind::Hold {
end_time: end_time as f64,
},
}
};
Self::new_single(hit_object, column)
}
pub(crate) fn add_slider_note(
&mut self,
generator: &DistanceObjectPatternGenerator<'_>,
column: u8,
start_time: i32,
end_time: i32,
) {
let pos = Pos2::new(column_to_pos(column, generator.total_columns));
let hit_object = if start_time == end_time {
HitObject {
pos,
start_time: start_time as f64,
kind: HitObjectKind::Circle,
}
} else {
HitObject {
pos,
start_time: start_time as f64,
kind: HitObjectKind::Hold {
end_time: end_time as f64,
},
}
};
self.contained_columns.insert(column);
self.hit_objects.push(hit_object);
}
pub(crate) fn add_object(&mut self, obj: HitObject, column: u8) {
self.hit_objects.push(obj);
self.contained_columns.insert(column);
}
pub(crate) fn column_has_obj(&self, column: u8) -> bool {
self.contained_columns.contains(&column)
}
pub(crate) fn column_with_objs(&self) -> i32 {
self.contained_columns.len() as i32
}
/// Moves all values of `other` into `self`,
/// leaving `other` empty but keeps the capacities.
pub(crate) fn append(&mut self, other: &mut Self) {
self.hit_objects.append(&mut other.hit_objects);
self.contained_columns
.extend(other.contained_columns.drain());
}
}
fn column_to_pos(column: u8, total_columns: i32) -> f32 {
let divisor = 512.0 / total_columns as f32;
(column as f32 * divisor).ceil()
}
@@ -0,0 +1,540 @@
use crate::{
beatmap::converts::mania::{
legacy_random::Random, pattern::Pattern, pattern_type::PatternType,
},
curve::Curve,
parse::{HitObject, HitSound},
Beatmap,
};
use super::PatternGenerator;
pub(crate) struct DistanceObjectPatternGenerator<'h> {
pub(crate) hit_object: &'h HitObject,
pub(crate) segment_duration: i32,
pub(crate) total_columns: i32,
pub(crate) sample: u8,
start_time: i32,
end_time: i32,
span_count: i32,
orig: &'h Beatmap,
prev_pattern: &'h Pattern,
convert_type: PatternType,
random: &'h mut Random,
edge_sounds: &'h [u8],
}
impl<'h> DistanceObjectPatternGenerator<'h> {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
sample: u8,
total_columns: i32,
prev_pattern: &'h Pattern,
orig: &'h Beatmap,
repeats: usize,
curve: &Curve,
edge_sounds: &'h [u8],
) -> Self {
let timing_point = orig.timing_point_at(hit_object.start_time);
let difficulty_point = orig
.difficulty_point_at(hit_object.start_time)
.unwrap_or_default();
let kiai = if timing_point.time < difficulty_point.time {
difficulty_point.kiai
} else {
timing_point.kiai
};
let convert_type = if kiai {
PatternType::default()
} else {
PatternType::LOW_PROBABILITY
};
let beat_len = timing_point.beat_len / difficulty_point.speed_multiplier;
let span_count = (repeats + 1) as i32;
let start_time = hit_object.start_time.round() as i32;
// * This matches stable's calculation.
let end_time = (start_time as f64
+ curve.dist() * beat_len * span_count as f64 * 0.01 / orig.slider_mult)
.floor() as i32;
let segment_duration = (end_time - start_time) / span_count;
Self {
hit_object,
segment_duration,
total_columns,
sample,
start_time,
end_time,
span_count,
orig,
prev_pattern,
convert_type,
random,
edge_sounds,
}
}
pub(crate) fn generate(&mut self) -> Vec<Pattern> {
let orig_pattern = self.generate_();
if orig_pattern.hit_objects.len() == 1 {
return vec![orig_pattern];
}
// * We need to split the intermediate pattern into two new patterns:
// * 1. A pattern containing all objects that do not end at our EndTime.
// * 2. A pattern containing all objects that end at our EndTime. This will be used for further pattern generation.
let mut intermediate_pattern = Pattern::default();
let mut end_time_pattern = Pattern::default();
for obj in orig_pattern.hit_objects {
let column = obj.column(self.total_columns as f32);
if self.end_time != obj.end_time().round() as i32 {
intermediate_pattern.add_object(obj, column);
} else {
end_time_pattern.add_object(obj, column);
}
}
vec![intermediate_pattern, end_time_pattern]
}
fn generate_(&mut self) -> Pattern {
let conversion_diff = self.conversion_difficulty();
if self.total_columns == 1 {
return Pattern::new_slider_note(self, 0, self.start_time, self.end_time);
} else if self.span_count > 1 {
if self.segment_duration <= 90 {
self.generate_random_hold_notes(self.start_time, 1)
} else if self.segment_duration <= 120 {
self.convert_type |= PatternType::FORCE_NOT_STACK;
self.generate_random_notes(self.start_time, self.span_count + 1)
} else if self.segment_duration <= 160 {
self.generate_stair(self.start_time)
} else if self.segment_duration <= 200 && conversion_diff > 3.0 {
self.generate_random_multiple_notes(self.start_time)
} else if self.end_time - self.start_time >= 4000 {
self.generate_n_random_notes(self.start_time, 0.23, 0.0, 0.0)
} else if self.segment_duration > 400
&& self.span_count < self.total_columns - 1 - self.random_start()
{
self.generate_tiled_hold_notes(self.start_time)
} else {
self.generate_hold_and_normal_notes(self.start_time, conversion_diff)
}
} else if self.segment_duration <= 110 {
if self.prev_pattern.column_with_objs() < self.total_columns {
self.convert_type |= PatternType::FORCE_NOT_STACK;
} else {
self.convert_type &= !PatternType::FORCE_NOT_STACK;
}
let note_count = 1 + (self.segment_duration >= 80) as i32;
self.generate_random_notes(self.start_time, note_count)
} else if conversion_diff > 6.5 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_n_random_notes(self.start_time, 0.78, 0.3, 0.0)
} else {
self.generate_n_random_notes(self.start_time, 0.85, 0.36, 0.03)
}
} else if conversion_diff > 4.0 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_n_random_notes(self.start_time, 0.43, 0.08, 0.0)
} else {
self.generate_n_random_notes(self.start_time, 0.56, 0.18, 0.0)
}
} else if conversion_diff > 2.5 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_n_random_notes(self.start_time, 0.3, 0.0, 0.0)
} else {
self.generate_n_random_notes(self.start_time, 0.37, 0.08, 0.0)
}
} else if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_n_random_notes(self.start_time, 0.17, 0.0, 0.0)
} else {
self.generate_n_random_notes(self.start_time, 0.27, 0.0, 0.0)
}
}
fn generate_random_hold_notes(&mut self, start_time: i32, note_count: i32) -> Pattern {
// * - - - -
// * ■ - ■ ■
// * □ - □ □
// * ■ - ■ ■
let mut pattern = Pattern::default();
let random_start = self.random_start();
let usable_columns =
self.total_columns - random_start - self.prev_pattern.column_with_objs();
let mut next_column = PatternGenerator::get_random_column(self, None, None);
for _ in 0..usable_columns.min(note_count) {
// * Find available column
next_column = self.find_available_column(
next_column,
None,
None,
None,
None,
&[&pattern, self.prev_pattern],
);
pattern.add_slider_note(self, next_column, start_time, self.end_time);
}
// * This is can't be combined with the above loop due to RNG
for _ in 0..note_count.saturating_sub(usable_columns) {
next_column =
self.find_available_column(next_column, None, None, None, None, &[&pattern]);
pattern.add_slider_note(self, next_column, start_time, self.end_time);
}
pattern
}
fn generate_random_notes(&mut self, mut start_time: i32, note_count: i32) -> Pattern {
// * - - - -
// * x - - -
// * - - x -
// * - - - x
// * x - - -
let mut next_column = self.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.total_columns
{
next_column = self.find_available_column(
next_column,
None,
None,
None,
None,
&[self.prev_pattern],
);
}
let mut last_column = next_column;
let mut pattern = Pattern::with_capacity(note_count as usize);
for _ in 0..note_count {
pattern.add_slider_note(self, next_column, start_time, start_time);
next_column = self.find_available_column(
next_column,
None,
None,
None,
Some(&|c| c != last_column as i32),
&[],
);
last_column = next_column;
start_time += self.segment_duration;
}
pattern
}
fn generate_stair(&mut self, mut start_time: i32) -> Pattern {
// * - - - -
// * x - - -
// * - x - -
// * - - x -
// * - - - x
// * - - x -
// * - x - -
// * x - - -
let mut column = self.get_column(Some(true)) as i32;
let mut increasing = self.random.gen_double() > 0.5;
let mut pattern = Pattern::with_capacity(self.span_count as usize + 1);
for _ in 0..=self.span_count as usize {
pattern.add_slider_note(self, column as u8, start_time, start_time);
start_time += self.segment_duration;
// * Check if we're at the borders of the stage, and invert the pattern if so
if increasing {
if column >= self.total_columns - 1 {
increasing = false;
column -= 1;
} else {
column += 1;
}
} else if column <= self.random_start() {
increasing = true;
column += 1;
} else {
column -= 1;
}
}
pattern
}
fn generate_random_multiple_notes(&mut self, mut start_time: i32) -> Pattern {
// * - - - -
// * x - - -
// * - x x -
// * - - - x
// * x - x -
let legacy = (4..=8).contains(&self.total_columns);
let interval = self
.random
.gen_int_range(1, self.total_columns as i32 - (legacy as i32));
let mut next_column = self.get_column(Some(true)) as i32;
let random_start = self.random_start();
let not_2k = self.total_columns > 2;
let mut pattern =
Pattern::with_capacity((self.span_count as usize + 1) * (1 + not_2k as usize));
for _ in 0..=self.span_count as usize {
pattern.add_slider_note(self, next_column as u8, start_time, start_time);
next_column += interval;
if next_column >= self.total_columns - random_start {
next_column = next_column - self.total_columns - random_start + (legacy as i32);
}
next_column += random_start;
// * If we're in 2K, let's not add many consecutive doubles
if not_2k {
pattern.add_slider_note(self, next_column as u8, start_time, start_time);
}
next_column = PatternGenerator::get_random_column(self, None, None) as i32;
start_time += self.segment_duration;
}
pattern
}
fn generate_n_random_notes(
&mut self,
start_time: i32,
mut p2: f64,
mut p3: f64,
mut p4: f64,
) -> Pattern {
// * - - - -
// * ■ - ■ ■
// * □ - □ □
// * ■ - ■ ■
match self.total_columns {
2 => {
p2 = 0.0;
p3 = 0.0;
p4 = 0.0;
}
3 => {
p2 = p2.min(0.1);
p3 = 0.0;
p4 = 0.0;
}
4 => {
p2 = p2.min(0.3);
p3 = p3.min(0.04);
p4 = 0.0;
}
5 => {
p2 = p2.min(0.34);
p3 = p3.min(0.1);
p4 = p4.min(0.03);
}
_ => {}
}
let is_double_sample = |sample: u8| sample.clap() || sample.finish();
let can_generate_two_notes = !self.convert_type.contains(PatternType::LOW_PROBABILITY)
&& (is_double_sample(self.sample)
|| is_double_sample(self.sample_info_list_at(self.start_time)));
if can_generate_two_notes {
p2 = 1.0;
}
let note_count = self.get_random_note_count(p2, p3, Some(p4), None, None);
self.generate_random_hold_notes(start_time, note_count)
}
fn generate_tiled_hold_notes(&mut self, mut start_time: i32) -> Pattern {
// * - - - -
// * ■ ■ ■ ■
// * □ □ □ □
// * □ □ □ □
// * □ □ □ ■
// * □ □ ■ -
// * □ ■ - -
// * ■ - - -
let column_repeat = self.span_count.min(self.total_columns) as usize;
// * Due to integer rounding, this is not guaranteed to be the same as EndTime (the class-level variable).
let end_time = start_time + self.segment_duration * self.span_count;
let mut next_column = self.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.total_columns
{
next_column = self.find_available_column(
next_column,
None,
None,
None,
None,
&[self.prev_pattern],
);
}
let mut pattern = Pattern::with_capacity(column_repeat);
for _ in 0..column_repeat {
next_column =
self.find_available_column(next_column, None, None, None, None, &[&pattern]);
pattern.add_slider_note(self, next_column, start_time, end_time);
start_time += self.segment_duration;
}
pattern
}
fn generate_hold_and_normal_notes(
&mut self,
mut start_time: i32,
conversion_diff: f64,
) -> Pattern {
// * - - - -
// * ■ x x -
// * ■ - x x
// * ■ x - x
// * ■ - x x
let mut pattern = Pattern::default();
let mut hold_column = self.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.total_columns
{
hold_column = self.find_available_column(
hold_column,
None,
None,
None,
None,
&[self.prev_pattern],
);
}
// * Create the hold note
pattern.add_slider_note(self, hold_column, start_time, self.end_time);
let mut next_column = PatternGenerator::get_random_column(self, None, None);
let mut note_count = if conversion_diff > 6.5 {
self.get_random_note_count(0.63, 0.0, None, None, None)
} else if conversion_diff > 4.0 {
let p2 = if self.total_columns < 6 { 0.12 } else { 0.45 };
self.get_random_note_count(p2, 0.0, None, None, None)
} else if conversion_diff > 2.5 {
let p2 = if self.total_columns < 6 { 0.0 } else { 0.24 };
self.get_random_note_count(p2, 0.0, None, None, None)
} else {
0
};
note_count = note_count.min(self.total_columns - 1);
let sample = self.sample_info_list_at(start_time);
let ignore_head = !(sample.whistle() || sample.finish() || sample.clap());
let mut row_pattern = Pattern::default();
let hold_column = hold_column as i32;
for _ in 0..=self.span_count as usize {
if !(ignore_head && start_time == self.start_time) {
for _ in 0..note_count {
next_column = self.find_available_column(
next_column,
None,
None,
None,
Some(&|c| c != hold_column),
&[&row_pattern],
);
row_pattern.add_slider_note(self, next_column, start_time, start_time);
}
}
pattern.append(&mut row_pattern);
start_time += self.segment_duration;
}
pattern
}
fn sample_info_list_at(&self, time: i32) -> u8 {
self.note_samples_at(time)
.first()
.map_or(self.sample, |sample| *sample)
}
fn note_samples_at(&self, time: i32) -> &[u8] {
let idx = if self.segment_duration == 0 {
0
} else {
((time - self.start_time) / self.segment_duration) as usize
};
&self.edge_sounds[idx..]
}
}
impl PatternGenerator for DistanceObjectPatternGenerator<'_> {
#[inline]
fn hit_object(&self) -> &HitObject {
self.hit_object
}
#[inline]
fn total_columns(&self) -> i32 {
self.total_columns
}
#[inline]
fn random(&mut self) -> &mut Random {
self.random
}
#[inline]
fn original_map(&self) -> &Beatmap {
self.orig
}
}
@@ -0,0 +1,97 @@
use crate::{
beatmap::converts::mania::{
legacy_random::Random, pattern::Pattern, pattern_type::PatternType,
},
parse::{HitObject, HitSound},
Beatmap,
};
use super::PatternGenerator;
pub(crate) struct EndTimeObjectPatternGenerator<'h> {
pub(crate) hit_object: &'h HitObject,
pub(crate) end_time: f64,
pub(crate) total_columns: i32,
pub(crate) sample: u8,
convert_type: PatternType,
prev_pattern: &'h Pattern,
random: &'h mut Random,
}
impl<'h> EndTimeObjectPatternGenerator<'h> {
pub(crate) fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
end_time: f64,
sample: u8,
total_columns: i32,
prev_pattern: &'h Pattern,
) -> Self {
let convert_type = if prev_pattern.column_with_objs() == total_columns {
PatternType::default()
} else {
PatternType::FORCE_NOT_STACK
};
Self {
hit_object,
end_time,
total_columns,
sample,
convert_type,
prev_pattern,
random,
}
}
pub(crate) fn generate(&mut self) -> Pattern {
let generate_hold = self.end_time - self.hit_object.start_time >= 100.0;
match self.total_columns {
8 if self.sample.finish() && self.end_time - self.hit_object.start_time < 1000.0 => {
Pattern::new_end_time_note(self, 0, generate_hold)
}
8 => {
let column = self.get_random_column(self.random_start());
Pattern::new_end_time_note(self, column, generate_hold)
}
_ => {
let column = self.get_random_column(0);
Pattern::new_end_time_note(self, column, generate_hold)
}
}
}
fn get_random_column(&mut self, lower: i32) -> u8 {
let column = PatternGenerator::get_random_column(self, Some(lower), None);
if self.convert_type.contains(PatternType::FORCE_NOT_STACK) {
self.find_available_column(column, Some(lower), None, None, None, &[self.prev_pattern])
} else {
self.find_available_column(column, Some(lower), None, None, None, &[])
}
}
}
impl PatternGenerator for EndTimeObjectPatternGenerator<'_> {
#[inline]
fn hit_object(&self) -> &HitObject {
self.hit_object
}
#[inline]
fn total_columns(&self) -> i32 {
self.total_columns
}
#[inline]
fn random(&mut self) -> &mut Random {
self.random
}
fn original_map(&self) -> &Beatmap {
panic!("trait method is not used")
}
}
@@ -0,0 +1,473 @@
use crate::{
beatmap::converts::mania::{
legacy_random::Random, pattern::Pattern, pattern_type::PatternType, PrevValues,
},
parse::{HitObject, HitSound},
Beatmap,
};
use super::PatternGenerator;
pub(crate) struct HitObjectPatternGenerator<'h> {
pub(crate) hit_object: &'h HitObject,
pub(crate) total_columns: i32,
pub(crate) sample: u8,
pub(crate) stair_type: PatternType,
convert_type: PatternType,
prev_pattern: &'h Pattern,
random: &'h mut Random,
orig: &'h Beatmap,
}
impl<'h> HitObjectPatternGenerator<'h> {
pub(crate) fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
sample: u8,
total_columns: i32,
prev: &'h PrevValues,
density: f64,
orig: &'h Beatmap,
) -> Self {
let timing_point = orig.timing_point_at(hit_object.start_time);
let pos_separation = (hit_object.pos - prev.pos).length();
let time_separation = hit_object.start_time - prev.time;
let mut convert_type = PatternType::default();
if time_separation <= 80.0 {
// * More than 187 BPM
convert_type |= PatternType::FORCE_NOT_STACK | PatternType::KEEP_SINGLE;
} else if time_separation <= 95.0 {
// * More than 157 BPM
convert_type |= PatternType::FORCE_NOT_STACK | PatternType::KEEP_SINGLE | prev.stair;
} else if time_separation <= 105.0 {
// * More than 140 BPM
convert_type |= PatternType::FORCE_NOT_STACK | PatternType::LOW_PROBABILITY;
} else if time_separation <= 125.0 {
// * More than 120 BPM
convert_type |= PatternType::FORCE_NOT_STACK;
} else if time_separation <= 135.0 && pos_separation < 20.0 {
// * More than 111 BPM stream
convert_type |= PatternType::CYCLE | PatternType::KEEP_SINGLE;
} else if time_separation <= 150.0 && pos_separation < 20.0 {
// * More than 100 BPM stream
convert_type |= PatternType::FORCE_STACK | PatternType::LOW_PROBABILITY;
} else if pos_separation < 20.0 && density >= timing_point.beat_len / 2.5 {
// * Low density stream
convert_type |= PatternType::REVERSE | PatternType::LOW_PROBABILITY;
} else if density < timing_point.beat_len / 2.5 {
// * High density
} else {
let difficulty_point = orig.difficulty_point_at(hit_object.start_time);
let kiai = match difficulty_point {
Some(difficulty_point) => {
if timing_point.time < difficulty_point.time {
difficulty_point.kiai
} else {
timing_point.kiai
}
}
None => timing_point.kiai,
};
if kiai {
// * High density
} else {
convert_type |= PatternType::LOW_PROBABILITY;
}
}
if !convert_type.contains(PatternType::KEEP_SINGLE) {
if sample.finish() && total_columns != 8 {
convert_type |= PatternType::MIRROR;
} else if sample.clap() {
convert_type |= PatternType::GATHERED;
}
}
Self {
hit_object,
stair_type: prev.stair,
convert_type,
total_columns,
sample,
prev_pattern: &prev.pattern,
random,
orig,
}
}
pub(crate) fn generate(&mut self) -> Pattern {
let pattern = self.generate_core();
for obj in pattern.hit_objects.iter() {
if self.convert_type.contains(PatternType::STAIR)
&& obj.column(self.total_columns as f32) as i32 == self.total_columns - 1
{
self.stair_type = PatternType::REVERSE_STAIR;
}
if self.convert_type.contains(PatternType::REVERSE_STAIR)
&& obj.column(self.total_columns as f32) as i32 == self.random_start()
{
self.stair_type = PatternType::STAIR;
}
}
pattern
}
fn generate_core(&mut self) -> Pattern {
if self.total_columns == 1 {
return Pattern::new_note(self, 0);
}
let last_column = self
.prev_pattern
.hit_objects
.last()
.map_or(0, |h| h.column(self.total_columns as f32));
let random_start = self.random_start() as u8;
if self.convert_type.contains(PatternType::REVERSE)
&& !self.prev_pattern.hit_objects.is_empty()
{
let mut pattern = Pattern::default();
for i in random_start..self.total_columns as u8 {
if self.prev_pattern.column_has_obj(i) {
pattern.add_note(self, random_start + self.total_columns as u8 - i - 1);
}
}
return pattern;
}
if self.convert_type.contains(PatternType::CYCLE)
&& self.prev_pattern.hit_objects.len() == 1
// * If we convert to 7K + 1, let's not overload the special key
&& (self.total_columns != 8 || last_column != 0)
// * Make sure the last column was not the centre column
&& (self.total_columns % 2 == 0 || last_column != self.total_columns as u8 / 2)
{
// * Generate a new pattern by cycling backwards (similar to Reverse but for only one hit object)
let column = random_start + self.total_columns as u8 - last_column - 1;
return Pattern::new_note(self, column);
}
if self.convert_type.contains(PatternType::FORCE_STACK)
&& !self.prev_pattern.hit_objects.is_empty()
{
let mut pattern = Pattern::default();
// * Generate a new pattern by placing on the already filled columns
for i in random_start..self.total_columns as u8 {
if self.prev_pattern.column_has_obj(i) {
pattern.add_note(self, i);
}
}
return pattern;
}
if self.prev_pattern.hit_objects.len() == 1 {
if self.convert_type.contains(PatternType::STAIR) {
// * Generate a new pattern by placing on the next column,
// * cycling back to the start if there is no "next"
let mut target_column = last_column + 1;
if target_column == self.total_columns as u8 {
target_column = random_start;
}
return Pattern::new_note(self, target_column);
}
if self.convert_type.contains(PatternType::REVERSE_STAIR) {
// * Generate a new pattern by placing on the previous column,
// * cycling back to the end if there is no "previous"
let mut target_column = last_column as i8 - 1;
if target_column == random_start as i8 - 1 {
target_column = self.total_columns as i8 - 1;
}
return Pattern::new_note(self, target_column as u8);
}
}
if self.convert_type.contains(PatternType::KEEP_SINGLE) {
return self.generate_random_notes(1);
}
let conversion_diff = self.conversion_difficulty();
if self.convert_type.contains(PatternType::MIRROR) {
if conversion_diff > 6.5 {
self.generate_random_pattern_with_mirrored(0.12, 0.38, 0.12)
} else if conversion_diff > 4.0 {
self.generate_random_pattern_with_mirrored(0.12, 0.17, 0.0)
} else {
self.generate_random_pattern_with_mirrored(0.12, 0.0, 0.0)
}
} else if conversion_diff > 6.5 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_random_pattern(0.78, 0.42, 0.0, 0.0)
} else {
self.generate_random_pattern(1.0, 0.62, 0.0, 0.0)
}
} else if conversion_diff > 4.0 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_random_pattern(0.35, 0.08, 0.0, 0.0)
} else {
self.generate_random_pattern(0.52, 0.15, 0.0, 0.0)
}
} else if conversion_diff > 2.0 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_random_pattern(0.18, 0.0, 0.0, 0.0)
} else {
self.generate_random_pattern(0.45, 0.0, 0.0, 0.0)
}
} else {
self.generate_random_pattern(0.0, 0.0, 0.0, 0.0)
}
}
fn generate_random_notes(&mut self, mut note_count: i32) -> Pattern {
let mut pattern = Pattern::default();
let allow_stacking = !self.convert_type.contains(PatternType::FORCE_NOT_STACK);
if !allow_stacking {
note_count =
(self.total_columns - self.random_start() - self.prev_pattern.column_with_objs())
.min(note_count);
}
let mut next_column = self.get_column(Some(true));
for _ in 0..note_count {
next_column = if allow_stacking {
self.find_available_column(
next_column,
None,
None,
Some(Self::get_next_column),
None,
&[&pattern],
)
} else {
self.find_available_column(
next_column,
None,
None,
Some(Self::get_next_column),
None,
&[&pattern, self.prev_pattern],
)
};
pattern.add_note(self, next_column);
}
pattern
}
fn get_next_column(&mut self, mut last: u8) -> u8 {
if self.convert_type.contains(PatternType::GATHERED) {
last += 1;
if last == self.total_columns as u8 {
last = self.random_start() as u8;
}
} else {
last = PatternGenerator::get_random_column(self, None, None);
}
last
}
fn has_special_column(&self) -> bool {
self.sample.clap() && self.sample.finish()
}
fn generate_random_pattern(&mut self, p2: f64, p3: f64, p4: f64, p5: f64) -> Pattern {
let random_note_count = self.get_random_note_count(p2, p3, p4, p5);
let mut pattern = self.generate_random_notes(random_note_count);
if self.random_start() > 0 && self.has_special_column() {
pattern.add_note(self, 0);
}
pattern
}
fn get_random_note_count(&mut self, mut p2: f64, mut p3: f64, mut p4: f64, mut p5: f64) -> i32 {
match self.total_columns {
2 => {
p2 = 0.0;
p3 = 0.0;
p4 = 0.0;
p5 = 0.0;
}
3 => {
p2 = p2.min(0.1);
p3 = 0.0;
p4 = 0.0;
p5 = 0.0;
}
4 => {
p2 = p2.min(0.23);
p3 = p3.min(0.04);
p4 = 0.0;
p5 = 0.0;
}
5 => {
p3 = p3.min(0.15);
p4 = p4.min(0.03);
p5 = 0.0;
}
_ => {}
}
if self.sample.clap() {
p2 = 1.0;
}
PatternGenerator::get_random_note_count(self, p2, p3, Some(p4), Some(p5), None)
}
fn generate_random_pattern_with_mirrored(
&mut self,
centre_probability: f64,
p2: f64,
p3: f64,
) -> Pattern {
if self.convert_type.contains(PatternType::FORCE_NOT_STACK) {
return self.generate_random_pattern(1.0 / 2.0 + p2 / 2.0, p2, (p2 + p3) / 2.0, p3);
}
let mut pattern = Pattern::default();
let (note_count, add_to_centre) =
self.get_random_note_count_mirrored(centre_probability, p2, p3);
let column_limit = if self.total_columns % 2 == 0 {
self.total_columns / 2
} else {
(self.total_columns - 1) / 2
};
let mut next_column = PatternGenerator::get_random_column(self, None, Some(column_limit));
for _ in 0..note_count {
next_column = self.find_available_column(
next_column,
None,
Some(column_limit),
None,
None,
&[&pattern],
);
// * Add normal note
pattern.add_note(self, next_column);
// * Add mirrored note
let column = (self.random_start() + self.total_columns) as u8 - next_column - 1;
pattern.add_note(self, column);
}
if add_to_centre {
pattern.add_note(self, self.total_columns as u8 / 2);
}
if self.random_start() > 0 && self.has_special_column() {
pattern.add_note(self, 0);
}
pattern
}
fn get_random_note_count_mirrored(
&mut self,
mut centre_probability: f64,
mut p2: f64,
mut p3: f64,
) -> (i32, bool) {
match self.total_columns {
2 => {
centre_probability = 0.0;
p2 = 0.0;
p3 = 0.0;
}
3 => {
centre_probability = centre_probability.min(0.03);
p2 = 0.0;
p3 = 0.0;
}
4 => {
centre_probability = 0.0;
// * Stable requires rngValue > x, which is an inverse-probability. Lazer uses true probability (1 - x).
// * But multiplying this value by 2 (stable) is not the same operation as dividing it by 2 (lazer),
// * so it needs to be converted to from a probability and then back after the multiplication.
p2 = 1.0 - ((1.0 - p2) * 2.0).max(0.8);
p3 = 0.0;
}
5 => {
centre_probability = centre_probability.min(0.03);
p3 = 0.0;
}
6 => {
centre_probability = 0.0;
// * Stable requires rngValue > x, which is an inverse-probability. Lazer uses true probability (1 - x).
// * But multiplying this value by 2 (stable) is not the same operation as dividing it by 2 (lazer),
// * so it needs to be converted to from a probability and then back after the multiplication.
p2 = 1.0 - ((1.0 - p2) * 2.0).max(0.05);
p3 = 1.0 - ((1.0 - p3) * 2.0).max(0.85);
}
_ => {}
}
// * The stable values were allowed to exceed 1, which indicate <0% probability.
// * These values needs to be clamped otherwise GetRandomNoteCount() will throw an exception.
p2 = p2.clamp(0.0, 1.0);
p3 = p3.clamp(0.0, 1.0);
let centre_val = self.random.gen_double();
let note_count = PatternGenerator::get_random_note_count(self, p2, p3, None, None, None);
let add_to_centre =
self.total_columns % 2 != 0 && note_count != 3 && centre_val > 1.0 - centre_probability;
(note_count, add_to_centre)
}
}
impl PatternGenerator for HitObjectPatternGenerator<'_> {
#[inline]
fn hit_object(&self) -> &HitObject {
self.hit_object
}
#[inline]
fn total_columns(&self) -> i32 {
self.total_columns
}
#[inline]
fn random(&mut self) -> &mut Random {
self.random
}
#[inline]
fn original_map(&self) -> &Beatmap {
self.orig
}
}
@@ -0,0 +1,141 @@
use crate::{parse::HitObject, Beatmap};
use super::{legacy_random::Random, pattern::Pattern};
pub(super) mod distance_object;
pub(super) mod end_time_object;
pub(super) mod hit_object;
trait PatternGenerator {
fn hit_object(&self) -> &HitObject;
fn total_columns(&self) -> i32;
fn random(&mut self) -> &mut Random;
fn original_map(&self) -> &Beatmap;
// ----------------------------------
fn random_start(&self) -> i32 {
(self.total_columns() == 8) as i32
}
fn get_column(&self, allow_special: Option<bool>) -> u8 {
let allow_special = allow_special.unwrap_or(false);
let res = if allow_special && self.total_columns() == 8 {
const LOCAL_X_DIVISOR: f32 = 512.0 / 7.0;
((self.hit_object().pos.x / LOCAL_X_DIVISOR).floor() as u8).clamp(0, 6) + 1
} else {
self.hit_object().column(self.total_columns() as f32)
};
res
}
fn get_random_note_count(
&mut self,
p2: f64,
p3: f64,
p4: Option<f64>,
p5: Option<f64>,
p6: Option<f64>,
) -> i32 {
let p4 = p4.unwrap_or(0.0);
let p5 = p5.unwrap_or(0.0);
let p6 = p6.unwrap_or(0.0);
let val = self.random().gen_double();
if val >= 1.0 - p6 {
6
} else if val >= 1.0 - p5 {
5
} else if val >= 1.0 - p4 {
4
} else if val >= 1.0 - p3 {
3
} else {
1 + (val >= 1.0 - p2) as i32
}
}
fn conversion_difficulty(&self) -> f64 {
let orig = self.original_map();
let last_obj_time = orig.hit_objects.last().map_or(0.0, |h| h.start_time);
let first_obj_time = orig.hit_objects.first().map_or(0.0, |h| h.start_time);
// * Drain time in seconds
let total_break_time = orig.total_break_time();
let mut drain_time = ((last_obj_time - first_obj_time - total_break_time) / 1000.0) as i32;
if drain_time == 0 {
drain_time = 10_000;
}
let mut conversion_difficulty = 0.0;
conversion_difficulty += (orig.hp + orig.ar.clamp(4.0, 7.0)) as f64 / 1.5;
conversion_difficulty += orig.hit_objects.len() as f64 / drain_time as f64 * 9.0;
conversion_difficulty /= 38.0;
conversion_difficulty *= 5.0;
conversion_difficulty /= 1.15;
conversion_difficulty = conversion_difficulty.min(12.0);
conversion_difficulty
}
fn get_random_column(&mut self, lower: Option<i32>, upper: Option<i32>) -> u8 {
let lower = lower.unwrap_or_else(|| self.random_start());
let upper = upper.unwrap_or_else(|| self.total_columns());
self.random().gen_int_range(lower, upper) as u8
}
fn find_available_column(
&mut self,
mut initial_column: u8,
lower: Option<i32>,
upper: Option<i32>,
next_column: Option<fn(&mut Self, u8) -> u8>,
validation: Option<&dyn Fn(i32) -> bool>,
patterns: &[&Pattern],
) -> u8 {
let lower = lower.unwrap_or_else(|| self.random_start());
let upper = upper.unwrap_or_else(|| self.total_columns());
let is_valid = |column: i32| {
if let Some(fun) = validation {
if !(fun)(column) {
return false;
}
}
let column = column as u8;
patterns
.iter()
.all(|pattern| !pattern.column_has_obj(column))
};
// * Check for the initial column
if is_valid(initial_column as i32) {
return initial_column;
}
// * Ensure that we have at least one free column, so that an endless loop is avoided
let has_valid_column = (lower..upper).any(is_valid);
assert!(has_valid_column);
// * Iterate until a valid column is found. This is a random iteration in the default case.
while {
initial_column = if let Some(fun) = next_column {
(fun)(self, initial_column)
} else {
PatternGenerator::get_random_column(self, Some(lower), Some(upper))
};
!is_valid(initial_column as i32)
} {}
initial_column
}
}
+104
View File
@@ -0,0 +1,104 @@
use std::{
fmt,
ops::{BitAndAssign, BitOr, BitOrAssign, Not},
};
#[derive(Copy, Clone, Default)]
pub(crate) struct PatternType(u16);
#[rustfmt::skip]
impl PatternType {
pub(crate) const FORCE_STACK: Self = Self(1 << 0);
pub(crate) const FORCE_NOT_STACK: Self = Self(1 << 1);
pub(crate) const KEEP_SINGLE: Self = Self(1 << 2);
pub(crate) const LOW_PROBABILITY: Self = Self(1 << 3);
// pub(crate) const ALTERNATE: Self = Self(1 << 4);
// pub(crate) const FORCE_SIG_SLIDER: Self = Self(1 << 5);
// pub(crate) const FORCE_NOT_SLIDER: Self = Self(1 << 6);
pub(crate) const GATHERED: Self = Self(1 << 7);
pub(crate) const MIRROR: Self = Self(1 << 8);
pub(crate) const REVERSE: Self = Self(1 << 9);
pub(crate) const CYCLE: Self = Self(1 << 10);
pub(crate) const STAIR: Self = Self(1 << 11);
pub(crate) const REVERSE_STAIR: Self = Self(1 << 12);
}
impl fmt::Display for PatternType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut written = false;
macro_rules! write_pattern {
($self:ident, $f:ident, $written:ident: $($pat:ident,)*) => {
$(
if $self.contains(Self::$pat) {
if $written {
$f.write_str(" | ")?;
} else {
$written = true;
}
$f.write_str(stringify!($pat))?;
}
)*
}
}
write_pattern! {
self, f, written:
FORCE_STACK,
FORCE_NOT_STACK,
KEEP_SINGLE,
LOW_PROBABILITY,
GATHERED,
MIRROR,
REVERSE,
CYCLE,
STAIR,
REVERSE_STAIR,
}
if !written {
f.write_str("NONE")?;
}
Ok(())
}
}
impl PatternType {
pub(crate) fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
}
impl BitOr for PatternType {
type Output = Self;
#[inline]
fn bitor(self, rhs: Self) -> Self::Output {
Self(self.0 | rhs.0)
}
}
impl BitOrAssign for PatternType {
#[inline]
fn bitor_assign(&mut self, rhs: Self) {
self.0 |= rhs.0;
}
}
impl BitAndAssign for PatternType {
#[inline]
fn bitand_assign(&mut self, rhs: Self) {
self.0 &= rhs.0;
}
}
impl Not for PatternType {
type Output = Self;
#[inline]
fn not(self) -> Self::Output {
Self(!self.0)
}
}
+2
View File
@@ -0,0 +1,2 @@
mod mania;
mod taiko;
+148
View File
@@ -0,0 +1,148 @@
use std::cmp::Ordering;
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind},
Beatmap,
};
const LEGACY_TAIKO_VELOCITY_MULTIPLIER: f32 = 1.4;
const OSU_BASE_SCORING_DIST: f32 = 100.0;
impl Beatmap {
pub(in crate::beatmap) fn convert_to_taiko(&self) -> Self {
let mut map = self.clone_without_hit_objects(true);
let mut curve_bufs = CurveBuffers::default();
for (obj, sound) in self.hit_objects.iter().zip(self.sounds.iter()) {
match obj.kind {
HitObjectKind::Circle => {
map.hit_objects.push(obj.to_owned());
map.sounds.push(*sound);
map.n_circles += 1;
}
HitObjectKind::Slider {
pixel_len,
repeats,
ref control_points,
ref edge_sounds,
} => {
let curve = Curve::new(control_points, pixel_len, &mut curve_bufs);
let mut params = SliderParams::new(obj.start_time, repeats, &curve);
if self.should_convert_slider_to_taiko_hits(&mut params) {
let mut i = 0;
let mut j = obj.start_time;
let edge_sound_count = edge_sounds.len().max(1);
while j <= obj.start_time + params.duration + params.tick_spacing / 8.0 {
let h = HitObject {
pos: Default::default(),
start_time: j,
kind: HitObjectKind::Circle,
};
map.hit_objects.push(h);
map.sounds.push(*edge_sounds.get(i).unwrap_or(sound));
map.n_circles += 1;
if params.tick_spacing.abs() <= f64::EPSILON {
break;
}
j += params.tick_spacing;
i = (i + 1) % edge_sound_count;
}
} else {
map.hit_objects.push(obj.to_owned());
map.n_sliders += 1;
}
}
HitObjectKind::Spinner { .. } => {
map.hit_objects.push(obj.to_owned());
map.sounds.push(*sound);
map.n_spinners += 1;
}
// Pathological case; shouldn't realistically happen
HitObjectKind::Hold { end_time } => {
let obj = HitObject {
pos: obj.pos,
start_time: obj.start_time,
kind: HitObjectKind::Spinner { end_time },
};
map.hit_objects.push(obj);
map.sounds.push(*sound);
map.n_spinners += 1;
}
}
}
// We only convert STD to TKO so we don't need to remove objects
// with the same timestamp that would appear only in MNA
map.hit_objects
.sort_by(|p1, p2| p1.partial_cmp(p2).unwrap_or(Ordering::Equal));
map
}
fn should_convert_slider_to_taiko_hits(&self, params: &mut SliderParams<'_>) -> bool {
let SliderParams {
curve,
duration,
repeats,
start_time,
tick_spacing,
} = params;
// * The true distance, accounting for any repeats. This ends up being the drum roll distance later
let spans = (*repeats + 1) as f64;
let dist = curve.dist() * spans * LEGACY_TAIKO_VELOCITY_MULTIPLIER as f64;
let timing_point = self.timing_point_at(*start_time);
let difficulty_point = self.difficulty_point_at(*start_time).unwrap_or_default();
let mut beat_len = timing_point.beat_len / difficulty_point.speed_multiplier;
let slider_scoring_point_dist =
OSU_BASE_SCORING_DIST as f64 * self.slider_mult / self.tick_rate;
// * The velocity and duration of the taiko hit object - calculated as the velocity of a drum roll.
let taiko_vel = slider_scoring_point_dist * self.tick_rate;
*duration = (dist / taiko_vel * beat_len).floor();
let osu_vel = taiko_vel * (1000.0_f32 as f64 / beat_len);
// * osu-stable always uses the speed-adjusted beatlength to determine the osu! velocity, but only uses it for conversion if beatmap version < 8
if self.version >= 8 {
beat_len = timing_point.beat_len;
}
// * If the drum roll is to be split into hit circles, assume the ticks are 1/8 spaced within the duration of one beat
*tick_spacing = (beat_len / self.tick_rate).min(*duration / spans);
*tick_spacing > 0.0 && dist / osu_vel * 1000.0 < 2.0 * beat_len
}
}
struct SliderParams<'c> {
curve: &'c Curve,
duration: f64,
repeats: usize,
start_time: f64,
tick_spacing: f64,
}
impl<'c> SliderParams<'c> {
fn new(start_time: f64, repeats: usize, curve: &'c Curve) -> Self {
Self {
curve,
repeats,
start_time,
duration: 0.0,
tick_spacing: 0.0,
}
}
}
+77
View File
@@ -1,13 +1,18 @@
use std::{borrow::Cow, cmp::Ordering};
use crate::parse::HitObject;
pub use self::{
attributes::BeatmapAttributes,
breaks::Break,
control_points::{ControlPoint, ControlPointIter, DifficultyPoint, TimingPoint},
mode::GameMode,
};
mod attributes;
mod breaks;
mod control_points;
mod converts;
mod mode;
/// The main beatmap struct containing all data relevant
@@ -53,6 +58,9 @@ pub struct Beatmap {
/// The stack leniency that is used to calculate
/// the stack offset for stacked positions.
pub stack_leniency: f32,
/// All break points of the beatmap.
pub breaks: Vec<Break>,
}
impl Beatmap {
@@ -76,4 +84,73 @@ impl Beatmap {
pub fn control_points(&self) -> ControlPointIter<'_> {
ControlPointIter::new(self)
}
/// Sum up the duration of all breaks (in milliseconds).
#[inline]
pub fn total_break_time(&self) -> f64 {
self.breaks.iter().map(Break::duration).sum()
}
/// Return the [`TimingPoint`] for the given timestamp.
///
/// If `time` is before the first timing point, `None` is returned.
#[inline]
pub fn timing_point_at(&self, time: f64) -> TimingPoint {
let idx_result = self
.timing_points
.binary_search_by(|probe| probe.time.partial_cmp(&time).unwrap_or(Ordering::Less));
match idx_result {
Ok(idx) => self.timing_points[idx],
Err(0) => self.timing_points.first().copied().unwrap_or_default(),
Err(idx) => self.timing_points[idx - 1],
}
}
/// Return the [`DifficultyPoint`] for the given timestamp.
///
/// If `time` is before the first difficulty point, `None` is returned.
#[inline]
pub fn difficulty_point_at(&self, time: f64) -> Option<DifficultyPoint> {
self.difficulty_points
.binary_search_by(|probe| probe.time.partial_cmp(&time).unwrap_or(Ordering::Less))
.map_or_else(|i| i.checked_sub(1), Some)
.map(|i| self.difficulty_points[i])
}
/// Convert a [`Beatmap`] of some mode into a different mode.
#[inline]
pub fn convert_mode(&self, mode: GameMode) -> Cow<'_, Self> {
if mode == self.mode {
return Cow::Borrowed(self);
}
match mode {
GameMode::STD | GameMode::CTB => Cow::Borrowed(self),
GameMode::TKO => Cow::Owned(self.convert_to_taiko()),
GameMode::MNA => Cow::Owned(self.convert_to_mania()),
}
}
fn clone_without_hit_objects(&self, with_sounds: bool) -> Self {
Self {
mode: self.mode,
version: self.version,
n_circles: 0,
n_sliders: 0,
n_spinners: 0,
ar: self.ar,
od: self.od,
cs: self.cs,
hp: self.hp,
slider_mult: self.slider_mult,
tick_rate: self.tick_rate,
hit_objects: Vec::with_capacity(self.hit_objects.len()),
sounds: Vec::with_capacity((with_sounds as usize) * self.sounds.len()),
timing_points: self.timing_points.clone(),
difficulty_points: self.difficulty_points.clone(),
stack_leniency: self.stack_leniency,
breaks: self.breaks.clone(),
}
}
}
+1
View File
@@ -49,6 +49,7 @@ impl FruitOrJuice {
pixel_len,
repeats,
control_points,
..
} => {
// HR business
params.last_pos = Some(h.pos.x + control_points[control_points.len() - 1].pos.x);
+23 -2
View File
@@ -16,7 +16,9 @@ use movement::Movement;
pub use pp::*;
use slider_state::SliderState;
use crate::{catch::fruit_or_juice::FruitParams, curve::CurveBuffers, Beatmap, Mods, Strains};
use crate::{
catch::fruit_or_juice::FruitParams, curve::CurveBuffers, Beatmap, Mods, OsuStars, Strains,
};
const SECTION_LENGTH: f64 = 750.0;
const STAR_SCALING_FACTOR: f64 = 0.153;
@@ -128,7 +130,6 @@ fn calculate_movement(params: CatchStars<'_>) -> (Movement, CatchDifficultyAttri
} = params;
let take = passed_objects.unwrap_or(usize::MAX);
let map_attributes = map.attributes().mods(mods);
let clock_rate = clock_rate.unwrap_or(map_attributes.clock_rate);
@@ -274,7 +275,27 @@ impl CatchPerformanceAttributes {
}
impl From<CatchPerformanceAttributes> for CatchDifficultyAttributes {
#[inline]
fn from(attributes: CatchPerformanceAttributes) -> Self {
attributes.difficulty
}
}
impl<'map> From<OsuStars<'map>> for CatchStars<'map> {
#[inline]
fn from(osu: OsuStars<'map>) -> Self {
let OsuStars {
map,
mods,
passed_objects,
clock_rate,
} = osu;
Self {
map,
mods,
passed_objects,
clock_rate,
}
}
}
+38 -1
View File
@@ -1,5 +1,5 @@
use super::{CatchDifficultyAttributes, CatchPerformanceAttributes, CatchScoreState, CatchStars};
use crate::{Beatmap, DifficultyAttributes, Mods, PerformanceAttributes};
use crate::{Beatmap, DifficultyAttributes, Mods, OsuPP, PerformanceAttributes};
/// Performance calculator on osu!catch maps.
///
@@ -431,6 +431,43 @@ impl CatchPPInner {
}
}
impl<'map> From<OsuPP<'map>> for CatchPP<'map> {
fn from(osu: OsuPP<'map>) -> Self {
let OsuPP {
map,
mods,
acc,
combo,
n300,
n100,
n50,
n_misses,
passed_objects,
clock_rate,
..
} = osu;
let res = Self {
map,
attributes: None,
mods,
combo,
n_fruits: n300,
n_droplets: n100,
n_tiny_droplets: n50,
n_tiny_droplet_misses: None,
n_misses,
passed_objects,
clock_rate,
};
match acc {
Some(acc) => res.accuracy(acc),
None => res,
}
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait CatchAttributeProvider {
/// Provide the actual difficulty attributes.
+5
View File
@@ -56,24 +56,29 @@ mod test {
TimingPoint {
time: 1.0,
beat_len: 10.0,
kiai: false,
},
TimingPoint {
time: 3.0,
beat_len: 20.0,
kiai: false,
},
TimingPoint {
time: 4.0,
beat_len: 30.0,
kiai: false,
},
],
difficulty_points: vec![
DifficultyPoint {
time: 2.0,
speed_multiplier: 15.0,
kiai: false,
},
DifficultyPoint {
time: 5.0,
speed_multiplier: 45.0,
kiai: false,
},
],
..Default::default()
+1
View File
@@ -268,6 +268,7 @@ impl From<ScoreState> for TaikoScoreState {
/// assert!(gradual_perf.process_next_object(state).is_none());
/// ```
#[derive(Clone, Debug)]
#[allow(clippy::large_enum_variant)]
pub enum GradualPerformanceAttributes<'map> {
/// Gradual osu!catch performance attributes.
Catch(CatchGradualPerformanceAttributes<'map>),
+1 -2
View File
@@ -1,7 +1,5 @@
//! A standalone crate to calculate star ratings and performance points for all [osu!](https://osu.ppy.sh/home) gamemodes.
//!
//! Conversions between game modes (i.e. "converts") are generally not supported.
//!
//! Async is supported through features, see below.
//!
//! ## Usage
@@ -208,6 +206,7 @@ mod stars;
pub use stars::AnyStars;
mod curve;
mod limited_queue;
mod mods;
pub use catch::{CatchPP, CatchStars};
@@ -3,6 +3,7 @@ use std::iter::{Cycle, Skip, Take};
use std::ops::Index;
use std::slice::Iter;
// TODO: make generic over const size
#[derive(Clone, Debug)]
pub(crate) struct LimitedQueue<T> {
queue: Vec<T>,
@@ -90,6 +91,7 @@ impl<T> Index<usize> for LimitedQueue<T> {
}
}
// TODO: replace with simple type definition
pub(crate) struct LimitedQueueIter<'a, T> {
iter: Take<Skip<Cycle<Iter<'a, T>>>>,
}
+26 -7
View File
@@ -3,12 +3,14 @@ mod gradual_performance;
mod pp;
mod strain;
use std::borrow::Cow;
pub use gradual_difficulty::*;
pub use gradual_performance::*;
pub use pp::*;
use strain::Strain;
use crate::{parse::HitObject, Beatmap, GameMode, Mods, Strains};
use crate::{parse::HitObject, Beatmap, GameMode, Mods, OsuStars, Strains};
const SECTION_LEN: f64 = 400.0;
const STAR_SCALING_FACTOR: f64 = 0.018;
@@ -33,7 +35,7 @@ const STAR_SCALING_FACTOR: f64 = 0.018;
/// ```
#[derive(Clone, Debug)]
pub struct ManiaStars<'map> {
map: &'map Beatmap,
map: Cow<'map, Beatmap>,
mods: u32,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
@@ -44,7 +46,7 @@ impl<'map> ManiaStars<'map> {
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map,
map: Cow::Borrowed(map),
mods: 0,
passed_objects: None,
clock_rate: None,
@@ -189,12 +191,9 @@ pub(crate) struct DifficultyHitObject<'o> {
impl<'o> DifficultyHitObject<'o> {
#[inline]
fn new(base: &'o HitObject, prev: &'o HitObject, columns: f32, clock_rate: f64) -> Self {
let x_divisor = 512.0 / columns;
let column = (base.pos.x / x_divisor).floor().min(columns - 1.0) as usize;
Self {
base,
column,
column: base.column(columns) as usize,
delta: (base.start_time - prev.start_time) / clock_rate,
start_time: base.start_time / clock_rate,
}
@@ -236,7 +235,27 @@ impl ManiaPerformanceAttributes {
}
impl From<ManiaPerformanceAttributes> for ManiaDifficultyAttributes {
#[inline]
fn from(attributes: ManiaPerformanceAttributes) -> Self {
attributes.difficulty
}
}
impl<'map> From<OsuStars<'map>> for ManiaStars<'map> {
#[inline]
fn from(osu: OsuStars<'map>) -> Self {
let OsuStars {
map,
mods,
passed_objects,
clock_rate,
} = osu;
Self {
map: map.convert_mode(GameMode::MNA),
mods,
passed_objects,
clock_rate,
}
}
}
+41 -5
View File
@@ -1,5 +1,7 @@
use std::borrow::Cow;
use super::{ManiaDifficultyAttributes, ManiaPerformanceAttributes, ManiaStars};
use crate::{Beatmap, DifficultyAttributes, Mods, PerformanceAttributes};
use crate::{Beatmap, DifficultyAttributes, GameMode, Mods, OsuPP, PerformanceAttributes};
/// Performance calculator on osu!mania maps.
///
@@ -31,7 +33,7 @@ use crate::{Beatmap, DifficultyAttributes, Mods, PerformanceAttributes};
#[derive(Clone, Debug)]
#[allow(clippy::upper_case_acronyms)]
pub struct ManiaPP<'map> {
map: &'map Beatmap,
map: Cow<'map, Beatmap>,
stars: Option<f64>,
mods: u32,
pub(crate) score: Option<f64>,
@@ -44,7 +46,7 @@ impl<'map> ManiaPP<'map> {
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map,
map: Cow::Borrowed(map),
stars: None,
mods: 0,
score: None,
@@ -112,7 +114,7 @@ impl<'map> ManiaPP<'map> {
/// Calculate all performance related values, including pp and stars.
pub fn calculate(self) -> ManiaPerformanceAttributes {
let stars = self.stars.unwrap_or_else(|| {
let mut calculator = ManiaStars::new(self.map).mods(self.mods);
let mut calculator = ManiaStars::new(self.map.as_ref()).mods(self.mods);
if let Some(passed_objects) = self.passed_objects {
calculator = calculator.passed_objects(passed_objects);
@@ -154,7 +156,19 @@ impl<'map> ManiaPP<'map> {
od *= 1.4;
}
let hit_window = ((od * clock_rate).floor() / clock_rate).ceil();
let hit_window = {
let not_converted = matches!(self.map, Cow::Borrowed(_));
let value = if not_converted {
od
} else if self.map.od > 4.0 {
34.0
} else {
47.0
};
((value * clock_rate).floor() / clock_rate).ceil()
};
let strain_value = self.compute_strain(scaled_score, stars);
let acc_value = self.compute_accuracy_value(scaled_score, strain_value, hit_window);
@@ -199,6 +213,28 @@ impl<'map> ManiaPP<'map> {
}
}
impl<'map> From<OsuPP<'map>> for ManiaPP<'map> {
#[inline]
fn from(osu: OsuPP<'map>) -> Self {
let OsuPP {
map,
mods,
passed_objects,
clock_rate,
..
} = osu;
Self {
map: map.convert_mode(GameMode::MNA),
stars: None,
mods,
score: None,
passed_objects,
clock_rate,
}
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait ManiaAttributeProvider {
/// Provide the star rating (only difficulty attribute for osu!mania).
+17 -5
View File
@@ -20,7 +20,7 @@ use skill::Skill;
use skill_kind::SkillKind;
use slider_state::SliderState;
use crate::{curve::CurveBuffers, Beatmap, Mods, Strains};
use crate::{curve::CurveBuffers, AnyStars, Beatmap, GameMode, Mods, Strains};
use self::skill::Skills;
@@ -49,10 +49,10 @@ const STACK_DISTANCE: f32 = 3.0;
/// ```
#[derive(Clone, Debug)]
pub struct OsuStars<'map> {
map: &'map Beatmap,
mods: u32,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
pub(crate) map: &'map Beatmap,
pub(crate) mods: u32,
pub(crate) passed_objects: Option<usize>,
pub(crate) clock_rate: Option<f64>,
}
impl<'map> OsuStars<'map> {
@@ -67,6 +67,17 @@ impl<'map> OsuStars<'map> {
}
}
/// Convert the map into another mode.
#[inline]
pub fn mode(self, mode: GameMode) -> AnyStars<'map> {
match mode {
GameMode::STD => AnyStars::Osu(self),
GameMode::TKO => AnyStars::Taiko(self.into()),
GameMode::CTB => AnyStars::Catch(self.into()),
GameMode::MNA => AnyStars::Mania(self.into()),
}
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
@@ -578,6 +589,7 @@ impl OsuPerformanceAttributes {
}
impl From<OsuPerformanceAttributes> for OsuDifficultyAttributes {
#[inline]
fn from(attributes: OsuPerformanceAttributes) -> Self {
attributes.difficulty
}
+1
View File
@@ -88,6 +88,7 @@ impl OsuObject {
pixel_len,
repeats,
control_points,
..
} => {
attributes.n_sliders += 1;
+19 -6
View File
@@ -1,5 +1,7 @@
use super::{OsuDifficultyAttributes, OsuPerformanceAttributes, OsuScoreState};
use crate::{Beatmap, DifficultyAttributes, Mods, OsuStars, PerformanceAttributes};
use crate::{
AnyPP, Beatmap, DifficultyAttributes, GameMode, Mods, OsuStars, PerformanceAttributes,
};
/// Performance calculator on osu!standard maps.
///
@@ -33,10 +35,10 @@ use crate::{Beatmap, DifficultyAttributes, Mods, OsuStars, PerformanceAttributes
#[derive(Clone, Debug)]
#[allow(clippy::upper_case_acronyms)]
pub struct OsuPP<'map> {
map: &'map Beatmap,
attributes: Option<OsuDifficultyAttributes>,
mods: u32,
acc: Option<f64>,
pub(crate) map: &'map Beatmap,
pub(crate) attributes: Option<OsuDifficultyAttributes>,
pub(crate) mods: u32,
pub(crate) acc: Option<f64>,
pub(crate) combo: Option<usize>,
pub(crate) n300: Option<usize>,
@@ -44,7 +46,7 @@ pub struct OsuPP<'map> {
pub(crate) n50: Option<usize>,
pub(crate) n_misses: usize,
pub(crate) passed_objects: Option<usize>,
clock_rate: Option<f64>,
pub(crate) clock_rate: Option<f64>,
}
impl<'map> OsuPP<'map> {
@@ -67,6 +69,17 @@ impl<'map> OsuPP<'map> {
}
}
/// Convert the map into another mode.
#[inline]
pub fn mode(self, mode: GameMode) -> AnyPP<'map> {
match mode {
GameMode::STD => AnyPP::Osu(self),
GameMode::TKO => AnyPP::Taiko(self.into()),
GameMode::CTB => AnyPP::Catch(self.into()),
GameMode::MNA => AnyPP::Mania(self.into()),
}
}
/// Provide the result of a previous difficulty or performance calculation.
/// If you already calculated the attributes for the current map-mod combination,
/// be sure to put them in here so that they don't have to be recalculated.
+5
View File
@@ -55,24 +55,29 @@ mod test {
TimingPoint {
time: 1.0,
beat_len: 10.0,
kiai: false,
},
TimingPoint {
time: 3.0,
beat_len: 20.0,
kiai: false,
},
TimingPoint {
time: 4.0,
beat_len: 30.0,
kiai: false,
},
],
difficulty_points: vec![
DifficultyPoint {
time: 2.0,
speed_multiplier: 15.0,
kiai: false,
},
DifficultyPoint {
time: 5.0,
speed_multiplier: 45.0,
kiai: false,
},
],
..Default::default()
+91 -80
View File
@@ -1,80 +1,91 @@
use std::cmp::Ordering;
use super::{PathControlPoint, Pos2};
/// "Intermediate" hitobject created through parsing.
/// Each mode will handle them differently.
#[derive(Clone, Debug, PartialEq)]
pub struct HitObject {
/// The position of the object.
pub pos: Pos2,
/// The start time of the object.
pub start_time: f64,
/// The type of the object.
pub kind: HitObjectKind,
}
impl HitObject {
/// The end time of the object.
#[inline]
pub fn end_time(&self) -> f64 {
match &self.kind {
HitObjectKind::Circle { .. } => self.start_time,
// incorrect, only called in mania which has no sliders though
HitObjectKind::Slider { .. } => self.start_time,
HitObjectKind::Spinner { end_time } => *end_time,
HitObjectKind::Hold { end_time, .. } => *end_time,
}
}
/// If the object is a circle.
#[inline]
pub fn is_circle(&self) -> bool {
matches!(self.kind, HitObjectKind::Circle { .. })
}
/// If the object is a slider.
#[inline]
pub fn is_slider(&self) -> bool {
matches!(self.kind, HitObjectKind::Slider { .. })
}
/// If the object is a spinner.
#[inline]
pub fn is_spinner(&self) -> bool {
matches!(self.kind, HitObjectKind::Spinner { .. })
}
}
impl PartialOrd for HitObject {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.start_time.partial_cmp(&other.start_time)
}
}
/// Further data related to specific object types.
#[derive(Clone, Debug, PartialEq)]
pub enum HitObjectKind {
/// A circle object.
Circle,
/// A full slider object.
Slider {
/// Total length of the slider in pixels.
pixel_len: f64,
/// The amount of repeat points of the slider.
repeats: usize,
/// The control points of the slider.
control_points: Vec<PathControlPoint>,
},
/// A spinner object.
Spinner {
/// The end time of the spinner.
end_time: f64,
},
/// A hold note object for osu!mania.
Hold {
/// The end time of the hold object.
end_time: f64,
},
}
use std::cmp::Ordering;
use super::{PathControlPoint, Pos2};
/// "Intermediate" hitobject created through parsing.
/// Each mode will handle them differently.
#[derive(Clone, Debug, PartialEq)]
pub struct HitObject {
/// The position of the object.
pub pos: Pos2,
/// The start time of the object.
pub start_time: f64,
/// The type of the object.
pub kind: HitObjectKind,
}
impl HitObject {
/// The end time of the object.
#[inline]
pub fn end_time(&self) -> f64 {
match &self.kind {
HitObjectKind::Circle { .. } => self.start_time,
// incorrect, only called in mania which has no sliders though
HitObjectKind::Slider { .. } => self.start_time,
HitObjectKind::Spinner { end_time } => *end_time,
HitObjectKind::Hold { end_time, .. } => *end_time,
}
}
/// If the object is a circle.
#[inline]
pub fn is_circle(&self) -> bool {
matches!(self.kind, HitObjectKind::Circle { .. })
}
/// If the object is a slider.
#[inline]
pub fn is_slider(&self) -> bool {
matches!(self.kind, HitObjectKind::Slider { .. })
}
/// If the object is a spinner.
#[inline]
pub fn is_spinner(&self) -> bool {
matches!(self.kind, HitObjectKind::Spinner { .. })
}
/// The column of this node for osu!mania
#[inline]
pub fn column(&self, total_columns: f32) -> u8 {
let x_divisor = 512.0 / total_columns;
(self.pos.x / x_divisor).floor().min(total_columns - 1.0) as u8
}
}
impl PartialOrd for HitObject {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.start_time.partial_cmp(&other.start_time)
}
}
/// Further data related to specific object types.
#[derive(Clone, Debug, PartialEq)]
pub enum HitObjectKind {
/// A circle object.
Circle,
/// A full slider object.
Slider {
/// Total length of the slider in pixels.
pixel_len: f64,
/// The amount of repeat points of the slider.
repeats: usize,
/// The control points of the slider.
control_points: Vec<PathControlPoint>,
/// Sample sounds for the slider head, end, and repeat points.
/// Required for converts.
edge_sounds: Vec<u8>,
},
/// A spinner object.
Spinner {
/// The end time of the spinner.
end_time: f64,
},
/// A hold note object for osu!mania.
Hold {
/// The end time of the hold object.
end_time: f64,
},
}
+168 -157
View File
@@ -12,9 +12,9 @@ pub use pos2::Pos2;
pub use slider_parsing::*;
use reader::FileReader;
use sort::legacy_sort;
pub(crate) use sort::legacy_sort;
use std::cmp::Ordering;
use std::{cmp::Ordering, num::ParseIntError};
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
use std::{fs::File, io::Read};
@@ -28,7 +28,7 @@ use std::path::Path;
#[cfg(feature = "async_std")]
use async_std::{fs::File, io::Read as AsyncRead, path::Path};
use crate::beatmap::{Beatmap, DifficultyPoint, GameMode, TimingPoint};
use crate::beatmap::{Beatmap, Break, DifficultyPoint, GameMode, TimingPoint};
fn sort_unstable<T: PartialOrd>(slice: &mut [T]) {
slice.sort_unstable_by(|p1, p2| p1.partial_cmp(p2).unwrap_or(Ordering::Equal));
@@ -121,28 +121,6 @@ macro_rules! parse_general_body {
}};
}
macro_rules! parse_general {
() => {
fn parse_general<R: Read>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_general_body!(self, reader, section)
}
};
(async) => {
async fn parse_general<R: AsyncRead + Unpin>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_general_body!(self, reader, section)
}
};
}
macro_rules! parse_difficulty_body {
($self:ident, $reader:ident, $section:ident) => {{
let mut ar = None;
@@ -187,26 +165,34 @@ macro_rules! parse_difficulty_body {
}};
}
macro_rules! parse_difficulty {
() => {
fn parse_difficulty<R: Read>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_difficulty_body!(self, reader, section)
}
};
macro_rules! parse_events_body {
($self:ident, $reader:ident, $section:ident) => {{
let mut empty = true;
(async) => {
async fn parse_difficulty<R: AsyncRead + Unpin>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_difficulty_body!(self, reader, section)
while next_line!($reader)? != 0 {
if let Some(bytes) = $reader.get_section() {
*$section = Section::from_bytes(bytes);
empty = false;
break;
}
let line = $reader.get_line()?;
let mut split = line.split(',');
// We're only interested in breaks
if let Some(b'2') = split.next().and_then(|value| value.bytes().next()) {
let start_time = split.next().next_field("break start")?.parse()?;
let end_time = split.next().next_field("break end")?.parse()?;
$self.breaks.push(Break {
start_time,
end_time,
});
}
}
};
Ok(empty)
}};
}
macro_rules! parse_timingpoints_body {
@@ -238,10 +224,20 @@ macro_rules! parse_timingpoints_body {
let beat_len: f64 = split.next().next_field("beat len")?.trim().parse()?;
let timing_change = split.nth(4).and_then(|value| value.bytes().next());
let effect_flags = split.next().and_then(|value| value.bytes().next());
let kiai = matches!(effect_flags, Some(b'1'));
if matches!(timing_change, Some(b'1') | None) {
let beat_len = beat_len.clamp(6.0, 60_000.0);
$self.timing_points.push(TimingPoint { time, beat_len });
let point = TimingPoint {
time,
beat_len,
kiai,
};
$self.timing_points.push(point);
if time < prev_time {
unsorted_timings = true;
@@ -258,6 +254,7 @@ macro_rules! parse_timingpoints_body {
let point = DifficultyPoint {
time,
speed_multiplier,
kiai,
};
$self.difficulty_points.push(point);
@@ -282,28 +279,6 @@ macro_rules! parse_timingpoints_body {
}};
}
macro_rules! parse_timingpoints {
() => {
fn parse_timingpoints<R: Read>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_timingpoints_body!(self, reader, section)
}
};
(async) => {
async fn parse_timingpoints<R: AsyncRead + Unpin>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_timingpoints_body!(self, reader, section)
}
};
}
macro_rules! parse_hitobjects_body {
($self:ident, $reader:ident, $section:ident) => {{
let mut unsorted = false;
@@ -430,10 +405,25 @@ macro_rules! parse_hitobjects_body {
.max(0.0)
.min(MAX_COORDINATE_VALUE);
let edge_sounds_opt = split.next().map(|sounds| {
sounds
.split('|')
.take(repeats + 2)
.map(str::parse)
.collect::<Result<Vec<_>, _>>()
});
let edge_sounds = match edge_sounds_opt {
None => Vec::new(),
Some(Ok(sounds)) => sounds,
Some(Err(err)) => return Err(ParseIntError::into(err)),
};
HitObjectKind::Slider {
repeats,
pixel_len,
control_points,
edge_sounds,
}
}
} else if kind & Self::SPINNER_FLAG > 0 {
@@ -482,28 +472,6 @@ macro_rules! parse_hitobjects_body {
}};
}
macro_rules! parse_hitobjects {
() => {
fn parse_hitobjects<R: Read>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_hitobjects_body!(self, reader, section)
}
};
(async) => {
async fn parse_hitobjects<R: AsyncRead + Unpin>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_hitobjects_body!(self, reader, section)
}
};
}
macro_rules! parse_body {
($input:ident) => {{
let mut reader = FileReader::new($input);
@@ -526,6 +494,7 @@ macro_rules! parse_body {
match section {
Section::General => section!(map, parse_general, reader, section),
Section::Difficulty => section!(map, parse_difficulty, reader, section),
Section::Events => section!(map, parse_events, reader, section),
Section::TimingPoints => section!(map, parse_timingpoints, reader, section),
Section::HitObjects => section!(map, parse_hitobjects, reader, section),
Section::None => {
@@ -544,53 +513,6 @@ macro_rules! parse_body {
}};
}
macro_rules! parse {
() => {
/// Parse a beatmap from a `.osu` file.
///
/// As argument you can give anything that implements [`std::io::Read`].
/// You'll likely want to pass (a reference of) a [`File`](std::fs::File)
/// or the file's content as a slice of bytes (`&[u8]`).
pub fn parse<R: Read>(input: R) -> ParseResult<Self> {
parse_body!(input)
}
};
(async) => {
/// Parse a beatmap from a `.osu` file.
///
/// As argument you can give anything that implements `tokio::io::AsyncRead`
/// or `async_std::io::Read`, depending which feature you chose.
/// You'll likely want to pass a `File`
/// or the file's content as a slice of bytes (`&[u8]`).
pub async fn parse<R: AsyncRead + Unpin>(input: R) -> ParseResult<Self> {
parse_body!(input)
}
};
}
macro_rules! from_path {
() => {
/// Pass the path to a `.osu` file.
///
/// Useful when you don't want to create the [`File`](std::fs::File) manually.
/// If you have the file lying around already though (and plan on re-using it),
/// passing `&file` to [`parse`](Beatmap::parse) should be preferred.
pub fn from_path<P: AsRef<Path>>(path: P) -> ParseResult<Self> {
Self::parse(File::open(path)?)
}
};
(async) => {
/// Pass the path to a `.osu` file.
///
/// Useful when you don't want to create the file manually.
pub async fn from_path<P: AsRef<Path>>(path: P) -> ParseResult<Self> {
Self::parse(File::open(path).await?).await
}
};
}
impl Beatmap {
const CIRCLE_FLAG: u8 = 1 << 0;
const SLIDER_FLAG: u8 = 1 << 1;
@@ -751,35 +673,122 @@ mod slider_parsing {
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
impl Beatmap {
parse!();
parse_general!();
parse_difficulty!();
parse_timingpoints!();
parse_hitobjects!();
/// Parse a beatmap from a `.osu` file.
///
/// As argument you can give anything that implements [`std::io::Read`].
/// You'll likely want to pass (a reference of) a [`File`](std::fs::File)
/// or the file's content as a slice of bytes (`&[u8]`).
pub fn parse<R: Read>(input: R) -> ParseResult<Self> {
parse_body!(input)
}
from_path!();
fn parse_general<R: Read>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_general_body!(self, reader, section)
}
fn parse_difficulty<R: Read>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_difficulty_body!(self, reader, section)
}
fn parse_events<R: Read>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_events_body!(self, reader, section)
}
fn parse_hitobjects<R: Read>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_hitobjects_body!(self, reader, section)
}
fn parse_timingpoints<R: Read>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_timingpoints_body!(self, reader, section)
}
/// Pass the path to a `.osu` file.
///
/// Useful when you don't want to create the [`File`](std::fs::File) manually.
/// If you have the file lying around already though (and plan on re-using it),
/// passing `&file` to [`parse`](Beatmap::parse) should be preferred.
pub fn from_path<P: AsRef<Path>>(path: P) -> ParseResult<Self> {
Self::parse(File::open(path)?)
}
}
#[cfg(feature = "async_tokio")]
#[cfg(any(feature = "async_tokio", feature = "async_std"))]
impl Beatmap {
parse!(async);
parse_general!(async);
parse_difficulty!(async);
parse_timingpoints!(async);
parse_hitobjects!(async);
/// Parse a beatmap from a `.osu` file.
///
/// As argument you can give anything that implements `tokio::io::AsyncRead`
/// or `async_std::io::Read`, depending which feature you chose.
/// You'll likely want to pass a `File`
/// or the file's content as a slice of bytes (`&[u8]`).
pub async fn parse<R: AsyncRead + Unpin>(input: R) -> ParseResult<Self> {
parse_body!(input)
}
from_path!(async);
}
async fn parse_general<R: AsyncRead + Unpin>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_general_body!(self, reader, section)
}
#[cfg(feature = "async_std")]
impl Beatmap {
parse!(async);
parse_general!(async);
parse_difficulty!(async);
parse_timingpoints!(async);
parse_hitobjects!(async);
async fn parse_difficulty<R: AsyncRead + Unpin>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_difficulty_body!(self, reader, section)
}
from_path!(async);
async fn parse_events<R: AsyncRead + Unpin>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_events_body!(self, reader, section)
}
async fn parse_hitobjects<R: AsyncRead + Unpin>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_hitobjects_body!(self, reader, section)
}
async fn parse_timingpoints<R: AsyncRead + Unpin>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_timingpoints_body!(self, reader, section)
}
/// Pass the path to a `.osu` file.
///
/// Useful when you don't want to create the file manually.
pub async fn from_path<P: AsRef<Path>>(path: P) -> ParseResult<Self> {
Self::parse(File::open(path).await?).await
}
}
#[derive(Copy, Clone, Debug)]
@@ -789,6 +798,7 @@ enum Section {
Difficulty,
TimingPoints,
HitObjects,
Events,
}
impl Section {
@@ -798,6 +808,7 @@ impl Section {
b"Difficulty" => Self::Difficulty,
b"TimingPoints" => Self::TimingPoints,
b"HitObjects" => Self::HitObjects,
b"Events" => Self::Events,
_ => Self::None,
}
}
+2 -4
View File
@@ -43,6 +43,7 @@ macro_rules! read_until {
}};
}
#[allow(unused_macro_rules)]
macro_rules! impl_reader {
() => {
impl<R: Read> FileReader<R> {
@@ -96,10 +97,7 @@ macro_rules! impl_reader {
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
impl_reader!();
#[cfg(feature = "async_tokio")]
impl_reader!(async);
#[cfg(feature = "async_std")]
#[cfg(any(feature = "async_tokio", feature = "async_std"))]
impl_reader!(async);
impl<R> FileReader<R> {
+14
View File
@@ -85,6 +85,20 @@ impl<'map> AnyPP<'map> {
}
}
/// If the map is an osu!standard map, convert it to another mode.
#[inline]
pub fn mode(self, mode: GameMode) -> Self {
match self {
AnyPP::Osu(o) => match mode {
GameMode::STD => AnyPP::Osu(o),
GameMode::TKO => AnyPP::Taiko(o.into()),
GameMode::CTB => AnyPP::Catch(o.into()),
GameMode::MNA => AnyPP::Mania(o.into()),
},
other => other,
}
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
+13
View File
@@ -44,6 +44,19 @@ impl<'map> AnyStars<'map> {
}
}
/// If the map is an osu!standard map, convert it to another mode.
pub fn mode(self, mode: GameMode) -> Self {
match self {
AnyStars::Osu(o) => match mode {
GameMode::STD => AnyStars::Osu(o),
GameMode::TKO => AnyStars::Taiko(o.into()),
GameMode::CTB => AnyStars::Catch(o.into()),
GameMode::MNA => AnyStars::Mania(o.into()),
},
other => other,
}
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
+24 -5
View File
@@ -2,7 +2,6 @@ mod difficulty_object;
mod gradual_difficulty;
mod gradual_performance;
mod hitobject_rhythm;
mod limited_queue;
mod pp;
mod rim;
mod skill;
@@ -14,7 +13,6 @@ use difficulty_object::DifficultyObject;
pub use gradual_difficulty::*;
pub use gradual_performance::*;
use hitobject_rhythm::{closest_rhythm, HitObjectRhythm};
use limited_queue::LimitedQueue;
pub use pp::*;
use rim::Rim;
use skill_kind::SkillKind;
@@ -22,8 +20,9 @@ use stamina_cheese::StaminaCheeseDetector;
use taiko_object::IntoTaikoObjectIter;
use crate::taiko::skill::Skills;
use crate::{Beatmap, Mods, Strains};
use crate::{Beatmap, GameMode, Mods, OsuStars, Strains};
use std::borrow::Cow;
use std::cmp::Ordering;
use std::f64::consts::PI;
@@ -53,7 +52,7 @@ const STAMINA_SKILL_MULTIPLIER: f64 = 0.02;
/// ```
#[derive(Clone, Debug)]
pub struct TaikoStars<'map> {
map: &'map Beatmap,
map: Cow<'map, Beatmap>,
mods: u32,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
@@ -64,7 +63,7 @@ impl<'map> TaikoStars<'map> {
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map,
map: Cow::Borrowed(map),
mods: 0,
passed_objects: None,
clock_rate: None,
@@ -331,7 +330,27 @@ impl TaikoPerformanceAttributes {
}
impl From<TaikoPerformanceAttributes> for TaikoDifficultyAttributes {
#[inline]
fn from(attributes: TaikoPerformanceAttributes) -> Self {
attributes.difficulty
}
}
impl<'map> From<OsuStars<'map>> for TaikoStars<'map> {
#[inline]
fn from(osu: OsuStars<'map>) -> Self {
let OsuStars {
map,
mods,
passed_objects,
clock_rate,
} = osu;
Self {
map: map.convert_mode(GameMode::TKO),
mods,
passed_objects,
clock_rate,
}
}
}
+38 -5
View File
@@ -1,5 +1,7 @@
use std::borrow::Cow;
use super::{TaikoDifficultyAttributes, TaikoPerformanceAttributes, TaikoScoreState, TaikoStars};
use crate::{Beatmap, DifficultyAttributes, Mods, PerformanceAttributes};
use crate::{Beatmap, DifficultyAttributes, GameMode, Mods, OsuPP, PerformanceAttributes};
/// Performance calculator on osu!taiko maps.
///
@@ -33,7 +35,7 @@ use crate::{Beatmap, DifficultyAttributes, Mods, PerformanceAttributes};
#[derive(Clone, Debug)]
#[allow(clippy::upper_case_acronyms)]
pub struct TaikoPP<'map> {
map: &'map Beatmap,
map: Cow<'map, Beatmap>,
attributes: Option<TaikoDifficultyAttributes>,
mods: u32,
combo: Option<usize>,
@@ -51,7 +53,7 @@ impl<'map> TaikoPP<'map> {
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map,
map: Cow::Borrowed(map),
attributes: None,
mods: 0,
combo: None,
@@ -171,7 +173,7 @@ impl<'map> TaikoPP<'map> {
/// Calculate all performance related values, including pp and stars.
pub fn calculate(mut self) -> TaikoPerformanceAttributes {
let attributes = self.attributes.take().unwrap_or_else(|| {
let mut calculator = TaikoStars::new(self.map).mods(self.mods);
let mut calculator = TaikoStars::new(self.map.as_ref()).mods(self.mods);
if let Some(passed_objects) = self.passed_objects {
calculator = calculator.passed_objects(passed_objects);
@@ -205,7 +207,7 @@ impl<'map> TaikoPP<'map> {
}
let inner = TaikoPPInner {
map: self.map,
map: self.map.as_ref(),
attributes,
mods: self.mods,
acc: self.acc,
@@ -302,6 +304,37 @@ fn difficulty_range_od(od: f64) -> f64 {
crate::difficulty_range(od, 20.0, 35.0, 50.0)
}
impl<'map> From<OsuPP<'map>> for TaikoPP<'map> {
#[inline]
fn from(osu: OsuPP<'map>) -> Self {
let OsuPP {
map,
mods,
acc,
combo,
n300,
n100,
n_misses,
passed_objects,
clock_rate,
..
} = osu;
Self {
map: map.convert_mode(GameMode::TKO),
attributes: None,
mods,
combo,
acc: acc.unwrap_or(1.0),
passed_objects,
clock_rate,
n300,
n100,
n_misses,
}
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait TaikoAttributeProvider {
/// Provide the actual difficulty attributes.
+3 -1
View File
@@ -1,4 +1,6 @@
use super::{DifficultyObject, HitObjectRhythm, LimitedQueue, Rim};
use crate::limited_queue::LimitedQueue;
use super::{DifficultyObject, HitObjectRhythm, Rim};
use std::ops::Index;
+2 -2
View File
@@ -1,5 +1,5 @@
use super::{LimitedQueue, Rim};
use crate::Beatmap;
use super::Rim;
use crate::{limited_queue::LimitedQueue, Beatmap};
const ROLL_MIN_REPETITIONS: usize = 12;
const TL_MIN_REPETITIONS: isize = 16;