Skip to main content

rhythm_open_exchange/codec/formats/fnf/
encoder.rs

1//! [WIP / UNSTABLE] Encoder for converting `RoxChart` to FNF .json format.
2//!
3//! > [!WARNING]
4//! > This encoder is currently Work-In-Progress and may not be fully accurate.
5
6use crate::codec::Encoder;
7use crate::error::RoxResult;
8use crate::model::RoxChart;
9
10use super::types::{FnfChart, FnfNote, FnfSection, FnfSong};
11
12/// Encoder for Friday Night Funkin' charts.
13pub struct FnfEncoder;
14
15impl Encoder for FnfEncoder {
16    fn encode(chart: &RoxChart) -> RoxResult<Vec<u8>> {
17        // Get base BPM from first timing point
18        let base_bpm = chart
19            .timing_points
20            .iter()
21            .find(|tp| !tp.is_inherited)
22            .map_or(120.0, |tp| tp.bpm);
23
24        // Determine if this is 8K (both sides) or 4K (player only)
25        let is_8k = chart.key_count() >= 8;
26
27        // Create a single large section with all notes
28        // This matches the JS converter approach
29        let mut section_notes: Vec<FnfNote> = Vec::new();
30
31        for note in &chart.notes {
32            #[allow(clippy::cast_precision_loss)]
33            let time_ms = note.time_us as f64 / 1000.0;
34
35            // Map columns to FNF lanes
36            let lane = if is_8k {
37                // 8K: columns 0-3 = opponent (lanes 0-3), columns 4-7 = player (lanes 4-7)
38                note.column
39            } else {
40                // 4K: all notes go to player side (lanes 0-3)
41                note.column
42            };
43
44            let fnf_note = match &note.note_type {
45                crate::model::NoteType::Hold { duration_us } => {
46                    #[allow(clippy::cast_precision_loss)]
47                    let duration_ms = *duration_us as f64 / 1000.0;
48                    FnfNote::hold(time_ms, lane, duration_ms)
49                }
50                _ => FnfNote::tap(time_ms, lane),
51            };
52
53            section_notes.push(fnf_note);
54        }
55
56        let section = FnfSection {
57            section_notes,
58            length_in_steps: 160_000, // Large number to contain all notes
59            must_hit_section: !is_8k, // true for 4K (player), false for 8K
60            change_bpm: true,
61            bpm: base_bpm,
62            type_of_section: 0,
63        };
64
65        // Create FNF chart structure
66        let fnf = FnfChart {
67            song: FnfSong {
68                song: chart.metadata.title.to_string(),
69                bpm: base_bpm,
70                speed: chart.metadata.difficulty_value.unwrap_or(1.5).into(),
71                player1: "bf".to_string(),
72                player2: chart.metadata.creator.to_string(),
73                needs_voices: false,
74                valid_score: true,
75                notes: vec![section], // Assuming fnf_sections should be vec![section]
76                sections: 0, // Will be calculated by FNF game
77                section_lengths: Vec::new(),
78            },
79        };
80
81        // Serialize to pretty JSON
82        let json = serde_json::to_string_pretty(&fnf)
83            .map_err(|e| crate::error::RoxError::InvalidFormat(format!("JSON error: {e}")))?;
84
85        Ok(json.into_bytes())
86    }
87}
88
89#[cfg(test)]
90mod tests {
91
92    #[test]
93    #[cfg(feature = "analysis")]
94    #[ignore = "FNF is currently WIP/Unstable"]
95    fn test_roundtrip_both() {
96        use super::*;
97        use crate::analysis::RoxAnalysis;
98        use crate::codec::Decoder;
99        use crate::codec::formats::fnf::FnfDecoder;
100        let data = crate::test_utils::get_test_asset("fnf/test-song.json");
101        // Decode both sides (8K)
102        let chart1 = FnfDecoder::decode(&data).unwrap();
103        let encoded = FnfEncoder::encode(&chart1).unwrap();
104        let chart2 = FnfDecoder::decode(&encoded).unwrap();
105
106        assert_eq!(chart1.key_count(), chart2.key_count());
107        assert_eq!(
108            chart1.notes_hash(),
109            chart2.notes_hash(),
110            "Notes hash mismatch"
111        );
112        // FNF only has one BPM for the whole song in this encoder implementation currently,
113        // but let's check timings hash anyway.
114        assert_eq!(
115            chart1.timings_hash(),
116            chart2.timings_hash(),
117            "Timings hash mismatch"
118        );
119    }
120}