diff --git a/Cargo.lock b/Cargo.lock index 5d074218..481de76f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -608,6 +608,18 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1230,6 +1242,7 @@ dependencies = [ "ltk_mod_core", "ltk_mod_project", "ltk_modpkg", + "ltk_overlay", "miette", "regex", "reqwest", @@ -1240,6 +1253,7 @@ dependencies = [ "sysinfo", "thiserror 2.0.18", "toml", + "tracing-subscriber", "zip", ] @@ -1335,6 +1349,16 @@ dependencies = [ "strum", ] +[[package]] +name = "ltk_hash" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada44666d233f89786fdfd6784346a140ddfc7c31d31ac23dfa7b066f9fbe41b" +dependencies = [ + "byteorder", + "xxhash-rust", +] + [[package]] name = "ltk_io_ext" version = "0.4.4" @@ -1347,6 +1371,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ltk_meta" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46b10820509cda6ec4cb8799e39f26997e0c6acc52baf66351e6604b637e298" +dependencies = [ + "byteorder", + "enum_dispatch", + "glam", + "indexmap", + "log", + "ltk_hash", + "ltk_io_ext", + "ltk_primitives", + "miette", + "num_enum", + "paste", + "thiserror 1.0.69", +] + [[package]] name = "ltk_mod_core" version = "0.1.0" @@ -1408,9 +1452,11 @@ dependencies = [ "indexmap", "ltk_fantome", "ltk_file", + "ltk_meta", "ltk_mod_project", "ltk_modpkg", "ltk_rst", + "ltk_sanitize", "ltk_wad", "memmap2", "rayon", @@ -1451,6 +1497,19 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "ltk_sanitize" +version = "0.1.0" +dependencies = [ + "indexmap", + "ltk_hash", + "ltk_meta", + "ltk_wad", + "sha2", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "ltk_wad" version = "0.3.1" @@ -1746,6 +1805,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.12.2" diff --git a/Cargo.toml b/Cargo.toml index 3d664320..d7a22fbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/ltk_modpkg", "crates/ltk_mod_project", "crates/ltk_overlay", + "crates/ltk_sanitize", ] [workspace.dependencies] diff --git a/crates/league-mod/Cargo.toml b/crates/league-mod/Cargo.toml index 153d8f92..ff4927a5 100644 --- a/crates/league-mod/Cargo.toml +++ b/crates/league-mod/Cargo.toml @@ -29,6 +29,8 @@ inquire = "0.7.5" slug = "0.1.6" ltk_modpkg = { version = "0.6.0", path = "../ltk_modpkg" } ltk_fantome = { version = "0.6.1", path = "../ltk_fantome" } +ltk_overlay = { version = "0.5.2", path = "../ltk_overlay" } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } glob = "0.3.2" semver = "1.0.25" binrw = "0.14.1" diff --git a/crates/league-mod/src/commands/mod.rs b/crates/league-mod/src/commands/mod.rs index 84d6ca9b..322d01ff 100644 --- a/crates/league-mod/src/commands/mod.rs +++ b/crates/league-mod/src/commands/mod.rs @@ -3,8 +3,10 @@ mod extract; mod info; mod init; mod pack; +mod sanitize; pub use extract::*; pub use info::*; pub use init::*; pub use pack::*; +pub use sanitize::*; diff --git a/crates/league-mod/src/commands/sanitize.rs b/crates/league-mod/src/commands/sanitize.rs new file mode 100644 index 00000000..f92455fd --- /dev/null +++ b/crates/league-mod/src/commands/sanitize.rs @@ -0,0 +1,133 @@ +use std::fs::File; + +use camino::{Utf8Path, Utf8PathBuf}; +use colored::Colorize; +use ltk_modpkg::Modpkg; +use ltk_overlay::skin_integrity::check_single_mod; +use ltk_overlay::{EnabledMod, FantomeContent, FsModContent, ModContentProvider, ModpkgContent}; +use miette::{miette, IntoDiagnostic}; + +use crate::println_pad; +use crate::utils::config; + +pub struct SanitizeModArgs { + pub file_path: String, + pub game_dir: Option, +} + +/// Verify a mod's base-skin integrity against the game files, straight from +/// the packaged archive — nothing is installed or extracted to disk. +/// +/// This is the same closed-world check the in-game verifier enforces: the +/// base skin's mesh references must resolve inside the champion WAD the game +/// loads. Violations mean the mod is broken (missing assets), outdated +/// (references assets removed from the game), or mis-packaged (assets shipped +/// to the wrong WAD). +pub fn sanitize_mod(args: SanitizeModArgs) -> miette::Result<()> { + // Violations and baseline anomalies inside the overlay/sanitize stack are + // reported via `tracing`; surface warnings and errors on stderr. + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()), + ) + .with_writer(std::io::stderr) + .try_init(); + + let mod_path = Utf8PathBuf::from(&args.file_path); + let game_dir = resolve_game_dir(args.game_dir.map(Utf8PathBuf::from))?; + + let mut enabled_mod = EnabledMod { + id: mod_path.file_stem().unwrap_or("mod").to_string(), + content: open_content(&mod_path)?, + enabled_layers: None, + }; + + let index_cache = config::config_path("game_index.bin").unwrap_or_else(|| { + Utf8PathBuf::from_path_buf(std::env::temp_dir().join("league-mod-game-index.bin")) + .unwrap_or_else(|_| Utf8PathBuf::from("league-mod-game-index.bin")) + }); + + let offenders = check_single_mod(&game_dir, &index_cache, &mut enabled_mod) + .map_err(|err| miette!("{err}"))?; + + if offenders.is_empty() { + println_pad!( + "{} {}", + "✅".bright_green(), + "Base-skin integrity OK".bright_green().bold() + ); + return Ok(()); + } + + for offender in &offenders { + println_pad!( + "{} {} ({})", + "❌ Broken base skin:".bright_red().bold(), + offender.champion.bright_cyan().bold(), + offender.wad.bright_white() + ); + for violation in &offender.violations { + println_pad!(" {} {}", "-".bright_red(), violation); + } + } + Err(miette!( + "{} champion WAD(s) violate base-skin integrity — this mod would be rejected in-game", + offenders.len() + )) +} + +/// Open the mod content by path kind: a `.fantome`/`.modpkg` archive (read +/// in-memory, never extracted) or a mod project directory. +fn open_content(path: &Utf8Path) -> miette::Result> { + if path.is_dir() { + return Ok(Box::new(FsModContent::new(path.to_owned()))); + } + match path + .extension() + .map(|ext| ext.to_ascii_lowercase()) + .as_deref() + { + Some("fantome") => { + let file = File::open(path.as_std_path()).into_diagnostic()?; + Ok(Box::new( + FantomeContent::new(file).map_err(|err| miette!("{err}"))?, + )) + } + Some("modpkg") => { + let file = File::open(path.as_std_path()).into_diagnostic()?; + let modpkg = Modpkg::mount_from_reader(file).into_diagnostic()?; + Ok(Box::new(ModpkgContent::new(modpkg))) + } + _ => Err(miette!( + "unsupported mod format: '{path}' (expected a .fantome or .modpkg file, or a mod project directory)" + )), + } +} + +/// Resolve the game directory (the one containing `DATA/FINAL`) from the +/// explicit argument or the configured League path, accepting the game dir +/// itself, the install root, or the game executable path. +fn resolve_game_dir(arg: Option) -> miette::Result { + let base = match arg { + Some(dir) => dir, + None => config::load_config().league_path.ok_or_else(|| { + miette!( + "no game directory: pass --game-dir or set the League path with `league-mod config set-league-path`" + ) + })?, + }; + + let base = if base.is_file() { + base.parent().unwrap_or(&base).to_owned() + } else { + base + }; + for candidate in [base.clone(), base.join("Game")] { + if candidate.join("DATA").join("FINAL").as_std_path().exists() { + return Ok(candidate); + } + } + Err(miette!( + "'{base}' does not look like a League game directory (no DATA/FINAL found)" + )) +} diff --git a/crates/league-mod/src/main.rs b/crates/league-mod/src/main.rs index 179408d0..972c706a 100644 --- a/crates/league-mod/src/main.rs +++ b/crates/league-mod/src/main.rs @@ -9,8 +9,9 @@ use clap::builder::{styling::AnsiColor, Styles}; use clap::ColorChoice; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; use commands::{ - extract_mod_package, info_mod_package, init_mod_project, pack_mod_project, + extract_mod_package, info_mod_package, init_mod_project, pack_mod_project, sanitize_mod, ExtractModPackageArgs, InfoModPackageArgs, InitModProjectArgs, PackFormat, PackModProjectArgs, + SanitizeModArgs, }; use miette::Result; @@ -73,6 +74,16 @@ pub enum Commands { #[arg(short, long)] output_dir: Option, }, + /// Verify a mod's base-skin integrity against the game files, straight + /// from the archive (nothing is installed or extracted) + Sanitize { + /// Path to a .fantome / .modpkg file or a mod project directory + file_path: String, + + /// Game directory (or install root); defaults to the configured League path + #[arg(long)] + game_dir: Option, + }, /// Manage application configuration Config { #[command(subcommand)] @@ -149,6 +160,13 @@ fn main() -> Result<()> { file_path, output_dir, }), + Commands::Sanitize { + file_path, + game_dir, + } => sanitize_mod(SanitizeModArgs { + file_path, + game_dir, + }), Commands::Config { action } => match action { ConfigAction::Show => config_cmd::show_config(), ConfigAction::SetLeaguePath { path } => config_cmd::set_league_path(path), diff --git a/crates/ltk_overlay/Cargo.toml b/crates/ltk_overlay/Cargo.toml index 3917f611..6ba42682 100644 --- a/crates/ltk_overlay/Cargo.toml +++ b/crates/ltk_overlay/Cargo.toml @@ -18,6 +18,7 @@ ltk_file = "0.2.8" ltk_mod_project = { version = "0.5.0", path = "../ltk_mod_project", features = ["fantome"] } ltk_modpkg = { version = "0.6.0", path = "../ltk_modpkg" } ltk_fantome = { version = "0.6.1", path = "../ltk_fantome" } +ltk_sanitize = { version = "0.1.0", path = "../ltk_sanitize" } indexmap = { workspace = true } ltk_rst = "0.2.0" @@ -52,5 +53,6 @@ memmap2 = "0.9" rayon = "1.10" [dev-dependencies] +ltk_meta = "0.6.1" tempfile = "3" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/ltk_overlay/src/builder/mod.rs b/crates/ltk_overlay/src/builder/mod.rs index 3df7e800..57694bb5 100644 --- a/crates/ltk_overlay/src/builder/mod.rs +++ b/crates/ltk_overlay/src/builder/mod.rs @@ -24,13 +24,14 @@ //! Call [`build_patched_wad`](crate::wad_builder::build_patched_wad). //! 7. Persist the new [`OverlayState`] with per-WAD fingerprints. -mod metadata; +pub(crate) mod metadata; mod resolve; use crate::content::ModContentProvider; use crate::error::{Error, Result}; use crate::game_index::GameIndex; use crate::linked_bins::{LinkedBinOffender, collect_linked_bin_offenders}; +use crate::skin_integrity::{SkinIntegrityOffender, collect_skin_integrity_offenders}; use crate::state::OverlayState; use crate::strings::{self, StringOverrideMode, StringPatchPlan}; use camino::{Utf8Path, Utf8PathBuf}; @@ -464,6 +465,11 @@ pub struct OverlayBuilder { /// [`build`](Self::build), drained via /// [`take_linked_bin_offenders`](Self::take_linked_bin_offenders). last_linked_bin_offenders: Vec, + /// Mods whose overridden base skin violates the in-game verifier's + /// closed-world assertion, from the most recent [`build`](Self::build), + /// drained via + /// [`take_skin_integrity_offenders`](Self::take_skin_integrity_offenders). + last_skin_integrity_offenders: Vec, } impl OverlayBuilder { @@ -488,6 +494,7 @@ impl OverlayBuilder { progress_callback: None, last_mod_wad_reports: Vec::new(), last_linked_bin_offenders: Vec::new(), + last_skin_integrity_offenders: Vec::new(), } } @@ -512,6 +519,19 @@ impl OverlayBuilder { std::mem::take(&mut self.last_linked_bin_offenders) } + /// Drain the base-skin integrity offenders detected during the most recent + /// [`build`](Self::build). + /// + /// Each entry is a mod whose overridden `skin0.bin` leaves the base skin's + /// mesh references unresolvable in the overlay WAD the game will load — + /// which the in-game verifier treats as a hard failure. Returns an empty + /// vector when the last build found none. On an exact-match skip the + /// offenders from the previous build are restored from the persisted + /// overlay state. + pub fn take_skin_integrity_offenders(&mut self) -> Vec { + std::mem::take(&mut self.last_skin_integrity_offenders) + } + /// Analyze a single mod's WAD footprint without building or modifying any /// overlay artifacts. /// @@ -599,6 +619,7 @@ impl OverlayBuilder { // Reset per-build outputs; each return path sets these as appropriate. self.last_linked_bin_offenders = Vec::new(); + self.last_skin_integrity_offenders = Vec::new(); let effective_blocked = self.effective_blocked_wads(); @@ -733,6 +754,24 @@ impl OverlayBuilder { self.sweep_unexpected_overlay_files(&new_wad_fingerprints); + // Verify base-skin integrity against the overlay WADs now on disk + // (built and reused alike) — the same closed-world check the in-game + // verifier enforces, run ahead of time so a broken mod can be surfaced + // per-mod instead of failing the whole overlay at injection. + self.last_skin_integrity_offenders = collect_skin_integrity_offenders( + &self.game_dir, + &self.overlay_root, + &all_meta, + &wad_hash_sets, + &game_index, + ); + if !self.last_skin_integrity_offenders.is_empty() { + tracing::info!( + "Base-skin check: {} mod(s) violate base-skin integrity", + self.last_skin_integrity_offenders.len() + ); + } + let reused_paths: Vec = wads_to_reuse .iter() .map(|p| self.overlay_root.join(p)) @@ -747,6 +786,7 @@ impl OverlayBuilder { new_wad_fingerprints, ); state.linked_bin_offenders = self.last_linked_bin_offenders.clone(); + state.skin_integrity_offenders = self.last_skin_integrity_offenders.clone(); state.save(&state_path)?; let total_wads = built_paths.len() as u32; @@ -849,6 +889,7 @@ impl OverlayBuilder { self.sweep_unexpected_overlay_files(&state.wad_fingerprints); self.last_linked_bin_offenders = state.linked_bin_offenders.clone(); + self.last_skin_integrity_offenders = state.skin_integrity_offenders.clone(); self.emit_progress(OverlayProgress::stage(OverlayStage::Complete)); Some(OverlayBuildResult { diff --git a/crates/ltk_overlay/src/lib.rs b/crates/ltk_overlay/src/lib.rs index cf7a5bda..80f3e39a 100644 --- a/crates/ltk_overlay/src/lib.rs +++ b/crates/ltk_overlay/src/lib.rs @@ -99,6 +99,7 @@ pub mod game_index; pub mod linked_bins; pub mod meta_cache; pub mod modpkg_content; +pub mod skin_integrity; pub mod state; pub mod strings; pub mod utils; @@ -115,5 +116,6 @@ pub use fantome_content::FantomeContent; pub use game_index::GameIndex; pub use linked_bins::LinkedBinOffender; pub use modpkg_content::ModpkgContent; +pub use skin_integrity::SkinIntegrityOffender; pub use state::OverlayState; pub use strings::StringOverrideMode; diff --git a/crates/ltk_overlay/src/skin_integrity.rs b/crates/ltk_overlay/src/skin_integrity.rs new file mode 100644 index 00000000..8439a034 --- /dev/null +++ b/crates/ltk_overlay/src/skin_integrity.rs @@ -0,0 +1,338 @@ +//! Base-skin correctness diagnostics over the built overlay WADs. +//! +//! Runs [`ltk_sanitize`]'s closed-world check on every champion WAD whose +//! `skin0.bin` was overridden by a mod, after the overlay WADs are written: +//! the base skin's mesh references must resolve inside the overlay WAD the +//! game will load, exactly as the in-game verifier asserts. Violations are +//! attributed to the mod that owns the winning `skin0.bin` override so the +//! manager can prompt the user about that specific broken mod instead of the +//! whole overlay failing at injection. +//! +//! [Baseline anomalies](ltk_sanitize::BaselineAnomaly) — the *original* game +//! WAD violating the check's assumptions — are never a mod diagnostic; they +//! are logged (`tracing::error`, stable `base-skin baseline anomaly` prefix) +//! and dropped, since they point at a corrupt game install or at an +//! assumption a game patch has invalidated. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs::File; + +use camino::{Utf8Path, Utf8PathBuf}; +use ltk_sanitize::{ + ChunkSource, SkinCheckOutcome, VirtualMerge, WadChunkSource, champion_from_wad_path, + check_base_skin, skin0_bin_name_hash, +}; +use serde::{Deserialize, Serialize}; + +use crate::builder::{EnabledMod, OverrideMeta, OverrideSource, metadata}; +use crate::content::ModContentProvider; +use crate::game_index::GameIndex; + +/// One champion WAD whose overridden base skin violates the closed-world +/// assertion the in-game verifier enforces (see [`ltk_sanitize`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkinIntegrityOffender { + /// Mod that owns the winning `skin0.bin` override in this WAD. + pub mod_id: String, + /// WAD filename (e.g. `Aatrox.wad.client`). + pub wad: String, + /// Lowercase champion directory name. + pub champion: String, + /// Human-readable violation lines (rendered + /// [`ModAnomaly`](ltk_sanitize::ModAnomaly)s). + pub violations: Vec, +} + +/// Check every champion WAD with an overridden `skin0.bin` against its +/// original and collect per-mod violations. +/// +/// Must run after the overlay WADs exist on disk (built and reused alike): +/// it mounts the actual files the game will load, so it also covers whatever +/// the write path did to the chunks. A WAD that cannot be read is logged and +/// skipped — this is a diagnostic and must never fail the build. +pub(crate) fn collect_skin_integrity_offenders( + game_dir: &Utf8Path, + overlay_root: &Utf8Path, + all_meta: &HashMap, + wad_hash_sets: &BTreeMap>, + game_index: &GameIndex, +) -> Vec { + let mut offenders = Vec::new(); + + for (wad_path, override_hashes) in wad_hash_sets { + let Some(champion) = champion_from_wad_path(wad_path.as_str()) else { + continue; + }; + let root_hash = skin0_bin_name_hash(&champion); + // A WAD whose skin0.bin is not overridden keeps the original chunk — + // the check would skip anyway, so don't even mount it. + if !override_hashes.contains(&root_hash) { + continue; + } + let Some(mod_id) = all_meta.get(&root_hash).map(|m| m.source.mod_id()) else { + continue; + }; + + let original_path = game_dir.join(wad_path); + let overlay_path = overlay_root.join(wad_path); + let (mut original, mut merged) = match (mount(&original_path), mount(&overlay_path)) { + (Ok(original), Ok(merged)) => (original, merged), + (Err(err), _) | (_, Err(err)) => { + tracing::error!("Base-skin check could not read '{wad_path}': {err}; skipping"); + continue; + } + }; + + let world = |hash: u64| find_in_other_wads(game_index, wad_hash_sets, wad_path, hash); + + match check_base_skin( + &mut WadChunkSource(&mut original), + &mut WadChunkSource(&mut merged), + &champion, + Some(&world), + ) { + SkinCheckOutcome::SkippedUnmodified | SkinCheckOutcome::Modified(_) => {} + SkinCheckOutcome::BaselineAnomaly(anomaly) => { + // Already logged by ltk_sanitize with the stable prefix; add + // the overlay context so logs pin down which file to look at. + tracing::error!("base-skin baseline anomaly in '{wad_path}': {anomaly}"); + } + SkinCheckOutcome::ModAnomaly(anomaly) => { + offenders.push(SkinIntegrityOffender { + mod_id: mod_id.to_string(), + wad: wad_path + .file_name() + .unwrap_or(wad_path.as_str()) + .to_string(), + champion, + violations: vec![anomaly.to_string()], + }); + } + } + } + + offenders +} + +/// Which other WADs — original game WADs or overlay override sets — contain +/// `hash`, to tell "shipped to the wrong WAD" apart from "missing +/// everywhere". +fn find_in_other_wads( + game_index: &GameIndex, + wad_hash_sets: &BTreeMap>, + wad_path: &Utf8Path, + hash: u64, +) -> Vec { + let mut found: Vec = game_index + .find_wads_with_hash(hash) + .unwrap_or_default() + .iter() + .filter(|path| path.as_path() != wad_path) + .filter_map(|path| path.file_name().map(str::to_string)) + .collect(); + for (other_path, hashes) in wad_hash_sets { + if other_path.as_path() == wad_path || !hashes.contains(&hash) { + continue; + } + if let Some(name) = other_path.file_name() { + let name = name.to_string(); + if !found.contains(&name) { + found.push(name); + } + } + } + found +} + +fn mount(path: &Utf8Path) -> Result, String> { + let file = File::open(path.as_std_path()).map_err(|err| format!("open: {err}"))?; + ltk_wad::Wad::mount(file).map_err(|err| format!("mount: {err}")) +} + +/// Check a single mod's base-skin integrity against the game **without +/// building an overlay or extracting the mod to disk**. +/// +/// The merged view is a [`VirtualMerge`] of the mod's override chunks (read +/// in-memory through its [`ModContentProvider`]) over the original game WAD, +/// routed exactly like an overlay build would route them. Untrusted archives +/// are therefore never written to the filesystem to be checked. +/// +/// `index_cache_path` is where the [`GameIndex`] cache lives (e.g. +/// `/game_index.bin`); the index is built from `game_dir` when the +/// cache is stale or absent. +/// +/// Returns one offender per champion WAD whose overridden `skin0.bin` leaves +/// the base skin violating the closed-world assertion. Baseline anomalies +/// are logged, never returned (see the module docs). +pub fn check_single_mod( + game_dir: &Utf8Path, + index_cache_path: &Utf8Path, + enabled_mod: &mut EnabledMod, +) -> crate::error::Result> { + let game_index = GameIndex::load_or_build(game_dir, index_cache_path)?; + let mod_meta = metadata::collect_single_mod_metadata(enabled_mod, &game_index, game_dir)?; + + let mut wad_hash_sets: BTreeMap> = BTreeMap::new(); + for (&hash, meta) in &mod_meta { + for target in meta.route_targets(hash, &game_index) { + wad_hash_sets + .entry(target.to_owned()) + .or_default() + .insert(hash); + } + } + + let mod_id = enabled_mod.id.clone(); + let mut mod_source = ModChunkSource::new(enabled_mod.content.as_mut(), &mod_meta); + let mut offenders = Vec::new(); + + for (wad_path, routed) in &wad_hash_sets { + let Some(champion) = champion_from_wad_path(wad_path.as_str()) else { + continue; + }; + if !routed.contains(&skin0_bin_name_hash(&champion)) { + continue; + } + + let original_path = game_dir.join(wad_path); + // Two mounts: one is the pristine comparison side, the other is the + // base layer of the virtual merge. + let (mut original, mut base) = match (mount(&original_path), mount(&original_path)) { + (Ok(original), Ok(base)) => (original, base), + (Err(err), _) | (_, Err(err)) => { + tracing::error!("Base-skin check could not read '{wad_path}': {err}; skipping"); + continue; + } + }; + + let world = |hash: u64| find_in_other_wads(&game_index, &wad_hash_sets, wad_path, hash); + + mod_source.routed = routed.clone(); + let mut base_source = WadChunkSource(&mut base); + let mut merged = VirtualMerge { + overlay: &mut mod_source, + base: &mut base_source, + }; + + match check_base_skin( + &mut WadChunkSource(&mut original), + &mut merged, + &champion, + Some(&world), + ) { + SkinCheckOutcome::SkippedUnmodified | SkinCheckOutcome::Modified(_) => {} + SkinCheckOutcome::BaselineAnomaly(anomaly) => { + tracing::error!("base-skin baseline anomaly in '{wad_path}': {anomaly}"); + } + SkinCheckOutcome::ModAnomaly(anomaly) => { + offenders.push(SkinIntegrityOffender { + mod_id: mod_id.clone(), + wad: wad_path + .file_name() + .unwrap_or(wad_path.as_str()) + .to_string(), + champion, + violations: vec![anomaly.to_string()], + }); + } + } + } + + Ok(offenders) +} + +/// [`ChunkSource`] over a single mod's override chunks, read lazily through +/// its content provider and cached per `(layer, WAD)` directory. +/// +/// Overridden chunks are judged by content — `load` hands back the exact +/// bytes the base-skin check compares and fingerprints — so byte-identical +/// overrides read as unmodified, everything else as modified, and +/// violations only ever come from *missing* chunks. +struct ModChunkSource<'a> { + provider: &'a mut dyn ModContentProvider, + meta: &'a HashMap, + /// Hashes routed to the WAD currently being checked; chunks outside it + /// are invisible (the closed world under test). + routed: HashSet, + wad_cache: HashMap<(String, String), HashMap>>, + raw_cache: Option>>, +} + +impl<'a> ModChunkSource<'a> { + fn new(provider: &'a mut dyn ModContentProvider, meta: &'a HashMap) -> Self { + Self { + provider, + meta, + routed: HashSet::new(), + wad_cache: HashMap::new(), + raw_cache: None, + } + } + + fn bytes_for(&mut self, name_hash: u64) -> Result, String> { + let meta = self + .meta + .get(&name_hash) + .ok_or_else(|| "chunk is not one of this mod's overrides".to_string())?; + let entries = match &meta.source { + OverrideSource::LayerWad { + layer, wad_name, .. + } => { + let key = (layer.clone(), wad_name.clone()); + if !self.wad_cache.contains_key(&key) { + let entries = self + .provider + .read_wad_overrides(layer, wad_name) + .map_err(|err| err.to_string())?; + self.wad_cache.insert(key.clone(), index_by_hash(entries)); + } + &self.wad_cache[&key] + } + OverrideSource::Raw { .. } => { + if self.raw_cache.is_none() { + let entries = self + .provider + .read_raw_overrides() + .map_err(|err| err.to_string())?; + self.raw_cache = Some(index_by_hash(entries)); + } + self.raw_cache.as_ref().expect("populated above") + } + OverrideSource::StringPatch { .. } => { + return Err("string-patch overrides have no source bytes".to_string()); + } + }; + entries + .get(&name_hash) + .cloned() + .ok_or_else(|| "override bytes not found in mod content".to_string()) + } +} + +/// Index provider entries by their resolved chunk path hash. +fn index_by_hash(entries: Vec<(Utf8PathBuf, Vec)>) -> HashMap> { + let mut by_hash = HashMap::new(); + for (rel_path, bytes) in entries { + match crate::utils::resolve_chunk_hash(&rel_path, &bytes) { + Ok(hash) => { + by_hash.insert(hash, bytes); + } + Err(err) => tracing::warn!("Skipping override '{rel_path}': {err}"), + } + } + by_hash +} + +impl ChunkSource for ModChunkSource<'_> { + fn contains(&mut self, name_hash: u64) -> bool { + self.routed.contains(&name_hash) && self.meta.contains_key(&name_hash) + } + + fn load(&mut self, name_hash: u64) -> Result, String> { + if !self.routed.contains(&name_hash) { + return Err("chunk is not routed to this WAD".to_string()); + } + self.bytes_for(name_hash) + } +} diff --git a/crates/ltk_overlay/src/state.rs b/crates/ltk_overlay/src/state.rs index 895c47a4..3b90cf2f 100644 --- a/crates/ltk_overlay/src/state.rs +++ b/crates/ltk_overlay/src/state.rs @@ -14,6 +14,7 @@ use crate::error::{Error, Result}; use crate::linked_bins::LinkedBinOffender; +use crate::skin_integrity::SkinIntegrityOffender; use camino::Utf8Path; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -101,6 +102,12 @@ pub struct OverlayState { /// re-surface the same advisory without recomputing. #[serde(default)] pub linked_bin_offenders: Vec, + + /// Mods whose overridden base skin violates the closed-world assertion the + /// in-game verifier enforces, as computed during the last build. Persisted + /// for the same reason as `linked_bin_offenders`. + #[serde(default)] + pub skin_integrity_offenders: Vec, } impl Default for OverlayState { @@ -114,6 +121,7 @@ impl Default for OverlayState { string_override_locales: Vec::new(), wad_fingerprints: BTreeMap::new(), linked_bin_offenders: Vec::new(), + skin_integrity_offenders: Vec::new(), } } } @@ -146,6 +154,7 @@ impl OverlayState { string_override_locales, wad_fingerprints, linked_bin_offenders: Vec::new(), + skin_integrity_offenders: Vec::new(), } } diff --git a/crates/ltk_overlay/tests/skin_integrity.rs b/crates/ltk_overlay/tests/skin_integrity.rs new file mode 100644 index 00000000..90196dd7 --- /dev/null +++ b/crates/ltk_overlay/tests/skin_integrity.rs @@ -0,0 +1,317 @@ +//! End-to-end tests for the base-skin integrity check: a mod that overrides a +//! champion's `skin0.bin` must leave the base skin's mesh references +//! resolvable in the overlay WAD the game loads (the in-game verifier's +//! closed-world assertion). Violations are attributed to the offending mod; +//! problems with the *original* game WAD are baseline anomalies and never +//! become mod diagnostics. + +use camino::{Utf8Path, Utf8PathBuf}; +use indexmap::IndexMap; +use ltk_meta::property::{NoMeta, values}; +use ltk_meta::{Bin, BinObject}; +use ltk_mod_project::{ModProject, ModProjectLayer}; +use ltk_overlay::{EnabledMod, FsModContent, OverlayBuilder, SkinIntegrityOffender}; +use ltk_sanitize::BinHash; +use ltk_wad::{WadBuilder, WadChunkBuilder, WadChunkCompression}; +use std::fs; +use std::io::{Cursor, Write}; + +const CHAMP_WAD: &str = "Testchamp.wad.client"; +const OTHER_WAD: &str = "Ahri.wad.client"; +const SKIN0_BIN: &str = "data/characters/testchamp/skins/skin0.bin"; +const SKL: &str = "assets/characters/testchamp/skins/base/body.skl"; +const SKN: &str = "assets/characters/testchamp/skins/base/body.skn"; +const TEX: &str = "assets/characters/testchamp/skins/base/body_tx_cm.tex"; + +fn h(name: &str) -> BinHash { + use ltk_sanitize::Hash as _; + BinHash::hash_str(name) +} + +/// A skin0 bin whose entry references the given slot paths. `scale` varies +/// the bytes so a "modded" bin differs from the original. +fn skin0_bin_refs(skeleton: &str, simple_skin: &str, texture: &str, scale: f32) -> Vec { + let mesh = values::Embedded(values::Struct { + class_hash: h("SkinMeshDataProperties"), + properties: IndexMap::from([ + (h("Skeleton"), values::String::from(skeleton).into()), + (h("SimpleSkin"), values::String::from(simple_skin).into()), + (h("Texture"), values::String::from(texture).into()), + (h("SkinScale"), values::F32::new(scale).into()), + ]), + meta: NoMeta, + }); + let entry = BinObject::::builder( + h("Characters/Testchamp/Skins/Skin0"), + h("SkinCharacterDataProperties"), + ) + .property(h("SkinMeshProperties"), mesh) + .build(); + + let bin = Bin::builder().object(entry).build(); + let mut cursor = Cursor::new(Vec::new()); + bin.to_writer(&mut cursor).unwrap(); + cursor.into_inner() +} + +/// [`skin0_bin_refs`] with stock skeleton/simple-skin references. +fn skin0_bin(texture: &str, scale: f32) -> Vec { + skin0_bin_refs(SKL, SKN, texture, scale) +} + +fn write_game_wad(game_dir: &Utf8Path, wad_name: &str, chunks: &[(&str, Vec)]) { + let champions_dir = game_dir.join("DATA").join("FINAL").join("Champions"); + fs::create_dir_all(champions_dir.as_std_path()).unwrap(); + + let mut builder = WadBuilder::default(); + for (chunk_path, _) in chunks { + builder = builder.with_chunk( + WadChunkBuilder::default() + .with_path(chunk_path) + .with_force_compression(WadChunkCompression::None), + ); + } + let chunks: Vec<(u64, Vec)> = chunks + .iter() + .map(|(path, bytes)| { + ( + ltk_overlay::utils::resolve_chunk_hash(Utf8Path::new(path), b"").unwrap(), + bytes.clone(), + ) + }) + .collect(); + let mut cursor = Cursor::new(Vec::new()); + builder + .build_to_writer(&mut cursor, move |hash, writer| { + let bytes = &chunks.iter().find(|(h, _)| *h == hash).unwrap().1; + writer.write_all(bytes)?; + Ok(()) + }) + .unwrap(); + + fs::write( + champions_dir.join(wad_name).as_std_path(), + cursor.into_inner(), + ) + .unwrap(); +} + +/// A game with a valid Testchamp baseline (skin0 + all three mesh assets) +/// and a second champion WAD for wrong-WAD scenarios. +fn write_game(game_dir: &Utf8Path) { + write_game_wad( + game_dir, + CHAMP_WAD, + &[ + (SKIN0_BIN, skin0_bin(TEX, 1.0)), + (SKL, b"skeleton-data".to_vec()), + (SKN, b"mesh-data".to_vec()), + (TEX, b"texture-data".to_vec()), + ], + ); + write_game_wad( + game_dir, + OTHER_WAD, + &[("assets/characters/ahri/vfx.tex", b"AHRI".to_vec())], + ); +} + +/// Write a mod whose files are `(wad directory name, chunk path, bytes)`. +fn write_mod_dir(root: &Utf8Path, name: &str, files: &[(&str, &str, Vec)]) -> Utf8PathBuf { + let mod_dir = root.join(name); + for (wad_name, chunk_path, bytes) in files { + let file = mod_dir + .join("content") + .join("base") + .join(wad_name) + .join(chunk_path); + fs::create_dir_all(file.parent().unwrap().as_std_path()).unwrap(); + fs::write(file.as_std_path(), bytes).unwrap(); + } + + let project = ModProject { + name: name.to_string(), + display_name: name.to_string(), + version: "1.0.0".to_string(), + description: String::new(), + authors: vec![], + license: None, + tags: vec![], + champions: vec![], + maps: vec![], + transformers: vec![], + layers: vec![ModProjectLayer { + name: "base".to_string(), + display_name: None, + priority: 0, + description: None, + string_overrides: Default::default(), + }], + thumbnail: None, + }; + fs::write( + mod_dir.join("mod.config.json").as_std_path(), + serde_json::to_string_pretty(&project).unwrap(), + ) + .unwrap(); + mod_dir +} + +fn build_and_take_offenders(root: &Utf8Path, mod_dir: &Utf8PathBuf) -> Vec { + let mut builder = OverlayBuilder::new( + root.join("Game"), + root.join("profile").join("overlay"), + root.join("profile"), + ); + builder.set_enabled_mods(vec![EnabledMod { + id: "test-mod".to_string(), + content: Box::new(FsModContent::new(mod_dir.clone())), + enabled_layers: None, + }]); + builder.build().unwrap(); + builder.take_skin_integrity_offenders() +} + +/// A modified skin0 whose references all resolve is not an offender. +#[test] +fn clean_skin_swap_is_not_flagged() { + let tmp = tempfile::tempdir().unwrap(); + let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); + write_game(&root.join("Game")); + + let mod_dir = write_mod_dir( + &root, + "clean-mod", + &[ + (CHAMP_WAD, SKIN0_BIN, skin0_bin(TEX, 2.0)), + (CHAMP_WAD, TEX, b"MODDED-texture".to_vec()), + ], + ); + + assert_eq!(build_and_take_offenders(&root, &mod_dir), vec![]); +} + +/// A mod that does not touch skin0.bin is never checked (or flagged), no +/// matter what else it ships. +#[test] +fn mod_without_skin0_override_is_not_flagged() { + let tmp = tempfile::tempdir().unwrap(); + let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); + write_game(&root.join("Game")); + + let mod_dir = write_mod_dir( + &root, + "texture-mod", + &[(CHAMP_WAD, TEX, b"MODDED-texture".to_vec())], + ); + + assert_eq!(build_and_take_offenders(&root, &mod_dir), vec![]); +} + +/// A skin0 referencing a skeleton that exists nowhere — an outdated or +/// broken mod — is flagged with a "missing everywhere" violation, and the +/// offender survives an exact-match skip via the persisted overlay state. +#[test] +fn dangling_reference_is_flagged_and_persisted() { + let tmp = tempfile::tempdir().unwrap(); + let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); + write_game(&root.join("Game")); + + let stale = "assets/characters/testchamp/skins/base/removed_in_patch.skl"; + let mod_dir = write_mod_dir( + &root, + "broken-mod", + &[(CHAMP_WAD, SKIN0_BIN, skin0_bin_refs(stale, SKN, TEX, 2.0))], + ); + + let offenders = build_and_take_offenders(&root, &mod_dir); + assert_eq!(offenders.len(), 1); + let offender = &offenders[0]; + assert_eq!(offender.mod_id, "test-mod"); + assert_eq!(offender.wad, CHAMP_WAD); + assert_eq!(offender.champion, "testchamp"); + assert_eq!(offender.violations.len(), 1); + assert!( + offender.violations[0].contains("broken or outdated"), + "unexpected violation text: {}", + offender.violations[0] + ); + + // Second build is an exact-match skip; offenders come back from state. + assert_eq!(build_and_take_offenders(&root, &mod_dir), offenders); +} + +/// The Texture property is not checked at all — a dangling texture +/// reference (a known authoring idiom) is never an offender. +#[test] +fn dangling_texture_is_ignored() { + let tmp = tempfile::tempdir().unwrap(); + let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); + write_game(&root.join("Game")); + + let stale = "assets/characters/testchamp/skins/base/suppressed.tex"; + let mod_dir = write_mod_dir( + &root, + "texture-trick-mod", + &[(CHAMP_WAD, SKIN0_BIN, skin0_bin(stale, 2.0))], + ); + + assert_eq!(build_and_take_offenders(&root, &mod_dir), vec![]); +} + +/// A skin0 referencing a custom asset the mod shipped into a *different* WAD +/// is flagged as misplaced, naming the WAD that has it. +#[test] +fn misplaced_reference_is_flagged_with_the_wrong_wad() { + let tmp = tempfile::tempdir().unwrap(); + let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); + write_game(&root.join("Game")); + + let custom = "assets/characters/testchamp/skins/base/custom.skl"; + let mod_dir = write_mod_dir( + &root, + "misplaced-mod", + &[ + (CHAMP_WAD, SKIN0_BIN, skin0_bin_refs(custom, SKN, TEX, 2.0)), + // New chunk shipped under the wrong WAD directory: it routes to + // Ahri.wad.client, not the champion WAD referencing it. + (OTHER_WAD, custom, b"custom-skeleton".to_vec()), + ], + ); + + let offenders = build_and_take_offenders(&root, &mod_dir); + assert_eq!(offenders.len(), 1); + assert_eq!(offenders[0].violations.len(), 1); + let violation = &offenders[0].violations[0]; + assert!( + violation.contains("wrong WAD") && violation.contains(OTHER_WAD), + "unexpected violation text: {violation}" + ); +} + +/// A corrupt *original* skin0.bin is a baseline anomaly: logged, but never a +/// mod diagnostic. +#[test] +fn corrupt_original_is_not_blamed_on_the_mod() { + let tmp = tempfile::tempdir().unwrap(); + let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); + let game_dir = root.join("Game"); + write_game_wad( + &game_dir, + CHAMP_WAD, + &[ + (SKIN0_BIN, b"not a property bin".to_vec()), + (SKL, b"skeleton-data".to_vec()), + (SKN, b"mesh-data".to_vec()), + (TEX, b"texture-data".to_vec()), + ], + ); + + let mod_dir = write_mod_dir( + &root, + "any-mod", + &[(CHAMP_WAD, SKIN0_BIN, skin0_bin(TEX, 2.0))], + ); + + assert_eq!(build_and_take_offenders(&root, &mod_dir), vec![]); +} diff --git a/crates/ltk_sanitize/Cargo.toml b/crates/ltk_sanitize/Cargo.toml new file mode 100644 index 00000000..459f22e1 --- /dev/null +++ b/crates/ltk_sanitize/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ltk_sanitize" +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +description = "Mod correctness verification for League of Legends overlays (base-skin reference integrity)" +repository = "https://github.com/LeagueToolkit/league-mod" +homepage = "https://github.com/LeagueToolkit/league-mod" +documentation = "https://github.com/LeagueToolkit/league-mod/wiki" +keywords = ["league-of-legends", "modding", "gaming", "toolkit"] +categories = ["game-development"] +authors = ["LeagueToolkit"] + +[dependencies] +ltk_wad = { workspace = true } +ltk_meta = "0.6.1" +ltk_hash = "0.4.0" +thiserror = "2.0" +tracing = "0.1" +sha2 = "0.10" + +[dev-dependencies] +indexmap = { workspace = true } diff --git a/crates/ltk_sanitize/src/check.rs b/crates/ltk_sanitize/src/check.rs new file mode 100644 index 00000000..089513ec --- /dev/null +++ b/crates/ltk_sanitize/src/check.rs @@ -0,0 +1,560 @@ +//! The composed base-skin correctness check. +//! +//! Judges the merged view of one champion WAD (mod content over the +//! original) against the closed-world assertion the in-game verifier +//! enforces: the base skin's mesh references must resolve inside this WAD. +//! +//! Every state the check can end in is a [`SkinCheckOutcome`] variant — +//! nothing is optional inside any of them: +//! +//! - [`SkinCheckOutcome::SkippedUnmodified`] — the merged root bin is +//! byte-identical (by decompressed content) to the original; nothing to +//! check. +//! - [`SkinCheckOutcome::BaselineAnomaly`] — never the mod's problem: the +//! **original** game WAD violates an assumption (corrupt install, or a +//! game patch broke an assumption this crate bakes in, e.g. the +//! required-slot rule). These should be logged loudly and looked at by +//! us, not shown as a mod diagnostic. +//! - [`SkinCheckOutcome::ModAnomaly`] — the mod's problem, the mirror of +//! the baseline judgment applied to the merged side: unresolvable skin +//! entry (reported as the corrupt bin in the skin graph when that is +//! what hid it), missing required slot, or a mesh reference that is not +//! usable from this WAD. +//! - [`SkinCheckOutcome::Modified`] — the mod modified the base skin and +//! it satisfies the assertion; carries the parsed entries of both sides, +//! the merged fingerprints, and any bins the resolve walk could not read +//! but did not need. + +use ltk_hash::{BinHash, Hash as _, WadHash}; +use ltk_meta::BinObject; +use sha2::{Digest, Sha256}; +use std::fmt; +use thiserror::Error; + +use crate::resolve::{ + CorruptBin, ResolveError, ResolveOutcome, ResolvedBinObject, resolve_bin_entry_with, +}; +use crate::skin::{ + MeshSlot, SkinMeshRefs, skin_character_data_class, skin_mesh_refs, skin0_bin_path, + skin0_entry_hash, +}; +use crate::source::ChunkSource; + +/// Why a referenced asset is missing, as far as the caller's world +/// knowledge can tell. +/// +/// Renders as the tail of a "{slot} '{path}' …" sentence (see +/// [`ModAnomaly::RefMissing`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefMissingKind { + /// No world lookup was provided — all that is known is that the chunk + /// is not in this WAD (the in-game verifier's view). + Unknown, + /// The chunk exists nowhere the world lookup knows of: the mod is + /// broken or outdated (e.g. it references a vanilla asset that was + /// removed from the game in a past patch). + Everywhere, + /// The chunk exists, but in other WADs — the mod shipped it to the + /// wrong WAD (commonly a localized WAD instead of the champion WAD). + Misplaced { found_in: Vec }, + /// In the TOC, but its bytes could not be read (load/decompression + /// failure) — present in name only, so the closed world is violated + /// just the same. + Unreadable { reason: String }, +} + +impl fmt::Display for RefMissingKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RefMissingKind::Unknown => write!(f, "is missing from this WAD"), + RefMissingKind::Everywhere => write!( + f, + "is missing from this WAD and everywhere else in the game and overlay — \ + the mod is likely broken or outdated (the asset may have been removed \ + from the game)" + ), + RefMissingKind::Misplaced { found_in } => write!( + f, + "is not in this WAD but exists in {} — shipped to the wrong WAD", + found_in.join(", ") + ), + RefMissingKind::Unreadable { reason } => { + write!(f, "is in the WAD but cannot be read: {reason}") + } + } + } +} + +/// How a present, readable reference relates to the original entry's same +/// slot. A reference that is *not* usable never gets a status — it fails +/// the whole check as [`ModAnomaly::RefMissing`]. +/// +/// The comparison is **slot-to-slot**: the merged reference is read at +/// *its* path in the merged view, the vanilla counterpart at the +/// **original entry's** path for the same slot, and the two contents are +/// compared. It is never path-in-original: looking the merged path up in +/// the original WAD would classify a repointed slot — `skin0` aimed at +/// another vanilla asset already in this WAD, the skin-unlock shape — as +/// Unmodified, because the bytes at that path *are* vanilla. They are +/// just not the bytes the original slot renders. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefStatus { + /// Renders the same *content* the original entry's same slot renders, + /// proven by comparing decompressed bytes. TOC checksums are never + /// read at all — they are declared by the untrusted WAD, and zstd is + /// not canonical — so neither a hostile declared checksum nor a + /// repack of untouched bytes can affect classification. Content + /// decides, not the path — a repoint that lands on a byte-identical + /// chunk still renders exactly what vanilla renders. + Unmodified, + /// Present but rendering different content than the original entry's + /// same slot: the bytes changed, or the reference was repointed at + /// another asset (vanilla or not — the skin-unlock shape lands here). + /// Not a correctness violation. Also lands here when the original + /// side could not prove equality (its chunk unreadable). + Modified { + /// SHA-256 of the decompressed chunk bytes — the fingerprint + /// consumers attest modified assets with (e.g. against a + /// known-fingerprint set) without re-fetching the chunk. Computed + /// from the actual bytes, never taken from anything the WAD + /// declares, and collision-resistant so a crafted chunk cannot + /// impersonate a known asset. + sha256: [u8; 32], + }, +} + +/// One checked mesh reference of a modified skin: present and readable in +/// the merged WAD (anything else is a [`ModAnomaly::RefMissing`], so it +/// never appears here). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MeshRef { + /// The path exactly as referenced by the bin. + pub path: String, + /// xxh64 chunk hash of the (lowercased) path. + pub name_hash: u64, + pub status: RefStatus, +} + +/// The **original** game WAD violated an assumption of the check. This is +/// never the mod's fault: it points at a corrupt game install, or at a game +/// patch invalidating an assumption this crate bakes in. Report it where +/// developers will see it (logs), not as a mod diagnostic. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum BaselineAnomaly { + #[error("original WAD has no '{bin_path}' chunk")] + OriginalRootMissing { bin_path: String }, + + #[error("merged WAD is missing '{bin_path}', which the original WAD contains")] + MergedRootMissing { bin_path: String }, + + /// A bin in the original's skin graph could not be read or parsed, and + /// the baseline entry was never found. Corruption the walk survived is + /// only logged, never this anomaly. + #[error("original WAD has a corrupt bin '{}': {}", .0.bin_path, .0.reason)] + OriginalCorruptBin(CorruptBin), + + #[error("original WAD: {0}")] + OriginalResolve(ResolveError), + + #[error("original skin0 entry has no {0} mesh property")] + OriginalMissingRequiredSlot(MeshSlot), + + #[error("original skin0 {slot} '{path}' is missing from the original WAD")] + OriginalRefUnresolved { slot: MeshSlot, path: String }, +} + +/// The mod broke the closed-world assertion — its base skin would fail +/// in-game verification. The merged-side mirror of [`BaselineAnomaly`]: +/// the first violation encountered, judged in the same fail-closed order +/// the baseline uses (unresolvable entry, missing required slot, unusable +/// reference). +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum ModAnomaly { + /// A bin in the merged skin graph could not be read or parsed, and the + /// `skin0` entry was never found — the unreadable bin is the likeliest + /// place it went, so it is reported in place of the bare + /// [`ResolveError::EntryNotFound`]. + /// + /// Corruption on its own is never this anomaly: the walk does not stop + /// for an unreadable bin, so an entry that resolved from a readable one + /// is judged normally and the unreadable bins ride along on + /// [`ModifiedSkin::corrupt_bins`]. + #[error("corrupt property bin '{}' in the skin graph: {}", .0.bin_path, .0.reason)] + CorruptBin(CorruptBin), + + #[error("base-skin entry could not be resolved: {0}")] + Resolve(ResolveError), + + #[error("skin0 entry has no {0} mesh property")] + MissingRequiredSlot(MeshSlot), + + /// A set mesh reference that is not usable from the merged WAD — + /// absent, or present but unreadable. `kind` refines why, as far as + /// the caller's world knowledge can tell. + #[error("{slot} '{path}' {kind}")] + RefMissing { + slot: MeshSlot, + /// The path exactly as referenced by the bin. + path: String, + kind: RefMissingKind, + }, +} + +/// A modified base skin that satisfies the closed-world assertion: the +/// entry resolved, every required mesh slot is set, and every reference is +/// present and readable in the merged WAD. Any failure on the way is a +/// [`ModAnomaly`] instead, so nothing here is optional — the one field +/// that can be empty is [`corrupt_bins`](Self::corrupt_bins), the +/// corruption the resolve walk survived. +#[derive(Debug, Clone, PartialEq)] +pub struct ModifiedSkin { + /// The bin the entry was found in (`skin0.bin` itself or a linked bin). + pub bin_path: String, + /// xxh64 chunk hash of `bin_path`. + pub bin_name_hash: u64, + /// The parsed `skin0` entry as the merged WAD defines it. Carried so a + /// consumer can read properties this check does not model + /// (`SkinClassification`, material overrides, VFX) without + /// re-resolving and re-parsing the skin graph. + pub object: BinObject, + /// The parsed `skin0` entry as the original game WAD defines it: the + /// vanilla baseline each slot was classified against (see + /// [`RefStatus`]). + pub original_object: BinObject, + /// The `Skeleton` reference and how it relates to vanilla. + pub skeleton: MeshRef, + /// The `SimpleSkin` reference and how it relates to vanilla. + pub simple_skin: MeshRef, + /// Bins in the merged skin graph that could not be read or parsed, in + /// walk order — usually empty. The entry resolved without them, so they + /// are not a correctness violation (see [`ModAnomaly::CorruptBin`]), + /// but they are parts of the graph this check could not see into: a + /// consumer that must vouch for the whole skin (the in-game verifier's + /// fast track) should read a non-empty list as "cannot vouch" and fall + /// through to its full scan, while a reporting consumer can surface + /// them as warnings. + pub corrupt_bins: Vec, +} + +/// Outcome of checking one champion WAD. +#[derive(Debug, Clone, PartialEq)] +pub enum SkinCheckOutcome { + /// The merged `skin0.bin` chunk decompresses to the same bytes as the + /// original — a vanilla base skin, nothing to check. Decided by + /// loading both roots and comparing content, so a merely + /// re-compressed root bin skips too. (Assumption shared with the + /// in-game verifier: an unmodified root bin means an unmodified base + /// skin; a mod would have to modify only a *linked* bin to sidestep + /// it, which in practice does not happen for skin swaps.) + /// + /// Note the referenced mesh chunks are not inspected either: a mod + /// can replace the chunk *contents* at the stock mesh paths while + /// keeping `skin0.bin` byte-identical. Harmless for the correctness + /// lane (nothing can be missing), but a consumer that gates further + /// scanning on this outcome inherits that blind spot. + SkippedUnmodified, + /// The original WAD violated an assumption — the check could not judge + /// the mod at all. Logged via `tracing::error` with a stable + /// `base-skin baseline anomaly` prefix. + BaselineAnomaly(BaselineAnomaly), + /// The mod broke the closed-world assertion and would fail in-game + /// verification; its rendered message is the user-facing diagnostic. + /// Logged via `tracing::warn`. + ModAnomaly(ModAnomaly), + /// The base skin is modified and satisfies the assertion. Boxed to + /// keep this enum small: the payload carries two parsed bin entries, + /// and without the indirection every outcome — including the common + /// [`SkippedUnmodified`](Self::SkippedUnmodified) — would pay their + /// size. + Modified(Box), +} + +/// Check one champion WAD's base skin in its merged view against the +/// original game WAD. +/// +/// * `original` — the unpatched game WAD. +/// * `merged` — the merged view the game will load: a built overlay WAD, or +/// a [`VirtualMerge`](crate::source::VirtualMerge) of mod content over +/// `original`. +/// * `champion` — lowercase champion directory name (see +/// [`champion_from_wad_path`](crate::skin::champion_from_wad_path)). +/// * `world` — optional lookup answering "which other WADs (game or +/// overlay) contain this chunk hash?", used to refine missing references +/// into [`RefMissingKind::Everywhere`] vs [`RefMissingKind::Misplaced`]. +pub fn check_base_skin( + original: &mut dyn ChunkSource, + merged: &mut dyn ChunkSource, + champion: &str, + world: Option<&dyn Fn(u64) -> Vec>, +) -> SkinCheckOutcome { + let root_bin_path = skin0_bin_path(champion); + let root_hash = *WadHash::hash_str(&root_bin_path); + let entry_hash = skin0_entry_hash(champion); + let skin_class = skin_character_data_class(); + + if !original.contains(root_hash) { + return anomaly(BaselineAnomaly::OriginalRootMissing { + bin_path: root_bin_path, + }); + } + if !merged.contains(root_hash) { + return anomaly(BaselineAnomaly::MergedRootMissing { + bin_path: root_bin_path, + }); + } + // The vanilla-skin skip compares decompressed content (TOC checksums + // are never read — see [`RefStatus`]). A root that cannot be read + // falls through to the full check, which maps the corruption to its + // proper anomaly (baseline for the original side, mod for the merged + // side). + if let (Ok(original_data), Ok(merged_data)) = (original.load(root_hash), merged.load(root_hash)) + && original_data == merged_data + { + tracing::debug!("'{root_bin_path}' is unmodified; base-skin check skipped"); + return SkinCheckOutcome::SkippedUnmodified; + } + + // The original WAD must satisfy every assumption this check enforces + // before the mod can be judged against it. Its mesh refs are kept as + // the comparison baseline: every merged slot is judged against the + // chunk the ORIGINAL entry's same slot references. + let (original_object, original_refs) = + match validate_baseline(original, &root_bin_path, entry_hash, skin_class) { + Ok(baseline) => baseline, + Err(baseline) => return anomaly(baseline), + }; + + match judge_mod( + original, + merged, + &root_bin_path, + entry_hash, + skin_class, + world, + original_object, + &original_refs, + ) { + Ok(modified) => SkinCheckOutcome::Modified(Box::new(modified)), + Err(mod_anomaly) => { + tracing::warn!("Base-skin violation for {champion}: {mod_anomaly}"); + SkinCheckOutcome::ModAnomaly(mod_anomaly) + } + } +} + +/// Resolve and judge the merged side, mirroring [`validate_baseline`]'s +/// fail-closed order: unresolvable entry, missing required slot, unusable +/// reference. Corruption the resolve walk survived is carried on the +/// result rather than reported (see [`resolve_or_explain`]). +#[expect(clippy::too_many_arguments)] +fn judge_mod( + original: &mut dyn ChunkSource, + merged: &mut dyn ChunkSource, + root_bin_path: &str, + entry_hash: BinHash, + skin_class: BinHash, + world: Option<&dyn Fn(u64) -> Vec>, + original_object: BinObject, + original_refs: &SkinMeshRefs, +) -> Result { + let outcome = resolve_bin_entry_with(merged, root_bin_path, entry_hash, Some(skin_class)); + let (resolved, corrupt_bins) = resolve_or_explain(outcome).map_err(|cause| match cause { + Unresolved::Corrupt(corrupt) => ModAnomaly::CorruptBin(corrupt), + Unresolved::Resolve(err) => ModAnomaly::Resolve(err), + })?; + + let merged_refs = skin_mesh_refs(&resolved.object); + if let Some(&slot) = merged_refs.missing_required_slots().first() { + return Err(ModAnomaly::MissingRequiredSlot(slot)); + } + + let mut classify = + |slot| classify_slot(original, merged, world, &merged_refs, original_refs, slot); + let skeleton = classify(MeshSlot::Skeleton)?; + let simple_skin = classify(MeshSlot::SimpleSkin)?; + + Ok(ModifiedSkin { + bin_path: resolved.bin_path, + bin_name_hash: resolved.bin_name_hash, + object: resolved.object, + original_object, + skeleton, + simple_skin, + corrupt_bins, + }) +} + +/// Why a resolve walk produced no entry. +enum Unresolved { + Corrupt(CorruptBin), + Resolve(ResolveError), +} + +/// Split a resolve outcome into the entry plus the corruption it survived, +/// or the failure to report. +/// +/// Corruption is never a verdict on its own. The walk does not stop for an +/// unreadable bin ([`resolve_bin_entry_with`]), so an entry defined by a +/// readable bin is judged normally and the unreadable ones ride along for +/// the caller to decide about. Corruption becomes the reported failure only +/// when the entry was never found — an unreadable bin may well have been +/// the one defining it, and "entry not found" would name the wrong cause. +/// Every other resolve error is a definitive verdict the walk reached on +/// its own (the entry was found with the wrong class, the root is absent, +/// the graph is absurd) and must not be masked by incidental corruption +/// elsewhere in the graph. +fn resolve_or_explain( + outcome: ResolveOutcome, +) -> Result<(ResolvedBinObject, Vec), Unresolved> { + match outcome.entry { + Ok(resolved) => Ok((resolved, outcome.corrupt)), + Err(err) => Err(match outcome.corrupt.into_iter().next() { + Some(corrupt) if matches!(err, ResolveError::EntryNotFound { .. }) => { + Unresolved::Corrupt(corrupt) + } + _ => Unresolved::Resolve(err), + }), + } +} + +/// Classify one required slot of the merged entry against the original +/// entry's same slot, or fail with the [`ModAnomaly`] it evidences. +/// +/// Two paths, read separately: the merged reference at ITS path in the +/// merged view, the vanilla counterpart at the ORIGINAL entry's path for +/// the same slot (see the [`RefStatus`] docs — comparing at the merged +/// path would bless repointed slots). +fn classify_slot( + original: &mut dyn ChunkSource, + merged: &mut dyn ChunkSource, + world: Option<&dyn Fn(u64) -> Vec>, + merged_refs: &SkinMeshRefs, + original_refs: &SkinMeshRefs, + slot: MeshSlot, +) -> Result { + let Some(path) = merged_refs.slot_path(slot) else { + return Err(ModAnomaly::MissingRequiredSlot(slot)); + }; + let name_hash = *WadHash::hash_str(path); + + if !merged.contains(name_hash) { + return Err(ModAnomaly::RefMissing { + slot, + path: path.to_owned(), + kind: match world { + None => RefMissingKind::Unknown, + Some(lookup) => { + let found_in = lookup(name_hash); + if found_in.is_empty() { + RefMissingKind::Everywhere + } else { + RefMissingKind::Misplaced { found_in } + } + } + }, + }); + } + let data = match merged.load(name_hash) { + Ok(data) => data, + // Present in name only: fail closed — a stored chunk exists that + // the check could not inspect. + Err(reason) => { + return Err(ModAnomaly::RefMissing { + slot, + path: path.to_owned(), + kind: RefMissingKind::Unreadable { reason }, + }); + } + }; + // One digest serves both purposes: equality against the original + // slot's content, and the fingerprint carried on Modified. Baseline + // validation guarantees the original entry sets every checked slot; + // the None arm is defensive. + let sha256: [u8; 32] = Sha256::digest(&data).into(); + let status = match original_refs.slot_path(slot) { + Some(original_path) + if original_sha256(original, *WadHash::hash_str(original_path)) == Some(sha256) => + { + RefStatus::Unmodified + } + _ => RefStatus::Modified { sha256 }, + }; + Ok(MeshRef { + path: path.to_owned(), + name_hash, + status, + }) +} + +/// SHA-256 of the original WAD's chunk at `name_hash` — the **original +/// entry's** slot reference, not the merged path. Comparing these digests +/// of decompressed content is the sole equality test; TOC checksums are +/// never consulted (declared by an untrusted WAD, and not canonical under +/// re-compression). `None` when the original lacks the chunk or cannot +/// read it — never a match, so the merged side classifies as +/// [`RefStatus::Modified`] (an unreadable *original* is never the mod's +/// problem to report, so it maps to no error and no +/// [`RefMissingKind::Unreadable`], which describes the merged side). +fn original_sha256(original: &mut dyn ChunkSource, name_hash: u64) -> Option<[u8; 32]> { + if !original.contains(name_hash) { + return None; + } + match original.load(name_hash) { + Ok(data) => Some(Sha256::digest(&data).into()), + Err(reason) => { + tracing::debug!( + "original chunk {name_hash:016x} is unreadable ({reason}); \ + classifying the merged chunk as modified" + ); + None + } + } +} + +/// Resolve and extract the original WAD's base skin, mapping every failure +/// to the [`BaselineAnomaly`] it evidences. On success, returns the parsed +/// baseline entry and its mesh refs — the slot-to-slot comparison baseline +/// for classifying the merged entry's references. +fn validate_baseline( + original: &mut dyn ChunkSource, + root_bin_path: &str, + entry_hash: BinHash, + skin_class: BinHash, +) -> Result<(BinObject, SkinMeshRefs), BaselineAnomaly> { + let outcome = resolve_bin_entry_with(original, root_bin_path, entry_hash, Some(skin_class)); + let (resolved, corrupt_bins) = resolve_or_explain(outcome).map_err(|cause| match cause { + Unresolved::Corrupt(corrupt) => BaselineAnomaly::OriginalCorruptBin(corrupt), + Unresolved::Resolve(err) => BaselineAnomaly::OriginalResolve(err), + })?; + // Corruption the baseline walk survived is not carried anywhere: an + // unreadable bin in the *original* is never the mod's problem to answer + // for (the same call `original_sha256` makes for an unreadable original + // chunk), and the entry resolved without it. It is still a corrupt + // install, so say so where we will see it. + for corrupt in corrupt_bins { + tracing::warn!( + "original WAD has a corrupt bin '{}' ({}); the base-skin entry resolved without it", + corrupt.bin_path, + corrupt.reason + ); + } + + let refs = skin_mesh_refs(&resolved.object); + if let Some(&slot) = refs.missing_required_slots().first() { + return Err(BaselineAnomaly::OriginalMissingRequiredSlot(slot)); + } + for (slot, path) in refs.slots() { + if !original.contains(*WadHash::hash_str(path)) { + return Err(BaselineAnomaly::OriginalRefUnresolved { + slot, + path: path.to_owned(), + }); + } + } + Ok((resolved.object, refs)) +} + +fn anomaly(baseline: BaselineAnomaly) -> SkinCheckOutcome { + tracing::error!("base-skin baseline anomaly: {baseline}"); + SkinCheckOutcome::BaselineAnomaly(baseline) +} diff --git a/crates/ltk_sanitize/src/lib.rs b/crates/ltk_sanitize/src/lib.rs new file mode 100644 index 00000000..0e3b9e20 --- /dev/null +++ b/crates/ltk_sanitize/src/lib.rs @@ -0,0 +1,67 @@ +//! Mod correctness verification for League of Legends overlays. +//! +//! The in-game verifier asserts a **closed world per WAD**: every asset a +//! champion's base skin (`skin0`) references must be present in the WAD that +//! references it, and a reference that does not resolve is a hard failure. +//! Real mods violate this in mundane, non-hostile ways — assets that are +//! simply missing, assets shipped into the wrong WAD (e.g. a localized WAD +//! instead of the champion WAD), or outdated mods whose skin bin references +//! vanilla assets that were removed from the game in a past patch. +//! +//! This crate is the shared implementation of that check, usable ahead of +//! time (mod managers, CLI tools, upload validation) and by the in-game +//! verifier itself, so the assertion cannot drift between implementations: +//! +//! - [`ChunkSource`](source::ChunkSource) abstracts where chunks come from — +//! a mounted WAD ([`WadChunkSource`](source::WadChunkSource)), or a mod's +//! archive entries virtually merged over the original game WAD +//! ([`VirtualMerge`](source::VirtualMerge)) so archives never need to be +//! extracted to disk to be checked. +//! - [`resolve_bin_entry_with`](resolve::resolve_bin_entry_with) resolves a +//! bin entry the way the game does (root bin, then `linked` bins), and +//! *records* corrupt bins it encounters instead of only logging them. +//! - [`check_base_skin`](check::check_base_skin) produces the verdict as a +//! [`SkinCheckOutcome`](check::SkinCheckOutcome): skipped (vanilla base +//! skin), a [`ModAnomaly`](check::ModAnomaly) violation owned by the +//! mod, a [`ModifiedSkin`](check::ModifiedSkin) that passed, or a +//! [`BaselineAnomaly`](check::BaselineAnomaly) when the **original** +//! game WAD violates the assumptions — which is never the mod's fault +//! and must be reported separately (corrupt install, or a game patch +//! broke an assumption this crate bakes in). +//! +//! Report types expose hashes as plain integers — `u64` xxh64 chunk hashes, +//! `u32` fnv1a bin-entry hashes — never as [`ltk_hash`] wrapper types, so a +//! consumer pinned to a different `ltk_hash` version never hits a type +//! clash consuming them. The deliberate exception is the parsed skin +//! entries a passing check carries — [`ModifiedSkin::object`] (merged) and +//! [`ModifiedSkin::original_object`] (vanilla baseline) — which are +//! [`BinObject`]s, so a consumer that reads them *is* coupled to this +//! crate's `ltk_meta`. For that reason [`ltk_meta`] and [`BinObject`] are +//! re-exported here: go through this crate's re-export rather than +//! depending on `ltk_meta` separately. Consumers that only read the +//! summarized fields stay decoupled as before. The re-exported +//! [`BinHash`]/[`WadHash`] are for constructing *inputs* to the +//! resolve/skin helpers; treat this crate's re-export as the source of +//! truth there. + +pub mod check; +pub mod resolve; +pub mod skin; +pub mod source; + +pub use check::{ + BaselineAnomaly, MeshRef, ModAnomaly, ModifiedSkin, RefMissingKind, RefStatus, + SkinCheckOutcome, check_base_skin, +}; +pub use resolve::{ + CorruptBin, MAX_LINKED_BINS, ResolveError, ResolveOutcome, ResolvedBinObject, + resolve_bin_entry_with, +}; +pub use skin::{ + MeshSlot, SkinMeshRefs, champion_from_wad_path, skin_character_data_class, skin_mesh_refs, + skin0_bin_name_hash, skin0_bin_path, skin0_entry_hash, +}; +pub use source::{ChunkSource, VirtualMerge, WadChunkSource}; + +pub use ltk_hash::{BinHash, Hash, WadHash}; +pub use ltk_meta::{self, BinObject}; diff --git a/crates/ltk_sanitize/src/resolve.rs b/crates/ltk_sanitize/src/resolve.rs new file mode 100644 index 00000000..ebba44b6 --- /dev/null +++ b/crates/ltk_sanitize/src/resolve.rs @@ -0,0 +1,199 @@ +//! Bin-entry resolution over linked property-bins. +//! +//! Resolves a bin entry the way the game does: start at a root bin (e.g. +//! `data/characters/{champ}/skins/skin0.bin`), and if it does not define the +//! entry, follow its `linked` bins breadth-first within the same chunk +//! source until one does. +//! +//! Unlike a pure diagnostic walk, corrupt bins encountered along the way are +//! **recorded** in the [`ResolveOutcome`] rather than only logged, and the +//! walk continues — the entry may still be defined by a later linked bin. +//! What corruption the walk survived is worth is the caller's call: the +//! base-skin check reports it only when it is what kept the entry from being +//! found, and otherwise hands it on for a strict consumer (the in-game +//! verifier) to refuse to vouch on, or a reporting consumer (a mod manager) +//! to attach to its diagnostics. + +use std::collections::{HashSet, VecDeque}; +use std::io::Cursor; + +use ltk_hash::{BinHash, Hash as _, WadHash}; +use ltk_meta::{Bin, BinObject}; +use thiserror::Error; + +use crate::source::ChunkSource; + +/// Upper bound on the number of bins visited while following `linked` bins, +/// guarding against absurd or cyclic dependency graphs. +pub const MAX_LINKED_BINS: usize = 64; + +/// Why a bin entry could not be resolved from a root bin and its linked bins. +/// +/// Distinct outcomes so callers can report precisely: a missing root bin is +/// "nothing to verify here" (non-champion WAD), while a champion WAD whose +/// skin entry cannot be found is itself diagnostic-worthy. +/// +/// Entry/class hashes are plain `u32` fnv1a values, never `ltk_hash` types: +/// this error travels inside reports (and consumers' own error enums), which +/// must not couple consumers to this crate's `ltk_hash` version. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum ResolveError { + #[error("bin '{bin_path}' is not in the WAD")] + RootBinMissing { bin_path: String }, + + #[error("entry {entry:08x} not found in '{root}' or its linked bins")] + EntryNotFound { root: String, entry: u32 }, + + #[error("entry {entry:08x} in '{bin_path}' has class {class:08x}, expected {expected:08x}")] + WrongClass { + bin_path: String, + entry: u32, + class: u32, + expected: u32, + }, + + #[error("gave up resolving entry from '{root}': more than {limit} linked bins")] + TooManyLinkedBins { root: String, limit: usize }, +} + +/// A bin that is present in the chunk source but could not be read or parsed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CorruptBin { + /// The bin path as referenced (root path or a `linked` entry). + pub bin_path: String, + /// xxh64 chunk hash of `bin_path`. + pub name_hash: u64, + /// Human-readable load/parse failure. + pub reason: String, +} + +/// A bin entry resolved by walking a root bin and its linked bins. +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedBinObject { + /// The bin file that defines the entry. + pub bin_path: String, + /// xxh64 chunk hash of `bin_path`. + pub bin_name_hash: u64, + /// The entry object. + pub object: BinObject, +} + +/// The result of a resolve walk: the entry (or why it could not be found), +/// plus every corrupt bin encountered along the way. Corruption never aborts +/// the walk — the entry may still be defined by a later linked bin — but it +/// is always surfaced so callers can decide how much it matters. +#[derive(Debug, Clone, PartialEq)] +pub struct ResolveOutcome { + pub entry: Result, + pub corrupt: Vec, +} + +/// Resolve a bin entry the way the game does: walk the root bin and its +/// `linked` bins (breadth-first, within this chunk source) and return as +/// soon as a bin defines the entry. +/// +/// Linked bins absent from the source (e.g. references into Global) are +/// skipped with a debug log; bins that fail to load or parse are recorded in +/// [`ResolveOutcome::corrupt`] and skipped. When `expected_class` is given, +/// a found entry with a different class is a definitive +/// [`ResolveError::WrongClass`] (entry path hashes are unique across the +/// merged bin graph, so there is nothing further to search). +pub fn resolve_bin_entry_with( + source: &mut dyn ChunkSource, + root_bin_path: &str, + entry_hash: BinHash, + expected_class: Option, +) -> ResolveOutcome { + let mut corrupt = Vec::new(); + + if !source.contains(*WadHash::hash_str(root_bin_path)) { + return ResolveOutcome { + entry: Err(ResolveError::RootBinMissing { + bin_path: root_bin_path.to_owned(), + }), + corrupt, + }; + } + + let mut queue = VecDeque::from([root_bin_path.to_owned()]); + let mut visited: HashSet = HashSet::new(); + + while let Some(bin_path) = queue.pop_front() { + let bin_name_hash = *WadHash::hash_str(&bin_path); + if !visited.insert(bin_name_hash) { + continue; + } + if visited.len() > MAX_LINKED_BINS { + return ResolveOutcome { + entry: Err(ResolveError::TooManyLinkedBins { + root: root_bin_path.to_owned(), + limit: MAX_LINKED_BINS, + }), + corrupt, + }; + } + + if !source.contains(bin_name_hash) { + tracing::debug!("Linked bin '{bin_path}' is not in this WAD, skipping"); + continue; + } + let data = match source.load(bin_name_hash) { + Ok(data) => data, + Err(reason) => { + tracing::warn!("Failed to load bin '{bin_path}': {reason}"); + corrupt.push(CorruptBin { + bin_path, + name_hash: bin_name_hash, + reason, + }); + continue; + } + }; + let bin = match Bin::from_reader(&mut Cursor::new(&data[..])) { + Ok(bin) => bin, + Err(err) => { + tracing::warn!("Failed to parse bin '{bin_path}': {err}"); + corrupt.push(CorruptBin { + bin_path, + name_hash: bin_name_hash, + reason: format!("parse: {err}"), + }); + continue; + } + }; + + if let Some(object) = bin.get_object(entry_hash) { + if let Some(expected) = expected_class + && object.class_hash != expected + { + return ResolveOutcome { + entry: Err(ResolveError::WrongClass { + bin_path, + entry: *entry_hash, + class: *object.class_hash, + expected: *expected, + }), + corrupt, + }; + } + return ResolveOutcome { + entry: Ok(ResolvedBinObject { + bin_path, + bin_name_hash, + object: object.clone(), + }), + corrupt, + }; + } + + queue.extend(bin.dependencies.iter().cloned()); + } + + ResolveOutcome { + entry: Err(ResolveError::EntryNotFound { + root: root_bin_path.to_owned(), + entry: *entry_hash, + }), + corrupt, + } +} diff --git a/crates/ltk_sanitize/src/skin.rs b/crates/ltk_sanitize/src/skin.rs new file mode 100644 index 00000000..75187498 --- /dev/null +++ b/crates/ltk_sanitize/src/skin.rs @@ -0,0 +1,149 @@ +//! Base-skin (`skin0`) domain knowledge: entry paths, mesh reference +//! extraction, and champion WAD detection. + +use std::fmt; + +use ltk_hash::{BinHash, Hash as _}; +use ltk_meta::{BinObject, PropertyValueEnum}; + +/// Which of a skin's two checked mesh assets a reference or diagnostic +/// refers to. +/// +/// Both are required: every base-game `skin0` sets them (empirically +/// 172/172), so a skin without one is malformed. The entry's `Texture` +/// property is deliberately not checked — it is optional (material-override +/// skins like Evelynn, Mel, and Yuumi omit it) and a dangling texture +/// reference is a known authoring idiom for suppressing the vanilla base +/// texture, so it carries no correctness signal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeshSlot { + Skeleton, + SimpleSkin, +} + +impl fmt::Display for MeshSlot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + MeshSlot::Skeleton => "skeleton", + MeshSlot::SimpleSkin => "simple-skin", + }) + } +} + +/// Mesh asset references extracted from one `SkinCharacterDataProperties` +/// entry (its `SkinMeshProperties` embed). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkinMeshRefs { + /// fnv1a path hash of the entry (e.g. `Characters/Nautilus/Skins/Skin0`), + /// as a plain `u32` — report structs never expose `ltk_hash` types. + pub entry_hash: u32, + /// `SkinMeshProperties.Skeleton` (`.skl`). + pub skeleton: Option, + /// `SkinMeshProperties.SimpleSkin` (`.skn`). + pub simple_skin: Option, +} + +impl SkinMeshRefs { + /// The referenced path for one slot, `None` when the entry does not set + /// it. + pub fn slot_path(&self, slot: MeshSlot) -> Option<&str> { + match slot { + MeshSlot::Skeleton => self.skeleton.as_deref(), + MeshSlot::SimpleSkin => self.simple_skin.as_deref(), + } + } + + /// The set slots and their referenced paths, in a fixed order. + pub fn slots(&self) -> impl Iterator { + [ + (MeshSlot::Skeleton, self.skeleton.as_deref()), + (MeshSlot::SimpleSkin, self.simple_skin.as_deref()), + ] + .into_iter() + .filter_map(|(slot, path)| path.map(|p| (slot, p))) + } + + /// Slots the entry does not set — every checked slot is required. + pub fn missing_required_slots(&self) -> Vec { + let mut missing = Vec::new(); + if self.skeleton.is_none() { + missing.push(MeshSlot::Skeleton); + } + if self.simple_skin.is_none() { + missing.push(MeshSlot::SimpleSkin); + } + missing + } +} + +/// Extract the checked mesh references from one +/// `SkinCharacterDataProperties` object (its `SkinMeshProperties` embed). +/// The `Texture` property is deliberately not read (see [`MeshSlot`]). +/// Unset properties stay `None` — judging that is +/// [`SkinMeshRefs::missing_required_slots`]' job. +pub fn skin_mesh_refs(object: &BinObject) -> SkinMeshRefs { + let mesh_prop = BinHash::hash_str("SkinMeshProperties"); + + let mut refs = SkinMeshRefs { + entry_hash: *object.path_hash, + skeleton: None, + simple_skin: None, + }; + let mesh_properties = match object.properties.get(&mesh_prop) { + Some(PropertyValueEnum::Embedded(embedded)) => Some(&embedded.0.properties), + Some(PropertyValueEnum::Struct(inner)) => Some(&inner.properties), + _ => None, + }; + if let Some(props) = mesh_properties { + let string_of = |key: &str| match props.get(&BinHash::hash_str(key)) { + Some(PropertyValueEnum::String(s)) => Some(s.value.clone()), + _ => None, + }; + refs.skeleton = string_of("Skeleton"); + refs.simple_skin = string_of("SimpleSkin"); + } + refs +} + +/// The root bin the game resolves a champion's base skin from. +pub fn skin0_bin_path(champion: &str) -> String { + format!("data/characters/{champion}/skins/skin0.bin") +} + +/// xxh64 chunk hash of [`skin0_bin_path`], for looking the root bin up in a +/// WAD TOC or override set. +pub fn skin0_bin_name_hash(champion: &str) -> u64 { + use ltk_hash::{Hash as _, WadHash}; + *WadHash::hash_str(skin0_bin_path(champion)) +} + +/// The bin entry path hash of a champion's base skin. +pub fn skin0_entry_hash(champion: &str) -> BinHash { + BinHash::hash_str(format!("characters/{champion}/skins/skin0")) +} + +/// The class every skin entry must have. +pub fn skin_character_data_class() -> BinHash { + BinHash::hash_str("SkinCharacterDataProperties") +} + +/// The lowercase champion directory name for a champion WAD, or `None` for +/// anything that is not a champion WAD the base-skin check covers. +/// +/// `wad_path` is the game-relative WAD path (any case, any separators), +/// e.g. `DATA/FINAL/Champions/Aatrox.wad.client` → `aatrox`. Localized +/// champion WADs (`Aatrox.en_US.wad.client`) and WADs outside +/// `data/final/champions/` are rejected — this is the same scope the +/// in-game verifier scans. +pub fn champion_from_wad_path(wad_path: &str) -> Option { + let normalized = wad_path.replace('\\', "/").to_ascii_lowercase(); + let (dir, file) = normalized.rsplit_once('/')?; + if dir != "data/final/champions" && !dir.ends_with("/data/final/champions") { + return None; + } + let champion = file.strip_suffix(".wad.client")?; + if champion.is_empty() || champion.contains(['_', '.']) { + return None; + } + Some(champion.to_string()) +} diff --git a/crates/ltk_sanitize/src/source.rs b/crates/ltk_sanitize/src/source.rs new file mode 100644 index 00000000..27d3a019 --- /dev/null +++ b/crates/ltk_sanitize/src/source.rs @@ -0,0 +1,71 @@ +//! Chunk access abstraction the checks run over. +//! +//! The verification logic only ever asks two questions about a WAD-like +//! container: "is a chunk with this path hash present?" and "give me its +//! decompressed bytes". Notably it never reads TOC checksums — they are +//! declared by an untrusted WAD and prove nothing about the content (see +//! [`RefStatus`](crate::check::RefStatus)). Abstracting the two questions +//! behind [`ChunkSource`] lets the same checks run over a mounted WAD file +//! (built overlays, original game WADs, the in-game verifier's +//! memory-mapped pairs) and over a mod archive virtually merged onto the +//! original WAD — so untrusted archives can be checked without extracting +//! them to disk. + +use std::io::{Read, Seek}; + +use ltk_wad::Wad; + +/// Read access to one WAD-like set of chunks, keyed by xxh64 path hash. +pub trait ChunkSource { + /// Whether a chunk with this name hash is present. + fn contains(&mut self, name_hash: u64) -> bool; + + /// Decompressed chunk bytes. `Err` carries a human-readable reason for a + /// chunk that is present but cannot be read (corruption); callers decide + /// whether that is fatal. + fn load(&mut self, name_hash: u64) -> Result, String>; +} + +/// [`ChunkSource`] over a mounted [`Wad`]. +pub struct WadChunkSource<'a, TSource: Read + Seek>(pub &'a mut Wad); + +impl ChunkSource for WadChunkSource<'_, TSource> { + fn contains(&mut self, name_hash: u64) -> bool { + self.0.chunks().get(name_hash).is_some() + } + + fn load(&mut self, name_hash: u64) -> Result, String> { + let chunk = self + .0 + .chunks() + .get(name_hash) + .copied() + .ok_or_else(|| "chunk not present in WAD".to_string())?; + self.0 + .load_chunk_decompressed(&chunk) + .map(|data| data.into_vec()) + .map_err(|err| err.to_string()) + } +} + +/// Two sources layered: `overlay` wins, `base` fills the rest — the merged +/// view of a mod's chunks on top of the original game WAD, without building +/// (or extracting) anything. +pub struct VirtualMerge<'a> { + pub overlay: &'a mut dyn ChunkSource, + pub base: &'a mut dyn ChunkSource, +} + +impl ChunkSource for VirtualMerge<'_> { + fn contains(&mut self, name_hash: u64) -> bool { + self.overlay.contains(name_hash) || self.base.contains(name_hash) + } + + fn load(&mut self, name_hash: u64) -> Result, String> { + if self.overlay.contains(name_hash) { + self.overlay.load(name_hash) + } else { + self.base.load(name_hash) + } + } +} diff --git a/crates/ltk_sanitize/tests/check.rs b/crates/ltk_sanitize/tests/check.rs new file mode 100644 index 00000000..61eaa779 --- /dev/null +++ b/crates/ltk_sanitize/tests/check.rs @@ -0,0 +1,604 @@ +//! Integration tests for the base-skin check, one per invariant, driving +//! everything through the public API over real in-memory WADs. + +use indexmap::IndexMap; +use ltk_sanitize::ltk_meta::property::{NoMeta, values}; +use ltk_sanitize::ltk_meta::{Bin, PropertyValueEnum}; +use ltk_sanitize::{ + BaselineAnomaly, BinHash, BinObject, ChunkSource, Hash as _, MeshSlot, ModAnomaly, + ModifiedSkin, RefMissingKind, RefStatus, ResolveError, SkinCheckOutcome, WadChunkSource, + WadHash, champion_from_wad_path, check_base_skin, +}; +use ltk_wad::Wad; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::io::{Cursor, Write}; + +const CHAMP: &str = "testchamp"; +const ROOT: &str = "data/characters/testchamp/skins/skin0.bin"; +const CONCAT: &str = "data/testchamp_skin0_concat.bin"; +const BROKEN: &str = "data/testchamp_skin0_broken.bin"; +const SKL: &str = "ASSETS/Characters/Testchamp/Skins/Skin01/body.skl"; +const SKN: &str = "ASSETS/Characters/Testchamp/Skins/Skin01/body.skn"; +const TEX: &str = "ASSETS/Characters/Testchamp/Skins/Skin01/body_TX_CM.tex"; +const NO_WORLD: Option<&dyn Fn(u64) -> Vec> = None; + +fn h(name: &str) -> BinHash { + BinHash::hash_str(name) +} + +fn chunk_hash(path: &str) -> u64 { + *WadHash::hash_str(path) +} + +/// A skin0 bin whose entry references the given slot paths; `scale` varies +/// the bytes so tests can produce a "modded" variant. A Texture property is +/// still written when given — the check must ignore it. +fn skin_bin( + skeleton: Option<&str>, + simple_skin: Option<&str>, + texture: Option<&str>, + scale: f32, +) -> Vec { + let mut properties = IndexMap::new(); + if let Some(path) = skeleton { + properties.insert(h("Skeleton"), values::String::from(path).into()); + } + if let Some(path) = simple_skin { + properties.insert(h("SimpleSkin"), values::String::from(path).into()); + } + if let Some(path) = texture { + properties.insert(h("Texture"), values::String::from(path).into()); + } + properties.insert(h("SkinScale"), values::F32::new(scale).into()); + let mesh = values::Embedded(values::Struct { + class_hash: h("SkinMeshDataProperties"), + properties, + meta: NoMeta, + }); + let entry = BinObject::::builder( + h("Characters/Testchamp/Skins/Skin0"), + h("SkinCharacterDataProperties"), + ) + .property(h("SkinMeshProperties"), mesh) + .build(); + bin_bytes(&Bin::builder().object(entry).build()) +} + +fn bin_bytes(bin: &Bin) -> Vec { + let mut cursor = Cursor::new(Vec::new()); + bin.to_writer(&mut cursor).unwrap(); + cursor.into_inner() +} + +/// Build an in-memory WAD from `path hash -> uncompressed contents`. +fn build_wad(contents: &BTreeMap>) -> Wad>> { + use ltk_wad::{WadBuilder, WadChunkBuilder}; + + let mut builder = WadBuilder::default(); + for &hash in contents.keys() { + builder = builder.with_chunk(WadChunkBuilder::default().with_hash(hash)); + } + let mut out = Cursor::new(Vec::new()); + builder + .build_to_writer(&mut out, |hash, cursor| { + cursor.write_all(&contents[&hash]).unwrap(); + Ok(()) + }) + .unwrap(); + out.set_position(0); + Wad::mount(out).unwrap() +} + +/// A valid original: skin0 references all slots and every asset exists. +fn original_contents() -> BTreeMap> { + BTreeMap::from([ + ( + chunk_hash(ROOT), + skin_bin(Some(SKL), Some(SKN), Some(TEX), 1.0), + ), + (chunk_hash(SKL), b"skeleton-data".to_vec()), + (chunk_hash(SKN), b"mesh-data".to_vec()), + (chunk_hash(TEX), b"texture-data".to_vec()), + ]) +} + +fn check( + original: &BTreeMap>, + merged: &BTreeMap>, + world: Option<&dyn Fn(u64) -> Vec>, +) -> SkinCheckOutcome { + let mut original = build_wad(original); + let mut merged = build_wad(merged); + check_base_skin( + &mut WadChunkSource(&mut original), + &mut WadChunkSource(&mut merged), + CHAMP, + world, + ) +} + +fn modified(outcome: SkinCheckOutcome) -> ModifiedSkin { + match outcome { + SkinCheckOutcome::Modified(skin) => *skin, + other => panic!("expected Modified, got {other:?}"), + } +} + +fn mod_anomaly(outcome: SkinCheckOutcome) -> ModAnomaly { + match outcome { + SkinCheckOutcome::ModAnomaly(anomaly) => anomaly, + other => panic!("expected ModAnomaly, got {other:?}"), + } +} + +/// `SkinMeshProperties.SkinScale`, which the fixtures vary per side — proof +/// a parsed entry came from the side it claims. +fn skin_scale(object: &BinObject) -> f32 { + let Some(PropertyValueEnum::Embedded(mesh)) = object.properties.get(&h("SkinMeshProperties")) + else { + panic!("no SkinMeshProperties embed"); + }; + match mesh.0.properties.get(&h("SkinScale")) { + Some(PropertyValueEnum::F32(scale)) => scale.value, + other => panic!("unexpected SkinScale: {other:?}"), + } +} + +/// Wraps a source, refusing to load one chunk — models a chunk that is +/// present in the TOC but unreadable (corruption). +struct Unreadable { + inner: S, + chunk: u64, +} + +impl ChunkSource for Unreadable { + fn contains(&mut self, name_hash: u64) -> bool { + self.inner.contains(name_hash) + } + fn load(&mut self, name_hash: u64) -> Result, String> { + if name_hash == self.chunk { + return Err("simulated unreadable chunk".to_string()); + } + self.inner.load(name_hash) + } +} + +// ----------------------------------------------------------------- skip + +#[test] +fn unmodified_skin0_is_skipped() { + let original = original_contents(); + let mut merged = original.clone(); + // Even a modified texture chunk: the root bin itself is vanilla. + merged.insert(chunk_hash(TEX), b"MODDED-texture".to_vec()); + + assert_eq!( + check(&original, &merged, NO_WORLD), + SkinCheckOutcome::SkippedUnmodified + ); +} + +// ------------------------------------------------------------- modified + +#[test] +fn modified_skin_carries_objects_and_fingerprints() { + let original = original_contents(); + let mut merged = original.clone(); + merged.insert( + chunk_hash(ROOT), + skin_bin(Some(SKL), Some(SKN), Some(TEX), 2.0), + ); + merged.insert(chunk_hash(SKN), b"MODDED-mesh".to_vec()); + + let skin = modified(check(&original, &merged, NO_WORLD)); + assert_eq!(skin.bin_path, ROOT); + // Both parsed entries, genuinely from their own side. + assert_eq!(skin_scale(&skin.object), 2.0); + assert_eq!(skin_scale(&skin.original_object), 1.0); + assert_eq!(skin.skeleton.status, RefStatus::Unmodified); + assert_eq!( + skin.simple_skin.status, + RefStatus::Modified { + sha256: Sha256::digest(b"MODDED-mesh").into(), + } + ); +} + +#[test] +fn repointed_slot_reads_modified() { + // The skin-unlock shape: a slot repointed at another vanilla asset + // resolves and holds vanilla bytes, but not the bytes the original + // SLOT renders — slot-to-slot comparison must read Modified. + let alt = "ASSETS/Characters/Testchamp/Skins/Skin02/alt.skl"; + let mut original = original_contents(); + original.insert(chunk_hash(alt), b"alt-skeleton-data".to_vec()); + let mut merged = original.clone(); + merged.insert( + chunk_hash(ROOT), + skin_bin(Some(alt), Some(SKN), Some(TEX), 2.0), + ); + + let skin = modified(check(&original, &merged, NO_WORLD)); + assert_eq!( + skin.skeleton.status, + RefStatus::Modified { + sha256: Sha256::digest(b"alt-skeleton-data").into(), + } + ); +} + +#[test] +fn repointed_slot_with_identical_content_reads_unmodified() { + // Content decides, not the path: a repoint landing on byte-identical + // content renders exactly what vanilla renders. + let alias = "ASSETS/Characters/Testchamp/Skins/Skin02/copy.skl"; + let mut original = original_contents(); + original.insert(chunk_hash(alias), b"skeleton-data".to_vec()); + let mut merged = original.clone(); + merged.insert( + chunk_hash(ROOT), + skin_bin(Some(alias), Some(SKN), Some(TEX), 2.0), + ); + + let skin = modified(check(&original, &merged, NO_WORLD)); + assert_eq!(skin.skeleton.status, RefStatus::Unmodified); +} + +#[test] +fn unreadable_original_still_classifies_modified() { + // An unreadable ORIGINAL chunk is never the mod's problem: equality + // just cannot be proven, so the merged chunk reads Modified. + let contents = original_contents(); + let mut merged_contents = contents.clone(); + merged_contents.insert( + chunk_hash(ROOT), + skin_bin(Some(SKL), Some(SKN), Some(TEX), 2.0), + ); + + let mut original = build_wad(&contents); + let mut original = Unreadable { + inner: WadChunkSource(&mut original), + chunk: chunk_hash(SKL), + }; + let mut merged = build_wad(&merged_contents); + + let outcome = check_base_skin( + &mut original, + &mut WadChunkSource(&mut merged), + CHAMP, + NO_WORLD, + ); + assert!(matches!( + modified(outcome).skeleton.status, + RefStatus::Modified { .. } + )); +} + +#[test] +fn dangling_texture_is_ignored() { + // The Texture property is never parsed — a dangling texture reference + // is not a violation. + let original = original_contents(); + let mut merged = original.clone(); + merged.insert( + chunk_hash(ROOT), + skin_bin(Some(SKL), Some(SKN), Some("gone.tex"), 2.0), + ); + + assert!(matches!( + check(&original, &merged, NO_WORLD), + SkinCheckOutcome::Modified(_) + )); +} + +#[test] +fn entry_found_via_linked_bin() { + let original = original_contents(); + let mut merged = original.clone(); + merged.insert( + chunk_hash(ROOT), + bin_bytes(&Bin::builder().dependency(CONCAT).build()), + ); + merged.insert( + chunk_hash(CONCAT), + skin_bin(Some(SKL), Some(SKN), Some(TEX), 2.0), + ); + + let skin = modified(check(&original, &merged, NO_WORLD)); + assert_eq!(skin.bin_path, CONCAT); +} + +// ---------------------------------------------------------- mod anomaly + +#[test] +fn dangling_skeleton_is_missing_everywhere() { + let original = original_contents(); + let mut merged = original.clone(); + merged.insert( + chunk_hash(ROOT), + skin_bin(Some("gone.skl"), Some(SKN), Some(TEX), 2.0), + ); + + let anomaly = mod_anomaly(check(&original, &merged, Some(&|_| Vec::new()))); + assert!(matches!( + anomaly, + ModAnomaly::RefMissing { + slot: MeshSlot::Skeleton, + kind: RefMissingKind::Everywhere, + .. + } + )); + assert!(anomaly.to_string().contains("broken or outdated")); +} + +#[test] +fn misplaced_ref_names_the_wad_that_has_it() { + let custom = "ASSETS/Characters/Testchamp/Skins/Base/custom.skn"; + let original = original_contents(); + let mut merged = original.clone(); + merged.insert( + chunk_hash(ROOT), + skin_bin(Some(SKL), Some(custom), Some(TEX), 2.0), + ); + + let world = |hash: u64| { + if hash == chunk_hash(custom) { + vec!["Testchamp.en_US.wad.client".to_string()] + } else { + Vec::new() + } + }; + let anomaly = mod_anomaly(check(&original, &merged, Some(&world))); + assert!(matches!( + &anomaly, + ModAnomaly::RefMissing { kind: RefMissingKind::Misplaced { found_in }, .. } + if found_in == &["Testchamp.en_US.wad.client".to_string()] + )); + assert!(anomaly.to_string().contains("wrong WAD")); +} + +#[test] +fn unreadable_ref_fails_closed() { + // Present in the TOC but unreadable: fails closed as RefMissing. + let contents = original_contents(); + let mut merged_contents = contents.clone(); + merged_contents.insert( + chunk_hash(ROOT), + skin_bin(Some(SKL), Some(SKN), Some(TEX), 2.0), + ); + + let mut original = build_wad(&contents); + let mut merged = build_wad(&merged_contents); + let mut merged = Unreadable { + inner: WadChunkSource(&mut merged), + chunk: chunk_hash(SKN), + }; + + let outcome = check_base_skin( + &mut WadChunkSource(&mut original), + &mut merged, + CHAMP, + NO_WORLD, + ); + assert!(matches!( + mod_anomaly(outcome), + ModAnomaly::RefMissing { + slot: MeshSlot::SimpleSkin, + kind: RefMissingKind::Unreadable { .. }, + .. + } + )); +} + +#[test] +fn unset_required_slot_is_a_mod_anomaly() { + let original = original_contents(); + let mut merged = original.clone(); + merged.insert(chunk_hash(ROOT), skin_bin(None, Some(SKN), Some(TEX), 2.0)); + + assert_eq!( + mod_anomaly(check(&original, &merged, NO_WORLD)), + ModAnomaly::MissingRequiredSlot(MeshSlot::Skeleton) + ); +} + +#[test] +fn corrupt_merged_bin_is_a_mod_anomaly() { + let original = original_contents(); + let mut merged = original.clone(); + merged.insert(chunk_hash(ROOT), b"not a property bin".to_vec()); + + assert!(matches!( + mod_anomaly(check(&original, &merged, NO_WORLD)), + ModAnomaly::CorruptBin(_) + )); +} + +/// A root bin that defines nothing and links `BROKEN` (unreadable) before +/// `CONCAT` (which defines the entry): the walk records the corruption, +/// keeps going, and resolves. +fn contents_with_corrupt_link_before(entry_bin: Vec) -> BTreeMap> { + let mut contents = original_contents(); + contents.insert( + chunk_hash(ROOT), + bin_bytes(&Bin::builder().dependency(BROKEN).dependency(CONCAT).build()), + ); + contents.insert(chunk_hash(BROKEN), b"not a property bin".to_vec()); + contents.insert(chunk_hash(CONCAT), entry_bin); + contents +} + +#[test] +fn corrupt_linked_bin_does_not_sink_a_resolved_skin() { + // The entry resolved from a readable bin, so the corruption is carried + // on the result for the consumer to weigh -- not a mod anomaly. + let original = original_contents(); + let merged = contents_with_corrupt_link_before(skin_bin(Some(SKL), Some(SKN), Some(TEX), 2.0)); + + let skin = modified(check(&original, &merged, NO_WORLD)); + assert_eq!(skin.bin_path, CONCAT); + assert_eq!(skin_scale(&skin.object), 2.0); + assert_eq!( + skin.corrupt_bins + .iter() + .map(|c| c.bin_path.as_str()) + .collect::>(), + vec![BROKEN] + ); +} + +#[test] +fn a_clean_resolve_carries_no_corrupt_bins() { + let original = original_contents(); + let mut merged = original.clone(); + merged.insert( + chunk_hash(ROOT), + skin_bin(Some(SKL), Some(SKN), Some(TEX), 2.0), + ); + + assert!( + modified(check(&original, &merged, NO_WORLD)) + .corrupt_bins + .is_empty() + ); +} + +#[test] +fn corrupt_linked_bin_does_not_mask_a_definitive_resolve_error() { + // WrongClass is a verdict the walk reached on its own: the entry WAS + // found. Reporting the incidental corruption instead would name the + // wrong cause. + let original = original_contents(); + let wrong_class = bin_bytes( + &Bin::builder() + .object( + BinObject::::builder(h("Characters/Testchamp/Skins/Skin0"), h("NotASkin")) + .build(), + ) + .build(), + ); + let merged = contents_with_corrupt_link_before(wrong_class); + + assert!(matches!( + mod_anomaly(check(&original, &merged, NO_WORLD)), + ModAnomaly::Resolve(ResolveError::WrongClass { .. }) + )); +} + +#[test] +fn corrupt_linked_bin_explains_an_unfound_entry() { + // Nothing defines the entry, and an unreadable bin is the likeliest + // place it went: report that, not the bare EntryNotFound. + let original = original_contents(); + let merged = contents_with_corrupt_link_before(bin_bytes(&Bin::builder().build())); + + let anomaly = mod_anomaly(check(&original, &merged, NO_WORLD)); + let ModAnomaly::CorruptBin(corrupt) = anomaly else { + panic!("expected CorruptBin, got {anomaly:?}"); + }; + assert_eq!(corrupt.bin_path, BROKEN); +} + +#[test] +fn linked_bin_cycles_terminate() { + let original = original_contents(); + let mut merged = original.clone(); + merged.insert( + chunk_hash(ROOT), + bin_bytes(&Bin::builder().dependency(CONCAT).build()), + ); + merged.insert( + chunk_hash(CONCAT), + bin_bytes(&Bin::builder().dependency(ROOT).build()), + ); + + assert!(matches!( + mod_anomaly(check(&original, &merged, NO_WORLD)), + ModAnomaly::Resolve(ResolveError::EntryNotFound { .. }) + )); +} + +// ------------------------------------------------------------- baseline + +#[test] +fn corrupt_original_is_a_baseline_anomaly() { + let mut original = original_contents(); + original.insert(chunk_hash(ROOT), b"garbage original".to_vec()); + let mut merged = original_contents(); + merged.insert( + chunk_hash(ROOT), + skin_bin(Some(SKL), Some(SKN), Some(TEX), 2.0), + ); + + assert!(matches!( + check(&original, &merged, NO_WORLD), + SkinCheckOutcome::BaselineAnomaly(BaselineAnomaly::OriginalCorruptBin(_)) + )); +} + +#[test] +fn corrupt_bin_the_original_resolved_past_is_not_a_baseline_anomaly() { + // Mirror of the merged side: the baseline entry resolved from a + // readable linked bin, so the corrupt one is logged, not blamed -- and + // the mod still gets judged. + let original = + contents_with_corrupt_link_before(skin_bin(Some(SKL), Some(SKN), Some(TEX), 1.0)); + let mut merged = original.clone(); + merged.insert( + chunk_hash(ROOT), + skin_bin(Some(SKL), Some(SKN), Some(TEX), 2.0), + ); + + let skin = modified(check(&original, &merged, NO_WORLD)); + assert_eq!(skin.bin_path, ROOT); + assert_eq!(skin_scale(&skin.object), 2.0); + // The baseline came from the bin past the corrupt one. + assert_eq!(skin_scale(&skin.original_object), 1.0); + assert!(skin.corrupt_bins.is_empty()); +} + +#[test] +fn original_missing_required_slot_is_a_baseline_anomaly() { + // The 172/172 assumption: a game patch shipping a skin0 without a + // skeleton must be reported to us, never blamed on the mod. + let mut original = original_contents(); + original.insert(chunk_hash(ROOT), skin_bin(None, Some(SKN), Some(TEX), 1.0)); + let mut merged = original.clone(); + merged.insert( + chunk_hash(ROOT), + skin_bin(Some(SKL), Some(SKN), Some(TEX), 2.0), + ); + + assert!(matches!( + check(&original, &merged, NO_WORLD), + SkinCheckOutcome::BaselineAnomaly(BaselineAnomaly::OriginalMissingRequiredSlot( + MeshSlot::Skeleton + )) + )); +} + +// ----------------------------------------------------------- wad scope + +#[test] +fn champion_wad_detection() { + assert_eq!( + champion_from_wad_path("DATA/FINAL/Champions/Aatrox.wad.client").as_deref(), + Some("aatrox") + ); + assert_eq!( + champion_from_wad_path("data\\final\\champions\\Nautilus.wad.client").as_deref(), + Some("nautilus") + ); + // Localized champion WADs, non-champion WADs, and bare filenames are + // out of scope. + assert_eq!( + champion_from_wad_path("DATA/FINAL/Champions/Aatrox.en_US.wad.client"), + None + ); + assert_eq!( + champion_from_wad_path("DATA/FINAL/Maps/Shipping/Map11.wad.client"), + None + ); + assert_eq!(champion_from_wad_path("Aatrox.wad.client"), None); +}