Skip to main content

modde_games/bannerlord/
mod.rs

1//! The Mount & Blade II: Bannerlord game plugin: `Modules` layout plus
2//! `SubModule.xml` parsing and dependency checking.
3
4pub mod saves;
5pub mod scanner;
6
7use std::path::{Path, PathBuf};
8
9use anyhow::Context;
10use modde_core::installer::InstallMethod;
11
12use crate::policies::{BareLayoutPolicy, ContentPolicy};
13use crate::traits::{ContentCategory, GamePlugin, ModSafety};
14
15/// [`GamePlugin`] for Mount & Blade II: Bannerlord.
16pub struct BannerlordGame;
17
18pub static BANNERLORD: BannerlordGame = BannerlordGame;
19
20const BANNERLORD_SAVE_BREAKING_EXT: &[&str] = &["dll", "xml", "xslt", "xsl", "pak"];
21const BANNERLORD_COSMETIC_EXT: &[&str] = &["png", "jpg", "dds", "tga"];
22const BANNERLORD_CONTENT_CATEGORIES: &[(&str, ContentCategory)] = &[
23    ("dll", ContentCategory::Binary),
24    ("xml", ContentCategory::Config),
25    ("xslt", ContentCategory::Config),
26    ("xsl", ContentCategory::Config),
27    ("pak", ContentCategory::Archive),
28    ("dds", ContentCategory::Texture),
29    ("png", ContentCategory::Texture),
30    ("tga", ContentCategory::Texture),
31    ("jpg", ContentCategory::Texture),
32];
33
34const BANNERLORD_CONTENT_POLICY: ContentPolicy = ContentPolicy {
35    save_breaking_ext: BANNERLORD_SAVE_BREAKING_EXT,
36    cosmetic_ext: BANNERLORD_COSMETIC_EXT,
37    save_breaking_dirs: &["bin", "submodule.xml"],
38    categories: BANNERLORD_CONTENT_CATEGORIES,
39};
40
41const BANNERLORD_BARE_LAYOUT_POLICY: BareLayoutPolicy = BareLayoutPolicy {
42    root_dirs: &["modules"],
43    root_file_exts: &["xml"],
44    case_insensitive_dirs: true,
45};
46
47/// Module identity and declared dependencies parsed from a `SubModule.xml`.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct BannerlordModuleInfo {
50    pub id: String,
51    pub name: String,
52    pub dependencies: Vec<String>,
53}
54
55/// Parse a Bannerlord `SubModule.xml` into a [`BannerlordModuleInfo`].
56pub fn parse_submodule_xml(path: &Path) -> anyhow::Result<BannerlordModuleInfo> {
57    let content = std::fs::read_to_string(path)
58        .with_context(|| format!("failed to read {}", path.display()))?;
59    let id = attr_after(&content, "<Id", "value").unwrap_or_else(|| {
60        path.parent()
61            .and_then(|parent| parent.file_name())
62            .map(|name| name.to_string_lossy().to_string())
63            .unwrap_or_else(|| "unknown".to_string())
64    });
65    let name = attr_after(&content, "<Name", "value").unwrap_or_else(|| id.clone());
66    let dependencies = content
67        .lines()
68        .filter(|line| line.contains("<DependedModule"))
69        .filter_map(|line| attr_after(line, "<DependedModule", "Id"))
70        .collect();
71    Ok(BannerlordModuleInfo {
72        id,
73        name,
74        dependencies,
75    })
76}
77
78/// Find dependencies referenced by modules that are not present in the set,
79/// returned as `(module_id, missing_dependency_id)` pairs.
80#[must_use]
81pub fn missing_dependencies(modules: &[BannerlordModuleInfo]) -> Vec<(String, String)> {
82    let available = modules
83        .iter()
84        .map(|module| module.id.as_str())
85        .collect::<std::collections::BTreeSet<_>>();
86    let mut missing = Vec::new();
87    for module in modules {
88        for dependency in &module.dependencies {
89            if !available.contains(dependency.as_str()) {
90                missing.push((module.id.clone(), dependency.clone()));
91            }
92        }
93    }
94    missing
95}
96
97fn attr_after(content: &str, marker: &str, attr: &str) -> Option<String> {
98    let start = content.find(marker)?;
99    let rest = &content[start..];
100    let needle = format!("{attr}=\"");
101    let value_start = rest.find(&needle)? + needle.len();
102    let rest = &rest[value_start..];
103    let value_end = rest.find('"')?;
104    Some(rest[..value_end].to_string())
105}
106
107impl GamePlugin for BannerlordGame {
108    fn game_id(&self) -> &'static str {
109        "bannerlord"
110    }
111
112    fn display_name(&self) -> &'static str {
113        "Mount & Blade II: Bannerlord"
114    }
115
116    fn mod_directory(&self, install: &Path) -> PathBuf {
117        install.join("Modules")
118    }
119
120    fn save_directory(&self) -> Option<PathBuf> {
121        Some(
122            modde_core::paths::home_dir()
123                .join("Documents/Mount and Blade II Bannerlord/Game Saves/Native"),
124        )
125    }
126
127    fn supports_save_profiles(&self) -> bool {
128        true
129    }
130
131    fn classify_mod(&self, mod_dir: &Path) -> ModSafety {
132        BANNERLORD_CONTENT_POLICY.classify_mod(mod_dir)
133    }
134
135    fn classify_extension(&self, ext: &str) -> ContentCategory {
136        BANNERLORD_CONTENT_POLICY.classify_extension(ext)
137    }
138
139    fn executable_dir(&self, install: &Path) -> PathBuf {
140        install.join("bin/Win64_Shipping_Client")
141    }
142
143    fn steam_app_id_u32(&self) -> Option<u32> {
144        Some(261550)
145    }
146
147    fn nexus_game_domain(&self) -> Option<&str> {
148        Some("mountandblade2bannerlord")
149    }
150
151    fn analyze_mod_archive(&self, extracted_dir: &Path) -> Option<InstallMethod> {
152        if extracted_dir.join("SubModule.xml").is_file() {
153            return Some(InstallMethod::DirectoryModFromXml {
154                marker: PathBuf::from("SubModule.xml"),
155                id_attr: "Id.value".to_string(),
156                fallback_name: None,
157            });
158        }
159        extracted_dir
160            .join("Modules")
161            .is_dir()
162            .then(|| InstallMethod::StripContentRoot {
163                root: "Modules".to_string(),
164            })
165    }
166
167    fn recognizes_bare_layout(&self, extracted_dir: &Path) -> bool {
168        extracted_dir.join("SubModule.xml").is_file()
169            || BANNERLORD_BARE_LAYOUT_POLICY.recognizes(extracted_dir)
170    }
171}