loadsmith-loader 0.3.1

Mod loader definitions and launch-argument generation for the loadsmith mod-manager library
Documentation
use std::{path::PathBuf, sync::LazyLock, vec};

use loadsmith_install::{InstallRule, InstallRuleset, OwnedInstallRuleset, RouteRule};

use crate::{LaunchArgs, LaunchContext, Loader, Result, glob_rule};

/// Loader implementation for [Unreal Shimloader](https://thunderstore.io/c/holocure/p/Thunderstore/unreal_shimloader/),
/// a UE4SS-based mod loader for games like HoloCure.
///
/// Routes packages into `shimloader/mod`, `shimloader/pak` (`.pak` files
/// only), `shimloader/cfg` (mutable, flat), and `shimloader/overlay`. The
/// loader uses `dwmapi.dll` as its proxy DLL.
///
/// # Examples
///
/// ```rust
/// use loadsmith_loader::{Shimloader, Loader};
///
/// let loader = Shimloader::with_default_rules();
/// assert_eq!(loader.id(), "Shimloader");
/// ```
#[derive(Debug, Clone)]
pub struct Shimloader {
    package_install_ruleset: OwnedInstallRuleset,
}

impl Shimloader {
    /// Creates a `Shimloader` with a custom install ruleset.
    pub fn with_rules(package_install_ruleset: OwnedInstallRuleset) -> Self {
        Self {
            package_install_ruleset,
        }
    }

    /// Creates a `Shimloader` with the default rules.
    ///
    /// Default routes:
    ///
    /// * `shimloader/mod` — default rule
    /// * `shimloader/pak` (files with `.pak` extension)
    /// * `shimloader/cfg` (mutable, flat)
    /// * `shimloader/overlay`
    pub fn with_default_rules() -> Self {
        OwnedInstallRuleset::from_rule_iter(
            [
                RouteRule::new_static("shimloader/mod"),
                RouteRule::new_static("shimloader/pak").with_file_extension("pak"),
                RouteRule::new_static("shimloader/cfg")
                    .with_subdir(false)
                    .with_mutable(true),
                RouteRule::new_static("shimloader/overlay"),
            ],
            Some(0),
        )
        .map(Self::with_rules)
        .expect("rules are not empty so there should always be a valid default rule index")
    }
}

impl Default for Shimloader {
    fn default() -> Self {
        Self::with_default_rules()
    }
}

impl Loader for Shimloader {
    fn id(&self) -> &'static str {
        "Shimloader"
    }

    fn loader_install_rules(&self) -> InstallRuleset<'_> {
        static RULES: LazyLock<Vec<InstallRule>> = LazyLock::new(|| {
            vec![
                glob_rule!("UE4SS/Mods/*" => "shimloader/mod")
                    .use_links(true)
                    .strip_levels(2)
                    .into(),
                glob_rule!("UE4SS/UE4SS.dll" => ".")
                    .use_links(true)
                    .strip_levels(1)
                    .into(),
                glob_rule!("UE4SS/UE4SS-settings.ini" => ".")
                    .use_links(true)
                    .strip_levels(1)
                    .into(),
                glob_rule!("dwmapi.dll" => ".").use_links(true).into(),
            ]
        });

        InstallRuleset::new(&RULES)
    }

    fn package_install_rules(&self) -> InstallRuleset<'_> {
        self.package_install_ruleset.as_ref()
    }

    fn generate_launch_args(&self, ctx: &LaunchContext) -> Result<LaunchArgs> {
        let path = ctx.profile_path().join("shimloader");

        let mod_path = path.join("mod");
        let pak_path = path.join("pak");
        let cfg_path = path.join("cfg");

        let mod_path = ctx.format_proton_path(&mod_path);
        let pak_path = ctx.format_proton_path(&pak_path);
        let cfg_path = ctx.format_proton_path(&cfg_path);

        let args = LaunchArgs::new()
            .arg("--mod-dir")
            .arg(&*mod_path)
            .arg("--pak-dir")
            .arg(&*pak_path)
            .arg("--cfg-dir")
            .arg(&*cfg_path);

        Ok(args)
    }

    fn package_config_dirs(&self) -> Vec<PathBuf> {
        vec!["shimloader/cfg".into()]
    }

    fn log_file(&self) -> Option<PathBuf> {
        None
    }

    fn proxy_dll(&self) -> Option<PathBuf> {
        Some("dwmapi.dll".into())
    }
}

#[cfg(test)]
mod tests {
    use loadsmith_core::{PackageRef, Version};

    use crate::{assert_map, assert_maps, test_util::MapFileTester};

    use super::*;

    #[test]
    fn map_loader_files() {
        assert_maps!(MapFileTester::new(
            Shimloader::with_default_rules(),
            PackageRef::new("Thunderstore-unreal_shimloader".to_string(), Version::new(1, 1, 7)),
            true,
        ), [
            "README.md" => None,
            "dwmapi.dll" => "./dwmapi.dll",
            "UE4SS/dwmapi.dll" => None,
            "UE4SS/UE4SS.dll" => "./UE4SS.dll",
            "UE4SS/UE4SS-settings.ini" => "./UE4SS-settings.ini",
            "UE4SS/Mods/mods.json" => "shimloader/mod/mods.json",
            "UE4SS/Mods/mods.txt" => "shimloader/mod/mods.txt",
            "UE4SS/Mods/shared/scripts/script.lua" => "shimloader/mod/shared/scripts/script.lua",
        ]);
    }

    #[test]
    fn map_package_files() {
        assert_maps!(
            MapFileTester::new(
                Shimloader::with_default_rules(),
                PackageRef::new("Author-Name".to_string(), Version::new(1, 0, 0)),
                false,
            ),
            [
                "README.md" => "shimloader/mod/Author-Name/README.md",
                "pak/file" => "shimloader/pak/Author-Name/file",
                "cfg/settings.json" => "shimloader/cfg/settings.json",
                "nested/file.txt" => "shimloader/mod/Author-Name/file.txt",
                "mypak.pak" => "shimloader/pak/Author-Name/mypak.pak",
            ]
        );
    }

    #[test]
    fn package_dir_works() {
        let loader = Shimloader::with_default_rules();
        let package = PackageRef::new("Author-Name".to_string(), Version::new(1, 0, 0));

        let package_dir = loader.package_dir(&package);

        assert_eq!(
            package_dir,
            Some(PathBuf::from("shimloader/mod/Author-Name"))
        );
    }
}