modde_games/bethesda/
diagnostics.rs1use std::path::PathBuf;
2
3use modde_core::diagnostics::{
4 DiagContext, DiagFix, Diagnostic, DiagnosticEngine, DiagnosticRule, Severity,
5};
6
7use super::plugin_header::{self, PluginWarning};
8
9pub struct MissingMasterRule;
11
12impl DiagnosticRule for MissingMasterRule {
13 fn name(&self) -> &str {
14 "missing-masters"
15 }
16
17 fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
18 let plugin_dir = ctx.staging_dir;
19
20 let active_plugins: Vec<&str> = collect_active_plugins(ctx);
21 if active_plugins.is_empty() {
22 return Vec::new();
23 }
24
25 let warnings = plugin_header::validate_plugins(plugin_dir, &active_plugins, false);
26
27 warnings
28 .into_iter()
29 .filter_map(|w| match w {
30 PluginWarning::MissingMaster { plugin, master } => Some(Diagnostic {
31 severity: Severity::Error,
32 title: format!("Missing master: {master}"),
33 detail: format!(
34 "Plugin '{plugin}' requires master '{master}' which is not in the load order. \
35 The game will crash on load."
36 ),
37 affected_mod: Some(plugin),
38 affected_file: Some(PathBuf::from(&master)),
39 fix: Some(DiagFix {
40 label: "Install missing master".to_string(),
41 description: format!("Install the mod that provides '{master}' and enable it."),
42 }),
43 }),
44 _ => None,
45 })
46 .collect()
47 }
48}
49
50pub struct Form43Rule;
52
53impl DiagnosticRule for Form43Rule {
54 fn name(&self) -> &str {
55 "form-43"
56 }
57
58 fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
59 if ctx.game_id != "skyrim-se" && ctx.game_id != "skyrim-ae" {
61 return Vec::new();
62 }
63
64 let plugin_dir = ctx.staging_dir;
65
66 let active_plugins: Vec<&str> = collect_active_plugins(ctx);
67 if active_plugins.is_empty() {
68 return Vec::new();
69 }
70
71 let warnings = plugin_header::validate_plugins(plugin_dir, &active_plugins, true);
72
73 warnings
74 .into_iter()
75 .filter_map(|w| match w {
76 PluginWarning::Form43 { plugin, version } => Some(Diagnostic {
77 severity: Severity::Warning,
78 title: format!("Form 43 plugin: {plugin}"),
79 detail: format!(
80 "Plugin '{plugin}' uses Form 43 (v{version:.2}), the Oldrim format. \
81 This can cause crashes in Skyrim SE/AE. Resave it in Creation Kit."
82 ),
83 affected_mod: Some(plugin),
84 affected_file: None,
85 fix: Some(DiagFix {
86 label: "Resave in Creation Kit".to_string(),
87 description: "Open the plugin in Creation Kit (SSE) and save it to convert to Form 44.".to_string(),
88 }),
89 }),
90 _ => None,
91 })
92 .collect()
93 }
94}
95
96pub struct EmptyModRule;
98
99impl DiagnosticRule for EmptyModRule {
100 fn name(&self) -> &str {
101 "empty-mod"
102 }
103
104 fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
105 ctx.profile
106 .mods
107 .iter()
108 .filter(|m| m.enabled)
109 .filter_map(|m| {
110 let mod_dir = ctx.store_dir.join(&m.mod_id);
111 let is_empty = if mod_dir.exists() {
112 match std::fs::read_dir(&mod_dir) {
113 Ok(mut entries) => entries.next().is_none(),
114 Err(_) => true,
115 }
116 } else {
117 true
118 };
119
120 if is_empty {
121 Some(Diagnostic {
122 severity: Severity::Warning,
123 title: format!("Empty mod: {}", m.mod_id),
124 detail: format!(
125 "Mod '{}' is enabled but has no files in the store directory. \
126 It may not have been downloaded or extracted correctly.",
127 m.mod_id
128 ),
129 affected_mod: Some(m.mod_id.clone()),
130 affected_file: Some(mod_dir),
131 fix: Some(DiagFix {
132 label: "Re-install mod".to_string(),
133 description: format!(
134 "Re-download and install '{}', or disable it if it is no longer needed.",
135 m.mod_id
136 ),
137 }),
138 })
139 } else {
140 None
141 }
142 })
143 .collect()
144 }
145}
146
147pub struct OrphanedOverridesRule;
149
150impl DiagnosticRule for OrphanedOverridesRule {
151 fn name(&self) -> &str {
152 "orphaned-overrides"
153 }
154
155 fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
156 let overrides_dir = &ctx.profile.overrides;
157 if !overrides_dir.exists() {
158 return Vec::new();
159 }
160
161 let has_files = match std::fs::read_dir(overrides_dir) {
162 Ok(mut entries) => entries.next().is_some(),
163 Err(_) => false,
164 };
165
166 if has_files {
167 vec![Diagnostic {
168 severity: Severity::Info,
169 title: "Overrides directory has files".to_string(),
170 detail: format!(
171 "The overrides directory '{}' contains files. \
172 These files take highest priority and override all mods. \
173 Review them to ensure they are intentional.",
174 overrides_dir.display()
175 ),
176 affected_mod: None,
177 affected_file: Some(overrides_dir.clone()),
178 fix: None,
179 }]
180 } else {
181 Vec::new()
182 }
183 }
184}
185
186pub fn bethesda_diagnostics() -> DiagnosticEngine {
188 let mut engine = DiagnosticEngine::new();
189 engine.add_rule(Box::new(MissingMasterRule));
190 engine.add_rule(Box::new(Form43Rule));
191 engine.add_rule(Box::new(EmptyModRule));
192 engine.add_rule(Box::new(OrphanedOverridesRule));
193 engine
194}
195
196fn collect_active_plugins<'a>(ctx: &'a DiagContext<'a>) -> Vec<&'a str> {
200 ctx.profile
205 .mods
206 .iter()
207 .filter(|m| m.enabled)
208 .map(|m| m.mod_id.as_str())
209 .filter(|id| {
210 let lower = id.to_lowercase();
211 lower.ends_with(".esp") || lower.ends_with(".esm") || lower.ends_with(".esl")
212 })
213 .collect()
214}