rhythm_open_exchange/codec/formats/fnf/
decoder.rs1use crate::codec::Decoder;
4use crate::error::RoxResult;
5use crate::model::{Metadata, Note, RoxChart, TimingPoint};
6
7use super::parser;
8use super::types::{FnfChart, FnfSide};
9
10pub struct FnfDecoder;
12
13impl FnfDecoder {
14 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 #[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 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 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, ..Default::default()
49 };
50
51 let mut current_bpm = fnf.song.bpm;
53 let mut added_initial_bpm = false;
54
55 for section in &fnf.song.notes {
57 if section.change_bpm && section.bpm > 0.0 {
59 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 chart.timing_points.push(TimingPoint::bpm(0, current_bpm));
71 added_initial_bpm = true;
72 }
73
74 for fnf_note in §ion.section_notes {
76 let raw_lane = fnf_note.lane();
77
78 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 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 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 if !added_initial_bpm {
133 chart.timing_points.push(TimingPoint::bpm(0, fnf.song.bpm));
134 }
135
136 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 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 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 assert_eq!(chart.key_count(), 4); 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); assert!(chart.metadata.is_coop);
179 }
180}