12 Commits
Author SHA1 Message Date
Ethan O'Brien 78362608c8 Fix inability to set some options 2026-09-27 14:26:59 -05:00
Ethan O'Brien 8c58ff5b01 Bump version to 2.0.0 2026-09-27 14:06:23 -05:00
Ethan O'Brien 536e66811f Expose more settings 2026-09-27 14:05:08 -05:00
Ethan O'Brien 5a55bbf32a New assets 2026-09-27 12:52:05 -05:00
Ethan O'Brien 02afd15890 reorganize 2026-09-27 12:51:11 -05:00
Ethan O'Brien ee4ce5fed4 Remove chart migrate 2026-09-27 12:48:47 -05:00
Ethan O'Brien 9897a5d04a Cleanup compile warnings 2026-09-27 12:42:46 -05:00
Ethan O'Brien b1b70ab4c1 Don't send JP story IDs to the global client 2026-09-27 12:40:56 -05:00
Ethan O'Brien 99ba099d1b Add ability to force updates for outdated asset versions 2026-09-27 11:39:31 -05:00
Ethan O'Brien f1c8e96fbe Fix note count calculation 2026-09-26 22:19:16 -05:00
Ethan O'Brien abbfb6027a assets 2026-09-26 17:16:12 -05:00
Ethan O'Brien b38aec4ecf new assets 2026-09-26 13:49:19 -05:00
14 changed files with 367 additions and 499 deletions
Generated
+1 -1
View File
@@ -1085,7 +1085,7 @@ dependencies = [
[[package]]
name = "ew"
version = "1.1.0"
version = "2.0.0"
dependencies = [
"actix-multipart",
"actix-web",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ew"
version = "1.1.0"
version = "2.0.0"
edition = "2024"
exclude = ["assets", "*.db"]
+1
View File
@@ -32,6 +32,7 @@ services:
#EN_ANDROID_ASSET_HASH: ""
#EN_IOS_ASSET_HASH: ""
#WINDOWS_ASSET_HASH: "" # Windows asset hash (JP only; GL Windows is unused)
#FORCE_UPDATES: "true" # Force outdated clients to update
# Everything below is for the "Help" page
#ANDROID_GLOBAL: "link.to/patched/android/global.apk"
+1
View File
@@ -18,6 +18,7 @@ args=(
[ "${NERF_CUSTOM_CARDS:-}" = "true" ] && args+=(--nerf-custom-cards)
[ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv)
[ "${ENABLE_ARCADE:-}" = "true" ] && args+=(--enable-arcade)
[ "${FORCE_UPDATES:-}" = "true" ] && args+=(--force-updates)
add_opt() {
local value="$1" flag="$2"
+1 -9
View File
@@ -35,22 +35,14 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
if args.purge {
println!("Purging accounts...");
// Cabinets first, so the accounts they take with them are gone before
// purge_accounts runs its VACUUM over the same database
let machines = crate::router::arcade::purge_machines();
if machines > 0 {
println!("Purged {} arcade machine(s)", machines);
}
println!("Purged {} arcade machines", machines);
let ct = crate::router::userdata::purge_accounts();
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()
+9
View File
@@ -89,6 +89,12 @@ pub struct Args {
#[arg(long, default_value = "", help = "Asset version for overridden asset hashes.")]
pub asset_version: String,
#[arg(long, default_value = "", help = "Global asset version for overridden asset hashes.")]
pub en_asset_version: String,
#[arg(long, default_value_t = false, help = "Force an application update for asset versions not marked as latest")]
pub force_updates: bool,
#[arg(long, default_value = "", help = "Asset hash for windows client.")]
pub windows_asset_hash: String,
@@ -115,7 +121,10 @@ pub enum Commands {
}
pub fn get_args() -> Args {
#[cfg(not(test))]
let mut args = Args::parse();
#[cfg(test)]
let mut args = Args::parse_from(["ew"]);
crate::runtime::overlay_args(&mut args);
args
}
+2 -174
View File
@@ -2,9 +2,6 @@
// 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};
@@ -1470,109 +1467,11 @@ mod tests {
fn field(fields: &mut HashMap<String, Vec<u8>>, 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<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);
}
// 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();
@@ -1594,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
@@ -1759,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
+99 -173
View File
@@ -18,8 +18,8 @@ use std::collections::HashMap;
// IsSliderLongMarker chained, child SAME line, parent different -> a slide ending in a hold
// IsDistanceMarker has both a parent and a child -> a middle segment
// So a hold is a chain that stays in its lane and a slide is a chain that moves across lanes;
// both are type 1. This is also how the client counts combo (NoteData.CalcMaxCombo: a note whose
// child shares its line does not count, its tail does).
// both are type 1. Combo follows LiveStatusSystem.PushInputResult, not the editor-only
// NoteData.CalcMaxCombo helper, which undercounts repeated same-lane checkpoints.
//
// Mapping rules:
// - line = position - 1 (both are right-to-left)
@@ -65,8 +65,8 @@ use std::collections::HashMap;
// ForceGroupId against the other chunk's GroupId and links the lane-closest pair).
// - notes[0] is ALWAYS the dummy header (id 0, num 100, type 0) - the client
// deserializes it verbatim.
// - max_combo_count = all real notes EXCEPT hold heads whose tail is on the same
// line (the game counts a same-lane hold as one combo for the chain)
// - max_combo_count excludes a same-lane hold's root and a cross-lane slide settling
// into a hold. Further same-lane checkpoints do count (see max_combo_count below).
// Two notes are SIMULTANEOUS (one spawn cluster) when their times agree to within this.
// Uploaded timings are decimal literals, so notes an author meant to be simultaneous parse to
@@ -143,6 +143,42 @@ pub fn first_note_time(chart: &JsonValue) -> Option<f64> {
}))
}
// LiveStatusSystem.PushInputResult skips combo for a non-slider long root or
// IsSliderLongMarker. LiveInputResultControl maps those results to their child.
// All other points count, including repeated checkpoints in a stationary slide.
// Validate the links before repairing stored data; never guess a broken chart's count.
pub fn max_combo_count(chart: &JsonValue) -> Option<i64> {
let notes = &chart["notes"];
if !notes.is_array() || notes.len() < 2 || notes[0]["id"] != 0 {
return None;
}
let mut by_id = HashMap::new();
for note in notes.members().skip(1) {
let id = note["id"].as_i64()?;
let line = note["line"].as_i64()?;
if id <= 0 || !(0..9).contains(&line) || by_id.insert(id, note).is_some() {
return None;
}
}
let mut count = 0;
for note in notes.members().skip(1) {
let id = note["id"].as_i64()?;
let parent_id = note["parent_id"].as_i64()?;
let child_id = note["child_id"].as_i64()?;
let parent = if parent_id == 0 { None } else { Some(*by_id.get(&parent_id)?) };
let child = if child_id == 0 { None } else { Some(*by_id.get(&child_id)?) };
if parent_id == id || child_id == id
|| parent.is_some_and(|p| p["child_id"] != id)
|| child.is_some_and(|c| c["parent_id"] != id) {
return None;
}
let hold_start = child.is_some_and(|c| c["line"] == note["line"])
&& parent.is_none_or(|p| p["line"] != note["line"]);
if !hold_start { count += 1; }
}
Some(count)
}
struct WorkNote {
time: f64,
line: i64,
@@ -324,17 +360,9 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
"parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0,
"force_sync_group_id": 0
}];
let mut max_combo_count = 0;
for index in order.iter() {
let note = &work[*index];
// NoteData.CalcMaxCombo: a note whose child is on the SAME line (a hold) does not count,
// its tail does. A cross-lane child (a slide segment) counts normally.
match note.child {
Some(child) if work[child].line == note.line => {},
_ => max_combo_count += 1
}
notes.push(object!{
"id": ids[*index],
"num": nums[*index],
@@ -349,120 +377,17 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
}).unwrap();
}
Ok((object!{
let mut chart = object!{
"max_lane": 9,
"sound_name": "",
"max_combo_count": max_combo_count,
"max_combo_count": 0,
"notes": notes
}, max_combo_count))
};
let combo = max_combo_count(&chart).ok_or("Invalid transcoded note links")?;
chart["max_combo_count"] = combo.into();
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(&notes[*b].1).then(notes[*a].0.cmp(&notes[*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(&notes[*b].1).then(notes[*a].0.cmp(&notes[*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 {
@@ -832,6 +757,56 @@ mod tests {
assert_eq!(combo, 3);
}
#[test]
fn repeated_same_lane_checkpoints_count_like_the_client() {
for final_effect in [11, 14] {
let mut beatmap = jzon::array![];
for i in 0..13 {
beatmap.push(sif_slide(1.0 + i as f64 * 0.1, 5,
if i == 12 { final_effect } else { 11 }, 0.0, 500)).unwrap();
}
let (chart, combo) = transcode(&beatmap).unwrap();
// The head shares the first checkpoint's result; all twelve
// non-root points still have their own runtime judgment.
assert_eq!(combo, 12);
assert_eq!(chart["max_combo_count"], 12);
assert_eq!(chart["notes"].len(), 14);
}
}
#[test]
fn combo_count_preserves_hold_and_cross_lane_rules() {
for (positions, expected) in [
(vec![5, 5], 1),
(vec![5, 5, 5], 2),
(vec![5, 5, 6], 2),
(vec![4, 5, 5], 2),
(vec![4, 5, 5, 5], 3),
(vec![4, 5, 6], 3),
] {
let mut beatmap = jzon::array![];
for (i, position) in positions.iter().enumerate() {
beatmap.push(sif_slide(1.0 + i as f64 * 0.1, *position, 11, 0.0, 500)).unwrap();
}
let (_, combo) = transcode(&beatmap).unwrap();
assert_eq!(combo, expected, "chain {:?}", positions);
}
}
#[test]
fn combo_repair_rejects_invalid_links() {
let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, 3, 1.0)]).unwrap();
for (field, value) in [("id", 2), ("parent_id", 999), ("child_id", 999), ("child_id", 1)] {
let mut broken = chart.clone();
broken["notes"][1][field] = value.into();
assert_eq!(max_combo_count(&broken), None, "invalid {}={}", field, value);
}
let mut broken = chart;
broken["notes"][2]["parent_id"] = 0.into();
assert_eq!(max_combo_count(&broken), None);
assert_eq!(max_combo_count(&object!{}), None);
}
#[test]
fn slide_hold_needs_a_duration() {
// The effect 3 duration check has to cover note_slide_hold too
@@ -929,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),
@@ -942,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)
@@ -985,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() {
-121
View File
@@ -1,121 +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.
//
// 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);
}
// Same "hardest difficulty" rule as upload/edit: the last level entry, which both write
// in ascending level order
if let Some(hardest) = song["levels"].members().last().and_then(|l| l["full_combo"].as_i64()) {
let missions = super::mission_combo(hardest);
if song["mission_combo"] != missions {
song["mission_combo"] = missions;
changed = true;
println!("Custom song {}: combo missions rescaled to the official 20/40/60/80%", music_id);
}
}
for level in 1..=LEVEL_COUNT {
let path = song_path(music_id, &format!("chart_{}.json", level));
let Ok(bytes) = fs::read(&path) else { continue; };
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);
}
}
+42 -8
View File
@@ -55,10 +55,10 @@ static ASSET_VERSIONS: &[AssetVersion] = &[
AssetVersion { region: "JP", platform: "Android", version: "7c74b0df372a460d82378e55dc456260", hash: "f5ac6238570d8ff8351fda9c79e8774a", latest: false },
AssetVersion { region: "JP", platform: "iOS", version: "7c74b0df372a460d82378e55dc456260", hash: "b77459c89ec94acc4fddc787ded62b95", latest: false },
// Re-written client versions 2.7.0
AssetVersion { region: "JP", platform: "Windows", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "c4b96d2ea6e55bd2d2a807c1ac9a1165", latest: true },
AssetVersion { region: "JP", platform: "Android", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "7cd5fdb3a60df2ad81be19fe5126c92b", latest: true },
AssetVersion { region: "JP", platform: "iOS", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "20e62977364b1e8e91040fc024086a56", latest: true },
// Re-written client versions 2.7.0 -
AssetVersion { region: "JP", platform: "Windows", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "4cbd26690102b6b98f5886263bdfefcd", latest: true },
AssetVersion { region: "JP", platform: "Android", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "4e64250bb32af39247fe7d7cdb840982", latest: true },
AssetVersion { region: "JP", platform: "iOS", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "96615485374096183e4e0ce900671910", latest: true },
AssetVersion { region: "JP", platform: "Linux", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "", latest: true },
@@ -68,6 +68,13 @@ static ASSET_VERSIONS: &[AssetVersion] = &[
];
impl AssetVersion {
fn accepts_override(&self) -> bool {
// The retired GL clients can still use a custom version/hash, but they are not
// current releases. Keep override eligibility separate from update policy.
self.latest || (self.region == "GL"
&& self.version == "5260ff15dff8ba0c00ad91400f515f55")
}
fn stock_hash(&self) -> String {
if self.latest && self.platform == "Android" && get_easter_mode() {
if let Some((_, easter)) = EASTER_HASHES.iter().find(|(r, _)| *r == self.region) {
@@ -79,7 +86,11 @@ impl AssetVersion {
fn override_pair(&self) -> Option<(String, String)> {
let args = crate::get_args();
let ov = args.asset_version.as_str();
let ov = if self.region == "GL" {
args.en_asset_version.as_str()
} else {
args.asset_version.as_str()
};
let oh = match (self.region, self.platform) {
("JP", "Windows") => args.windows_asset_hash.as_str(),
("JP", "Linux") => args.linux_asset_hash.as_str(),
@@ -100,15 +111,18 @@ impl AssetVersion {
}
fn valid_hashes(asset_version: &str, platform: &str) -> Vec<String> {
let args = crate::get_args();
let mut out = Vec::new();
for entry in ASSET_VERSIONS {
if entry.platform != platform {
continue;
}
if entry.version == asset_version && !entry.hash.is_empty() {
if entry.latest || !args.force_updates {
out.push(entry.stock_hash());
}
if entry.latest {
}
if entry.accepts_override() {
if let Some((ov, oh)) = entry.override_pair() {
if ov == asset_version {
out.push(oh);
@@ -125,7 +139,7 @@ fn preferred_hash(asset_version: &str, platform: &str) -> Option<String> {
if entry.platform != platform {
continue;
}
if entry.latest {
if entry.accepts_override() {
if let Some((ov, oh)) = entry.override_pair() {
if ov == asset_version {
return Some(oh);
@@ -165,7 +179,7 @@ pub fn get_player_region(asset_version: &str) -> Option<String> {
if entry.version == asset_version {
return Some(entry.region.to_string());
}
if entry.latest {
if entry.accepts_override() {
if let Some((ov, _)) = entry.override_pair() {
if ov == asset_version {
return Some(entry.region.to_string());
@@ -410,6 +424,10 @@ pub fn send(mut data: JsonValue, uid: i64, req: &HttpRequest) -> HttpResponse {
//println!("{}", jzon::stringify(data.clone()));
data["server_time"] = set_time(data["server_time"].as_u64().unwrap_or(0), uid, true).into();
if data["data"].is_object() {
super::mission::filter_response(&mut data["data"], req.headers());
}
if !data["data"]["item_list"].is_empty() || !data["data"]["updated_value_list"]["item_list"].is_empty() {
items::check_for_region(&mut data, req.headers());
}
@@ -494,6 +512,22 @@ pub(crate) fn get_cards(arr: JsonValue, user: &JsonValue) -> JsonValue {
mod platform_tests {
use super::*;
#[test]
fn host_version_overrides_keep_jp_and_global_distinct() {
crate::runtime::apply_config_json(
r#"{"assetVersion":"jp-test-version","enAssetVersion":"gl-test-version","jpAndroidAssetHash":"jp-test-hash","enAndroidAssetHash":"gl-test-hash"}"#,
);
assert_eq!(get_player_region("jp-test-version").as_deref(), Some("JP"));
assert_eq!(get_player_region("gl-test-version").as_deref(), Some("GL"));
assert_eq!(preferred_hash("jp-test-version", "Android").as_deref(), Some("jp-test-hash"));
assert_eq!(preferred_hash("gl-test-version", "Android").as_deref(), Some("gl-test-hash"));
assert!(valid_hashes("gl-test-version", "Android").contains(&"gl-test-hash".to_string()));
assert!(!ASSET_VERSIONS.iter().any(|entry| entry.region == "GL" && entry.latest));
crate::runtime::apply_config_json("{}");
}
#[test]
fn standalone_player_platforms_are_known() {
assert_eq!(parse_platform("WindowsPlayer"), "Windows");
-11
View File
@@ -422,17 +422,6 @@ pub fn update_mission_status(master_mission_id: i64, expire: u64, completed: boo
None
}
pub fn update_mission_status_multi(master_mission_id: JsonValue, expire: u64, completed: bool, claimed: bool, advance: i64, missions: &mut JsonValue) -> JsonValue {
let mut rv = array![];
for mission in master_mission_id.members() {
let val = update_mission_status(mission.as_i64().unwrap(), expire, completed, claimed, advance, missions);
if let Some(val2) = val {
rv.push(val2).unwrap();
}
}
rv
}
pub fn get_mission_status(id: i64, missions: &JsonValue) -> JsonValue {
for mission in missions.members() {
if mission["master_mission_id"].as_i64().unwrap() == id {
+90
View File
@@ -3,6 +3,29 @@ use actix_web::{web, HttpRequest, Responder};
use crate::router::{global, userdata, items, databases, Login, Session, Api};
pub fn filter_response(data: &mut JsonValue, headers: &actix_web::http::header::HeaderMap) {
if items::get_region(headers) { return; }
lazy_static::lazy_static! {
static ref GLOBAL_IDS: std::collections::HashSet<i64> =
databases::csv::table(databases::csv::Region::En, "mission")
.members().filter_map(|row| row["id"].as_i64()).collect();
}
// Only project the response. Shared JP/global accounts keep their progress.
for field in ["mission_list", "clear_mission_ids"] {
if !data[field].is_array() { continue; }
for i in (0..data[field].len()).rev() {
let id = if field == "mission_list" { data[field][i]["master_mission_id"].as_i64() }
else { data[field][i].as_i64() };
if !id.is_some_and(|id| GLOBAL_IDS.contains(&id)) {
data[field].array_remove(i);
}
}
}
if data["updated_value_list"].is_object() {
filter_response(&mut data["updated_value_list"], headers);
}
}
pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/mission")
@@ -16,6 +39,73 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
mod tests {
use super::*;
#[actix_web::test]
async fn mission_endpoint_filters_global_wire_response_not_saved_account() {
let _test = crate::runtime::lock_test_data_path();
let (uid, key) = userdata::starter::create("Regional missions test").unwrap();
let mut user = userdata::get_acc(&key);
user["tutorial_step"] = 999.into();
userdata::save_acc(&key, user);
for version in ["5260ff15dff8ba0c00ad91400f515f55", "4c921d2443335e574a82e04ec9ea243c"] {
let request = actix_web::test::TestRequest::default()
.insert_header(("aoharu-user-id", uid.to_string()))
.insert_header(("aoharu-asset-version", version)).to_http_request();
let response = mission(Login(key.clone())).await.respond_to(&request);
let body = actix_web::body::to_bytes(response.into_body()).await.ok().unwrap();
let plain = crate::encryption::decrypt_packet(std::str::from_utf8(&body).unwrap()).unwrap();
let response = jzon::parse(&plain).unwrap();
assert_eq!(response["code"], 0);
let missions = &response["data"]["mission_list"];
assert!(missions.members().any(|m| m["master_mission_id"] == 1073001));
assert_eq!(missions.members().any(|m| m["master_mission_id"] == 1073037),
items::get_region(request.headers()));
assert!(userdata::get_acc_missions(&key).members()
.any(|m| m["master_mission_id"] == 1073037));
}
}
#[test]
fn global_missions_match_client_masterdata_without_losing_saved_progress() {
let request = actix_web::test::TestRequest::default()
.insert_header(("aoharu-asset-version", "5260ff15dff8ba0c00ad91400f515f55"))
.to_http_request();
assert!(!items::get_region(request.headers()));
let mut saved = array![];
super::super::story::refresh(&object! {live_list: []}, &mut saved);
let original = saved.clone();
let ids = databases::csv::table(databases::csv::Region::En, "mission");
let known: std::collections::HashSet<_> = ids.members()
.filter_map(|r| r["id"].as_i64()).collect();
let absent: Vec<_> = saved.members().filter_map(|m| {
let id = m["master_mission_id"].as_i64().unwrap();
(!known.contains(&id)).then_some(id)
}).collect();
assert_eq!(absent.len(), 30);
assert!(absent.iter().all(|id| (1073037..=1073066).contains(id)));
let expected: JsonValue = saved.members().filter(|m|
known.contains(&m["master_mission_id"].as_i64().unwrap()))
.cloned().collect::<Vec<_>>().into();
let mut response = object! {mission_list: saved.clone(),
clear_mission_ids: [1073037, 1073001, 1073066],
updated_value_list: {mission_list: saved.clone(), clear_mission_ids: [1073001, 1073037]},
reward_list: [{type: 1, amount: 100}]};
filter_response(&mut response, request.headers());
assert_eq!(response["mission_list"], expected);
assert_eq!(response["updated_value_list"]["mission_list"], expected);
assert_eq!(response["clear_mission_ids"], array![1073001]);
assert_eq!(response["updated_value_list"]["clear_mission_ids"], array![1073001]);
assert_eq!(response["reward_list"], array![object! {type: 1, amount: 100}]);
assert_eq!(saved, original);
let mut unrelated = object! {item_list: [], value: 42};
let before = unrelated.clone();
filter_response(&mut unrelated, request.headers());
assert_eq!(unrelated, before);
let mut jp = object! {mission_list: saved};
let before = jp.clone();
filter_response(&mut jp, actix_web::test::TestRequest::default().to_http_request().headers());
assert_eq!(jp, before);
}
#[actix_web::test]
async fn bond_title_claim_notifies_client_and_persists() {
let _test = crate::runtime::lock_test_data_path();
+2
View File
@@ -104,6 +104,7 @@ impl Value {
}
}
#[allow(dead_code)]
pub fn as_str(&self) -> Option<&str> {
match self {
Value::Str(v) => Some(v),
@@ -148,6 +149,7 @@ impl Map {
self.get(key).and_then(Value::as_int)
}
#[allow(dead_code)]
pub fn get_str(&self, key: &str) -> Option<&str> {
self.get(key).and_then(Value::as_str)
}
+117
View File
@@ -23,11 +23,31 @@ pub struct HostConfig {
pub port: u16,
pub jp_android_asset_hash: String,
pub en_android_asset_hash: String,
pub asset_version: String,
pub en_asset_version: String,
pub jp_ios_asset_hash: String,
pub en_ios_asset_hash: String,
pub windows_asset_hash: String,
pub enable_custom_songs: bool,
pub enable_custom_cards: bool,
pub enable_custom_3dmv: bool,
pub enable_arcade: bool,
pub nerf_custom_cards: bool,
pub owner: Vec<i64>,
pub hidden: bool,
pub force_updates: bool,
pub disable_imports: bool,
pub disable_exports: bool,
pub linux_asset_hash: String,
pub macos_asset_hash: String,
pub global_android: String,
pub japan_android: String,
pub global_ios: String,
pub japan_ios: String,
pub arcade_machine_ttl: u64,
pub arcade_session_ttl: u64,
pub image_asset_path: String,
pub masterdata: String,
}
// Lets an embedding app (or the tests) enable the opt-in custom songs feature
@@ -162,6 +182,38 @@ pub fn apply_config_json(json: &str) {
cfg.port = parsed["port"].as_u64().unwrap_or(0) as u16;
cfg.jp_android_asset_hash = s("jpAndroidAssetHash");
cfg.en_android_asset_hash = s("enAndroidAssetHash");
cfg.asset_version = s("assetVersion");
cfg.en_asset_version = s("enAssetVersion");
cfg.jp_ios_asset_hash = s("jpIosAssetHash");
cfg.en_ios_asset_hash = s("enIosAssetHash");
cfg.windows_asset_hash = s("windowsAssetHash");
cfg.linux_asset_hash = s("linuxAssetHash");
cfg.macos_asset_hash = s("macosAssetHash");
cfg.global_android = s("globalAndroid");
cfg.japan_android = s("japanAndroid");
cfg.global_ios = s("globalIos");
cfg.japan_ios = s("japanIos");
cfg.arcade_machine_ttl = parsed["arcadeMachineTtl"].as_u64().unwrap_or(90);
cfg.arcade_session_ttl = parsed["arcadeSessionTtl"].as_u64().unwrap_or(30);
cfg.image_asset_path = s("imageAssetPath");
cfg.masterdata = s("masterdata");
cfg.owner = parsed["ownerUids"].members().filter_map(|v| v.as_i64()).collect();
cfg.hidden = parsed["hidden"].as_bool().unwrap_or(false);
cfg.force_updates = parsed["forceUpdates"].as_bool().unwrap_or(false);
cfg.disable_imports = parsed["disableImports"].as_bool().unwrap_or(false);
cfg.disable_exports = parsed["disableExports"].as_bool().unwrap_or(false);
cfg.enable_custom_songs = parsed["enableCustomSongs"].as_bool().unwrap_or(false);
cfg.enable_custom_cards = parsed["enableCustomCards"].as_bool().unwrap_or(false);
cfg.nerf_custom_cards = parsed["nerfCustomCards"].as_bool().unwrap_or(false);
cfg.enable_custom_3dmv = parsed["enableCustom3dmv"].as_bool().unwrap_or(false);
cfg.enable_arcade = parsed["enableArcade"].as_bool().unwrap_or(false);
let owners = cfg.owner.clone();
let masterdata = cfg.masterdata.clone();
let nerf_custom_cards = cfg.nerf_custom_cards;
drop(cfg);
update_owners(&owners);
update_masterdata_path(&masterdata);
set_nerf_custom_cards(nerf_custom_cards);
}
pub fn overlay_args(args: &mut crate::options::Args) {
@@ -183,6 +235,34 @@ pub fn overlay_args(args: &mut crate::options::Args) {
}
overlay_str!(jp_android_asset_hash);
overlay_str!(en_android_asset_hash);
overlay_str!(asset_version);
overlay_str!(en_asset_version);
overlay_str!(jp_ios_asset_hash);
overlay_str!(en_ios_asset_hash);
overlay_str!(windows_asset_hash);
overlay_str!(linux_asset_hash);
overlay_str!(macos_asset_hash);
overlay_str!(global_android);
overlay_str!(japan_android);
overlay_str!(global_ios);
overlay_str!(japan_ios);
overlay_str!(image_asset_path);
overlay_str!(masterdata);
if cfg.arcade_machine_ttl != 0 {
args.arcade_machine_ttl = cfg.arcade_machine_ttl;
}
if cfg.arcade_session_ttl != 0 {
args.arcade_session_ttl = cfg.arcade_session_ttl;
}
// An embedding app may supply these options, but its default values must
// not erase flags passed by the standalone server (including Docker).
if !cfg.owner.is_empty() {
args.owner = cfg.owner.clone();
}
args.hidden |= cfg.hidden;
args.force_updates |= cfg.force_updates;
args.disable_imports |= cfg.disable_imports;
args.disable_exports |= cfg.disable_exports;
// Overlay only ever enables the features; a command-line --enable-custom-*
// flag is never overridden back to off
if cfg.enable_custom_songs {
@@ -226,3 +306,40 @@ pub fn lock_test_data_path() -> std::sync::MutexGuard<'static, ()> {
set_enable_arcade(true);
guard
}
#[cfg(test)]
#[test]
fn android_host_config_updates_owner_and_feature_options() {
use clap::Parser;
let _guard = lock_test_data_path();
apply_config_json(r#"{"ownerUids":[42,84],"hidden":true,"forceUpdates":true,"disableImports":true,"disableExports":true,"enableCustomSongs":true,"enableCustomCards":true,"nerfCustomCards":true,"enableCustom3dmv":true,"enableArcade":true,"linuxAssetHash":"linux-hash","macosAssetHash":"mac-hash","globalAndroid":"https://example.com/gl.apk","arcadeMachineTtl":45,"arcadeSessionTtl":15}"#);
let mut args = crate::options::Args::parse_from(["ew"]);
overlay_args(&mut args);
assert_eq!(get_owners(), vec![42, 84]);
assert_eq!(args.owner, vec![42, 84]);
assert!(args.hidden && args.force_updates && args.disable_imports && args.disable_exports);
assert!(args.enable_custom_songs && args.enable_custom_cards && args.nerf_custom_cards);
assert!(get_nerf_custom_cards());
assert!(args.enable_custom_3dmv && args.enable_arcade);
assert_eq!(args.linux_asset_hash, "linux-hash");
assert_eq!(args.macos_asset_hash, "mac-hash");
assert_eq!(args.global_android, "https://example.com/gl.apk");
assert_eq!(args.arcade_machine_ttl, 45);
assert_eq!(args.arcade_session_ttl, 15);
apply_config_json(r#"{"ownerUids":[],"enableCustomSongs":false}"#);
let mut reset = crate::options::Args::parse_from(["ew"]);
overlay_args(&mut reset);
assert!(get_owners().is_empty());
assert!(!reset.enable_custom_songs);
assert!(!reset.hidden);
assert!(!get_nerf_custom_cards());
let mut cli = crate::options::Args::parse_from([
"ew", "--force-updates", "--hidden", "--disable-imports",
"--disable-exports", "--owner", "123",
]);
overlay_args(&mut cli);
assert!(cli.force_updates && cli.hidden && cli.disable_imports && cli.disable_exports);
assert_eq!(cli.owner, vec![123]);
}