Compare commits

...

18 Commits

Author SHA1 Message Date
bdeebfee4f changed the way files get resolved on mod install
to better handle fomod and it's required interactivity
2026-03-30 22:07:46 +02:00
7e20cd370c removed callback function from fomod installer 2026-03-29 22:48:21 +02:00
ea50f4d59b improved mod list 2026-03-29 15:36:53 +02:00
49f38cb21a get mod game type 2026-03-29 15:36:35 +02:00
e70c6e6901 handle instance select in own state 2026-03-28 21:23:13 +01:00
93676901c0 moved block for mod_list out of app 2026-03-28 20:48:50 +01:00
2322cd00d2 added selectable instance to tui 2026-03-27 12:16:08 +01:00
d746e830fd added instances function to root_config 2026-03-27 12:15:38 +01:00
ac7b07ee3d added basic tui 2026-03-23 14:43:47 +01:00
3f91386763 unnest archives with only one dir in it 2026-03-22 14:27:16 +01:00
560562cc25 added new fomod test case 2026-03-22 14:26:39 +01:00
f404f597c1 fixed fomod plugin type deps 2026-03-22 14:25:27 +01:00
b3126d1798 added swf to included files 2026-03-22 14:24:37 +01:00
bdd5d849eb improved unpack unterface & removed unpack command 2026-03-20 13:14:22 +01:00
ddf76602be fixed typo 2026-03-20 13:10:29 +01:00
4a152f07da improved NexusID parsing 2026-03-20 13:10:23 +01:00
afc3f68f36 fix missing GameType in root_config game 2026-03-20 13:10:07 +01:00
fcc65f68bb save Link as a different format in toml 2026-03-20 13:08:56 +01:00
28 changed files with 1899 additions and 334 deletions

1002
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,11 +6,13 @@ edition = "2024"
[dependencies]
anyhow = "1.0.102"
clap = { version = "4.5.60", features = ["derive"] }
crossterm = "0.29.0"
env_logger = "0.11.9"
globset = "0.4.18"
libloot = "0.29.0"
log = "0.4.29"
quick-xml = { version = "0.39.2", features = ["serde-types", "serialize"] }
ratatui = "0.30.0"
serde = { version = "1.0.228", features = ["derive"] }
sevenz-rust2 = { version = "0.20.2" }
thiserror = "2.0.18"

View File

@@ -7,5 +7,5 @@ mod load_order;
pub use activate::{ActivationError, activate_instance};
pub use download::handle_nxm;
pub use include::insert_mod_to_instance;
pub use install::resolve_files_for_install;
pub use install::{resolve_files_for_install, ResolveFileResult};
pub use load_order::{LoadOrderError, create_loadorder};

View File

@@ -32,7 +32,8 @@ pub fn handle_nxm(root_config: &mut RootConfig, raw_url: &str) -> anyhow::Result
return Err(anyhow!("Mod with generated id already exists"));
}
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);

View File

