Skip to main content

rhythm_open_exchange/codec/formats/qua/
encoder.rs

1//! Encoder for converting `RoxChart` to .qua format.
2
3use crate::codec::Encoder;
4use crate::error::RoxResult;
5use crate::model::RoxChart;
6
7use super::types::{QuaChart, QuaHitObject, QuaSliderVelocity, QuaTimingPoint};
8
9/// Encoder for Quaver beatmaps.
10pub struct QuaEncoder;
11
12impl Encoder for QuaEncoder {
13    fn encode(chart: &RoxChart) -> RoxResult<Vec<u8>> {
14
15use compact_str::CompactString;
16
17        let mut qua = QuaChart {
18            audio_file: chart.metadata.audio_file.to_string(),
19            // Safe: preview_time_us / 1000 fits in i32 for typical beatmaps
20            #[allow(clippy::cast_possible_truncation)]
21            preview_time: (chart.metadata.preview_time_us / 1000) as i32,
22            background_file: Some(chart
23                .metadata
24                .background_file
25                .as_ref()
26                .unwrap_or(&CompactString::new(""))
27                .to_string()),
28            map_id: if let Some(id) = chart.metadata.chart_id {
29                id as i32
30            } else {
31                -1
32            },
33            title: chart.metadata.title.to_string(),
34            artist: chart.metadata.artist.to_string(),
35            creator: chart.metadata.creator.to_string(),
36            difficulty_name: chart.metadata.difficulty_name.to_string(),
37            source: Some(chart.metadata.source.clone().unwrap_or_default().to_string()),
38            tags: Some(chart
39                .metadata
40                .tags
41                .iter()
42                .map(|s| s.as_str())
43                .collect::<Vec<_>>()
44                .join(" ")),
45            description: None,
46            initial_scroll_velocity: 1.0,
47            bpm_does_not_affect_sv: true,
48            ..Default::default()
49        };
50
51        // Convert timing points
52        for tp in &chart.timing_points {
53            // Safe: time_us / 1000 is small enough for f64
54            #[allow(clippy::cast_precision_loss)]
55            let start_time = tp.time_us as f64 / 1000.0;
56
57            if tp.is_inherited {
58                // SV point
59                qua.slider_velocities.push(QuaSliderVelocity {
60                    start_time,
61                    multiplier: f64::from(tp.scroll_speed),
62                });
63            } else {
64                // BPM point
65                qua.timing_points.push(QuaTimingPoint {
66                    start_time,
67                    bpm: tp.bpm,
68                    signature: None,
69                });
70            }
71        }
72
73        // Convert notes
74        for note in &chart.notes {
75            #[allow(clippy::cast_precision_loss)]
76            let start_time = note.time_us as f64 / 1000.0;
77            // Quaver lanes are 1-indexed
78            let lane = note.column + 1;
79
80            let end_time = match &note.note_type {
81                crate::model::NoteType::Hold { duration_us } => {
82                    #[allow(clippy::cast_precision_loss)]
83                    let end = (note.time_us + duration_us) as f64 / 1000.0;
84                    Some(end)
85                }
86                _ => None,
87            };
88
89            qua.hit_objects.push(QuaHitObject {
90                start_time,
91                lane,
92                end_time,
93                key_sounds: Vec::new(),
94            });
95        }
96
97        // Serialize to YAML
98        let yaml = serde_yaml::to_string(&qua).map_err(|e| {
99            crate::error::RoxError::InvalidFormat(format!("YAML encoding error: {e}"))
100        })?;
101
102        Ok(yaml.into_bytes())
103    }
104}
105
106#[cfg(test)]
107mod tests {
108
109    #[test]
110    fn test_roundtrip() {
111        use super::*;
112        use crate::codec::Decoder;
113        use crate::codec::formats::qua::QuaDecoder;
114        let data = crate::test_utils::get_test_asset("quaver/4K.qua");
115        let chart1 = QuaDecoder::decode(&data).unwrap();
116        let encoded = QuaEncoder::encode(&chart1).unwrap();
117        let chart2 = QuaDecoder::decode(&encoded).unwrap();
118
119        assert_eq!(chart1.key_count(), chart2.key_count());
120
121        // Use deep comparison with tolerance instead of hashes due to YAML float rounding
122        assert_eq!(chart1.notes.len(), chart2.notes.len());
123        for (n1, n2) in chart1.notes.iter().zip(chart2.notes.iter()) {
124            assert_eq!(n1.column, n2.column);
125            assert!(
126                (n1.time_us - n2.time_us).abs() <= 1000,
127                "Note time mismatch"
128            );
129        }
130
131        assert_eq!(chart1.timing_points.len(), chart2.timing_points.len());
132        for (tp1, tp2) in chart1.timing_points.iter().zip(chart2.timing_points.iter()) {
133            assert!(
134                (tp1.time_us - tp2.time_us).abs() <= 1000,
135                "Timing point time mismatch"
136            );
137            if !tp1.is_inherited {
138                assert!((tp1.bpm - tp2.bpm).abs() < 0.01, "BPM mismatch");
139            }
140        }
141    }
142}