use crate::{
sm_msd::{self, MsdElement, MsdFile},
utils::ByteString,
};
use std::{
ffi::{OsStr, OsString},
fmt::Display,
fs, io,
path::{Path, PathBuf},
str::Utf8Error,
};
use thiserror::Error;
#[derive(Error, Debug, PartialEq, Eq)]
pub enum LoadError {
#[error("this was not valid sm ({0})")]
InvalidSM(String),
#[error("expected {0} to parse as utf-8, got Utf8Error {1}")]
UnexpectedNonUtf8(String, Utf8Error),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Bpm {
pub bpm: f64,
pub offset_beats: f64,
}
impl Bpm {
fn from_bytes(bytes: &[u8]) -> Result<Self, LoadError> {
let str = match std::str::from_utf8(bytes) {
Ok(str) => str,
Err(utf8err) => return Err(LoadError::UnexpectedNonUtf8("BPM".into(), utf8err)),
};
let elements = str.split('=').collect::<Vec<&str>>();
if elements.len() < 2 {
return Err(LoadError::InvalidSM(format!(
"Invalid SM BPM string '{}'.",
str
)));
}
let (offset, bpm) = (elements[0], elements[1]);
let (offset_beats, _) = match lexical::parse_partial(offset) {
Ok(v) => v,
Err(_) => {
return Err(LoadError::InvalidSM(format!(
"Failed to parse {offset} as a float."
)))
}
};
let (bpm, _) = match lexical::parse_partial(bpm) {
Ok(v) => v,
Err(_) => {
return Err(LoadError::InvalidSM(format!(
"Failed to parse {bpm} as a float."
)))
}
};
if bpm <= 0.0 {
return Err(LoadError::InvalidSM(format!("BPM was negative ({bpm})")));
}
Ok(Bpm { bpm, offset_beats })
}
}
#[derive(Debug, Clone)]
pub struct SongMetadata {
pub offset_secs: Option<f64>,
pub bpms: Vec<Bpm>,
pub subtitle: Option<String>,
pub artist: String,
pub title: String,
pub music: Option<OsString>,
}
#[derive(Debug, Clone)]
pub struct Chart {
pub tags: MsdFile,
pub song_info: SongMetadata,
pub chart_data: ChartData,
pub path: PathBuf,
}
macro_rules! msd_tag_fallback {
($metadata: expr, $tag: expr, $fallback: expr) => {{
match $metadata.first_tag_first_val($tag) {
Some(slice) => String::from_utf8_lossy(&slice).into_owned(),
None => $fallback.to_owned(),
}
}};
}
macro_rules! msd_tag {
($metadata: expr, $tag: expr) => {{
match $metadata.first_tag_first_val($tag) {
Some(slice) => Some(String::from_utf8_lossy(&slice).into_owned()),
None => None,
}
}};
}
impl Chart {
fn infer_author(path: impl AsRef<Path>) -> Option<String> {
let path = path.as_ref().clone();
let name = path.parent()?.file_name()?;
let name = OsStr::to_string_lossy(name);
use regex::Regex;
let standard = Regex::new(r"\((.+)\) *$").expect("Invalid standard regex.");
let square = Regex::new(r"\[(.+)\] *$").expect("Invalid square regex.");
let std_start = Regex::new(r"^ *\((.+)\)").expect("Invalid std_start regex.");
let sqr_start = Regex::new(r"^ *\[(.+)\]").expect("Invalid sqr_start regex.");
if let Some(m) = standard.captures(&name) {
return m.get(1).map(|f| f.as_str().trim().to_owned());
}
if let Some(m) = square.captures(&name) {
return m.get(1).map(|f| f.as_str().trim().to_owned());
}
if let Some(m) = std_start.captures(&name) {
return m.get(1).map(|f| f.as_str().trim().to_owned());
}
if let Some(m) = sqr_start.captures(&name) {
return m.get(1).map(|f| f.as_str().trim().to_owned());
}
None
}
fn look_for_audio(path: impl AsRef<Path>) -> Option<OsString> {
let dir = path.as_ref().parent()?;
let entries = fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
match entry.path().extension().and_then(OsStr::to_str) {
Some("ogg" | "mp3" | "wav" | "oga") => return Some(entry.file_name()),
_ => continue,
}
}
None
}
}
pub fn from_path(sm_path: impl AsRef<Path>) -> io::Result<Result<Vec<Chart>, LoadError>> {
let bytes = fs::read(&sm_path)?;
Ok(from_bytes(&bytes, sm_path))
}
pub fn from_bytes(bytes: &[u8], sm_path: impl AsRef<Path>) -> Result<Vec<Chart>, LoadError> {
let (metadata, charts) = parse(bytes);
let bpms: Vec<Bpm> = match metadata.first_tag_first_val("BPMS") {
Some(bpm_str) => parse_bpms(&bpm_str).unwrap_or(vec![Bpm {
bpm: 60.0,
offset_beats: 0.0,
}]),
None => vec![Bpm {
bpm: 60.0,
offset_beats: 0.0,
}],
};
let offset = match metadata.first_tag_first_val("OFFSET") {
Some(v) => {
let str = match std::str::from_utf8(&v) {
Ok(s) => s,
Err(err) => return Err(LoadError::UnexpectedNonUtf8("OFFSET".into(), err)),
};
match lexical::parse_partial::<f64, &str>(str) {
Ok((float, _)) => {
if float == 0.0 {
None
} else {
Some(-1.0 * float)
}
}
Err(_) => None,
}
}
None => None,
};
let music_path = metadata.first_tag_first_val("MUSIC").map(|bytes| {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
OsStr::from_bytes(&bytes).to_owned()
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
OsString::from_wide(&bytes)
}
});
let music_path = music_path.map(|os_str| {
let path = sm_path.as_ref().to_path_buf();
let path = path.join(&os_str);
if path.exists() {
os_str
} else {
match Chart::look_for_audio(&sm_path) {
Some(data) => data,
None => os_str,
}
}
});
let song_info = SongMetadata {
artist: msd_tag_fallback!(metadata, "ARTIST", "Unknown Artist"),
title: match metadata.first_tag_first_val("TITLE") {
Some(v) => String::from_utf8_lossy(&v).into_owned(),
None => {
let mut path = sm_path.as_ref().to_path_buf();
path.pop();
match path.file_name() {
Some(name) => name.to_string_lossy().into_owned(),
None => "Untitled Song".to_owned(),
}
}
},
subtitle: msd_tag!(metadata, "SUBTITLE"),
bpms,
music: music_path,
offset_secs: offset,
};
let mut ok_charts = vec![];
for chart in charts {
let mut chart = chart?;
if chart.author.is_empty() || &*chart.author == b"Copied From" || &*chart.author == b"Blank"
{
if let Some(inferred_name) = Chart::infer_author(&sm_path) {
chart.author = inferred_name.as_bytes().into();
}
}
let full_file = Chart {
tags: metadata.clone(),
song_info: song_info.clone(),
chart_data: chart,
path: sm_path.as_ref().to_path_buf(),
};
ok_charts.push(full_file);
}
Ok(ok_charts)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(missing_docs)]
pub enum StepsType {
DanceSingle,
DanceDouble,
DanceCouple,
DanceSolo,
DanceThreepanel,
DanceRoutine,
PumpSingle,
PumpHalfDouble,
PumpDouble,
PumpCouple,
PumpRoutine,
Kb7Single,
Ez2Single,
Ez2Double,
Ez2Real,
ParaSingle,
Ds3ddxSingle,
BmSingle5,
BmVersus5,
BmDouble5,
BmSingle7,
BmVersus7,
BmDouble7,
ManiaxSingle,
ManiaxDouble,
TechnoSingle4,
TechnoSingle5,
TechnoSingle8,
TechnoDouble4,
TechnoDouble5,
TechnoDouble8,
PnmFive,
PnmNine,
LightsCabinet,
KickboxHuman,
KickboxQuadarm,
KickboxInsect,
KickboxArachnid,
Other(ByteString),
}
impl Display for StepsType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self {
StepsType::DanceSingle => "dance-single".into(),
StepsType::DanceDouble => "dance-double".into(),
StepsType::DanceCouple => "dance-couple".into(),
StepsType::DanceSolo => "dance-solo".into(),
StepsType::DanceThreepanel => "dance-threepanel".into(),
StepsType::DanceRoutine => "dance-routine".into(),
StepsType::PumpSingle => "pump-single".into(),
StepsType::PumpHalfDouble => "pump-halfdouble".into(),
StepsType::PumpDouble => "pump-double".into(),
StepsType::PumpCouple => "pump-couple".into(),
StepsType::PumpRoutine => "pump-routine".into(),
StepsType::Kb7Single => "kb7-single".into(),
StepsType::Ez2Single => "ez2-single".into(),
StepsType::Ez2Double => "ez2-double".into(),
StepsType::Ez2Real => "ez2-real".into(),
StepsType::ParaSingle => "para-single".into(),
StepsType::Ds3ddxSingle => "ds3ddx-single".into(),
StepsType::BmSingle5 => "bm-single5".into(),
StepsType::BmVersus5 => "bm-versus5".into(),
StepsType::BmDouble5 => "bm-double5".into(),
StepsType::BmSingle7 => "bm-single7".into(),
StepsType::BmVersus7 => "bm-versus7".into(),
StepsType::BmDouble7 => "bm-double7".into(),
StepsType::ManiaxSingle => "maniax-single".into(),
StepsType::ManiaxDouble => "maniax-double".into(),
StepsType::TechnoSingle4 => "techno-single4".into(),
StepsType::TechnoSingle5 => "techno-single5".into(),
StepsType::TechnoSingle8 => "techno-single8".into(),
StepsType::TechnoDouble4 => "techno-double4".into(),
StepsType::TechnoDouble5 => "techno-double5".into(),
StepsType::TechnoDouble8 => "techno-double8".into(),
StepsType::PnmFive => "pnm-five".into(),
StepsType::PnmNine => "pnm-nine".into(),
StepsType::LightsCabinet => "lights-cabinet".into(),
StepsType::KickboxHuman => "kickbox-human".into(),
StepsType::KickboxQuadarm => "kickbox-quadarm".into(),
StepsType::KickboxInsect => "kickbox-insect".into(),
StepsType::KickboxArachnid => "kickbox-arachnid".into(),
StepsType::Other(a) => String::from_utf8_lossy(a).into_owned(),
};
f.write_str(&str)
}
}
impl StepsType {
fn from_bytes(bytes: &ByteString) -> Self {
match &**bytes {
b"dance-single" => StepsType::DanceSingle,
b"dance-double" => StepsType::DanceDouble,
b"dance-couple" => StepsType::DanceCouple,
b"dance-solo" => StepsType::DanceSolo,
b"dance-threepanel" => StepsType::DanceThreepanel,
b"dance-routine" => StepsType::DanceRoutine,
b"pump-single" => StepsType::PumpSingle,
b"pump-halfdouble" => StepsType::PumpHalfDouble,
b"pump-double" => StepsType::PumpDouble,
b"pump-couple" => StepsType::PumpCouple,
b"pump-routine" => StepsType::PumpRoutine,
b"kb7-single" => StepsType::Kb7Single,
b"ez2-single" => StepsType::Ez2Single,
b"ez2-double" => StepsType::Ez2Double,
b"ez2-real" => StepsType::Ez2Real,
b"para-single" => StepsType::ParaSingle,
b"ds3ddx-single" => StepsType::Ds3ddxSingle,
b"bm-single5" => StepsType::BmSingle5,
b"bm-versus5" => StepsType::BmVersus5,
b"bm-double5" => StepsType::BmDouble5,
b"bm-single7" => StepsType::BmSingle7,
b"bm-versus7" => StepsType::BmVersus7,
b"bm-double7" => StepsType::BmDouble7,
b"maniax-single" => StepsType::ManiaxSingle,
b"maniax-double" => StepsType::ManiaxDouble,
b"techno-single4" => StepsType::TechnoSingle4,
b"techno-single5" => StepsType::TechnoSingle5,
b"techno-single8" => StepsType::TechnoSingle8,
b"techno-double4" => StepsType::TechnoDouble4,
b"techno-double5" => StepsType::TechnoDouble5,
b"techno-double8" => StepsType::TechnoDouble8,
b"pnm-five" => StepsType::PnmFive,
b"pnm-nine" => StepsType::PnmNine,
b"lights-cabinet" => StepsType::LightsCabinet,
b"kickbox-human" => StepsType::KickboxHuman,
b"kickbox-quadarm" => StepsType::KickboxQuadarm,
b"kickbox-insect" => StepsType::KickboxInsect,
b"kickbox-arachnid" => StepsType::KickboxArachnid,
_ => StepsType::Other(bytes.clone()),
}
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum Difficulty {
Beginner,
Easy,
Medium,
Hard,
Challenge,
Edit(ByteString),
}
impl Display for Difficulty {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use Difficulty::*;
let str = match self {
Beginner => "Beginner".into(),
Easy => "Easy".into(),
Medium => "Medium".into(),
Hard => "Hard".into(),
Challenge => "Challenge".into(),
Edit(txt) => format!("Edit {}", String::from_utf8_lossy(txt)),
};
write!(f, "{str}")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChartData {
pub steps_type: StepsType,
pub author: ByteString,
pub difficulty: Difficulty,
pub level: usize,
pub notedata: Vec<Measure>,
}
#[derive(Debug, PartialEq, Clone, Eq)]
pub enum NoteVariant {
Note,
HoldStart,
RollStart,
HoldOrRollEnd,
AutoKeysound,
Lift,
Fake,
Mine,
Unknown(u8),
}
#[derive(Debug, PartialEq, Clone, Eq)]
pub struct Event {
pub row: usize,
pub column: usize,
pub variant: NoteVariant,
}
#[derive(Debug, PartialEq, Clone, Eq)]
pub struct Measure {
pub size: usize,
pub events: Vec<Event>,
}
fn parse_notedata(raw_notedata: &[u8]) -> Vec<Measure> {
let mut measures = vec![];
for measure in raw_notedata.split(|char| *char == b',') {
let mut size = 0;
let mut events = vec![];
for row in measure.split(|char| *char == b'\n') {
let row = row.trim_ascii();
if row.is_empty() {
continue;
}
let mut skip_until_closebrck = false;
let mut index = 0;
for ch in row.iter() {
if *ch == b'[' {
skip_until_closebrck = true;
continue;
}
if *ch == b']' {
skip_until_closebrck = false;
continue;
}
if skip_until_closebrck {
continue;
}
let variant = match ch {
b'0' => {
index += 1;
continue;
}
b'1' => NoteVariant::Note,
b'2' => NoteVariant::HoldStart,
b'3' => NoteVariant::HoldOrRollEnd,
b'4' => NoteVariant::RollStart,
b'M' => NoteVariant::Mine,
b'K' => NoteVariant::AutoKeysound,
b'L' => NoteVariant::Lift,
b'F' => NoteVariant::Fake,
ch => NoteVariant::Unknown(*ch),
};
events.push(Event {
row: size,
column: index,
variant,
});
index += 1;
}
size += 1;
}
if size == 0 {
continue;
}
measures.push(Measure { size, events })
}
measures
}
fn parse(sm: &[u8]) -> (MsdFile, Vec<Result<ChartData, LoadError>>) {
let msd_file = sm_msd::from_bytes(sm);
let charts = msd_file
.all_with_tag("NOTES")
.iter()
.map(|el| parse_notes(el))
.collect();
(msd_file, charts)
}
fn parse_bpms(bpms: &[u8]) -> Result<Vec<Bpm>, LoadError> {
let mut bpm_vec = vec![];
for bpm in bpms.split(|by| *by == b',') {
match Bpm::from_bytes(bpm.trim_ascii()) {
Ok(v) => bpm_vec.push(v),
Err(err) => return Err(err),
}
}
Ok(bpm_vec)
}
fn parse_notes(el: &MsdElement) -> Result<ChartData, LoadError> {
if el.values.len() < 6 {
return Err(LoadError::InvalidSM(format!(
"Invalid amount of fields inside #NOTES. Got {}, expected at least 6.",
el.values.len()
)));
}
let fields = &el.values;
let steps_type = StepsType::from_bytes(&fields[0]);
let author = &fields[1];
let diff = &fields[2];
let difficulty = match diff.to_ascii_lowercase().as_slice() {
b"beginner" => Difficulty::Beginner,
b"easy" | b"basic" | b"light" => Difficulty::Easy,
b"medium" | b"another" | b"trick" | b"standard" | b"difficult" => Difficulty::Medium,
b"hard" | b"ssr" | b"maniac" | b"heavy" => Difficulty::Hard,
b"challenge" | b"expert" | b"oni" => Difficulty::Challenge,
b"edit" => Difficulty::Edit(author.clone()),
d => {
return Err(LoadError::InvalidSM(format!(
"Unknown difficulty {}",
String::from_utf8_lossy(d),
)))
}
};
let level = String::from_utf8_lossy(&fields[3]).parse().unwrap_or(1);
let raw_notedata = &fields[5];
let notedata = parse_notedata(raw_notedata);
Ok(ChartData {
steps_type,
author: author.clone(),
difficulty,
level,
notedata,
})
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn bpms() {
assert_eq!(
parse_bpms(b"0.000=104.03"),
Ok(vec![Bpm {
bpm: 104.03,
offset_beats: 0.0
}])
);
assert_eq!(
parse_bpms(b"0.000=104.03,1.000=400"),
Ok(vec![
Bpm {
bpm: 104.03,
offset_beats: 0.0
},
Bpm {
bpm: 400.00,
offset_beats: 1.0
}
])
);
assert_eq!(
parse_bpms(b"0.000=-104.03"),
Err(LoadError::InvalidSM("BPM was negative (-104.03)".into()))
);
}
#[test]
fn bpm_partial() {
assert_eq!(
parse_bpms(b"0.000=123.456.789"),
Ok(vec![Bpm {
bpm: 123.456,
offset_beats: 0.0
}])
);
}
#[test]
fn load_notes() {
assert_eq!(
parse_notes(&MsdElement {
tag: Box::new(*b"NOTES"),
values: vec![
Box::new(*b"dance-single"),
Box::new(*b"Author"),
Box::new(*b"Hard"),
Box::new(*b"1"),
Box::new(*b"nonsense groove"),
Box::new(
*b"1000
0100
0010
0001,
M000
00000
1234
LKMF"
)
]
}),
Ok(ChartData {
steps_type: StepsType::DanceSingle,
author: Box::new(*b"Author"),
difficulty: Difficulty::Hard,
level: 1,
notedata: vec![
Measure {
size: 4,
events: vec![
Event {
column: 0,
row: 0,
variant: NoteVariant::Note
},
Event {
column: 1,
row: 1,
variant: NoteVariant::Note
},
Event {
column: 2,
row: 2,
variant: NoteVariant::Note
},
Event {
column: 3,
row: 3,
variant: NoteVariant::Note
},
]
},
Measure {
size: 4,
events: vec![
Event {
column: 0,
row: 0,
variant: NoteVariant::Mine
},
Event {
column: 0,
row: 2,
variant: NoteVariant::Note
},
Event {
column: 1,
row: 2,
variant: NoteVariant::HoldStart
},
Event {
column: 2,
row: 2,
variant: NoteVariant::HoldOrRollEnd
},
Event {
column: 3,
row: 2,
variant: NoteVariant::RollStart
},
Event {
column: 0,
row: 3,
variant: NoteVariant::Lift
},
Event {
column: 1,
row: 3,
variant: NoteVariant::AutoKeysound
},
Event {
column: 2,
row: 3,
variant: NoteVariant::Mine
},
Event {
column: 3,
row: 3,
variant: NoteVariant::Fake
},
]
}
]
})
)
}
#[test]
fn load_notes_obscurekeysounds() {
assert_eq!(
parse_notes(&MsdElement {
tag: Box::new(*b"NOTES"),
values: vec![
Box::new(*b"dance-single"),
Box::new(*b"Author"),
Box::new(*b"Hard"),
Box::new(*b"1"),
Box::new(*b"nonsense groove"),
Box::new(
*b"1000
0100[1]
001[100000]0
0001[1,
[1]M000
00[1]000
123[999}>)]4
LKMF"
)
]
}),
Ok(ChartData {
steps_type: StepsType::DanceSingle,
author: Box::new(*b"Author"),
difficulty: Difficulty::Hard,
level: 1,
notedata: vec![
Measure {
size: 4,
events: vec![
Event {
column: 0,
row: 0,
variant: NoteVariant::Note
},
Event {
column: 1,
row: 1,
variant: NoteVariant::Note
},
Event {
column: 2,
row: 2,
variant: NoteVariant::Note
},
Event {
column: 3,
row: 3,
variant: NoteVariant::Note
},
]
},
Measure {
size: 4,
events: vec![
Event {
column: 0,
row: 0,
variant: NoteVariant::Mine
},
Event {
column: 0,
row: 2,
variant: NoteVariant::Note
},
Event {
column: 1,
row: 2,
variant: NoteVariant::HoldStart
},
Event {
column: 2,
row: 2,
variant: NoteVariant::HoldOrRollEnd
},
Event {
column: 3,
row: 2,
variant: NoteVariant::RollStart
},
Event {
column: 0,
row: 3,
variant: NoteVariant::Lift
},
Event {
column: 1,
row: 3,
variant: NoteVariant::AutoKeysound
},
Event {
column: 2,
row: 3,
variant: NoteVariant::Mine
},
Event {
column: 3,
row: 3,
variant: NoteVariant::Fake
},
]
}
]
})
)
}
#[test]
fn infer_author_normal() {
assert_eq!(
Chart::infer_author("Songs/Tachyon Epsilon/Hello (Kommisar)/chart.sm"),
Some("Kommisar".into())
);
}
#[test]
fn infer_author_square() {
assert_eq!(
Chart::infer_author("Songs/Tachyon Epsilon/Hello [Kommisar]/chart.sm"),
Some("Kommisar".into())
);
}
#[test]
fn infer_author_start() {
assert_eq!(
Chart::infer_author("Songs/Tachyon Epsilon/(Kommisar) Hello/chart.sm"),
Some("Kommisar".into())
);
}
#[test]
fn infer_author_sq_start() {
assert_eq!(
Chart::infer_author("Songs/Tachyon Epsilon/[Kommisar] Hello/chart.sm"),
Some("Kommisar".into())
);
}
#[test]
fn infer_author_space() {
assert_eq!(
Chart::infer_author("Songs/Tachyon Epsilon/[ Kommisar ] Hello/chart.sm"),
Some("Kommisar".into())
);
}
#[test]
fn infer_author_space2() {
assert_eq!(
Chart::infer_author("Songs/Tachyon Epsilon/( Kommisar ) Hello/chart.sm"),
Some("Kommisar".into())
);
}
}