Skip to main content

rhythm_open_exchange/codec/formats/sm/
decoder.rs

1#![allow(clippy::doc_markdown)]
2//! Decoder for converting StepMania (`.sm`) files to `RoxChart`.
3
4use crate::codec::Decoder;
5use crate::error::RoxResult;
6use crate::model::{Metadata, Note, RoxChart, TimingPoint};
7
8use super::parser;
9use super::types::{SmChart, SmFile, SmNoteType};
10
11/// Decoder for StepMania (`.sm`) beatmaps.
12pub struct SmDecoder;
13
14impl SmDecoder {
15    /// Convert an `SmFile` to a `RoxChart`.
16    ///
17    /// If the file contains multiple charts, this returns the first one.
18    /// Use `decode_chart` to decode a specific chart.
19    #[must_use]
20    pub fn from_file(sm: &SmFile) -> Option<RoxChart> {
21        sm.charts.first().map(|chart| Self::from_chart(sm, chart))
22    }
23
24    /// Convert a specific chart from an `SmFile` to a `RoxChart`.
25    #[must_use]
26    pub fn from_chart(sm: &SmFile, chart: &SmChart) -> RoxChart {
27        let mut rox = RoxChart::new(chart.column_count);
28
29        // Map metadata
30        rox.metadata = Metadata {
31            key_count: chart.column_count,
32            title: sm.metadata.title.clone().into(),
33            artist: sm.metadata.artist.clone().into(),
34            creator: sm.metadata.credit.clone().into(),
35            difficulty_name: chart.difficulty.clone().into(),
36            #[allow(clippy::cast_precision_loss)]
37            difficulty_value: Some(chart.meter as f32),
38            audio_file: sm.metadata.music.clone().into(),
39            background_file: if sm.metadata.background.is_empty() {
40                None
41            } else {
42                Some(sm.metadata.background.clone().into())
43            },
44            audio_offset_us: -sm.offset_us,
45            #[allow(clippy::cast_possible_truncation)]
46            preview_time_us: (sm.metadata.sample_start * 1_000_000.0) as i64,
47            #[allow(clippy::cast_possible_truncation)]
48            preview_duration_us: (sm.metadata.sample_length * 1_000_000.0) as i64,
49            source: Some(sm.metadata.banner.clone().into()),
50            genre: None,
51            language: None,
52            tags: Vec::new(),
53            is_coop: false,
54            ..Default::default()
55        };
56
57        // Convert BPM timing points
58        for (time_us, bpm) in &sm.bpms {
59            rox.timing_points.push(TimingPoint::bpm(*time_us, *bpm));
60        }
61
62        // Convert notes
63        // We need to track hold/roll heads to pair with tails
64        let mut pending_holds: Vec<(i64, u8)> = Vec::new(); // (start_time, column)
65        let mut pending_rolls: Vec<(i64, u8)> = Vec::new(); // (start_time, column)
66
67        // Sort notes by time, then column for consistent processing
68        let mut sorted_notes = chart.notes.clone();
69        sorted_notes.sort_by(|a, b| a.time_us.cmp(&b.time_us).then(a.column.cmp(&b.column)));
70
71        for note in &sorted_notes {
72            match note.note_type {
73                SmNoteType::Tap => {
74                    rox.notes.push(Note::tap(note.time_us, note.column));
75                }
76                SmNoteType::HoldHead => {
77                    // Store for later when we find the tail
78                    pending_holds.push((note.time_us, note.column));
79                }
80                SmNoteType::RollHead => {
81                    // Store for later when we find the tail
82                    pending_rolls.push((note.time_us, note.column));
83                }
84                SmNoteType::Tail => {
85                    // Find matching hold or roll head
86                    if let Some(idx) = pending_holds
87                        .iter()
88                        .position(|(_, col)| *col == note.column)
89                    {
90                        let (start_time, column) = pending_holds.remove(idx);
91                        let duration = note.time_us - start_time;
92                        rox.notes.push(Note::hold(start_time, duration, column));
93                    } else if let Some(idx) = pending_rolls
94                        .iter()
95                        .position(|(_, col)| *col == note.column)
96                    {
97                        let (start_time, column) = pending_rolls.remove(idx);
98                        let duration = note.time_us - start_time;
99                        rox.notes.push(Note::burst(start_time, duration, column));
100                    }
101                    // Orphan tails are ignored
102                }
103                SmNoteType::Mine => {
104                    rox.notes.push(Note::mine(note.time_us, note.column));
105                }
106                SmNoteType::Lift => {
107                    // Convert lift to tap (no direct ROX equivalent)
108                    rox.notes.push(Note::tap(note.time_us, note.column));
109                }
110                SmNoteType::Empty | SmNoteType::Fake => {
111                    // Ignored
112                }
113            }
114        }
115
116        // Sort notes by time
117        rox.notes.sort_by_key(|n| n.time_us);
118
119        rox
120    }
121
122    /// Decode all charts from an SM file.
123    #[must_use]
124    pub fn decode_all(sm: &SmFile) -> Vec<RoxChart> {
125        sm.charts
126            .iter()
127            .map(|chart| Self::from_chart(sm, chart))
128            .collect()
129    }
130}
131
132impl Decoder for SmDecoder {
133    fn decode(data: &[u8]) -> RoxResult<RoxChart> {
134        let sm = parser::parse(data)?;
135        sm.charts
136            .first()
137            .map(|chart| Self::from_chart(&sm, chart))
138            .ok_or_else(|| {
139                crate::error::RoxError::InvalidFormat("No charts found in SM file".into())
140            })
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::codec::Decoder;
148
149    /// Basic SM file content for testing.
150    const BASIC_SM: &str = r#"
151#TITLE:Test Song;
152#ARTIST:Test Artist;
153#CREDIT:Test Mapper;
154#MUSIC:song.ogg;
155#OFFSET:0;
156#BPMS:0=120;
157#STOPS:;
158
159#NOTES:
160     dance-single:
161     :
162     Beginner:
163     1:
164     0,0,0,0,0:
1650000
1661000
1670100
1680010
169,
1700001
1710000
1720000
1730000
174;
175"#;
176
177    #[test]
178    fn test_decode_basic_sm() {
179        let chart = <SmDecoder as Decoder>::decode(BASIC_SM.as_bytes()).expect("Failed to decode");
180
181        assert_eq!(chart.key_count(), 4);
182        assert_eq!(chart.metadata.title, "Test Song");
183        assert_eq!(chart.metadata.artist, "Test Artist");
184        assert_eq!(chart.metadata.creator, "Test Mapper");
185        assert_eq!(chart.metadata.difficulty_name, "Beginner");
186        assert!(!chart.notes.is_empty());
187    }
188
189    #[test]
190    fn test_sm_note_count() {
191        let chart = <SmDecoder as Decoder>::decode(BASIC_SM.as_bytes()).expect("Failed to decode");
192
193        // 4 tap notes: 1 per column across 2 measures
194        assert_eq!(chart.notes.len(), 4);
195    }
196
197    #[test]
198    fn test_sm_timing_points() {
199        let chart = <SmDecoder as Decoder>::decode(BASIC_SM.as_bytes()).expect("Failed to decode");
200
201        // Should have at least one BPM timing point
202        assert!(!chart.timing_points.is_empty());
203        assert_eq!(chart.timing_points[0].bpm, 120.0);
204    }
205
206    #[test]
207    fn test_decode_asset_4k() {
208        // assets/stepmania/4k.sm
209        let data = crate::test_utils::get_test_asset("stepmania/4k.sm");
210        let chart = <SmDecoder as Decoder>::decode(&data).expect("Failed to decode 4k.sm");
211
212        // Validating against expected properties of 4k.sm (assuming simple 4k chart)
213        assert_eq!(chart.key_count(), 4);
214        // Note: I don't know the exact metadata of 4k.sm, so I'll just check it decoded successfully and has notes
215        // Ideally I'd inspect the actual file content, but for now validating decode success is good.
216    }
217}