modde-games 0.2.1

Game plugin implementations for modde
Documentation
//! Reusable, data-driven policies that let games describe their content
//! classification, archive layout, DLL overrides, mod directory, and collision
//! rules declaratively instead of hand-writing per-game scanning logic.

use std::path::{Path, PathBuf};

use smallvec::SmallVec;

use modde_core::collision::{CollisionClassifier, CollisionSeverity};

use crate::traits::{ContentCategory, ModSafety};

/// Extension and directory rules used to classify installed mod content.
#[derive(Debug, Clone, Copy)]
pub struct ContentPolicy {
    pub save_breaking_ext: &'static [&'static str],
    pub cosmetic_ext: &'static [&'static str],
    pub save_breaking_dirs: &'static [&'static str],
    pub categories: &'static [(&'static str, ContentCategory)],
}

impl ContentPolicy {
    /// Map a file extension (lowercase, no dot) to its [`ContentCategory`].
    #[must_use]
    pub fn classify_extension(self, ext: &str) -> ContentCategory {
        self.categories
            .iter()
            .find_map(|(candidate, category)| (*candidate == ext).then_some(*category))
            .unwrap_or(ContentCategory::Other)
    }

    /// Walk a mod directory and classify it as save-breaking, save-safe, or unknown.
    #[must_use]
    pub fn classify_mod(self, mod_dir: &Path) -> ModSafety {
        if !mod_dir.exists() {
            return ModSafety::Unknown;
        }

        let mut has_any_file = false;
        let mut has_cosmetic = false;
        let mut stack = vec![mod_dir.to_path_buf()];

        while let Some(dir) = stack.pop() {
            let entries = match std::fs::read_dir(&dir) {
                Ok(entries) => entries,
                Err(_) => continue,
            };

            for entry in entries.flatten() {
                let path = entry.path();
                if path.is_dir() {
                    if !self.save_breaking_dirs.is_empty() {
                        let rel = path.strip_prefix(mod_dir).unwrap_or(&path);
                        let rel = rel.to_string_lossy().to_lowercase().replace('\\', "/");
                        if self
                            .save_breaking_dirs
                            .iter()
                            .any(|pattern| rel.contains(pattern))
                        {
                            return ModSafety::SaveBreaking;
                        }
                    }
                    stack.push(path);
                    continue;
                }

                has_any_file = true;
                let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
                    continue;
                };
                let ext = ext.to_lowercase();
                if self.save_breaking_ext.contains(&ext.as_str()) {
                    return ModSafety::SaveBreaking;
                }
                if self.cosmetic_ext.contains(&ext.as_str()) {
                    has_cosmetic = true;
                }
            }
        }

        if has_cosmetic && has_any_file {
            ModSafety::SaveSafe
        } else {
            ModSafety::Unknown
        }
    }
}

/// Recognizes a "bare" mod layout — files/dirs that belong directly at the mod
/// root (e.g. a `meshes/`, `textures/` folder or a loose `.esp`) rather than
/// being wrapped in an extra top-level directory.
#[derive(Debug, Clone, Copy)]
pub struct BareLayoutPolicy {
    pub root_dirs: &'static [&'static str],
    pub root_file_exts: &'static [&'static str],
    pub case_insensitive_dirs: bool,
}

impl BareLayoutPolicy {
    /// Returns `true` if `extracted_dir` directly contains a recognised root
    /// directory or file extension (i.e. it is already a bare mod layout).
    #[must_use]
    pub fn recognizes(self, extracted_dir: &Path) -> bool {
        let Ok(entries) = std::fs::read_dir(extracted_dir) else {
            return false;
        };

        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                let name = entry.file_name().to_string_lossy().to_string();
                if self.case_insensitive_dirs {
                    let name = name.to_lowercase();
                    if self.root_dirs.iter().any(|candidate| *candidate == name) {
                        return true;
                    }
                } else if self.root_dirs.iter().any(|candidate| *candidate == name) {
                    return true;
                }
            } else if path.is_file()
                && let Some(ext) = path.extension().and_then(|e| e.to_str())
            {
                let ext = ext.to_lowercase();
                if self.root_file_exts.contains(&ext.as_str()) {
                    return true;
                }
            }
        }

        false
    }
}

/// Strategy for locating proxy DLLs within a staging tree.
#[derive(Debug, Clone, Copy)]
pub enum StagingDllSearch {
    /// Look one level deep, inside each direct child directory.
    DirectChildDirs,
    /// Look inside each `mods/<mod>/bin/x64` subtree.
    NestedModsBinX64,
}

/// Identifies which proxy/loader DLLs (e.g. `dxgi`, `winmm`) a game install
/// uses so they can be registered as Wine DLL overrides.
#[derive(Debug, Clone, Copy)]
pub struct DllOverridePolicy {
    pub proxy_dlls: &'static [&'static str],
    pub staging_search: StagingDllSearch,
}