@@ -4,30 +4,34 @@ use std::{
};
use globset::{Glob, GlobSet, GlobSetBuilder};
use log::{debug, trace};
use crate::{
fomod, install_prompt,
mod_config_installer::run_fomod_installer,
types::{ModConfig, ModFile, ModdedInstance, RootConfig},
fomod,
types::{ModConfig, ModFile, RootConfig},
utils::{resolve_case_insensitive, walk_all_files},
};
pub fn resolve_files_for_install(
root_config: &RootConfig,
instance: &ModdedInstance,
mod_to_install: &ModConfig,
) -> anyhow::Result<Vec<ModFile>> {
) -> anyhow::Result<ResolveFileResult> {
let mod_location = root_config.mod_location().join(mod_to_install.path());
let files = match determain_mod_kind(mod_to_install, &mod_location)? {
ModKind::Fomod(xml_path) => install_fomod(instance, xml_path, &mod_location)?,
ModKind::EmbeddedData(_data_path) => install_from_dir(mod_to_install, mod_location)?,
ModKind::Root => install_root(mod_to_install, mod_location)?,
ModKind::Unkown => install_from_dir_to_data(mod_to_install, mod_location)?,
let result = match determain_mod_kind(mod_to_install, &mod_location)? {
ModKind::Fomod(xml_path) => {
let module_config = fomod::Config::load_from_file(xml_path)?;
ResolveFileResult::Fomod(module_config)
}
ModKind::EmbeddedData(_data_path) => {
ResolveFileResult::Files(install_from_dir(mod_to_install, mod_location)?)
}
ModKind::Root => ResolveFileResult::Files(install_root(mod_to_install, mod_location)?),
ModKind::Unkown => {
ResolveFileResult::Files(install_from_dir_to_data(mod_to_install, mod_location)?)
}
};
Ok(files)
Ok(result)
}
fn determain_mod_kind(
@@ -51,32 +55,38 @@ fn determain_mod_kind(
}
}
fn install_fomod(
instance: &ModdedInstance,
module_config_path: impl AsRef<Path>,
mod_root: impl AsRef<Path>,
) -> anyhow::Result<Vec<ModFile>> {
debug!("Running FOmod installer");
let module_config = fomod::Config::load_from_file(module_config_path)?;
let active_plugins: Vec<_> = instance
.active_plugins()
.map(|e| e.to_string_lossy())
.map(|e| e.to_string())
.collect();
trace!("Current loded plugins: {:?}", active_plugins);
let files = run_fomod_installer(module_config, &active_plugins, install_prompt::prompt)?;
let mod_files: Vec<_> = files
.iter()
.map(|f| ModFile::from_installer(f.clone(), &mod_root))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect();
Ok(mod_files)
}
// fn install_fomod(
// instance: &ModdedInstance,
// module_config_path: impl AsRef<Path>,
// mod_root: impl AsRef<Path>,
// ) -> anyhow::Result<Vec<ModFile>> {
// debug!("Running FOmod installer");
// let module_config = fomod::Config::load_from_file(module_config_path)?;
//
// let active_plugins: Vec<_> = instance
// .active_plugins()
// .map(|e| e.to_string_lossy())
// .map(|e| e.to_string())
// .collect();
//
// trace!("Current loded plugins: {:?}", active_plugins);
//
// let mut installer = FomodInstaller::new(&module_config, &active_plugins);
// let mut selection: Option<Vec<usize>> = None;
// while let Some(prompt) = installer.run_step(selection.as_deref()) {
// selection = Some(install_prompt::prompt(prompt));
// }
// let files = installer.finalize();
//
// let mod_files: Vec<_> = files
// .iter()
// .map(|f| ModFile::from_installer(f.clone(), &mod_root))
// .collect::<Result<Vec<_>, _>>()?
// .into_iter()
// .flatten()
// .collect();
// Ok(mod_files)
// }
fn install_from_dir(
mod_config: &ModConfig,
@@ -143,6 +153,11 @@ enum ModKind {
Unkown,
}
pub enum ResolveFileResult {
Files(Vec<ModFile>),
Fomod(fomod::Config),
}
fn should_be_included(path: impl AsRef<Path>) -> bool {
matches!(
path.as_ref().extension().and_then(|e| e.to_str()),
@@ -160,6 +175,7 @@ fn should_be_included(path: impl AsRef<Path>) -> bool {
| "ilstrings"
| "dlstrings"
| "dll"
| "swf"
)
)
}

View File

@@ -19,5 +19,5 @@ pub enum Commands {
LoadOrder { instance: String },
ApiCheck,
Download { url: String },
Unpack { id: String, path: String },
Tui,
}

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

@@ -8,3 +8,4 @@ pub mod types;
pub mod unpacker;
pub mod utils;
pub mod actions;
pub mod tui;

View File

@@ -5,13 +5,14 @@ use std::{error::Error, path::Path};
use fomod_manager::{
actions::{
activate_instance, create_loadorder, handle_nxm, insert_mod_to_instance,
ResolveFileResult, activate_instance, create_loadorder, handle_nxm, insert_mod_to_instance,
resolve_files_for_install,
},
cli::{self, Args},
mod_config_installer::FomodInstaller,
nexus::NexusAPI,
types::{ModConfig, RootConfig},
unpacker::unpack,
tui,
types::RootConfig,
};
fn command_activate(
@@ -30,7 +31,19 @@ fn command_add(root_config: &RootConfig, instance_id: &str, mod_id: &str) -> any
.mod_by_id(mod_id)
.ok_or(anyhow!("Can't find mod in config"))?;
let files = resolve_files_for_install(root_config, &instance, &mod_to_install)?;
let files = match resolve_files_for_install(root_config, &mod_to_install)? {
ResolveFileResult::Files(mod_files) => mod_files,
ResolveFileResult::Fomod(module_config) => {
let mod_location = root_config.mod_location().join(mod_to_install.path());
let active_plugins: Vec<String> = instance.active_plugins().collect();
let mut installer = FomodInstaller::new(&module_config, &active_plugins);
let mut selection: Option<Vec<usize>> = None;
while let Some(prompt) = installer.run_step(selection.as_deref()) {
selection = Some(fomod_manager::install_prompt::prompt(prompt));
}
installer.finalize(mod_location)?
}
};
match insert_mod_to_instance(&mut instance, &mod_to_install, &files, 0) {
None => {
@@ -70,30 +83,9 @@ fn command_download(root_config: &mut RootConfig, raw_url: &str) -> anyhow::Resu
Ok(())
}
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"));
}
unpack(root_config, id, file)?;
let new_mod = ModConfig::new(id, id);
root_config.add_mod(&new_mod);
root_config.save_to_file()?;
Ok(())
}
fn setup_logger() {
env_logger::builder()
.filter_level(log::LevelFilter::max())
.filter_level(log::LevelFilter::Off)
.format_timestamp(None)
.filter_module("ureq_proto::util", log::LevelFilter::Debug)
.filter_module("rustls::client::hs", log::LevelFilter::Debug)
@@ -124,8 +116,8 @@ 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)?;
cli::Commands::Tui => {
tui::run(&mut root_config)?;
}
}

View File

@@ -1,10 +1,13 @@
use std::{collections::HashMap, fmt::Display};
use std::{collections::HashMap, fmt::Display, io, path::Path};
use log::{debug, warn};
use crate::fomod::{
CompositeDependency, Config, DependencyOperator, DependencyState, FileList, FileTypeEnum,
Group, GroupType, ModuleDependency, Plugin, PluginTypeDescriptorEnum, PluginTypeEnum,
use crate::{
fomod::{
CompositeDependency, Config, DependencyOperator, DependencyState, FileList, FileTypeEnum,
Group, GroupType, ModuleDependency, Plugin, PluginTypeDescriptorEnum, PluginTypeEnum,
},
types::ModFile,
};
#[derive(Debug)]
@@ -117,6 +120,7 @@ fn evaluate_module_depbendecy(
}
}
#[derive(Debug)]
pub struct GroupPrompt {
pub name: String,
pub select_type: GroupType,
@@ -146,6 +150,7 @@ impl GroupPrompt {
}
}
#[derive(Debug)]
pub struct InstallOption {
pub name: String,
pub option_type: PluginTypeEnum,
@@ -176,7 +181,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;
}
}
@@ -187,66 +196,90 @@ fn resolve_plugin_type(
}
}
pub fn run_fomod_installer(
fomod_config: Config,
installed_plugins: &[String],
group_prompt: fn(GroupPrompt) -> Vec<usize>,
) -> anyhow::Result<Vec<FileTypeEnum>> {
let mut state = InstallerState::new();
pub struct FomodInstaller<'a> {
state: InstallerState,
current_step: (usize, usize),
config: &'a Config,
installed_plugins: &'a [String],
}
// Always-installed files first
if let Some(required) = &fomod_config.required_install_files {
state.add_files(required);
impl<'a> FomodInstaller<'a> {
pub fn new(fomod_config: &'a Config, installed_plugins: &'a [String]) -> Self {
let mut state = InstallerState::new();
if let Some(required) = &fomod_config.required_install_files {
state.add_files(required);
}
Self {
state,
current_step: (0, 0),
config: fomod_config,
installed_plugins,
}
}
if let Some(install_steps) = fomod_config.install_steps {
let steps = &install_steps.install_step;
pub fn run_step(&mut self, selection: Option<&[usize]>) -> Option<GroupPrompt> {
let Some(install_steps) = &self.config.install_steps else {
return None;
};
for step in steps {
// Check if the step should be visible
if step
.visible
.as_ref()
.is_some_and(|v| !evaluate_module_depbendecy(v, &state, installed_plugins))
{
// Dependency to show the step not meet. Skipping.
continue;
}
let step = install_steps.install_step.get(self.current_step.0)?;
for group in &step.optional_file_groups.group {
// TODO: Skip groups where all plugins are NotUsable
// Check if the step should be visible
if step
.visible
.as_ref()
.is_some_and(|v| !evaluate_module_depbendecy(v, &self.state, self.installed_plugins))
{
// Dependency to show the step not meet. Skipping.
self.current_step = (self.current_step.0 + 1, 0);
return self.run_step(selection);
}
let prompt = GroupPrompt::new(group, &state, installed_plugins);
let Some(group) = step.optional_file_groups.group.get(self.current_step.1) else {
self.current_step = (self.current_step.0 + 1, 0);
return self.run_step(selection);
};
let selected_plugins = (group_prompt)(prompt);
// TODO: Skip groups where all plugins are NotUsable
match selection {
Some(selected_plugins) => {
for i in selected_plugins {
let plugin = &group.plugins.plugin[i];
let plugin = &group.plugins.plugin[*i];
// Add files from selected plugin
if let Some(files) = &plugin.files {
state.add_files(files);
self.state.add_files(files);
}
// Set condition flags
if let Some(condition_flags) = &plugin.condition_flags {
for flag in &condition_flags.flag {
state.set_flag(&flag.name, &flag.flag_value);
self.state.set_flag(&flag.name, &flag.flag_value);
}
}
}
// Next step
self.current_step = (self.current_step.0, self.current_step.1 + 1);
self.run_step(None)
}
None => Some(GroupPrompt::new(group, &self.state, self.installed_plugins)),
}
}
// Evaluate conditional file installs based on final flag state
if let Some(conditional) = &fomod_config.conditional_file_installs {
for pattern in &conditional.patterns.pattern {
if evaluate_module_depbendecy(&pattern.dependencies, &state, installed_plugins) {
state.add_files(&pattern.files);
}
}
}
pub fn finalize(self, mod_root: impl AsRef<Path>) -> Result<Vec<ModFile>, io::Error> {
let files: Vec<_> = self
.state
.into_file_list()
.iter()
.map(|f| ModFile::from_installer(f.clone(), &mod_root))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect();
Ok(state.into_file_list())
Ok(files)
}
}

6
src/tui.rs Normal file
View File

@@ -0,0 +1,6 @@
mod app;
mod mod_list;
mod status;
mod instance;
pub use app::run;

137
src/tui/app.rs Normal file
View File

@@ -0,0 +1,137 @@
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use log::error;
use ratatui::{
DefaultTerminal, Frame,
layout::{Constraint, Direction, Layout, Rect},
widgets::{StatefulWidget, Widget},
};
use crate::{
tui::{
instance::{InstanceSelect, InstanceSelectState},
mod_list::{ModList, ModListState},
status::StatusBar,
},
types::{ModdedInstance, RootConfig},
};
pub fn run(root_config: &mut RootConfig) -> anyhow::Result<()> {
ratatui::run(|terminal| App::new(root_config).run(terminal))?;
Ok(())
}
#[derive(Debug)]
struct App<'a> {
root_config: &'a mut RootConfig,
loaded_instance: Option<ModdedInstance>,
exit: bool,
mod_list_state: ModListState,
selected_instance_state: InstanceSelectState,
}
impl<'a> App<'a> {
fn new(root_config: &'a mut RootConfig) -> Self {
let mut mod_list_state = ModListState::new();
mod_list_state.update_list(root_config, None);
Self {
root_config,
loaded_instance: None,
exit: false,
mod_list_state,
selected_instance_state: InstanceSelectState::new(),
}
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
terminal.clear()?;
while !self.exit {
terminal.draw(|frame| self.draw(frame))?;
self.handle_events()?;
}
Ok(())
}
fn draw(&mut self, frame: &mut Frame) {
frame.render_widget(self, frame.area());
}
fn handle_events(&mut self) -> io::Result<()> {
match event::read()? {
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event)
}
_ => {}
};
Ok(())
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Esc | KeyCode::Char('q') => self.exit(),
KeyCode::Up | KeyCode::Char('k') => {
self.mod_list_state.select_prev();
}
KeyCode::Down | KeyCode::Char('j') => {
self.mod_list_state.select_next();
}
KeyCode::Right | KeyCode::Char('l') => {
self.selected_instance_state.next_instance(self.root_config);
self.load_instance();
}
KeyCode::Left | KeyCode::Char('h') => {
self.selected_instance_state.prev_instance(self.root_config);
self.load_instance();
}
_ => {}
}
}
fn exit(&mut self) {
self.exit = true;
}
fn load_instance(&mut self) {
let Some(selected) = self.selected_instance_state.instance() else {
return;
};
match self.root_config.load_instance_by_id(selected) {
Ok(instance) => {
self.loaded_instance = Some(instance);
self.mod_list_state
.update_list(self.root_config, self.loaded_instance.as_ref());
}
Err(err) => {
error!("Failed to load instance: {err}");
self.exit();
}
}
}
}
impl<'a> Widget for &mut App<'a> {
fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized,
{
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(1),
])
.split(area);
InstanceSelect.render(chunks[0], buf, &mut self.selected_instance_state);
ModList.render(chunks[1], buf, &mut self.mod_list_state);
StatusBar.render(chunks[2], buf);
}
}

