1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
//! Main chart container.
use bincode::{Decode, Encode, config};
use super::{Hitsound, Metadata, Note, TimingPoint};
/// Current ROX format version.
pub const ROX_VERSION: u8 = 1;
/// Magic bytes to identify ROX files: "ROX\0"
pub const ROX_MAGIC: [u8; 4] = [0x52, 0x4F, 0x58, 0x00];
/// A complete VSRG chart in ROX format.
#[derive(Debug, Clone, PartialEq, Encode, Decode)]
pub struct RoxChart {
/// Format version for backwards compatibility.
pub version: u8,
/// Number of columns/keys (e.g., 4 for 4K, 7 for 7K).
pub key_count: u8,
/// Chart metadata.
pub metadata: Metadata,
/// Timing points (BPM and SV changes).
pub timing_points: Vec<TimingPoint>,
/// All notes in the chart.
pub notes: Vec<Note>,
/// Hitsound samples (notes reference by index).
pub hitsounds: Vec<Hitsound>,
}
impl RoxChart {
/// Create a new empty chart with the given key count.
#[must_use]
pub fn new(key_count: u8) -> Self {
Self {
version: ROX_VERSION,
key_count,
metadata: Metadata::default(),
timing_points: Vec::new(),
notes: Vec::new(),
hitsounds: Vec::new(),
}
}
/// Get the total duration of the chart in microseconds.
#[must_use]
pub fn duration_us(&self) -> i64 {
self.notes
.iter()
.map(super::note::Note::end_time_us)
.max()
.unwrap_or(0)
}
/// Get the number of notes (taps + holds).
#[must_use]
pub fn note_count(&self) -> usize {
self.notes.len()
}
/// Compute a hash of the chart.
/// Returns a hash as a hex string.
/// Uses BLAKE3 on native, SHA256 on WASM.
#[must_use]
pub fn hash(&self) -> String {
let config = config::standard()
.with_little_endian()
.with_variable_int_encoding();
let encoded = bincode::encode_to_vec(self, config).unwrap_or_default();
#[cfg(not(target_arch = "wasm32"))]
{
blake3::hash(&encoded).to_hex().to_string()
}
#[cfg(target_arch = "wasm32")]
{
use sha2::{Digest, Sha256};
let result = Sha256::digest(&encoded);
format!("{:x}", result)
}
}
/// Compute a short hash (first 16 hex chars).
#[must_use]
pub fn short_hash(&self) -> String {
self.hash()[..16].to_string()
}
/// Validate the chart for consistency and correctness.
///
/// Checks:
/// - All note columns are within bounds
/// - All hold/burst durations are > 0
/// - Timing points are sorted by time
/// - At least one BPM timing point exists
/// - First BPM timing point is at or before the first note
/// - No overlapping notes on the same column
///
/// # Errors
///
/// Returns an error if any validation check fails.
pub fn validate(&self) -> Result<(), crate::RoxError> {
// Check column bounds
for note in &self.notes {
if note.column >= self.key_count {
return Err(crate::RoxError::InvalidColumn {
column: note.column,
key_count: self.key_count,
});
}
}
// Check coop mode requires even key count
if self.metadata.is_coop && !self.key_count.is_multiple_of(2) {
return Err(crate::RoxError::InvalidFormat(format!(
"Coop mode requires even key count, got {}",
self.key_count
)));
}
// Check hold/burst durations > 0
for note in &self.notes {
let duration = note.duration_us();
if (note.is_hold() || note.is_burst()) && duration <= 0 {
return Err(crate::RoxError::InvalidHoldDuration {
time_us: note.time_us,
duration_us: duration,
});
}
}
// Check timing points sorted by time
let mut prev_time = i64::MIN;
for tp in &self.timing_points {
if tp.time_us < prev_time {
return Err(crate::RoxError::TimingPointsNotSorted {
prev_time_us: prev_time,
time_us: tp.time_us,
});
}
prev_time = tp.time_us;
}
// Check BPM timing point requirements only if chart has notes
if !self.notes.is_empty() {
// Check at least one BPM timing point exists
let first_bpm = self.timing_points.iter().find(|tp| !tp.is_inherited);
let Some(_first_bpm) = first_bpm else {
return Err(crate::RoxError::NoBpmTimingPoint);
};
// Strict check removed: Real-world maps sometimes have notes slightly before the first BPM.
// Engines should handle this by extending the first BPM backwards.
/*
// Check first BPM is at or before first note
if let Some(first_note) = self.notes.first()
&& first_bpm.time_us > first_note.time_us
{
return Err(crate::RoxError::BpmAfterFirstNote {
bpm_time_us: first_bpm.time_us,
note_time_us: first_note.time_us,
});
}
*/
}
// Check for overlapping notes on same column
// Group notes by column, then check for overlaps
for col in 0..self.key_count {
let mut col_notes: Vec<_> = self.notes.iter().filter(|n| n.column == col).collect();
col_notes.sort_by_key(|n| n.time_us);
for window in col_notes.windows(2) {
let prev = window[0];
let curr = window[1];
let prev_end = prev.end_time_us();
if curr.time_us < prev_end {
return Err(crate::RoxError::OverlappingNotes {
column: col,
time_us: curr.time_us,
});
}
}
}
Ok(())
}
}