use jzon::{object, JsonValue}; use std::collections::HashMap; // Transcodes a SIF1/NPPS4 beatmap (array of {timing_sec, effect, effect_value, position}) // into the SIF2 chart JSON the client deserializes into NoteData. // // SIF1 note effects, from the game's own LiveModel.NoteEffect // (m_live/model/note_effect.lua): note_normal 1, note_event 2, note_hold 3, note_bomb_1 4, // note_bomb_3 5, note_bomb_5 6, note_bomb_9 7, note_slide 11, note_slide_event 12, // note_slide_hold 13, with isHold(e) = e == 3 and isSlide(e) = e >= 11. // // SIF2 side: type 1 is a tap, 2 is a flick, and 3 is a skill/star note. // LiveTimeController.ToMarkerType accepts 1..3; stock charts happen not to // use type 2, but imported Bandori charts do. // // A SLIDE is structural, not a type. MarkerData derives it from the parent/child chain: // IsSliderMarker chained and the child is on a DIFFERENT line -> a slide segment // IsSliderLongMarker chained, child SAME line, parent different -> a slide ending in a hold // IsDistanceMarker has both a parent and a child -> a middle segment // So a hold is a chain that stays in its lane and a slide is a chain that moves across lanes; // both are type 1. Combo follows LiveStatusSystem.PushInputResult, not the editor-only // NoteData.CalcMaxCombo helper, which undercounts repeated same-lane checkpoints. // // Mapping rules: // - line = position - 1 (both are right-to-left) // - effect 1 (note_normal) and 2 (note_event) -> type 1 (tap). note_event is an ordinary // note that also fed SIF1's event scoring; it plays identically and SIF2 has no // equivalent, so the distinction is dropped. Simultaneous hits are expressed by sharing // a spawn num, not by the effect. // - effect 3 (hold) -> head note (type 1) at timing_sec plus a SYNTHESIZED tail note // (type 1, same line) at timing_sec + effect_value, linked through parent/child ids // - effect 4/5/6/7 (bomb_1/3/5/9, SIF1's star notes) -> type 3, SIF2's bomb note. SIF1 // varies the blast width per effect; SIF2 has one bomb with one damage value, so the // four collapse into type 3 and only the radius is lost. Previously only bomb_1 mapped // here and the three wider ones arrived as ordinary taps. // - effect 11/12/13/14 (slide) -> CHAINED across lanes, normally type 1. Slides sharing a notes_level // form one run: sorted by time and linked parent -> child. Cross-lane links // are sliders; same-lane links are hold segments. A run ends on effect 13 (slide hold), whose synthesized // same-line tail then makes it IsSliderLongMarker — the slide settling into a hold the // player releases. Verified against a real upload: every notes_level shared by more than // one note held exactly the slide-effect notes, each a monotonic sweep like // pos 9->8->7->6 with effects 11,11,11,13. // A lone slide with no chain partner stays a plain note. Same-lane links // become hold segments; effect 14 keeps the final point as a flick. // - effects 8/14 are custom-song extensions for a flick and a slide-end flick (type 2). // Effect 9 is a skill note (type 3); effect 10 is a hold with a flick tail. // The stock SIF1 vocabulary does not use 8/9/10/14. // - effect 0 (random) and anything else unknown -> plain type 1. // - notes_attribute is dropped (SIF2 has no per-note attribute). notes_level is consumed as // the chain id above and not emitted. // - ids are sequential from 1 in time order. num is the spawn group: the dummy // header occupies 100 and real groups count up from 101. The client spawns markers one // num-group at a time, and LiveMarkerControl.CreateMarkerUI (list overload) plain-RETURNS // when the group holds more than 2 markers — so a num may be shared by AT MOST two notes, // or the whole group's head markers never render (hold bands are created by the separate, // unguarded CreateLongMarkerBandUI call, which is why 4 simultaneous holds showed trails // with no heads). Simultaneous notes (equal final time, which covers SIF1 effect 2 pairs // AND synthesized hold tails) are therefore sorted by lane and chunked into pairs: each // chunk gets its own num, and every chunk after the first carries the PREVIOUS chunk's num // in force_sync_group_id. That is exactly the official encoding — all 4292 shipped NoteData // assets have num groups of only 1 or 2, and the 15 charts with 3-4 simultaneous notes // (SIFAC ports, e.g. 1132_5_Sn, 1136_5_An) pair the lowest lanes under the first num and // point the later chunk's m_ForceSyncGroupID at it. The client turns that into the extra // connector line (LiveTimeController.CreateMarkerTimeData force-group pass matches // ForceGroupId against the other chunk's GroupId and links the lane-closest pair). // - notes[0] is ALWAYS the dummy header (id 0, num 100, type 0) - the client // deserializes it verbatim. // - max_combo_count excludes a same-lane hold's root and a cross-lane slide settling // into a hold. Further same-lane checkpoints do count (see max_combo_count below). // Two notes are SIMULTANEOUS (one spawn cluster) when their times agree to within this. // Uploaded timings are decimal literals, so notes an author meant to be simultaneous parse to // bit-identical f64 - but a hold's SYNTHESIZED tail is computed (timing + effect_value), and // e.g. 1.4 + 0.7 is 2.0999999999999996, which exact equality splits from a note literally at // 2.1. That cost the two the shared spawn num, and with it the client's sync connector line // (LiveTimeController.CreateMarkerTimeData pairs GroupSyncMarkerData by GroupId). The client // itself never compares two note times - simultaneity is entirely decided here by `num`, and // it stores time as f32 anyway - so this tolerance only has to sit above f64 accumulation // noise (~1e-15) and below any real spacing: the tightest a chart ever uses is a 1/64 note at // 250 BPM, ~15 ms, four orders of magnitude above this. pub const SIMULTANEOUS_EPSILON_SEC: f64 = 1e-6; fn simultaneous(a: f64, b: f64) -> bool { (a - b).abs() <= SIMULTANEOUS_EPSILON_SEC } // MISS window, from the live_input_result masterdata BAD row (_offsetTimeSec 0.15, // _offsetTimeSecSlider 0.34). LiveTimeController.UpdateMarkerTime destroys and force-MISSes a // marker once the chart clock passes time + this (LiveUtils.GetMissOffsetTime), so a note is // only judgeable while the live is still running that far past it. const MISS_OFFSET_SEC: f64 = 0.15; const MISS_OFFSET_SLIDER_SEC: f64 = 0.34; // The most notes one difficulty may carry. Every transcode step is linear in this // and the chart JSON is bounded only in bytes by the upload caps, so an explicit // ceiling is what keeps a 64MB chart from turning into tens of millions of // JsonValue allocations on a worker. The hardest shipped SIF2 chart is under 1500 // notes and the whole 2146-chart official set peaks well below that, so this is // more than an order of magnitude of headroom pub const MAX_NOTES: usize = 20_000; // MarkerData.IsSliderMarker: a chained note with a cross-lane parent or child. fn is_slider(data: &JsonValue, line_of: &dyn Fn(i64) -> Option) -> bool { let parent_id = data["parent_id"].as_i64().unwrap_or(0); let child_id = data["child_id"].as_i64().unwrap_or(0); if parent_id == 0 && child_id == 0 { return false; } let line = data["line"].as_i64().unwrap_or(0); if child_id != 0 && data["child_line"].as_i64().unwrap_or(0) != line { return true; } parent_id != 0 && line_of(parent_id) != Some(line) } // The chart clock time at which the LAST note stops being judgeable - i.e. the moment the live // must still be running to. The live ends when the audio does (LiveTimeController's // m_MusicDuration is LiveMst._endWait + the music length, and _endWait is 0 in every one of the // 637 official live rows and in ours), so this is what has to fit inside the audio. pub fn end_time(chart: &JsonValue) -> f64 { // Indexed, not scanned: this runs once per note over a chart whose note count // is only bounded by MAX_NOTES, and the linear lookup made it quadratic let lines: HashMap = chart["notes"].members().skip(1) .map(|n| (n["id"].as_i64().unwrap_or(0), n["line"].as_i64().unwrap_or(0))) .collect(); let line_of = |id: i64| lines.get(&id).copied(); let mut end: f64 = 0.0; for data in chart["notes"].members().skip(1) { let offset = if is_slider(data, &line_of) { MISS_OFFSET_SLIDER_SEC } else { MISS_OFFSET_SEC }; end = end.max(data["time"].as_f64().unwrap_or(0.0) + offset); } end } // The earliest note in the chart, or None for an empty chart pub fn first_note_time(chart: &JsonValue) -> Option { chart["notes"].members().skip(1) .filter_map(|n| n["time"].as_f64()) .fold(None, |first: Option, time| Some(match first { Some(first) => first.min(time), None => time })) } // LiveStatusSystem.PushInputResult skips combo for a non-slider long root or // IsSliderLongMarker. LiveInputResultControl maps those results to their child. // All other points count, including repeated checkpoints in a stationary slide. // Validate the links before repairing stored data; never guess a broken chart's count. pub fn max_combo_count(chart: &JsonValue) -> Option { let notes = &chart["notes"]; if !notes.is_array() || notes.len() < 2 || notes[0]["id"] != 0 { return None; } let mut by_id = HashMap::new(); for note in notes.members().skip(1) { let id = note["id"].as_i64()?; let line = note["line"].as_i64()?; if id <= 0 || !(0..9).contains(&line) || by_id.insert(id, note).is_some() { return None; } } let mut count = 0; for note in notes.members().skip(1) { let id = note["id"].as_i64()?; let parent_id = note["parent_id"].as_i64()?; let child_id = note["child_id"].as_i64()?; let parent = if parent_id == 0 { None } else { Some(*by_id.get(&parent_id)?) }; let child = if child_id == 0 { None } else { Some(*by_id.get(&child_id)?) }; if parent_id == id || child_id == id || parent.is_some_and(|p| p["child_id"] != id) || child.is_some_and(|c| c["parent_id"] != id) { return None; } let hold_start = child.is_some_and(|c| c["line"] == note["line"]) && parent.is_none_or(|p| p["line"] != note["line"]); if !hold_start { count += 1; } } Some(count) } struct WorkNote { time: f64, line: i64, kind: i64, // Chain links, as indices into the work list. A hold is parent -> child on the SAME line; // a slide is parent -> child across DIFFERENT lines (see MarkerData.IsSliderMarker). parent: Option, child: Option } // LiveModel.NoteEffect.isHold, widened to note_slide_hold: both carry a duration in // effect_value (notes.lua isTimeOver adds effect_value for note_hold and note_slide_hold alike). fn is_hold(effect: i64) -> bool { effect == 3 || effect == 10 || effect == 13 } // LiveModel.NoteEffect.isSlide fn is_slide(effect: i64) -> bool { effect >= 11 } // SIF1's star notes: note_bomb_1/3/5/9. The suffix is the blast width — star_icon.lua maps // them to 0/1/2/4 extra lanes either side, damaging that spread when the note is missed. // SIF2 has a single bomb note with one damage value (LiveInputResultMst._bombLifeDamage, // applied by LiveLifeControl.CheckLife for type 3, and drawn with the star mark by // MarkerUI), so all four map to type 3 and only the radius is lost. fn is_bomb(effect: i64) -> bool { (4..=7).contains(&effect) } fn parse_sif_note(data: &JsonValue, index: usize) -> Result<(f64, i64, f64, i64, i64), String> { let timing = data["timing_sec"].as_f64().ok_or(format!("Note {}: missing timing_sec", index))?; let effect = data["effect"].as_i64().ok_or(format!("Note {}: missing effect", index))?; let effect_value = data["effect_value"].as_f64().unwrap_or(0.0); let position = data["position"].as_i64().ok_or(format!("Note {}: missing position", index))?; // Slide chain id. SIF1 keeps it at 1 for unchained notes; editors emit an arbitrary // per-chain number, so it is only meaningful as "these slides belong together". let group = data["notes_level"].as_i64().unwrap_or(1); if !(1..=9).contains(&position) { return Err(format!("Note {}: position {} is outside 1-9", index, position)); } // NaN and the infinities pass every comparison below and then reach the // time-order sort, whose partial_cmp().unwrap() panics on an incomparable // pair - a worker panic authored by the uploaded file if !timing.is_finite() || !effect_value.is_finite() { return Err(format!("Note {}: timing_sec/effect_value must be finite numbers", index)); } if timing < 0.0 { return Err(format!("Note {}: negative timing_sec {}", index, timing)); } if is_hold(effect) && effect_value <= 0.0 { return Err(format!("Note {}: hold with effect_value {} (must be > 0)", index, effect_value)); } Ok((timing, effect, effect_value, position, group)) } // Returns the chart JSON and its max_combo_count (== the difficulty's full_combo) pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> { if !beatmap.is_array() || beatmap.is_empty() { return Err(String::from("Chart is not a JSON array of notes")); } if beatmap.len() > MAX_NOTES { return Err(format!("Chart has {} notes - the maximum is {}", beatmap.len(), MAX_NOTES)); } let mut work: Vec = Vec::new(); // Slide chain id -> the work indices in that chain, in input order let mut chains: Vec<(i64, Vec)> = Vec::new(); // (timing bits, position) -> effect, for the duplicate check below. Indexed // rather than rescanned: the old scan-all-preceding-notes loop was quadratic // over an input whose size the upload caps only bound in bytes. The key uses // the raw f64 bits, which is exactly the equality the scan tested (both // infinities and NaN are already rejected in parse_sif_note, so bit equality // and value equality agree here) let mut seen: HashMap<(u64, i64), i64> = HashMap::new(); for (i, data) in beatmap.members().enumerate() { let (timing, effect, effect_value, position, group) = parse_sif_note(data, i)?; match seen.insert((timing.to_bits(), position), effect) { Some(other) if other != effect => { return Err(format!("Note {}: duplicate timing {} on position {} with a different effect", i, timing, position)); }, _ => {} } let head = work.len(); work.push(WorkNote { time: timing, line: position - 1, // Effects 8/14 are the custom-song interchange's flick and // slide-end flick; 9 carries a Bandori skill note. kind: if effect == 8 || effect == 14 { 2 } else if effect == 9 || is_bomb(effect) { 3 } else { 1 }, parent: None, child: None }); // notes_level > 1 identifies the chain; 1 is SIF1's "not chained" default and must NOT be // treated as a group, or every unchained slide in the song would link into one run // (notes.lua guards its own grouping the same way: `if 1 < notes_level`). if is_slide(effect) && group > 1 { match chains.iter_mut().find(|(id, _)| *id == group) { Some((_, members)) => members.push(head), None => chains.push((group, vec![head])) } } if is_hold(effect) { let tail = work.len(); work.push(WorkNote { time: timing + effect_value, line: position - 1, kind: if effect == 10 { 2 } else { 1 }, parent: Some(head), child: None }); work[head].child = Some(tail); } } // Link each slide chain in time order. A cross-lane link becomes a slider // and a same-lane link becomes a hold segment. A member that already // has a child is a slide-hold, i.e. the end of the run, so the chain stops there — its tail // stays its child and the cross-lane parent link makes it IsSliderLongMarker. for (_, members) in chains.iter() { if members.len() < 2 { // A lone slide cannot be a slider: SIF2 needs a cross-lane child. Leave it a tap. continue; } let mut ordered = members.clone(); ordered.sort_by(|a, b| work[*a].time.partial_cmp(&work[*b].time).unwrap()); for pair in ordered.windows(2) { let (a, b) = (pair[0], pair[1]); if work[a].child.is_some() || work[b].parent.is_some() { break; } work[a].child = Some(b); work[b].parent = Some(a); } } // Sequential ids in time order. Stable sort keeps input order on ties let mut order: Vec = (0..work.len()).collect(); order.sort_by(|a, b| work[*a].time.partial_cmp(&work[*b].time).unwrap()); let mut ids = vec![0i64; work.len()]; let mut nums = vec![0i64; work.len()]; let mut force_sync = vec![0i64; work.len()]; let mut num = 100; for (i, index) in order.iter().enumerate() { ids[*index] = (i + 1) as i64; } // Spawn groups: at most TWO notes per num (see the header comment — a bigger group's head // markers never render). A cluster of simultaneous notes is sorted by lane and chunked into // pairs; the leftmost pair takes the first num, and each later chunk points its // force_sync_group_id at the previous chunk's num, matching the official SIFAC-port encoding. let mut start = 0; while start < order.len() { let mut end = start + 1; while end < order.len() && simultaneous(work[order[end]].time, work[order[start]].time) { end += 1; } let mut cluster: Vec = order[start..end].to_vec(); cluster.sort_by_key(|index| work[*index].line); let mut prev_num = 0; for chunk in cluster.chunks(2) { num += 1; for index in chunk { nums[*index] = num; force_sync[*index] = prev_num; } prev_num = num; } start = end; } let mut notes = jzon::array![{ "id": 0, "num": 100, "line": 0, "time": 0.0, "type": 0, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0 }]; for index in order.iter() { let note = &work[*index]; notes.push(object!{ "id": ids[*index], "num": nums[*index], "line": note.line, "time": note.time, "type": note.kind, "parent_id": if let Some(parent) = note.parent { ids[parent] } else { 0 }, "child_id": if let Some(child) = note.child { ids[child] } else { 0 }, "child_num": if let Some(child) = note.child { nums[child] } else { 0 }, "child_line": if let Some(child) = note.child { work[child].line } else { 0 }, "force_sync_group_id": force_sync[*index] }).unwrap(); } let mut chart = object!{ "max_lane": 9, "sound_name": "", "max_combo_count": 0, "notes": notes }; let combo = max_combo_count(&chart).ok_or("Invalid transcoded note links")?; chart["max_combo_count"] = combo.into(); Ok((chart, combo)) } #[cfg(test)] mod tests { use super::*; fn sif_note(timing_sec: f64, position: i64, effect: i64, effect_value: f64) -> JsonValue { object!{ "timing_sec": timing_sec, "notes_attribute": 1, "notes_level": 1, "effect": effect, "effect_value": effect_value, "position": position } } // A slide carrying its chain id; slides sharing one belong to the same run fn sif_slide(timing_sec: f64, position: i64, effect: i64, effect_value: f64, group: i64) -> JsonValue { object!{ "timing_sec": timing_sec, "notes_attribute": 1, "notes_level": group, "effect": effect, "effect_value": effect_value, "position": position } } // (line, type, parent_id, child_id, child_line) for each note after the dummy header fn shape(chart: &JsonValue) -> Vec<(i64, i64, i64, i64, i64)> { chart["notes"].members().skip(1).map(|d| ( d["line"].as_i64().unwrap(), d["type"].as_i64().unwrap(), d["parent_id"].as_i64().unwrap(), d["child_id"].as_i64().unwrap(), d["child_line"].as_i64().unwrap() )).collect() } #[test] fn plain_taps() { let beatmap = jzon::array![ sif_note(1.0, 1, 1, 2.0), sif_note(2.0, 5, 1, 2.0), sif_note(3.0, 9, 1, 2.0) ]; let (chart, combo) = transcode(&beatmap).unwrap(); assert_eq!(combo, 3); assert_eq!(chart["max_combo_count"], 3); assert_eq!(chart["max_lane"], 9); assert_eq!(chart["notes"].len(), 4); // Dummy header is verbatim assert_eq!(chart["notes"][0]["id"], 0); assert_eq!(chart["notes"][0]["num"], 100); assert_eq!(chart["notes"][0]["type"], 0); // Real notes: sequential ids, monotonic nums, right-to-left lines assert_eq!(chart["notes"][1]["id"], 1); assert_eq!(chart["notes"][1]["num"], 101); assert_eq!(chart["notes"][1]["line"], 0); assert_eq!(chart["notes"][1]["type"], 1); assert_eq!(chart["notes"][2]["num"], 102); assert_eq!(chart["notes"][2]["line"], 4); assert_eq!(chart["notes"][3]["id"], 3); assert_eq!(chart["notes"][3]["num"], 103); assert_eq!(chart["notes"][3]["line"], 8); } #[test] fn hold_head_and_tail() { let beatmap = jzon::array![ sif_note(1.0, 3, 3, 2.5) ]; let (chart, combo) = transcode(&beatmap).unwrap(); // The synthesized same-lane tail counts, the head does not assert_eq!(combo, 1); assert_eq!(chart["notes"].len(), 3); let head = &chart["notes"][1]; let tail = &chart["notes"][2]; assert_eq!(head["id"], 1); assert_eq!(head["child_id"], 2); assert_eq!(head["child_num"], tail["num"].clone()); assert_eq!(head["child_line"], 2); assert_eq!(head["parent_id"], 0); assert_eq!(tail["id"], 2); assert_eq!(tail["parent_id"], 1); assert_eq!(tail["child_id"], 0); assert_eq!(tail["line"], 2); assert_eq!(tail["time"].as_f64().unwrap(), 3.5); } // A hold tail's time is COMPUTED (timing + effect_value), so it can land a few f64 ulps off // a note written at the same beat - 1.4 + 0.7 is 2.0999999999999996, not 2.1. Exact equality // split those two into separate spawn groups and the client lost the sync connector line // between them; the epsilon keeps them together. The tail must still be emitted at its own // computed time (the client stores time as f32, which lands both on 2.1 anyway) #[test] fn a_computed_hold_tail_shares_the_beats_spawn_group() { assert_ne!(1.4f64 + 0.7f64, 2.1f64); let beatmap = jzon::array![ sif_note(1.4, 3, 3, 0.7), // hold, tail computed at 2.0999999999999996 sif_note(2.1, 7, 1, 0.0) // tap written at 2.1 ]; let (chart, _) = transcode(&beatmap).unwrap(); let tail = chart["notes"].members().find(|n| n["parent_id"] != 0).unwrap(); let tap = chart["notes"].members().find(|n| n["line"] == 6).unwrap(); assert_eq!(tail["num"], tap["num"].clone()); assert!((tail["time"].as_f64().unwrap() - 2.1).abs() < 1e-9); assert_spawn_groups_hold_at_most_two(&chart); // Genuinely distinct beats stay distinct: the epsilon is orders of magnitude below the // tightest spacing a chart ever uses (a 1/64 note at 250 BPM is ~15 ms) let beatmap = jzon::array![ sif_note(2.1, 3, 1, 0.0), sif_note(2.115, 7, 1, 0.0) ]; let (chart, _) = transcode(&beatmap).unwrap(); assert_ne!(chart["notes"][1]["num"], chart["notes"][2]["num"].clone()); } // end_time is what validate_chart_fits_audio compares against the track length: the last // moment a note is still judgeable, using the BAD-row MISS window (0.15 tap / 0.34 slider) #[test] fn end_time_uses_the_miss_window_of_the_last_note() { let (chart, _) = transcode(&jzon::array![sif_note(10.0, 3, 1, 0.0)]).unwrap(); assert!((end_time(&chart) - 10.15).abs() < 1e-9); assert_eq!(first_note_time(&chart), Some(10.0)); // A hold: the synthesized tail is the last note, and it is same-lane so not a slider let (chart, _) = transcode(&jzon::array![sif_note(10.0, 3, 3, 2.0)]).unwrap(); assert!((end_time(&chart) - 12.15).abs() < 1e-9); assert_eq!(first_note_time(&chart), Some(10.0)); // A cross-lane slide run: every segment is a slider, so the wider window applies let (chart, _) = transcode(&jzon::array![ sif_slide(10.0, 9, 11, 0.0, 5), sif_slide(10.5, 8, 11, 0.0, 5) ]).unwrap(); assert!((end_time(&chart) - 10.84).abs() < 1e-9); } // The client spawns markers one num-group at a time and CreateMarkerUI refuses lists of // more than 2, so no num may ever be shared by 3+ notes (official charts never do) fn assert_spawn_groups_hold_at_most_two(chart: &JsonValue) { let mut counts: Vec<(i64, i64)> = Vec::new(); for data in chart["notes"].members().skip(1) { let num = data["num"].as_i64().unwrap(); match counts.iter_mut().find(|(n, _)| *n == num) { Some((_, c)) => *c += 1, None => counts.push((num, 1)) } } for (num, count) in counts { assert!(count <= 2, "num {} is shared by {} notes; the client renders no heads for such a group", num, count); } } #[test] fn parallel_pair() { let beatmap = jzon::array![ sif_note(1.0, 2, 2, 2.0), sif_note(1.0, 8, 2, 2.0) ]; let (chart, combo) = transcode(&beatmap).unwrap(); // Simultaneous notes share a spawn group and both count assert_eq!(combo, 2); assert_eq!(chart["notes"][1]["num"], chart["notes"][2]["num"].clone()); assert_eq!(chart["notes"][1]["type"], 1); assert_eq!(chart["notes"][2]["type"], 1); // A plain pair is the GroupSync path; the force-group field stays clear assert_eq!(chart["notes"][1]["force_sync_group_id"], 0); assert_eq!(chart["notes"][2]["force_sync_group_id"], 0); } #[test] fn three_simultaneous_notes_split_into_pair_plus_force_synced_single() { // Official encoding (e.g. 1132_5_Sn, 1136_5_An: the only shipped charts with 3-4 // simultaneous notes): the cluster is sorted by lane, the lowest two lanes share the // first num, and the leftover note takes the NEXT num with force_sync_group_id pointing // back at the pair's num. Input arrives lane-scrambled to prove the chunking sorts. let beatmap = jzon::array![ sif_note(1.0, 8, 1, 0.0), sif_note(1.0, 2, 1, 0.0), sif_note(1.0, 5, 1, 0.0) ]; let (chart, combo) = transcode(&beatmap).unwrap(); assert_eq!(combo, 3); assert_eq!(chart["notes"].len(), 4); assert_spawn_groups_hold_at_most_two(&chart); // Emission keeps input order on time ties; grouping is by lane let (right, left, mid) = (&chart["notes"][1], &chart["notes"][2], &chart["notes"][3]); assert_eq!(left["line"], 1); assert_eq!(mid["line"], 4); assert_eq!(right["line"], 7); // Lanes 1 and 4 pair under the first num, force-clear assert_eq!(left["num"], 101); assert_eq!(mid["num"], 101); assert_eq!(left["force_sync_group_id"], 0); assert_eq!(mid["force_sync_group_id"], 0); // Lane 7 rides the next num and force-syncs against the pair's num assert_eq!(right["num"], 102); assert_eq!(right["force_sync_group_id"], 101); } #[test] fn four_simultaneous_holds_pair_heads_and_tails() { // Second field report: 4 holds hitting together rendered their bands but no head // markers — all four heads shared one num, and the client's CreateMarkerUI refuses // groups over 2 while CreateLongMarkerBandUI (a separate, unguarded call) still drew // the bands. Officially both the head cluster AND the tail cluster split 2+2 with the // second chunk force-synced to the first (1132_5_Sn time 12.208 does this to tails). let beatmap = jzon::array![ sif_note(1.0, 3, 3, 2.0), sif_note(1.0, 4, 3, 2.0), sif_note(1.0, 6, 3, 2.0), sif_note(1.0, 7, 3, 2.0) ]; let (chart, combo) = transcode(&beatmap).unwrap(); // Same-lane hold heads don't count; the four tails do assert_eq!(combo, 4); assert_eq!(chart["notes"].len(), 9); assert_spawn_groups_hold_at_most_two(&chart); // Heads at 1.0: lanes 2,3 share num 101; lanes 5,6 share num 102 force-synced to 101 for (index, line, num, fs) in [(1, 2, 101, 0), (2, 3, 101, 0), (3, 5, 102, 101), (4, 6, 102, 101)] { let head = &chart["notes"][index]; assert_eq!(head["line"], line, "head {}", index); assert_eq!(head["num"], num, "head {}", index); assert_eq!(head["force_sync_group_id"], fs, "head {}", index); assert_eq!(head["parent_id"], 0, "head {}", index); } // Tails at 3.0: the SAME pairing applies to the synthesized cluster for (index, line, num, fs) in [(5, 2, 103, 0), (6, 3, 103, 0), (7, 5, 104, 103), (8, 6, 104, 103)] { let tail = &chart["notes"][index]; assert_eq!(tail["line"], line, "tail {}", index); assert_eq!(tail["num"], num, "tail {}", index); assert_eq!(tail["force_sync_group_id"], fs, "tail {}", index); assert_eq!(tail["child_id"], 0, "tail {}", index); } // The chains still line up: each head's child_num names the tail's spawn group assert_eq!(chart["notes"][1]["child_id"], 5); assert_eq!(chart["notes"][1]["child_num"], 103); assert_eq!(chart["notes"][3]["child_id"], 7); assert_eq!(chart["notes"][3]["child_num"], 104); } #[test] fn mixed() { let beatmap = jzon::array![ sif_note(1.0, 5, 1, 2.0), // tap sif_note(2.0, 3, 3, 1.5), // hold: head at 2.0, tail at 3.5 sif_note(2.5, 7, 4, 0.0), // star sif_note(3.5, 1, 2, 2.0), // parallel with the hold tail sif_note(4.0, 9, 11, 0.0) // lone slide, no chain partner -> stays a tap ]; let (chart, combo) = transcode(&beatmap).unwrap(); // 6 real notes, minus the same-lane hold head assert_eq!(combo, 5); assert_eq!(chart["notes"].len(), 7); // Time order: tap(1.0), head(2.0), star(2.5), tail(3.5), parallel(3.5), swing(4.0) assert_eq!(chart["notes"][2]["child_id"], 4); assert_eq!(chart["notes"][3]["type"], 3); assert_eq!(chart["notes"][4]["parent_id"], 2); // The tail and the parallel tap at 3.5 share a spawn group assert_eq!(chart["notes"][4]["num"], chart["notes"][5]["num"].clone()); assert_eq!(chart["notes"][6]["type"], 1); // Ids stay sequential in time order for (i, data) in chart["notes"].members().enumerate() { assert_eq!(data["id"], i); } } #[test] fn slide_chain_links_across_lanes() { // A three-note sweep right to left, one chain. Every link must cross lanes, which is // what MarkerData.IsSliderMarker keys on. let beatmap = jzon::array![ sif_slide(1.0, 9, 11, 0.0, 500), sif_slide(1.2, 8, 11, 0.0, 500), sif_slide(1.4, 7, 12, 0.0, 500) ]; let (chart, combo) = transcode(&beatmap).unwrap(); // No tails: nothing here is a hold assert_eq!(chart["notes"].len(), 4); // Slides are ordinary notes; the chain carries the meaning assert_eq!(shape(&chart), vec![ (8, 1, 0, 2, 7), // root, child on line 7 (7, 1, 1, 3, 6), // middle: has parent AND child -> IsDistanceMarker (6, 1, 2, 0, 0) // last of the run ]); // Every link crosses lanes, so all three count for combo assert_eq!(combo, 3); } #[test] fn separate_chains_do_not_link() { let beatmap = jzon::array![ sif_slide(1.0, 9, 11, 0.0, 500), sif_slide(1.2, 8, 11, 0.0, 500), sif_slide(2.0, 4, 11, 0.0, 501), sif_slide(2.2, 3, 11, 0.0, 501) ]; let (chart, _) = transcode(&beatmap).unwrap(); assert_eq!(shape(&chart), vec![ (8, 1, 0, 2, 7), (7, 1, 1, 0, 0), // chain 500 ends here, does not reach chain 501 (3, 1, 0, 4, 2), (2, 1, 3, 0, 0) ]); } #[test] fn default_notes_level_does_not_chain() { // notes_level 1 is SIF1's "unchained" default. Treating it as a group id would link every // slide in the song into one run spanning the whole track. let beatmap = jzon::array![ sif_note(1.0, 9, 11, 0.0), sif_note(1.2, 8, 11, 0.0), sif_note(40.0, 2, 11, 0.0) ]; let (chart, combo) = transcode(&beatmap).unwrap(); assert_eq!(shape(&chart), vec![ (8, 1, 0, 0, 0), (7, 1, 0, 0, 0), (1, 1, 0, 0, 0) ]); assert_eq!(combo, 3); } #[test] fn lone_slide_stays_a_tap() { // Nothing to chain to, and a slider needs a cross-lane child let (chart, combo) = transcode(&jzon::array![sif_slide(1.0, 5, 11, 0.0, 500)]).unwrap(); assert_eq!(chart["notes"].len(), 2); assert_eq!(shape(&chart), vec![(4, 1, 0, 0, 0)]); assert_eq!(combo, 1); } #[test] fn slide_chain_ends_in_a_hold() { // A sweep terminating on effect 13: the run settles into a hold on the last lane. // Regression: effect 13 used to lose its hold entirely and arrive as a lone note. let beatmap = jzon::array![ sif_slide(1.0, 9, 11, 0.0, 500), sif_slide(1.2, 8, 11, 0.0, 500), sif_slide(1.4, 7, 13, 0.5, 500) ]; let (chart, combo) = transcode(&beatmap).unwrap(); assert_eq!(chart["notes"].len(), 5); assert_eq!(shape(&chart), vec![ (8, 1, 0, 2, 7), // root of the slide (7, 1, 1, 3, 6), // middle (6, 1, 2, 4, 6), // parent on line 7, child on line 6 -> IsSliderLongMarker (6, 1, 3, 0, 0) // the hold tail, released normally ]); // The tail sits at the slide-hold's time plus its duration assert_eq!(chart["notes"][4]["time"].as_f64().unwrap(), 1.9); // The same-lane hold head does not count; its tail does assert_eq!(combo, 3); } #[test] fn repeated_same_lane_checkpoints_count_like_the_client() { for final_effect in [11, 14] { let mut beatmap = jzon::array![]; for i in 0..13 { beatmap.push(sif_slide(1.0 + i as f64 * 0.1, 5, if i == 12 { final_effect } else { 11 }, 0.0, 500)).unwrap(); } let (chart, combo) = transcode(&beatmap).unwrap(); // The head shares the first checkpoint's result; all twelve // non-root points still have their own runtime judgment. assert_eq!(combo, 12); assert_eq!(chart["max_combo_count"], 12); assert_eq!(chart["notes"].len(), 14); } } #[test] fn combo_count_preserves_hold_and_cross_lane_rules() { for (positions, expected) in [ (vec![5, 5], 1), (vec![5, 5, 5], 2), (vec![5, 5, 6], 2), (vec![4, 5, 5], 2), (vec![4, 5, 5, 5], 3), (vec![4, 5, 6], 3), ] { let mut beatmap = jzon::array![]; for (i, position) in positions.iter().enumerate() { beatmap.push(sif_slide(1.0 + i as f64 * 0.1, *position, 11, 0.0, 500)).unwrap(); } let (_, combo) = transcode(&beatmap).unwrap(); assert_eq!(combo, expected, "chain {:?}", positions); } } #[test] fn combo_repair_rejects_invalid_links() { let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, 3, 1.0)]).unwrap(); for (field, value) in [("id", 2), ("parent_id", 999), ("child_id", 999), ("child_id", 1)] { let mut broken = chart.clone(); broken["notes"][1][field] = value.into(); assert_eq!(max_combo_count(&broken), None, "invalid {}={}", field, value); } let mut broken = chart; broken["notes"][2]["parent_id"] = 0.into(); assert_eq!(max_combo_count(&broken), None); assert_eq!(max_combo_count(&object!{}), None); } #[test] fn slide_hold_needs_a_duration() { // The effect 3 duration check has to cover note_slide_hold too assert!(transcode(&jzon::array![sif_note(1.0, 5, 13, 0.0)]).is_err()); assert!(transcode(&jzon::array![sif_note(1.0, 5, 13, -1.0)]).is_err()); } #[test] fn every_bomb_width_is_a_star_note() { // note_bomb_1/3/5/9. SIF2 has one bomb note, so all four land on type 3; before, // only bomb_1 did and the wider three arrived as ordinary taps. for effect in [4, 5, 6, 7] { let (chart, combo) = transcode(&jzon::array![sif_note(1.0, 5, effect, 0.0)]).unwrap(); assert_eq!(chart["notes"][1]["type"], 3, "effect {}", effect); // Bombs are instantaneous — no tail, and they count for combo assert_eq!(chart["notes"].len(), 2, "effect {}", effect); assert_eq!(chart["notes"][1]["child_id"], 0, "effect {}", effect); assert_eq!(combo, 1, "effect {}", effect); } } #[test] fn every_defined_effect_maps_to_a_real_note_type() { // The whole LiveModel.NoteEffect vocabulary, and what each must become for (effect, kind) in [(1, 1), (2, 1), (3, 1), (4, 3), (5, 3), (6, 3), (7, 3), (11, 1), (12, 1), (13, 1)] { let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, effect, 1.0)]).unwrap(); assert_eq!(chart["notes"][1]["type"], kind, "effect {}", effect); // Whatever it is, the client must be able to resolve it for data in chart["notes"].members().skip(1) { let t = data["type"].as_i64().unwrap(); assert!((1..=3).contains(&t), "effect {} produced unresolvable type {}", effect, t); } } } #[test] fn undefined_effects_fall_back_to_taps() { for effect in [0, 15] { let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, effect, 0.0)]).unwrap(); assert_eq!(chart["notes"][1]["type"], 1, "effect {}", effect); } } #[test] fn bandori_flick_skill_and_slide_flick() { for (effect, kind) in [(8, 2), (9, 3), (14, 2)] { let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, effect, 0.0)]).unwrap(); assert_eq!(chart["notes"][1]["type"], kind, "effect {}", effect); } let (chart, combo) = transcode(&jzon::array![ sif_slide(1.0, 7, 11, 0.0, 2), sif_slide(1.3, 5, 14, 0.0, 2) ]).unwrap(); assert_eq!(combo, 2); assert_eq!(chart["notes"][2]["type"], 2); assert_eq!(chart["notes"][1]["child_id"], chart["notes"][2]["id"].clone()); let (hold, combo) = transcode(&jzon::array![sif_note(1.0, 5, 10, 0.5)]).unwrap(); assert_eq!(combo, 1); assert_eq!(hold["notes"][1]["type"], 1); assert_eq!(hold["notes"][2]["type"], 2); } // Lifted verbatim from a chart uploaded to the live server (custom song 10008, "Edelied", // exported via /custom_song/download/10008) — a swipe run of note_slide into note_slide_hold, // which is the shape that used to arrive as undifferentiated taps. Note the editor writes a // large arbitrary notes_level (38615 here) rather than SIF1's small group index, which is why // notes_level is not used as a sync group. #[test] fn real_uploaded_swipe_run() { let beatmap = jzon::array![ object!{ "timing_sec": 22.0, "effect": 11, "effect_value": 2.0, "notes_attribute": 2, "notes_level": 38615, "position": 9 }, object!{ "timing_sec": 22.166666666666668, "effect": 11, "effect_value": 2.0, "notes_attribute": 2, "notes_level": 38615, "position": 8 }, object!{ "timing_sec": 22.333333333333332, "effect": 11, "effect_value": 2.0, "notes_attribute": 2, "notes_level": 38615, "position": 7 }, object!{ "timing_sec": 22.5, "effect": 13, "effect_value": 0.33333333333333215, "notes_attribute": 2, "notes_level": 38615, "position": 6 } ]; let (chart, combo) = transcode(&beatmap).unwrap(); // 4 source notes + the slide-hold's tail, plus the dummy header assert_eq!(chart["notes"].len(), 6); // One slider running 9 -> 8 -> 7 -> 6 (lines 8..5), the last settling into a hold assert_eq!(shape(&chart), vec![ (8, 1, 0, 2, 7), (7, 1, 1, 3, 6), (6, 1, 2, 4, 5), (5, 1, 3, 5, 5), // slide into hold (5, 1, 4, 0, 0) // tail ]); // The same-lane hold head is the only note that does not count assert_eq!(combo, 4); // Every type must be one ToMarkerType resolves; 0 would silently become MarkerType.None for data in chart["notes"].members().skip(1) { let kind = data["type"].as_i64().unwrap(); assert!((1..=3).contains(&kind), "unresolvable type {}", kind); } } // Parallel holds and a nine-lane wall must retain valid spawn groups. #[test] fn transcode_wall_and_parallel_holds() { let beatmap = jzon::array![ sif_note(1.0, 2, 3, 1.0), sif_note(1.0, 4, 3, 1.0), sif_note(1.0, 6, 3, 1.0), sif_note(1.0, 8, 3, 1.0), sif_note(2.75, 1, 1, 0.0), sif_note(2.75, 2, 1, 0.0), sif_note(2.75, 3, 1, 0.0), sif_note(2.75, 4, 1, 0.0), sif_note(2.75, 5, 1, 0.0), sif_note(2.75, 6, 1, 0.0), sif_note(2.75, 7, 1, 0.0), sif_note(2.75, 8, 1, 0.0), sif_note(2.75, 9, 1, 0.0), sif_note(3.625, 3, 1, 0.0), sif_note(3.625, 5, 1, 0.0), sif_note(3.625, 7, 1, 0.0) ]; let (chart, _) = transcode(&beatmap).unwrap(); // Spell the wall out: adjacent-lane pairs, each later chunk force-synced to the // previous one (heads 101/102, tails 103/104, wall 105..109, triple 110/111) assert_spawn_groups_hold_at_most_two(&chart); let wall: Vec<(i64, i64, i64)> = chart["notes"].members() .filter(|d| d["time"].as_f64() == Some(2.75)) .map(|d| (d["line"].as_i64().unwrap(), d["num"].as_i64().unwrap(), d["force_sync_group_id"].as_i64().unwrap())) .collect(); let mut wall_sorted = wall.clone(); wall_sorted.sort(); assert_eq!(wall_sorted, vec![ (0, 105, 0), (1, 105, 0), (2, 106, 105), (3, 106, 105), (4, 107, 106), (5, 107, 106), (6, 108, 107), (7, 108, 107), (8, 109, 108) ]); // child_num follows the child's NEW num: each head's child_num names a tail group for head in chart["notes"].members().filter(|d| d["time"].as_f64() == Some(1.0)) { let child_id = head["child_id"].as_i64().unwrap(); let tail = chart["notes"].members().find(|d| d["id"].as_i64() == Some(child_id)).unwrap(); assert_eq!(head["child_num"], tail["num"].clone()); assert!([103, 104].contains(&tail["num"].as_i64().unwrap())); } } #[test] fn rejects_bad_charts() { assert!(transcode(&jzon::array![sif_note(1.0, 0, 1, 2.0)]).is_err()); assert!(transcode(&jzon::array![sif_note(1.0, 10, 1, 2.0)]).is_err()); assert!(transcode(&jzon::array![sif_note(-1.0, 5, 1, 2.0)]).is_err()); assert!(transcode(&jzon::array![sif_note(1.0, 5, 3, 0.0)]).is_err()); assert!(transcode(&jzon::array![sif_note(1.0, 5, 1, 2.0), sif_note(1.0, 5, 3, 2.0)]).is_err()); assert!(transcode(&jzon::object!{}).is_err()); } }