Skip to main content

modde_games/bethesda/
plugin_header.rs

1//! Bethesda plugin (ESP/ESM/ESL) header parser.
2//!
3//! Reads only the first ~1KB of a plugin file to extract:
4//! - Plugin version (Form 43 vs Form 44)
5//! - Master file dependencies
6//! - Plugin flags (ESM, ESL)
7//!
8//! This avoids loading entire multi-GB plugin files.
9
10use std::io::{self, Read, Seek, SeekFrom};
11use std::path::Path;
12
13use anyhow::{Context, Result, bail};
14
15/// Plugin record type identifier.
16const TES4_SIGNATURE: &[u8; 4] = b"TES4";
17
18/// The MAST sub-record signature (master dependency).
19const MAST_SIGNATURE: &[u8; 4] = b"MAST";
20
21/// Plugin version for Skyrim LE (Form 43) — outdated for SSE.
22pub const FORM_43_VERSION: f32 = 0.94;
23
24/// Plugin version for Skyrim SE (Form 44).
25pub const FORM_44_VERSION: f32 = 1.70;
26
27/// Flags in the TES4 record header.
28pub mod flags {
29    /// Plugin is flagged as a master file (ESM).
30    pub const ESM: u32 = 0x0000_0001;
31    /// Plugin is flagged as light (ESL).
32    pub const ESL: u32 = 0x0000_0200;
33}
34
35/// Parsed header from a Bethesda plugin file.
36#[derive(Debug, Clone)]
37pub struct PluginHeader {
38    /// The plugin filename (just the name, not the full path).
39    pub filename: String,
40    /// Record flags from the TES4 header.
41    pub record_flags: u32,
42    /// Plugin version (e.g., 0.94 for Form 43, 1.70 for Form 44).
43    pub version: f32,
44    /// Number of records in the plugin.
45    pub num_records: u32,
46    /// Master file dependencies.
47    pub masters: Vec<String>,
48}
49
50impl PluginHeader {
51    /// Whether this plugin uses the outdated Form 43 format (Skyrim LE).
52    pub fn is_form_43(&self) -> bool {
53        self.version < FORM_44_VERSION - 0.01
54    }
55
56    /// Whether this plugin is flagged as a master (ESM).
57    pub fn is_esm(&self) -> bool {
58        self.record_flags & flags::ESM != 0
59            || self.filename.to_lowercase().ends_with(".esm")
60    }
61
62    /// Whether this plugin is flagged as a light plugin (ESL).
63    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/// A validation warning for a plugin.
70#[derive(Debug, Clone)]
71pub enum PluginWarning {
72    /// Plugin uses Form 43 format (Skyrim LE) in a Form 44 game (SSE).
73    Form43 {
74        plugin: String,
75        version: f32,
76    },
77    /// Plugin depends on a master that is not in the active load order.
78    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
104/// Parse a plugin header from a file path.
105///
106/// Only reads the first ~8KB to extract the TES4 record and MAST sub-records.
107pub 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    // Read TES4 record header (24 bytes)
117    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    // Read the HEDR sub-record (inside TES4 data, first sub-record)
131    let mut plugin_version: f32 = 0.0;
132    let mut num_records: u32 = 0;
133    let mut masters: Vec<String> = Vec::new();
134
135    // Read TES4 sub-records up to data_size bytes
136    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                // HEDR: version (f32), numRecords (u32), nextObjectId (u32)
150                plugin_version = read_f32_le(&mut file)?;
151                num_records = read_u32_le(&mut file)?;
152            }
153            sub if sub == MAST_SIGNATURE => {
154                // MAST: null-terminated string
155                let mut buf = vec![0u8; sub_size as usize];
156                file.read_exact(&mut buf)?;
157                // Trim trailing null
158                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                // Skip unknown sub-record
167            }
168        }
169
170        // Seek to the end of this sub-record
171        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
183/// Validate plugins against the active load order.
184///
185/// Returns warnings for Form 43 plugins and missing masters.
186/// `active_plugins` should be all plugin filenames in the load order (case-insensitive matching).
187/// `plugin_dir` is the game's Data directory containing the plugins.
188pub 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        // Check Form 43
215        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        // Check missing masters
223        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    /// Build a minimal TES4 record in memory for testing.
260    fn build_test_plugin(version: f32, masters: &[&str], record_flags: u32) -> Vec<u8> {
261        let mut data = Vec::new();
262
263        // Build TES4 sub-records
264        let mut sub_records = Vec::new();
265
266        // HEDR sub-record: version(f32) + numRecords(u32) + nextObjectId(u32) = 12 bytes
267        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()); // numRecords
271        sub_records.extend_from_slice(&0x800u32.to_le_bytes()); // nextObjectId
272
273        // MAST sub-records for each master
274        for master in masters {
275            let name_bytes = master.as_bytes();
276            let sub_size = (name_bytes.len() + 1) as u16; // +1 for null terminator
277            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); // null terminator
281
282            // DATA sub-record (8 bytes, file size — required after each MAST)
283            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        // TES4 record header
289        data.extend_from_slice(b"TES4");
290        data.extend_from_slice(&(sub_records.len() as u32).to_le_bytes()); // data size
291        data.extend_from_slice(&record_flags.to_le_bytes()); // flags
292        data.extend_from_slice(&0u32.to_le_bytes()); // form ID
293        data.extend_from_slice(&0u32.to_le_bytes()); // revision
294        data.extend_from_slice(&44u16.to_le_bytes()); // version field
295        data.extend_from_slice(&0u16.to_le_bytes()); // unknown
296
297        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        // Also create a dummy Skyrim.esm so it doesn't show missing master
367        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}