Skip to main content

rhythm_open_exchange/codec/formats/taiko/
decoder.rs

1//! Decoder for converting osu!taiko to `RoxChart` (4K).
2//!
3//! Converts Taiko drums to a 4K layout:
4//! - Columns 0, 3: Kats (rim hits) - alternating
5//! - Columns 1, 2: Dons (center hits) - alternating
6//! - Big notes (Finish): Hit both columns at once
7
8use crate::codec::Decoder;
9use crate::error::RoxResult;
10use crate::model::{Metadata, Note, RoxChart, TimingPoint};
11
12use super::types::{AlternationState, ColumnLayout};
13use crate::codec::formats::taiko::parser;
14
15/// Decoder for osu!taiko beatmaps.
16pub struct TaikoDecoder;
17
18impl TaikoDecoder {
19    /// Decode with a specific column layout.
20    ///
21    /// # Errors
22    ///
23    /// Returns an error if parsing fails.
24    pub fn decode_with_layout(data: &[u8], layout: ColumnLayout) -> RoxResult<RoxChart> {
25        let mut state = AlternationState::new(layout);
26        Self::decode_with_state(data, &mut state)
27    }
28
29    /// Decode with custom state (useful for testing).
30    ///
31    /// # Errors
32    ///
33    /// Returns an error if the data is not valid UTF-8 or has invalid format.
34    pub fn decode_with_state(data: &[u8], state: &mut AlternationState) -> RoxResult<RoxChart> {
35        let beatmap = parser::parse(data)?;
36
37        // Taiko converts to 4K
38        let mut chart = RoxChart::new(4);
39
40        // Map metadata (reusing OsuBeatmap fields)
41        chart.metadata = Metadata {
42            // Map osu! IDs (osu IDs are always positive in practice)
43            #[allow(clippy::cast_sign_loss)]
44            chart_id: beatmap.metadata.beatmap_id.map(|id| id as u64),
45            #[allow(clippy::cast_sign_loss)]
46            chartset_id: beatmap.metadata.beatmap_set_id.map(|id| id as u64),
47            key_count: 4,
48            title: beatmap
49                .metadata
50                .title_unicode
51                .clone()
52                .unwrap_or_else(|| beatmap.metadata.title.clone())
53                .into(),
54            artist: beatmap
55                .metadata
56                .artist_unicode
57                .clone()
58                .unwrap_or_else(|| beatmap.metadata.artist.clone())
59                .into(),
60            creator: beatmap.metadata.creator.clone().into(),
61            difficulty_name: beatmap.metadata.version.clone().into(),
62            difficulty_value: Some(beatmap.difficulty.overall_difficulty),
63            audio_file: beatmap.general.audio_filename.clone().into(),
64            background_file: beatmap.background.clone().map(|s| s.into()),
65            audio_offset_us: i64::from(beatmap.general.audio_lead_in) * 1000,
66            preview_time_us: if beatmap.general.preview_time > 0 {
67                i64::from(beatmap.general.preview_time) * 1000
68            } else {
69                0
70            },
71            source: beatmap.metadata.source.clone().map(|s| s.into()),
72            tags: beatmap
73                .metadata
74                .tags
75                .iter()
76                .map(|s| s.clone().into())
77                .collect(),
78            ..Default::default()
79        };
80
81        // Convert BPM timing points
82        for tp in &beatmap.timing_points {
83            #[allow(clippy::cast_possible_truncation)]
84            let time_us = (tp.time * 1000.0) as i64;
85
86            if tp.uninherited {
87                if let Some(bpm) = tp.bpm() {
88                    let mut timing = TimingPoint::bpm(time_us, bpm);
89                    timing.signature = tp.meter;
90                    chart.timing_points.push(timing);
91                }
92            } else {
93                // SV logic if needed, but Taiko SV is complex.
94                // For now, let's stick to BPM.
95            }
96        }
97
98        // Ensure at least one BPM point
99        if chart.timing_points.is_empty() {
100            chart.timing_points.push(TimingPoint::bpm(0, 120.0));
101        }
102
103        // Convert hit objects
104        for ho in &beatmap.hit_objects {
105            // Skip spinners
106            if ho.is_spinner() {
107                continue;
108            }
109
110            #[allow(clippy::cast_possible_truncation)]
111            let time_us = (ho.time_ms * 1000.0) as i64;
112            let is_big = ho.hitsound.is_big();
113
114            // Determine columns based on note type
115            let columns = if ho.hitsound.is_kat() {
116                state.next_kat_columns(is_big)
117            } else {
118                // Default to Don (including empty hitsound)
119                state.next_don_columns(is_big)
120            };
121
122            // Create notes for each column
123            for col in columns {
124                chart.notes.push(Note::tap(time_us, col));
125            }
126        }
127
128        // Sort notes by time
129        chart.notes.sort_by_key(|n| n.time_us);
130
131        Ok(chart)
132    }
133}
134
135impl Decoder for TaikoDecoder {
136    fn decode(data: &[u8]) -> RoxResult<RoxChart> {
137        let mut state = AlternationState::default();
138        Self::decode_with_state(data, &mut state)
139    }
140}