Compare commits

..

12 Commits

22 changed files with 629 additions and 227 deletions

View File

@@ -3,7 +3,7 @@ use log::error;
use crate::{
nexus::{NXMUrl, download_nxm},
types::RootConfig,
types::{ModConfig, RootConfig},
unpacker::unpack,
};
@@ -32,11 +32,13 @@ pub fn handle_nxm(root_config: &mut RootConfig, raw_url: &str) -> anyhow::Result
return Err(anyhow!("Mod with generated id already exists"));
}
let new_mod = unpack(root_config, &mod_id, dl_file)?;
let extract_to = root_config.mod_location().join(&mod_id);
unpack(dl_file, extract_to)?;
let file_id: u64 = nxm_url.file.parse()?;
let new_mod = ModConfig::from_mod_info(&mod_id, &mod_id, &mod_info, file_id);
root_config.add_mod(&new_mod);
root_config.save_to_file()?;
Ok(())
}

View File

@@ -160,6 +160,7 @@ fn should_be_included(path: impl AsRef<Path>) -> bool {
| "ilstrings"
| "dlstrings"
| "dll"
| "swf"
)
)
}

View File

@@ -15,9 +15,8 @@ pub struct Args {
#[derive(Subcommand, Debug)]
pub enum Commands {
Activate { instance: String, target: PathBuf },
Add { instance: String, mod_id: String },
Include { instance: String, mod_id: String },
LoadOrder { instance: String },
ApiCheck,
Download { url: String },
Unpack { id: String, path: String },
}

View File

@@ -118,6 +118,7 @@ pub enum PluginTypeDescriptorEnum {
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DependencyPluginType {
#[serde(rename = "defaultType")]
pub default_type: PluginType,
pub patterns: DependencyPatternList,
}
@@ -129,7 +130,7 @@ pub struct DependencyPatternList {
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DependencyPattern {
pub dependencies: CompositeDependency,
pub dependencies: Vec<CompositeDependency>,
#[serde(rename = "type")]
pub typ: PluginType,
}

View File

@@ -11,7 +11,6 @@ use fomod_manager::{
cli::{self, Args},
nexus::NexusAPI,
types::RootConfig,
unpacker::unpack,
};
fn command_activate(
@@ -63,22 +62,7 @@ fn command_order(root_config: &RootConfig, instance_id: &str) -> anyhow::Result<
}
fn command_download(root_config: &mut RootConfig, raw_url: &str) -> anyhow::Result<()> {
handle_nxm(root_config, raw_url)
}
fn command_unpack(
root_config: &mut RootConfig,
id: &str,
file: impl AsRef<Path>,
) -> anyhow::Result<()> {
if root_config.game_by_id(id).is_some() {
error!("Mod already present");
return Err(anyhow!("Mod already exists"));
}
let new_mod = unpack(root_config, id, file)?;
root_config.add_mod(&new_mod);
handle_nxm(root_config, raw_url)?;
root_config.save_to_file()?;
@@ -105,7 +89,7 @@ fn main() -> Result<(), Box<dyn Error>> {
cli::Commands::Activate { instance, target } => {
command_activate(&root_config, &instance, &target)?;
}
cli::Commands::Add { instance, mod_id } => {
cli::Commands::Include { instance, mod_id } => {
command_add(&root_config, &instance, &mod_id)?;
}
cli::Commands::LoadOrder { instance } => {
@@ -118,9 +102,6 @@ fn main() -> Result<(), Box<dyn Error>> {
cli::Commands::Download { url } => {
command_download(&mut root_config, &url)?;
}
cli::Commands::Unpack { id, path } => {
command_unpack(&mut root_config, &id, path)?;
}
}
Ok(())

View File

@@ -176,7 +176,11 @@ fn resolve_plugin_type(
PluginTypeDescriptorEnum::PluginType(plugin_type) => plugin_type.name,
PluginTypeDescriptorEnum::DependencyType(dependency_plugin_type) => {
for dep in &dependency_plugin_type.patterns.pattern {
if evaluate_dependency(&dep.dependencies, state, installed_plugins) {
if dep
.dependencies
.iter()
.all(|e| evaluate_dependency(e, state, installed_plugins))
{
return dep.typ.name;
}
}

View File

@@ -2,6 +2,6 @@ mod api;
mod downloader;
mod url;
pub use api::NexusAPI;
pub use api::{ModInfo, NexusAPI};
pub use downloader::download_nxm;
pub use url::NXMUrl;

View File

@@ -1,7 +1,7 @@
use serde::Deserialize;
use url::Url;
use crate::nexus::NXMUrl;
use crate::{nexus::NXMUrl, types::GameType};
const NEXUS_ENDPOINT: &str = "https://api.nexusmods.com";
@@ -95,7 +95,7 @@ pub struct ModInfo {
pub mod_id: u64,
// pub game_id: u64,
// pub allow_rating: bool,
// pub domain_name: String,
pub domain_name: String,
// pub category_id: u64,
pub version: String,
// pub endorsement_count: u64,
@@ -145,6 +145,10 @@ impl ModInfo {
if short_name.len() > MAX_CHARS {
short_name.truncate(MAX_CHARS);
}
format!("{}-{}", short_name, self.mod_id)
format!("{}-{}", short_name.to_lowercase(), self.mod_id)
}
pub fn get_game_type(&self) -> GameType {
GameType::from_nexus_domain(&self.domain_name).unwrap_or(GameType::Unknown)
}
}

View File

@@ -1,20 +1,24 @@
use thiserror::Error;
mod game;
mod game_type;
mod installed_mod;
mod link;
mod mod_config;
mod mod_file;
mod modded_instance;
mod root_config;
mod nexus_id;
pub use game::*;
pub use game_type::GameType;
pub use installed_mod::*;
pub use link::*;
pub use mod_config::*;
pub use mod_file::*;
pub use modded_instance::*;
pub use root_config::*;
pub use nexus_id::*;
#[derive(Error, Debug)]
pub enum ConfigReadWriteError {

View File

@@ -6,18 +6,23 @@ use std::{
use serde::{Deserialize, Serialize};
use crate::{types::link::Link, utils::walk_all_files};
use crate::{
types::{GameType, link::Link},
utils::walk_all_files,
};
/// Available game
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Game {
path: PathBuf,
kind: GameType,
}
impl Game {
pub fn new(path: impl AsRef<Path>) -> Self {
pub fn new(path: impl AsRef<Path>, game_type: GameType) -> Self {
Self {
path: path.as_ref().to_owned(),
kind: game_type,
}
}
@@ -40,4 +45,8 @@ impl Game {
pub fn install_location(&self) -> &Path {
&self.path
}
pub fn game_type(&self) -> GameType {
self.kind.clone()
}
}

186
src/types/game_type.rs Normal file
View File

@@ -0,0 +1,186 @@
use std::fmt::Display;
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum GameType {
Oblivion,
Skyrim,
Fallout3,
FalloutNV,
Fallout4,
SkyrimSE,
Fallout4VR,
SkyrimVR,
Morrowind,
Starfield,
OpenMW,
OblivionRemastered,
Custom(String),
#[default]
Unknown,
}
impl GameType {
pub fn to_libloot_type(self) -> Option<libloot::GameType> {
match self {
GameType::Oblivion => Some(libloot::GameType::Oblivion),
GameType::Skyrim => Some(libloot::GameType::Skyrim),
GameType::Fallout3 => Some(libloot::GameType::Fallout3),
GameType::FalloutNV => Some(libloot::GameType::FalloutNV),
GameType::Fallout4 => Some(libloot::GameType::Fallout4),
GameType::SkyrimSE => Some(libloot::GameType::SkyrimSE),
GameType::Fallout4VR => Some(libloot::GameType::Fallout4VR),
GameType::SkyrimVR => Some(libloot::GameType::SkyrimVR),
GameType::Morrowind => Some(libloot::GameType::Morrowind),
GameType::Starfield => Some(libloot::GameType::Starfield),
GameType::OpenMW => Some(libloot::GameType::OpenMW),
GameType::OblivionRemastered => Some(libloot::GameType::OblivionRemastered),
GameType::Custom(_) => None,
GameType::Unknown => None,
}
}
pub fn to_nexus_domain(self) -> Option<String> {
match self {
GameType::Oblivion => Some("oblivion".to_owned()),
GameType::Skyrim => Some("skyrim".to_owned()),
GameType::Fallout3 => Some("fallout3".to_owned()),
GameType::FalloutNV => Some("newvegas".to_owned()),
GameType::Fallout4 => Some("fallout4".to_owned()),
GameType::SkyrimSE => Some("skyrimspecialedition".to_owned()),
GameType::Fallout4VR => Some("fallout4".to_owned()),
GameType::SkyrimVR => Some("skyrimspecialedition".to_owned()),
GameType::Morrowind => Some("morrowind".to_owned()),
GameType::Starfield => Some("starfield".to_owned()),
GameType::OpenMW => Some("morrowind".to_owned()),
GameType::OblivionRemastered => Some("oblivionremastered".to_owned()),
GameType::Custom(_) => None,
GameType::Unknown => None,
}
}
pub fn from_nexus_domain(domain: &str) -> Option<Self> {
match domain {
"oblivion" => Some(GameType::Oblivion),
"skyrim" => Some(GameType::Skyrim),
"fallout3" => Some(GameType::Fallout3),
"newvegas" => Some(GameType::FalloutNV),
"fallout4" => Some(GameType::Fallout4),
"skyrimspecialedition" => Some(GameType::SkyrimSE),
"morrowind" => Some(GameType::Morrowind),
"starfield" => Some(GameType::Starfield),
"oblivionremastered" => Some(GameType::OblivionRemastered),
_ => None,
}
}
}
impl Display for GameType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
GameType::Oblivion => "Oblivion",
GameType::Skyrim => "Skyrim",
GameType::Fallout3 => "Fallout 3",
GameType::FalloutNV => "Fallout New Vegas",
GameType::Fallout4 => "Fallout 4",
GameType::SkyrimSE => "Skyrim Special Edition",
GameType::Fallout4VR => "Fallout 4 VR",
GameType::SkyrimVR => "Skyrim VR",
GameType::Morrowind => "Morrowind",
GameType::Starfield => "Starfield",
GameType::OpenMW => "OpenMW",
GameType::OblivionRemastered => "Oblivion Remastered",
GameType::Custom(name) => name,
GameType::Unknown => "Unknown",
};
write!(f, "{}", s)
}
}
impl<'de> Deserialize<'de> for GameType {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(match s.as_str() {
"oblivion" => Self::Oblivion,
"skyrim" => Self::Skyrim,
"fo3" => Self::Fallout3,
"fonv" => Self::FalloutNV,
"fo4" => Self::Fallout4,
"sse" => Self::SkyrimSE,
"fo4vr" => Self::Fallout4VR,
"skyrimvr" => Self::SkyrimVR,
"morrowind" => Self::Morrowind,
"starfield" => Self::Starfield,
"openmw" => Self::OpenMW,
"oblivionrm" => Self::OblivionRemastered,
"unknown" => Self::Unknown,
_ => Self::Custom(s),
})
}
}
impl Serialize for GameType {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let s = match self {
Self::Custom(s) => s,
Self::Oblivion => "oblivion",
Self::Skyrim => "skyrim",
Self::Fallout3 => "fo3",
Self::FalloutNV => "fonv",
Self::Fallout4 => "fo4",
Self::SkyrimSE => "sse",
Self::Fallout4VR => "fo4vr",
Self::SkyrimVR => "skyrimvr",
Self::Morrowind => "morrowind",
Self::Starfield => "starfield",
Self::OpenMW => "openmw",
Self::OblivionRemastered => "oblivionrm",
Self::Unknown => "unknown",
};
serializer.serialize_str(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Deserialize, Serialize, PartialEq, Debug)]
struct Wrapper {
value: GameType,
}
fn roundtrip(game_type: GameType) {
let val = Wrapper { value: game_type };
let serialized = toml::to_string(&val).unwrap();
let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
assert_eq!(val, deserialized);
}
#[test]
fn parse_back_and_forth_all() {
for e in [
GameType::Oblivion,
GameType::Skyrim,
GameType::Fallout3,
GameType::FalloutNV,
GameType::Fallout4,
GameType::SkyrimSE,
GameType::Fallout4VR,
GameType::SkyrimVR,
GameType::Morrowind,
GameType::Starfield,
GameType::OpenMW,
GameType::OblivionRemastered,
GameType::Custom("custom".to_owned()),
GameType::Unknown,
] {
roundtrip(e);
}
}
}

View File

@@ -1,15 +1,16 @@
use serde::{
Deserialize, Deserializer, Serialize, Serializer,
de::{self, Visitor},
};
use std::{
fmt::Debug,
fmt::{self, Debug},
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use crate::types::mod_file::ModFile;
/// A link between a file from a mod and a destination in a ModdedInstance
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
#[serde(from = "(PathBuf, PathBuf)", into = "(PathBuf,PathBuf)")]
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct Link {
src: PathBuf,
dst: PathBuf,
@@ -36,18 +37,46 @@ impl Link {
}
}
impl From<(PathBuf, PathBuf)> for Link {
fn from(value: (PathBuf, PathBuf)) -> Self {
Self {
src: value.0,
dst: value.1,
impl Serialize for Link {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
if self.src == self.dst {
serializer.serialize_str(&self.src.to_string_lossy())
} else {
serializer.serialize_str(&format!(
"{} -> {}",
self.src.to_string_lossy(),
self.dst.to_string_lossy()
))
}
}
}
impl From<Link> for (PathBuf, PathBuf) {
fn from(value: Link) -> Self {
(value.src, value.dst)
impl<'de> Deserialize<'de> for Link {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct LinkVisitor;
impl<'de> Visitor<'de> for LinkVisitor {
type Value = Link;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str(r#"a string like "src -> dst" or "path" if they are the same"#)
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Link, E> {
match value.split_once(" -> ") {
Some((src, dst)) => Ok(Link {
src: PathBuf::from(src),
dst: PathBuf::from(dst),
}),
None => Ok(Link {
src: PathBuf::from(value),
dst: PathBuf::from(value),
}),
}
}
}
deserializer.deserialize_str(LinkVisitor)
}
}

View File

@@ -2,6 +2,11 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{
nexus::ModInfo,
types::{GameType, NexusID},
};
/// Config for an available mod
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
@@ -22,6 +27,14 @@ pub struct ModConfig {
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
ignore: Vec<String>,
name: Option<String>,
nexus_id: Option<NexusID>,
#[serde(default)]
#[serde(skip_serializing_if = "is_default")]
game: GameType,
}
impl ModConfig {
@@ -31,9 +44,26 @@ impl ModConfig {
path: source.as_ref().to_owned(),
root_mod: false,
ignore: Vec::new(),
name: None,
nexus_id: None,
game: GameType::Unknown,
}
}
pub fn from_mod_info(
id: &str,
source: impl AsRef<Path>,
mod_info: &ModInfo,
file_id: u64,
) -> Self {
let mut normal = Self::new(id, source);
normal.name = Some(mod_info.name.clone());
normal.game = mod_info.get_game_type();
normal.nexus_id = Some(NexusID::new(mod_info.mod_id, file_id));
normal
}
pub fn add_id(mut self, id: &str) -> Self {
self.id = id.to_owned();
self
@@ -55,8 +85,20 @@ impl ModConfig {
pub fn ignore(&self) -> &[String] {
&self.ignore
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn nexus_id(&self) -> Option<&NexusID> {
self.nexus_id.as_ref()
}
}
fn is_false(b: &bool) -> bool {
!b
}
fn is_default<T: Default + PartialEq>(t: &T) -> bool {
t == &T::default()
}

70
src/types/nexus_id.rs Normal file
View File

@@ -0,0 +1,70 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NexusID {
mod_id: u64,
file_id: u64,
}
impl NexusID {
pub fn new(mod_id: u64, file_id: u64) -> Self {
Self { mod_id, file_id }
}
}
impl Serialize for NexusID {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let s = format!("{}:{}", self.mod_id, self.file_id);
serializer.serialize_str(&s)
}
}
impl<'de> Deserialize<'de> for NexusID {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let mut parts = s.split(':');
let mod_id = parts
.next()
.ok_or_else(|| serde::de::Error::custom("missing first value"))
.and_then(|p| u64::from_str(p).map_err(serde::de::Error::custom))?;
let file_id = parts
.next()
.ok_or_else(|| serde::de::Error::custom("missing second value"))
.and_then(|p| u64::from_str(p).map_err(serde::de::Error::custom))?;
if parts.next().is_some() {
return Err(serde::de::Error::custom("too many parts"));
}
Ok(Self { mod_id, file_id })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Deserialize, Serialize, PartialEq, Debug)]
struct Wrapper {
value: NexusID,
}
#[test]
fn serde_roundtrip() {
let val = Wrapper {
value: NexusID::new(1234, 5678),
};
let serialized = toml::to_string(&val).unwrap();
let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
assert_eq!(val, deserialized);
}
}

View File

@@ -57,6 +57,10 @@ impl RootConfig {
}
pub fn save_to_file(&self) -> Result<(), ConfigReadWriteError> {
debug!(
"Saving root_config to: {}",
self.self_path.to_string_lossy()
);
let content = toml::to_string_pretty(self)?;
let mut file = fs::File::create(&self.self_path)?;
write!(file, "{}", content)?;
@@ -66,7 +70,13 @@ impl RootConfig {
pub fn game_by_id(&self, id: &str) -> Option<Game> {
self.games.get(id).map(|parsed_game| {
if parsed_game.install_location().is_relative() {
Game::new(self.self_parent.join(parsed_game.install_location()))
let abs_path = self.self_parent.join(parsed_game.install_location());
debug!(
"game path for {} is relative. Resolving to {}",
id,
abs_path.to_string_lossy()
);
Game::new(abs_path, parsed_game.game_type())
} else {
parsed_game.clone()
}
@@ -82,13 +92,19 @@ impl RootConfig {
}
pub fn load_instance_by_id(&self, id: &str) -> Result<ModdedInstance, ConfigReadWriteError> {
debug!("Loading instance {}", id);
let conf = self
.instances
.get(id)
.ok_or(ConfigReadWriteError::IDNotFound)?;
if conf.path.is_relative() {
ModdedInstance::load_from_file(self.self_parent.join(&conf.path))
let abs_path = self.self_parent.join(&conf.path);
debug!(
"instance path is relative. Resolving to {}",
abs_path.to_string_lossy()
);
ModdedInstance::load_from_file(abs_path)
} else {
ModdedInstance::load_from_file(&conf.path)
}
@@ -96,7 +112,12 @@ impl RootConfig {
pub fn mod_location(&self) -> PathBuf {
if self.mod_location.is_relative() {
self.self_parent.join(&self.mod_location)
let abs_path = self.self_parent.join(&self.mod_location);
debug!(
"mod_location path is relative. Resolving to {}",
abs_path.to_string_lossy()
);
abs_path
} else {
self.mod_location.clone()
}
@@ -109,7 +130,12 @@ impl RootConfig {
pub fn download_location(&self) -> Option<PathBuf> {
self.download_location.as_ref().map(|e| {
if e.is_relative() {
self.self_parent.join(e)
let abs_path = self.self_parent.join(e);
debug!(
"download_location path is relative. Resolving to {}",
abs_path.to_string_lossy()
);
abs_path
} else {
e.clone()
}
@@ -124,11 +150,16 @@ struct InstancePointer {
#[cfg(test)]
mod tests {
use crate::types::GameType;
use super::*;
fn create_config() -> RootConfig {
RootConfig {
games: HashMap::from([("sse".to_owned(), Game::new("/games/sse"))]),
games: HashMap::from([(
"sse".to_owned(),
Game::new("/games/sse", GameType::SkyrimSE),
)]),
mod_location: PathBuf::from("mods"),
download_location: Some(PathBuf::from("download")),
nexus_api_key: Some("1234".to_owned()),
@@ -157,6 +188,7 @@ mod tests {
let unwraped = game.expect("Asserted before");
assert_eq!(unwraped.install_location(), "/games/sse");
assert_eq!(unwraped.game_type(), GameType::SkyrimSE);
}
#[test]

View File

@@ -1,29 +1,24 @@
use std::{fs, path::Path};
use std::{
fs,
path::{Path, PathBuf},
};
use anyhow::anyhow;
use anyhow::{Ok, anyhow};
use log::error;
use zip::ZipArchive;
use crate::types::{ModConfig, RootConfig};
pub fn unpack(
root_config: &RootConfig,
id: &str,
path: impl AsRef<Path>,
) -> anyhow::Result<ModConfig> {
let extract_to = root_config.mod_location().join(id);
pub fn unpack(archive_path: impl AsRef<Path>, extract_to: impl AsRef<Path>) -> anyhow::Result<()> {
if fs::exists(&extract_to)? {
return Err(anyhow!(
"File already exists: {}",
extract_to.to_string_lossy()
extract_to.as_ref().to_string_lossy()
));
}
match path.as_ref().extension().and_then(|e| e.to_str()) {
Some("7z") => unpack_7z_file(path, &extract_to),
Some("zip") => unpack_zip_file(path, &extract_to),
Some("rar") => unpack_rar(path, &extract_to),
match archive_path.as_ref().extension().and_then(|e| e.to_str()) {
Some("7z") => unpack_7z_file(archive_path, &extract_to),
Some("zip") => unpack_zip_file(archive_path, &extract_to),
Some("rar") => unpack_rar(archive_path, &extract_to),
Some(ext) => {
error!("Unsupported archive format: {}", ext);
Err(anyhow!("Unsupported archive format: {}", ext))
@@ -31,15 +26,15 @@ pub fn unpack(
None => {
error!(
"Failed to determine the file extension for {}",
&path.as_ref().to_string_lossy()
&archive_path.as_ref().to_string_lossy()
);
Err(anyhow!("Failed to determine file extension"))
}
}?;
let new_mod = ModConfig::new(id, id);
unnest_dir(extract_to)?;
Ok(new_mod)
Ok(())
}
fn unpack_7z_file(path: impl AsRef<Path>, to: impl AsRef<Path>) -> anyhow::Result<()> {
@@ -63,3 +58,47 @@ fn unpack_rar(path: impl AsRef<Path>, to: impl AsRef<Path>) -> anyhow::Result<()
Ok(())
}
/// Moves a directorys content into the parent if it is the only dir
fn unnest_dir(path: impl AsRef<Path>) -> anyhow::Result<()> {
let path = path.as_ref();
let Some(nested_dir) = check_nested_dir(path) else {
return Ok(());
};
for entry in fs::read_dir(&nested_dir)? {
let entry = entry?;
let src = entry.path();
let dest = path.join(entry.file_name());
fs::rename(&src, &dest)?;
}
fs::remove_dir(&nested_dir)?;
Ok(())
}
/// Check if the extracted archive has a single directory in it which contains the mod files
fn check_nested_dir(path: impl AsRef<Path>) -> Option<PathBuf> {
let path = path.as_ref();
let entries: Vec<_> = fs::read_dir(path).ok()?.filter_map(|e| e.ok()).collect();
if entries.len() == 1 {
let entry = &entries[0];
let entry_path = entry.path();
if entry_path
.file_name()
.is_some_and(|e| e == "Data" || e == "data")
{
return None;
}
if entry_path.is_dir() {
return Some(entry_path);
}
}
None
}

View File

@@ -10,6 +10,7 @@ pub fn path_to_lowercase(path: impl AsRef<Path>) -> PathBuf {
PathBuf::from(path.as_ref().to_string_lossy().to_lowercase())
}
/// Searches for a path but ignores case. Returns the first it finds.
pub fn resolve_case_insensitive(
base: impl AsRef<Path>,
rel: impl AsRef<Path>,
@@ -44,7 +45,7 @@ pub fn resolve_case_insensitive(
}
/// Use walkdir to walk all actual files in a dir
/// Returns early id any error occurs
/// Returns early if any error occurs
pub fn walk_all_files(
path: impl AsRef<Path>,
) -> Result<impl Iterator<Item = walkdir::DirEntry>, walkdir::Error> {

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://qconsulting.ca/fo3/ModConfig5.0.xsd">
<moduleName>powerofthree's Tweaks</moduleName>
<requiredInstallFiles>
<folder source="Required" destination="" />
</requiredInstallFiles>
<installSteps order="Explicit">
<installStep name="Main">
<optionalFileGroups order="Explicit">
<group name="DLL" type="SelectExactlyOne">
<plugins order="Explicit">
<plugin name="SSE v1.6.629+ (&quot;Anniversary Edition&quot;)">
<description>Select this if you are using Skyrim Anniversary Edition v1.6.629 or higher.</description>
<files>
<folder source="AE/SKSE/Plugins" destination="SKSE/Plugins" priority="0" />
</files>
<typeDescriptor>
<dependencyType>
<defaultType name="Optional" />
<patterns>
<pattern>
<dependencies>
<gameDependency version="1.6" />
</dependencies>
<type name="Recommended" />
</pattern>
<pattern>
<dependencies>
<gameDependency version="1.5" />
</dependencies>
<type name="Optional" />
</pattern>
</patterns>
</dependencyType>
</typeDescriptor>
</plugin>
<plugin name="SSE v1.5.97 (&quot;Special Edition&quot;)">
<description>Select this if you are using Skyrim Special Edition v1.5.97.</description>
<files>
<folder source="SE/SKSE/Plugins" destination="SKSE/Plugins" priority="0" />
</files>
<typeDescriptor>
<dependencyType>
<defaultType name="Optional" />
<patterns>
<pattern>
<dependencies>
<gameDependency version="1.6" />
</dependencies>
<type name="Optional" />
</pattern>
<pattern>
<dependencies>
<gameDependency version="1.5" />
</dependencies>
<type name="Recommended" />
</pattern>
</patterns>
</dependencyType>
</typeDescriptor>
</plugin>
</plugins>
</group>
</optionalFileGroups>
</installStep>
</installSteps>
</config>

View File

@@ -10,171 +10,99 @@ load_order = [
"ccBGSSSE037-Curios.esl",
"ccBGSSSE025-AdvDSGS.esm",
"_ResourcePack.esl",
"RaceMenu.esp",
"SkyUI_SE.esp",
"RaceMenuPlugin.esp",
]
game_file_overrides = [[
"skse64_loader.exe",
"SkyrimSELauncher.exe",
]]
[[mods]]
id = "skyui"
files = [
[
"SkyUI_SE.esp",
"Data/SkyUI_SE.esp",
],
[
"SkyUI_SE.bsa",
"Data/SkyUI_SE.bsa",
],
game_file_overrides = [
"skse64_loader.exe -> SkyrimSELauncher.exe"
]
priority = 0
[[mods]]
id = "skse"
files = [
[
"Data/Scripts/actorbase.pex",
"Data/Scripts/actorbase.pex",
],
[
"Data/Scripts/weather.pex",
"Data/Scripts/weather.pex",
],
[
"skse64_loader.exe",
"skse64_loader.exe",
],
[
"Data/Scripts/headpart.pex",
"Data/Scripts/headpart.pex",
],
[
"Data/Scripts/math.pex",
"Data/Scripts/form.pex",
"Data/Scripts/soulgem.pex",
"Data/Scripts/soulgem.pex",
],
[
"Data/Scripts/modevent.pex",
"Data/Scripts/modevent.pex",
],
[
"Data/Scripts/actorvalueinfo.pex",
"Data/Scripts/actorvalueinfo.pex",
],
[
"Data/Scripts/book.pex",
"Data/Scripts/book.pex",
],
[
"Data/Scripts/formlist.pex",
"Data/Scripts/stringutil.pex",
"Data/Scripts/colorcomponent.pex",
"Data/Scripts/quest.pex",
"Data/Scripts/faction.pex",
"Data/Scripts/combatstyle.pex",
"Data/Scripts/actorbase.pex",
"Data/Scripts/potion.pex",
"Data/Scripts/potion.pex",
],
[
"Data/Scripts/spell.pex",
"Data/Scripts/spell.pex",
],
[
"Data/Scripts/perk.pex",
"Data/Scripts/perk.pex",
],
[
"Data/Scripts/actor.pex",
"Data/Scripts/game.pex",
"Data/Scripts/armor.pex",
"Data/Scripts/headpart.pex",
"Data/Scripts/objectreference.pex",
"Data/Scripts/objectreference.pex",
],
"Data/Scripts/weapon.pex",
"Data/Scripts/perk.pex",
"Data/Scripts/constructibleobject.pex",
"Data/Scripts/armoraddon.pex",
"Data/Scripts/textureset.pex",
"Data/Scripts/scroll.pex",
"Data/Scripts/actorvalueinfo.pex",
"Data/Scripts/equipslot.pex",
"Data/Scripts/art.pex",
"Data/Scripts/colorform.pex",
"Data/Scripts/weather.pex",
"Data/Scripts/gamedata.pex",
"Data/Scripts/skse.pex",
"Data/Scripts/sound.pex",
"Data/Scripts/formtype.pex",
"Data/Scripts/spawnertask.pex",
"Data/Scripts/netimmerse.pex",
"Data/Scripts/ingredient.pex",
"Data/Scripts/book.pex",
"Data/Scripts/ui.pex",
"Data/Scripts/leveleditem.pex",
"Data/Scripts/spell.pex",
"Data/Scripts/leveledspell.pex",
"Data/Scripts/modevent.pex",
"Data/Scripts/keyword.pex",
"Data/Scripts/activemagiceffect.pex",
"Data/Scripts/utility.pex",
"Data/Scripts/shout.pex",
"Data/Scripts/input.pex",
"Data/Scripts/race.pex",
"Data/Scripts/sounddescriptor.pex",
"Data/Scripts/wornobject.pex",
"Data/Scripts/ammo.pex",
"Data/Scripts/defaultobjectmanager.pex",
"Data/Scripts/camera.pex",
"Data/Scripts/apparatus.pex",
"skse64_1_6_1170.dll",
"Data/Scripts/magiceffect.pex",
"Data/Scripts/location.pex",
"Data/Scripts/alias.pex",
"Data/Scripts/treeobject.pex",
"Data/Scripts/leveledactor.pex",
"Data/Scripts/enchantment.pex",
"Data/Scripts/uicallback.pex",
"Data/Scripts/flora.pex",
"Data/Scripts/outfit.pex",
"Data/Scripts/cell.pex",
]
priority = 0
[[mods]]
id = "deadly_spells"
id = "SkyUI-12604-35407"
files = [
[
"000 Core Files/textures/impactdecals/decalsnowhole01_n.dds",
"Data/textures/impactdecals/decalsnowhole01_n.dds",
],
[
"40 Two Fire Esp/textures/impactdecals/decalflamespread01_g.dds",
"Data/textures/impactdecals/decalflamespread01_g.dds",
],
[
"000 Core Files/textures/impactdecals/decalsparkburn01_g.dds",
"Data/textures/impactdecals/decalsparkburn01_g.dds",
],
[
"40 Two Fire Esp/textures/impactdecals/decalflamespread01.dds",
"Data/textures/impactdecals/decalflamespread01.dds",
],
[
"000 Core Files/textures/impactdecals/decalfrostimpact01_n.dds",
"Data/textures/impactdecals/decalfrostimpact01_n.dds",
],
[
"000 Core Files/textures/impactdecals/decalspitimpact01_n.dds",
"Data/textures/impactdecals/decalspitimpact01_n.dds",
],
[
"40 Two Fire Esp/DeadlySpellImpacts - Two Fire.esp",
"Data/DeadlySpellImpacts - Two Fire.esp",
],
[
"000 Core Files/textures/impactdecals/decalsnowmelt01.dds",
"Data/textures/impactdecals/decalsnowmelt01.dds",
],
[
"000 Core Files/textures/impactdecals/decalspitimpact01.dds",
"Data/textures/impactdecals/decalspitimpact01.dds",
],
[
"000 Core Files/textures/impactdecals/decalsnowhole01.dds",
"Data/textures/impactdecals/decalsnowhole01.dds",
],
[
"000 Core Files/textures/impactdecals/decalsparkburn01.dds",
"Data/textures/impactdecals/decalsparkburn01.dds",
],
[
"10 Fire Cracks/textures/impactdecals/decalflameburn01_g.dds",
"Data/textures/impactdecals/decalflameburn01_g.dds",
],
[
"000 Core Files/textures/impactdecals/decalsnowmelt01_n.dds",
"Data/textures/impactdecals/decalsnowmelt01_n.dds",
],
[
"000 Core Files/textures/impactdecals/decalfrostimpact01.dds",
"Data/textures/impactdecals/decalfrostimpact01.dds",
],
[
"10 Fire Cracks/textures/impactdecals/decalflameburn01_n.dds",
"Data/textures/impactdecals/decalflameburn01_n.dds",
],
[
"000 Core Files/textures/impactdecals/decalsnowmelt01_g.dds",
"Data/textures/impactdecals/decalsnowmelt01_g.dds",
],
[
"000 Core Files/textures/impactdecals/decalsparkburn01_n.dds",
"Data/textures/impactdecals/decalsparkburn01_n.dds",
],
[
"000 Core Files/textures/impactdecals/decalsnowhole01_g.dds",
"Data/textures/impactdecals/decalsnowhole01_g.dds",
],
[
"40 Two Fire Esp/Manual Installation of the Two Fire Option.txt",
"Data/Manual Installation of the Two Fire Option.txt",
],
[
"10 Fire Cracks/textures/impactdecals/decalflameburn01.dds",
"Data/textures/impactdecals/decalflameburn01.dds",
],
[
"40 Two Fire Esp/textures/impactdecals/decalflamespread01_n.dds",
"Data/textures/impactdecals/decalflamespread01_n.dds",
],
[
"000 Core Files/DeadlySpellImpacts.esp",
"Data/DeadlySpellImpacts.esp",
],
"SkyUI_SE.bsa -> Data/SkyUI_SE.bsa",
"SkyUI_SE.esp -> Data/SkyUI_SE.esp",
]
priority = 1
priority = 0
[[mods]]
id = "racemenu-19080-465102"
files = [
"RaceMenu.esp -> Data/RaceMenu.esp",
"SKSE/Plugins/skee64.ini -> Data/SKSE/Plugins/skee64.ini",
"RaceMenu.bsa -> Data/RaceMenu.bsa",
"RaceMenuPlugin.esp -> Data/RaceMenuPlugin.esp",
"SKSE/Plugins/skee64.dll -> Data/SKSE/Plugins/skee64.dll",
]
priority = 0

View File

@@ -4,9 +4,11 @@ nexus_api_key = "1234"
[games.example_game]
path = "/home/user/games/sse"
kind = "sse"
[games.sse]
path = "games/sse"
kind = "unkown"
[instances.example1]
path = "example1.toml"

View File

@@ -34,6 +34,7 @@ fn parse() {
"example_04.xml",
"example_05.xml",
"banana.xml",
"po3tweaks.xml"
] {
fomod::Config::load_from_file(get_xml(xml))
.unwrap_or_else(|e| panic!("Parse for {xml} with {}", err_to_string(e)));

View File

@@ -38,14 +38,14 @@ fn parse_complex() {
let unwraped = inst.expect("Asserted before");
assert_eq!(unwraped.game_id(), "sse");
assert_eq!(unwraped.load_order().len(), 11);
assert_eq!(unwraped.load_order().len(), 13);
assert_eq!(
unwraped.game_file_overrides().first().unwrap(),
&Link::new("skse64_loader.exe", "SkyrimSELauncher.exe")
);
assert_eq!(unwraped.mods().len(), 3);
let test_mod = unwraped.mods().iter().find(|e| e.mod_id() == "skyui");
let test_mod = unwraped.mods().iter().find(|e| e.mod_id() == "SkyUI-12604-35407");
assert!(test_mod.is_some());
assert_eq!(test_mod.unwrap().priority(), 0);