From ee4ce5fed4aec58b53cbfa380d25e642c360442b Mon Sep 17 00:00:00 2001 From: Ethan O'Brien Date: Sun, 27 Sep 2026 12:48:47 -0500 Subject: [PATCH] Remove chart migrate --- src/lib.rs | 4 - src/router/custom_song.rs | 233 +----------------------------- src/router/custom_song/chart.rs | 161 +-------------------- src/router/custom_song/migrate.rs | 132 ----------------- 4 files changed, 5 insertions(+), 525 deletions(-) delete mode 100644 src/router/custom_song/migrate.rs diff --git a/src/lib.rs b/src/lib.rs index f65f78f..95cddd8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,12 +45,8 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> { println!("Purged {} accounts", ct); } - router::custom_song::migrate::run(); router::custom_song::sweep_audio(); router::custom_3dmv::sweep_blobs(); - // The multi-live relay's expiry timers, on the system arbiter rather than on whichever - // HTTP worker happened to serve the first WebSocket upgrade — a worker panic must not - // be able to strand every room's seats for the life of the process. router::multi_live::start_sweeper(); let rv = HttpServer::new(|| App::new() diff --git a/src/router/custom_song.rs b/src/router/custom_song.rs index d8d0b50..bdafd90 100644 --- a/src/router/custom_song.rs +++ b/src/router/custom_song.rs @@ -2,8 +2,6 @@ // in-process symphonia + vorbis machinery pub mod audio; mod chart; -// Idempotent startup repairs for stored custom charts and catalog metadata. -pub mod migrate; mod package; use jzon::{array, object, JsonValue}; @@ -1469,167 +1467,11 @@ mod tests { fn field(fields: &mut HashMap>, key: &str, value: &str) { fields.insert(String::from(key), value.as_bytes().to_vec()); } - - // 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": 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": 1.5, "notes_attribute": 1, "notes_level": 1, - "effect": 1, "effect_value": 0.0, "position": position - }).unwrap(); - } - jzon::stringify(beatmap).into_bytes() - } - - // The startup migration: a chart stored with the PRE-pairing encoding (whole equal-time - // clusters sharing one num) is regrouped in place, its catalog md5/size follow the new - // bytes, the revision bumps exactly once, correctly-encoded songs stay byte-identical, - // and a second run is a complete no-op - #[test] - fn startup_migration_regroups_pre_fix_charts() { - let _lock = crate::runtime::lock_test_data_path(); - - let mut fields = HashMap::new(); - field(&mut fields, "name", "Migration Target"); - field(&mut fields, "artist", "Wall Artist"); - field(&mut fields, "attribute", "1"); - field(&mut fields, "level_number_4", "15"); - fields.insert(String::from("jacket"), test_png()); - fields.insert(String::from("audio"), test_ogg_tone(550.0)); - fields.insert(String::from("chart_4"), wall_chart()); - let target = create_song(3333, &fields).unwrap(); - - let mut fields = HashMap::new(); - field(&mut fields, "name", "Migration Control"); - field(&mut fields, "artist", "Control Artist"); - field(&mut fields, "attribute", "2"); - field(&mut fields, "level_number_1", "5"); - fields.insert(String::from("jacket"), test_png()); - fields.insert(String::from("audio"), test_ogg_tone(770.0)); - fields.insert(String::from("chart_1"), test_chart()); - let control = create_song(4444, &fields).unwrap(); - - // The upload stored the CURRENT encoding; capture it, then doctor the store back to - // the pre-pairing form exactly as an old server would have written it: squashed - // chart bytes on disk and the catalog md5/size matching those bytes - let path = song_path(target, "chart_4.json"); - let fixed_bytes = fs::read(&path).unwrap(); - let mut squashed = jzon::parse(&String::from_utf8_lossy(&fixed_bytes)).unwrap(); - chart::squash_to_pre_fix(&mut squashed); - let squashed_bytes = jzon::stringify(squashed).into_bytes(); - assert_ne!(squashed_bytes, fixed_bytes); - fs::write(&path, &squashed_bytes).unwrap(); - let mut song = database::get_song(target).unwrap(); - let (md5, size) = asset_meta(&squashed_bytes); - for entry in song["levels"].members_mut() { - if entry["level"] == 4 { - entry["md5"] = md5.clone().into(); - entry["size"] = size.into(); - } - } - database::update_song(target, &song); - - let control_bytes = fs::read(song_path(control, "chart_1.json")).unwrap(); - let control_song = database::get_song(control).unwrap(); - let revision = database::get_revision(); - - migrate::run(); - - // The target chart is byte-identical to what the current transcoder stores, and the - // catalog follows the new bytes - let migrated = fs::read(&path).unwrap(); - assert_eq!(migrated, fixed_bytes); - let song = database::get_song(target).unwrap(); - let level = song["levels"].members().find(|l| l["level"] == 4).unwrap(); - let (md5, size) = asset_meta(&fixed_bytes); - assert_eq!(level["md5"].to_string(), md5); - assert_eq!(level["size"].as_usize().unwrap(), size); - // full_combo never depended on grouping and must not move - assert_eq!(level["full_combo"], 9 + 4); - - // Exactly one revision bump, and the control song is untouched - assert_eq!(database::get_revision(), revision + 1); - assert_eq!(fs::read(song_path(control, "chart_1.json")).unwrap(), control_bytes); - assert_eq!(jzon::stringify(database::get_song(control).unwrap()), jzon::stringify(control_song)); - - // Idempotent: a second boot changes nothing and bumps nothing - migrate::run(); - assert_eq!(fs::read(&path).unwrap(), fixed_bytes); - assert_eq!(database::get_revision(), revision + 1); - } - - #[test] - fn startup_migration_recounts_same_lane_checkpoints() { - let _lock = crate::runtime::lock_test_data_path(); - let mut beatmap = array![]; - for i in 0..13 { - beatmap.push(object!{ - "timing_sec": 0.5 + i as f64 * 0.05, "position": 5, - "notes_level": 500, "effect": if i == 12 { 14 } else { 11 }, - "effect_value": 0.0 - }).unwrap(); - } - let mut fields = HashMap::new(); - field(&mut fields, "name", "Repeated Checkpoint Count"); - field(&mut fields, "artist", "Count Test"); - field(&mut fields, "attribute", "1"); - field(&mut fields, "level_number_4", "10"); - fields.insert(String::from("jacket"), test_png()); - fields.insert(String::from("audio"), test_ogg_tone(615.0)); - fields.insert(String::from("chart_4"), jzon::stringify(beatmap).into_bytes()); - let id = create_song(3333, &fields).unwrap(); - let path = song_path(id, "chart_4.json"); - let corrected_bytes = fs::read(&path).unwrap(); - let corrected_song = database::get_song(id).unwrap(); - assert_eq!(corrected_song["levels"][0]["full_combo"], 12); - - let mut chart = jzon::parse(&String::from_utf8_lossy(&corrected_bytes)).unwrap(); - chart["max_combo_count"] = 1.into(); - let old_bytes = jzon::stringify(chart).into_bytes(); - fs::write(&path, &old_bytes).unwrap(); - let mut song = corrected_song.clone(); - let (md5, size) = asset_meta(&old_bytes); - song["levels"][0]["full_combo"] = 1.into(); - song["levels"][0]["md5"] = md5.into(); - song["levels"][0]["size"] = size.into(); - song["mission_combo"] = mission_combo(1); - database::update_song(id, &song); - let revision = database::get_revision(); - - migrate::run(); - assert_eq!(fs::read(&path).unwrap(), corrected_bytes); - assert_eq!(database::get_song(id).unwrap(), corrected_song); - assert_eq!(database::get_revision(), revision + 1); - migrate::run(); - assert_eq!(fs::read(&path).unwrap(), corrected_bytes); - assert_eq!(database::get_revision(), revision + 1); - - // A stale catalog alone must also recover, without rewriting the chart. - song = corrected_song.clone(); - song["levels"][0]["full_combo"] = 1.into(); - database::update_song(id, &song); - migrate::run(); - assert_eq!(fs::read(&path).unwrap(), corrected_bytes); - assert_eq!(database::get_song(id).unwrap(), corrected_song); - assert_eq!(database::get_revision(), revision + 2); - migrate::run(); - assert_eq!(database::get_revision(), revision + 2); - } + // The live PLAY cue must never be a loop cue: the client reports a looping playback as // 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. + // means the live never ends. New uploads emit is_loop:false; the preview cue keeps looping. #[test] fn play_cue_never_loops() { let _lock = crate::runtime::lock_test_data_path(); @@ -1651,28 +1493,6 @@ mod tests { 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); } // Song text is rendered by TMP with rich text on and no escaping, so a tag in a name or an @@ -1816,55 +1636,6 @@ mod tests { 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 diff --git a/src/router/custom_song/chart.rs b/src/router/custom_song/chart.rs index 719598c..6a01863 100644 --- a/src/router/custom_song/chart.rs +++ b/src/router/custom_song/chart.rs @@ -388,112 +388,6 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> { Ok((chart, combo)) } -// Regroups a STORED transcoded chart whose spawn groups predate the pairing rule above: the -// old transcoder gave every note of an equal-time cluster one shared num (force_sync_group_id -// always 0), and the client renders no head markers for a group of 3+ (see the header -// comment). This rebuilds num / force_sync_group_id in place with the same clustering the -// transcoder now uses — equal final time, lane-sorted, chunks of two, chained -// force_sync_group_id — and re-points child_num at each child's renumbered spawn group. -// Everything else (ids, times, lines, types, parent/child links, max_combo_count — combo -// counting never depended on grouping) is untouched, so on a chart the current transcoder -// produced this reproduces the stored bytes exactly. -// -// Returns false (chart untouched) unless some num is shared by MORE than two notes. That -// makes it a safe no-op on current uploads AND on official-style encodings (whose num values -// differ from ours — e.g. gaps of 3 — but whose groups never exceed two). -pub fn regroup(chart: &mut JsonValue) -> bool { - // (id, time, line) per real note; the dummy header at [0] stays untouched - let notes: Vec<(i64, f64, i64)> = chart["notes"].members().skip(1).map(|n| ( - n["id"].as_i64().unwrap_or(0), - n["time"].as_f64().unwrap_or(0.0), - n["line"].as_i64().unwrap_or(0) - )).collect(); - - // Only a pre-pairing chart (some num shared 3+ ways) is rewritten - let mut group_sizes: Vec<(i64, i64)> = Vec::new(); - for data in chart["notes"].members().skip(1) { - let num = data["num"].as_i64().unwrap_or(0); - match group_sizes.iter_mut().find(|(n, _)| *n == num) { - Some((_, count)) => *count += 1, - None => group_sizes.push((num, 1)) - } - } - if group_sizes.iter().all(|(_, count)| *count <= 2) { - return false; - } - - // Time order; ids break ties (transcode issues them in time order, so this reproduces - // the emission order the grouping pass originally saw) - let mut order: Vec = (0..notes.len()).collect(); - order.sort_by(|a, b| notes[*a].1.total_cmp(¬es[*b].1).then(notes[*a].0.cmp(¬es[*b].0))); - - // id -> (new num, new force_sync_group_id) - let mut assigned: Vec<(i64, i64, i64)> = Vec::with_capacity(notes.len()); - let mut num = 100; - let mut start = 0; - while start < order.len() { - let mut end = 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(); - cluster.sort_by_key(|index| notes[*index].2); - let mut prev_num = 0; - for chunk in cluster.chunks(2) { - num += 1; - for index in chunk { - assigned.push((notes[*index].0, num, prev_num)); - } - prev_num = num; - } - start = end; - } - let lookup = |id: i64| assigned.iter().find(|(i, _, _)| *i == id).map(|(_, n, f)| (*n, *f)); - - for data in chart["notes"].members_mut().skip(1) { - let Some((new_num, force)) = lookup(data["id"].as_i64().unwrap_or(0)) else { continue; }; - data["num"] = new_num.into(); - data["force_sync_group_id"] = force.into(); - let child = data["child_id"].as_i64().unwrap_or(0); - if child != 0 { - // child_num names the child's spawn group and must follow its new num - data["child_num"] = lookup(child).map(|(n, _)| n).unwrap_or(0).into(); - } - } - true -} - -// Test helper: fabricates what pre-pairing servers stored, by squashing a current chart back -// to the OLD encoding — one shared num per equal-time cluster, force_sync_group_id 0, and -// child_num following. Lives outside the tests module so the migration tests in -// router/custom_song.rs can build realistic pre-fix fixtures from transcode output. -#[cfg(test)] -pub fn squash_to_pre_fix(chart: &mut JsonValue) { - let notes: Vec<(i64, f64)> = chart["notes"].members().skip(1) - .map(|n| (n["id"].as_i64().unwrap(), n["time"].as_f64().unwrap())) - .collect(); - let mut order: Vec = (0..notes.len()).collect(); - order.sort_by(|a, b| notes[*a].1.total_cmp(¬es[*b].1).then(notes[*a].0.cmp(¬es[*b].0))); - let mut nums: Vec<(i64, i64)> = Vec::new(); - let mut num = 100; - let mut last_time = f64::NEG_INFINITY; - for index in order { - if notes[index].1 != last_time { - num += 1; - last_time = notes[index].1; - } - nums.push((notes[index].0, num)); - } - let lookup = |id: i64| nums.iter().find(|(i, _)| *i == id).map(|(_, n)| *n).unwrap_or(0); - for data in chart["notes"].members_mut().skip(1) { - data["num"] = lookup(data["id"].as_i64().unwrap()).into(); - data["force_sync_group_id"] = 0.into(); - let child = data["child_id"].as_i64().unwrap_or(0); - if child != 0 { - data["child_num"] = lookup(child).into(); - } - } -} #[cfg(test)] mod tests { @@ -1010,11 +904,9 @@ mod tests { } } - // The 10011 field shape: 4 parallel holds into a full 9-lane wall into a triple. Squashing - // transcode output reproduces the old encoding exactly (one num per cluster, no force - // links); regroup must restore the current encoding BYTE-IDENTICALLY, child_num included. + // Parallel holds and a nine-lane wall must retain valid spawn groups. #[test] - fn regroup_restores_pre_fix_wall_and_parallel_holds() { + 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), @@ -1023,23 +915,7 @@ mod tests { 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 (expected, _) = transcode(&beatmap).unwrap(); - - let mut chart = expected.clone(); - squash_to_pre_fix(&mut chart); - // Sanity: the squash really is the old encoding — whole clusters share one num - assert_eq!(chart["notes"][1]["num"], 101); - assert_eq!(chart["notes"][4]["num"], 101); // all 4 hold heads - assert_eq!(chart["notes"][9]["num"], 103); - assert_eq!(chart["notes"][17]["num"], 103); // all 9 wall notes - assert_eq!(chart["notes"][1]["child_num"], 102, "squashed child_num must follow"); - for data in chart["notes"].members() { - assert_eq!(data["force_sync_group_id"], 0); - } - - assert!(regroup(&mut chart), "a squashed chart must be rewritten"); - assert_eq!(jzon::stringify(chart.clone()), jzon::stringify(expected.clone()), - "regroup must reproduce the current transcoder's output exactly"); + 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) @@ -1066,37 +942,6 @@ mod tests { } } - #[test] - fn regroup_is_a_no_op_on_current_encoding() { - let beatmap = jzon::array![ - sif_note(1.0, 2, 1, 0.0), sif_note(1.0, 5, 1, 0.0), sif_note(1.0, 8, 1, 0.0), - sif_note(2.0, 4, 3, 1.5) - ]; - let (chart, _) = transcode(&beatmap).unwrap(); - let before = jzon::stringify(chart.clone()); - let mut chart = chart; - assert!(!regroup(&mut chart)); - assert_eq!(jzon::stringify(chart), before); - } - - // Official-shaped encodings (1132_5_Sn t=9.781: num gaps of 3, force_sync naming the other - // pair) have groups of at most two and must never be "normalized" to our num sequence - #[test] - fn regroup_is_a_no_op_on_official_shaped_charts() { - let mut chart = object!{ - "max_lane": 9, "sound_name": "", "max_combo_count": 4, - "notes": [ - {"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}, - {"id": 45, "num": 145, "line": 0, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0}, - {"id": 46, "num": 145, "line": 1, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0}, - {"id": 47, "num": 148, "line": 7, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 145}, - {"id": 48, "num": 148, "line": 8, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 145} - ] - }; - let before = jzon::stringify(chart.clone()); - assert!(!regroup(&mut chart)); - assert_eq!(jzon::stringify(chart), before); - } #[test] fn rejects_bad_charts() { diff --git a/src/router/custom_song/migrate.rs b/src/router/custom_song/migrate.rs deleted file mode 100644 index 017b62c..0000000 --- a/src/router/custom_song/migrate.rs +++ /dev/null @@ -1,132 +0,0 @@ -use std::fs; - -use super::{chart, database, song_path, asset_meta, LEVEL_COUNT}; -use crate::runtime::get_data_path; - -// One-time, idempotent startup migration for charts transcoded before the spawn-group pairing -// rule (chart.rs header): the old transcoder gave every note of an equal-time cluster ONE -// shared num, and the client creates no head markers for a group of 3+ notes -// (LiveMarkerControl.CreateMarkerUI plain-returns on count > 2), so 3+ simultaneous notes -// were judged but never rendered. Stored transcoded charts are derived data with everything -// 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. Combo totals are recalculated using -// the client's runtime rule, including repeated same-lane slide checkpoints. -// -// 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 -// re-download the fixed chart on their next catalog sync. The revision is bumped ONCE if -// anything changed. Charts the current transcoder produced (and official-shaped encodings) -// are left byte-identical, so re-running every boot is free. -pub fn run() { - if super::disabled() { - return; - } - let Ok(entries) = fs::read_dir(get_data_path("custom_songs")) else { - // No custom_songs directory yet - nothing was ever uploaded - return; - }; - - let mut music_ids: Vec = entries.flatten() - .filter_map(|entry| entry.file_name().to_string_lossy().parse::().ok()) - .collect(); - music_ids.sort(); - - let mut songs_changed = 0; - let mut charts_changed = 0; - for music_id in music_ids { - // A song directory without a catalog row is never served; leave it alone - 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); - } - for level in 1..=LEVEL_COUNT { - let path = song_path(music_id, &format!("chart_{}.json", level)); - let Ok(bytes) = fs::read(&path) else { continue; }; - let Ok(mut chart_data) = jzon::parse(&String::from_utf8_lossy(&bytes)) else { - println!("Custom song {} chart {}: not valid JSON, migration skipped", music_id, level); - continue; - }; - let Some(full_combo) = chart::max_combo_count(&chart_data) else { - println!("Custom song {} chart {}: invalid note links, migration skipped", music_id, level); - continue; - }; - let regrouped = chart::regroup(&mut chart_data); - let recounted = chart_data["max_combo_count"] != full_combo; - let (md5, size) = if regrouped || recounted { - chart_data["max_combo_count"] = full_combo.into(); - let new_bytes = jzon::stringify(chart_data); - if let Err(e) = fs::write(&path, &new_bytes) { - println!("Custom song {} chart {}: rewrite failed ({}), migration skipped", music_id, level, e); - continue; - } - changed = true; - charts_changed += 1; - println!("Custom song {} chart {}: repaired grouping={}, combo={}", music_id, level, regrouped, recounted); - asset_meta(new_bytes.as_bytes()) - } else { - asset_meta(&bytes) - }; - // The catalog md5/size must follow the served bytes or the client's - // download-and-verify loop would never accept the asset - for entry in song["levels"].members_mut() { - if entry["level"] == level { - changed |= entry["full_combo"] != full_combo - || entry["md5"] != md5 || entry["size"] != size; - entry["full_combo"] = full_combo.into(); - entry["md5"] = md5.clone().into(); - entry["size"] = size.into(); - } - } - } - - // Recompute after chart repair, using the hardest difficulty as upload/edit do. - 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); - } - } - - if changed { - database::update_song(music_id, &song); - songs_changed += 1; - } - } - - if songs_changed > 0 { - database::bump_revision(); - println!("Custom song migration: rewrote {} chart(s) in {} song(s), catalog revision bumped", charts_changed, songs_changed); - } -}