modde_games/bethesda/
plugin_header.rs1use std::io::{self, Read, Seek, SeekFrom};
11use std::path::Path;
12
13use anyhow::{Context, Result, bail};
14
15const TES4_SIGNATURE: &[u8; 4] = b"TES4";
17
18const MAST_SIGNATURE: &[u8; 4] = b"MAST";
20
21pub const FORM_43_VERSION: f32 = 0.94;
23
24pub const FORM_44_VERSION: f32 = 1.70;
26
27pub mod flags {
29 pub const ESM: u32 = 0x0000_0001;
31 pub const ESL: u32 = 0x0000_0200;
33}
34
35#[derive(Debug, Clone)]
37pub struct PluginHeader {
38 pub filename: String,
40 pub record_flags: u32,
42 pub version: f32,
44 pub num_records: u32,
46 pub masters: Vec<String>,
48}
49
50impl PluginHeader {
51 pub fn is_form_43(&self) -> bool {
53 self.version < FORM_44_VERSION - 0.01
54 }
55
56 pub fn is_esm(&self) -> bool {
58 self.record_flags & flags::ESM != 0
59 || self.filename.to_lowercase().ends_with(".esm")
60 }
61
62 pub fn is_esl(&self) -> bool {
64 self.record_flags & flags::ESL != 0
65 || self.filename.to_lowercase().ends_with(".esl")
66 }
67}
68
69#[derive(Debug, Clone)]
71pub enum PluginWarning {
72 Form43 {
74 plugin: String,
75 version: f32,
76 },
77 MissingMaster {
79 plugin: String,
80 master: String,
81 },
82}
83
84impl std::fmt::Display for PluginWarning {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 match self {
87 PluginWarning::Form43 { plugin, version } => {
88 write!(
89 f,
90 "{plugin}: uses Form 43 (v{version:.2}) — this is the Oldrim format, \
91 which can cause CTDs in SSE. Resave in Creation Kit."
92 )
93 }
94 PluginWarning::MissingMaster { plugin, master } => {
95 write!(
96 f,
97 "{plugin}: missing master '{master}' — the game will crash on load."
98 )
99 }
100 }
101 }
102}
103
104pub fn parse_plugin_header(path: &Path) -> Result<PluginHeader> {
108 let filename = path
109 .file_name()
110 .map(|n| n.to_string_lossy().to_string())
111 .unwrap_or_default();
112
113 let mut file = std::fs::File::open(path)
114 .with_context(|| format!("failed to open plugin: {}", path.display()))?;
115
116 let mut sig = [0u8; 4];
118 file.read_exact(&mut sig)?;
119 if &sig != TES4_SIGNATURE {
120 bail!("not a valid Bethesda plugin: expected TES4, got {:?}", sig);
121 }
122
123 let data_size = read_u32_le(&mut file)?;
124 let record_flags = read_u32_le(&mut file)?;
125 let _form_id = read_u32_le(&mut file)?;
126 let _revision = read_u32_le(&mut file)?;
127 let version = read_u16_le(&mut file)?;
128 let _unknown = read_u16_le(&mut file)?;
129
130 let mut plugin_version: f32 = 0.0;
132 let mut num_records: u32 = 0;
133 let mut masters: Vec<String> = Vec::new();
134
135 let data_start = file.stream_position()?;
137 let data_end = data_start + data_size as u64;
138
139 while file.stream_position()? < data_end {
140 let mut sub_sig = [0u8; 4];
141 if file.read_exact(&mut sub_sig).is_err() {
142 break;
143 }
144 let sub_size = read_u16_le(&mut file)? as u64;
145 let sub_start = file.stream_position()?;
146
147 match &sub_sig {
148 b"HEDR" => {
149 plugin_version = read_f32_le(&mut file)?;
151 num_records = read_u32_le(&mut file)?;
152 }
153 sub if sub == MAST_SIGNATURE => {
154 let mut buf = vec![0u8; sub_size as usize];
156 file.read_exact(&mut buf)?;
157 if buf.last() == Some(&0) {
159 buf.pop();
160 }
161 if let Ok(name) = String::from_utf8(buf) {
162 masters.push(name);
163 }
164 }
165 _ => {
166 }
168 }
169
170 file.seek(SeekFrom::Start(sub_start + sub_size))?;
172 }
173
174 Ok(PluginHeader {
175 filename,
176 record_flags: record_flags,
177 version: if version >= 1 { plugin_version } else { plugin_version },
178 num_records,
179 masters,
180 })
181}
182
183pub fn validate_plugins(
189 plugin_dir: &Path,
190 active_plugins: &[&str],
191 check_form_43: bool,
192) -> Vec<PluginWarning> {
193 let active_lower: std::collections::HashSet<String> = active_plugins
194 .iter()
195 .map(|p| p.to_lowercase())
196 .collect();
197
198 let mut warnings = Vec::new();
199
200 for plugin_name in active_plugins {
201 let path = plugin_dir.join(plugin_name);
202 if !path.exists() {
203 continue;
204 }
205
206 let header = match parse_plugin_header(&path) {
207 Ok(h) => h,
208 Err(e) => {
209 tracing::warn!(plugin = *plugin_name, error = %e, "failed to parse plugin header");
210 continue;
211 }
212 };
213
214 if check_form_43 && header.is_form_43() {
216 warnings.push(PluginWarning::Form43 {
217 plugin: plugin_name.to_string(),
218 version: header.version,
219 });
220 }
221
222 for master in &header.masters {
224 if !active_lower.contains(&master.to_lowercase()) {
225 warnings.push(PluginWarning::MissingMaster {
226 plugin: plugin_name.to_string(),
227 master: master.clone(),
228 });
229 }
230 }
231 }
232
233 warnings
234}
235
236fn read_u32_le(r: &mut impl Read) -> io::Result<u32> {
237 let mut buf = [0u8; 4];
238 r.read_exact(&mut buf)?;
239 Ok(u32::from_le_bytes(buf))
240}
241
242fn read_u16_le(r: &mut impl Read) -> io::Result<u16> {
243 let mut buf = [0u8; 2];
244 r.read_exact(&mut buf)?;
245 Ok(u16::from_le_bytes(buf))
246}
247
248fn read_f32_le(r: &mut impl Read) -> io::Result<f32> {
249 let mut buf = [0u8; 4];
250 r.read_exact(&mut buf)?;
251 Ok(f32::from_le_bytes(buf))
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use std::io::Write;
258
259 fn build_test_plugin(version: f32, masters: &[&str], record_flags: u32) -> Vec<u8> {
261 let mut data = Vec::new();
262
263 let mut sub_records = Vec::new();
265
266 sub_records.extend_from_slice(b"HEDR");
268 sub_records.extend_from_slice(&12u16.to_le_bytes());
269 sub_records.extend_from_slice(&version.to_le_bytes());
270 sub_records.extend_from_slice(&100u32.to_le_bytes()); sub_records.extend_from_slice(&0x800u32.to_le_bytes()); for master in masters {
275 let name_bytes = master.as_bytes();
276 let sub_size = (name_bytes.len() + 1) as u16; sub_records.extend_from_slice(b"MAST");
278 sub_records.extend_from_slice(&sub_size.to_le_bytes());
279 sub_records.extend_from_slice(name_bytes);
280 sub_records.push(0); sub_records.extend_from_slice(b"DATA");
284 sub_records.extend_from_slice(&8u16.to_le_bytes());
285 sub_records.extend_from_slice(&0u64.to_le_bytes());
286 }
287
288 data.extend_from_slice(b"TES4");
290 data.extend_from_slice(&(sub_records.len() as u32).to_le_bytes()); data.extend_from_slice(&record_flags.to_le_bytes()); data.extend_from_slice(&0u32.to_le_bytes()); data.extend_from_slice(&0u32.to_le_bytes()); data.extend_from_slice(&44u16.to_le_bytes()); data.extend_from_slice(&0u16.to_le_bytes()); data.extend_from_slice(&sub_records);
298 data
299 }
300
301 #[test]
302 fn test_parse_form_44_plugin() {
303 let tmp = tempfile::NamedTempFile::new().unwrap();
304 let plugin_data = build_test_plugin(1.70, &["Skyrim.esm", "Update.esm"], 0);
305 std::fs::write(tmp.path(), &plugin_data).unwrap();
306
307 let header = parse_plugin_header(tmp.path()).unwrap();
308 assert!(!header.is_form_43());
309 assert_eq!(header.masters.len(), 2);
310 assert_eq!(header.masters[0], "Skyrim.esm");
311 assert_eq!(header.masters[1], "Update.esm");
312 assert_eq!(header.num_records, 100);
313 }
314
315 #[test]
316 fn test_parse_form_43_plugin() {
317 let tmp = tempfile::NamedTempFile::new().unwrap();
318 let plugin_data = build_test_plugin(0.94, &["Skyrim.esm"], 0);
319 std::fs::write(tmp.path(), &plugin_data).unwrap();
320
321 let header = parse_plugin_header(tmp.path()).unwrap();
322 assert!(header.is_form_43());
323 }
324
325 #[test]
326 fn test_esm_flag() {
327 let tmp = tempfile::NamedTempFile::new().unwrap();
328 let plugin_data = build_test_plugin(1.70, &[], flags::ESM);
329 std::fs::write(tmp.path(), &plugin_data).unwrap();
330
331 let header = parse_plugin_header(tmp.path()).unwrap();
332 assert!(header.is_esm());
333 assert!(!header.is_esl());
334 }
335
336 #[test]
337 fn test_esl_flag() {
338 let tmp = tempfile::NamedTempFile::new().unwrap();
339 let plugin_data = build_test_plugin(1.70, &[], flags::ESL);
340 std::fs::write(tmp.path(), &plugin_data).unwrap();
341
342 let header = parse_plugin_header(tmp.path()).unwrap();
343 assert!(!header.is_esm());
344 assert!(header.is_esl());
345 }
346
347 #[test]
348 fn test_validate_missing_master() {
349 let tmp = tempfile::tempdir().unwrap();
350 let plugin_data = build_test_plugin(1.70, &["Skyrim.esm", "MissingMod.esp"], 0);
351 std::fs::write(tmp.path().join("MyMod.esp"), &plugin_data).unwrap();
352
353 let active = vec!["Skyrim.esm", "MyMod.esp"];
354 let warnings = validate_plugins(tmp.path(), &active, true);
355
356 assert_eq!(warnings.len(), 1);
357 assert!(matches!(&warnings[0], PluginWarning::MissingMaster { master, .. } if master == "MissingMod.esp"));
358 }
359
360 #[test]
361 fn test_validate_form_43_warning() {
362 let tmp = tempfile::tempdir().unwrap();
363 let plugin_data = build_test_plugin(0.94, &["Skyrim.esm"], 0);
364 std::fs::write(tmp.path().join("OldMod.esp"), &plugin_data).unwrap();
365
366 let esm_data = build_test_plugin(1.70, &[], flags::ESM);
368 std::fs::write(tmp.path().join("Skyrim.esm"), &esm_data).unwrap();
369
370 let active = vec!["Skyrim.esm", "OldMod.esp"];
371 let warnings = validate_plugins(tmp.path(), &active, true);
372
373 assert!(warnings.iter().any(|w| matches!(w, PluginWarning::Form43 { plugin, .. } if plugin == "OldMod.esp")));
374 }
375
376 #[test]
377 fn test_invalid_file_header() {
378 let tmp = tempfile::NamedTempFile::new().unwrap();
379 std::fs::write(tmp.path(), b"NOT_A_PLUGIN_FILE").unwrap();
380
381 let result = parse_plugin_header(tmp.path());
382 assert!(result.is_err());
383 }
384}