92
src/tui/instance.rs Normal file
View File

@@ -0,0 +1,92 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Style,
widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
};
use crate::types::RootConfig;
#[derive(Debug)]
pub struct InstanceSelectState {
selected: Option<String>,
}
impl InstanceSelectState {
pub fn new() -> Self {
Self { selected: None }
}
pub fn instance(&self) -> Option<&str> {
self.selected.as_deref()
}
pub fn next_instance(&mut self, root_config: &RootConfig) {
let mut instances = root_config.instances();
instances.sort();
if instances.is_empty() {
self.selected = None;
return;
}
let next = match &self.selected {
None => instances.first().cloned(),
Some(curr) => {
let idx = instances.iter().position(|x| x == curr);
match idx {
Some(i) => {
let next_index = (i + 1) % instances.len();
instances.get(next_index).cloned()
}
None => instances.first().cloned(),
}
}
};
self.selected = next;
}
pub fn prev_instance(&mut self, root_config: &RootConfig) {
let mut instances = root_config.instances();
instances.sort();
if instances.is_empty() {
self.selected = None;
return;
}
let prev = match &self.selected {
None => instances.last().cloned(),
Some(curr) => {
let idx = instances.iter().position(|x| x == curr);
match idx {
Some(i) => {
let prev_index = if i == 0 { instances.len() - 1 } else { i - 1 };
instances.get(prev_index).cloned()
}
None => Some(instances[instances.len() - 1].clone()),
}
}
};
self.selected = prev;
}
}
pub struct InstanceSelect;
impl StatefulWidget for InstanceSelect {
type State = InstanceSelectState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let list_block = Block::default()
.title("Instance")
.borders(Borders::ALL)
.style(Style::default());
Paragraph::new(state.selected.clone().unwrap_or("None".to_owned()))
.block(list_block)
.render(area, buf);
}
}

