Skip to main content

rg_formats/
sm.rs

1//! Parsing, Processing and serializing of `.sm` files.
2//!
3//! This module handles parsing of `.sm` files. Parsing SSC is not yet supported.
4//! For parsing the raw `.msd` format that underpins these formats, see [`sm_msd`].
5
6use crate::{
7	sm_msd::{self, MsdElement, MsdFile},
8	utils::ByteString,
9};
10use std::{
11	ffi::{OsStr, OsString},
12	fmt::Display,
13	fs, io,
14	path::{Path, PathBuf},
15	str::Utf8Error,
16};
17use thiserror::Error;
18
19/// Parsing an SM file can fail in a couple of ways. Mainly nonsensical/invalid data.
20#[derive(Error, Debug, PartialEq, Eq)]
21pub enum LoadError {
22	/// This simply wasn't valid SM. This could be anything like having a bpm of `-1`,
23	/// having a bpm that wasn't a parsable number, like the empty string,
24	/// or not having enough fields in #NOTES.
25	#[error("this was not valid sm ({0})")]
26	InvalidSM(String),
27
28	/// We expected valid UTF-8 at some point in the process, but got something else.
29	/// This can happen if non-utf8 is placed into the BPM or OFFSET fields.
30	#[error("expected {0} to parse as utf-8, got Utf8Error {1}")]
31	UnexpectedNonUtf8(String, Utf8Error),
32}
33
34/// A bpm change in the SM format.
35#[derive(Debug, Clone, PartialEq)]
36pub struct Bpm {
37	/// The BPM this chart is as a float.
38	pub bpm: f64,
39	/// When this BPM occurs. The first BPM **must** occur at 0.0.
40	pub offset_beats: f64,
41}
42
43impl Bpm {
44	fn from_bytes(bytes: &[u8]) -> Result<Self, LoadError> {
45		let str = match std::str::from_utf8(bytes) {
46			Ok(str) => str,
47			Err(utf8err) => return Err(LoadError::UnexpectedNonUtf8("BPM".into(), utf8err)),
48		};
49
50		let elements = str.split('=').collect::<Vec<&str>>();
51
52		if elements.len() < 2 {
53			return Err(LoadError::InvalidSM(format!(
54				"Invalid SM BPM string '{}'.",
55				str
56			)));
57		}
58
59		let (offset, bpm) = (elements[0], elements[1]);
60
61		let (offset_beats, _) = match lexical::parse_partial(offset) {
62			Ok(v) => v,
63			Err(_) => {
64				return Err(LoadError::InvalidSM(format!(
65					"Failed to parse {offset} as a float."
66				)))
67			}
68		};
69
70		let (bpm, _) = match lexical::parse_partial(bpm) {
71			Ok(v) => v,
72			Err(_) => {
73				return Err(LoadError::InvalidSM(format!(
74					"Failed to parse {bpm} as a float."
75				)))
76			}
77		};
78
79		if bpm <= 0.0 {
80			return Err(LoadError::InvalidSM(format!("BPM was negative ({bpm})")));
81		}
82
83		Ok(Bpm { bpm, offset_beats })
84	}
85}
86
87/// Various parsed metadata and tags from the SM file.
88#[derive(Debug, Clone)]
89pub struct SongMetadata {
90	/// The offset (in seconds) that this chart has. This is a parsed variant of the
91	/// `#OFFSET` tag, and is multiplied by -1.0 versus the actual stored format.
92	pub offset_secs: Option<f64>,
93
94	/// All the bpm changes in this chart.
95	pub bpms: Vec<Bpm>,
96
97	/// The subtitle for this chart, if it had one. This is converted into utf8.
98	pub subtitle: Option<String>,
99	/// The artist for this chart. This is parsed as utf8 lossily.
100	pub artist: String,
101	/// The title for this chart. This is parsed as utf8 lossily.
102	pub title: String,
103	/// The path to the audio file for this chart. This is a raw strip of the bytes that
104	/// were in the `#MUSIC` tag, instead of being converted into utf8.
105	///
106	/// **NOTE:** The actual value of this field - in the file - is pretty much irrelevant
107	/// because Stepmania runs inference in the case where it doesn't point to an audio
108	/// file. We basically ignore this field unless it *definitely* 100% points to a
109	/// real audio file.
110	pub music: Option<OsString>,
111}
112
113/// A complete SM chart, with song metadata and the chart data.
114#[derive(Debug, Clone)]
115pub struct Chart {
116	/// all metadata on this chart. **All keys are UPPERCASED!**.
117	pub tags: MsdFile,
118
119	/// Info about the song this chart belonged to.
120	pub song_info: SongMetadata,
121	/// Info about the actual chart itself.
122	pub chart_data: ChartData,
123	/// Where this chart was loaded from on disk.
124	pub path: PathBuf,
125}
126
127macro_rules! msd_tag_fallback {
128	($metadata: expr, $tag: expr, $fallback: expr) => {{
129		match $metadata.first_tag_first_val($tag) {
130			Some(slice) => String::from_utf8_lossy(&slice).into_owned(),
131			None => $fallback.to_owned(),
132		}
133	}};
134}
135
136macro_rules! msd_tag {
137	($metadata: expr, $tag: expr) => {{
138		match $metadata.first_tag_first_val($tag) {
139			Some(slice) => Some(String::from_utf8_lossy(&slice).into_owned()),
140			None => None,
141		}
142	}};
143}
144
145impl Chart {
146	/// Try and infer the author of this chart from the file path.
147	///
148	/// More specifically, this infers from the *directory* name that contains this
149	/// SM file, and not the name of the SM file itself.
150	///
151	/// As such, the provided file path should look like:
152	///
153	/// ```txt
154	/// "Hello (Kommisar)/chart.sm"
155	/// ```
156	///
157	/// Authors are usually placed in the folder name in one of four forms:
158	/// Chart Name (Charter)
159	/// Chart Name [Charter]
160	/// (Charter) Chart Name
161	/// [Charter] Chart Name
162	///
163	/// The latter three are *very* old and non-standard, but we support them
164	/// for compatibility.
165	///
166	/// Sometimes, this function fails to correctly infer this information
167	/// as some songs have brackets in them and the charter isn't mentioned in there
168	/// This can happen if the folder name is something like:
169	///
170	/// Song Title (Speed Up Ver.)
171	///
172	/// However, it's rare for those charts to not have an author attached.
173	fn infer_author(path: impl AsRef<Path>) -> Option<String> {
174		let path = path.as_ref().clone();
175
176		let name = path.parent()?.file_name()?;
177		let name = OsStr::to_string_lossy(name);
178
179		use regex::Regex;
180
181		let standard = Regex::new(r"\((.+)\) *$").expect("Invalid standard regex.");
182		let square = Regex::new(r"\[(.+)\] *$").expect("Invalid square regex.");
183		let std_start = Regex::new(r"^ *\((.+)\)").expect("Invalid std_start regex.");
184		let sqr_start = Regex::new(r"^ *\[(.+)\]").expect("Invalid sqr_start regex.");
185
186		if let Some(m) = standard.captures(&name) {
187			return m.get(1).map(|f| f.as_str().trim().to_owned());
188		}
189
190		if let Some(m) = square.captures(&name) {
191			return m.get(1).map(|f| f.as_str().trim().to_owned());
192		}
193
194		if let Some(m) = std_start.captures(&name) {
195			return m.get(1).map(|f| f.as_str().trim().to_owned());
196		}
197
198		if let Some(m) = sqr_start.captures(&name) {
199			return m.get(1).map(|f| f.as_str().trim().to_owned());
200		}
201
202		None
203	}
204
205	/// Look for an audio file in this directory. This does not do any recursion, and
206	/// looks for anything with an `.mp3`, `.ogg`, `.wav` or `.oga` extension.
207	///
208	/// Returns None if the file could not be found. Also returns None if we failed to
209	/// read the directory in question.
210	fn look_for_audio(path: impl AsRef<Path>) -> Option<OsString> {
211		let dir = path.as_ref().parent()?;
212
213		let entries = fs::read_dir(dir).ok()?;
214
215		for entry in entries.flatten() {
216			match entry.path().extension().and_then(OsStr::to_str) {
217				// see "FT_SOUND" in the sm codebase.
218				Some("ogg" | "mp3" | "wav" | "oga") => return Some(entry.file_name()),
219				_ => continue,
220			}
221		}
222
223		None
224	}
225}
226
227/// Load an SM file from a path.
228///
229/// This returns an error if the file was unable to be read.
230///
231/// This returns an inner error if the file was able to be read, but the contents
232/// were not valid SM.
233///
234/// In the event that one chart fails to parse, all charts fail to parse.
235pub fn from_path(sm_path: impl AsRef<Path>) -> io::Result<Result<Vec<Chart>, LoadError>> {
236	let bytes = fs::read(&sm_path)?;
237
238	Ok(from_bytes(&bytes, sm_path))
239}
240
241/// Load an SM file from bytes.
242///
243/// This method is kind of useless, as to parse the sm file we **need** to know its
244/// location on disk (for resolving #MUSIC and other information).
245///
246/// You probably want [`from_path`]. This method is only publically exposed for testing
247/// reasons, where you might want the byte content and the path to be disjoint.
248///
249/// In the event that one chart fails to parse, all charts fail to parse.
250pub fn from_bytes(bytes: &[u8], sm_path: impl AsRef<Path>) -> Result<Vec<Chart>, LoadError> {
251	let (metadata, charts) = parse(bytes);
252
253	let bpms: Vec<Bpm> = match metadata.first_tag_first_val("BPMS") {
254		Some(bpm_str) => parse_bpms(&bpm_str).unwrap_or(vec![Bpm {
255			bpm: 60.0,
256			offset_beats: 0.0,
257		}]),
258		None => vec![Bpm {
259			bpm: 60.0,
260			offset_beats: 0.0,
261		}],
262	};
263
264	let offset = match metadata.first_tag_first_val("OFFSET") {
265		Some(v) => {
266			let str = match std::str::from_utf8(&v) {
267				Ok(s) => s,
268				Err(err) => return Err(LoadError::UnexpectedNonUtf8("OFFSET".into(), err)),
269			};
270
271			match lexical::parse_partial::<f64, &str>(str) {
272				Ok((float, _)) => {
273					// an offset of 0 is irrelevant
274					// n.b. you can't have floats in match arms (lol)
275					if float == 0.0 {
276						None
277					} else {
278						// i have literally no idea why SM stores offset * -1
279						// the sm codebase *also* immediately multiplies by -1 so
280						// who knows, lol
281						Some(-1.0 * float)
282					}
283				}
284
285				// an invalid offset is treated as 0 offset.
286				Err(_) => None,
287			}
288		}
289		None => None,
290	};
291
292	let music_path = metadata.first_tag_first_val("MUSIC").map(|bytes| {
293		// we have arbitrary bytes. lets try and shunt these into a path.
294		// if we can't do that, we can't find the audio file
295
296		#[cfg(unix)]
297		{
298			use std::os::unix::ffi::OsStrExt;
299
300			OsStr::from_bytes(&bytes).to_owned()
301		}
302
303		#[cfg(windows)]
304		{
305			use std::os::windows::ffi::OsStrExt;
306
307			// probably wide bytes. might not be. I actually don't know if this is
308			// correct or not.
309			OsString::from_wide(&bytes)
310		}
311	});
312
313	let music_path = music_path.map(|os_str| {
314		let path = sm_path.as_ref().to_path_buf();
315		let path = path.join(&os_str);
316
317		if path.exists() {
318			os_str
319		} else {
320			// great. now we have to look for the audio file using stepmania error
321			// correction.
322			match Chart::look_for_audio(&sm_path) {
323				Some(data) => data,
324				// whatever. can't find anything to error correct on, just blindly
325				// believe whatever the MUSIC tag says.
326				None => os_str,
327			}
328		}
329	});
330
331	let song_info = SongMetadata {
332		artist: msd_tag_fallback!(metadata, "ARTIST", "Unknown Artist"),
333		title: match metadata.first_tag_first_val("TITLE") {
334			Some(v) => String::from_utf8_lossy(&v).into_owned(),
335			None => {
336				// gotta infer the song title from the file path, since it wasn't
337				// specified in the file.
338
339				let mut path = sm_path.as_ref().to_path_buf();
340
341				path.pop();
342
343				match path.file_name() {
344					Some(name) => name.to_string_lossy().into_owned(),
345					None => "Untitled Song".to_owned(),
346				}
347			}
348		},
349		subtitle: msd_tag!(metadata, "SUBTITLE"),
350		bpms,
351		music: music_path,
352		offset_secs: offset,
353	};
354
355	let mut ok_charts = vec![];
356
357	for chart in charts {
358		// if any chart failed to parse, bail out.
359		let mut chart = chart?;
360
361		// even if we know who made this chart, we should ignore
362		// anything that says "Blank" or "Copied From", as
363		// that basically also means unknown.
364		if chart.author.is_empty() || &*chart.author == b"Copied From" || &*chart.author == b"Blank"
365		{
366			if let Some(inferred_name) = Chart::infer_author(&sm_path) {
367				chart.author = inferred_name.as_bytes().into();
368			}
369		}
370
371		let full_file = Chart {
372			tags: metadata.clone(),
373			song_info: song_info.clone(),
374			chart_data: chart,
375			path: sm_path.as_ref().to_path_buf(),
376		};
377
378		ok_charts.push(full_file);
379	}
380
381	Ok(ok_charts)
382}
383
384/// This is all the possible modes SM supports as of 2023/07/27.
385///
386/// There are still potentially more than this, for those cases they fall into "Unknown".
387///
388/// Note that most of these modes are effectively useless, and have never been played or
389/// even tested by anyone. I'm not even honestly sure why I bothered writing them all out,
390/// but it's done now.
391#[derive(Debug, Clone, PartialEq, Eq)]
392#[allow(missing_docs)]
393pub enum StepsType {
394	DanceSingle,
395	DanceDouble,
396	DanceCouple,
397	DanceSolo,
398	DanceThreepanel,
399	DanceRoutine,
400	PumpSingle,
401	PumpHalfDouble,
402	PumpDouble,
403	PumpCouple,
404	PumpRoutine,
405	Kb7Single,
406	Ez2Single,
407	Ez2Double,
408	Ez2Real,
409	ParaSingle,
410	Ds3ddxSingle,
411	BmSingle5,
412	BmVersus5,
413	BmDouble5,
414	BmSingle7,
415	BmVersus7,
416	BmDouble7,
417	ManiaxSingle,
418	ManiaxDouble,
419	TechnoSingle4,
420	TechnoSingle5,
421	TechnoSingle8,
422	TechnoDouble4,
423	TechnoDouble5,
424	TechnoDouble8,
425	PnmFive,
426	PnmNine,
427	LightsCabinet,
428	KickboxHuman,
429	KickboxQuadarm,
430	KickboxInsect,
431	KickboxArachnid,
432
433	/// Some unknown gamemode.
434	Other(ByteString),
435}
436
437impl Display for StepsType {
438	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439		let str = match self {
440			StepsType::DanceSingle => "dance-single".into(),
441			StepsType::DanceDouble => "dance-double".into(),
442			StepsType::DanceCouple => "dance-couple".into(),
443			StepsType::DanceSolo => "dance-solo".into(),
444			StepsType::DanceThreepanel => "dance-threepanel".into(),
445			StepsType::DanceRoutine => "dance-routine".into(),
446			StepsType::PumpSingle => "pump-single".into(),
447			StepsType::PumpHalfDouble => "pump-halfdouble".into(),
448			StepsType::PumpDouble => "pump-double".into(),
449			StepsType::PumpCouple => "pump-couple".into(),
450			StepsType::PumpRoutine => "pump-routine".into(),
451			StepsType::Kb7Single => "kb7-single".into(),
452			StepsType::Ez2Single => "ez2-single".into(),
453			StepsType::Ez2Double => "ez2-double".into(),
454			StepsType::Ez2Real => "ez2-real".into(),
455			StepsType::ParaSingle => "para-single".into(),
456			StepsType::Ds3ddxSingle => "ds3ddx-single".into(),
457			StepsType::BmSingle5 => "bm-single5".into(),
458			StepsType::BmVersus5 => "bm-versus5".into(),
459			StepsType::BmDouble5 => "bm-double5".into(),
460			StepsType::BmSingle7 => "bm-single7".into(),
461			StepsType::BmVersus7 => "bm-versus7".into(),
462			StepsType::BmDouble7 => "bm-double7".into(),
463			StepsType::ManiaxSingle => "maniax-single".into(),
464			StepsType::ManiaxDouble => "maniax-double".into(),
465			StepsType::TechnoSingle4 => "techno-single4".into(),
466			StepsType::TechnoSingle5 => "techno-single5".into(),
467			StepsType::TechnoSingle8 => "techno-single8".into(),
468			StepsType::TechnoDouble4 => "techno-double4".into(),
469			StepsType::TechnoDouble5 => "techno-double5".into(),
470			StepsType::TechnoDouble8 => "techno-double8".into(),
471			StepsType::PnmFive => "pnm-five".into(),
472			StepsType::PnmNine => "pnm-nine".into(),
473			StepsType::LightsCabinet => "lights-cabinet".into(),
474			StepsType::KickboxHuman => "kickbox-human".into(),
475			StepsType::KickboxQuadarm => "kickbox-quadarm".into(),
476			StepsType::KickboxInsect => "kickbox-insect".into(),
477			StepsType::KickboxArachnid => "kickbox-arachnid".into(),
478			StepsType::Other(a) => String::from_utf8_lossy(a).into_owned(),
479		};
480
481		f.write_str(&str)
482	}
483}
484
485impl StepsType {
486	fn from_bytes(bytes: &ByteString) -> Self {
487		match &**bytes {
488			b"dance-single" => StepsType::DanceSingle,
489			b"dance-double" => StepsType::DanceDouble,
490			b"dance-couple" => StepsType::DanceCouple,
491			b"dance-solo" => StepsType::DanceSolo,
492			b"dance-threepanel" => StepsType::DanceThreepanel,
493			b"dance-routine" => StepsType::DanceRoutine,
494			b"pump-single" => StepsType::PumpSingle,
495			b"pump-halfdouble" => StepsType::PumpHalfDouble,
496			b"pump-double" => StepsType::PumpDouble,
497			b"pump-couple" => StepsType::PumpCouple,
498			b"pump-routine" => StepsType::PumpRoutine,
499			b"kb7-single" => StepsType::Kb7Single,
500			b"ez2-single" => StepsType::Ez2Single,
501			b"ez2-double" => StepsType::Ez2Double,
502			b"ez2-real" => StepsType::Ez2Real,
503			b"para-single" => StepsType::ParaSingle,
504			b"ds3ddx-single" => StepsType::Ds3ddxSingle,
505			b"bm-single5" => StepsType::BmSingle5,
506			b"bm-versus5" => StepsType::BmVersus5,
507			b"bm-double5" => StepsType::BmDouble5,
508			b"bm-single7" => StepsType::BmSingle7,
509			b"bm-versus7" => StepsType::BmVersus7,
510			b"bm-double7" => StepsType::BmDouble7,
511			b"maniax-single" => StepsType::ManiaxSingle,
512			b"maniax-double" => StepsType::ManiaxDouble,
513			b"techno-single4" => StepsType::TechnoSingle4,
514			b"techno-single5" => StepsType::TechnoSingle5,
515			b"techno-single8" => StepsType::TechnoSingle8,
516			b"techno-double4" => StepsType::TechnoDouble4,
517			b"techno-double5" => StepsType::TechnoDouble5,
518			b"techno-double8" => StepsType::TechnoDouble8,
519			b"pnm-five" => StepsType::PnmFive,
520			b"pnm-nine" => StepsType::PnmNine,
521			b"lights-cabinet" => StepsType::LightsCabinet,
522			b"kickbox-human" => StepsType::KickboxHuman,
523			b"kickbox-quadarm" => StepsType::KickboxQuadarm,
524			b"kickbox-insect" => StepsType::KickboxInsect,
525			b"kickbox-arachnid" => StepsType::KickboxArachnid,
526			_ => StepsType::Other(bytes.clone()),
527		}
528	}
529}
530
531/// There are 5 possible difficulties for an SM chart, which correspond to multiple
532/// possible names in the format.
533///
534/// There is also a 6th overflow difficulty, called "Edit". This takes one argument
535/// which disambiguates further, as multiple edits are legal for the same song.
536#[derive(Debug, PartialEq, Eq, Clone)]
537pub enum Difficulty {
538	/// This is a beginner chart.
539	Beginner,
540	/// This is an easy, basic or light chart.
541	Easy,
542	/// This is a medium, another, trick, standard or difficult chart.
543	Medium,
544	/// This is a hard, ssr, maniac or heavy chart.
545	Hard,
546	/// This is a challenge, expert or oni chart.
547	Challenge,
548	/// This is an edit chart. The `Author` information becomes part of the difficulty
549	/// name for disambiguation between multiple Edit charts.
550	Edit(ByteString),
551}
552
553impl Display for Difficulty {
554	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
555		use Difficulty::*;
556
557		let str = match self {
558			Beginner => "Beginner".into(),
559			Easy => "Easy".into(),
560			Medium => "Medium".into(),
561			Hard => "Hard".into(),
562			Challenge => "Challenge".into(),
563			Edit(txt) => format!("Edit {}", String::from_utf8_lossy(txt)),
564		};
565
566		write!(f, "{str}")
567	}
568}
569
570/// Actual chart/notes data for an SM file. This has no song metadata attached onto it.
571/// For a convenient combination of [`ChartData`] and [`SongMetadata`], see [`Chart`].
572#[derive(Debug, Clone, PartialEq, Eq)]
573pub struct ChartData {
574	/// What [`StepsType`] this notedata says it is.
575	pub steps_type: StepsType,
576	/// Who made this chart. If this isn't present in the file, or is set to "Copied From"
577	/// or "Blank", this is inferred from the name of the folder.
578	pub author: ByteString,
579	/// What difficulty this chart is.
580	pub difficulty: Difficulty,
581
582	/// What level this chart is. Negative numbers and 0 are converted into `1`.
583	pub level: usize,
584
585	/// The actual note data in parsed form. It's rare, but if you *really* need to access
586	/// the raw note data, you can use [`fn@SongData::raw_notedata()`].
587	pub notedata: Vec<Measure>,
588}
589
590/// An event in SM is one of the following variants.
591#[derive(Debug, PartialEq, Clone, Eq)]
592pub enum NoteVariant {
593	/// A note was here. This corresponds to "1" in the SM file.
594	Note,
595	/// A hold started here. This corresponds to "2" in the SM file.
596	HoldStart,
597	/// A roll started here. This corresponds to "4" in the SM file.
598	RollStart,
599	/// A hold or roll was terminated here. This corresponds to "3" in the SM file.
600	HoldOrRollEnd,
601
602	/// A keysound should trigger here. This corresponds to "K" in the SM file.
603	AutoKeysound,
604	/// A lift was here. This corresponds to "L" in the SM file.
605	Lift,
606	/// A fake was here. This corresponds to "F" in the SM file.
607	Fake,
608	/// A mine was here. This corresponds to "M" in the SM file.
609	Mine,
610
611	/// An unknown note type was here -- any char that doesn't match one of the other
612	/// kinds.
613	Unknown(u8),
614}
615
616/// An event (note, hold start, mine, etc.) that happened in an SM chart's notedata.
617/// This tells you what row it occured on, what column it occured on, and what kind
618/// of note it was.
619#[derive(Debug, PartialEq, Clone, Eq)]
620pub struct Event {
621	/// What row (into the measure) this event occured on. How far this is into the chart
622	/// is relative to the containing [`Measure`]s `size` property.
623	pub row: usize,
624
625	/// What column this note occured on. This is indexed from 0.
626	///
627	/// NOTE: there is absolutely no guarantee how many columns can appear in a chart.
628	/// A chart is totally within its right to change how many columns it has at any time,
629	/// for any reason. SM simply discards columns it doesn't care for. You should likely
630	/// do the same.
631	pub column: usize,
632
633	/// What kind of event this was.
634	pub variant: NoteVariant,
635}
636
637/// A measure in an SM file. This is a collection of `size` rows, with `events` happening
638/// on a given column with a given type.
639#[derive(Debug, PartialEq, Clone, Eq)]
640pub struct Measure {
641	/// How many rows were in this measure.
642	pub size: usize,
643	/// What events are in this measure. See [`Event`] for more information.
644	pub events: Vec<Event>,
645}
646
647fn parse_notedata(raw_notedata: &[u8]) -> Vec<Measure> {
648	let mut measures = vec![];
649
650	for measure in raw_notedata.split(|char| *char == b',') {
651		let mut size = 0;
652		let mut events = vec![];
653
654		for row in measure.split(|char| *char == b'\n') {
655			let row = row.trim_ascii();
656
657			if row.is_empty() {
658				continue;
659			}
660
661			// we can't use enumerate because of stupid SM functionality
662			// where you can strap keysounds onto column events.
663			let mut skip_until_closebrck = false;
664			let mut index = 0;
665
666			for ch in row.iter() {
667				// Stepmania supports annotating events with keysounds
668				// but basically nobody has ever used this feature and it is deep
669				// in the guts of the SM codebase.
670				//
671				// the syntax for this is `1[0]001`, which corresponds to `1001`, but
672				//                                                         ^
673				//          this guy has the 0th keysound associated with it.
674				//
675				// as such, if we see [ we skip until ]. simple.
676				if *ch == b'[' {
677					skip_until_closebrck = true;
678					continue;
679				}
680
681				if *ch == b']' {
682					skip_until_closebrck = false;
683					continue;
684				}
685
686				if skip_until_closebrck {
687					continue;
688				}
689
690				let variant = match ch {
691					// skip all non-data.
692					b'0' => {
693						index += 1;
694						continue;
695					}
696					b'1' => NoteVariant::Note,
697					b'2' => NoteVariant::HoldStart,
698					b'3' => NoteVariant::HoldOrRollEnd,
699					b'4' => NoteVariant::RollStart,
700					b'M' => NoteVariant::Mine,
701					b'K' => NoteVariant::AutoKeysound,
702					b'L' => NoteVariant::Lift,
703					b'F' => NoteVariant::Fake,
704					ch => NoteVariant::Unknown(*ch),
705				};
706
707				events.push(Event {
708					row: size,
709					column: index,
710					variant,
711				});
712
713				index += 1;
714			}
715
716			size += 1;
717		}
718
719		if size == 0 {
720			// entire measure is empty?
721			continue;
722		}
723
724		measures.push(Measure { size, events })
725	}
726
727	measures
728}
729
730/// Actually parse all the `#NOTES` tags in this SM file. Since technically all of these
731/// can fail independently, this returns a vector of results.
732fn parse(sm: &[u8]) -> (MsdFile, Vec<Result<ChartData, LoadError>>) {
733	let msd_file = sm_msd::from_bytes(sm);
734
735	let charts = msd_file
736		.all_with_tag("NOTES")
737		.iter()
738		.map(|el| parse_notes(el))
739		.collect();
740
741	(msd_file, charts)
742}
743
744fn parse_bpms(bpms: &[u8]) -> Result<Vec<Bpm>, LoadError> {
745	let mut bpm_vec = vec![];
746
747	for bpm in bpms.split(|by| *by == b',') {
748		match Bpm::from_bytes(bpm.trim_ascii()) {
749			Ok(v) => bpm_vec.push(v),
750			Err(err) => return Err(err),
751		}
752	}
753
754	Ok(bpm_vec)
755}
756
757fn parse_notes(el: &MsdElement) -> Result<ChartData, LoadError> {
758	// this is absolutely *unbelievably* ridiculous. We expect exactly 6 values.
759	//
760	// A lot of charts (for whatever reason) in modern packs have comments like this
761	// //--------------- dance-single - sorae 80/31
762	// ---------------
763	// The newline means the comment doesn't tear out the whole thing
764	// and there's a trailing "-------" in the list of values
765	// as such, even though we only expect 6 params
766	// we may find more than that. that's completely fine, stepmania will accept it.
767	if el.values.len() < 6 {
768		return Err(LoadError::InvalidSM(format!(
769			"Invalid amount of fields inside #NOTES. Got {}, expected at least 6.",
770			el.values.len()
771		)));
772	}
773
774	let fields = &el.values;
775
776	let steps_type = StepsType::from_bytes(&fields[0]);
777
778	let author = &fields[1];
779	let diff = &fields[2];
780
781	let difficulty = match diff.to_ascii_lowercase().as_slice() {
782		b"beginner" => Difficulty::Beginner,
783		b"easy" | b"basic" | b"light" => Difficulty::Easy,
784		b"medium" | b"another" | b"trick" | b"standard" | b"difficult" => Difficulty::Medium,
785		b"hard" | b"ssr" | b"maniac" | b"heavy" => Difficulty::Hard,
786		b"challenge" | b"expert" | b"oni" => Difficulty::Challenge,
787		b"edit" => Difficulty::Edit(author.clone()),
788		d => {
789			return Err(LoadError::InvalidSM(format!(
790				"Unknown difficulty {}",
791				String::from_utf8_lossy(d),
792			)))
793		}
794	};
795
796	let level = String::from_utf8_lossy(&fields[3]).parse().unwrap_or(1);
797
798	let raw_notedata = &fields[5];
799
800	let notedata = parse_notedata(raw_notedata);
801
802	Ok(ChartData {
803		steps_type,
804		author: author.clone(),
805		difficulty,
806		level,
807		notedata,
808	})
809}
810
811#[cfg(test)]
812mod tests {
813	use pretty_assertions::assert_eq;
814
815	use super::*;
816
817	#[test]
818	fn bpms() {
819		assert_eq!(
820			parse_bpms(b"0.000=104.03"),
821			Ok(vec![Bpm {
822				bpm: 104.03,
823				offset_beats: 0.0
824			}])
825		);
826
827		assert_eq!(
828			parse_bpms(b"0.000=104.03,1.000=400"),
829			Ok(vec![
830				Bpm {
831					bpm: 104.03,
832					offset_beats: 0.0
833				},
834				Bpm {
835					bpm: 400.00,
836					offset_beats: 1.0
837				}
838			])
839		);
840
841		assert_eq!(
842			parse_bpms(b"0.000=-104.03"),
843			Err(LoadError::InvalidSM("BPM was negative (-104.03)".into()))
844		);
845	}
846
847	#[test]
848	fn bpm_partial() {
849		assert_eq!(
850			parse_bpms(b"0.000=123.456.789"),
851			Ok(vec![Bpm {
852				bpm: 123.456,
853				offset_beats: 0.0
854			}])
855		);
856	}
857
858	#[test]
859	fn load_notes() {
860		assert_eq!(
861			parse_notes(&MsdElement {
862				tag: Box::new(*b"NOTES"),
863				values: vec![
864					Box::new(*b"dance-single"),
865					Box::new(*b"Author"),
866					Box::new(*b"Hard"),
867					Box::new(*b"1"),
868					Box::new(*b"nonsense groove"),
869					Box::new(
870						*b"1000
8710100
8720010
8730001,
874M000
87500000
8761234
877LKMF"
878					)
879				]
880			}),
881			Ok(ChartData {
882				steps_type: StepsType::DanceSingle,
883				author: Box::new(*b"Author"),
884				difficulty: Difficulty::Hard,
885				level: 1,
886				notedata: vec![
887					Measure {
888						size: 4,
889						events: vec![
890							Event {
891								column: 0,
892								row: 0,
893								variant: NoteVariant::Note
894							},
895							Event {
896								column: 1,
897								row: 1,
898								variant: NoteVariant::Note
899							},
900							Event {
901								column: 2,
902								row: 2,
903								variant: NoteVariant::Note
904							},
905							Event {
906								column: 3,
907								row: 3,
908								variant: NoteVariant::Note
909							},
910						]
911					},
912					Measure {
913						size: 4,
914						events: vec![
915							Event {
916								column: 0,
917								row: 0,
918								variant: NoteVariant::Mine
919							},
920							Event {
921								column: 0,
922								row: 2,
923								variant: NoteVariant::Note
924							},
925							Event {
926								column: 1,
927								row: 2,
928								variant: NoteVariant::HoldStart
929							},
930							Event {
931								column: 2,
932								row: 2,
933								variant: NoteVariant::HoldOrRollEnd
934							},
935							Event {
936								column: 3,
937								row: 2,
938								variant: NoteVariant::RollStart
939							},
940							Event {
941								column: 0,
942								row: 3,
943								variant: NoteVariant::Lift
944							},
945							Event {
946								column: 1,
947								row: 3,
948								variant: NoteVariant::AutoKeysound
949							},
950							Event {
951								column: 2,
952								row: 3,
953								variant: NoteVariant::Mine
954							},
955							Event {
956								column: 3,
957								row: 3,
958								variant: NoteVariant::Fake
959							},
960						]
961					}
962				]
963			})
964		)
965	}
966
967	#[test]
968	fn load_notes_obscurekeysounds() {
969		assert_eq!(
970			parse_notes(&MsdElement {
971				tag: Box::new(*b"NOTES"),
972				values: vec![
973					Box::new(*b"dance-single"),
974					Box::new(*b"Author"),
975					Box::new(*b"Hard"),
976					Box::new(*b"1"),
977					Box::new(*b"nonsense groove"),
978					Box::new(
979						*b"1000
9800100[1]
981001[100000]0
9820001[1,
983[1]M000
98400[1]000
985123[999}>)]4
986LKMF"
987					)
988				]
989			}),
990			Ok(ChartData {
991				steps_type: StepsType::DanceSingle,
992				author: Box::new(*b"Author"),
993				difficulty: Difficulty::Hard,
994				level: 1,
995				notedata: vec![
996					Measure {
997						size: 4,
998						events: vec![
999							Event {
1000								column: 0,
1001								row: 0,
1002								variant: NoteVariant::Note
1003							},
1004							Event {
1005								column: 1,
1006								row: 1,
1007								variant: NoteVariant::Note
1008							},
1009							Event {
1010								column: 2,
1011								row: 2,
1012								variant: NoteVariant::Note
1013							},
1014							Event {
1015								column: 3,
1016								row: 3,
1017								variant: NoteVariant::Note
1018							},
1019						]
1020					},
1021					Measure {
1022						size: 4,
1023						events: vec![
1024							Event {
1025								column: 0,
1026								row: 0,
1027								variant: NoteVariant::Mine
1028							},
1029							Event {
1030								column: 0,
1031								row: 2,
1032								variant: NoteVariant::Note
1033							},
1034							Event {
1035								column: 1,
1036								row: 2,
1037								variant: NoteVariant::HoldStart
1038							},
1039							Event {
1040								column: 2,
1041								row: 2,
1042								variant: NoteVariant::HoldOrRollEnd
1043							},
1044							Event {
1045								column: 3,
1046								row: 2,
1047								variant: NoteVariant::RollStart
1048							},
1049							Event {
1050								column: 0,
1051								row: 3,
1052								variant: NoteVariant::Lift
1053							},
1054							Event {
1055								column: 1,
1056								row: 3,
1057								variant: NoteVariant::AutoKeysound
1058							},
1059							Event {
1060								column: 2,
1061								row: 3,
1062								variant: NoteVariant::Mine
1063							},
1064							Event {
1065								column: 3,
1066								row: 3,
1067								variant: NoteVariant::Fake
1068							},
1069						]
1070					}
1071				]
1072			})
1073		)
1074	}
1075
1076	#[test]
1077	fn infer_author_normal() {
1078		assert_eq!(
1079			Chart::infer_author("Songs/Tachyon Epsilon/Hello (Kommisar)/chart.sm"),
1080			Some("Kommisar".into())
1081		);
1082	}
1083
1084	#[test]
1085	fn infer_author_square() {
1086		assert_eq!(
1087			Chart::infer_author("Songs/Tachyon Epsilon/Hello [Kommisar]/chart.sm"),
1088			Some("Kommisar".into())
1089		);
1090	}
1091
1092	#[test]
1093	fn infer_author_start() {
1094		assert_eq!(
1095			Chart::infer_author("Songs/Tachyon Epsilon/(Kommisar) Hello/chart.sm"),
1096			Some("Kommisar".into())
1097		);
1098	}
1099
1100	#[test]
1101	fn infer_author_sq_start() {
1102		assert_eq!(
1103			Chart::infer_author("Songs/Tachyon Epsilon/[Kommisar] Hello/chart.sm"),
1104			Some("Kommisar".into())
1105		);
1106	}
1107
1108	#[test]
1109	fn infer_author_space() {
1110		assert_eq!(
1111			Chart::infer_author("Songs/Tachyon Epsilon/[ Kommisar ] Hello/chart.sm"),
1112			Some("Kommisar".into())
1113		);
1114	}
1115
1116	#[test]
1117	fn infer_author_space2() {
1118		assert_eq!(
1119			Chart::infer_author("Songs/Tachyon Epsilon/( Kommisar ) Hello/chart.sm"),
1120			Some("Kommisar".into())
1121		);
1122	}
1123}