use std::collections::{HashMap, HashSet};
use thiserror::Error;
use crate::bms::{
command::{
ObjId,
channel::{Key, NoteKind, PlayerSide},
time::ObjTime,
},
model::{Bms, obj::WavObj},
prelude::{KeyLayoutBeat, KeyLayoutMapper, KeyMapping},
};
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Error)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ValidityMissing {
#[error("Missing WAV definition for note object id: {0:?}")]
WavForNote(ObjId),
#[error("Missing WAV definition for BGM object id: {0:?}")]
WavForBgm(ObjId),
#[error("Missing BMP definition for BGA object id: {0:?}")]
BmpForBga(ObjId),
#[error("Missing BPM change definition for object id: {0:?}")]
BpmChangeDef(ObjId),
#[error("Missing STOP definition for object id: {0:?}")]
StopDef(ObjId),
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Error)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ValidityInvalid {
#[error("Playable note placed in section 000 at {time:?} (side={side:?}, key={key:?})")]
PlayableNoteInTrackZero {
side: PlayerSide,
key: Key,
time: ObjTime,
},
#[error("Visible single-note overlap at {time:?} (side={side:?}, key={key:?})")]
OverlapVisibleSingleWithSingle {
side: PlayerSide,
key: Key,
time: ObjTime,
},
#[error(
"Visible single-note overlaps a long note at {time:?} (side={side:?}, key={key:?}; ln=[{ln_start:?}..{ln_end:?}])"
)]
OverlapVisibleSingleWithLong {
side: PlayerSide,
key: Key,
time: ObjTime,
ln_start: ObjTime,
ln_end: ObjTime,
},
#[error(
"Landmine overlaps a long note starting at {ln_start:?} (side={side:?}, key={key:?}; ln_end={ln_end:?})"
)]
OverlapsLandmineLongAtStart {
side: PlayerSide,
key: Key,
ln_start: ObjTime,
ln_end: ObjTime,
},
#[error("Landmine overlaps visible single note at {time:?} (side={side:?}, key={key:?})")]
OverlapLandmineWithSingle {
side: PlayerSide,
key: Key,
time: ObjTime,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[must_use]
pub struct ValidityCheckOutput {
pub missing: Vec<ValidityMissing>,
pub invalid: Vec<ValidityInvalid>,
}
impl Bms {
pub fn check_validity(&self) -> ValidityCheckOutput {
let missing = self.check_missing();
let invalid = self.check_invalid();
ValidityCheckOutput { missing, invalid }
}
fn check_missing(&self) -> Vec<ValidityMissing> {
let mut missing = vec![];
for obj_id in self.wav.notes.all_notes().map(|obj| &obj.wav_id) {
if !self.wav.wav_files.contains_key(obj_id) {
missing.push(ValidityMissing::WavForNote(*obj_id));
}
}
for bga_obj in self.bmp.bga_changes.values() {
if !self.bmp.bmp_files.contains_key(&bga_obj.id) {
missing.push(ValidityMissing::BmpForBga(bga_obj.id));
}
}
for id in &self.bpm.bpm_change_ids_used {
if !self.bpm.bpm_defs.contains_key(id) {
missing.push(ValidityMissing::BpmChangeDef(*id));
}
}
for id in &self.stop.stop_ids_used {
if !self.stop.stop_defs.contains_key(id) {
missing.push(ValidityMissing::StopDef(*id));
}
}
missing
}
fn check_invalid(&self) -> Vec<ValidityInvalid> {
let mut invalid = vec![];
let mut lane_to_notes: HashMap<Key, Vec<&WavObj>> = HashMap::new();
for obj in self.wav.notes.all_notes() {
let Some(map) = KeyLayoutBeat::from_channel_id(obj.channel_id) else {
continue;
};
if map.kind().is_playable() && obj.offset.track().0 == 0 {
invalid.push(ValidityInvalid::PlayableNoteInTrackZero {
side: map.side(),
key: map.key(),
time: obj.offset,
});
}
lane_to_notes.entry(map.key()).or_default().push(obj);
}
for (key, objs) in lane_to_notes {
if objs.is_empty() {
continue;
}
let mut lane_objs = objs;
lane_objs.sort_unstable_by_key(|o| o.offset);
let long_times: Vec<ObjTime> = lane_objs
.iter()
.filter_map(|o| {
let map = KeyLayoutBeat::from_channel_id(o.channel_id)?;
(map.kind() == NoteKind::Long).then_some(o.offset)
})
.collect();
let mut single_offsets = HashSet::new();
for (single_obj, map) in lane_objs
.iter()
.filter_map(|obj| {
KeyLayoutBeat::from_channel_id(obj.channel_id).map(|map| (obj, map))
})
.filter(|(_, map)| map.kind() == NoteKind::Visible)
{
if !single_offsets.insert(single_obj.offset) {
invalid.push(ValidityInvalid::OverlapVisibleSingleWithSingle {
side: map.side(),
key,
time: single_obj.offset,
});
}
}
for (landmine_obj, map) in lane_objs
.iter()
.filter_map(|obj| {
KeyLayoutBeat::from_channel_id(obj.channel_id).map(|map| (obj, map))
})
.filter(|(_, map)| map.kind() == NoteKind::Landmine)
{
if single_offsets.contains(&landmine_obj.offset) {
invalid.push(ValidityInvalid::OverlapLandmineWithSingle {
side: map.side(),
key,
time: landmine_obj.offset,
});
}
}
let time_overlaps_any_ln = |t: ObjTime| -> Option<(ObjTime, ObjTime)> {
if long_times.is_empty() {
return None;
}
let pos = long_times.partition_point(|&x| x < t);
if long_times.get(pos).copied() == Some(t) {
if pos % 2 == 0 {
let end = long_times.get(pos + 1).copied()?;
return Some((t, end));
}
if pos > 0 {
let start = long_times.get(pos - 1).copied()?;
if start == t {
return Some((start, t));
}
}
return None;
}
if pos % 2 == 1 {
let start = long_times.get(pos - 1).copied()?;
let end = long_times.get(pos).copied()?;
if start <= t {
return Some((start, end));
}
}
None
};
for (single_obj, map) in lane_objs
.iter()
.filter_map(|obj| {
KeyLayoutBeat::from_channel_id(obj.channel_id).map(|map| (obj, map))
})
.filter(|(_, map)| map.kind() == NoteKind::Visible)
{
if let Some((start, end)) = time_overlaps_any_ln(single_obj.offset) {
invalid.push(ValidityInvalid::OverlapVisibleSingleWithLong {
side: map.side(),
key,
time: single_obj.offset,
ln_start: start,
ln_end: end,
});
}
}
let mut warned_ln_intervals: HashSet<(ObjTime, ObjTime)> = HashSet::new();
for (landmine_obj, map) in lane_objs
.iter()
.filter_map(|obj| {
KeyLayoutBeat::from_channel_id(obj.channel_id).map(|map| (obj, map))
})
.filter(|(_, map)| map.kind() == NoteKind::Landmine)
{
if let Some((start, end)) = time_overlaps_any_ln(landmine_obj.offset)
&& warned_ln_intervals.insert((start, end))
{
invalid.push(ValidityInvalid::OverlapsLandmineLongAtStart {
side: map.side(),
key,
ln_start: start,
ln_end: end,
});
}
}
}
invalid
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bms::{
command::{
ObjId,
channel::{Key, NoteKind, PlayerSide},
time::ObjTime,
},
model::{
Notes,
obj::{BgaLayer, BgaObj, WavObj},
},
};
fn t(track: u64, num: u64, den: u64) -> ObjTime {
ObjTime::new(track, num, den).expect("denominator should be non-zero")
}
#[test]
fn test_missing_wav_for_note() {
let mut bms = Bms::default();
let id = ObjId::try_from("0A", false).unwrap();
let time = t(1, 0, 4);
let mut notes = Notes::default();
notes.push_note(WavObj {
offset: time,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Visible, Key::Key(1))
.to_channel_id(),
wav_id: id,
});
bms.wav.notes = notes;
let out = bms.check_validity();
assert!(out.missing.contains(&ValidityMissing::WavForNote(id)));
}
#[test]
fn test_missing_bmp_for_bga() {
let mut bms = Bms::default();
let id = ObjId::try_from("0B", false).unwrap();
let time = t(1, 0, 4);
bms.bmp.bga_changes.insert(
time,
BgaObj {
time,
id,
layer: BgaLayer::Base,
},
);
let out = bms.check_validity();
assert!(out.missing.contains(&ValidityMissing::BmpForBga(id)));
}
#[test]
fn test_visible_note_in_track_zero() {
let mut bms = Bms::default();
let id = ObjId::try_from("10", false).unwrap();
let time = t(0, 0, 4);
let mut notes = Notes::default();
notes.push_note(WavObj {
offset: time,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Visible, Key::Key(1))
.to_channel_id(),
wav_id: id,
});
bms.wav.notes = notes;
let out = bms.check_validity();
assert!(out.invalid.iter().any(|e| matches!(
e,
ValidityInvalid::PlayableNoteInTrackZero { time: t0, side: PlayerSide::Player1, key: Key::Key(1) } if *t0 == time
)));
}
#[test]
fn test_overlap_visible_single_with_single() {
let mut bms = Bms::default();
let id1 = ObjId::try_from("01", false).unwrap();
let id2 = ObjId::try_from("02", false).unwrap();
let time = t(1, 0, 4);
let mut notes = Notes::default();
notes.push_note(WavObj {
offset: time,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Visible, Key::Key(1))
.to_channel_id(),
wav_id: id1,
});
notes.push_note(WavObj {
offset: time,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Visible, Key::Key(1))
.to_channel_id(),
wav_id: id2,
});
bms.wav.notes = notes;
let out = bms.check_validity();
assert!(out.invalid.iter().any(|e| matches!(
e,
ValidityInvalid::OverlapVisibleSingleWithSingle { time: t0, side: PlayerSide::Player1, key: Key::Key(1) } if *t0 == time
)));
}
#[test]
fn test_overlap_visible_single_with_long() {
let mut bms = Bms::default();
let id_ln_s = ObjId::try_from("0E", false).unwrap();
let id_ln_e = ObjId::try_from("0F", false).unwrap();
let id_vis = ObjId::try_from("03", false).unwrap();
let ln_start = t(2, 0, 4);
let ln_end = t(2, 2, 4);
let vis_time = t(2, 1, 4);
let mut notes = Notes::default();
notes.push_note(WavObj {
offset: ln_start,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Long, Key::Key(1))
.to_channel_id(),
wav_id: id_ln_s,
});
notes.push_note(WavObj {
offset: ln_end,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Long, Key::Key(1))
.to_channel_id(),
wav_id: id_ln_e,
});
notes.push_note(WavObj {
offset: vis_time,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Visible, Key::Key(1))
.to_channel_id(),
wav_id: id_vis,
});
bms.wav.notes = notes;
let out = bms.check_validity();
assert!(out.invalid.iter().any(|e| matches!(
e,
ValidityInvalid::OverlapVisibleSingleWithLong { side: PlayerSide::Player1, key: Key::Key(1), time: t0, ln_start: s, ln_end: e } if *t0 == vis_time && *s == ln_start && *e == ln_end
)));
}
#[test]
fn test_landmine_overlap_long_warn_at_start() {
let mut bms = Bms::default();
let id_ln_s = ObjId::try_from("1A", false).unwrap();
let id_ln_e = ObjId::try_from("1B", false).unwrap();
let id_mine = ObjId::try_from("1C", false).unwrap();
let ln_start = t(3, 0, 4);
let ln_end = t(3, 2, 4);
let mine_time = t(3, 0, 4);
let mut notes = Notes::default();
notes.push_note(WavObj {
offset: ln_start,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Long, Key::Key(1))
.to_channel_id(),
wav_id: id_ln_s,
});
notes.push_note(WavObj {
offset: ln_end,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Long, Key::Key(1))
.to_channel_id(),
wav_id: id_ln_e,
});
notes.push_note(WavObj {
offset: mine_time,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Landmine, Key::Key(1))
.to_channel_id(),
wav_id: id_mine,
});
bms.wav.notes = notes;
let out = bms.check_validity();
assert!(out.invalid.iter().any(|e| matches!(
e,
ValidityInvalid::OverlapsLandmineLongAtStart { side: PlayerSide::Player1, key: Key::Key(1), ln_start: s, ln_end: e } if *s == ln_start && *e == ln_end
)));
}
#[test]
fn test_overlap_landmine_with_single() {
let mut bms = Bms::default();
let id_vis = ObjId::try_from("04", false).unwrap();
let id_mine = ObjId::try_from("05", false).unwrap();
let time = t(1, 0, 4);
let mut notes = Notes::default();
notes.push_note(WavObj {
offset: time,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Visible, Key::Key(1))
.to_channel_id(),
wav_id: id_vis,
});
notes.push_note(WavObj {
offset: time,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Landmine, Key::Key(1))
.to_channel_id(),
wav_id: id_mine,
});
bms.wav.notes = notes;
let out = bms.check_validity();
assert!(out.invalid.iter().any(|e| matches!(
e,
ValidityInvalid::OverlapLandmineWithSingle { time: t0, side: PlayerSide::Player1, key: Key::Key(1) } if *t0 == time
)));
}
#[test]
fn test_zero_length_long_note_overlap() {
let mut bms = Bms::default();
let id_ln_start = ObjId::try_from("20", false).unwrap();
let id_ln_end = ObjId::try_from("21", false).unwrap();
let id_vis = ObjId::try_from("22", false).unwrap();
let zero_length_time = t(2, 0, 4);
let vis_time = t(2, 0, 4); let mut notes = Notes::default();
notes.push_note(WavObj {
offset: zero_length_time,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Long, Key::Key(1))
.to_channel_id(),
wav_id: id_ln_start,
});
notes.push_note(WavObj {
offset: zero_length_time, channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Long, Key::Key(1))
.to_channel_id(),
wav_id: id_ln_end,
});
notes.push_note(WavObj {
offset: vis_time,
channel_id: KeyLayoutBeat::new(PlayerSide::Player1, NoteKind::Visible, Key::Key(1))
.to_channel_id(),
wav_id: id_vis,
});
bms.wav.notes = notes;
let out = bms.check_validity();
assert!(
out.invalid.iter().any(|e| matches!(
e,
ValidityInvalid::OverlapVisibleSingleWithLong {
side: PlayerSide::Player1,
key: Key::Key(1),
time: t0,
ln_start: s,
ln_end: e
} if *t0 == vis_time && *s == zero_length_time && *e == zero_length_time
)),
"Failed to detect overlap with zero-length long note. Current output: {:?}",
out.invalid
);
}
}