Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ members = [
"crates/ltk_modpkg",
"crates/ltk_mod_project",
"crates/ltk_overlay",
"crates/ltk_sanitize",
]

[workspace.dependencies]
Expand Down
2 changes: 2 additions & 0 deletions crates/league-mod/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions crates/league-mod/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
133 changes: 133 additions & 0 deletions crates/league-mod/src/commands/sanitize.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

/// 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<Box<dyn ModContentProvider>> {
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<Utf8PathBuf>) -> miette::Result<Utf8PathBuf> {
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)"
))
}
20 changes: 19 additions & 1 deletion crates/league-mod/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -73,6 +74,16 @@ pub enum Commands {
#[arg(short, long)]
output_dir: Option<String>,
},
/// 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<String>,
},
/// Manage application configuration
Config {
#[command(subcommand)]
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions crates/ltk_overlay/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"] }
Loading
Loading