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.
This commit is contained in:
Oliver Hamlet
2023-04-23 11:31:25 +01:00
parent 50662558ae
commit aff9d546f1
6 changed files with 232 additions and 20 deletions
+8
View File
@@ -1,4 +1,5 @@
use std::ffi::{CStr, CString}; use std::ffi::{CStr, CString};
use std::path::PathBuf;
use std::slice; use std::slice;
use libc::{c_char, c_int, size_t}; 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)) 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<Vec<PathBuf>, 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> { unsafe fn map_plugin_version(c_object: &plugin_version) -> Result<(String, String), i32> {
to_str(c_object.plugin_name) to_str(c_object.plugin_name)
.and_then(|n| to_str(c_object.version).map(|v| (n.into(), v.into()))) .and_then(|n| to_str(c_object.version).map(|v| (n.into(), v.into())))
+37 -1
View File
@@ -7,7 +7,7 @@ use loot_condition_interpreter::State;
use crate::constants::*; use crate::constants::*;
use crate::helpers::{ 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)] #[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) .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)
}
+32
View File
@@ -181,6 +181,37 @@ void test_lci_state_set_crc_cache() {
lci_state_destroy(state); 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) { int main(void) {
test_game_id_values(); test_game_id_values();
@@ -192,6 +223,7 @@ int main(void) {
test_lci_state_set_active_plugins(); test_lci_state_set_active_plugins();
test_lci_state_set_plugin_versions(); test_lci_state_set_plugin_versions();
test_lci_state_set_crc_cache(); test_lci_state_set_crc_cache();
test_lci_state_set_additional_data_paths();
printf("SUCCESS\n"); printf("SUCCESS\n");
return 0; return 0;
+96 -16
View File
@@ -22,15 +22,21 @@ fn is_match(game_type: GameType, regex: &Regex, file_name: &OsStr) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
fn evaluate_file_regex(state: &State, parent_path: &Path, regex: &Regex) -> Result<bool, Error> { fn evaluate_regex(
let dir_iterator = match read_dir(state.data_path.join(parent_path)) { game_type: GameType,
data_path: &Path,
parent_path: &Path,
regex: &Regex,
mut condition: impl FnMut() -> bool,
) -> Result<bool, Error> {
let dir_iterator = match read_dir(data_path.join(parent_path)) {
Ok(i) => i, Ok(i) => i,
Err(_) => return Ok(false), Err(_) => return Ok(false),
}; };
for entry in dir_iterator { for entry in dir_iterator {
let entry = entry.map_err(|e| Error::IoError(parent_path.to_path_buf(), e))?; 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); return Ok(true);
} }
} }
@@ -38,6 +44,24 @@ fn evaluate_file_regex(state: &State, parent_path: &Path, regex: &Regex) -> Resu
Ok(false) Ok(false)
} }
fn evaluate_file_regex(state: &State, parent_path: &Path, regex: &Regex) -> Result<bool, Error> {
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<bool, Error> { fn evaluate_readable(state: &State, path: &Path) -> Result<bool, Error> {
if path.is_dir() { if path.is_dir() {
Ok(read_dir(resolve_path(state, path)).is_ok()) Ok(read_dir(resolve_path(state, path)).is_ok())
@@ -47,24 +71,39 @@ fn evaluate_readable(state: &State, path: &Path) -> Result<bool, Error> {
} }
fn evaluate_many(state: &State, parent_path: &Path, regex: &Regex) -> Result<bool, Error> { fn evaluate_many(state: &State, parent_path: &Path, regex: &Regex) -> Result<bool, Error> {
let dir_iterator = match read_dir(state.data_path.join(parent_path)) { // Share the found_one state across all data paths because they're all
Ok(i) => i, // treated as if they were merged into one directory.
Err(_) => return Ok(false),
};
let mut found_one = false; let mut found_one = false;
for entry in dir_iterator { let mut condition = || {
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 { if found_one {
return Ok(true); true
} else { } else {
found_one = true; found_one = true;
false
} }
};
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<bool, Error> { fn evaluate_active_path(state: &State, path: &Path) -> Result<bool, Error> {
@@ -290,22 +329,23 @@ mod tests {
} }
fn state_with_active_plugins<T: Into<PathBuf>>(data_path: T, active_plugins: &[&str]) -> State { fn state_with_active_plugins<T: Into<PathBuf>>(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<T: Into<PathBuf>>(data_path: T, loot_path: &str) -> State { fn state_with_loot_path<T: Into<PathBuf>>(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<T: Into<PathBuf>>( fn state_with_versions<T: Into<PathBuf>>(
data_path: T, data_path: T,
plugin_versions: &[(&str, &str)], plugin_versions: &[(&str, &str)],
) -> State { ) -> State {
state_with_data(data_path, "", &[], plugin_versions) state_with_data(data_path, Vec::default(), "", &[], plugin_versions)
} }
fn state_with_data<T: Into<PathBuf>>( fn state_with_data<T: Into<PathBuf>>(
data_path: T, data_path: T,
additional_data_paths: Vec<T>,
loot_path: &str, loot_path: &str,
active_plugins: &[&str], active_plugins: &[&str],
plugin_versions: &[(&str, &str)], plugin_versions: &[(&str, &str)],
@@ -315,9 +355,21 @@ mod tests {
create_dir(&data_path).unwrap(); 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 { State {
game_type: GameType::Oblivion, game_type: GameType::Oblivion,
data_path, data_path,
additional_data_paths,
loot_path: loot_path.into(), loot_path: loot_path.into(),
active_plugins: active_plugins active_plugins: active_plugins
.into_iter() .into_iter()
@@ -460,6 +512,20 @@ mod tests {
assert!(function.eval(&state).unwrap()); 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] #[test]
fn function_readable_eval_should_be_true_for_a_file_that_can_be_opened_as_read_only() { 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")); let function = Function::Readable(PathBuf::from("Cargo.toml"));
@@ -709,6 +775,20 @@ mod tests {
assert!(function.eval(&state).unwrap()); 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] #[test]
fn function_many_active_eval_should_be_true_if_the_regex_matches_more_than_one_active_plugin() { fn function_many_active_eval_should_be_true_if_the_regex_matches_more_than_one_active_plugin() {
let function = Function::ManyActive(regex("Blank.*")); let function = Function::ManyActive(regex("Blank.*"));
+47
View File
@@ -59,6 +59,24 @@ pub fn resolve_path(state: &State, path: &Path) -> PathBuf {
if path == Path::new("LOOT") { if path == Path::new("LOOT") {
state.loot_path.clone() state.loot_path.clone()
} else { } 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); let path = state.data_path.join(path);
if !path.exists() && has_unghosted_plugin_file_extension(state.game_type, &path) { if !path.exists() && has_unghosted_plugin_file_extension(state.game_type, &path) {
@@ -435,4 +453,33 @@ mod tests {
resolved_path 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);
}
} }
+9
View File
@@ -51,6 +51,9 @@ pub struct State {
game_type: GameType, game_type: GameType,
/// Game Data folder path. /// Game Data folder path.
data_path: PathBuf, 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<PathBuf>,
/// Path to the LOOT executable, used to resolve conditions that use the "LOOT" path. /// Path to the LOOT executable, used to resolve conditions that use the "LOOT" path.
loot_path: PathBuf, loot_path: PathBuf,
/// Lowercased plugin filenames. /// Lowercased plugin filenames.
@@ -68,6 +71,7 @@ impl State {
State { State {
game_type, game_type,
data_path, data_path,
additional_data_paths: Vec::default(),
loot_path, loot_path,
active_plugins: HashSet::default(), active_plugins: HashSet::default(),
crc_cache: RwLock::default(), crc_cache: RwLock::default(),
@@ -124,6 +128,10 @@ impl State {
) -> Result<(), PoisonError<RwLockWriteGuard<HashMap<Function, bool>>>> { ) -> Result<(), PoisonError<RwLockWriteGuard<HashMap<Function, bool>>>> {
self.condition_cache.write().map(|mut c| c.clear()) self.condition_cache.write().map(|mut c| c.clear())
} }
pub fn set_additional_data_paths(&mut self, additional_data_paths: Vec<PathBuf>) {
self.additional_data_paths = additional_data_paths;
}
} }
/// Compound conditions joined by 'or' /// Compound conditions joined by 'or'
@@ -287,6 +295,7 @@ mod tests {
State { State {
game_type: GameType::Oblivion, game_type: GameType::Oblivion,
data_path, data_path,
additional_data_paths: Vec::default(),
loot_path: PathBuf::new(), loot_path: PathBuf::new(),
active_plugins: HashSet::new(), active_plugins: HashSet::new(),
crc_cache: RwLock::default(), crc_cache: RwLock::default(),