impl DllOverridePolicy {
    /// Detect proxy DLLs present directly in the game executable directory.
    #[must_use]
    pub fn from_executable_dir(self, executable_dir: &Path) -> SmallVec<[String; 4]> {
        let mut out = SmallVec::new();
        for &name in self.proxy_dlls {
            if executable_dir.join(format!("{name}.dll")).exists() {
                out.push(name.to_string());
            }
        }
        out
    }

    /// Detect proxy DLLs within a staging tree using the configured [`StagingDllSearch`].
    #[must_use]
    pub fn from_staging(self, staging: &Path) -> SmallVec<[String; 4]> {
        match self.staging_search {
            StagingDllSearch::DirectChildDirs => self.scan_direct_child_dirs(staging),
            StagingDllSearch::NestedModsBinX64 => self.scan_nested_mods_bin_x64(staging),
        }
    }

    fn push_dll_name(self, out: &mut SmallVec<[String; 4]>, file_name: &std::ffi::OsStr) {
        let name = file_name.to_string_lossy().to_lowercase();
        if let Some(stem) = name.strip_suffix(".dll")
            && self.proxy_dlls.contains(&stem)
            && !out.iter().any(|item| item == stem)
        {
            out.push(stem.to_string());
        }
    }

    fn scan_direct_child_dirs(self, staging: &Path) -> SmallVec<[String; 4]> {
        let mut out = SmallVec::new();
        let Ok(entries) = std::fs::read_dir(staging) else {
            return out;
        };
        for entry in entries.flatten() {
            if !entry.file_type().is_ok_and(|t| t.is_dir()) {
                continue;
            }
            let Ok(inner) = std::fs::read_dir(entry.path()) else {
                continue;
            };
            for file in inner.flatten() {
                self.push_dll_name(&mut out, &file.file_name());
            }
        }
        out
    }

    fn scan_nested_mods_bin_x64(self, staging: &Path) -> SmallVec<[String; 4]> {
        let mut out = SmallVec::new();
        let mods_dir = staging.join("mods");
        if !mods_dir.is_dir() {
            return out;
        }

        for entry in std::fs::read_dir(&mods_dir).into_iter().flatten().flatten() {
            if !entry.file_type().is_ok_and(|t| t.is_dir()) {
                continue;
            }
            let mod_bin_x64 = entry.path().join("bin/x64");
            if !mod_bin_x64.is_dir() {
                continue;
            }
            for file in std::fs::read_dir(&mod_bin_x64)
                .into_iter()
                .flatten()
                .flatten()
            {
                self.push_dll_name(&mut out, &file.file_name());
            }
        }

        out
    }
}

/// Describes where a game expects deployed mods to live, relative to its install dir.
#[derive(Debug, Clone, Copy)]
pub enum ModDirectoryLayout {
    /// A path relative to the install root.
    Relative(&'static str),
    /// The Unreal Engine 4 `<Project>/Content/Paks/~mods` convention.
    Ue4PaksMods { project_name: &'static str },
}

impl ModDirectoryLayout {
    /// Resolve the mod directory against a concrete `install` path.
    #[must_use]
    pub fn resolve(self, install: &Path) -> PathBuf {
        match self {
            Self::Relative(path) => install.join(path),
            Self::Ue4PaksMods { project_name } => install
                .join(project_name)
                .join("Content")
                .join("Paks")
                .join("~mods"),
        }
    }
}

/// Maps file extensions to a [`CollisionSeverity`] so file conflicts between
/// mods can be ranked (e.g. plugin overwrites are worse than loose-texture overwrites).
#[derive(Debug, Clone, Copy)]
pub struct CollisionPolicy {
    pub archive_extensions: &'static [&'static str],
    pub severities: &'static [(&'static str, CollisionSeverity)],
}

impl CollisionPolicy {
    /// Classify the collision severity of `file_path` from its extension.
    #[must_use]
    pub fn classify_severity(self, file_path: &str) -> CollisionSeverity {
        let ext = file_path.rsplit('.').next().unwrap_or("").to_lowercase();
        self.severities
            .iter()
            .find_map(|(candidate, severity)| (*candidate == ext).then_some(*severity))
            .unwrap_or(CollisionSeverity::Unknown)
    }
}

/// Adapts a [`CollisionPolicy`] into a `modde_core` `CollisionClassifier`,
/// letting policy-driven games plug into the shared collision engine.
#[derive(Debug, Clone, Copy)]
pub struct PolicyCollisionClassifier {
    pub policy: CollisionPolicy,
}

impl CollisionClassifier for PolicyCollisionClassifier {
    fn index_archive(&self, _archive_path: &Path) -> anyhow::Result<Vec<(String, u64)>> {
        Ok(Vec::new())
    }

    fn classify_severity(&self, file_path: &str) -> CollisionSeverity {
        self.policy.classify_severity(file_path)
    }

    fn archive_extensions(&self) -> &[&str] {
        self.policy.archive_extensions
    }
}