use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::Deserialize;
use tracing::{debug, info, warn};
use modde_core::resolver::{LoadOrderRule, ModId};
#[derive(Debug, Clone, Default)]
pub struct LootMasterlist {
pub plugins: HashMap<String, LootPluginEntry>,
}
#[derive(Debug, Clone, Default)]
pub struct LootPluginEntry {
pub after: Vec<String>,
pub requires: Vec<String>,
pub incompatible: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RawMasterlist {
#[serde(default)]
plugins: Vec<RawPlugin>,
}
#[derive(Debug, Deserialize)]
struct RawPlugin {
name: String,
#[serde(default)]
after: Vec<RawFileRef>,
#[serde(default)]
requires: Vec<RawFileRef>,
#[serde(default)]
incompatible: Vec<RawFileRef>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawFileRef {
Simple(String),
Complex { name: String },
}
impl RawFileRef {
fn name(&self) -> &str {
match self {
RawFileRef::Simple(s) => s,
RawFileRef::Complex { name } => name,
}
}
}
impl LootMasterlist {
pub fn from_yaml(yaml: &str) -> Result<Self> {
let raw: RawMasterlist =
serde_yaml_ng::from_str(yaml).context("failed to parse LOOT masterlist YAML")?;
let mut plugins = HashMap::new();
for plugin in raw.plugins {
let key = plugin.name.to_lowercase();
let entry = LootPluginEntry {
after: plugin.after.iter().map(|r| r.name().to_lowercase()).collect(),
requires: plugin.requires.iter().map(|r| r.name().to_lowercase()).collect(),
incompatible: plugin.incompatible.iter().map(|r| r.name().to_lowercase()).collect(),
};
if !entry.after.is_empty() || !entry.requires.is_empty() || !entry.incompatible.is_empty() {
plugins.insert(key, entry);
}
}
info!(plugin_count = plugins.len(), "parsed LOOT masterlist");
Ok(Self { plugins })
}
pub fn from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read LOOT masterlist: {}", path.display()))?;
Self::from_yaml(&content)
}
pub fn rules_for_plugins(&self, active_plugins: &[&str]) -> Vec<LoadOrderRule> {
let active_lower: HashMap<String, &str> = active_plugins
.iter()
.map(|p| (p.to_lowercase(), *p))
.collect();
let mut rules = Vec::new();
for plugin_name in active_plugins {
let key = plugin_name.to_lowercase();
if let Some(entry) = self.plugins.get(&key) {
for after_name in &entry.after {
if let Some(&original) = active_lower.get(after_name) {
rules.push(LoadOrderRule::LoadAfter {
mod_id: ModId::from(*plugin_name),
after: ModId::from(original),
});
}
}
for req_name in &entry.requires {
if let Some(&original) = active_lower.get(req_name) {
rules.push(LoadOrderRule::LoadAfter {
mod_id: ModId::from(*plugin_name),
after: ModId::from(original),
});
} else {
debug!(
plugin = *plugin_name,
requires = req_name.as_str(),
"required plugin not in active set"
);
}
}
for incompat_name in &entry.incompatible {
if let Some(&original) = active_lower.get(incompat_name) {
rules.push(LoadOrderRule::Incompatible {
mod_a: ModId::from(*plugin_name),
mod_b: ModId::from(original),
});
}
}
}
}
debug!(rule_count = rules.len(), "generated load order rules from LOOT masterlist");
rules
}
}
pub fn masterlist_url(game_id: &str) -> Option<&'static str> {
match game_id {
"skyrim-se" | "skyrim-ae" => Some(
"https://raw.githubusercontent.com/loot/skyrimse/master/masterlist.yaml",
),
"fallout4" => Some(
"https://raw.githubusercontent.com/loot/fallout4/master/masterlist.yaml",
),
"fallout76" => Some(
"https://raw.githubusercontent.com/loot/fallout76/master/masterlist.yaml",
),
"starfield" => Some(
"https://raw.githubusercontent.com/loot/starfield/master/masterlist.yaml",
),
_ => {
warn!(game_id, "no LOOT masterlist URL known for game");
None
}
}
}
pub fn masterlist_cache_path(game_id: &str) -> std::path::PathBuf {
modde_core::paths::data_dir().join("loot").join(format!("{game_id}.yaml"))
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_YAML: &str = r#"
plugins:
- name: "Unofficial Skyrim Special Edition Patch.esp"
after:
- "Skyrim.esm"
- "Update.esm"
- "Dawnguard.esm"
requires:
- "Skyrim.esm"
- name: "SkyUI_SE.esp"
after:
- "Unofficial Skyrim Special Edition Patch.esp"
- name: "BadMod.esp"
incompatible:
- name: "ConflictMod.esp"
"#;
#[test]
fn test_parse_masterlist() {
let ml = LootMasterlist::from_yaml(TEST_YAML).unwrap();
assert_eq!(ml.plugins.len(), 3);
let ussep = &ml.plugins["unofficial skyrim special edition patch.esp"];
assert_eq!(ussep.after.len(), 3);
assert_eq!(ussep.requires.len(), 1);
assert!(ussep.incompatible.is_empty());
let skyui = &ml.plugins["skyui_se.esp"];
assert_eq!(skyui.after.len(), 1);
assert_eq!(skyui.after[0], "unofficial skyrim special edition patch.esp");
}
#[test]
fn test_rules_for_plugins() {
let ml = LootMasterlist::from_yaml(TEST_YAML).unwrap();
let active = vec![
"Skyrim.esm",
"Update.esm",
"Dawnguard.esm",
"Unofficial Skyrim Special Edition Patch.esp",
"SkyUI_SE.esp",
];
let rules = ml.rules_for_plugins(&active);
let load_after_count = rules.iter().filter(|r| matches!(r, LoadOrderRule::LoadAfter { .. })).count();
assert_eq!(load_after_count, 5);
}
#[test]
fn test_incompatible_rules() {
let ml = LootMasterlist::from_yaml(TEST_YAML).unwrap();
let active = vec!["BadMod.esp", "ConflictMod.esp"];
let rules = ml.rules_for_plugins(&active);
let incompat_count = rules.iter().filter(|r| matches!(r, LoadOrderRule::Incompatible { .. })).count();
assert_eq!(incompat_count, 1);
}
#[test]
fn test_inactive_plugins_excluded() {
let ml = LootMasterlist::from_yaml(TEST_YAML).unwrap();
let active = vec!["SkyUI_SE.esp"];
let rules = ml.rules_for_plugins(&active);
assert!(rules.is_empty());
}
#[test]
fn test_masterlist_urls() {
assert!(masterlist_url("skyrim-se").is_some());
assert!(masterlist_url("skyrim-ae").is_some());
assert!(masterlist_url("fallout4").is_some());
assert!(masterlist_url("cyberpunk2077").is_none());
}
}