initial rewrite

This commit is contained in:
MaxOhn
2024-02-09 17:54:14 +01:00
parent ecf1b2c7f4
commit a8bf9601b6
190 changed files with 10892 additions and 18142 deletions
+247
View File
@@ -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
}
}
+66
View File
@@ -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;
}
}
}
+186
View File
@@ -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
}
}
+706
View File
@@ -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,
}
}
}
+199
View File
@@ -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);