Skip to main content

rhythm_open_exchange/codec/formats/osu/
decoder.rs

1//! Decoder for converting .osu to `RoxChart`.
2
3use std::collections::HashMap;
4
5use crate::codec::Decoder;
6use crate::error::RoxResult;
7use crate::model::{Hitsound, Metadata, Note, RoxChart, TimingPoint};
8
9use super::parser;
10use super::types::OsuBeatmap;
11
12/// Decoder for osu!mania beatmaps.
13pub struct OsuDecoder;
14
15impl OsuDecoder {
16    /// Convert an `OsuBeatmap` to `RoxChart`.
17    #[must_use]
18    pub fn from_beatmap(beatmap: &OsuBeatmap) -> RoxChart {
19        // Safe: circle_size is always 4-18 for mania which fits in u8
20        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
21        let key_count = beatmap.difficulty.circle_size as u8;
22        let mut chart = RoxChart::new(key_count);
23
24        // Map metadata
25        chart.metadata = Metadata {
26            // Map osu! IDs (osu IDs are always positive in practice)
27            #[allow(clippy::cast_sign_loss)]
28            chart_id: beatmap.metadata.beatmap_id.map(|id| id as u64),
29            #[allow(clippy::cast_sign_loss)]
30            chartset_id: beatmap.metadata.beatmap_set_id.map(|id| id as u64),
31            key_count,
32            title: beatmap
33                .metadata
34                .title_unicode
35                .clone()
36                .unwrap_or_else(|| beatmap.metadata.title.clone())
37                .into(),
38            artist: beatmap
39                .metadata
40                .artist_unicode
41                .clone()
42                .unwrap_or_else(|| beatmap.metadata.artist.clone())
43                .into(),
44            creator: beatmap.metadata.creator.clone().into(),
45            difficulty_name: beatmap.metadata.version.clone().into(),
46            difficulty_value: Some(beatmap.difficulty.overall_difficulty),
47            audio_file: beatmap.general.audio_filename.clone().into(),
48            background_file: beatmap.background.clone().map(Into::into),
49            audio_offset_us: i64::from(beatmap.general.audio_lead_in) * 1000,
50            preview_time_us: if beatmap.general.preview_time > 0 {
51                i64::from(beatmap.general.preview_time) * 1000
52            } else {
53                0
54            },
55            source: beatmap.metadata.source.clone().map(Into::into),
56            tags: beatmap
57                .metadata
58                .tags
59                .iter()
60                .map(|s| s.clone().into())
61                .collect(),
62            ..Default::default()
63        };
64
65        // Convert timing points
66        for tp in &beatmap.timing_points {
67            // Safe: time in ms fits in i64 after multiplying by 1000
68            #[allow(clippy::cast_possible_truncation)]
69            let time_us = (tp.time * 1000.0) as i64;
70
71            if tp.uninherited {
72                // BPM point
73                if let Some(bpm) = tp.bpm() {
74                    let mut timing = TimingPoint::bpm(time_us, bpm);
75                    timing.signature = tp.meter;
76                    chart.timing_points.push(timing);
77                }
78            } else {
79                // SV point
80                let sv = tp.scroll_velocity();
81                chart.timing_points.push(TimingPoint::sv(time_us, sv));
82            }
83        }
84
85        // Map to track unique hitsound files and their indices
86        let mut hitsound_map: HashMap<String, u16> = HashMap::new();
87
88        // Convert hit objects to notes
89        for ho in &beatmap.hit_objects {
90            let column = ho.column(key_count);
91            let time_us = i64::from(ho.time) * 1000;
92
93            let mut note = if ho.is_hold() {
94                let duration_us = i64::from(ho.duration_ms()) * 1000;
95                Note::hold(time_us, duration_us, column)
96            } else {
97                Note::tap(time_us, column)
98            };
99
100            // Parse hitsound from extras
101            // Format: endTime:sampleSet:additions:customIndex:volume:filename
102            // Or for taps: sampleSet:additions:customIndex:volume:filename
103            if !ho.extras.is_empty() {
104                let parts: Vec<&str> = ho.extras.split(':').collect();
105
106                // For holds, the first part is endTime, so filename is at index 5
107                // For taps, filename is at index 4 (if present)
108                let filename_idx = if ho.is_hold() { 5 } else { 4 };
109
110                if let Some(&filename) = parts.get(filename_idx) {
111                    let filename = filename.trim();
112                    if !filename.is_empty() {
113                        // Get or create hitsound index
114                        let hitsound_index = if let Some(&idx) = hitsound_map.get(filename) {
115                            idx
116                        } else {
117                            // Parse volume from extras (index 4 for holds, 3 for taps)
118                            let volume_idx = if ho.is_hold() { 4 } else { 3 };
119                            let volume: Option<u8> = parts
120                                .get(volume_idx)
121                                .and_then(|v| v.parse().ok())
122                                .filter(|&v| v > 0 && v <= 100);
123
124                            let hitsound = if let Some(vol) = volume {
125                                Hitsound::with_volume(filename, vol)
126                            } else {
127                                Hitsound::new(filename)
128                            };
129
130                            // Safe: Limited by u16 max in ROX format
131                            #[allow(clippy::cast_possible_truncation)]
132                            let idx = chart.hitsounds.len() as u16;
133                            chart.hitsounds.push(hitsound);
134                            hitsound_map.insert(filename.to_string(), idx);
135                            idx
136                        };
137
138                        note.hitsound_index = Some(hitsound_index);
139                    }
140                }
141            }
142
143            chart.notes.push(note);
144        }
145
146        // Sort notes by time
147        chart.notes.sort_by_key(|n| n.time_us);
148
149        chart
150    }
151}
152
153impl Decoder for OsuDecoder {
154    fn decode(data: &[u8]) -> RoxResult<RoxChart> {
155        let beatmap = parser::parse(data)?;
156
157        // Validate it's mania mode (3)
158        if beatmap.general.mode != 3 {
159            return Err(crate::error::RoxError::InvalidFormat(format!(
160                "Not a mania beatmap (mode={}, expected 3)",
161                beatmap.general.mode
162            )));
163        }
164
165        Ok(Self::from_beatmap(&beatmap))
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::codec::Decoder;
173
174    #[test]
175    fn test_decode_sample_7k() {
176        let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
177        let chart = <OsuDecoder as Decoder>::decode(&data).expect("Failed to decode");
178
179        assert_eq!(chart.key_count(), 7);
180        assert!(!chart.notes.is_empty());
181        assert!(!chart.timing_points.is_empty());
182        assert_eq!(chart.metadata.difficulty_name, "7K Awakened");
183        assert_eq!(chart.metadata.creator, "arcwinolivirus");
184    }
185
186    #[test]
187    fn test_decode_metadata() {
188        let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
189        let chart = <OsuDecoder as Decoder>::decode(&data).unwrap();
190
191        // Check unicode title is used
192        assert!(chart.metadata.title.contains("宙の旋律") || chart.metadata.title.contains("Sora"));
193        assert!(!chart.metadata.audio_file.is_empty());
194        assert!(chart.metadata.background_file.is_some());
195    }
196
197    #[test]
198    fn test_decode_timing_points() {
199        let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
200        let chart = <OsuDecoder as Decoder>::decode(&data).unwrap();
201
202        // Should have at least one BPM point
203        let bpm_points: Vec<_> = chart
204            .timing_points
205            .iter()
206            .filter(|tp| !tp.is_inherited)
207            .collect();
208        assert!(!bpm_points.is_empty());
209
210        // First timing point should be around 186 BPM
211        let first_bpm = &bpm_points[0];
212        assert!((first_bpm.bpm - 186.0).abs() < 1.0);
213    }
214
215    #[test]
216    fn test_decode_notes_sorted() {
217        let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
218        let chart = <OsuDecoder as Decoder>::decode(&data).unwrap();
219
220        // Notes should be sorted by time
221        for window in chart.notes.windows(2) {
222            assert!(window[0].time_us <= window[1].time_us);
223        }
224    }
225
226    #[test]
227    fn test_decode_hitsounds() {
228        let data = crate::test_utils::get_test_asset("osu/mania_hitsound.osu");
229        let chart = <OsuDecoder as Decoder>::decode(&data).expect("Failed to decode");
230
231        // Should have 4K
232        assert_eq!(chart.key_count(), 4);
233
234        // Should have 4 unique hitsound samples
235        assert_eq!(chart.hitsounds.len(), 4);
236
237        // Should have 276 notes with hitsounds
238        let notes_with_hs = chart
239            .notes
240            .iter()
241            .filter(|n| n.hitsound_index.is_some())
242            .count();
243        assert_eq!(notes_with_hs, 276);
244
245        // Verify hitsound files are parsed correctly
246        let hs_files: Vec<&str> = chart.hitsounds.iter().map(|h| h.file.as_str()).collect();
247        assert!(hs_files.contains(&"RimShot.wav"));
248        assert!(hs_files.contains(&"KICK 2.wav"));
249    }
250}