modde_games/bannerlord/
mod.rs1pub 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
15pub 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#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct BannerlordModuleInfo {
50 pub id: String,
51 pub name: String,
52 pub dependencies: Vec<String>,
53}
54
55pub 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#[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}