114
src/tui/mod_list.rs Normal file
View File

@@ -0,0 +1,114 @@
use std::collections::HashSet;
use ratatui::{
buffer::Buffer,
layout::{Constraint, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Cell, Row, StatefulWidget, Table, TableState},
};
use crate::types::{ModConfig, ModdedInstance, RootConfig};
#[derive(Debug)]
pub struct ModListState {
table_state: TableState,
items: Vec<ListItem>,
}
impl ModListState {
pub fn new() -> Self {
Self {
table_state: TableState::new(),
items: Vec::new(),
}
}
pub fn select_next(&mut self) {
self.table_state.select_next();
}
pub fn select_prev(&mut self) {
self.table_state.select_previous();
}
pub fn update_list(
&mut self,
root_config: &RootConfig,
loaded_instance: Option<&ModdedInstance>,
) {
let instance_game_type = loaded_instance
.and_then(|e| root_config.game_by_id(e.game_id()))
.map(|e| e.game_type());
let included_ids: Option<HashSet<_>> =
loaded_instance.map(|instance| instance.mods().iter().map(|m| m.mod_id()).collect());
let mut items: Vec<_> = root_config
.mods()
.iter()
.filter(|e| instance_game_type.clone().is_none_or(|gt| e.1.game() == gt))
.map(|(id, config)| ListItem {
id: id.to_owned(),
mod_config: config.clone(),
included: included_ids
.as_ref()
.is_some_and(|set| set.contains(id.as_str())),
})
.collect();
items.sort_by_key(|item| item.id.clone());
self.items = items;
}
}
#[derive(Debug)]
struct ListItem {
mod_config: ModConfig,
id: String,
included: bool,
}
#[derive(Debug)]
pub struct ModList;
impl StatefulWidget for ModList {
type State = ModListState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let block = Block::default()
.title("Mod list")
.borders(Borders::ALL)
.style(Style::default());
let rows: Vec<Row> = state
.items
.iter()
.map(|item| {
Row::new(vec![
Cell::from(item.mod_config.name().unwrap_or(&item.id)),
Cell::from(item.id.as_str()),
Cell::from(if item.included { "" } else { "" }),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
],
)
.row_highlight_style(
Style::default()
.fg(Color::Yellow)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.block(block);
StatefulWidget::render(table, area, buf, &mut state.table_state);
}
}

12
src/tui/status.rs Normal file
View File

@@ -0,0 +1,12 @@
use ratatui::{text::Line, widgets::Widget};
pub struct StatusBar;
impl Widget for StatusBar {
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized {
Line::from("Up Down Left right").render(area, buf);
}
}

View File

@@ -19,10 +19,10 @@ pub struct Game {
}
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: GameType::default(),
kind: game_type,
}
}
@@ -45,4 +45,8 @@ impl Game {
pub fn install_location(&self) -> &Path {
&self.path
}
pub fn game_type(&self) -> GameType {
self.kind.clone()
}
}

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

