mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-10-11 17:14:30 +08:00
Remove chart migrate
This commit is contained in:
@@ -45,12 +45,8 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
|
|||||||
println!("Purged {} accounts", ct);
|
println!("Purged {} accounts", ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
router::custom_song::migrate::run();
|
|
||||||
router::custom_song::sweep_audio();
|
router::custom_song::sweep_audio();
|
||||||
router::custom_3dmv::sweep_blobs();
|
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();
|
router::multi_live::start_sweeper();
|
||||||
|
|
||||||
let rv = HttpServer::new(|| App::new()
|
let rv = HttpServer::new(|| App::new()
|
||||||
|
|||||||
+2
-231
@@ -2,8 +2,6 @@
|
|||||||
// in-process symphonia + vorbis machinery
|
// in-process symphonia + vorbis machinery
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
mod chart;
|
mod chart;
|
||||||
// Idempotent startup repairs for stored custom charts and catalog metadata.
|
|
||||||
pub mod migrate;
|
|
||||||
mod package;
|
mod package;
|
||||||
|
|
||||||
use jzon::{array, object, JsonValue};
|
use jzon::{array, object, JsonValue};
|
||||||
@@ -1469,167 +1467,11 @@ mod tests {
|
|||||||
fn field(fields: &mut HashMap<String, Vec<u8>>, key: &str, value: &str) {
|
fn field(fields: &mut HashMap<String, Vec<u8>>, key: &str, value: &str) {
|
||||||
fields.insert(String::from(key), value.as_bytes().to_vec());
|
fields.insert(String::from(key), value.as_bytes().to_vec());
|
||||||
}
|
}
|
||||||
|
// The live PLAY cue must never be a loop cue: the client reports a looping playback as
|
||||||
// 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<u8> {
|
|
||||||
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
|
// 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,
|
// 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]
|
#[test]
|
||||||
fn play_cue_never_loops() {
|
fn play_cue_never_loops() {
|
||||||
let _lock = crate::runtime::lock_test_data_path();
|
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"]["play"]["loop_end_sec"], 0.0);
|
||||||
assert_eq!(song["sound"]["select"]["is_loop"], true);
|
assert_eq!(song["sound"]["select"]["is_loop"], true);
|
||||||
assert!(song["sound"]["select"]["loop_end_sec"].as_f64().unwrap() > 0.0);
|
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
|
// 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();
|
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
|
// 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
|
// must be identical apart from the assigned music_id - INCLUDING the audio
|
||||||
|
|||||||
@@ -388,112 +388,6 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
|||||||
Ok((chart, combo))
|
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<usize> = (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<usize> = 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<usize> = (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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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
|
// Parallel holds and a nine-lane wall must retain valid spawn groups.
|
||||||
// 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.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn regroup_restores_pre_fix_wall_and_parallel_holds() {
|
fn transcode_wall_and_parallel_holds() {
|
||||||
let beatmap = jzon::array![
|
let beatmap = jzon::array![
|
||||||
sif_note(1.0, 2, 3, 1.0), sif_note(1.0, 4, 3, 1.0),
|
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(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(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)
|
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 (chart, _) = 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");
|
|
||||||
|
|
||||||
// Spell the wall out: adjacent-lane pairs, each later chunk force-synced to the
|
// 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)
|
// 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]
|
#[test]
|
||||||
fn rejects_bad_charts() {
|
fn rejects_bad_charts() {
|
||||||
|
|||||||
@@ -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<i64> = entries.flatten()
|
|
||||||
.filter_map(|entry| entry.file_name().to_string_lossy().parse::<i64>().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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user