From e6e047782514daf798dba58fab3d2c12e293279e Mon Sep 17 00:00:00 2001 From: Ethan O'Brien Date: Sat, 6 Jun 2026 15:09:39 -0500 Subject: [PATCH] Add support for mods (Probably) --- build.rs | 2 +- src/main.rs | 1 + src/options.rs | 5 ++- src/router/asset_lists.rs | 80 +++++++++++++++++++++++++++++++++++---- src/runtime.rs | 37 ++++++++++++++++++ 5 files changed, 115 insertions(+), 10 deletions(-) diff --git a/build.rs b/build.rs index 93d4fb2..cc690ec 100644 --- a/build.rs +++ b/build.rs @@ -11,6 +11,6 @@ fn main() { } if !std::fs::exists("webui/index.html").unwrap_or(false) { - panic!("Could not compile crate! Missing webui! Did you pull submodules?"); + panic!("Missing webui! Did you pull submodules?\nRun `git submodule update --init --recursive`"); } } diff --git a/src/main.rs b/src/main.rs index cf3fd56..da47952 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ fn main() -> std::io::Result<()> { let args = ew::get_args(); ew::runtime::update_data_path(&args.path); ew::runtime::update_masterdata_path(&args.masterdata); + ew::runtime::update_mod_paths(&args.mods); ew::run_server(false) } diff --git a/src/options.rs b/src/options.rs index 2789cf9..481e459 100644 --- a/src/options.rs +++ b/src/options.rs @@ -63,7 +63,10 @@ pub struct Args { pub image_asset_path: String, #[arg(long, default_value = "", help = "Optional directory to load asset lists and master data CSVs from at runtime. Layout mirrors the bundled assets (asset_lists/, csv/, csv-en/). Missing files fall back to the internal copies.")] - pub masterdata: String + pub masterdata: String, + + #[arg(long = "mod", value_name = "DIR", action = clap::ArgAction::Append, help = "Path to a mod directory layered on top of --masterdata + the bundled defaults. May be passed multiple times. Each mod dir mirrors the masterdata layout (asset_lists/, csv/, csv-en/, userdata/) but only needs to include the files it adds rows to. CSV rows merge by primary key (first column), asset_lists entries merge by m_identifier, new_user.json top-level arrays union. Later --mod wins on collisions.")] + pub mods: Vec } pub fn get_args() -> Args { diff --git a/src/router/asset_lists.rs b/src/router/asset_lists.rs index 6d763e0..501a68c 100644 --- a/src/router/asset_lists.rs +++ b/src/router/asset_lists.rs @@ -1,8 +1,15 @@ use actix_web::{HttpRequest, web, Responder, HttpResponse}; use actix_web::http::header::ContentType; use jzon::object; +use lazy_static::lazy_static; +use std::collections::HashMap; +use std::sync::Mutex; use crate::include_file; +lazy_static! { + static ref MERGED_CACHE: Mutex> = Mutex::new(HashMap::new()); +} + pub fn routes(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/assetLists") @@ -25,18 +32,75 @@ async fn get(_req: HttpRequest) -> impl Responder { } fn load_list(name: &str) -> String { + if let Some(cached) = MERGED_CACHE.lock().unwrap().get(name) { + return cached.clone(); + } let rel = format!("asset_lists/{}.json", name); - if let Some(bytes) = crate::runtime::read_masterdata_file(&rel) { - if let Ok(s) = String::from_utf8(bytes) { - return s; + let base = crate::runtime::read_masterdata_file(&rel) + .and_then(|b| String::from_utf8(b).ok()) + .unwrap_or_else(|| match name { + "Bundle" => include_file!("src/router/asset_lists/Bundle.json"), + "Movie" => include_file!("src/router/asset_lists/Movie.json"), + "Sound" => include_file!("src/router/asset_lists/Sound.json"), + _ => unreachable!(), + }); + + let mod_files = crate::runtime::read_mod_files(&rel); + let merged = if mod_files.is_empty() { + base + } else { + merge_asset_list(name, base, mod_files) + }; + MERGED_CACHE.lock().unwrap().insert(name.to_string(), merged.clone()); + merged +} + +fn merge_asset_list(name: &str, base: String, mod_files: Vec<(String, Vec)>) -> String { + let mut root = match jzon::parse(&base) { + Ok(v) => v, + Err(_) => return base, + }; + + let mut by_ident: HashMap = HashMap::new(); + if let jzon::JsonValue::Array(ref arr) = root["m_manifestCollection"] { + for (i, e) in arr.iter().enumerate() { + let id = e["m_identifier"].to_string(); + by_ident.insert(id.to_string(), i); } } - match name { - "Bundle" => include_file!("src/router/asset_lists/Bundle.json"), - "Movie" => include_file!("src/router/asset_lists/Movie.json"), - "Sound" => include_file!("src/router/asset_lists/Sound.json"), - _ => unreachable!(), + + for (mod_dir, bytes) in mod_files { + let Ok(s) = String::from_utf8(bytes) else { continue }; + let Ok(mod_root) = jzon::parse(&s) else { continue }; + let mod_entries = match mod_root["m_manifestCollection"] { + jzon::JsonValue::Array(ref arr) => arr.clone(), + _ => continue, + }; + let mut added = 0usize; + let mut replaced = 0usize; + for entry in mod_entries { + let ident = entry["m_identifier"].as_str().unwrap_or("").to_string(); + if ident.is_empty() { + continue; + } + if let Some(&idx) = by_ident.get(&ident) { + root["m_manifestCollection"][idx] = entry; + replaced += 1; + } else { + let idx = root["m_manifestCollection"].len(); + let _ = root["m_manifestCollection"].push(entry); + by_ident.insert(ident, idx); + added += 1; + } + } + if added > 0 || replaced > 0 { + println!( + "[mod {}] {}.json: +{} new entries, {} replaced", + mod_dir, name, added, replaced + ); + } } + jzon::stringify(root) } async fn supported() -> impl Responder { diff --git a/src/runtime.rs b/src/runtime.rs index 4460658..7495b39 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -9,6 +9,7 @@ lazy_static! { static ref DATAPATH: RwLock = RwLock::new(String::new()); static ref MASTERDATA_PATH: RwLock = RwLock::new(String::new()); static ref MASTERDATA_WARNED: Mutex> = Mutex::new(HashSet::new()); + static ref MOD_PATHS: RwLock> = RwLock::new(Vec::new()); static ref EASTER: RwLock = RwLock::new(false); } @@ -79,3 +80,39 @@ pub fn set_easter_mode(enabled: bool) { pub fn get_easter_mode() -> bool { *EASTER.read().unwrap() } + +pub fn update_mod_paths(paths: &[String]) { + let cleaned: Vec = paths.iter() + .map(|p| p.trim_end_matches('/').to_string()) + .filter(|p| !p.is_empty()) + .filter_map(|p| { + if Path::new(&p).is_dir() { + Some(p) + } else { + println!("Couldn't find mod directory {} — skipping", p); + None + } + }) + .collect(); + if !cleaned.is_empty() { + println!("Loaded {} mod overlay{}", cleaned.len(), + if cleaned.len() == 1 { "" } else { "s" }); + for p in &cleaned { + println!(" mod: {}", p); + } + } + let mut w = MOD_PATHS.write().unwrap(); + *w = cleaned; +} + +pub fn read_mod_files(rel_path: &str) -> Vec<(String, Vec)> { + let paths = MOD_PATHS.read().unwrap().clone(); + let mut out = Vec::new(); + for p in paths { + let full = format!("{}/{}", p, rel_path); + if let Ok(bytes) = fs::read(&full) { + out.push((p, bytes)); + } + } + out +}