map_parser/maps/
osu.rs

1use bitflags::bitflags;
2use num::clamp;
3use std::{
4    fs::File,
5    io::{self, BufRead},
6    path::Path,
7};
8
9use crate::enums::GameMode;
10
11use super::qua::{
12    CustomAudioSampleInfo, HitObjectInfo, QuaverMap, SliderVelocityInfo, SoundEffectInfo,
13    TimingPointInfo,
14};
15
16#[derive(Default)]
17pub struct OsuBeatmap {
18    pub original_file_name: String,
19    pub is_valid: bool,
20    pub peppy_file_format: String,
21
22    // [General]
23    pub audio_file_name: String,
24    pub audio_lead_in: i32,
25    pub preview_time: i32,
26    pub countdown: i32,
27    pub sample_set: String,
28    pub stack_leniency: f32,
29    pub mode: i32,
30    pub letterbox_in_breaks: i32,
31    pub special_style: i32,
32    pub widescreen_storyboard: i32,
33
34    // [Editor]
35    pub bookmarks: String,
36    pub distance_spacing: f32,
37    pub beat_divisor: i32,
38    pub grid_size: i32,
39    pub timeline_zoom: i32,
40
41    // [Metadata]
42    pub title: String,
43    pub title_unicode: String,
44    pub artist: String,
45    pub artist_unicode: String,
46    pub creator: String,
47    pub version: String,
48    pub source: String,
49    pub tags: String,
50    pub beatmap_id: i32,
51    pub beatmap_set_id: i32,
52
53    // [Difficulty]
54    pub hp_drain_rate: f32,
55    pub key_count: i32,
56    pub overall_difficulty: f32,
57    pub approach_rate: f32,
58    pub slider_multiplier: f32,
59    pub slider_tick_rate: f32,
60
61    // [Events]
62    pub background: String,
63    pub sound_effects: Vec<OsuSampleInfo>,
64
65    // [TimingPoints]
66    pub timing_points: Vec<OsuTimingPoint>,
67
68    // [HitObjects]
69    pub hit_objects: Vec<OsuHitObject>,
70
71    pub custom_audio_samples: Vec<String>,
72}
73
74impl OsuBeatmap {
75    pub fn from_path(file_path: &str) -> Self {
76        let mut self_ = Self::default();
77
78        if !Path::new(file_path).exists() {
79            self_.is_valid = false;
80        }
81
82        self_.is_valid = true;
83        self_.original_file_name = file_path.to_string();
84
85        let mut section: &str = "";
86
87        if let Ok(lines) = Self::read_lines(&self_.original_file_name) {
88            for raw_line in lines {
89                let raw_line = raw_line.unwrap();
90                if raw_line.trim().is_empty()
91                    || raw_line.starts_with("//")
92                    || raw_line.starts_with(' ')
93                    || raw_line.starts_with('_')
94                {
95                    continue;
96                }
97
98                let line = Self::strip_comments(raw_line.as_str());
99
100                section = match line.trim() {
101                    "[General]" => "[General]",
102                    "[Editor]" => "[Editor]",
103                    "[Metadata]" => "[Metadata]",
104                    "[Difficulty]" => "[Difficulty]",
105                    "[Events]" => "[Events]",
106                    "[TimingPoints]" => "[TimingPoints]",
107                    "[HitObjects]" => "[HitObjects]",
108                    "[Colours]" => "[Colours]",
109                    _ => section,
110                };
111
112                if line.starts_with("osu file format") {
113                    self_.peppy_file_format = line.to_string();
114                }
115
116                if section.eq("[General]") && line.contains(':') {
117                    let key = &line[..line.find(':').unwrap()];
118                    let value = line.split(':').last().unwrap().trim();
119                    match key.trim() {
120                        "AudioFilename" => self_.audio_file_name = value.parse().unwrap(),
121                        "AudioLeadIn" => self_.audio_lead_in = value.parse().unwrap(),
122                        "PreviewTime" => self_.preview_time = value.parse().unwrap(),
123                        "Countdown" => self_.countdown = value.parse().unwrap(),
124                        "SampleSet" => self_.sample_set = value.parse().unwrap(),
125                        "StackLeniency" => self_.stack_leniency = value.parse().unwrap(),
126                        "Mode" => {
127                            self_.mode = value.parse().unwrap();
128                            if self_.mode != 3 {
129                                self_.is_valid = false
130                            }
131                        }
132                        "LetterboxInBreaks" => self_.letterbox_in_breaks = value.parse().unwrap(),
133                        "SpecialStyle" => self_.special_style = value.parse().unwrap(),
134                        "WidescreenStoryboard" => {
135                            self_.widescreen_storyboard = value.parse().unwrap()
136                        }
137                        _ => (),
138                    }
139                }
140
141                if section.eq("[Editor]") && line.contains(':') {
142                    let key = &line[..line.find(':').unwrap()];
143                    let value = line.split(':').last().unwrap().trim();
144
145                    match key.trim() {
146                        "Bookmarks" => self_.bookmarks = value.parse().unwrap(),
147                        "DistanceSpacing" => self_.distance_spacing = value.parse().unwrap(),
148                        "BeatDivisor" => self_.beat_divisor = value.parse().unwrap(),
149                        "GridSize" => self_.grid_size = value.parse().unwrap(),
150                        "TimelineZoom" => self_.timeline_zoom = value.parse().unwrap(),
151                        _ => (),
152                    }
153                }
154
155                if section.eq("[Metadata]") && line.contains(':') {
156                    let key = &line[..line.find(':').unwrap()];
157                    let value = line.split(':').last().unwrap().trim();
158
159                    match key.trim() {
160                        "Title" => self_.title = value.parse().unwrap(),
161                        "TitleUnicode" => self_.title_unicode = value.parse().unwrap(),
162                        "Artist" => self_.artist = value.parse().unwrap(),
163                        "ArtistUnicode" => self_.artist_unicode = value.parse().unwrap(),
164                        "Creator" => self_.creator = value.parse().unwrap(),
165                        "Version" => self_.version = value.parse().unwrap(),
166                        "Source" => self_.source = value.parse().unwrap(),
167                        "Tags" => self_.tags = value.parse().unwrap(),
168                        "BeatmapID" => self_.beatmap_id = value.parse().unwrap(),
169                        "BeatmapSetID" => self_.beatmap_set_id = value.parse().unwrap(),
170                        _ => (),
171                    }
172                }
173
174                if section.eq("[Difficulty]") && line.contains(':') {
175                    let key = &line[..line.find(':').unwrap()];
176                    let value = line.split(':').last().unwrap().trim();
177
178                    match key.trim() {
179                        "HPDrainRate" => self_.hp_drain_rate = value.parse().unwrap(),
180                        "CircleSize" => {
181                            let key_count = value.parse().unwrap();
182
183                            if key_count != 4 && key_count != 7 && key_count != 5 && key_count != 8
184                            {
185                                self_.is_valid = false;
186                            }
187
188                            self_.key_count = key_count;
189                        }
190                        "OverallDifficulty" => self_.overall_difficulty = value.parse().unwrap(),
191                        "ApproachRate" => self_.approach_rate = value.parse().unwrap(),
192                        "SliderMultiplier" => self_.slider_multiplier = value.parse().unwrap(),
193                        "SliderTickRate" => self_.slider_tick_rate = value.parse().unwrap(),
194                        _ => (),
195                    }
196                }
197
198                if section.eq("[Events]") {
199                    let values: Vec<&str> = line.split(',').collect();
200
201                    if line.to_lowercase().contains("png")
202                        || line.to_lowercase().contains("jpg")
203                        || line.to_lowercase().contains("jpeg")
204                    {
205                        self_.background = values[2].replace('\"', "");
206                    }
207
208                    if values[0] == "Sample" || values[0] == "5" {
209                        /*
210                        let path = values[3]
211                            .replace('"', "")
212                            .replace(std::path::MAIN_SEPARATOR, "/");
213                        */
214
215                        self_.sound_effects.push(OsuSampleInfo {
216                            start_time: values[1].parse().unwrap(),
217                            layer: values[2].parse().unwrap(),
218                            volume: std::cmp::max(
219                                0,
220                                std::cmp::min(
221                                    100,
222                                    if values.len() >= 5 {
223                                        values[4].parse().unwrap()
224                                    } else {
225                                        100
226                                    },
227                                ),
228                            ),
229                            sample: 0,
230                        })
231                    }
232                }
233
234                if section.eq("[TimingPoints]") && line.contains(',') {
235                    let values: Vec<&str> = line.split(',').collect();
236
237                    let ms_per_beat: f32 = values[1].parse().unwrap();
238
239                    let timing_point = OsuTimingPoint {
240                        offset: values[0].parse().unwrap(),
241                        milliseconds_per_beat: ms_per_beat,
242                        // signature: if values[2] == "0" {
243                        //     TimeSignature::Quadruple
244                        // } else {
245                        //     TimeSignature::from_bits(values[2].parse().unwrap()).unwrap()
246                        // },
247                        sample_type: values[3].parse().unwrap(),
248                        sample_set: values[4].parse().unwrap(),
249                        volume: values[5].parse().unwrap(),
250                        inherited: values[6].parse().unwrap(),
251                        kiai_mode: values[7].parse().unwrap(),
252                    };
253
254                    self_.timing_points.push(timing_point);
255                }
256
257                if section.eq("[HitObjects]") && line.contains(',') {
258                    let values: Vec<&str> = line.split(',').collect();
259
260                    let mut hit_object = OsuHitObject {
261                        x: values[0].parse().unwrap(),
262                        y: values[1].parse().unwrap(),
263                        start_time: values[2].parse().unwrap(),
264                        type_: HitObjectType::from_bits(values[3].parse().unwrap()).unwrap(),
265                        hit_sound: HitSoundType::from_bits(values[4].parse().unwrap()).unwrap(),
266                        additions: String::from("0:0:0:0:"),
267                        key_sound: -1,
268                        end_time: 0,
269                        volume: 0,
270                        ..Default::default()
271                    };
272
273                    if hit_object.type_ == HitObjectType::Hold {
274                        let end_time = &values[5][..values[5].find(':').unwrap()];
275                        hit_object.end_time = end_time.parse().unwrap();
276                    }
277
278                    if values.len() > 5 {
279                        let additions: Vec<&str> = values[5].split(':').collect();
280
281                        let volume_field = if hit_object.type_ == HitObjectType::Hold {
282                            4
283                        } else {
284                            3
285                        };
286
287                        if additions.len() > volume_field && !additions[volume_field].is_empty() {
288                            hit_object.volume =
289                                std::cmp::max(0, additions[volume_field].parse().unwrap());
290                        }
291
292                        let key_sound_field = volume_field + 1;
293                        if additions.len() > key_sound_field
294                            && !additions[key_sound_field].is_empty()
295                        {
296                            /*
297                            hit_object.key_sound = Self::custom_audio_sample_index(
298                                &mut self_,
299                                additions[key_sound_field],
300                            )
301                            */
302                        }
303
304                        self_.hit_objects.push(hit_object);
305                    }
306                }
307            }
308        }
309
310        self_
311    }
312
313    pub fn to_qua(self) -> QuaverMap {
314        let mut qua = QuaverMap {
315            audio_file: self.audio_file_name,
316            song_preview_time: self.preview_time,
317            background_file: self.background,
318            map_id: -1,
319            map_set_id: -1,
320            title: self.title,
321            artist: self.artist,
322            source: self.source,
323            tags: self.tags,
324            creator: self.creator,
325            difficulty_name: self.version,
326            description: String::from("This is a Quaver converted osu! map"),
327            ..Default::default()
328        };
329
330        match self.key_count {
331            4 => qua.mode = GameMode::Keys4,
332            7 => qua.mode = GameMode::Keys7,
333            8 => {
334                qua.mode = GameMode::Keys7;
335                qua.has_scratch_key = true;
336            }
337            _ => qua.mode = GameMode::Keys4,
338        }
339
340        for path in self.custom_audio_samples {
341            qua.custom_audio_samples.push(CustomAudioSampleInfo {
342                path,
343                unaffected_by_rate: false,
344            })
345        }
346
347        for info in self.sound_effects {
348            if info.volume == 0 {
349                continue;
350            }
351
352            qua.sound_effects.push(SoundEffectInfo {
353                start_time: info.start_time as f32,
354                sample: info.sample + 1,
355                volume: info.volume,
356            })
357        }
358
359        for tp in self.timing_points {
360            let is_sv = tp.inherited == 0 || tp.milliseconds_per_beat < 0.;
361
362            if is_sv {
363                qua.slider_velocities.push(SliderVelocityInfo {
364                    start_time: tp.offset,
365                    multiplier: clamp(-100. / tp.milliseconds_per_beat, 0.1, 10.),
366                })
367            } else {
368                qua.timing_points.push(TimingPointInfo {
369                    start_time: tp.offset,
370                    bpm: 60000. / tp.milliseconds_per_beat,
371                    // signature: tp.signature,
372                    ..Default::default()
373                })
374            }
375        }
376
377        for hit_object in self.hit_objects {
378            let mut key_lane = clamp(
379                hit_object.x as f64 / (512f64 / self.key_count as f64),
380                0.,
381                (self.key_count - 1) as f64,
382            ) as i32
383                + 1;
384
385            if qua.has_scratch_key {
386                if key_lane == 1 {
387                    key_lane = self.key_count;
388                } else {
389                    key_lane -= 1
390                };
391            }
392
393            if hit_object.type_ == HitObjectType::Circle {
394                qua.hit_objects.push(HitObjectInfo {
395                    start_time: hit_object.start_time,
396                    lane: key_lane,
397                    end_time: 0,
398                    // hit_sound: HitSounds::Normal, // TODO
399                    // key_sounds: TODO
400                })
401            }
402        }
403
404        qua.sort();
405
406        qua
407    }
408
409    fn custom_audio_sample_index(&mut self, path: &str) -> i32 {
410        for i in 0..self.custom_audio_samples.len() {
411            if self.custom_audio_samples[i] == path {
412                return i as i32;
413            }
414        }
415
416        self.custom_audio_samples.push(path.to_string());
417        self.custom_audio_samples.len() as i32 - 1
418    }
419
420    fn strip_comments(line: &str) -> &str {
421        let index = line.find("//").unwrap_or(0);
422        if index > 0 {
423            return &line[..index];
424        }
425        line
426    }
427
428    fn read_lines<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
429    where
430        P: AsRef<Path>,
431    {
432        let file = File::open(filename)?;
433        Ok(io::BufReader::new(file).lines())
434    }
435}
436
437bitflags! {
438    #[derive(Default)]
439    pub struct HitObjectType: i32{
440        const Circle = 1 << 0;
441        const Slider = 1 << 1;
442        const NewCombo = 1 << 2;
443        const Spinner = 1 << 3;
444        const ComboOffset = 1 << 4 | 1 << 5 | 1 << 6;
445        const Hold = 1 << 7;
446    }
447}
448
449bitflags! {
450    #[derive(Default)]
451    pub struct HitSoundType: u32 {
452        const None = 0;
453        const Normal = 1;
454        const Whistle = 2;
455        const Finish = 4;
456        const Clap = 8;
457    }
458}
459
460pub struct OsuTimingPoint {
461    pub offset: f32,
462    pub milliseconds_per_beat: f32,
463    // pub signature: TimeSignature,
464    pub sample_type: i32,
465    pub sample_set: i32,
466    pub volume: i32,
467    pub inherited: i32,
468    pub kiai_mode: i32,
469}
470
471#[derive(Default, Debug)]
472pub struct OsuHitObject {
473    pub x: i32,
474    pub y: i32,
475    pub start_time: i32,
476    pub type_: HitObjectType,
477    pub hit_sound: HitSoundType,
478    pub end_time: i32,
479    pub additions: String,
480    pub key1: bool,
481    pub key2: bool,
482    pub key3: bool,
483    pub key4: bool,
484    pub key5: bool,
485    pub key6: bool,
486    pub key7: bool,
487    pub volume: i32,
488    pub key_sound: i32,
489}
490
491pub struct OsuSampleInfo {
492    pub start_time: i32,
493    pub layer: i32,
494    pub volume: i32,
495    pub sample: i32,
496}