osu_file_parser/osu_file/hitobjects/
mod.rs

1pub mod error;
2pub mod types;
3
4use crate::osu_file::types::Decimal;
5use either::Either;
6use nom::branch::alt;
7use nom::bytes::complete::*;
8use nom::character::complete::char;
9use nom::combinator::*;
10use nom::error::context;
11use nom::sequence::*;
12use nom::*;
13use rust_decimal_macros::dec;
14
15use crate::helper::*;
16use crate::parsers::*;
17
18pub use error::*;
19pub use types::*;
20
21use super::Error;
22use super::Integer;
23use super::Position;
24use super::Version;
25use super::VersionedDefault;
26use super::VersionedFromStr;
27use super::VersionedToString;
28use super::VersionedTryFrom;
29
30#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
31pub struct HitObjects(pub Vec<HitObject>);
32
33impl VersionedFromStr for HitObjects {
34    type Err = Error<ParseError>;
35
36    fn from_str(s: &str, version: Version) -> std::result::Result<Option<Self>, Self::Err> {
37        let mut hitobjects = Vec::new();
38
39        for (line_index, s) in s.lines().enumerate() {
40            if s.trim().is_empty() {
41                continue;
42            }
43
44            hitobjects.push(Error::new_from_result_into(
45                HitObject::from_str(s, version).map(|v| v.unwrap()),
46                line_index,
47            )?);
48        }
49
50        Ok(Some(HitObjects(hitobjects)))
51    }
52}
53
54impl VersionedToString for HitObjects {
55    fn to_string(&self, version: Version) -> Option<String> {
56        Some(
57            self.0
58                .iter()
59                .filter_map(|o| o.to_string(version))
60                .collect::<Vec<_>>()
61                .join("\n"),
62        )
63    }
64}
65
66impl VersionedDefault for HitObjects {
67    fn default(_: Version) -> Option<Self> {
68        Some(HitObjects(Vec::new()))
69    }
70}
71
72/// A struct that represents a hitobject.
73///
74/// All hitobjects will have the properties: `x`, `y`, `time`, `type`, `hitsound`, `hitsample`.
75///
76/// The `type` property is a `u8` integer with each bit flags containing some information, which are split into the functions and enums:
77/// [hitobject_type][Self::obj_params], [new_combo][Self::new_combo], [combo_skip_count][Self::combo_skip_count]
78#[derive(Clone, Debug, Hash, PartialEq, Eq)]
79#[non_exhaustive]
80pub struct HitObject {
81    /// The position of the hitobject.
82    pub position: Position,
83    /// The time when the object is to be hit, in milliseconds from the beginning of the beatmap's audio.
84    pub time: Decimal,
85    /// The hitobject parameters.
86    /// Each hitobject contains different parameters.
87    /// Also is used to know which hitobject type this is.
88    pub obj_params: HitObjectParams,
89    /// If the hitobject is a new combo.
90    pub new_combo: bool,
91    /// A 3-bit integer specifying how many combo colours to skip, if this object starts a new combo.
92    pub combo_skip_count: ComboSkipCount,
93    /// The [hitsound][HitSound] property of the hitobject.
94    pub hitsound: HitSound,
95    /// The [hitsample][HitSample] property of the hitobject.
96    pub hitsample: Option<HitSample>,
97}
98
99impl HitObject {
100    fn type_to_string(&self) -> String {
101        let mut bit_flag: u8 = 0;
102
103        bit_flag |= match self.obj_params {
104            HitObjectParams::HitCircle => 1,
105            HitObjectParams::Slider { .. } => 2,
106            HitObjectParams::Spinner { .. } => 8,
107            HitObjectParams::OsuManiaHold { .. } => 128,
108        };
109
110        if self.new_combo {
111            bit_flag |= 4;
112        }
113
114        // 3 bit value from 4th ~ 6th bits
115        bit_flag |= self.combo_skip_count.get() << 4;
116
117        bit_flag.to_string()
118    }
119
120    pub fn hitcircle_default() -> Self {
121        Self {
122            position: Default::default(),
123            time: Default::default(),
124            obj_params: HitObjectParams::HitCircle,
125            new_combo: Default::default(),
126            combo_skip_count: Default::default(),
127            hitsound: Default::default(),
128            hitsample: Default::default(),
129        }
130    }
131
132    pub fn spinner_default() -> Self {
133        Self {
134            position: Default::default(),
135            time: Default::default(),
136            obj_params: HitObjectParams::Spinner {
137                end_time: Default::default(),
138            },
139            new_combo: Default::default(),
140            combo_skip_count: Default::default(),
141            hitsound: Default::default(),
142            hitsample: Default::default(),
143        }
144    }
145
146    pub fn osu_mania_hold_default() -> Self {
147        Self {
148            position: Position {
149                x: dec!(0).into(),
150                ..Default::default()
151            },
152            time: Default::default(),
153            obj_params: HitObjectParams::OsuManiaHold {
154                end_time: Default::default(),
155            },
156            new_combo: Default::default(),
157            combo_skip_count: Default::default(),
158            hitsound: Default::default(),
159            hitsample: Default::default(),
160        }
161    }
162}
163
164const OLD_VERSION_TIME_OFFSET: rust_decimal::Decimal = dec!(24);
165
166impl VersionedFromStr for HitObject {
167    type Err = ParseHitObjectError;
168
169    fn from_str(s: &str, version: Version) -> std::result::Result<Option<Self>, Self::Err> {
170        let hitsound = context(
171            ParseHitObjectError::InvalidHitSound.into(),
172            comma_field_versioned_type(version),
173        );
174        let mut hitsample = alt((
175            nothing().map(|_| None),
176            preceded(
177                context(ParseHitObjectError::MissingHitSample.into(), comma()),
178                context(
179                    ParseHitObjectError::InvalidHitSample.into(),
180                    map_res(rest, |s| {
181                        HitSample::from_str(s, version).map(|v| v.unwrap())
182                    }),
183                ),
184            )
185            .map(Some),
186        ));
187
188        let (s, (position, time, obj_type, hitsound)) = tuple((
189            tuple((
190                context(ParseHitObjectError::InvalidX.into(), comma_field_type()),
191                preceded(
192                    context(ParseHitObjectError::MissingY.into(), comma()),
193                    context(ParseHitObjectError::InvalidY.into(), comma_field_type()),
194                ),
195            ))
196            .map(|(x, y)| (Position { x, y })),
197            preceded(
198                context(ParseHitObjectError::MissingTime.into(), comma()),
199                context(ParseHitObjectError::InvalidTime.into(), comma_field_type()),
200            )
201            // version 3 has a slight time delay of 24ms
202            .map(|mut t: Decimal| {
203                if (3..=4).contains(&version) {
204                    if let Either::Left(value) = t.get_mut() {
205                        *value += OLD_VERSION_TIME_OFFSET;
206                    }
207                }
208
209                t
210            }),
211            preceded(
212                context(ParseHitObjectError::MissingObjType.into(), comma()),
213                context(
214                    ParseHitObjectError::InvalidObjType.into(),
215                    comma_field_type::<_, Integer>(),
216                ),
217            ),
218            preceded(
219                context(ParseHitObjectError::MissingHitSound.into(), comma()),
220                hitsound,
221            ),
222        ))(s)?;
223
224        let new_combo = nth_bit_state_i64(obj_type as i64, 2);
225        let combo_skip_count = <ComboSkipCount as VersionedTryFrom<u8>>::try_from(
226            (obj_type >> 4 & 0b111) as u8,
227            version,
228        )
229        .unwrap()
230        .unwrap();
231
232        let hitobject = if nth_bit_state_i64(obj_type as i64, 0) {
233            let (_, hitsample) = hitsample(s)?;
234
235            // hitcircle
236            HitObject {
237                position,
238                time,
239                obj_params: HitObjectParams::HitCircle,
240                new_combo,
241                combo_skip_count,
242                hitsound,
243                hitsample,
244            }
245        } else if nth_bit_state_i64(obj_type as i64, 1) {
246            // slider
247            let pipe = char('|');
248
249            let (
250                _,
251                (
252                    (curve_type, curve_points),
253                    slides,
254                    length,
255                    (
256                        edge_sounds,
257                        edge_sets,
258                        hitsample,
259                        edge_sounds_short_hand,
260                        edge_sets_shorthand,
261                    ),
262                ),
263            ) = tuple((
264                alt((
265                    // assume curve points doesn't exist
266                    preceded(
267                        context(ParseHitObjectError::MissingCurveType.into(), comma()),
268                        context(
269                            ParseHitObjectError::InvalidCurveType.into(),
270                            comma_field_versioned_type(version),
271                        ),
272                    )
273                    .map(|curve_type| (curve_type, Vec::new())),
274                    // assume curve points exist
275                    tuple((
276                        preceded(
277                            context(ParseHitObjectError::MissingCurveType.into(), comma()),
278                            context(
279                                ParseHitObjectError::InvalidCurveType.into(),
280                                map_res(take_till(|c| c == '|'), |f: &str| {
281                                    CurveType::from_str(f, version).map(|c| c.unwrap())
282                                }),
283                            ),
284                        ),
285                        preceded(
286                            context(ParseHitObjectError::MissingCurvePoint.into(), pipe),
287                            context(
288                                ParseHitObjectError::InvalidCurvePoint.into(),
289                                pipe_vec_versioned_map(version).map(|mut v| {
290                                    if version == 3 && !v.is_empty() {
291                                        v.remove(0);
292                                    }
293                                    v
294                                }),
295                            ),
296                        ),
297                    )),
298                )),
299                preceded(
300                    context(ParseHitObjectError::MissingSlidesCount.into(), comma()),
301                    context(
302                        ParseHitObjectError::InvalidSlidesCount.into(),
303                        comma_field_type(),
304                    ),
305                ),
306                preceded(
307                    context(ParseHitObjectError::MissingLength.into(), comma()),
308                    context(
309                        ParseHitObjectError::InvalidLength.into(),
310                        comma_field_type(),
311                    ),
312                ),
313                alt((
314                    nothing().map(|_| (Vec::new(), Vec::new(), None, true, true)),
315                    tuple((
316                        preceded(
317                            context(ParseHitObjectError::MissingEdgeSound.into(), comma()),
318                            context(
319                                ParseHitObjectError::InvalidEdgeSound.into(),
320                                pipe_vec_versioned_map(version),
321                            ),
322                        ),
323                        alt((
324                            nothing().map(|_| (Vec::new(), None, true)),
325                            tuple((
326                                preceded(
327                                    context(ParseHitObjectError::MissingEdgeSet.into(), comma()),
328                                    context(
329                                        ParseHitObjectError::InvalidEdgeSet.into(),
330                                        pipe_vec_versioned_map(version),
331                                    ),
332                                ),
333                                hitsample,
334                            ))
335                            .map(|(edge_sets, hitsample)| (edge_sets, hitsample, false)),
336                        )),
337                    ))
338                    .map(
339                        |(edge_sounds, (edge_sets, hitsample, edge_sets_shorthand))| {
340                            (
341                                edge_sounds,
342                                edge_sets,
343                                hitsample,
344                                false,
345                                edge_sets_shorthand,
346                            )
347                        },
348                    ),
349                )),
350            ))(s)?;
351
352            HitObject {
353                position,
354                time,
355                obj_params: HitObjectParams::Slider(SlideParams {
356                    curve_type,
357                    curve_points,
358                    slides,
359                    length,
360                    edge_sounds,
361                    edge_sets,
362                    edge_sets_shorthand,
363                    edge_sounds_short_hand,
364                }),
365                new_combo,
366                combo_skip_count,
367                hitsound,
368                hitsample,
369            }
370        } else if nth_bit_state_i64(obj_type as i64, 3) {
371            // spinner
372            let (_, (end_time, hitsample)) = tuple((
373                preceded(
374                    context(ParseHitObjectError::MissingEndTime.into(), comma()),
375                    context(
376                        ParseHitObjectError::InvalidEndTime.into(),
377                        comma_field_type(),
378                    ),
379                )
380                .map(|mut t: Decimal| {
381                    if (3..=4).contains(&version) {
382                        if let Either::Left(value) = t.get_mut() {
383                            *value += OLD_VERSION_TIME_OFFSET;
384                        }
385                    }
386
387                    t
388                }),
389                hitsample,
390            ))(s)?;
391
392            HitObject {
393                position,
394                time,
395                obj_params: HitObjectParams::Spinner { end_time },
396                new_combo,
397                combo_skip_count,
398                hitsound,
399                hitsample,
400            }
401        } else if nth_bit_state_i64(obj_type as i64, 7) {
402            // osu!mania hold
403            // ppy has done it once again
404            let hitsample = alt((
405                nothing().map(|_| None),
406                preceded(
407                    context(ParseHitObjectError::MissingHitSample.into(), char(':')),
408                    context(
409                        ParseHitObjectError::InvalidHitSample.into(),
410                        map_res(rest, |s| {
411                            HitSample::from_str(s, version).map(|v| v.unwrap())
412                        }),
413                    ),
414                )
415                .map(Some),
416            ));
417            let end_time = context(
418                ParseHitObjectError::InvalidEndTime.into(),
419                map_res(take_until(":"), |s: &str| s.parse()),
420            )
421            .map(|mut t: Decimal| {
422                if (3..=4).contains(&version) {
423                    if let Either::Left(value) = t.get_mut() {
424                        *value += OLD_VERSION_TIME_OFFSET;
425                    }
426                }
427
428                t
429            });
430            let (_, (end_time, hitsample)) = tuple((
431                preceded(
432                    context(ParseHitObjectError::MissingEndTime.into(), comma()),
433                    end_time,
434                ),
435                hitsample,
436            ))(s)?;
437
438            HitObject {
439                position,
440                time,
441                obj_params: HitObjectParams::OsuManiaHold { end_time },
442                new_combo,
443                combo_skip_count,
444                hitsound,
445                hitsample,
446            }
447        } else {
448            return Err(ParseHitObjectError::UnknownObjType);
449        };
450
451        Ok(Some(hitobject))
452    }
453}
454
455impl VersionedToString for HitObject {
456    fn to_string(&self, version: Version) -> Option<String> {
457        let mut properties: Vec<String> = vec![
458            self.position.x.to_string(),
459            self.position.y.to_string(),
460            if (3..=4).contains(&version) {
461                match self.time.get() {
462                    Either::Left(value) => (value - OLD_VERSION_TIME_OFFSET).to_string(),
463                    Either::Right(value) => value.to_string(),
464                }
465            } else {
466                self.time.to_string()
467            },
468            self.type_to_string(),
469            self.hitsound.to_string(version).unwrap(),
470        ];
471
472        match &self.obj_params {
473            HitObjectParams::HitCircle => (),
474            HitObjectParams::Slider(SlideParams {
475                curve_type,
476                curve_points,
477                slides,
478                length,
479                edge_sounds,
480                edge_sets,
481                edge_sounds_short_hand,
482                edge_sets_shorthand,
483            }) => {
484                properties.push(curve_type.to_string(version).unwrap());
485
486                let has_curve_points = version == 3 || !curve_points.is_empty();
487
488                let mut properties_2 = Vec::new();
489
490                if version == 3 {
491                    let mut curve_points = curve_points.clone();
492                    curve_points.insert(0, CurvePoint(self.position.clone()));
493                    properties_2.push(pipe_vec_to_string(&curve_points, version));
494                } else if has_curve_points {
495                    properties_2.push(pipe_vec_to_string(curve_points, version));
496                }
497                properties_2.push(slides.to_string());
498                properties_2.push(length.to_string());
499
500                if !edge_sounds.is_empty()
501                    || !*edge_sounds_short_hand
502                    || !edge_sets.is_empty()
503                    || !*edge_sets_shorthand
504                    || self.hitsample.is_some()
505                {
506                    properties_2.push(pipe_vec_to_string(edge_sounds, version));
507                }
508                if !edge_sets.is_empty() || !*edge_sets_shorthand || self.hitsample.is_some() {
509                    properties_2.push(pipe_vec_to_string(edge_sets, version));
510                }
511                if let Some(hitsample) = &self.hitsample {
512                    if let Some(hitsample) = hitsample.to_string(version) {
513                        properties_2.push(hitsample);
514                    }
515                }
516
517                let slider_str = if has_curve_points {
518                    format!("{}|{}", properties.join(","), properties_2.join(","))
519                } else {
520                    format!("{},{}", properties.join(","), properties_2.join(","))
521                };
522
523                return Some(slider_str);
524            }
525            HitObjectParams::Spinner { end_time } => {
526                properties.push(if (3..=4).contains(&version) {
527                    match end_time.get() {
528                        Either::Left(value) => (value - OLD_VERSION_TIME_OFFSET).to_string(),
529                        Either::Right(value) => value.to_string(),
530                    }
531                } else {
532                    end_time.to_string()
533                });
534            }
535            HitObjectParams::OsuManiaHold { end_time } => {
536                properties.push(if (3..=4).contains(&version) {
537                    match end_time.get() {
538                        Either::Left(value) => (value - OLD_VERSION_TIME_OFFSET).to_string(),
539                        Either::Right(value) => value.to_string(),
540                    }
541                } else {
542                    end_time.to_string()
543                });
544
545                let hitsample = if let Some(hitsample) = &self.hitsample {
546                    if let Some(hitsample) = hitsample.to_string(version) {
547                        hitsample
548                    } else {
549                        String::new()
550                    }
551                } else {
552                    String::new()
553                };
554
555                return Some(format!("{}:{hitsample}", properties.join(",")));
556            }
557        }
558
559        if let Some(hitsample) = &self.hitsample {
560            if let Some(hitsample) = hitsample.to_string(version) {
561                properties.push(hitsample);
562            }
563        }
564
565        let s = properties.join(",");
566
567        // v3 for some reason has a trailing comma for hitcircles
568        let s = if version == 3 && matches!(self.obj_params, HitObjectParams::HitCircle) {
569            format!("{s},")
570        } else {
571            s
572        };
573
574        Some(s)
575    }
576}
577
578#[derive(Clone, Debug, Hash, PartialEq, Eq)]
579#[non_exhaustive]
580pub enum HitObjectParams {
581    HitCircle,
582    Slider(SlideParams),
583    Spinner { end_time: Decimal },
584    OsuManiaHold { end_time: Decimal },
585}
586
587#[derive(Clone, Debug, Hash, PartialEq, Eq)]
588pub struct SlideParams {
589    pub curve_type: CurveType,
590    pub curve_points: Vec<CurvePoint>,
591    pub slides: Integer,
592    pub length: Decimal,
593    pub edge_sounds: Vec<HitSound>,
594    edge_sounds_short_hand: bool,
595    pub edge_sets: Vec<EdgeSet>,
596    edge_sets_shorthand: bool,
597}
598
599impl SlideParams {
600    pub fn new(
601        curve_type: CurveType,
602        curve_points: Vec<CurvePoint>,
603        slides: Integer,
604        length: Decimal,
605        edge_sounds: Vec<HitSound>,
606        edge_sets: Vec<EdgeSet>,
607    ) -> Self {
608        Self {
609            curve_type,
610            curve_points,
611            slides,
612            length,
613            edge_sounds,
614            edge_sets,
615            edge_sets_shorthand: true,
616            edge_sounds_short_hand: true,
617        }
618    }
619}