This repository has been archived on 2024-01-28. You can view files and clone it, but cannot push or open issues/pull-requests.
brd/src/osu/beatmap.rs

804 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

//! The description format of an osu! beatmap.
//!
//! The beatmap file format is described in the [osu! knowledge base].
//!
//! Example (building a minimal beatmap):
//!
//! ```
//! # use brd::osu::beatmap;
//! let awesome_beatmap = beatmap::BeatmapBuilder::default()
//! .general(
//! beatmap::GeneralBuilder::default()
//! .audio_filename("audio.mp3")
//! .build()
//! .unwrap(),
//! )
//! .metadata(
//! beatmap::MetadataBuilder::default()
//! .title("My awesome song")
//! .artist("Awesome artist")
//! .creator("Me")
//! .version("Hard")
//! .source("Awesome songs vol.3")
//! .build()
//! .unwrap(),
//! )
//! .difficulty(
//! beatmap::DifficultyBuilder::default()
//! .hp_drain_rate(4.0)
//! .circle_size(4.0)
//! .overall_difficulty(3.0)
//! .approach_rate(8.0)
//! .slider_multiplier(0.64)
//! .slider_tick_rate(1.0)
//! .build()
//! .unwrap(),
//! )
//! .timing_points(beatmap::TimingPoints(vec![
//! beatmap::TimingPointBuilder::default()
//! .time(0)
//! .beat_length(1000.0 / 3.0)
//! .build()
//! .unwrap(),
//! ]))
//! .hit_objects(beatmap::HitObjects(vec![
//! beatmap::hit_object::HitCircleBuilder::default()
//! .x(256)
//! .y(192)
//! .time(8000)
//! .build()
//! .unwrap()
//! .into(),
//! ]))
//! .build()
//! .unwrap();
//!
//! assert_eq!(
//! format!("{}", awesome_beatmap),
//! r#"osu file format v14
//!
//! [General]
//! AudioFilename: audio.mp3
//! AudioLeadIn: 0
//! PreviewTime: -1
//! Countdown: 1
//! SampleSet: Normal
//! Mode: 0
//!
//! [Editor]
//!
//! [Metadata]
//! Title:My awesome song
//! Artist:Awesome artist
//! Creator:Me
//! Version:Hard
//! Source:Awesome songs vol.3
//! Tags:
//!
//! [Difficulty]
//! HPDrainRate:4
//! CircleSize:4
//! OverallDifficulty:3
//! ApproachRate:8
//! SliderMultiplier:0.64
//! SliderTickRate:1
//!
//! [Events]
//!
//!
//! [TimingPoints]
//! 0,333.33334,4,0,0,100,1,0
//!
//! [Colours]
//!
//!
//! [HitObjects]
//! 256,192,8000,1,0,0:0:0:0:
//! "#
//! );
//! ```
//!
//! [osu! knowledge base]: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)
pub mod hit_object;
pub use hit_object::HitObject;
use std::fmt;
use derive_builder::Builder;
use derive_more::{Deref, DerefMut};
use num_traits::ToPrimitive;
use super::types::*;
use crate::utils;
#[derive(Builder, Clone)]
pub struct General {
#[builder(setter(into))]
pub audio_filename: String,
#[builder(default)]
pub audio_lead_in: Time,
#[builder(default = "-1")]
pub preview_time: SignedTime,
#[builder(default)]
pub countdown: Countdown,
// SampleSets normal default (BeatmapDefault) does not make sense here
#[builder(default = "SampleSet::Normal")]
pub sample_set: SampleSet,
#[builder(default)]
pub mode: Mode,
}
impl fmt::Display for General {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"\
[General]\n\
AudioFilename: {}\n\
AudioLeadIn: {}\n\
PreviewTime: {}\n\
Countdown: {}\n\
SampleSet: {:?}\n\
Mode: {}\n\
",
self.audio_filename,
self.audio_lead_in,
self.preview_time,
ToPrimitive::to_u8(&self.countdown).unwrap(),
self.sample_set,
ToPrimitive::to_u8(&self.mode).unwrap()
)
}
}
#[derive(Clone, Default)]
pub struct Editor;
impl fmt::Display for Editor {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(f, "[Editor]")
}
}
#[derive(Builder, Clone)]
#[builder(setter(into))]
pub struct Metadata {
pub title: String,
pub artist: String,
#[builder(default = "\"brd::osu\".to_string()")]
pub creator: String,
pub version: String,
#[builder(default)]
pub source: String,
#[builder(default)]
pub tags: Vec<String>,
}
impl fmt::Display for Metadata {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"\
[Metadata]\n\
Title:{}\n\
Artist:{}\n\
Creator:{}\n\
Version:{}\n\
Source:{}\n\
Tags:{}\n\
",
self.title,
self.artist,
self.creator,
self.version,
self.source,
self.tags.join(" ")
)
}
}
#[derive(Builder, Clone, Debug)]
#[builder(build_fn(validate = "Self::validate"))]
pub struct Difficulty {
#[builder(setter(into))]
pub hp_drain_rate: RangeSetting,
/// Also is the number of keys in mania
#[builder(setter(into))]
pub circle_size: RangeSetting,
#[builder(setter(into))]
pub overall_difficulty: RangeSetting,
#[builder(setter(into))]
pub approach_rate: RangeSetting,
pub slider_multiplier: f32,
pub slider_tick_rate: f32,
}
impl DifficultyBuilder {
fn validate_option(maybe_value: &Option<RangeSetting>, name: &str) -> Result<(), String> {
if let Some(value) = maybe_value {
if !value.validate() {
return Err(format!(
"{} has to be between {} and {}",
name,
RangeSetting::MIN,
RangeSetting::MAX
));
}
}
Ok(())
}
fn validate(&self) -> Result<(), String> {
Self::validate_option(&self.hp_drain_rate, "hp_drain_rate")?;
Self::validate_option(&self.circle_size, "circle_size")?;
Self::validate_option(&self.overall_difficulty, "overall_difficulty")?;
Self::validate_option(&self.approach_rate, "approach_rate")?;
Ok(())
}
}
impl fmt::Display for Difficulty {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"\
[Difficulty]\n\
HPDrainRate:{}\n\
CircleSize:{}\n\
OverallDifficulty:{}\n\
ApproachRate:{}\n\
SliderMultiplier:{}\n\
SliderTickRate:{}\n\
",
self.hp_drain_rate,
self.circle_size,
self.overall_difficulty,
self.approach_rate,
self.slider_multiplier,
self.slider_tick_rate
)
}
}
#[derive(Clone, Default, Deref, DerefMut)]
pub struct Events(pub Vec<Event>);
impl fmt::Display for Events {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"\
[Events]\n\
{}\n\
",
utils::join_display_values(self.to_vec(), "\n")
)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
Background {
filename: String,
x_offset: OsuPixel,
y_offset: OsuPixel,
},
Video {
filename: String,
start_time: Time,
x_offset: OsuPixel,
y_offset: OsuPixel,
},
Break {
start_time: Time,
end_time: Time,
},
}
impl fmt::Display for Event {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Event::Background {
filename,
x_offset,
y_offset,
} => write!(f, "0,0,{},{},{}", filename, x_offset, y_offset),
Event::Video {
start_time,
filename,
x_offset,
y_offset,
} => write!(
f,
"Video,{},{},{},{}",
start_time, filename, x_offset, y_offset
),
Event::Break {
start_time,
end_time,
} => write!(f, "Break,{},{}", start_time, end_time),
}
}
}
#[derive(Clone, Default, Deref, DerefMut)]
pub struct TimingPoints(pub Vec<TimingPoint>);
impl fmt::Display for TimingPoints {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"\
[TimingPoints]\n\
{}\n\
",
utils::join_display_values(self.to_vec(), "\n")
)
}
}
#[derive(Builder, Clone, Default)]
pub struct TimingPointEffects {
pub kiai_time: bool,
pub omit_first_barline: bool,
}
impl fmt::Display for TimingPointEffects {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"{}",
utils::bitarray_to_byte([
self.kiai_time,
false,
false,
self.omit_first_barline,
false,
false,
false,
false
])
)
}
}
#[derive(Builder, Clone)]
pub struct TimingPoint {
pub time: Time,
pub beat_length: f32,
#[builder(default = "4")]
pub meter: u8,
#[builder(default = "SampleSet::BeatmapDefault")]
pub sample_set: SampleSet,
#[builder(default = "0")]
pub sample_index: u32,
#[builder(default = "100")]
pub volume: u8,
#[builder(default = "true")]
pub uninherited: bool,
#[builder(default)]
pub effects: TimingPointEffects,
}
impl fmt::Display for TimingPoint {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"{},{},{},{},{},{},{},{}",
self.time,
self.beat_length,
self.meter,
ToPrimitive::to_u8(&self.sample_set).unwrap(),
self.sample_index,
self.volume,
self.uninherited as u8,
self.effects
)
}
}
#[derive(Clone, Default, Deref, DerefMut)]
pub struct Colours(pub Vec<Colour>);
impl fmt::Display for Colours {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"\
[Colours]\n\
{}\n\
",
utils::join_display_values(self.to_vec(), "\n")
)
}
}
#[derive(Clone, Debug)]
pub enum ColourScope {
Combo(u16),
SliderTrackOverride,
SliderBorder,
}
impl fmt::Display for ColourScope {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ColourScope::Combo(i) => write!(f, "Combo{}", i),
_ => write!(f, "{:?}", self),
}
}
}
#[derive(Builder, Clone)]
pub struct Colour {
pub scope: ColourScope,
pub colour: [u8; 3],
}
impl fmt::Display for Colour {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"{} : {}",
self.scope,
utils::join_display_values(self.colour.to_vec(), ",")
)
}
}
impl fmt::Display for HitSound {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"{}",
utils::bitarray_to_byte([
self.normal,
self.whistle,
self.finish,
self.clap,
false,
false,
false,
false
])
)
}
}
impl fmt::Display for HitSample {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"{}:{}:{}:{}:{}",
ToPrimitive::to_u8(&self.normal_set).unwrap(),
ToPrimitive::to_u8(&self.addition_set).unwrap(),
self.index,
self.volume,
self.filename
)
}
}
#[derive(Clone, Default, Deref, DerefMut)]
pub struct HitObjects(pub Vec<HitObject>);
impl fmt::Display for HitObjects {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"\
[HitObjects]\n\
{}\n\
",
utils::join_display_values(self.to_vec(), "\n")
)
}
}
#[derive(Builder)]
pub struct Beatmap {
#[builder(default = "14")]
pub version: u8,
pub general: General,
#[builder(default)]
pub editor: Editor,
pub metadata: Metadata,
pub difficulty: Difficulty,
#[builder(default)]
pub events: Events,
pub timing_points: TimingPoints,
#[builder(default)]
pub colours: Colours,
pub hit_objects: HitObjects,
}
impl fmt::Display for Beatmap {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"osu file format v{}\n\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
self.version,
self.general.clone(),
self.editor.clone(),
self.metadata.clone(),
self.difficulty.clone(),
self.events.clone(),
self.timing_points.clone(),
self.colours.clone(),
self.hit_objects.clone()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn general() {
let general = GeneralBuilder::default()
.audio_filename("foo.mp3")
.audio_lead_in(23)
.preview_time(5000)
.countdown(Countdown::Double)
.sample_set(SampleSet::Drum)
.mode(Mode::Mania)
.build()
.unwrap();
assert_eq!(
format!("{}", general),
"[General]\n\
AudioFilename: foo.mp3\n\
AudioLeadIn: 23\n\
PreviewTime: 5000\n\
Countdown: 3\n\
SampleSet: Drum\n\
Mode: 3\n",
)
}
#[test]
fn editor() {
assert_eq!(format!("{}", Editor), "[Editor]\n");
}
#[test]
fn metadata() {
let metadata = MetadataBuilder::default()
.title("Song Title")
.artist("Song Artist")
.creator("mycoolusername42")
.version("Super Hard")
.source("Best Hits Vol. 23")
.tags(vec![
"some".to_string(),
"descriptive".to_string(),
"tags".to_string(),
])
.build()
.unwrap();
assert_eq!(
format!("{}", metadata),
"[Metadata]\n\
Title:Song Title\n\
Artist:Song Artist\n\
Creator:mycoolusername42\n\
Version:Super Hard\n\
Source:Best Hits Vol. 23\n\
Tags:some descriptive tags\n"
);
}
#[test]
fn dificulty_builder_error() {
assert_eq!(
DifficultyBuilder::default()
.hp_drain_rate(25.0)
.circle_size(5.0)
.overall_difficulty(5.0)
.approach_rate(5.0)
.build()
.unwrap_err(),
"hp_drain_rate has to be between 0 and 10"
);
}
#[test]
fn difficulty() {
let difficulty = DifficultyBuilder::default()
.hp_drain_rate(4.0)
.circle_size(5.0)
.overall_difficulty(6.0)
.approach_rate(7.0)
.slider_multiplier(0.64)
.slider_tick_rate(1.0)
.build()
.unwrap();
assert_eq!(
format!("{}", difficulty),
"[Difficulty]\n\
HPDrainRate:4\n\
CircleSize:5\n\
OverallDifficulty:6\n\
ApproachRate:7\n\
SliderMultiplier:0.64\n\
SliderTickRate:1\n"
)
}
#[test]
fn events() {
let mut events = Events(Vec::new());
events.push(Event::Background {
filename: "foo.jpg".to_string(),
x_offset: 42.into(),
y_offset: 23.into(),
});
events.push(Event::Video {
filename: "foo.mp4".to_string(),
start_time: 500,
x_offset: 42.into(),
y_offset: 23.into(),
});
events.push(Event::Break {
start_time: 23000,
end_time: 42000,
});
assert_eq!(
format!("{}", events),
"[Events]\n\
0,0,foo.jpg,42,23\n\
Video,500,foo.mp4,42,23\n\
Break,23000,42000\n"
)
}
#[test]
fn timing_points() {
let mut timing_points = TimingPoints(Vec::new());
timing_points.push(
TimingPointBuilder::default()
.time(0)
.beat_length(1000.0 / 3.0)
.build()
.unwrap(),
);
timing_points.push(
TimingPointBuilder::default()
.time(5000)
.beat_length(500.0)
.meter(8)
.sample_set(SampleSet::Drum)
.sample_index(1)
.volume(50)
.uninherited(false)
.effects(
TimingPointEffectsBuilder::default()
.kiai_time(true)
.omit_first_barline(true)
.build()
.unwrap(),
)
.build()
.unwrap(),
);
assert_eq!(
format!("{}", timing_points),
"[TimingPoints]\n\
0,333.33334,4,0,0,100,1,0\n\
5000,500,8,3,1,50,0,9\n"
);
}
#[test]
fn colours() {
let mut colours = Colours::default();
colours.push(
ColourBuilder::default()
.scope(ColourScope::Combo(42))
.colour([0, 127, 255])
.build()
.unwrap(),
);
colours.push(
ColourBuilder::default()
.scope(ColourScope::SliderTrackOverride)
.colour([127, 255, 0])
.build()
.unwrap(),
);
colours.push(
ColourBuilder::default()
.scope(ColourScope::SliderBorder)
.colour([255, 0, 127])
.build()
.unwrap(),
);
assert_eq!(
format!("{}", colours),
"[Colours]\n\
Combo42 : 0,127,255\n\
SliderTrackOverride : 127,255,0\n\
SliderBorder : 255,0,127\n"
)
}
#[test]
fn hit_sound() {
assert_eq!(format!("{}", HitSound::default()), "0");
assert_eq!(
format!(
"{}",
HitSoundBuilder::default().normal(true).build().unwrap()
),
"1"
);
assert_eq!(
format!(
"{}",
HitSoundBuilder::default().whistle(true).build().unwrap()
),
"2"
);
assert_eq!(
format!(
"{}",
HitSoundBuilder::default().finish(true).build().unwrap()
),
"4"
);
assert_eq!(
format!("{}", HitSoundBuilder::default().clap(true).build().unwrap()),
"8"
);
}
#[test]
fn hit_sample() {
assert_eq!(format!("{}", HitSample::default()), "0:0:0:0:");
assert_eq!(
format!(
"{}",
HitSampleBuilder::default()
.normal_set(SampleSet::Drum)
.addition_set(SampleSet::Normal)
.index(23)
.volume(42)
.filename("foo.mp3")
.build()
.unwrap()
),
"3:1:23:42:foo.mp3"
);
}
#[test]
fn hit_objects() {
let mut hit_objects: HitObjects = Default::default();
hit_objects.push(
hit_object::HitCircleBuilder::default()
.x(200)
.y(400)
.time(5732)
.build()
.unwrap()
.into(),
);
hit_objects.push(
hit_object::HitCircleBuilder::default()
.x(400)
.y(500)
.time(7631)
.build()
.unwrap()
.into(),
);
assert_eq!(
format!("{}", hit_objects),
"[HitObjects]\n\
200,400,5732,1,0,0:0:0:0:\n\
400,500,7631,1,0,0:0:0:0:\n"
);
}
}