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    #[must_use]
53    pub fn is_form_43(&self) -> bool {
54        self.version < FORM_44_VERSION - 0.01
55    }
56
57    /// Whether this plugin is flagged as a master (ESM).
58    #[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    /// Whether this plugin is flagged as a light plugin (ESL).
64    #[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/// A validation warning for a plugin.
71#[derive(Debug, Clone)]
72pub enum PluginWarning {
73    /// Plugin uses Form 43 format (Skyrim LE) in a Form 44 game (SSE).
74    Form43 { plugin: String, version: f32 },
75    /// Plugin depends on a master that is not in the active load order.
76    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
99/// Parse a plugin header from a file path.
100///
101/// Only reads the first ~8KB to extract the TES4 record and MAST sub-records.
102pub 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    // Read TES4 record header (24 bytes)
112    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    // Read the HEDR sub-record (inside TES4 data, first sub-record)
126    let mut plugin_version: f32 = 0.0;
127    let mut num_records: u32 = 0;
128    let mut masters: Vec<String> = Vec::new();
129
130    // Read TES4 sub-records up to data_size bytes
131    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                // HEDR: version (f32), numRecords (u32), nextObjectId (u32)
145                plugin_version = read_f32_le(&mut file)?;
146                num_records = read_u32_le(&mut file)?;
147            }
148            sub if sub == MAST_SIGNATURE => {
149                // MAST: null-terminated string
150                let mut buf = vec![0u8; sub_size as usize];
151                file.read_exact(&mut buf)?;
152                // Trim trailing null
153                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                // Skip unknown sub-record
162            }
163        }
164
165        // Seek to the end of this sub-record
166        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
182/// Validate plugins against the active load order.
183///
184/// Returns warnings for Form 43 plugins and missing masters.
185/// `active_plugins` should be all plugin filenames in the load order (case-insensitive matching).
186/// `plugin_dir` is the game's Data directory containing the plugins.
187pub 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        // Check Form 43
212        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        // Check missing masters
220        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    /// Build a minimal TES4 record in memory for testing.
256    fn build_test_plugin(version: f32, masters: &[&str], record_flags: u32) -> Vec<u8> {
257        let mut data = Vec::new();
258
259        // Build TES4 sub-records
260        let mut sub_records = Vec::new();
261
262        // HEDR sub-record: version(f32) + numRecords(u32) + nextObjectId(u32) = 12 bytes
263        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()); // numRecords
267        sub_records.extend_from_slice(&0x800u32.to_le_bytes()); // nextObjectId
268
269        // MAST sub-records for each master
270        for master in masters {
271            let name_bytes = master.as_bytes();
272            let sub_size = (name_bytes.len() + 1) as u16; // +1 for null terminator
273            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); // null terminator
277
278            // DATA sub-record (8 bytes, file size — required after each MAST)
279            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        // TES4 record header
285        data.extend_from_slice(b"TES4");
286        data.extend_from_slice(&(sub_records.len() as u32).to_le_bytes()); // data size
287        data.extend_from_slice(&record_flags.to_le_bytes()); // flags
288        data.extend_from_slice(&0u32.to_le_bytes()); // form ID
289        data.extend_from_slice(&0u32.to_le_bytes()); // revision
290        data.extend_from_slice(&44u16.to_le_bytes()); // version field
291        data.extend_from_slice(&0u16.to_le_bytes()); // unknown
292
293        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        // Also create a dummy Skyrim.esm so it doesn't show missing master
365        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}