From aff9d546f1a60882e517aaecac0f1983e31e1965 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 23 Apr 2023 10:12:49 +0100 Subject: [PATCH] Add support for additional data paths The Microsoft Store installs Fallout 4 DLCs into separate game directories, so allow them to be provided for evaluating conditions. --- ffi/src/helpers.rs | 8 +++ ffi/src/state.rs | 38 +++++++++++++- ffi/tests/ffi.cpp | 32 ++++++++++++ src/function/eval.rs | 118 ++++++++++++++++++++++++++++++++++++------- src/function/path.rs | 47 +++++++++++++++++ src/lib.rs | 9 ++++ 6 files changed, 232 insertions(+), 20 deletions(-) diff --git a/ffi/src/helpers.rs b/ffi/src/helpers.rs index b9399fd..95b8d60 100644 --- a/ffi/src/helpers.rs +++ b/ffi/src/helpers.rs @@ -1,4 +1,5 @@ use std::ffi::{CStr, CString}; +use std::path::PathBuf; use std::slice; use libc::{c_char, c_int, size_t}; @@ -76,6 +77,13 @@ pub unsafe fn to_str_vec<'a>( to_vec(array, array_size, |c| to_str(*c)) } +pub unsafe fn to_path_buf_vec( + array: *const *const c_char, + array_size: size_t, +) -> Result, i32> { + to_vec(array, array_size, |c| to_str(*c).map(PathBuf::from)) +} + unsafe fn map_plugin_version(c_object: &plugin_version) -> Result<(String, String), i32> { to_str(c_object.plugin_name) .and_then(|n| to_str(c_object.version).map(|v| (n.into(), v.into()))) diff --git a/ffi/src/state.rs b/ffi/src/state.rs index fbbc63e..5ae65ee 100644 --- a/ffi/src/state.rs +++ b/ffi/src/state.rs @@ -7,7 +7,7 @@ use loot_condition_interpreter::State; use crate::constants::*; use crate::helpers::{ - error, map_game_type, map_plugin_crcs, map_plugin_versions, to_str, to_str_vec, + error, map_game_type, map_plugin_crcs, map_plugin_versions, to_path_buf_vec, to_str, to_str_vec, }; #[allow(non_camel_case_types)] @@ -200,3 +200,39 @@ pub unsafe extern "C" fn lci_state_clear_condition_cache(state: *mut lci_state) }) .unwrap_or(LCI_ERROR_PANICKED) } + +/// Sets the external data paths for the given state. +/// +/// If the operating environment contains multiple directories containing relevant plugins and other +/// data files, this function can be used to provide those directory paths that are not the game's +/// main data directory so that files in those directories are taken into account when evaluating +/// conditions. +/// +/// Returns `LCI_OK` if successful, otherwise a `LCI_ERROR_*` code is returned. +#[no_mangle] +pub unsafe extern "C" fn lci_state_set_additional_data_paths( + state: *mut lci_state, + paths: *const *const c_char, + num_paths: size_t, +) -> c_int { + catch_unwind(|| { + if state.is_null() || (paths.is_null() && num_paths != 0) { + return error(LCI_ERROR_INVALID_ARGS, "Null pointer passed"); + } + + let mut state = match (*state).0.write() { + Err(e) => return error(LCI_ERROR_POISONED_THREAD_LOCK, &e.to_string()), + Ok(h) => h, + }; + + let additional_data_paths = match to_path_buf_vec(paths, num_paths) { + Ok(x) => x, + Err(x) => return error(x, "An external data path contained a null byte"), + }; + + state.set_additional_data_paths(additional_data_paths); + + LCI_OK + }) + .unwrap_or(LCI_ERROR_PANICKED) +} diff --git a/ffi/tests/ffi.cpp b/ffi/tests/ffi.cpp index a1e5813..dcc53d0 100644 --- a/ffi/tests/ffi.cpp +++ b/ffi/tests/ffi.cpp @@ -181,6 +181,37 @@ void test_lci_state_set_crc_cache() { lci_state_destroy(state); } +void test_lci_state_set_additional_data_paths() { + printf("testing lci_state_set_additional_data_paths()...\n"); + + lci_state * state = nullptr; + int return_code = lci_state_create(&state, LCI_GAME_OBLIVION, ".", "."); + + assert(return_code == LCI_OK); + assert(state != nullptr); + + return_code = lci_condition_eval("file(\"Blank.esm\")", state); + + assert(return_code == LCI_RESULT_FALSE); + + const char * data_paths[] = { "../../tests/testing-plugins/Oblivion/Data" }; + + return_code = lci_state_set_additional_data_paths(state, data_paths, 1); + assert(return_code == LCI_OK); + + return_code = lci_state_clear_condition_cache(state); + assert(return_code == LCI_OK); + + return_code = lci_condition_eval("file(\"Blank.esm\")", state); + + assert(return_code == LCI_RESULT_TRUE); + + return_code = lci_state_set_additional_data_paths(state, nullptr, 0); + assert(return_code == LCI_OK); + + lci_state_destroy(state); +} + int main(void) { test_game_id_values(); @@ -192,6 +223,7 @@ int main(void) { test_lci_state_set_active_plugins(); test_lci_state_set_plugin_versions(); test_lci_state_set_crc_cache(); + test_lci_state_set_additional_data_paths(); printf("SUCCESS\n"); return 0; diff --git a/src/function/eval.rs b/src/function/eval.rs index bddda44..c38abaa 100644 --- a/src/function/eval.rs +++ b/src/function/eval.rs @@ -22,15 +22,21 @@ fn is_match(game_type: GameType, regex: &Regex, file_name: &OsStr) -> bool { .unwrap_or(false) } -fn evaluate_file_regex(state: &State, parent_path: &Path, regex: &Regex) -> Result { - let dir_iterator = match read_dir(state.data_path.join(parent_path)) { +fn evaluate_regex( + game_type: GameType, + data_path: &Path, + parent_path: &Path, + regex: &Regex, + mut condition: impl FnMut() -> bool, +) -> Result { + let dir_iterator = match read_dir(data_path.join(parent_path)) { Ok(i) => i, Err(_) => return Ok(false), }; for entry in dir_iterator { let entry = entry.map_err(|e| Error::IoError(parent_path.to_path_buf(), e))?; - if is_match(state.game_type, regex, &entry.file_name()) { + if is_match(game_type, regex, &entry.file_name()) && condition() { return Ok(true); } } @@ -38,6 +44,24 @@ fn evaluate_file_regex(state: &State, parent_path: &Path, regex: &Regex) -> Resu Ok(false) } +fn evaluate_file_regex(state: &State, parent_path: &Path, regex: &Regex) -> Result { + for data_path in &state.additional_data_paths { + let result = evaluate_regex(state.game_type, data_path, parent_path, regex, || true)?; + + if result { + return Ok(true); + } + } + + evaluate_regex( + state.game_type, + &state.data_path, + parent_path, + regex, + || true, + ) +} + fn evaluate_readable(state: &State, path: &Path) -> Result { if path.is_dir() { Ok(read_dir(resolve_path(state, path)).is_ok()) @@ -47,24 +71,39 @@ fn evaluate_readable(state: &State, path: &Path) -> Result { } fn evaluate_many(state: &State, parent_path: &Path, regex: &Regex) -> Result { - let dir_iterator = match read_dir(state.data_path.join(parent_path)) { - Ok(i) => i, - Err(_) => return Ok(false), + // Share the found_one state across all data paths because they're all + // treated as if they were merged into one directory. + let mut found_one = false; + let mut condition = || { + if found_one { + true + } else { + found_one = true; + false + } }; - let mut found_one = false; - for entry in dir_iterator { - let entry = entry.map_err(|e| Error::IoError(parent_path.to_path_buf(), e))?; - if is_match(state.game_type, regex, &entry.file_name()) { - if found_one { - return Ok(true); - } else { - found_one = true; - } + for data_path in &state.additional_data_paths { + let result = evaluate_regex( + state.game_type, + data_path, + parent_path, + regex, + &mut condition, + )?; + + if result { + return Ok(true); } } - Ok(false) + evaluate_regex( + state.game_type, + &state.data_path, + parent_path, + regex, + &mut condition, + ) } fn evaluate_active_path(state: &State, path: &Path) -> Result { @@ -290,22 +329,23 @@ mod tests { } fn state_with_active_plugins>(data_path: T, active_plugins: &[&str]) -> State { - state_with_data(data_path, "", active_plugins, &[]) + state_with_data(data_path, Vec::default(), "", active_plugins, &[]) } fn state_with_loot_path>(data_path: T, loot_path: &str) -> State { - state_with_data(data_path, loot_path, &[], &[]) + state_with_data(data_path, Vec::default(), loot_path, &[], &[]) } fn state_with_versions>( data_path: T, plugin_versions: &[(&str, &str)], ) -> State { - state_with_data(data_path, "", &[], plugin_versions) + state_with_data(data_path, Vec::default(), "", &[], plugin_versions) } fn state_with_data>( data_path: T, + additional_data_paths: Vec, loot_path: &str, active_plugins: &[&str], plugin_versions: &[(&str, &str)], @@ -315,9 +355,21 @@ mod tests { create_dir(&data_path).unwrap(); } + let additional_data_paths = additional_data_paths + .into_iter() + .map(|data_path| { + let data_path: PathBuf = data_path.into(); + if !data_path.exists() { + create_dir(&data_path).unwrap(); + } + data_path + }) + .collect(); + State { game_type: GameType::Oblivion, data_path, + additional_data_paths, loot_path: loot_path.into(), active_plugins: active_plugins .into_iter() @@ -460,6 +512,20 @@ mod tests { assert!(function.eval(&state).unwrap()); } + #[test] + fn function_file_regex_eval_should_check_all_configured_data_paths() { + let function = Function::FileRegex(PathBuf::from("Data"), regex("Blank\\.esp")); + let state = state_with_data( + "./src", + vec!["./tests/testing-plugins/Oblivion"], + ".", + &[], + &[], + ); + + assert!(function.eval(&state).unwrap()); + } + #[test] fn function_readable_eval_should_be_true_for_a_file_that_can_be_opened_as_read_only() { let function = Function::Readable(PathBuf::from("Cargo.toml")); @@ -709,6 +775,20 @@ mod tests { assert!(function.eval(&state).unwrap()); } + #[test] + fn function_many_eval_should_check_across_all_configured_data_paths() { + let function = Function::Many(PathBuf::from("Data"), regex("Blank\\.esp")); + let state = state_with_data( + "./tests/testing-plugins/Skyrim", + vec!["./tests/testing-plugins/Oblivion"], + ".", + &[], + &[], + ); + + assert!(function.eval(&state).unwrap()); + } + #[test] fn function_many_active_eval_should_be_true_if_the_regex_matches_more_than_one_active_plugin() { let function = Function::ManyActive(regex("Blank.*")); diff --git a/src/function/path.rs b/src/function/path.rs index 16b6244..58c82e6 100644 --- a/src/function/path.rs +++ b/src/function/path.rs @@ -59,6 +59,24 @@ pub fn resolve_path(state: &State, path: &Path) -> PathBuf { if path == Path::new("LOOT") { state.loot_path.clone() } else { + // First check external data paths, as files there may override files in the main data path. + for data_path in &state.additional_data_paths { + let mut path = data_path.join(path); + + if path.exists() { + return path; + } + + if has_unghosted_plugin_file_extension(state.game_type, &path) { + path = add_ghost_extension(path); + } + + if path.exists() { + return path; + } + } + + // Now check the main data path. let path = state.data_path.join(path); if !path.exists() && has_unghosted_plugin_file_extension(state.game_type, &path) { @@ -435,4 +453,33 @@ mod tests { resolved_path ); } + + #[test] + fn resolve_path_should_check_external_data_paths_in_order_before_data_path() { + use std::fs::copy; + use std::fs::create_dir; + + let tmp_dir = tempfile::tempdir().unwrap(); + let external_data_path_1 = tmp_dir.path().join("Data1"); + let external_data_path_2 = tmp_dir.path().join("Data2"); + let data_path = tmp_dir.path().join("Data3"); + + create_dir(&external_data_path_1).unwrap(); + create_dir(&external_data_path_2).unwrap(); + create_dir(&data_path).unwrap(); + copy( + Path::new("Cargo.toml"), + external_data_path_2.join("Cargo.toml"), + ) + .unwrap(); + copy(Path::new("Cargo.toml"), data_path.join("Cargo.toml")).unwrap(); + + let mut state = State::new(GameType::Skyrim, data_path, "loot.exe".into()); + state.set_additional_data_paths(vec![external_data_path_1, external_data_path_2.clone()]); + + let input_path = Path::new("Cargo.toml"); + let resolved_path = resolve_path(&state, input_path); + + assert_eq!(external_data_path_2.join(input_path), resolved_path); + } } diff --git a/src/lib.rs b/src/lib.rs index 82ce601..4b0d178 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,6 +51,9 @@ pub struct State { game_type: GameType, /// Game Data folder path. data_path: PathBuf, + /// Other directories that may contain plugins and other game files, used before data_path and + /// in the order they're listed. + additional_data_paths: Vec, /// Path to the LOOT executable, used to resolve conditions that use the "LOOT" path. loot_path: PathBuf, /// Lowercased plugin filenames. @@ -68,6 +71,7 @@ impl State { State { game_type, data_path, + additional_data_paths: Vec::default(), loot_path, active_plugins: HashSet::default(), crc_cache: RwLock::default(), @@ -124,6 +128,10 @@ impl State { ) -> Result<(), PoisonError>>> { self.condition_cache.write().map(|mut c| c.clear()) } + + pub fn set_additional_data_paths(&mut self, additional_data_paths: Vec) { + self.additional_data_paths = additional_data_paths; + } } /// Compound conditions joined by 'or' @@ -287,6 +295,7 @@ mod tests { State { game_type: GameType::Oblivion, data_path, + additional_data_paths: Vec::default(), loot_path: PathBuf::new(), active_plugins: HashSet::new(), crc_cache: RwLock::default(),