@@ -32,6 +32,8 @@ pub struct ModConfig {
nexus_id: Option<NexusID>,
#[serde(default)]
#[serde(skip_serializing_if = "is_default")]
game: GameType,
}
@@ -91,8 +93,16 @@ impl ModConfig {
pub fn nexus_id(&self) -> Option<&NexusID> {
self.nexus_id.as_ref()
}
pub fn game(&self) -> GameType {
self.game.clone()
}
}
fn is_false(b: &bool) -> bool {
!b
}
fn is_default<T: Default + PartialEq>(t: &T) -> bool {
t == &T::default()
}

View File

@@ -1,5 +1,4 @@
use std::{
ffi::OsStr,
fs::{self, read_to_string},
io::Write,
path::{Path, PathBuf},
@@ -106,8 +105,12 @@ impl ModdedInstance {
&self.mods
}
pub fn active_plugins(&self) -> impl Iterator<Item = &OsStr> {
self.mods.iter().flat_map(|e| e.active_plugins())
pub fn active_plugins(&self) -> impl Iterator<Item = String> {
self.mods
.iter()
.flat_map(|e| e.active_plugins())
.map(|e| e.to_string_lossy())
.map(|e| e.to_string())
}
}
@@ -156,6 +159,5 @@ mod tests {
let new_mod = InstalledMod::new("mod1", 1);
cfg.update_or_create_mod(&new_mod);
}
}

