From 29ed7a1b46c5bed3d412f3ac55c7cf8aa62d62b6 Mon Sep 17 00:00:00 2001 From: Ethan O'Brien Date: Sat, 1 Aug 2026 20:37:03 -0500 Subject: [PATCH] Fix some custom song stuff --- src/router/custom_song.rs | 315 +++++++++++++++++++++++++++--- src/router/custom_song/chart.rs | 120 +++++++++++- src/router/custom_song/migrate.rs | 41 ++++ 3 files changed, 444 insertions(+), 32 deletions(-) diff --git a/src/router/custom_song.rs b/src/router/custom_song.rs index 4e1c2f6..567a3e8 100644 --- a/src/router/custom_song.rs +++ b/src/router/custom_song.rs @@ -307,15 +307,20 @@ fn process_jacket(bytes: &[u8]) -> Result<(Vec, Vec), String> { Ok((jacket_png, blur_png)) } -fn cue_json(cue: &audio::Cue, cue_name: String) -> JsonValue { +// is_loop follows the official cue convention, and it is load-bearing: the client's CriWare +// layer reports a LOOP cue as forever-playing, so Playback.IsPlayEnd() never turns true for it. +// The live's end trigger (LiveTimeController.UpdateFree: isMusicEnded -> InLiveDelay -> EndWait) +// hangs off exactly that signal, so a looping PLAY cue means the live never ends. Only the +// music-select PREVIEW cue loops, like the official select bgm. +fn cue_json(cue: &audio::Cue, cue_name: String, is_loop: bool) -> JsonValue { object!{ "cue_name": cue_name, "md5": cue.md5.clone(), "size": cue.bytes.len(), "duration_sec": cue.duration_sec as f32, - "is_loop": true, + "is_loop": is_loop, "loop_start_sec": 0.0, - "loop_end_sec": cue.duration_sec as f32 + "loop_end_sec": if is_loop { cue.duration_sec as f32 } else { 0.0 } } } @@ -326,20 +331,74 @@ fn asset_meta(bytes: &[u8]) -> (String, usize) { (format!("{:x}", md5::compute(bytes)), bytes.len()) } -// Score thresholds when the uploader doesn't provide any: take the highest -// difficulty's full_combo and stars, budget base = full_combo * 200 * (1 + stars / 10) -// (~200 points per note, scaled up for harder charts), then C/B/A/S at -// 50%/75%/100%/130% of base. Multi live thresholds are 1.2x the solo ones. -fn default_scores(full_combo: i64, level_number: i64) -> (JsonValue, JsonValue) { - let base = full_combo as f64 * 200.0 * (1.0 + level_number as f64 / 10.0); - let score = |mult: f64| (base * mult) as u32; +// Score rank thresholds. These are NOT per-song in SIF2: all 637 rows of the official live +// masterdata carry the exact same C/B/A/S tuple, solo and multi alike (live.csv columns +// _scoreC.._multiScoreS), because the score depends on deck strength rather than chart size. +// Deriving them from the chart's note count instead made rank S trivial on a short custom +// chart and unreachable on a long one, and skewed everything else that reads them - the live +// score gauge and the deck-confirm score estimation both scale off _scoreS/_multiScoreS +// (LiveData.MaxScore = _scoreS * 5 / 4). +const OFFICIAL_SCORE: [i64; 4] = [20000, 100000, 250000, 350000]; +const OFFICIAL_MULTI_SCORE: [i64; 4] = [70000, 350000, 875000, 1225000]; + +fn default_scores() -> (JsonValue, JsonValue) { (object!{ - "c": score(0.5), "b": score(0.75), "a": score(1.0), "s": score(1.3) + "c": OFFICIAL_SCORE[0], "b": OFFICIAL_SCORE[1], "a": OFFICIAL_SCORE[2], "s": OFFICIAL_SCORE[3] }, object!{ - "c": score(0.5 * 1.2), "b": score(0.75 * 1.2), "a": score(1.0 * 1.2), "s": score(1.3 * 1.2) + "c": OFFICIAL_MULTI_SCORE[0], "b": OFFICIAL_MULTI_SCORE[1], "a": OFFICIAL_MULTI_SCORE[2], "s": OFFICIAL_MULTI_SCORE[3] }) } +// Combo-mission targets. Official live_mission_combo rows are round(hardest difficulty's +// full combo * 0.2/0.4/0.6/0.8) - verified against 626 of the 637 shipped rows (the 11 +// outliers are songs that gained a harder difficulty after the mission row was authored). +// The previous 25/50/75/100% spread made the fourth mission demand a literal FULL COMBO of +// the hardest chart, a target no official song ever sets. +fn mission_combo(hardest_combo: i64) -> JsonValue { + let target = |fraction: f64| (hardest_combo as f64 * fraction + 0.5) as i64; + jzon::array![target(0.2), target(0.4), target(0.6), target(0.8)] +} + +// The live's count-in, and the longest a marker can be in flight before its note. The count-in +// is LiveMst._startWait, 2.0 in every official live row and in ours. The flight time is +// LiveUtils.GetMarkerMoveTime(speed) = 1.725 - 0.125 * speed, clamped at 0.1, where speed is the +// player's per-difficulty rhythm-icon setting; its slowest end (and even a hypothetical 0) stays +// under the count-in, so a note at t >= 0 always has room to travel. Kept explicit so the check +// below stays honest if either constant ever moves. +const START_WAIT_SEC: f64 = 2.0; +const MAX_MARKER_MOVE_SEC: f64 = 1.725; + +// A chart has to fit INSIDE its audio, at both ends. +// +// Tail: the live ends the moment the audio does - LiveTimeController's m_MusicDuration is +// LiveMst._endWait + the music length and _endWait is 0 - so a note whose MISS window closes +// after that is never judged. The player cannot full-combo the chart (the combo missions and +// the FULL COMBO banner both compare against full_combo, which counts every note), and the +// trailing markers are still on screen when the result screen takes over. +// +// Head: a marker spawns at time - GetMarkerMoveTime and the chart clock starts at -_startWait, +// so a note earlier than the flight time minus the count-in would pop in already halfway down +// the lane. +fn validate_chart_fits_audio(level: i64, chart: &JsonValue, duration_sec: f64) -> Result<(), String> { + let end = chart::end_time(chart); + if end > duration_sec { + return Err(format!( + "Difficulty {}: the chart needs {:.2}s but the audio is only {:.2}s long - the last note is never judged, because the live ends when the music does", + level, end, duration_sec + )); + } + if let Some(first) = chart::first_note_time(chart) { + let earliest = MAX_MARKER_MOVE_SEC - START_WAIT_SEC; + if first < earliest { + return Err(format!( + "Difficulty {}: the first note is at {:.2}s, before the {:.2}s the live needs to bring a marker down the lane", + level, first, earliest + )); + } + } + Ok(()) +} + fn create_song(uid: i64, fields: &HashMap>) -> Result { let name = field_str(fields, "name"); let artist = field_str(fields, "artist"); @@ -398,6 +457,10 @@ fn create_song(uid: i64, fields: &HashMap>) -> Result>) -> Result>) -> Result>) -> Result>) -> Result<(), S (None, None) }; + // Every chart in the RESULTING song has to fit the RESULTING audio, so replacing either + // side re-checks the other: new audio is validated against the charts that stay, and a new + // chart against the audio that stays (read back from the catalog's own cue metadata). + let play_duration = match &play { + Some(play) => Some(play.duration_sec), + None => old_song["sound"]["play"]["duration_sec"].as_f64() + }; + if let Some(duration) = play_duration { + for (level, chart, _, _) in charts.iter() { + match chart { + Some((chart, _)) => validate_chart_fits_audio(*level, chart, duration)?, + None => { + let path = song_path(music_id, &format!("chart_{}.json", level)); + if let Ok(bytes) = fs::read(&path) { + if let Ok(stored) = jzon::parse(&String::from_utf8_lossy(&bytes)) { + validate_chart_fits_audio(*level, &stored, duration)?; + } + } + } + } + } + } + let suffix = format!("Custom{}", music_id); let mut levels = array![]; for (level, chart, full_combo, level_number) in charts.iter() { @@ -656,8 +742,8 @@ fn update_song(music_id: i64, fields: &HashMap>) -> Result<(), S // Scores and combo missions always derive from the resulting state, with // the same formulas as upload - let (_, _, hardest_combo, hardest_stars) = charts.last().unwrap(); - let (score, multi_score) = default_scores(*hardest_combo, *hardest_stars); + let (_, _, hardest_combo, _) = charts.last().unwrap(); + let (score, multi_score) = default_scores(); let mut manifest_levels = array![]; for (level, _, _, level_number) in charts.iter() { @@ -685,10 +771,10 @@ fn update_song(music_id: i64, fields: &HashMap>) -> Result<(), S // Same id everywhere, so the cue sheet/cue names never change let mut sound = old_song["sound"].clone(); if let Some(play) = &play { - sound["play"] = cue_json(play, format!("play_{}_{}", music_id, suffix)); + sound["play"] = cue_json(play, format!("play_{}_{}", music_id, suffix), false); } if let Some(select) = &select { - sound["select"] = cue_json(select, format!("select_{}_{}", music_id, suffix)); + sound["select"] = cue_json(select, format!("select_{}_{}", music_id, suffix), true); } let song = object!{ @@ -707,8 +793,8 @@ fn update_song(music_id: i64, fields: &HashMap>) -> Result<(), S "end_wait": 0.0, "score": score, "multi_score": multi_score, - // Combo missions at 25/50/75/100% of the hardest difficulty's full combo - "mission_combo": [hardest_combo / 4, hardest_combo / 2, hardest_combo * 3 / 4, *hardest_combo], + // Combo missions at the official 20/40/60/80% of the hardest difficulty's full combo + "mission_combo": mission_combo(*hardest_combo), "jacket": format!("/custom_song/assets/{}/jacket.png", music_id), "jacket_md5": jacket_md5, "jacket_size": jacket_size, @@ -1029,17 +1115,19 @@ mod tests { // A SIF1 chart whose transcode contains 3+ simultaneous notes: 4 parallel holds into a // full 9-lane wall (the shape of the field-reported chart that exposed the old encoding) + // 4 parallel holds then a 9-wide wall. Timed to fit inside the 2s test track: uploads are + // rejected when a chart outlives its audio (validate_chart_fits_audio) fn wall_chart() -> Vec { let mut beatmap = jzon::array![]; for position in [2, 4, 6, 8] { beatmap.push(jzon::object!{ - "timing_sec": 1.0, "notes_attribute": 1, "notes_level": 1, - "effect": 3, "effect_value": 1.0, "position": position + "timing_sec": 0.5, "notes_attribute": 1, "notes_level": 1, + "effect": 3, "effect_value": 0.5, "position": position }).unwrap(); } for position in 1..=9 { beatmap.push(jzon::object!{ - "timing_sec": 2.75, "notes_attribute": 1, "notes_level": 1, + "timing_sec": 1.5, "notes_attribute": 1, "notes_level": 1, "effect": 1, "effect_value": 0.0, "position": position }).unwrap(); } @@ -1123,6 +1211,170 @@ mod tests { assert_eq!(database::get_revision(), revision + 1); } + // The live PLAY cue must never be a loop cue: the client reports a looping playback as + // forever-playing, and the live's end trigger waits on playback-end, so a looping play cue + // means the live never ends. New uploads emit is_loop:false, the preview cue keeps looping, + // and the startup migration un-loops catalogs written before the distinction existed. + #[test] + fn play_cue_never_loops() { + let _lock = crate::runtime::lock_test_data_path(); + + let mut fields = HashMap::new(); + field(&mut fields, "name", "Loop Check"); + field(&mut fields, "artist", "Loop Artist"); + field(&mut fields, "attribute", "1"); + field(&mut fields, "level_number_1", "5"); + fields.insert(String::from("jacket"), test_png()); + // A tone no other test uses: the audio store is content-addressed and shared across + // tests, so a duplicate cue would keep another test's cue alive past its own GC + fields.insert(String::from("audio"), test_ogg_tone(880.0)); + fields.insert(String::from("chart_1"), test_chart()); + let music_id = create_song(5555, &fields).unwrap(); + + let song = database::get_song(music_id).unwrap(); + assert_eq!(song["sound"]["play"]["is_loop"], false); + assert_eq!(song["sound"]["play"]["loop_end_sec"], 0.0); + assert_eq!(song["sound"]["select"]["is_loop"], true); + assert!(song["sound"]["select"]["loop_end_sec"].as_f64().unwrap() > 0.0); + + // Doctor the catalog back to the pre-fix shape an old server would have written, + // then boot: the migration un-loops the play cue and bumps the revision once + let mut old = song.clone(); + old["sound"]["play"]["is_loop"] = true.into(); + old["sound"]["play"]["loop_end_sec"] = old["sound"]["play"]["duration_sec"].clone(); + database::update_song(music_id, &old); + let revision = database::get_revision(); + + migrate::run(); + + let song = database::get_song(music_id).unwrap(); + assert_eq!(song["sound"]["play"]["is_loop"], false); + assert_eq!(song["sound"]["play"]["loop_end_sec"], 0.0); + assert_eq!(song["sound"]["select"]["is_loop"], true); + // The ogg bytes never moved, so the audio md5 must not change (no re-download) + assert_eq!(song["sound"]["play"]["md5"], old["sound"]["play"]["md5"]); + assert_eq!(database::get_revision(), revision + 1); + + // Idempotent + migrate::run(); + assert_eq!(database::get_revision(), revision + 1); + } + + // A chart that outlives its audio is rejected on upload AND on edit (from either side - + // swapping in a longer chart, or shorter audio under charts that stay). The live ends when + // the music does, so those notes would never be judged. A note at t=0 is fine: the 2.0s + // count-in always covers the marker's flight time. + #[test] + fn chart_must_fit_its_audio() { + let _lock = crate::runtime::lock_test_data_path(); + + // Taps at the given times, one per lane sweep + let chart_at = |times: &[f64]| { + let mut beatmap = jzon::array![]; + for (i, time) in times.iter().enumerate() { + beatmap.push(jzon::object!{ + "timing_sec": *time, "notes_attribute": 1, "notes_level": 1, + "effect": 1, "effect_value": 0.0, "position": (i % 9) + 1 + }).unwrap(); + } + jzon::stringify(beatmap).into_bytes() + }; + let base = |chart: Vec| { + let mut fields = HashMap::new(); + field(&mut fields, "name", "Fit Check"); + field(&mut fields, "artist", "Fit Artist"); + field(&mut fields, "attribute", "1"); + field(&mut fields, "level_number_1", "5"); + fields.insert(String::from("jacket"), test_png()); + // 2 seconds of audio + fields.insert(String::from("audio"), test_ogg_tone(990.0)); + fields.insert(String::from("chart_1"), chart); + fields + }; + + // 2.5s note in a 2.0s track: the last note's MISS window closes long after the live ends + let error = create_song(6666, &base(chart_at(&[0.5, 2.5]))).unwrap_err(); + assert!(error.contains("Difficulty 1"), "{}", error); + assert!(error.contains("the audio is only"), "{}", error); + // Nothing was stored + assert!(database::get_song(6666).is_none()); + + // Right at the edge: 1.8 + the 0.15 tap MISS window is 1.95, inside 2.0. A note at t=0 + // is accepted too - the count-in covers the marker flight + let music_id = create_song(6666, &base(chart_at(&[0.0, 1.8]))).unwrap(); + let before = database::get_song(music_id).unwrap(); + + // Editing in a chart that doesn't fit the stored audio is rejected, and the stored + // song is untouched + let mut fields = HashMap::new(); + fields.insert(String::from("chart_1"), chart_at(&[0.5, 3.0])); + let error = update_song(music_id, &fields).unwrap_err(); + assert!(error.contains("the audio is only"), "{}", error); + assert_eq!(jzon::stringify(database::get_song(music_id).unwrap()), jzon::stringify(before.clone())); + + // Adding a difficulty whose chart doesn't fit is rejected the same way + let mut fields = HashMap::new(); + fields.insert(String::from("chart_4"), chart_at(&[0.5, 5.0])); + field(&mut fields, "level_number_4", "12"); + assert!(update_song(music_id, &fields).is_err()); + assert_eq!(jzon::stringify(database::get_song(music_id).unwrap()), jzon::stringify(before)); + + // A chart that DOES fit still edits in + let mut fields = HashMap::new(); + fields.insert(String::from("chart_1"), chart_at(&[0.25, 1.5])); + update_song(music_id, &fields).unwrap(); + } + + // The startup migration also corrects the two fabricated masterdata values in stored + // catalogs: score-rank thresholds (official constants, not per-song) and combo missions + // (20/40/60/80% of the hardest full combo, not 25/50/75/100%) + #[test] + fn startup_migration_fixes_fabricated_scores_and_missions() { + let _lock = crate::runtime::lock_test_data_path(); + + let mut fields = HashMap::new(); + field(&mut fields, "name", "Old Values"); + field(&mut fields, "artist", "Old Artist"); + field(&mut fields, "attribute", "3"); + field(&mut fields, "level_number_1", "5"); + fields.insert(String::from("jacket"), test_png()); + fields.insert(String::from("audio"), test_ogg_tone(1100.0)); + fields.insert(String::from("chart_1"), wall_chart()); + let music_id = create_song(7777, &fields).unwrap(); + + let mut song = database::get_song(music_id).unwrap(); + let hardest = song["levels"].members().last().unwrap()["full_combo"].as_i64().unwrap(); + // Doctor the catalog back to the pre-fix formulas + let base = hardest as f64 * 200.0 * (1.0 + 5.0 / 10.0); + song["score"] = object!{ + "c": (base * 0.5) as u32, "b": (base * 0.75) as u32, "a": base as u32, "s": (base * 1.3) as u32 + }; + song["multi_score"] = object!{ + "c": (base * 0.6) as u32, "b": (base * 0.9) as u32, "a": (base * 1.2) as u32, "s": (base * 1.56) as u32 + }; + song["mission_combo"] = jzon::array![hardest / 4, hardest / 2, hardest * 3 / 4, hardest]; + database::update_song(music_id, &song); + let revision = database::get_revision(); + + migrate::run(); + + let song = database::get_song(music_id).unwrap(); + let (score, multi_score) = default_scores(); + assert_eq!(song["score"], score); + assert_eq!(song["multi_score"], multi_score); + assert_eq!(song["score"]["s"], 350000); + assert_eq!(song["multi_score"]["s"], 1225000); + // 20/40/60/80%, and never the full combo itself + assert_eq!(song["mission_combo"], mission_combo(hardest)); + assert_eq!(song["mission_combo"][3].as_i64().unwrap(), (hardest as f64 * 0.8 + 0.5) as i64); + assert!(song["mission_combo"][3].as_i64().unwrap() < hardest); + assert_eq!(database::get_revision(), revision + 1); + + // Idempotent + migrate::run(); + assert_eq!(database::get_revision(), revision + 1); + } + // Export a song, import the package as another user, and the served song // must be identical apart from the assigned music_id - INCLUDING the audio // md5s: ogg uploads are stored as-is and the preview encode is @@ -1219,16 +1471,19 @@ mod tests { assert_eq!(song["sound"]["select"]["md5"], before["sound"]["select"]["md5"]); assert_eq!(database::get_revision(), revision + 1); - // Adding a difficulty re-derives scores from the new hardest chart, - // and the manifest follows so exports reflect the edited state + // Adding a difficulty re-derives the combo missions from the new hardest chart, the + // score thresholds stay on the official constants, and the manifest follows so + // exports reflect the edited state let mut fields = HashMap::new(); fields.insert(String::from("chart_4"), test_chart()); field(&mut fields, "level_number_4", "12"); update_song(music_id, &fields).unwrap(); let song = database::get_song(music_id).unwrap(); assert_eq!(song["levels"].len(), 2); - let (score, _) = default_scores(3, 12); + let (score, _) = default_scores(); assert_eq!(song["score"]["s"], score["s"]); + let hardest = song["levels"].members().last().unwrap()["full_combo"].as_i64().unwrap(); + assert_eq!(song["mission_combo"], mission_combo(hardest)); assert!(fs::read(song_path(music_id, "chart_4.json")).is_ok()); assert!(fs::read(song_path(music_id, "original/chart_4.json")).is_ok()); let manifest = jzon::parse(&String::from_utf8_lossy(&fs::read(song_path(music_id, "original/manifest.json")).unwrap())).unwrap(); diff --git a/src/router/custom_song/chart.rs b/src/router/custom_song/chart.rs index 5e3fd9e..c24ff4c 100644 --- a/src/router/custom_song/chart.rs +++ b/src/router/custom_song/chart.rs @@ -64,6 +64,71 @@ use jzon::{object, JsonValue}; // - max_combo_count = all real notes EXCEPT hold heads whose tail is on the same // line (the game counts a same-lane hold as one combo for the chain) +// 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; + +// 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 { + let lines: Vec<(i64, i64)> = 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.iter().find(|(i, _)| *i == id).map(|(_, line)| *line); + + 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 + })) +} + struct WorkNote { time: f64, line: i64, @@ -207,7 +272,7 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> { let mut start = 0; while start < order.len() { let mut end = start + 1; - while end < order.len() && work[order[end]].time == work[order[start]].time { + while end < order.len() && simultaneous(work[order[end]].time, work[order[start]].time) { end += 1; } let mut cluster: Vec = order[start..end].to_vec(); @@ -307,7 +372,7 @@ pub fn regroup(chart: &mut JsonValue) -> bool { let mut start = 0; while start < order.len() { let mut end = start + 1; - while end < order.len() && notes[order[end]].1 == notes[order[start]].1 { + while end < order.len() && simultaneous(notes[order[end]].1, notes[order[start]].1) { end += 1; } let mut cluster: Vec = order[start..end].to_vec(); @@ -460,6 +525,57 @@ mod tests { 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) { diff --git a/src/router/custom_song/migrate.rs b/src/router/custom_song/migrate.rs index ca8e70b..2f21470 100644 --- a/src/router/custom_song/migrate.rs +++ b/src/router/custom_song/migrate.rs @@ -11,6 +11,10 @@ use crate::runtime::get_data_path; // the regroup needs (time + line per note), so they are rewritten in place — no original // upload required, which also covers songs from before export support. // +// The same pass also corrects the stored catalog values that were fabricated rather than +// derived from official masterdata (see below): the looping PLAY cue, the per-song score-rank +// thresholds and the combo-mission targets. +// // For each song directory with a catalog row, every chart with an over-shared num is // regrouped (chart::regroup), rewritten to disk, and its level's md5/size in the catalog // blob updated — the changed md5 re-keys the client's content-addressed cache, so clients @@ -38,6 +42,43 @@ pub fn run() { let Some(mut song) = database::get_song(music_id) else { continue; }; let mut changed = false; + + // Catalogs written before cue_json took an is_loop argument marked BOTH cues as loop + // cues. A looping PLAY cue never reports playback-end to the client, and the live's end + // trigger (LiveTimeController: isMusicEnded -> InLiveDelay -> EndWait) hangs off exactly + // that, so those lives never finish. Only the metadata changes - the ogg bytes and their + // md5 are untouched, so no re-download is needed, just a catalog resync. + if song["sound"]["play"]["is_loop"] == true { + song["sound"]["play"]["is_loop"] = false.into(); + song["sound"]["play"]["loop_end_sec"] = 0.0.into(); + changed = true; + println!("Custom song {}: play cue un-looped (pre-fix catalog)", music_id); + } + + // Score-rank thresholds and combo-mission targets used to be invented per song. Both are + // objective masterdata: the official live rows all carry ONE score tuple, and every + // live_mission_combo row is round(hardest full combo * 0.2/0.4/0.6/0.8) (see + // default_scores / mission_combo). Recompute them the way an edit would, so songs + // uploaded before the fix stop handing out rank S for free and stop asking for a literal + // full combo on the fourth combo mission. + let (score, multi_score) = super::default_scores(); + if song["score"] != score || song["multi_score"] != multi_score { + song["score"] = score; + song["multi_score"] = multi_score; + changed = true; + println!("Custom song {}: score rank thresholds reset to the official values", music_id); + } + // Same "hardest difficulty" rule as upload/edit: the last level entry, which both write + // in ascending level order + if let Some(hardest) = song["levels"].members().last().and_then(|l| l["full_combo"].as_i64()) { + let missions = super::mission_combo(hardest); + if song["mission_combo"] != missions { + song["mission_combo"] = missions; + changed = true; + println!("Custom song {}: combo missions rescaled to the official 20/40/60/80%", music_id); + } + } + for level in 1..=LEVEL_COUNT { let path = song_path(music_id, &format!("chart_{}.json", level)); let Ok(bytes) = fs::read(&path) else { continue; };