From 0d85857f99a301ad35e69b02b608bb8d41766896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AE=B8=E6=9D=B0=E5=8F=8B=20Jieyou=20Xu=20=28Joe=29?= Date: Sat, 2 Mar 2024 14:02:52 +0000 Subject: [PATCH] Implement auto-verification of non-gameplay-affecting mods Also includes an auto-verification mod lint to check if mods are elligible for auto-verification. --- Cargo.lock | 8 +- Cargo.toml | 2 +- hook/src/hooks.rs | 4 +- hook/src/lib.rs | 2 + mint_lib/src/mod_info.rs | 10 +- src/gui/message.rs | 1 + src/gui/mod.rs | 196 ++++++++++++++++++----------- src/integrate.rs | 167 +++++++++++++++++++++++- src/lib.rs | 1 + src/mod_lints/auto_verification.rs | 174 +++++++++++++++++++++++++ src/mod_lints/mod.rs | 17 +++ src/providers/cache.rs | 47 ++++++- src/providers/mod_store.rs | 17 +++ src/state/mod.rs | 4 +- 14 files changed, 557 insertions(+), 93 deletions(-) create mode 100644 src/mod_lints/auto_verification.rs diff --git a/Cargo.lock b/Cargo.lock index f5fbaefa..94f3616a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4357,18 +4357,18 @@ dependencies = [ [[package]] name = "snafu" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d342c51730e54029130d7dc9fd735d28c4cd360f1368c01981d4f03ff207f096" +checksum = "5ed22871b3fe6eff9f1b48f6cbd54149ff8e9acd740dea9146092435f9c43bd3" dependencies = [ "snafu-derive", ] [[package]] name = "snafu-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080c44971436b1af15d6f61ddd8b543995cf63ab8e677d46b00cc06f4ef267a0" +checksum = "4651148226ec36010993fcba6c3381552e8463e9f3e337b75af202b0688b5274" dependencies = [ "heck", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 04e51241..f5098f11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,7 +87,7 @@ repak.workspace = true include_dir = "0.7.3" postcard.workspace = true fs-err.workspace = true -snafu = "0.8.0" +snafu = "0.8.1" [target.'cfg(target_env = "msvc")'.dependencies] hook = { path = "hook", artifact = "cdylib", optional = true, target = "x86_64-pc-windows-msvc"} diff --git a/hook/src/hooks.rs b/hook/src/hooks.rs index 2aa18a7b..9ae60ae1 100644 --- a/hook/src/hooks.rs +++ b/hook/src/hooks.rs @@ -63,7 +63,9 @@ pub unsafe fn initialize() -> Result<()> { )?; HookUFunctionBind.enable()?; - if let Ok(server_name) = &globals().resolution.server_name { + if let Ok(server_name) = &globals().resolution.server_name + && globals().meta.mods.iter().any(|m| m.gameplay_affecting) + { GetServerName .initialize( std::mem::transmute(server_name.get_server_name.0), diff --git a/hook/src/lib.rs b/hook/src/lib.rs index 71b653a4..91e3b0f6 100644 --- a/hook/src/lib.rs +++ b/hook/src/lib.rs @@ -1,3 +1,5 @@ +#![feature(let_chains)] + mod hooks; mod ue; diff --git a/mint_lib/src/mod_info.rs b/mint_lib/src/mod_info.rs index b6991389..e3dae82f 100644 --- a/mint_lib/src/mod_info.rs +++ b/mint_lib/src/mod_info.rs @@ -29,7 +29,7 @@ pub enum ApprovalStatus { } /// Whether a mod can be resolved by clients or not -#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd, Hash)] +#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd, Hash, Serialize, Deserialize)] pub enum ResolvableStatus { Unresolvable(String), Resolvable, @@ -112,11 +112,13 @@ impl ModIdentifier { Self(s) } } + impl From for ModIdentifier { fn from(value: String) -> Self { Self::new(value) } } + impl From<&str> for ModIdentifier { fn from(value: &str) -> Self { Self::new(value.to_owned()) @@ -130,21 +132,25 @@ pub struct Meta { pub mods: Vec, pub config: MetaConfig, } + #[derive(Debug, Serialize, Deserialize)] pub struct MetaConfig { pub disable_fix_exploding_gas: bool, } + #[derive(Debug, Serialize, Deserialize)] pub struct SemverVersion { pub major: u32, pub minor: u32, pub patch: u32, } + impl Display for SemverVersion { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}.{}.{}", self.major, self.minor, self.patch) } } + #[derive(Debug, Serialize, Deserialize)] pub struct MetaMod { pub name: String, @@ -153,7 +159,9 @@ pub struct MetaMod { pub author: String, pub approval: ApprovalStatus, pub required: bool, + pub gameplay_affecting: bool, } + impl Meta { pub fn to_server_list_string(&self) -> String { use itertools::Itertools; diff --git a/src/gui/message.rs b/src/gui/message.rs index 5ab475c4..7c0e1d50 100644 --- a/src/gui/message.rs +++ b/src/gui/message.rs @@ -414,6 +414,7 @@ async fn integrate_async( crate::integrate::integrate( fsd_pak, config, + store, to_integrate.into_iter().zip(paths).collect(), ) }) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 84a20233..f0a879a4 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -139,6 +139,7 @@ struct LintOptions { non_asset_files: bool, split_asset_pairs: bool, unmodified_game_assets: bool, + auto_verification: bool, } enum LastActionStatus { @@ -241,6 +242,68 @@ impl App { }) .collect::>(); + let mk_searchable_tag = |ctx: &mut Ctx, + tag_str: &str, + ui: &mut Ui, + color: Option, + hover_str: Option<&str>| { + let text_color = if color.is_some() { + Color32::BLACK + } else { + Color32::GRAY + }; + let mut job = LayoutJob::default(); + let mut is_match = false; + if let Some(search_string) = &self.search_string { + for (m, chunk) in find_string::FindString::new(tag_str, search_string) { + let background = if m { + is_match = true; + TextFormat { + background: Color32::YELLOW, + color: text_color, + ..Default::default() + } + } else { + TextFormat { + color: text_color, + ..Default::default() + } + }; + job.append(chunk, 0.0, background); + } + } else { + job.append( + tag_str, + 0.0, + TextFormat { + color: text_color, + ..Default::default() + }, + ); + } + + let button = if let Some(color) = color { + egui::Button::new(job) + .small() + .fill(color) + .stroke(egui::Stroke::NONE) + } else { + egui::Button::new(job).small().stroke(egui::Stroke::NONE) + }; + + let res = if let Some(hover_str) = hover_str { + ui.add_enabled(false, button) + .on_disabled_hover_text(hover_str) + } else { + ui.add_enabled(false, button) + }; + + if is_match && self.scroll_to_match { + res.scroll_to_me(None); + ctx.scroll_to_match = false; + } + }; + let ui_mod_tags = |ctx: &mut Ctx, ui: &mut Ui, info: &ModInfo| { if let Some(ModioTags { qol, @@ -253,73 +316,10 @@ impl App { versions: _, }) = info.modio_tags.as_ref() { - let mut mk_searchable_modio_tag = - |tag_str: &str, - ui: &mut Ui, - color: Option, - hover_str: Option<&str>| { - let text_color = if color.is_some() { - Color32::BLACK - } else { - Color32::GRAY - }; - let mut job = LayoutJob::default(); - let mut is_match = false; - if let Some(search_string) = &self.search_string { - for (m, chunk) in - find_string::FindString::new(tag_str, search_string) - { - let background = if m { - is_match = true; - TextFormat { - background: Color32::YELLOW, - color: text_color, - ..Default::default() - } - } else { - TextFormat { - color: text_color, - ..Default::default() - } - }; - job.append(chunk, 0.0, background); - } - } else { - job.append( - tag_str, - 0.0, - TextFormat { - color: text_color, - ..Default::default() - }, - ); - } - - let button = if let Some(color) = color { - egui::Button::new(job) - .small() - .fill(color) - .stroke(egui::Stroke::NONE) - } else { - egui::Button::new(job).small().stroke(egui::Stroke::NONE) - }; - - let res = if let Some(hover_str) = hover_str { - ui.add_enabled(false, button) - .on_disabled_hover_text(hover_str) - } else { - ui.add_enabled(false, button) - }; - - if is_match && self.scroll_to_match { - res.scroll_to_me(None); - ctx.scroll_to_match = false; - } - }; - match approval_status { ApprovalStatus::Verified => { - mk_searchable_modio_tag( + mk_searchable_tag( + ctx, "Verified", ui, Some(egui::Color32::LIGHT_GREEN), @@ -327,7 +327,8 @@ impl App { ); } ApprovalStatus::Approved => { - mk_searchable_modio_tag( + mk_searchable_tag( + ctx, "Approved", ui, Some(egui::Color32::LIGHT_BLUE), @@ -335,13 +336,20 @@ impl App { ); } ApprovalStatus::Sandbox => { - mk_searchable_modio_tag("Sandbox", ui, Some(egui::Color32::LIGHT_YELLOW), Some("Contains significant, possibly progression breaking, changes to gameplay")); + mk_searchable_tag( + ctx, + "Sandbox", + ui, + Some(egui::Color32::LIGHT_YELLOW), + Some("Contains significant, possibly progression breaking, changes to gameplay") + ); } } match required_status { RequiredStatus::RequiredByAll => { - mk_searchable_modio_tag( + mk_searchable_tag( + ctx, "RequiredByAll", ui, Some(egui::Color32::LIGHT_RED), @@ -351,7 +359,8 @@ impl App { ); } RequiredStatus::Optional => { - mk_searchable_modio_tag( + mk_searchable_tag( + ctx, "Optional", ui, None, @@ -361,20 +370,49 @@ impl App { } if *qol { - mk_searchable_modio_tag("QoL", ui, None, None); + mk_searchable_tag(ctx, "QoL", ui, None, None); } if *gameplay { - mk_searchable_modio_tag("Gameplay", ui, None, None); + mk_searchable_tag(ctx, "Gameplay", ui, None, None); } if *audio { - mk_searchable_modio_tag("Audio", ui, None, None); + mk_searchable_tag(ctx, "Audio", ui, None, None); } if *visual { - mk_searchable_modio_tag("Visual", ui, None, None); + mk_searchable_tag(ctx, "Visual", ui, None, None); } if *framework { - mk_searchable_modio_tag("Framework", ui, None, None); + mk_searchable_tag(ctx, "Framework", ui, None, None); } + } else if let Some(status) = self + .state + .store + .get_gameplay_affecting_status(&info.resolution.url) + { + match status { + false => mk_searchable_tag( + ctx, + "Verified", + ui, + Some(egui::Color32::LIGHT_GREEN), + Some("Does not contain any gameplay affecting features or changes"), + ), + true => mk_searchable_tag( + ctx, + "Not Verified", + ui, + Some(egui::Color32::LIGHT_RED), + Some("Contains gameplay affecting features or changes, or cannot be auto-verified"), + ), + } + } else { + mk_searchable_tag( + ctx, + "Not Verified", + ui, + Some(egui::Color32::LIGHT_RED), + Some("Contains gameplay affecting features or changes, or cannot be auto-verified"), + ); } }; @@ -1093,6 +1131,16 @@ impl App { "This lint requires DRG pak path to be specified", ); ui.end_row(); + + ui.label("Check if mods can be auto-verified"); + ui.add_enabled( + self.state.config.drg_pak_path.is_some(), + toggle_switch(&mut self.lint_options.auto_verification), + ) + .on_disabled_hover_text( + "This lint requires DRG pak path to be specified", + ); + ui.end_row(); }); }); diff --git a/src/integrate.rs b/src/integrate.rs index 89e4a5c4..db00411a 100644 --- a/src/integrate.rs +++ b/src/integrate.rs @@ -2,20 +2,22 @@ use std::collections::{HashMap, HashSet}; use std::ffi::{OsStr, OsString}; use std::io::{self, BufReader, BufWriter, Cursor, ErrorKind, Read, Seek}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use fs_err as fs; use repak::PakWriter; use serde::Deserialize; use snafu::{prelude::*, Whatever}; -use tracing::info; +use tracing::*; use uasset_utils::splice::{ extract_tracked_statements, inject_tracked_statements, walk, AssetVersion, TrackedStatement, }; +use unreal_asset::reader::ArchiveTrait; use crate::mod_lints::LintError; -use crate::providers::{ModInfo, ProviderError, ReadSeek}; -use mint_lib::mod_info::{ApprovalStatus, Meta, MetaConfig, MetaMod, SemverVersion}; +use crate::providers::{ModInfo, ModStore, ProviderError, ReadSeek}; +use mint_lib::mod_info::{ApprovalStatus, Meta, MetaConfig, MetaMod, ModioTags, SemverVersion}; use mint_lib::DRGInstallation; use unreal_asset::{ @@ -168,12 +170,12 @@ pub enum IntegrationError { RepakError { source: repak::Error }, #[snafu(transparent)] UnrealAssetError { source: unreal_asset::Error }, - #[snafu(display("mod {}: I/O error encountered during its processing", mod_info.name))] + #[snafu(display("mod {}: I/O error encountered during its processing: {source}", mod_info.name))] CtxtIoError { source: std::io::Error, mod_info: ModInfo, }, - #[snafu(display("mod {}: repak error encountered during its processing", mod_info.name))] + #[snafu(display("mod {}: repak error encountered during its processing: {source}", mod_info.name))] CtxtRepakError { source: repak::Error, mod_info: ModInfo, @@ -187,6 +189,11 @@ pub enum IntegrationError { ProviderError { source: ProviderError }, #[snafu(display("integration error: {msg}"))] GenericError { msg: &'static str }, + #[snafu(display("mod {}: integration error: {msg}"))] + CtxtGenericError { + msg: &'static str, + mod_info: ModInfo, + }, #[snafu(transparent)] JoinError { source: tokio::task::JoinError }, #[snafu(transparent)] @@ -212,6 +219,7 @@ impl IntegrationError { pub fn integrate>( path_pak: P, config: MetaConfig, + store: Arc, mods: Vec<(ModInfo, PathBuf)>, ) -> Result<(), IntegrationError> { let Ok(installation) = DRGInstallation::from_pak_path(&path_pak) else { @@ -236,6 +244,13 @@ pub fn integrate>( .into_iter() .map(PathBuf::from) .collect::>(); + + let fsd_lowercase_path_map = fsd_pak + .files() + .into_iter() + .map(|p| (p.to_ascii_lowercase(), p)) + .collect::>(); + let mut directories: HashMap = HashMap::new(); for f in &paths { let mut dir = &mut directories; @@ -398,10 +413,13 @@ pub fn integrate>( let mut added_paths = HashSet::new(); + let mut gameplay_affecting_results = HashMap::new(); + for (mod_info, path) in &mods { let raw_mod_file = fs::File::open(path).with_context(|_| CtxtIoSnafu { mod_info: mod_info.clone(), })?; + let mut buf = get_pak_from_data(Box::new(BufReader::new(raw_mod_file))).map_err(|e| { if let IntegrationError::IoError { source } = e { IntegrationError::CtxtIoError { @@ -412,12 +430,34 @@ pub fn integrate>( e } })?; + let pak = repak::PakBuilder::new() .reader(&mut buf) .with_context(|_| CtxtRepakSnafu { mod_info: mod_info.clone(), })?; + let gameplay_affecting = if let Some(ModioTags { + approval_status, .. + }) = mod_info.modio_tags + { + approval_status != ApprovalStatus::Verified + } else { + check_gameplay_affecting( + &fsd_lowercase_path_map, + &mut fsd_pak_reader, + &fsd_pak, + mod_info, + &mut buf, + &pak, + )? + }; + + debug!(?mod_info, ?gameplay_affecting); + + gameplay_affecting_results.insert(mod_info.resolution.url.clone(), gameplay_affecting); + store.update_gameplay_affecting_status(mod_info.resolution.url.clone(), gameplay_affecting); + let mount = Path::new(pak.mount_point()); for p in pak.files() { @@ -572,6 +612,13 @@ pub fn integrate>( .as_ref() .map(|t| t.approval_status) .unwrap_or(ApprovalStatus::Sandbox), + gameplay_affecting: match info.modio_tags.as_ref().map(|t| t.approval_status) { + Some(ApprovalStatus::Verified) => false, + Some(_) => true, + None => *gameplay_affecting_results + .get(&info.resolution.url) + .unwrap_or(&true), + }, }) .collect(), }; @@ -589,6 +636,116 @@ pub fn integrate>( Ok(()) } +pub fn check_gameplay_affecting( + fsd_lowercase_path_map: &HashMap, + fsd_pak: &mut F, + fsd_pak_reader: &repak::PakReader, + mod_info: &ModInfo, + mod_pak: &mut M, + mod_pak_reader: &repak::PakReader, +) -> Result +where + F: Read + Seek, + M: Read + Seek, +{ + let mount = Path::new(mod_pak_reader.mount_point()); + + let whitelist = [ + "SoundWave", + "SoundCue", + "SoundClass", + "SoundMix", + "MaterialInstanceConstant", + "Material", + "SkeletalMesh", + "StaticMesh", + "Texture2D", + "AnimSequence", + "Skeleton", + "StringTable", + ] + .into_iter() + .collect::>(); + + let check_asset = |data: Vec| -> Result { + debug!("check_asset"); + let asset = unreal_asset::AssetBuilder::new( + Cursor::new(data), + unreal_asset::engine_version::EngineVersion::VER_UE4_27, + ) + .skip_data(true) + .build()?; + + for export in &asset.asset_data.exports { + let base = export.get_base_export(); + // don't care about exported classes in this case + if base.outer_index.index == 0 + && base.class_index.is_import() + && !asset + .get_import(base.class_index) + .map(|import| import.object_name.get_content(|c| whitelist.contains(c))) + .unwrap_or(false) + { + // invalid import or import name is not whitelisted, unknown + return Ok(true); + }; + } + + Ok(false) + }; + + let mod_lowercase_path_map = mod_pak_reader + .files() + .into_iter() + .map(|p| -> Result<(String, String), IntegrationError> { + let j = mount.join(&p); + let new_path = j.strip_prefix("../../../").map_err(|_| { + IntegrationError::ModfileInvalidPrefix { + mod_info: mod_info.clone(), + modfile_path: PathBuf::from(p.clone()), + } + })?; + let new_path_str = &new_path.to_string_lossy().replace('\\', "/"); + + Ok((new_path_str.to_ascii_lowercase(), p)) + }) + .collect::, IntegrationError>>()?; + + for lower in mod_lowercase_path_map.keys() { + if let Some((base, ext)) = lower.rsplit_once('.') { + if ["uasset", "uexp", "umap", "ubulk", "ufont"].contains(&ext) { + let key_uasset = format!("{base}.uasset"); + let key_umap = format!("{base}.umap"); + // check mod pak for uasset or umap + // if not found, check fsd pak for uasset or umap + let asset = if let Some(path) = mod_lowercase_path_map.get(&key_uasset) { + mod_pak_reader.get(path, mod_pak)? + } else if let Some(path) = mod_lowercase_path_map.get(&key_umap) { + mod_pak_reader.get(path, mod_pak)? + } else if let Some(path) = fsd_lowercase_path_map.get(&key_uasset) { + fsd_pak_reader.get(path, fsd_pak)? + } else if let Some(path) = fsd_lowercase_path_map.get(&key_umap) { + fsd_pak_reader.get(path, fsd_pak)? + } else { + // not found, unknown + return Ok(true); + }; + + let asset_result = check_asset(asset.clone())?; + debug!(?asset_result); + + if asset_result { + debug!("GameplayAffecting: true"); + debug!("{:#?}", asset.clone()); + return Ok(true); + } + } + } + } + + Ok(false) +} + pub(crate) fn get_pak_from_data( mut data: Box, ) -> Result, IntegrationError> { diff --git a/src/lib.rs b/src/lib.rs index 475b8072..6d5536e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -144,6 +144,7 @@ pub async fn resolve_unordered_and_integrate>( integrate::integrate( game_path, state.config.deref().into(), + state.store.clone(), to_integrate.into_iter().zip(paths).collect(), ) } diff --git a/src/mod_lints/auto_verification.rs b/src/mod_lints/auto_verification.rs new file mode 100644 index 00000000..8755eaf3 --- /dev/null +++ b/src/mod_lints/auto_verification.rs @@ -0,0 +1,174 @@ +use std::collections::{HashMap, HashSet}; +use std::io::Seek; + +use fs_err as fs; +use tracing::*; + +use unreal_asset::exports::ExportBaseTrait; +use unreal_asset::reader::ArchiveTrait; + +use super::*; + +#[derive(Default)] +pub struct AutoVerificationLint; + +impl Lint for AutoVerificationLint { + type Output = BTreeMap>; + + fn check_mods(&mut self, lcx: &LintCtxt) -> Result { + let Some(game_pak_path) = &lcx.fsd_pak_path else { + InvalidGamePathSnafu.fail()? + }; + + let mut fsd_pak_file = fs::File::open(game_pak_path)?; + let fsd_pak = repak::PakBuilder::new().reader(&mut fsd_pak_file)?; + let mut fsd_pak_reader = BufReader::new(fsd_pak_file); + + let fsd_lowercase_path_map = fsd_pak + .files() + .into_iter() + .map(|p| (p.to_ascii_lowercase(), p)) + .collect::>(); + + let mut res = BTreeMap::new(); + + lcx.for_each_mod( + |mod_spec, mod_pak_seekable, mod_pak_reader| { + let mod_affecting_res = check_gameplay_affecting( + &fsd_lowercase_path_map, + &mut fsd_pak_reader, + &fsd_pak, + mod_pak_seekable, + mod_pak_reader, + )?; + if let ModGameplayAffectingResult::Yes(paths) = mod_affecting_res { + res.insert(mod_spec, paths); + } + Ok(()) + }, + None::, + None::, + None::, + )?; + + Ok(res) + } +} + +pub enum ModGameplayAffectingResult { + No, + Yes(BTreeSet), +} + +pub fn check_gameplay_affecting( + fsd_lowercase_path_map: &HashMap, + fsd_pak: &mut F, + fsd_pak_reader: &repak::PakReader, + mod_pak: &mut M, + mod_pak_reader: &repak::PakReader, +) -> Result +where + F: Read + Seek, + M: Read + Seek, +{ + debug!("check_gameplay_affecting"); + + let mount = Path::new(mod_pak_reader.mount_point()); + + let whitelist = [ + "SoundWave", + "SoundCue", + "SoundClass", + "SoundMix", + "MaterialInstanceConstant", + "Material", + "SkeletalMesh", + "StaticMesh", + "Texture2D", + "AnimSequence", + "Skeleton", + "StringTable", + ] + .into_iter() + .collect::>(); + + let check_asset = |data: Vec| -> Result { + debug!("check_asset"); + let asset = unreal_asset::AssetBuilder::new( + Cursor::new(data), + unreal_asset::engine_version::EngineVersion::VER_UE4_27, + ) + .skip_data(true) + .build() + .map_err(|e| LintError::UnrealAssetError { source: e })?; + + for export in &asset.asset_data.exports { + let base = export.get_base_export(); + // don't care about exported classes in this case + if base.outer_index.index == 0 + && base.class_index.is_import() + && !asset + .get_import(base.class_index) + .map(|import| import.object_name.get_content(|c| whitelist.contains(c))) + .unwrap_or(false) + { + // invalid import or import name is not whitelisted, unknown + return Ok(true); + }; + } + + Ok(false) + }; + + let mod_lowercase_path_map = mod_pak_reader + .files() + .into_iter() + .map(|p| -> Result<(String, String), LintError> { + let j = mount.join(&p); + let new_path = j + .strip_prefix("../../../") + .map_err(|e| LintError::PrefixMismatch { source: e })?; + let new_path_str = &new_path.to_string_lossy().replace('\\', "/"); + + Ok((new_path_str.to_ascii_lowercase(), p)) + }) + .collect::, LintError>>()?; + + let mut gameplay_affecting_paths = BTreeSet::new(); + + for lower in mod_lowercase_path_map.keys() { + if let Some((base, ext)) = lower.rsplit_once('.') { + if ["uasset", "uexp", "umap", "ubulk", "ufont"].contains(&ext) { + let key_uasset = format!("{base}.uasset"); + let key_umap = format!("{base}.umap"); + // check mod pak for uasset or umap + // if not found, check fsd pak for uasset or umap + let asset = if let Some(path) = mod_lowercase_path_map.get(&key_uasset) { + mod_pak_reader.get(path, mod_pak)? + } else if let Some(path) = mod_lowercase_path_map.get(&key_umap) { + mod_pak_reader.get(path, mod_pak)? + } else if let Some(path) = fsd_lowercase_path_map.get(&key_uasset) { + fsd_pak_reader.get(path, fsd_pak)? + } else if let Some(path) = fsd_lowercase_path_map.get(&key_umap) { + fsd_pak_reader.get(path, fsd_pak)? + } else { + // not found, unknown + gameplay_affecting_paths.insert(lower.to_owned()); + continue; + }; + + let asset_result = check_asset(asset.clone())?; + + if asset_result { + gameplay_affecting_paths.insert(lower.to_owned()); + } + } + } + } + + if gameplay_affecting_paths.is_empty() { + Ok(ModGameplayAffectingResult::No) + } else { + Ok(ModGameplayAffectingResult::Yes(gameplay_affecting_paths)) + } +} diff --git a/src/mod_lints/mod.rs b/src/mod_lints/mod.rs index 054135a9..fd78cc10 100644 --- a/src/mod_lints/mod.rs +++ b/src/mod_lints/mod.rs @@ -1,6 +1,7 @@ mod archive_multiple_paks; mod archive_only_non_pak_files; mod asset_register_bin; +mod auto_verification; mod conflicting_mods; mod empty_archive; mod non_asset_files; @@ -22,6 +23,7 @@ use tracing::trace; use self::archive_multiple_paks::ArchiveMultiplePaksLint; use self::archive_only_non_pak_files::ArchiveOnlyNonPakFilesLint; use self::asset_register_bin::AssetRegisterBinLint; +use self::auto_verification::AutoVerificationLint; use self::empty_archive::EmptyArchiveLint; use self::non_asset_files::NonAssetFilesLint; use self::outdated_pak_version::OutdatedPakVersionLint; @@ -29,6 +31,7 @@ use self::shader_files::ShaderFilesLint; pub use self::split_asset_pairs::SplitAssetPair; use self::split_asset_pairs::SplitAssetPairsLint; use self::unmodified_game_assets::UnmodifiedGameAssetsLint; + use crate::mod_lints::conflicting_mods::ConflictingModsLint; use crate::providers::{ModSpecification, ReadSeek}; @@ -39,6 +42,8 @@ pub enum LintError { #[snafu(transparent)] IoError { source: std::io::Error }, #[snafu(transparent)] + UnrealAssetError { source: unreal_asset::Error }, + #[snafu(transparent)] PrefixMismatch { source: std::path::StripPrefixError }, #[snafu(display("empty archive"))] EmptyArchive, @@ -64,6 +69,10 @@ impl LintCtxt { Ok(Self { mods, fsd_pak_path }) } + /// Function F takes: + /// - `ModSpecification` + /// - `&mut Box`: a seekable reader of the first pak in a mod + /// - `&PakReader`: a simple reader of the first pak in a mod pub fn for_each_mod( &self, mut f: F, @@ -253,6 +262,9 @@ impl LintId { pub const UNMODIFIED_GAME_ASSETS: Self = LintId { name: "unmodified_game_assets", }; + pub const AUTO_VERIFICATION: Self = LintId { + name: "auto_verification", + }; } #[derive(Default, Debug)] @@ -268,6 +280,7 @@ pub struct LintReport { pub split_asset_pairs_mods: Option>>, pub unmodified_game_assets_mods: Option>>, + pub auto_verification_failed_mods: Option>>, } pub fn run_lints( @@ -320,6 +333,10 @@ pub fn run_lints( let res = UnmodifiedGameAssetsLint.check_mods(&lint_ctxt)?; lint_report.unmodified_game_assets_mods = Some(res); } + LintId::AUTO_VERIFICATION => { + let res = AutoVerificationLint.check_mods(&lint_ctxt)?; + lint_report.auto_verification_failed_mods = Some(res); + } _ => unimplemented!(), } } diff --git a/src/providers/cache.rs b/src/providers/cache.rs index d7e10797..9fd0c093 100644 --- a/src/providers/cache.rs +++ b/src/providers/cache.rs @@ -6,6 +6,9 @@ use std::sync::{Arc, RwLock}; use fs_err as fs; use serde::{Deserialize, Serialize}; use snafu::prelude::*; +use tracing::*; + +use mint_lib::mod_info::ModIdentifier; use crate::state::config::ConfigWrapper; @@ -22,9 +25,13 @@ pub trait ModProviderCache: Sync + Send + std::fmt::Debug { #[obake::versioned] #[obake(version("0.0.0"))] +#[obake(version("0.1.0"))] #[derive(Debug, Default, Serialize, Deserialize)] pub struct Cache { + #[obake(cfg(">=0.0.0"))] pub(super) cache: HashMap>, + #[obake(cfg(">=0.1.0"))] + pub(super) gameplay_affecting_cache: HashMap, } impl Cache { @@ -57,20 +64,32 @@ impl Cache { pub enum VersionAnnotatedCache { #[serde(rename = "0.0.0")] V0_0_0(Cache!["0.0.0"]), + #[serde(rename = "0.1.0")] + V0_1_0(Cache!["0.1.0"]), +} + +impl From for Cache!["0.1.0"] { + fn from(legacy: Cache!["0.0.0"]) -> Self { + Self { + cache: legacy.cache, + gameplay_affecting_cache: Default::default(), + } + } } impl Default for VersionAnnotatedCache { fn default() -> Self { - VersionAnnotatedCache::V0_0_0(Default::default()) + VersionAnnotatedCache::V0_1_0(Default::default()) } } impl Deref for VersionAnnotatedCache { - type Target = Cache!["0.0.0"]; + type Target = Cache!["0.1.0"]; fn deref(&self) -> &Self::Target { match self { - VersionAnnotatedCache::V0_0_0(c) => c, + VersionAnnotatedCache::V0_0_0(_) => unreachable!(), + VersionAnnotatedCache::V0_1_0(c) => c, } } } @@ -78,7 +97,8 @@ impl Deref for VersionAnnotatedCache { impl DerefMut for VersionAnnotatedCache { fn deref_mut(&mut self) -> &mut Self::Target { match self { - VersionAnnotatedCache::V0_0_0(c) => c, + VersionAnnotatedCache::V0_0_0(_) => unreachable!(), + VersionAnnotatedCache::V0_1_0(c) => c, } } } @@ -139,6 +159,7 @@ pub(crate) fn read_cache_metadata_or_default( }); }; let version = obj_map.remove("version"); + debug!(?version); if let Some(v) = version && let serde_json::Value::String(vs) = v { @@ -149,6 +170,7 @@ pub(crate) fn read_cache_metadata_or_default( // . match serde_json::from_slice::(&buf) { Ok(c) => { + debug!("read as cache version v0.0.0"); MaybeVersionedCache::Versioned(VersionAnnotatedCache::V0_0_0(c)) } Err(e) => Err(e).context(DeserializeVersionedCacheFailedSnafu { @@ -156,6 +178,20 @@ pub(crate) fn read_cache_metadata_or_default( })?, } } + "0.1.0" => { + // HACK: workaround a serde issue relating to flattening with tags + // involving numeric keys in hashmaps, see + // . + match serde_json::from_slice::(&buf) { + Ok(c) => { + debug!("read as cache version v0.1.0"); + MaybeVersionedCache::Versioned(VersionAnnotatedCache::V0_1_0(c)) + } + Err(e) => Err(e).context(DeserializeVersionedCacheFailedSnafu { + version: "v0.1.0", + })?, + } + } _ => unimplemented!(), } } else { @@ -175,7 +211,8 @@ pub(crate) fn read_cache_metadata_or_default( let cache: VersionAnnotatedCache = match cache { MaybeVersionedCache::Versioned(v) => match v { - VersionAnnotatedCache::V0_0_0(v) => VersionAnnotatedCache::V0_0_0(v), + VersionAnnotatedCache::V0_0_0(v) => VersionAnnotatedCache::V0_1_0(v.into()), + VersionAnnotatedCache::V0_1_0(v) => VersionAnnotatedCache::V0_1_0(v), }, MaybeVersionedCache::Legacy(legacy) => VersionAnnotatedCache::V0_0_0(legacy), }; diff --git a/src/providers/mod_store.rs b/src/providers/mod_store.rs index d5983d0a..85165aa1 100644 --- a/src/providers/mod_store.rs +++ b/src/providers/mod_store.rs @@ -232,4 +232,21 @@ impl ModStore { .unwrap() .get_version_name(spec, self.cache.clone()) } + + pub fn update_gameplay_affecting_status(&self, id: ModIdentifier, stat: bool) { + self.cache + .write() + .unwrap() + .gameplay_affecting_cache + .insert(id, stat); + } + + pub fn get_gameplay_affecting_status(&self, id: &ModIdentifier) -> Option { + self.cache + .read() + .unwrap() + .gameplay_affecting_cache + .get(id) + .copied() + } } diff --git a/src/state/mod.rs b/src/state/mod.rs index 27b60ec9..4ff27f45 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -74,9 +74,9 @@ pub struct ModData { pub active_profile: String, #[obake(cfg("0.0.0"))] pub profiles: BTreeMap, - #[obake(cfg("0.1.0"))] + #[obake(cfg(">=0.1.0"))] pub profiles: BTreeMap, - #[obake(cfg("0.1.0"))] + #[obake(cfg(">=0.1.0"))] pub groups: BTreeMap, }