View File

@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::str::FromStr;
#[derive(Debug, Deserialize, Serialize, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NexusID {
mod_id: u64,
file_id: u64,
@@ -11,3 +12,59 @@ impl NexusID {
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()
}
@@ -77,18 +87,32 @@ impl RootConfig {
self.mods.get(id).map(|e| e.clone().add_id(id))
}
pub fn mods(&self) -> &HashMap<String, ModConfig> {
&self.mods
}
pub fn add_mod(&mut self, new_mod: &ModConfig) {
self.mods.insert(new_mod.id().to_owned(), new_mod.clone());
}
pub fn instances(&self) -> Vec<String> {
self.instances.keys().cloned().collect()
}
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 +120,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 +138,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 +158,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 +196,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,25 +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::RootConfig;
pub fn unpack(root_config: &RootConfig, id: &str, path: impl AsRef<Path>) -> anyhow::Result<()> {
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))
@@ -27,12 +26,14 @@ pub fn unpack(root_config: &RootConfig, id: &str, path: impl AsRef<Path>) -> any
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"))
}
}?;
unnest_dir(extract_to)?;
Ok(())
}
@@ -57,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

@@ -20,7 +20,12 @@ fn add_plain() -> Result<(), Box<dyn Error>> {
let mod_to_install = root_config
.mod_by_id("add_test_plain")
.expect("Mod not found");
let files_to_add = resolve_files_for_install(&root_config, &instance, &mod_to_install)?;
let files_to_add = match resolve_files_for_install(&root_config, &mod_to_install)? {
fomod_manager::actions::ResolveFileResult::Files(mod_files) => mod_files,
_ => {
panic!("Resolved files have wrong type");
}
};
insert_mod_to_instance(&mut instance, &mod_to_install, &files_to_add, 0);
@@ -53,7 +58,12 @@ fn add_nested() -> Result<(), Box<dyn Error>> {
let mod_to_install = root_config
.mod_by_id("add_test_nested")
.expect("Mod not found");
let files_to_add = resolve_files_for_install(&root_config, &instance, &mod_to_install)?;
let files_to_add = match resolve_files_for_install(&root_config, &mod_to_install)? {
fomod_manager::actions::ResolveFileResult::Files(mod_files) => mod_files,
_ => {
panic!("Resolved files have wrong type");
}
};
insert_mod_to_instance(&mut instance, &mod_to_install, &files_to_add, 0);
@@ -86,7 +96,12 @@ fn add_root() -> Result<(), Box<dyn Error>> {
let mod_to_install = root_config
.mod_by_id("add_test_root")
.expect("Mod not found");
let files_to_add = resolve_files_for_install(&root_config, &instance, &mod_to_install)?;
let files_to_add = match resolve_files_for_install(&root_config, &mod_to_install)? {
fomod_manager::actions::ResolveFileResult::Files(mod_files) => mod_files,
_ => {
panic!("Resolved files have wrong type");
}
};
insert_mod_to_instance(&mut instance, &mod_to_install, &files_to_add, 0);
@@ -117,7 +132,12 @@ fn add_filter() -> Result<(), Box<dyn Error>> {
let mod_to_install = root_config
.mod_by_id("add_test_filter")
.expect("Mod not found");
let files_to_add = resolve_files_for_install(&root_config, &instance, &mod_to_install)?;
let files_to_add = match resolve_files_for_install(&root_config, &mod_to_install)? {
fomod_manager::actions::ResolveFileResult::Files(mod_files) => mod_files,
_ => {
panic!("Resolved files have wrong type");
}
};
insert_mod_to_instance(&mut instance, &mod_to_install, &files_to_add, 0);

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

@@ -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);