mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-08-26 06:52:17 +08:00
Clamp custom skill values to official ranges, migrate pre-fix chart groupings
This commit is contained in:
@@ -35,6 +35,11 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
|
||||
println!("Purged {} accounts", ct);
|
||||
}
|
||||
|
||||
// One-time, idempotent: charts stored before the custom-song spawn-group pairing rule are
|
||||
// regrouped in place and their catalog md5s refreshed, so clients re-download the fixed
|
||||
// encoding. No-op when custom songs are disabled or nothing needs rewriting.
|
||||
router::custom_song::migrate::run();
|
||||
|
||||
let rv = HttpServer::new(|| App::new()
|
||||
//.wrap(Cors::permissive())
|
||||
.wrap_fn(|req, srv| {
|
||||
|
||||
@@ -87,9 +87,12 @@ const CARD_TYPE_MAX: i64 = 4;
|
||||
const CARD_RARITY_MIN: i64 = 1;
|
||||
const CARD_RARITY_MAX: i64 = 3;
|
||||
const RARITY_NAMES: &[&str] = &["R", "SR", "UR"];
|
||||
// Sanity ceilings for the level-indexed skill arrays
|
||||
const SKILL_PROBABILITY_MAX: i64 = 1_000_000;
|
||||
const SKILL_MILLI_SECS_MAX: i64 = 600_000;
|
||||
// Indexed by effect_type (0 unused)
|
||||
const EFFECT_NAMES: &[&str] = &[
|
||||
"none", "smile p up", "pure p up", "cool p up", "score up",
|
||||
"perfect window", "stamina recovery", "skill chance up",
|
||||
"skill level boost", "param sync", "combo fever", "skill repeat"
|
||||
];
|
||||
|
||||
struct ArtKind {
|
||||
kind: &'static str,
|
||||
@@ -144,6 +147,36 @@ const MAX_VOICE_SECONDS: f64 = 30.0;
|
||||
|
||||
type Fields = HashMap<String, Vec<u8>>;
|
||||
|
||||
// new_skill's level-array columns come out of the csv layer as either
|
||||
// [number] (a single plain value) or ["a/b/c"] (the slash-packed string) -
|
||||
// flatten both into the numeric values
|
||||
fn shipped_values(cell: &JsonValue) -> Vec<i64> {
|
||||
let mut rv = Vec::new();
|
||||
let mut sources: Vec<String> = Vec::new();
|
||||
for member in cell.members() {
|
||||
if let Some(value) = member.as_i64() {
|
||||
rv.push(value);
|
||||
} else if let Some(s) = member.as_str() {
|
||||
sources.push(s.to_string());
|
||||
}
|
||||
}
|
||||
if !cell.is_array() {
|
||||
if let Some(value) = cell.as_i64() {
|
||||
rv.push(value);
|
||||
} else if let Some(s) = cell.as_str() {
|
||||
sources.push(s.to_string());
|
||||
}
|
||||
}
|
||||
for source in sources {
|
||||
for part in source.split('/') {
|
||||
if let Ok(value) = part.trim().parse::<i64>() {
|
||||
rv.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
rv
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
// Id allocation and the insert must not race between two uploads
|
||||
static ref UPLOAD_LOCK: Mutex<()> = Mutex::new(());
|
||||
@@ -180,6 +213,70 @@ lazy_static! {
|
||||
rv
|
||||
};
|
||||
|
||||
// Skill MAGNITUDE envelopes derived from the shipped new_skill.csv, the
|
||||
// same way STAT_CAPS derives from card.csv. Enums are bounded elsewhere;
|
||||
// these bound the numbers (a 40-second buff and a 1e8 score-up both got
|
||||
// uploaded before this existed). Official rows (id < 100M) define the
|
||||
// range; effect types no official row uses (1-3, 7-11 - the SIF1-port
|
||||
// additions) fall back to the imported band's engineered range, flagged
|
||||
// so the error message says which. (min, max, "official"/"imported")
|
||||
static ref SKILL_VALUE_RANGES: HashMap<i64, (i64, i64, &'static str)> = {
|
||||
let mut official: HashMap<i64, (i64, i64)> = HashMap::new();
|
||||
let mut imported: HashMap<i64, (i64, i64)> = HashMap::new();
|
||||
for row in table(Region::Jp, "new_skill").members() {
|
||||
let Some(id) = row["id"].as_i64() else { continue; };
|
||||
let Some(effect) = row["effectType"].as_i64() else { continue; };
|
||||
let into = if id < 100_000_000 { &mut official } else { &mut imported };
|
||||
for value in shipped_values(&row["effectiveValues"]) {
|
||||
let entry = into.entry(effect).or_insert((value, value));
|
||||
entry.0 = entry.0.min(value);
|
||||
entry.1 = entry.1.max(value);
|
||||
}
|
||||
}
|
||||
let mut rv = HashMap::new();
|
||||
for (effect, (min, max)) in imported {
|
||||
rv.insert(effect, (min, max, "imported"));
|
||||
}
|
||||
for (effect, (min, max)) in official {
|
||||
rv.insert(effect, (min, max, "official"));
|
||||
}
|
||||
rv
|
||||
};
|
||||
|
||||
// trigger -> (min, max) trigger_value over the official rows (trigger 4 =
|
||||
// SECOND counts in milliseconds, hence its larger numbers)
|
||||
static ref SKILL_TRIGGER_RANGES: HashMap<i64, (i64, i64)> = {
|
||||
let mut rv: HashMap<i64, (i64, i64)> = HashMap::new();
|
||||
for row in table(Region::Jp, "new_skill").members() {
|
||||
if row["id"].as_i64().unwrap_or(i64::MAX) >= 100_000_000 { continue; }
|
||||
let Some(trigger) = row["trigger"].as_i64() else { continue; };
|
||||
for value in shipped_values(&row["triggerValue"]) {
|
||||
let entry = rv.entry(trigger).or_insert((value, value));
|
||||
entry.0 = entry.0.min(value);
|
||||
entry.1 = entry.1.max(value);
|
||||
}
|
||||
}
|
||||
rv
|
||||
};
|
||||
|
||||
// (probability, effective_milli_secs) global (min, max) over official rows
|
||||
static ref SKILL_SCALAR_RANGES: ((i64, i64), (i64, i64)) = {
|
||||
let mut prob = (i64::MAX, 0);
|
||||
let mut ms = (i64::MAX, 0);
|
||||
for row in table(Region::Jp, "new_skill").members() {
|
||||
if row["id"].as_i64().unwrap_or(i64::MAX) >= 100_000_000 { continue; }
|
||||
for value in shipped_values(&row["probability"]) {
|
||||
prob.0 = prob.0.min(value);
|
||||
prob.1 = prob.1.max(value);
|
||||
}
|
||||
for value in shipped_values(&row["effectiveMilliSecs"]) {
|
||||
ms.0 = ms.0.min(value);
|
||||
ms.1 = ms.1.max(value);
|
||||
}
|
||||
}
|
||||
(prob, ms)
|
||||
};
|
||||
|
||||
// rarity -> (hp, smile, cool, pure) ceilings: the maximum any official
|
||||
// card of that rarity reaches. do_reinforce trusts the stored card
|
||||
// completely, so an uploaded stat is permanent - cap it at upload
|
||||
@@ -367,6 +464,24 @@ pub fn upload_limits() -> JsonValue {
|
||||
"name_en": row["nameEn"].clone()
|
||||
}).unwrap();
|
||||
}
|
||||
// The skill magnitude envelopes, so the form's number inputs carry real
|
||||
// min/max and reject out-of-range (and e-notation) before submitting
|
||||
let mut effect_value_ranges = object!{};
|
||||
for (effect, (min, max, source)) in SKILL_VALUE_RANGES.iter() {
|
||||
effect_value_ranges[effect.to_string()] = object!{
|
||||
"min": *min,
|
||||
"max": *max,
|
||||
"source": *source
|
||||
};
|
||||
}
|
||||
let mut trigger_value_ranges = object!{};
|
||||
for (trigger, (min, max)) in SKILL_TRIGGER_RANGES.iter() {
|
||||
trigger_value_ranges[trigger.to_string()] = object!{
|
||||
"min": *min,
|
||||
"max": *max
|
||||
};
|
||||
}
|
||||
let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES;
|
||||
object!{
|
||||
"stat_caps": stat_caps,
|
||||
"skill_levels": skill_levels,
|
||||
@@ -377,8 +492,12 @@ pub fn upload_limits() -> JsonValue {
|
||||
"effect_type_max": SKILL_EFFECT_TYPE_MAX,
|
||||
"sub_target_max": SKILL_SUB_TARGET_MAX,
|
||||
"school_grade_max": SKILL_SCHOOL_GRADE_MAX,
|
||||
"probability_max": SKILL_PROBABILITY_MAX,
|
||||
"milli_secs_max": SKILL_MILLI_SECS_MAX,
|
||||
"skill_ranges": {
|
||||
"effect_values": effect_value_ranges,
|
||||
"trigger_values": trigger_value_ranges,
|
||||
"probability": { "min": prob_min, "max": prob_max },
|
||||
"milli_secs": { "min": ms_min, "max": ms_max }
|
||||
},
|
||||
"min_source_dim": art::MIN_SOURCE_DIM
|
||||
}
|
||||
}
|
||||
@@ -903,22 +1022,32 @@ fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, st
|
||||
}
|
||||
|
||||
// The client walks these in lockstep with the skill level, so each must
|
||||
// carry exactly one value per level of the rarity's curve. Shipped
|
||||
// carry exactly one value per level of the rarity's curve (shipped
|
||||
// masterdata also allows a single constant duration, so
|
||||
// effective_milli_secs may be 1 long
|
||||
// effective_milli_secs may be 1 long). MAGNITUDES are clamped to the
|
||||
// ranges the shipped skill rows actually use - a 40-second buff and a
|
||||
// 1e8 score-up both made it through before these bounds existed
|
||||
let (trigger_min, trigger_max) = *SKILL_TRIGGER_RANGES.get(&trigger).unwrap_or(&(1, 1));
|
||||
let (value_min, value_max, value_source) = *SKILL_VALUE_RANGES.get(&effect_type).unwrap_or(&(1, 1, "official"));
|
||||
let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES;
|
||||
let bounds = [
|
||||
("skill_trigger_value", "trigger_value", trigger_min, trigger_max,
|
||||
format!("for trigger {} (the official range)", trigger)),
|
||||
("skill_probability", "probability", prob_min, prob_max,
|
||||
format!("({}%-{}%, the official range)", prob_min / 1000, prob_max / 1000)),
|
||||
("skill_effective_milli_secs", "effective_milli_secs", ms_min, ms_max,
|
||||
String::from("milliseconds (the official range)")),
|
||||
("skill_effective_values", "effective_values", value_min, value_max,
|
||||
format!("for effect_type {} ({}) (the {} range)", effect_type, EFFECT_NAMES[effect_type as usize], value_source))
|
||||
];
|
||||
let mut arrays: HashMap<&str, Vec<i64>> = HashMap::new();
|
||||
for (form, key, max) in [
|
||||
("skill_trigger_value", "trigger_value", u32::MAX as i64),
|
||||
("skill_probability", "probability", SKILL_PROBABILITY_MAX),
|
||||
("skill_effective_milli_secs", "effective_milli_secs", SKILL_MILLI_SECS_MAX),
|
||||
("skill_effective_values", "effective_values", u32::MAX as i64)
|
||||
] {
|
||||
for (form, key, min, max, label) in bounds {
|
||||
let values = levels_of(fields, form, &stored_skill, key)?;
|
||||
if values.len() != levels && !(key == "effective_milli_secs" && values.len() == 1) {
|
||||
return Err(format!("{} needs exactly {} values for a rarity {} card, got {}", form, levels, rarity, values.len()));
|
||||
}
|
||||
if let Some(value) = values.iter().find(|v| **v > max) {
|
||||
return Err(format!("{}: '{}' exceeds the maximum of {}", form, value, max));
|
||||
if let Some(value) = values.iter().find(|v| **v < min || **v > max) {
|
||||
return Err(format!("{}: '{}' is out of range - values must be {}-{} {}", form, value, min, max, label));
|
||||
}
|
||||
arrays.insert(key, values);
|
||||
}
|
||||
@@ -1624,6 +1753,31 @@ pub mod tests {
|
||||
wipe(4010);
|
||||
}
|
||||
|
||||
// Diagnostic + sanity guard for the derived skill magnitude envelopes
|
||||
#[test]
|
||||
fn skill_ranges_derive_from_shipped_rows() {
|
||||
println!("effect_values:");
|
||||
for effect in 1..=11i64 {
|
||||
let (min, max, source) = SKILL_VALUE_RANGES.get(&effect).copied().unwrap_or((0, 0, "MISSING"));
|
||||
println!(" {} ({}): {}..{} [{}]", effect, EFFECT_NAMES[effect as usize], min, max, source);
|
||||
assert!(min >= 1 && max >= min, "effect {} has no sane range", effect);
|
||||
}
|
||||
println!("trigger_values:");
|
||||
for trigger in 1..=4i64 {
|
||||
let (min, max) = SKILL_TRIGGER_RANGES.get(&trigger).copied().unwrap_or((0, 0));
|
||||
println!(" {}: {}..{}", trigger, min, max);
|
||||
assert!(min >= 1 && max >= min, "trigger {} has no sane range", trigger);
|
||||
}
|
||||
let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES;
|
||||
println!("probability: {}..{}", prob_min, prob_max);
|
||||
println!("milli_secs: {}..{}", ms_min, ms_max);
|
||||
// The two incidents must be outside whatever derives
|
||||
assert!(prob_min >= 1000 && prob_max <= 100_000);
|
||||
assert!(ms_min >= 500 && ms_max < 40_000, "40s buffs must be out of range");
|
||||
let (_, value_max, _) = SKILL_VALUE_RANGES[&4];
|
||||
assert!(value_max < 100_000_000, "1e8 score-ups must be out of range");
|
||||
}
|
||||
|
||||
// A full create: derived ids, pinned columns, art md5s and the catalog
|
||||
// shape the client parses
|
||||
#[test]
|
||||
@@ -1806,10 +1960,35 @@ pub mod tests {
|
||||
assert!(run(&mutated("skill_effective_values", "1/2/x")).unwrap_err().contains("not a number"));
|
||||
assert!(run(&mutated("skill_trigger_value", "")).unwrap_err().contains("skill_trigger_value"));
|
||||
assert!(run(&mutated("skill_effective_milli_secs", "2000")).is_ok());
|
||||
assert!(run(&mutated("skill_probability", "2000000/1/1")).unwrap_err().contains("maximum"));
|
||||
// Rarity 2 wants 5 entries, so the rarity-1 arrays no longer fit
|
||||
assert!(run(&mutated("rarity", "2")).unwrap_err().contains("needs exactly 5"));
|
||||
|
||||
// Skill MAGNITUDES clamp to the shipped ranges - both real incidents:
|
||||
// the 40-second buff and the 1e8 score-up
|
||||
let ms_err = run(&mutated("skill_effective_milli_secs", "40000/40000/40000")).unwrap_err();
|
||||
assert!(ms_err.contains("skill_effective_milli_secs") && ms_err.contains("2000-6000"), "{}", ms_err);
|
||||
let value_err = run(&mutated("skill_effective_values", "100000000/100000000/100000000")).unwrap_err();
|
||||
assert!(value_err.contains("91-439") && value_err.contains("score up") && value_err.contains("official"), "{}", value_err);
|
||||
// Literal e-notation never even parses as an integer
|
||||
assert!(run(&mutated("skill_effective_values", "1e8/1e8/1e8")).unwrap_err().contains("not a number"));
|
||||
// Boundary accept/reject for effect 4 (score up), official 91-439
|
||||
assert!(run(&mutated("skill_effective_values", "91/200/439")).is_ok());
|
||||
assert!(run(&mutated("skill_effective_values", "91/200/440")).unwrap_err().contains("out of range"));
|
||||
assert!(run(&mutated("skill_effective_values", "90/200/439")).unwrap_err().contains("out of range"));
|
||||
// A SIF1-port effect type bounds to the imported band's range
|
||||
let mut fields = base_fields();
|
||||
field(&mut fields, "skill_effect_type", "5");
|
||||
field(&mut fields, "skill_effective_values", "1/2/2");
|
||||
assert!(run(&fields).is_ok());
|
||||
field(&mut fields, "skill_effect_type", "7");
|
||||
let err = run(&fields).unwrap_err();
|
||||
assert!(err.contains("1090-2600") && err.contains("imported"), "{}", err);
|
||||
// Trigger values bound per trigger (trigger 3 = PERFECTs, 13-30) and
|
||||
// probability to the official 16%-75%
|
||||
assert!(run(&mutated("skill_trigger_value", "13/20/31")).unwrap_err().contains("13-30"));
|
||||
assert!(run(&mutated("skill_probability", "80000/39000/39000")).unwrap_err().contains("16000-75000"));
|
||||
assert!(run(&mutated("skill_probability", "15999/39000/39000")).unwrap_err().contains("out of range"));
|
||||
|
||||
// Stat bounds, computed from the official card.csv: hp really is a
|
||||
// tiny per-rarity constant (R 2 / SR 3 / UR 4), so an R card allows
|
||||
// 1-2 - and the message says so instead of hiding the limit
|
||||
@@ -1871,8 +2050,8 @@ pub mod tests {
|
||||
fields.insert(String::from("pr"), seeded_png(32, 32, 9));
|
||||
assert!(runc(&fields).unwrap_err().contains("at least"));
|
||||
|
||||
// Only the two deliberate successes above wrote rows
|
||||
assert_eq!(database::card_count_for_owner(4004), 2);
|
||||
// Only the deliberate successes above wrote rows
|
||||
assert_eq!(database::card_count_for_owner(4004), 4);
|
||||
wipe(4004);
|
||||
}
|
||||
|
||||
@@ -1930,10 +2109,12 @@ pub mod tests {
|
||||
let before = database::get_card(id).unwrap();
|
||||
|
||||
// The sc_00 override is deliberately the wrong size: an update crops
|
||||
// it to target just like create does
|
||||
// it to target just like create does. The effect change brings values
|
||||
// in that effect's shipped range along (the old ones are out of it)
|
||||
let mut edit = Fields::new();
|
||||
field(&mut edit, "name", "Renamed");
|
||||
field(&mut edit, "skill_effect_type", "7");
|
||||
field(&mut edit, "skill_effective_values", "1090/2000/2600");
|
||||
edit.insert(String::from("sc_00"), seeded_png(700, 900, 99));
|
||||
with_permissions(4007, &[permissions::CARD_UPLOAD], || update_card(4007, id, &edit).unwrap());
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// in-process symphonia + vorbis machinery
|
||||
pub mod audio;
|
||||
mod chart;
|
||||
// One-time startup regroup of charts stored before the spawn-group pairing rule; called from
|
||||
// run_server, no-op when the feature is disabled or every chart is already correctly grouped
|
||||
pub mod migrate;
|
||||
mod package;
|
||||
|
||||
use jzon::{array, object, JsonValue};
|
||||
@@ -1015,6 +1018,102 @@ mod tests {
|
||||
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)
|
||||
fn wall_chart() -> Vec<u8> {
|
||||
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
|
||||
}).unwrap();
|
||||
}
|
||||
for position in 1..=9 {
|
||||
beatmap.push(jzon::object!{
|
||||
"timing_sec": 2.75, "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);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -262,6 +262,113 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
||||
}, max_combo_count))
|
||||
}
|
||||
|
||||
// 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() && 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -657,6 +764,94 @@ 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.
|
||||
#[test]
|
||||
fn regroup_restores_pre_fix_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 (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");
|
||||
|
||||
// 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 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() {
|
||||
assert!(transcode(&jzon::array![sif_note(1.0, 0, 1, 2.0)]).is_err());
|
||||
|
||||
80
src/router/custom_song/migrate.rs
Normal file
80
src/router/custom_song/migrate.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
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.
|
||||
//
|
||||
// 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;
|
||||
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;
|
||||
};
|
||||
if !chart::regroup(&mut chart_data) {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
// The catalog md5/size must follow the served bytes or the client's
|
||||
// download-and-verify loop would never accept the asset
|
||||
let (md5, size) = asset_meta(new_bytes.as_bytes());
|
||||
for entry in song["levels"].members_mut() {
|
||||
if entry["level"] == level {
|
||||
entry["md5"] = md5.clone().into();
|
||||
entry["size"] = size.into();
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
charts_changed += 1;
|
||||
println!("Custom song {}: regrouped chart level {} (pre-pairing spawn groups)", music_id, level);
|
||||
}
|
||||
|
||||
if changed {
|
||||
database::update_song(music_id, &song);
|
||||
songs_changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if songs_changed > 0 {
|
||||
database::bump_revision();
|
||||
println!("Custom song spawn-group migration: rewrote {} chart(s) in {} song(s), catalog revision bumped", charts_changed, songs_changed);
|
||||
}
|
||||
}
|
||||
2
webui
2
webui
Submodule webui updated: 28b1c65fc6...124065a313
Reference in New Issue
Block a user