Skip to main content

loadsmith_loader/loaders/
northstar.rs

1use std::{path::PathBuf, sync::LazyLock};
2
3use globset::{GlobBuilder, GlobSet};
4use loadsmith_install::{InstallRule, InstallRuleset, OwnedInstallRuleset, RouteRule};
5
6use crate::{LaunchArgs, LaunchContext, Loader, Result, glob, glob_rule};
7
8/// Loader implementation for [Northstar](https://northstar.thunderstore.io/),
9/// a mod loader for Titanfall 2.
10///
11/// Northstar places packages into `R2Northstar/mods` and launches with
12/// `-northstar -profile=<path>`. It excludes common metadata files
13/// (`manifest.json`, `README.md`, `icon.png`, `LICENSE`) from installation.
14///
15/// # Examples
16///
17/// ```rust
18/// use loadsmith_loader::{Northstar, Loader};
19///
20/// let loader = Northstar::with_default_rules();
21/// assert_eq!(loader.id(), "Northstar");
22/// ```
23#[derive(Debug, Clone)]
24pub struct Northstar {
25    package_install_ruleset: OwnedInstallRuleset,
26}
27
28impl Northstar {
29    /// Creates a `Northstar` loader with a custom install ruleset.
30    pub fn with_rules(package_install_ruleset: OwnedInstallRuleset) -> Self {
31        Self {
32            package_install_ruleset,
33        }
34    }
35
36    /// Creates a `Northstar` loader with the default rules.
37    ///
38    /// Files are routed into `R2Northstar/mods`. Metadata files
39    /// (`manifest.json`, `README.md`, `icon.png`, `LICENSE`) are excluded.
40    pub fn with_default_rules() -> Self {
41        let exclude = GlobSet::builder()
42            .add(glob!("manifest.json"))
43            .add(glob!("README.md"))
44            .add(glob!("icon.png"))
45            .add(glob!("LICENSE"))
46            .build()
47            .expect("constant globs should be valid");
48
49        let ruleset = OwnedInstallRuleset::from_rule_iter(
50            vec![
51                RouteRule::new_static("R2Northstar/mods")
52                    .with_subdir(false)
53                    .with_flatten(false),
54            ],
55            None,
56        )
57        .expect("rules are not empty so there should always be a valid default rule index")
58        .with_exclude(exclude);
59
60        Self::with_rules(ruleset)
61    }
62
63    /// Adds an install rule to the end of the ruleset.
64    pub fn add_install_rule(&mut self, rule: impl Into<InstallRule>) {
65        self.package_install_ruleset.add_rule(rule.into());
66    }
67
68    /// Inserts an install rule at the given index.
69    pub fn insert_install_rule(&mut self, index: usize, rule: impl Into<InstallRule>) {
70        self.package_install_ruleset.insert_rule(index, rule.into());
71    }
72}
73
74impl Default for Northstar {
75    fn default() -> Self {
76        Self::with_default_rules()
77    }
78}
79
80impl Loader for Northstar {
81    fn id(&self) -> &'static str {
82        "Northstar"
83    }
84
85    fn loader_install_rules(&self) -> InstallRuleset<'_> {
86        static RULES: LazyLock<Vec<InstallRule>> = LazyLock::new(|| {
87            vec![
88                glob_rule!("Northstar/*" => ".")
89                    .strip_top_level(true)
90                    .use_links(true)
91                    .into(),
92            ]
93        });
94
95        InstallRuleset::new(&RULES)
96    }
97
98    fn package_install_rules(&self) -> InstallRuleset<'_> {
99        self.package_install_ruleset.as_ref()
100    }
101
102    fn prepare_launch(&self, ctx: &LaunchContext) -> Result<()> {
103        static GLOB_SET: LazyLock<GlobSet> = LazyLock::new(|| {
104            GlobSet::builder()
105                .add(
106                    GlobBuilder::new("*.{dll,exe,bat}")
107                        .literal_separator(true)
108                        .build()
109                        .expect("constant glob should be valid"),
110                )
111                .build()
112                .expect("constant globs should be valid")
113        });
114
115        ctx.copy_glob_to_game(&GLOB_SET)
116    }
117
118    fn generate_launch_args(&self, ctx: &LaunchContext) -> Result<LaunchArgs> {
119        let r2northstar_path = ctx.profile_path().join("R2Northstar");
120        let path = ctx.format_proton_path(&r2northstar_path);
121
122        let args = LaunchArgs::new()
123            .arg("-northstar")
124            .arg(format!("-profile={path}"));
125
126        Ok(args)
127    }
128
129    fn package_config_dirs(&self) -> Vec<PathBuf> {
130        Vec::new()
131    }
132
133    fn log_file(&self) -> Option<PathBuf> {
134        None
135    }
136
137    fn proxy_dll(&self) -> Option<PathBuf> {
138        None
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use loadsmith_core::{PackageRef, Version};
145
146    use crate::{assert_map, assert_maps, test_util::MapFileTester};
147
148    use super::*;
149
150    #[test]
151    fn map_loader_files() {
152        assert_maps!(MapFileTester::new(
153            Northstar::with_default_rules(),
154            PackageRef::new("northstar-Northstar".to_string(), Version::new(1, 31, 10)),
155            true,
156        ), [
157            "README.md" => None,
158            "Northstar/r2ds.bat" => "./r2ds.bat",
159            "Northstar/R2Northstar/file.txt" => "./R2Northstar/file.txt",
160            "Northstar/R2Northstar/plugins/DiscordRPC.dll" => "./R2Northstar/plugins/DiscordRPC.dll",
161        ])
162    }
163
164    #[test]
165    fn map_package_files() {
166        assert_maps!(MapFileTester::new(
167            Northstar::with_default_rules(),
168            PackageRef::new("Author-Name".to_string(), Version::new(1, 0, 0)),
169            false,
170        ), [
171            "manifest.json" => None,
172            "README.md" => None,
173            "icon.png" => None,
174            "LICENSE" => None,
175            "file.txt" => None,
176            "mods/file.txt" => "R2Northstar/mods/file.txt",
177            "mods/nested/file.txt" => "R2Northstar/mods/nested/file.txt",
178            "nested/mods/file.txt" => "R2Northstar/mods/nested/file.txt",
179        ]);
180    }
181
182    #[test]
183    fn package_dir_works() {
184        let loader = Northstar::with_default_rules();
185        let package = PackageRef::new("Author-Name".to_string(), Version::new(1, 0, 0));
186
187        assert!(loader.package_dir(&package).is_none());
188    }
189}