use std::io::{self, Read, Seek, SeekFrom};
use std::path::Path;
use anyhow::{Context, Result, bail};
const TES4_SIGNATURE: &[u8; 4] = b"TES4";
const MAST_SIGNATURE: &[u8; 4] = b"MAST";
pub const FORM_43_VERSION: f32 = 0.94;
pub const FORM_44_VERSION: f32 = 1.70;
pub mod flags {
pub const ESM: u32 = 0x0000_0001;
pub const ESL: u32 = 0x0000_0200;
}
#[derive(Debug, Clone)]
pub struct PluginHeader {
pub filename: String,
pub record_flags: u32,
pub version: f32,
pub num_records: u32,
pub masters: Vec<String>,
}
impl PluginHeader {
#[must_use]
pub fn is_form_43(&self) -> bool {
self.version < FORM_44_VERSION - 0.01
}
#[must_use]
pub fn is_esm(&self) -> bool {
self.record_flags & flags::ESM != 0 || self.filename.to_lowercase().ends_with(".esm")
}
#[must_use]
pub fn is_esl(&self) -> bool {
self.record_flags & flags::ESL != 0 || self.filename.to_lowercase().ends_with(".esl")
}
}
#[derive(Debug, Clone)]
pub enum PluginWarning {
Form43 { plugin: String, version: f32 },
MissingMaster { plugin: String, master: String },
}
impl std::fmt::Display for PluginWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PluginWarning::Form43 { plugin, version } => {
write!(
f,
"{plugin}: uses Form 43 (v{version:.2}) — this is the Oldrim format, \
which can cause CTDs in SSE. Resave in Creation Kit."
)
}
PluginWarning::MissingMaster { plugin, master } => {
write!(
f,
"{plugin}: missing master '{master}' — the game will crash on load."
)
}
}
}
}
pub fn parse_plugin_header(path: &Path) -> Result<PluginHeader> {
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let mut file = std::fs::File::open(path)
.with_context(|| format!("failed to open plugin: {}", path.display()))?;
let mut sig = [0u8; 4];
file.read_exact(&mut sig)?;
if &sig != TES4_SIGNATURE {
bail!("not a valid Bethesda plugin: expected TES4, got {sig:?}");
}
let data_size = read_u32_le(&mut file)?;
let record_flags = read_u32_le(&mut file)?;
let _form_id = read_u32_le(&mut file)?;
let _revision = read_u32_le(&mut file)?;
let version = read_u16_le(&mut file)?;
let _unknown = read_u16_le(&mut file)?;
let mut plugin_version: f32 = 0.0;
let mut num_records: u32 = 0;
let mut masters: Vec<String> = Vec::new();
let data_start = file.stream_position()?;
let data_end = data_start + u64::from(data_size);
while file.stream_position()? < data_end {
let mut sub_sig = [0u8; 4];
if file.read_exact(&mut sub_sig).is_err() {
break;
}
let sub_size = u64::from(read_u16_le(&mut file)?);
let sub_start = file.stream_position()?;
match &sub_sig {
b"HEDR" => {
plugin_version = read_f32_le(&mut file)?;
num_records = read_u32_le(&mut file)?;
}
sub if sub == MAST_SIGNATURE => {
let mut buf = vec![0u8; sub_size as usize];
file.read_exact(&mut buf)?;
if buf.last() == Some(&0) {
buf.pop();
}
if let Ok(name) = String::from_utf8(buf) {
masters.push(name);
}
}
_ => {
}
}
file.seek(SeekFrom::Start(sub_start + sub_size))?;
}
Ok(PluginHeader {
filename,
record_flags,
version: if version >= 1 {
plugin_version
} else {
plugin_version
},
num_records,
masters,
})
}
pub fn validate_plugins(
plugin_dir: &Path,
active_plugins: &[&str],
check_form_43: bool,
) -> Vec<PluginWarning> {
let active_lower: std::collections::HashSet<String> =
active_plugins.iter().map(|p| p.to_lowercase()).collect();
let mut warnings = Vec::new();
for plugin_name in active_plugins {
let path = plugin_dir.join(plugin_name);
if !path.exists() {
continue;
}
let header = match parse_plugin_header(&path) {
Ok(h) => h,
Err(e) => {
tracing::warn!(plugin = *plugin_name, error = %e, "failed to parse plugin header");
continue;
}
};
if check_form_43 && header.is_form_43() {
warnings.push(PluginWarning::Form43 {
plugin: plugin_name.to_string(),
version: header.version,
});
}
for master in &header.masters {
if !active_lower.contains(&master.to_lowercase()) {
warnings.push(PluginWarning::MissingMaster {
plugin: plugin_name.to_string(),
master: master.clone(),
});
}
}
}
warnings
}
fn read_u32_le(r: &mut impl Read) -> io::Result<u32> {
let mut buf = [0u8; 4];
r.read_exact(&mut buf)?;
Ok(u32::from_le_bytes(buf))
}
fn read_u16_le(r: &mut impl Read) -> io::Result<u16> {
let mut buf = [0u8; 2];
r.read_exact(&mut buf)?;
Ok(u16::from_le_bytes(buf))
}
fn read_f32_le(r: &mut impl Read) -> io::Result<f32> {
let mut buf = [0u8; 4];
r.read_exact(&mut buf)?;
Ok(f32::from_le_bytes(buf))
}
#[cfg(test)]
mod tests {
use super::*;
fn build_test_plugin(version: f32, masters: &[&str], record_flags: u32) -> Vec<u8> {
let mut data = Vec::new();
let mut sub_records = Vec::new();
sub_records.extend_from_slice(b"HEDR");
sub_records.extend_from_slice(&12u16.to_le_bytes());
sub_records.extend_from_slice(&version.to_le_bytes());
sub_records.extend_from_slice(&100u32.to_le_bytes()); sub_records.extend_from_slice(&0x800u32.to_le_bytes());
for master in masters {
let name_bytes = master.as_bytes();
let sub_size = (name_bytes.len() + 1) as u16; sub_records.extend_from_slice(b"MAST");
sub_records.extend_from_slice(&sub_size.to_le_bytes());
sub_records.extend_from_slice(name_bytes);
sub_records.push(0);
sub_records.extend_from_slice(b"DATA");
sub_records.extend_from_slice(&8u16.to_le_bytes());
sub_records.extend_from_slice(&0u64.to_le_bytes());
}
data.extend_from_slice(b"TES4");
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);
data
}
#[test]
fn test_parse_form_44_plugin() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let plugin_data = build_test_plugin(1.70, &["Skyrim.esm", "Update.esm"], 0);
std::fs::write(tmp.path(), &plugin_data).unwrap();
let header = parse_plugin_header(tmp.path()).unwrap();
assert!(!header.is_form_43());
assert_eq!(header.masters.len(), 2);
assert_eq!(header.masters[0], "Skyrim.esm");
assert_eq!(header.masters[1], "Update.esm");
assert_eq!(header.num_records, 100);
}
#[test]
fn test_parse_form_43_plugin() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let plugin_data = build_test_plugin(0.94, &["Skyrim.esm"], 0);
std::fs::write(tmp.path(), &plugin_data).unwrap();
let header = parse_plugin_header(tmp.path()).unwrap();
assert!(header.is_form_43());
}
#[test]
fn test_esm_flag() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let plugin_data = build_test_plugin(1.70, &[], flags::ESM);
std::fs::write(tmp.path(), &plugin_data).unwrap();
let header = parse_plugin_header(tmp.path()).unwrap();
assert!(header.is_esm());
assert!(!header.is_esl());
}
#[test]
fn test_esl_flag() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let plugin_data = build_test_plugin(1.70, &[], flags::ESL);
std::fs::write(tmp.path(), &plugin_data).unwrap();
let header = parse_plugin_header(tmp.path()).unwrap();
assert!(!header.is_esm());
assert!(header.is_esl());
}
#[test]
fn test_validate_missing_master() {
let tmp = tempfile::tempdir().unwrap();
let plugin_data = build_test_plugin(1.70, &["Skyrim.esm", "MissingMod.esp"], 0);
std::fs::write(tmp.path().join("MyMod.esp"), &plugin_data).unwrap();
let active = vec!["Skyrim.esm", "MyMod.esp"];
let warnings = validate_plugins(tmp.path(), &active, true);
assert_eq!(warnings.len(), 1);
assert!(
matches!(&warnings[0], PluginWarning::MissingMaster { master, .. } if master == "MissingMod.esp")
);
}
#[test]
fn test_validate_form_43_warning() {
let tmp = tempfile::tempdir().unwrap();
let plugin_data = build_test_plugin(0.94, &["Skyrim.esm"], 0);
std::fs::write(tmp.path().join("OldMod.esp"), &plugin_data).unwrap();
let esm_data = build_test_plugin(1.70, &[], flags::ESM);
std::fs::write(tmp.path().join("Skyrim.esm"), &esm_data).unwrap();
let active = vec!["Skyrim.esm", "OldMod.esp"];
let warnings = validate_plugins(tmp.path(), &active, true);
assert!(
warnings.iter().any(
|w| matches!(w, PluginWarning::Form43 { plugin, .. } if plugin == "OldMod.esp")
)
);
}
#[test]
fn test_invalid_file_header() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), b"NOT_A_PLUGIN_FILE").unwrap();
let result = parse_plugin_header(tmp.path());
assert!(result.is_err());
}
}