Skip to main content

rhythm_open_exchange/codec/formats/fnf/
decoder.rs

1//! Decoder for converting FNF .json to `RoxChart`.
2
3use crate::codec::Decoder;
4use crate::error::RoxResult;
5use crate::model::{Metadata, Note, RoxChart, TimingPoint};
6
7use super::parser;
8use super::types::{FnfChart, FnfSide};
9
10/// Decoder for Friday Night Funkin' charts.
11pub struct FnfDecoder;
12
13impl FnfDecoder {
14    /// Decode with a specific side selection.
15    ///
16    /// # Errors
17    ///
18    /// Returns an error if parsing fails.
19    pub fn decode_with_side(data: &[u8], side: FnfSide) -> RoxResult<RoxChart> {
20        let fnf = parser::parse(data)?;
21        Ok(Self::from_fnf(&fnf, side))
22    }
23
24    /// Convert an `FnfChart` to `RoxChart` with the specified side.
25    #[must_use]
26    pub fn from_fnf(fnf: &FnfChart, side: FnfSide) -> RoxChart {
27        let key_count = match side {
28            FnfSide::Player | FnfSide::Opponent => 4,
29            FnfSide::Both => 8,
30        };
31
32        let mut chart = RoxChart::new(key_count);
33
34        // Map metadata
35        chart.metadata = Metadata {
36            key_count,
37            title: fnf.song.song.clone().into(),
38            artist: "Unknown".into(),
39            creator: fnf.song.player2.clone().into(),
40            difficulty_name: "Normal".into(),
41            audio_file: "Inst.ogg".into(),
42            // FNF usually has a separate Voices track, but we'll map Inst as main audio
43            background_file: None,
44            preview_time_us: 0,
45            source: Some("Friday Night Funkin'".into()),
46            tags: vec!["fnf".into()],
47            is_coop: side == FnfSide::Both, // true for 8K coop mode
48            ..Default::default()
49        };
50
51        // Track current BPM for timing points
52        let mut current_bpm = fnf.song.bpm;
53        let mut added_initial_bpm = false;
54
55        // Process each section
56        for section in &fnf.song.notes {
57            // Handle BPM changes
58            if section.change_bpm && section.bpm > 0.0 {
59                // Find the first note time in this section for the timing point
60                if let Some(first_note) = section.section_notes.first() {
61                    #[allow(clippy::cast_possible_truncation)]
62                    let time_us = (first_note.time_ms() * 1000.0) as i64;
63                    chart
64                        .timing_points
65                        .push(TimingPoint::bpm(time_us, section.bpm));
66                    current_bpm = section.bpm;
67                }
68            } else if !added_initial_bpm {
69                // Add initial BPM at time 0
70                chart.timing_points.push(TimingPoint::bpm(0, current_bpm));
71                added_initial_bpm = true;
72            }
73
74            // Process notes in this section
75            for fnf_note in &section.section_notes {
76                let raw_lane = fnf_note.lane();
77
78                // Determine if this note belongs to player or opponent
79                // In FNF: mustHitSection determines which side is which
80                // mustHitSection=true: lanes 0-3 = player, 4-7 = opponent
81                // mustHitSection=false: lanes 0-3 = opponent, 4-7 = player
82                let (is_player_note, base_lane) = if raw_lane < 4 {
83                    (section.must_hit_section, raw_lane)
84                } else {
85                    (!section.must_hit_section, raw_lane - 4)
86                };
87
88                // Filter based on requested side
89                let column = match side {
90                    FnfSide::Player => {
91                        if is_player_note {
92                            Some(base_lane)
93                        } else {
94                            None
95                        }
96                    }
97                    FnfSide::Opponent => {
98                        if is_player_note {
99                            None
100                        } else {
101                            Some(base_lane)
102                        }
103                    }
104                    FnfSide::Both => {
105                        // Opponent on left (0-3), player on right (4-7)
106                        if is_player_note {
107                            Some(base_lane + 4)
108                        } else {
109                            Some(base_lane)
110                        }
111                    }
112                };
113
114                if let Some(col) = column {
115                    #[allow(clippy::cast_possible_truncation)]
116                    let time_us = (fnf_note.time_ms() * 1000.0) as i64;
117
118                    let note = if fnf_note.is_hold() {
119                        #[allow(clippy::cast_possible_truncation)]
120                        let duration_us = (fnf_note.duration_ms() * 1000.0) as i64;
121                        Note::hold(time_us, duration_us, col)
122                    } else {
123                        Note::tap(time_us, col)
124                    };
125
126                    chart.notes.push(note);
127                }
128            }
129        }
130
131        // Add initial BPM if no sections had notes
132        if !added_initial_bpm {
133            chart.timing_points.push(TimingPoint::bpm(0, fnf.song.bpm));
134        }
135
136        // Sort notes and timing points by time
137        chart.notes.sort_by_key(|n| n.time_us);
138        chart.timing_points.sort_by_key(|tp| tp.time_us);
139
140        chart
141    }
142}
143
144impl Decoder for FnfDecoder {
145    /// Decode FNF chart, extracting player notes only (4K).
146    fn decode(data: &[u8]) -> RoxResult<RoxChart> {
147        Self::decode_with_side(data, FnfSide::Player)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::codec::Decoder;
155
156    #[test]
157    #[ignore = "FNF is currently WIP/Unstable"]
158    fn test_decode_asset_fnf_player() {
159        // assets/fnf/test-song.json
160        let data = crate::test_utils::get_test_asset("fnf/test-song.json");
161        let chart =
162            <FnfDecoder as Decoder>::decode(&data).expect("Failed to decode test-song.json");
163
164        // Basic validation
165        assert_eq!(chart.key_count(), 4); // Player side is 4K
166        assert!(!chart.notes.is_empty());
167        assert!(!chart.timing_points.is_empty());
168    }
169
170    #[test]
171    #[ignore = "FNF is currently WIP/Unstable"]
172    fn test_decode_asset_fnf_both() {
173        let data = crate::test_utils::get_test_asset("fnf/test-song.json");
174        let chart = FnfDecoder::decode_with_side(&data, FnfSide::Both)
175            .expect("Failed to decode both sides");
176
177        assert_eq!(chart.key_count(), 8); // Both sides is 8K
178        assert!(chart.metadata.is_coop);
179    }
180}