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