initial rewrite
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
use rosu_map::section::general::GameMode;
|
||||
|
||||
use crate::util::mods::Mods;
|
||||
|
||||
use super::{converted::Converted, Beatmap};
|
||||
|
||||
/// Summary struct for a [`Beatmap`]'s attributes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BeatmapAttributes {
|
||||
/// The approach rate.
|
||||
pub ar: f64,
|
||||
/// The overall difficulty.
|
||||
pub od: f64,
|
||||
/// The circle size.
|
||||
pub cs: f64,
|
||||
/// The health drain rate
|
||||
pub hp: f64,
|
||||
/// The clock rate with respect to mods.
|
||||
pub clock_rate: f64,
|
||||
/// The hit windows for approach rate and overall difficulty.
|
||||
pub hit_windows: HitWindows,
|
||||
}
|
||||
|
||||
/// AR and OD hit windows
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct HitWindows {
|
||||
/// Hit window for approach rate i.e. TimePreempt in milliseconds.
|
||||
pub ar: f64,
|
||||
/// Hit window for overall difficulty i.e. time to hit a 300 ("Great") in milliseconds.
|
||||
pub od: f64,
|
||||
}
|
||||
|
||||
/// A builder for [`BeatmapAttributes`] and [`HitWindows`].
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BeatmapAttributesBuilder {
|
||||
mode: GameMode,
|
||||
ar: f32,
|
||||
od: f32,
|
||||
cs: f32,
|
||||
hp: f32,
|
||||
mods: u32,
|
||||
clock_rate: Option<f64>,
|
||||
is_convert: bool,
|
||||
}
|
||||
|
||||
impl BeatmapAttributesBuilder {
|
||||
const OSU_MIN: f64 = 80.0;
|
||||
const OSU_AVG: f64 = 50.0;
|
||||
const OSU_MAX: f64 = 20.0;
|
||||
|
||||
const TAIKO_MIN: f64 = 50.0;
|
||||
const TAIKO_AVG: f64 = 35.0;
|
||||
const TAIKO_MAX: f64 = 20.0;
|
||||
|
||||
/// Create a new [`BeatmapAttributesBuilder`].
|
||||
pub fn new(map: &Beatmap) -> Self {
|
||||
map.into()
|
||||
}
|
||||
|
||||
const fn new_internal(map: &Beatmap, is_convert: bool) -> Self {
|
||||
Self {
|
||||
mode: map.mode,
|
||||
ar: map.ar,
|
||||
od: map.od,
|
||||
cs: map.cs,
|
||||
hp: map.hp,
|
||||
mods: 0,
|
||||
clock_rate: None,
|
||||
is_convert,
|
||||
}
|
||||
}
|
||||
|
||||
/// Specify the approach rate.
|
||||
pub fn ar(&mut self, ar: f32) -> &mut Self {
|
||||
self.ar = ar;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the overall difficulty.
|
||||
pub fn od(&mut self, od: f32) -> &mut Self {
|
||||
self.od = od;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the circle size.
|
||||
pub fn cs(&mut self, cs: f32) -> &mut Self {
|
||||
self.cs = cs;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the drain rate.
|
||||
pub fn hp(&mut self, hp: f32) -> &mut Self {
|
||||
self.hp = hp;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the mods.
|
||||
pub fn mods(&mut self, mods: u32) -> &mut Self {
|
||||
self.mods = mods;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify a custom clock rate.
|
||||
pub fn clock_rate(&mut self, clock_rate: f64) -> &mut Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Calculate the AR and OD hit windows.
|
||||
pub fn hit_windows(&self) -> HitWindows {
|
||||
let mods = self.mods;
|
||||
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
|
||||
|
||||
let mod_mult = |val: f32| {
|
||||
if mods.hr() {
|
||||
(val * 1.4).min(10.0)
|
||||
} else if mods.ez() {
|
||||
val * 0.5
|
||||
} else {
|
||||
val
|
||||
}
|
||||
};
|
||||
|
||||
let raw_ar = mod_mult(self.ar);
|
||||
let preempt = difficulty_range(f64::from(raw_ar), 1800.0, 1200.0, 450.0) / clock_rate;
|
||||
|
||||
// OD
|
||||
let hit_window = match self.mode {
|
||||
GameMode::Osu | GameMode::Catch => {
|
||||
let raw_od = mod_mult(self.od);
|
||||
|
||||
difficulty_range(
|
||||
f64::from(raw_od),
|
||||
Self::OSU_MIN,
|
||||
Self::OSU_AVG,
|
||||
Self::OSU_MAX,
|
||||
) / clock_rate
|
||||
}
|
||||
GameMode::Taiko => {
|
||||
let raw_od = mod_mult(self.od);
|
||||
|
||||
let diff_range = difficulty_range(
|
||||
f64::from(raw_od),
|
||||
Self::TAIKO_MIN,
|
||||
Self::TAIKO_AVG,
|
||||
Self::TAIKO_MAX,
|
||||
);
|
||||
|
||||
diff_range / clock_rate
|
||||
}
|
||||
GameMode::Mania => {
|
||||
let mut value = if !self.is_convert {
|
||||
34.0 + 3.0 * (10.0 - self.od).clamp(0.0, 10.0)
|
||||
} else if self.od > 4.0 {
|
||||
34.0
|
||||
} else {
|
||||
47.0
|
||||
};
|
||||
|
||||
if mods.hr() {
|
||||
value /= 1.4;
|
||||
} else if mods.ez() {
|
||||
value *= 1.4;
|
||||
}
|
||||
|
||||
((f64::from(value) * clock_rate).floor() / clock_rate).ceil()
|
||||
}
|
||||
};
|
||||
|
||||
HitWindows {
|
||||
ar: preempt,
|
||||
od: hit_window,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the [`BeatmapAttributes`].
|
||||
pub fn build(&self) -> BeatmapAttributes {
|
||||
let mods = self.mods;
|
||||
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
|
||||
|
||||
// HP
|
||||
let hp = (self.hp * mods.od_ar_hp_multiplier() as f32).min(10.0);
|
||||
|
||||
// CS
|
||||
let mut cs = self.cs;
|
||||
|
||||
if mods.hr() {
|
||||
cs = (cs * 1.3).min(10.);
|
||||
} else if mods.ez() {
|
||||
cs *= 0.5;
|
||||
}
|
||||
|
||||
let hit_windows = self.hit_windows();
|
||||
let HitWindows { ar, od } = hit_windows;
|
||||
|
||||
// AR
|
||||
let ar = if ar > 1200.0 {
|
||||
(1800.0 - ar) / 120.0
|
||||
} else {
|
||||
(1200.0 - ar) / 150.0 + 5.0
|
||||
};
|
||||
|
||||
// OD
|
||||
let od = match self.mode {
|
||||
GameMode::Osu => (Self::OSU_MIN - od) / 6.0,
|
||||
GameMode::Taiko => (Self::TAIKO_MIN - od) / (Self::TAIKO_MIN - Self::TAIKO_AVG) * 5.0,
|
||||
GameMode::Catch | GameMode::Mania => f64::from(self.od),
|
||||
};
|
||||
|
||||
BeatmapAttributes {
|
||||
ar,
|
||||
od,
|
||||
cs: f64::from(cs),
|
||||
hp: f64::from(hp),
|
||||
clock_rate,
|
||||
hit_windows,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Beatmap> for BeatmapAttributesBuilder {
|
||||
fn from(map: &Beatmap) -> Self {
|
||||
Self::new_internal(map, false)
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> From<&Converted<'_, M>> for BeatmapAttributesBuilder {
|
||||
fn from(converted: &Converted<'_, M>) -> Self {
|
||||
Self::new_internal(converted.map.as_ref(), converted.is_convert)
|
||||
}
|
||||
}
|
||||
|
||||
fn difficulty_range(difficulty: f64, min: f64, mid: f64, max: f64) -> f64 {
|
||||
if difficulty > 5.0 {
|
||||
mid + (max - mid) * (difficulty - 5.0) / 5.0
|
||||
} else if difficulty < 5.0 {
|
||||
mid - (mid - min) * (5.0 - difficulty) / 5.0
|
||||
} else {
|
||||
mid
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::model::{control_point::TimingPoint, hit_object::HitObject};
|
||||
|
||||
pub fn bpm(last_hit_object: Option<&HitObject>, timing_points: &[TimingPoint]) -> f64 {
|
||||
// This is incorrect if the last object is a slider since there
|
||||
// is no reasonable way to get the slider end time at this point.
|
||||
let last_time = last_hit_object
|
||||
.map(HitObject::end_time)
|
||||
.or_else(|| timing_points.last().map(|t| t.time))
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let mut bpm_points = BeatLenDuration::new(last_time);
|
||||
|
||||
// * osu-stable forced the first control point to start at 0.
|
||||
// * This is reproduced here to maintain compatibility around
|
||||
// * osu!mania scroll speed and song select display.
|
||||
match timing_points {
|
||||
[curr] => bpm_points.add(curr.beat_len, 0.0, last_time),
|
||||
[curr, next, ..] => bpm_points.add(curr.beat_len, 0.0, next.time),
|
||||
[] => {}
|
||||
}
|
||||
|
||||
timing_points
|
||||
.iter()
|
||||
.skip(1)
|
||||
.zip(timing_points.iter().skip(2).map(|t| t.time))
|
||||
.for_each(|(curr, next_time)| bpm_points.add(curr.beat_len, curr.time, next_time));
|
||||
|
||||
if let [.., _, curr] = timing_points {
|
||||
bpm_points.add(curr.beat_len, curr.time, last_time);
|
||||
}
|
||||
|
||||
let most_common_beat_len = bpm_points
|
||||
.map
|
||||
.into_iter()
|
||||
// * Get the most common one, or 0 as a suitable default
|
||||
.max_by(|(_, a), (_, b)| a.total_cmp(b))
|
||||
.map_or(0.0, |(beat_len, _)| f64::from_bits(beat_len));
|
||||
|
||||
60_000.0 / most_common_beat_len
|
||||
}
|
||||
|
||||
/// Maps `beat_len` to a cumulative duration
|
||||
struct BeatLenDuration {
|
||||
last_time: f64,
|
||||
map: HashMap<u64, f64>,
|
||||
}
|
||||
|
||||
impl BeatLenDuration {
|
||||
fn new(last_time: f64) -> Self {
|
||||
Self {
|
||||
last_time,
|
||||
map: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, beat_len: f64, curr_time: f64, next_time: f64) {
|
||||
let beat_len = (1000.0 * beat_len).round() / 1000.0;
|
||||
let entry = self.map.entry(beat_len.to_bits()).or_default();
|
||||
|
||||
if curr_time <= self.last_time {
|
||||
*entry += next_time - curr_time;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use std::{
|
||||
any,
|
||||
borrow::Cow,
|
||||
fmt::{Debug, Formatter, Result as FmtResult},
|
||||
marker::PhantomData,
|
||||
};
|
||||
|
||||
use crate::model::mode::{ConvertStatus, IGameMode};
|
||||
|
||||
use super::{attributes::BeatmapAttributesBuilder, Beatmap};
|
||||
|
||||
const INCOMPATIBLE_MODES: &str = "the gamemodes were incompatible";
|
||||
|
||||
/// A [`Beatmap`] that is attached to a mode.
|
||||
///
|
||||
/// # Incompatibility
|
||||
///
|
||||
/// The following conversions are compatible:
|
||||
/// - `Osu` → `Osu`
|
||||
/// - `Taiko` → `Taiko`
|
||||
/// - `Catch` → `Catch`
|
||||
/// - `Mania` → `Mania`
|
||||
/// - `Osu` → `Taiko`
|
||||
/// - `Osu` → `Catch`
|
||||
/// - `Osu` → `Mania`
|
||||
///
|
||||
/// All other conversions are incompatible.
|
||||
pub struct Converted<'a, M> {
|
||||
pub(crate) map: Cow<'a, Beatmap>,
|
||||
pub(crate) is_convert: bool,
|
||||
mode: PhantomData<M>,
|
||||
}
|
||||
|
||||
impl<'a, M> Converted<'a, M> {
|
||||
/// Initialize a [`Converted`] beatmap by promising the given map's mode
|
||||
/// matches the generic type `M`.
|
||||
pub(crate) const fn new(map: Cow<'a, Beatmap>, is_convert: bool) -> Self {
|
||||
Self {
|
||||
map,
|
||||
is_convert,
|
||||
mode: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: IGameMode> Converted<'_, M> {
|
||||
/// Attempt to convert a [`Beatmap`] to the specified mode.
|
||||
///
|
||||
/// If the conversion is incompatible the [`Beatmap`] will be returned
|
||||
/// unchanged as `Err`.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn try_from_owned(map: Beatmap) -> Result<Self, Beatmap> {
|
||||
let mut map = Cow::Owned(map);
|
||||
|
||||
match M::try_convert(&mut map) {
|
||||
ConvertStatus::Noop => Ok(Self::new(map, false)),
|
||||
ConvertStatus::Done => Ok(Self::new(map, true)),
|
||||
ConvertStatus::Incompatible => {
|
||||
let Cow::Owned(map) = map else { unreachable!() };
|
||||
|
||||
Err(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`Beatmap`] to the specified mode.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the conversion is incompatible.
|
||||
pub fn unchecked_from_owned(map: Beatmap) -> Self {
|
||||
Self::try_from_owned(map).unwrap_or_else(|_| panic!("{}", INCOMPATIBLE_MODES))
|
||||
}
|
||||
|
||||
/// Sum up the duration of all breaks (in milliseconds).
|
||||
pub fn total_break_time(&self) -> f64 {
|
||||
self.map.total_break_time()
|
||||
}
|
||||
|
||||
/// Returns a [`BeatmapAttributesBuilder`] to calculate modified beatmap
|
||||
/// attributes.
|
||||
pub fn attributes(&self) -> BeatmapAttributesBuilder {
|
||||
self.into()
|
||||
}
|
||||
|
||||
/// The beats per minute of the map.
|
||||
pub fn bpm(&self) -> f64 {
|
||||
self.map.bpm()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, M: IGameMode> Converted<'a, M> {
|
||||
/// Borrow the contained [`Beatmap`] to cheaply create a new owned
|
||||
/// [`Converted`].
|
||||
///
|
||||
/// This is the same as `.clone()` except cheap but its lifetime might be
|
||||
/// shorter.
|
||||
#[must_use]
|
||||
pub fn as_owned(&'a self) -> Self {
|
||||
Self::new(Cow::Borrowed(self.map.as_ref()), self.is_convert)
|
||||
}
|
||||
|
||||
/// Attempt to convert a [`&Beatmap`] to the specified mode.
|
||||
///
|
||||
/// If the conversion is incompatible, `None` is returned.
|
||||
///
|
||||
/// [`&Beatmap`]: Beatmap
|
||||
pub fn try_from_ref(map: &'a Beatmap) -> Option<Self> {
|
||||
let mut map = Cow::Borrowed(map);
|
||||
|
||||
match M::try_convert(&mut map) {
|
||||
ConvertStatus::Noop => Some(Self::new(map, false)),
|
||||
ConvertStatus::Done => Some(Self::new(map, true)),
|
||||
ConvertStatus::Incompatible => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`&Beatmap`] to the specified mode.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the conversion is incompatible.
|
||||
///
|
||||
/// [`&Beatmap`]: Beatmap
|
||||
pub fn unchecked_from_ref(map: &'a Beatmap) -> Self {
|
||||
Self::try_from_ref(map).expect(INCOMPATIBLE_MODES)
|
||||
}
|
||||
|
||||
/// Attempt to convert a [`Converted`] from mode `M` to mode `N`.
|
||||
///
|
||||
/// If the conversion is incompatible the [`Converted`] will be returned
|
||||
/// unchanged as `Err`.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn try_convert<N: IGameMode>(self) -> Result<Converted<'a, N>, Self> {
|
||||
match self.map {
|
||||
Cow::Borrowed(map) => Converted::<N>::try_from_ref(map).ok_or(self),
|
||||
Cow::Owned(map) => Converted::<N>::try_from_owned(map)
|
||||
.map_err(|map| Self::new(Cow::Owned(map), self.is_convert)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> Clone for Converted<'_, M> {
|
||||
fn clone(&self) -> Self {
|
||||
Self::new(self.map.clone(), self.is_convert)
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> Debug for Converted<'_, M> {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
struct GenericFormatter<T>(PhantomData<T>);
|
||||
|
||||
impl<T> Default for GenericFormatter<T> {
|
||||
fn default() -> Self {
|
||||
Self(PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Debug for GenericFormatter<T> {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
let full_type_name = any::type_name::<T>();
|
||||
|
||||
// Strip fully qualified syntax
|
||||
if let Some(position) = full_type_name.rfind("::") {
|
||||
if let Some(type_name) = full_type_name.get(position + 2..) {
|
||||
f.write_str(type_name)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
f.debug_struct("Converted")
|
||||
.field("map", &self.map)
|
||||
.field("is_convert", &self.is_convert)
|
||||
.field("mode", &GenericFormatter::<M>::default())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> PartialEq for Converted<'_, M> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.map == other.map && self.is_convert == other.is_convert
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
use std::{cmp, slice};
|
||||
|
||||
use rosu_map::{
|
||||
section::{
|
||||
difficulty::{Difficulty, DifficultyState, ParseDifficultyError},
|
||||
events::{BreakPeriod, EventType, ParseEventTypeError},
|
||||
general::{GameMode, GeneralKey, ParseGameModeError},
|
||||
hit_objects::{
|
||||
hit_samples::{HitSoundType, ParseHitSoundTypeError},
|
||||
HitObjectType, ParseHitObjectTypeError, PathControlPoint, PathType,
|
||||
},
|
||||
timing_points::{ControlPoint, EffectFlags, ParseEffectFlagsError},
|
||||
},
|
||||
util::{KeyValue, ParseNumber, ParseNumberError, Pos, StrExt, MAX_PARSE_VALUE},
|
||||
DecodeBeatmap, DecodeState,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
control_point::{
|
||||
difficulty_point_at, effect_point_at, DifficultyPoint, EffectPoint, TimingPoint,
|
||||
},
|
||||
hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner},
|
||||
},
|
||||
util::{float_ext::FloatExt, legacy_sort::legacy_sort, tandem_sort::TandemSorter},
|
||||
};
|
||||
|
||||
use super::{Beatmap, DEFAULT_SLIDER_LENIENCY};
|
||||
|
||||
/// The state of a [`Beatmap`] for [`DecodeBeatmap`].
|
||||
pub struct BeatmapState {
|
||||
version: i32,
|
||||
stack_leniency: f32,
|
||||
mode: GameMode,
|
||||
difficulty: DifficultyState,
|
||||
breaks: Vec<BreakPeriod>,
|
||||
timing_points: Vec<TimingPoint>,
|
||||
difficulty_points: Vec<DifficultyPoint>,
|
||||
effect_points: Vec<EffectPoint>,
|
||||
hit_objects: Vec<HitObject>,
|
||||
hit_sounds: Vec<HitSoundType>,
|
||||
|
||||
pending_control_points_time: f64,
|
||||
pending_timing_point: Option<TimingPoint>,
|
||||
pending_difficulty_point: Option<DifficultyPoint>,
|
||||
pending_effect_point: Option<EffectPoint>,
|
||||
|
||||
curve_points: Vec<PathControlPoint>,
|
||||
vertices: Vec<PathControlPoint>,
|
||||
point_split: Vec<*const str>,
|
||||
}
|
||||
|
||||
impl BeatmapState {
|
||||
fn add_pending_point<P: Pending>(&mut self, time: f64, point: P, timing_change: bool) {
|
||||
if time.not_eq(self.pending_control_points_time) {
|
||||
self.flush_pending_points();
|
||||
}
|
||||
|
||||
if timing_change {
|
||||
point.push_front(self);
|
||||
} else {
|
||||
point.push_back(self);
|
||||
}
|
||||
|
||||
self.pending_control_points_time = time;
|
||||
}
|
||||
|
||||
fn flush_pending_points(&mut self) {
|
||||
if let Some(point) = self.pending_timing_point.take() {
|
||||
self.add_control_point(point);
|
||||
}
|
||||
|
||||
if let Some(point) = self.pending_difficulty_point.take() {
|
||||
self.add_control_point(point);
|
||||
}
|
||||
|
||||
if let Some(point) = self.pending_effect_point.take() {
|
||||
self.add_control_point(point);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_control_point<P: ControlPoint<Self>>(&mut self, point: P) {
|
||||
if !point.check_already_existing(self) {
|
||||
point.add(self);
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_path_str(&mut self, point_str: &str, offset: Pos) -> Result<(), ParseBeatmapError> {
|
||||
let f = |this: &mut Self, point_split: &[&str]| {
|
||||
let mut start_idx = 0;
|
||||
let mut end_idx = 0;
|
||||
let mut first = true;
|
||||
|
||||
while {
|
||||
end_idx += 1;
|
||||
|
||||
end_idx < point_split.len()
|
||||
} {
|
||||
let is_letter = point_split[end_idx]
|
||||
.chars()
|
||||
.next()
|
||||
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?
|
||||
.is_ascii_alphabetic();
|
||||
|
||||
if !is_letter {
|
||||
continue;
|
||||
}
|
||||
|
||||
let end_point = point_split.get(end_idx + 1).copied();
|
||||
this.convert_points(&point_split[start_idx..end_idx], end_point, first, offset)?;
|
||||
|
||||
start_idx = end_idx;
|
||||
first = false;
|
||||
}
|
||||
|
||||
if end_idx > start_idx {
|
||||
this.convert_points(&point_split[start_idx..end_idx], None, first, offset)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
self.point_split(point_str.split('|'), f)
|
||||
}
|
||||
|
||||
fn convert_points(
|
||||
&mut self,
|
||||
points: &[&str],
|
||||
end_point: Option<&str>,
|
||||
first: bool,
|
||||
offset: Pos,
|
||||
) -> Result<(), ParseBeatmapError> {
|
||||
fn read_point(value: &str, start_pos: Pos) -> Result<PathControlPoint, ParseBeatmapError> {
|
||||
let mut v = value
|
||||
.split(':')
|
||||
.map(|s| s.parse_with_limits(f64::from(MAX_COORDINATE_VALUE)));
|
||||
|
||||
let (x, y) = v
|
||||
.next()
|
||||
.zip(v.next())
|
||||
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
|
||||
|
||||
let pos = Pos::new(x? as i32 as f32, y? as i32 as f32);
|
||||
|
||||
Ok(PathControlPoint::new(pos - start_pos))
|
||||
}
|
||||
|
||||
fn is_linear(p0: Pos, p1: Pos, p2: Pos) -> bool {
|
||||
((p1.y - p0.y) * (p2.x - p0.x)).eq((p1.x - p0.x) * (p2.y - p0.y))
|
||||
}
|
||||
|
||||
let mut path_type = points
|
||||
.first()
|
||||
.copied()
|
||||
.map(PathType::new_from_str)
|
||||
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
|
||||
|
||||
let read_offset = usize::from(first);
|
||||
let readable_points = points.len() - 1;
|
||||
let end_point_len = usize::from(end_point.is_some());
|
||||
|
||||
self.vertices.clear();
|
||||
self.vertices
|
||||
.reserve(read_offset + readable_points + end_point_len);
|
||||
|
||||
if first {
|
||||
self.vertices.push(PathControlPoint::default());
|
||||
}
|
||||
|
||||
for &point in points.iter().skip(1) {
|
||||
self.vertices.push(read_point(point, offset)?);
|
||||
}
|
||||
|
||||
if let Some(end_point) = end_point {
|
||||
self.vertices.push(read_point(end_point, offset)?);
|
||||
}
|
||||
|
||||
if path_type == PathType::PERFECT_CURVE {
|
||||
if let [a, b, c] = self.vertices.as_slice() {
|
||||
if is_linear(a.pos, b.pos, c.pos) {
|
||||
path_type = PathType::LINEAR;
|
||||
}
|
||||
} else {
|
||||
path_type = PathType::BEZIER;
|
||||
}
|
||||
}
|
||||
|
||||
self.vertices[0].path_type = Some(path_type);
|
||||
|
||||
let mut start_idx = 0;
|
||||
let mut end_idx = 0;
|
||||
|
||||
while {
|
||||
end_idx += 1;
|
||||
|
||||
end_idx < self.vertices.len() - end_point_len
|
||||
} {
|
||||
if self.vertices[end_idx].pos != self.vertices[end_idx - 1].pos {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path_type == PathType::CATMULL && end_idx > 1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if end_idx == self.vertices.len() - end_point_len - 1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.vertices[end_idx - 1].path_type = Some(path_type);
|
||||
|
||||
self.curve_points.extend(&self.vertices[start_idx..end_idx]);
|
||||
|
||||
start_idx = end_idx + 1;
|
||||
}
|
||||
|
||||
if end_idx > start_idx {
|
||||
self.curve_points.extend(&self.vertices[start_idx..end_idx]);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn point_split<'a, I, F, O>(&mut self, point_split: I, f: F) -> O
|
||||
where
|
||||
I: Iterator<Item = &'a str>,
|
||||
F: FnOnce(&mut Self, &[&'a str]) -> O,
|
||||
{
|
||||
self.point_split.extend(point_split.map(|s| s as *const _));
|
||||
let ptr = self.point_split.as_ptr();
|
||||
let len = self.point_split.len();
|
||||
|
||||
// SAFETY:
|
||||
// - *const str and &str have the same layout.
|
||||
// - `self.point_split` is cleared after every use, ensuring that it
|
||||
// does not contain any invalid pointers.
|
||||
let point_split = unsafe { slice::from_raw_parts(ptr.cast(), len) };
|
||||
let res = f(self, point_split);
|
||||
self.point_split.clear();
|
||||
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
impl DecodeState for BeatmapState {
|
||||
fn create(version: i32) -> Self {
|
||||
Self {
|
||||
version,
|
||||
stack_leniency: DEFAULT_SLIDER_LENIENCY,
|
||||
mode: GameMode::Osu,
|
||||
difficulty: DifficultyState::create(version),
|
||||
breaks: Vec::new(),
|
||||
timing_points: Vec::with_capacity(1),
|
||||
difficulty_points: Vec::new(),
|
||||
effect_points: Vec::with_capacity(32),
|
||||
hit_objects: Vec::with_capacity(512),
|
||||
hit_sounds: Vec::with_capacity(512),
|
||||
pending_control_points_time: 0.0,
|
||||
pending_timing_point: None,
|
||||
pending_difficulty_point: None,
|
||||
pending_effect_point: None,
|
||||
curve_points: Vec::new(), // TODO: check for default size
|
||||
vertices: Vec::new(), // TODO: check for default size
|
||||
point_split: Vec::new(), // TODO: check for default size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BeatmapState> for Beatmap {
|
||||
fn from(mut state: BeatmapState) -> Self {
|
||||
state.flush_pending_points();
|
||||
|
||||
let Difficulty {
|
||||
hp_drain_rate,
|
||||
circle_size,
|
||||
overall_difficulty,
|
||||
approach_rate,
|
||||
slider_multiplier,
|
||||
slider_tick_rate,
|
||||
} = state.difficulty.into();
|
||||
|
||||
let mut sorter = TandemSorter::new(
|
||||
&state.hit_objects,
|
||||
|a, b| a.start_time.total_cmp(&b.start_time),
|
||||
true,
|
||||
);
|
||||
|
||||
sorter.sort(&mut state.hit_objects);
|
||||
sorter.sort(&mut state.hit_sounds);
|
||||
|
||||
if state.mode == GameMode::Mania {
|
||||
legacy_sort(&mut state.hit_objects);
|
||||
}
|
||||
|
||||
Beatmap {
|
||||
version: state.version,
|
||||
stack_leniency: state.stack_leniency,
|
||||
mode: state.mode,
|
||||
ar: approach_rate,
|
||||
cs: circle_size,
|
||||
hp: hp_drain_rate,
|
||||
od: overall_difficulty,
|
||||
slider_multiplier,
|
||||
slider_tick_rate,
|
||||
breaks: state.breaks,
|
||||
timing_points: state.timing_points,
|
||||
difficulty_points: state.difficulty_points,
|
||||
effect_points: state.effect_points,
|
||||
hit_objects: state.hit_objects,
|
||||
hit_sounds: state.hit_sounds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All the ways that parsing a [`Beatmap`] can fail.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ParseBeatmapError {
|
||||
#[error("failed to parse effect flags")]
|
||||
EffectFlags(#[from] ParseEffectFlagsError),
|
||||
#[error("failed to parse event type")]
|
||||
EventType(#[from] ParseEventTypeError),
|
||||
#[error("failed to parse hit object type")]
|
||||
HitObjectType(#[from] ParseHitObjectTypeError),
|
||||
#[error("failed to parse hit sound type")]
|
||||
HitSoundType(#[from] ParseHitSoundTypeError),
|
||||
#[error("invalid event line")]
|
||||
InvalidEventLine,
|
||||
#[error("repeat count is way too high")]
|
||||
InvalidRepeatCount,
|
||||
#[error("invalid timing point line")]
|
||||
InvalidTimingPointLine,
|
||||
#[error("invalid hit object line")]
|
||||
InvalidHitObjectLine,
|
||||
#[error("failed to parse mode")]
|
||||
Mode(#[from] ParseGameModeError),
|
||||
#[error("failed to parse number")]
|
||||
Number(#[from] ParseNumberError),
|
||||
#[error("beat length cannot be NaN in a timing control point")]
|
||||
TimingControlPointNaN,
|
||||
#[error("unknown hit object type")]
|
||||
UnknownHitObjectType,
|
||||
}
|
||||
|
||||
impl From<ParseDifficultyError> for ParseBeatmapError {
|
||||
fn from(e: ParseDifficultyError) -> Self {
|
||||
match e {
|
||||
ParseDifficultyError::Number(e) => Self::Number(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_COORDINATE_VALUE: i32 = 131_072;
|
||||
|
||||
impl DecodeBeatmap for Beatmap {
|
||||
type Error = ParseBeatmapError;
|
||||
type State = BeatmapState;
|
||||
|
||||
fn parse_general(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
|
||||
let Ok(KeyValue { key, value }) = KeyValue::parse(line.trim_comment()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match key {
|
||||
GeneralKey::StackLeniency => state.stack_leniency = value.parse_num()?,
|
||||
GeneralKey::Mode => state.mode = value.parse()?,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_editor(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_metadata(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_difficulty(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
|
||||
Difficulty::parse_difficulty(&mut state.difficulty, line).map_err(ParseBeatmapError::from)
|
||||
}
|
||||
|
||||
fn parse_events(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
|
||||
let mut split = line.trim_comment().split(',');
|
||||
|
||||
let event_type: EventType = split
|
||||
.next()
|
||||
.ok_or(ParseBeatmapError::InvalidEventLine)?
|
||||
.parse()?;
|
||||
|
||||
if event_type == EventType::Break {
|
||||
let Some((start_time, end_time)) = split.next().zip(split.next()) else {
|
||||
return Err(ParseBeatmapError::InvalidEventLine);
|
||||
};
|
||||
|
||||
let start_time = f64::parse(start_time)?;
|
||||
let end_time = start_time.max(f64::parse(end_time)?);
|
||||
|
||||
state.breaks.push(BreakPeriod {
|
||||
start_time,
|
||||
end_time,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_timing_points(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
|
||||
let mut split = line.trim_comment().split(',');
|
||||
|
||||
let (time, beat_len) = split
|
||||
.next()
|
||||
.zip(split.next())
|
||||
.ok_or(ParseBeatmapError::InvalidTimingPointLine)?;
|
||||
|
||||
let time = time.parse_num::<f64>()?;
|
||||
|
||||
// Manual `str::parse_num::<f64>` so that NaN does not cause an error
|
||||
let beat_len = beat_len
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.map_err(ParseNumberError::InvalidFloat)?;
|
||||
|
||||
if beat_len < f64::from(-MAX_PARSE_VALUE) {
|
||||
return Err(ParseNumberError::NumberUnderflow.into());
|
||||
} else if beat_len > f64::from(MAX_PARSE_VALUE) {
|
||||
return Err(ParseNumberError::NumberOverflow.into());
|
||||
}
|
||||
|
||||
let speed_multiplier = if beat_len < 0.0 {
|
||||
100.0 / -beat_len
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let _ = split.next(); // timing signature
|
||||
let _ = split.next(); // sample set
|
||||
let _ = split.next(); // custom sample bank
|
||||
let _ = split.next(); // sample volume
|
||||
|
||||
let timing_change = split
|
||||
.next()
|
||||
.map_or(true, |next| matches!(next.chars().next(), Some('1')));
|
||||
|
||||
let kiai = split
|
||||
.next()
|
||||
.map(str::parse::<EffectFlags>)
|
||||
.transpose()?
|
||||
.is_some_and(|flags| flags.has_flag(EffectFlags::KIAI));
|
||||
|
||||
if timing_change {
|
||||
if beat_len.is_nan() {
|
||||
return Err(ParseBeatmapError::TimingControlPointNaN);
|
||||
}
|
||||
|
||||
let timing = TimingPoint::new(time, beat_len);
|
||||
state.add_pending_point(time, timing, timing_change);
|
||||
}
|
||||
|
||||
let difficulty = DifficultyPoint::new(time, beat_len, speed_multiplier);
|
||||
state.add_pending_point(time, difficulty, timing_change);
|
||||
|
||||
let effect = EffectPoint::new(time, kiai);
|
||||
state.add_pending_point(time, effect, timing_change);
|
||||
|
||||
state.pending_control_points_time = time;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_colors(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_hit_objects(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
|
||||
let mut split = line.trim_comment().split(',');
|
||||
|
||||
let (Some(x), Some(y), Some(start_time), Some(kind), Some(sound_type)) = (
|
||||
split.next(),
|
||||
split.next(),
|
||||
split.next(),
|
||||
split.next(),
|
||||
split.next(),
|
||||
) else {
|
||||
return Err(ParseBeatmapError::InvalidHitObjectLine);
|
||||
};
|
||||
|
||||
let pos = Pos {
|
||||
x: x.parse_with_limits(MAX_COORDINATE_VALUE as f32)? as i32 as f32,
|
||||
y: y.parse_with_limits(MAX_COORDINATE_VALUE as f32)? as i32 as f32,
|
||||
};
|
||||
|
||||
let start_time = f64::parse(start_time)?;
|
||||
let hit_object_type: HitObjectType = kind.parse()?;
|
||||
|
||||
let mut sound: HitSoundType = sound_type.parse()?;
|
||||
|
||||
let mut parse_custom_sound = |bank_info: Option<&str>| {
|
||||
let mut split = match bank_info {
|
||||
Some(s) if !s.is_empty() => s.split(':'),
|
||||
_ => return Ok::<_, ParseNumberError>(()),
|
||||
};
|
||||
|
||||
let _ = split.next().map(i32::parse).transpose()?; // normal bank
|
||||
let _ = split.next().map(i32::parse).transpose()?; // additional bank
|
||||
let _ = split.next().map(i32::parse).transpose()?; // custom sample bank
|
||||
let _ = split.next().map(i32::parse).transpose()?; // volume
|
||||
|
||||
// filename
|
||||
match split.next() {
|
||||
None | Some("") => {}
|
||||
Some(_) => sound = HitSoundType::default(),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let kind = if hit_object_type.has_flag(HitObjectType::CIRCLE) {
|
||||
parse_custom_sound(split.next())?;
|
||||
|
||||
HitObjectKind::Circle
|
||||
} else if hit_object_type.has_flag(HitObjectType::SLIDER) {
|
||||
let (point_str, repeat_count) = split
|
||||
.next()
|
||||
.zip(split.next())
|
||||
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
|
||||
|
||||
let mut len = None;
|
||||
|
||||
let mut repeats = repeat_count.parse_num::<i32>()?;
|
||||
|
||||
if repeats > 9000 {
|
||||
return Err(ParseBeatmapError::InvalidRepeatCount);
|
||||
}
|
||||
|
||||
repeats = cmp::max(0, repeats - 1);
|
||||
|
||||
if let Some(next) = split.next() {
|
||||
let new_len = next
|
||||
.parse_with_limits(f64::from(MAX_COORDINATE_VALUE))?
|
||||
.max(0.0);
|
||||
|
||||
if new_len.not_eq(0.0) {
|
||||
len = Some(new_len);
|
||||
}
|
||||
}
|
||||
|
||||
let node_sounds = if let Some(sounds) = split.next().map(|sounds| sounds.split('|')) {
|
||||
sounds.map(|s| s.parse().unwrap_or_default()).collect()
|
||||
} else {
|
||||
Box::default()
|
||||
};
|
||||
|
||||
let _ = split.next(); // node banks
|
||||
parse_custom_sound(split.next())?;
|
||||
|
||||
state.convert_path_str(point_str, pos)?;
|
||||
let mut control_points = Vec::with_capacity(state.curve_points.len());
|
||||
control_points.append(&mut state.curve_points);
|
||||
|
||||
let slider = Slider {
|
||||
expected_dist: len,
|
||||
repeats: repeats as usize,
|
||||
control_points: control_points.into_boxed_slice(),
|
||||
node_sounds,
|
||||
};
|
||||
|
||||
HitObjectKind::Slider(slider)
|
||||
} else if hit_object_type.has_flag(HitObjectType::SPINNER) {
|
||||
let end_time = split
|
||||
.next()
|
||||
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?
|
||||
.parse_num::<f64>()?;
|
||||
|
||||
parse_custom_sound(split.next())?;
|
||||
|
||||
HitObjectKind::Spinner(Spinner { end_time })
|
||||
} else if hit_object_type.has_flag(HitObjectType::HOLD) {
|
||||
let end_time = if let Some(s) = split.next().filter(|s| !s.is_empty()) {
|
||||
let (end_time, bank_info) = s
|
||||
.split_once(':')
|
||||
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
|
||||
|
||||
parse_custom_sound(Some(bank_info))?;
|
||||
|
||||
end_time.parse_num::<f64>()?.max(start_time)
|
||||
} else {
|
||||
start_time
|
||||
};
|
||||
|
||||
HitObjectKind::Hold(HoldNote { end_time })
|
||||
} else {
|
||||
return Err(ParseBeatmapError::UnknownHitObjectType);
|
||||
};
|
||||
|
||||
state.hit_objects.push(HitObject {
|
||||
pos,
|
||||
start_time,
|
||||
kind,
|
||||
});
|
||||
state.hit_sounds.push(sound);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_variables(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_catch_the_beat(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_mania(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
trait Pending: Sized {
|
||||
fn pending(state: &mut BeatmapState) -> &mut Option<Self>;
|
||||
|
||||
fn push_front(self, state: &mut BeatmapState) {
|
||||
let pending = Self::pending(state);
|
||||
|
||||
if pending.is_none() {
|
||||
*pending = Some(self);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_back(self, state: &mut BeatmapState) {
|
||||
*Self::pending(state) = Some(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl Pending for TimingPoint {
|
||||
fn pending(state: &mut BeatmapState) -> &mut Option<Self> {
|
||||
&mut state.pending_timing_point
|
||||
}
|
||||
}
|
||||
|
||||
impl Pending for DifficultyPoint {
|
||||
fn pending(state: &mut BeatmapState) -> &mut Option<Self> {
|
||||
&mut state.pending_difficulty_point
|
||||
}
|
||||
}
|
||||
|
||||
impl Pending for EffectPoint {
|
||||
fn pending(state: &mut BeatmapState) -> &mut Option<Self> {
|
||||
&mut state.pending_effect_point
|
||||
}
|
||||
}
|
||||
|
||||
impl ControlPoint<BeatmapState> for TimingPoint {
|
||||
fn check_already_existing(&self, _: &BeatmapState) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn add(self, state: &mut BeatmapState) {
|
||||
match state
|
||||
.timing_points
|
||||
.binary_search_by(|probe| probe.time.total_cmp(&self.time))
|
||||
{
|
||||
Err(i) => state.timing_points.insert(i, self),
|
||||
Ok(i) => state.timing_points[i] = self,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ControlPoint<BeatmapState> for DifficultyPoint {
|
||||
fn check_already_existing(&self, state: &BeatmapState) -> bool {
|
||||
match difficulty_point_at(&state.difficulty_points, self.time) {
|
||||
Some(existing) => self.is_redundant(existing),
|
||||
None => self.is_redundant(&DifficultyPoint::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(self, state: &mut BeatmapState) {
|
||||
match state
|
||||
.difficulty_points
|
||||
.binary_search_by(|probe| probe.time.total_cmp(&self.time))
|
||||
{
|
||||
Err(i) => state.difficulty_points.insert(i, self),
|
||||
Ok(i) => state.difficulty_points[i] = self,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ControlPoint<BeatmapState> for EffectPoint {
|
||||
fn check_already_existing(&self, state: &BeatmapState) -> bool {
|
||||
match effect_point_at(&state.effect_points, self.time) {
|
||||
Some(existing) => self.is_redundant(existing),
|
||||
None => self.is_redundant(&EffectPoint::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(self, state: &mut BeatmapState) {
|
||||
match state
|
||||
.effect_points
|
||||
.binary_search_by(|probe| probe.time.total_cmp(&self.time))
|
||||
{
|
||||
Err(i) => state.effect_points.insert(i, self),
|
||||
Ok(i) => state.effect_points[i] = self,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use std::{io, path::Path, str::FromStr};
|
||||
|
||||
use rosu_map::{
|
||||
section::{events::BreakPeriod, general::GameMode, hit_objects::hit_samples::HitSoundType},
|
||||
LATEST_FORMAT_VERSION,
|
||||
};
|
||||
|
||||
pub use self::{
|
||||
attributes::{BeatmapAttributes, BeatmapAttributesBuilder, HitWindows},
|
||||
converted::Converted,
|
||||
decode::{BeatmapState, ParseBeatmapError},
|
||||
};
|
||||
|
||||
use super::{
|
||||
control_point::{
|
||||
difficulty_point_at, effect_point_at, timing_point_at, DifficultyPoint, EffectPoint,
|
||||
TimingPoint,
|
||||
},
|
||||
hit_object::HitObject,
|
||||
mode::IGameMode,
|
||||
};
|
||||
|
||||
mod attributes;
|
||||
mod bpm;
|
||||
mod converted;
|
||||
mod decode;
|
||||
|
||||
/// All beatmap data that is relevant for difficulty and performance
|
||||
/// calculation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Beatmap {
|
||||
pub version: i32,
|
||||
|
||||
// General
|
||||
pub stack_leniency: f32,
|
||||
pub mode: GameMode,
|
||||
|
||||
// Difficulty
|
||||
pub ar: f32,
|
||||
pub cs: f32,
|
||||
pub hp: f32,
|
||||
pub od: f32,
|
||||
pub slider_multiplier: f64,
|
||||
pub slider_tick_rate: f64,
|
||||
|
||||
// Events
|
||||
pub breaks: Vec<BreakPeriod>,
|
||||
|
||||
// TimingPoints
|
||||
pub timing_points: Vec<TimingPoint>,
|
||||
pub difficulty_points: Vec<DifficultyPoint>,
|
||||
pub effect_points: Vec<EffectPoint>,
|
||||
|
||||
// HitObjects
|
||||
pub hit_objects: Vec<HitObject>,
|
||||
pub hit_sounds: Vec<HitSoundType>,
|
||||
}
|
||||
|
||||
impl Beatmap {
|
||||
/// Parse a [`Beatmap`] by providing a path to a `.osu` file.
|
||||
pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, io::Error> {
|
||||
rosu_map::from_path(path)
|
||||
}
|
||||
|
||||
/// Parse a [`Beatmap`] by providing the content of a `.osu` file as a
|
||||
/// slice of bytes.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, io::Error> {
|
||||
rosu_map::from_bytes(bytes)
|
||||
}
|
||||
|
||||
/// Finds the [`TimingPoint`] that is active at the given time.
|
||||
pub(crate) fn timing_point_at(&self, time: f64) -> Option<&TimingPoint> {
|
||||
timing_point_at(&self.timing_points, time)
|
||||
}
|
||||
|
||||
/// Finds the [`DifficultyPoint`] that is active at the given time.
|
||||
pub(crate) fn difficulty_point_at(&self, time: f64) -> Option<&DifficultyPoint> {
|
||||
difficulty_point_at(&self.difficulty_points, time)
|
||||
}
|
||||
|
||||
/// Finds the [`EffectPoint`] that is active at the given time.
|
||||
pub(crate) fn effect_point_at(&self, time: f64) -> Option<&EffectPoint> {
|
||||
effect_point_at(&self.effect_points, time)
|
||||
}
|
||||
|
||||
/// Sum up the duration of all breaks (in milliseconds).
|
||||
pub fn total_break_time(&self) -> f64 {
|
||||
self.breaks.iter().map(BreakPeriod::duration).sum()
|
||||
}
|
||||
|
||||
/// Attempt to convert a [`&Beatmap`] to the specified mode.
|
||||
///
|
||||
/// If the conversion is incompatible, `None` is returned.
|
||||
///
|
||||
/// [`&Beatmap`]: Beatmap
|
||||
pub fn try_as_converted<M: IGameMode>(&self) -> Option<Converted<'_, M>> {
|
||||
Converted::try_from_ref(self)
|
||||
}
|
||||
|
||||
/// Convert a [`&Beatmap`] to the specified mode.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the conversion is incompatible.
|
||||
///
|
||||
/// [`&Beatmap`]: Beatmap
|
||||
pub fn unchecked_as_converted<M: IGameMode>(&self) -> Converted<'_, M> {
|
||||
Converted::unchecked_from_ref(self)
|
||||
}
|
||||
|
||||
/// Attempt to convert a [`Beatmap`] to the specified mode.
|
||||
///
|
||||
/// If the conversion is incompatible the [`Beatmap`] will be returned
|
||||
/// unchanged as `Err`.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn try_into_converted<'a, M: IGameMode>(self) -> Result<Converted<'a, M>, Self> {
|
||||
Converted::try_from_owned(self)
|
||||
}
|
||||
|
||||
/// Convert a [`Beatmap`] to the specified mode.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the conversion is incompatible.
|
||||
pub fn unchecked_into_converted<'a, M: IGameMode>(self) -> Converted<'a, M> {
|
||||
Converted::unchecked_from_owned(self)
|
||||
}
|
||||
|
||||
/// Returns a [`BeatmapAttributesBuilder`] to calculate modified beatmap
|
||||
/// attributes.
|
||||
pub fn attributes(&self) -> BeatmapAttributesBuilder {
|
||||
self.into()
|
||||
}
|
||||
|
||||
/// The beats per minute of the map.
|
||||
pub fn bpm(&self) -> f64 {
|
||||
bpm::bpm(self.hit_objects.last(), &self.timing_points)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Beatmap {
|
||||
type Err = io::Error;
|
||||
|
||||
/// Parse a [`Beatmap`] by providing the content of a `.osu` file as a
|
||||
/// string.
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
rosu_map::from_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SLIDER_LENIENCY: f32 = 0.7;
|
||||
|
||||
impl Default for Beatmap {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: LATEST_FORMAT_VERSION,
|
||||
stack_leniency: DEFAULT_SLIDER_LENIENCY,
|
||||
mode: GameMode::default(),
|
||||
ar: 5.0,
|
||||
cs: 5.0,
|
||||
hp: 5.0,
|
||||
od: 5.0,
|
||||
slider_multiplier: 1.4,
|
||||
slider_tick_rate: 1.0,
|
||||
breaks: Vec::default(),
|
||||
timing_points: Vec::default(),
|
||||
difficulty_points: Vec::default(),
|
||||
effect_points: Vec::default(),
|
||||
hit_objects: Vec::default(),
|
||||
hit_sounds: Vec::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! define_beat_len_fn {
|
||||
( $fn_name:ident, $clamp:literal) => {
|
||||
#[allow(unused)]
|
||||
pub(crate) fn $fn_name(map: &Beatmap, beat_len: f64, time: f64) -> f64 {
|
||||
let slider_velocity = map
|
||||
.difficulty_point_at(time)
|
||||
.map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
|
||||
point.slider_velocity
|
||||
});
|
||||
|
||||
let slider_velocity_as_beat_len = -100.0 / slider_velocity;
|
||||
|
||||
let bpm_mult = if slider_velocity_as_beat_len < 0.0 {
|
||||
(((-slider_velocity_as_beat_len) as f32).clamp(10.0, $clamp) / 100.0) as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
beat_len * bpm_mult
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
define_beat_len_fn!(get_precision_adjusted_beat_len_taiko_mania, 10_000.0);
|
||||
define_beat_len_fn!(get_precision_adjusted_beat_len_osu_catch, 1000.0);
|
||||
Reference in New Issue
Block a user