Skip to main content

loadsmith_loader/loaders/
bep_in_ex.rs

1use std::{path::PathBuf, sync::LazyLock};
2
3use camino::Utf8PathBuf;
4use globset::{GlobBuilder, GlobSet};
5use loadsmith_install::{InstallRule, InstallRuleset, OwnedInstallRuleset, RouteRule};
6
7use crate::{Error, LaunchArgs, LaunchContext, Loader, Result, doorstop, glob, glob_rule};
8
9/// Loader implementation for [BepInEx](https://docs.bepinex.dev/), a
10/// general-purpose Unity mod loader.
11///
12/// BepInEx uses Doorstop to inject into the game process and supports several
13/// sub-directories for plugins, patchers, monomod assemblies, and config.
14///
15/// # Examples
16///
17/// ```rust
18/// use loadsmith_loader::{BepInEx, Loader};
19///
20/// let loader = BepInEx::with_default_rules();
21/// assert_eq!(loader.id(), "BepInEx");
22/// ```
23#[derive(Debug, Clone)]
24pub struct BepInEx {
25    package_install_ruleset: OwnedInstallRuleset,
26}
27
28impl BepInEx {
29    /// Creates a `BepInEx` 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 `BepInEx` loader with the default install rules.
37    ///
38    /// Default routes:
39    ///
40    /// * `BepInEx/config` (mutable, flat)
41    /// * `BepInEx/patchers`
42    /// * `BepInEx/core`
43    /// * `BepInEx/monomod` (files with `.mm.dll` extension)
44    /// * `BepInEx/plugins` (files with `.dll` extension) — **default rule**
45    pub fn with_default_rules() -> Self {
46        OwnedInstallRuleset::from_rule_iter(
47            vec![
48                RouteRule::new_static("BepInEx/config")
49                    .with_subdir(false)
50                    .with_mutable(true),
51                RouteRule::new_static("BepInEx/patchers"),
52                RouteRule::new_static("BepInEx/core"),
53                RouteRule::new_static("BepInEx/monomod").with_file_extension("mm.dll"),
54                RouteRule::new_static("BepInEx/plugins").with_file_extension("dll"),
55            ],
56            Some(4),
57        )
58        .map(Self::with_rules)
59        .expect("rules are not empty so there should always be a valid default rule index")
60    }
61
62    /// Adds an install rule to the end of the ruleset.
63    pub fn add_install_rule(&mut self, rule: impl Into<InstallRule>) {
64        self.package_install_ruleset.add_rule(rule.into());
65    }
66
67    /// Inserts an install rule at the given index.
68    pub fn insert_install_rule(&mut self, index: usize, rule: impl Into<InstallRule>) {
69        self.package_install_ruleset.insert_rule(index, rule.into());
70    }
71}
72
73impl Default for BepInEx {
74    fn default() -> Self {
75        Self::with_default_rules()
76    }
77}
78
79impl Loader for BepInEx {
80    fn id(&self) -> &'static str {
81        "BepInEx"
82    }
83
84    fn loader_install_rules(&self) -> InstallRuleset<'_> {
85        static RULES: LazyLock<Vec<InstallRule>> = LazyLock::new(|| {
86            vec![
87                glob_rule!("*/*.{ini,cfg}" => ".")
88                    .strip_top_level(true)
89                    .into(),
90                glob_rule!("*/*" => ".")
91                    .strip_top_level(true)
92                    .use_links(true)
93                    .into(),
94            ]
95        });
96
97        InstallRuleset::new(&RULES)
98    }
99
100    fn package_install_rules(&self) -> InstallRuleset<'_> {
101        self.package_install_ruleset.as_ref()
102    }
103
104    fn prepare_launch(&self, ctx: &LaunchContext) -> Result<()> {
105        static INCLUDE_SET: LazyLock<GlobSet> = LazyLock::new(|| {
106            GlobSet::builder()
107                .add(
108                    // copy all loose files
109                    GlobBuilder::new("*")
110                        .literal_separator(true)
111                        .build()
112                        .expect("constant glob should be valid"),
113                )
114                .add(glob!("doorstop_libs/*"))
115                .add(glob!("dotnet/*"))
116                .add(glob!("corlibs/*"))
117                .build()
118                .expect("constant globs should be valid")
119        });
120
121        ctx.copy_glob_to_game(&INCLUDE_SET)
122    }
123
124    fn generate_launch_args(&self, ctx: &LaunchContext) -> Result<LaunchArgs> {
125        let (enable_prefix, target_prefix) = doorstop::args(None, ctx)?;
126        let preloader_path = bepinex_preloader_path(None, ctx)?;
127
128        let args = LaunchArgs::new()
129            .arg(enable_prefix)
130            .arg("true")
131            .arg(target_prefix)
132            .arg(&*ctx.format_proton_path(&preloader_path));
133
134        Ok(args)
135    }
136
137    fn package_config_dirs(&self) -> Vec<PathBuf> {
138        vec!["BepInEx/config".into()]
139    }
140
141    fn log_file(&self) -> Option<PathBuf> {
142        Some("BepInEx/BepInEx.log".into())
143    }
144
145    fn proxy_dll(&self) -> Option<PathBuf> {
146        Some("winhttp.dll".into())
147    }
148}
149
150pub(crate) fn bepinex_preloader_path(
151    prefix: Option<&str>,
152    ctx: &LaunchContext,
153) -> Result<Utf8PathBuf> {
154    let mut core_directory = ctx.profile_path().to_path_buf();
155
156    if let Some(prefix) = prefix {
157        core_directory.push(prefix);
158    }
159
160    core_directory.push("BepInEx");
161    core_directory.push("core");
162
163    const PRELOADER_NAMES: &[&str] = &[
164        "BepInEx.Unity.Mono.Preloader.dll",
165        "BepInEx.Unity.IL2CPP.dll",
166        "BepInEx.Preloader.dll",
167        "BepInEx.IL2CPP.dll",
168    ];
169
170    let entry = core_directory
171        .read_dir()
172        .map_err(|err| Error::BepInExCoreDirectoryMissing { source: err })?
173        .filter_map(|entry| entry.ok())
174        .find(|entry| {
175            let file_name = entry.file_name();
176            PRELOADER_NAMES.iter().any(|name| file_name == **name)
177        })
178        .ok_or(Error::BepInExPreloaderNotFound { core_directory })?;
179
180    let path = Utf8PathBuf::from_path_buf(entry.path())
181        .expect("BepInEx core directory should be valid UTF-8");
182
183    Ok(path)
184}
185
186#[cfg(test)]
187mod tests {
188    use loadsmith_core::{PackageRef, Version};
189
190    use crate::{assert_map, assert_maps, test_util::MapFileTester};
191
192    use super::*;
193
194    #[test]
195    fn map_loader_files() {
196        assert_maps!(MapFileTester::new(
197            BepInEx::with_default_rules(),
198            PackageRef::new("BepInEx-BepInExPack".to_string(), Version::new(5, 4, 2100)),
199            true,
200        ), [
201            "README.md" => None,
202            "BepInExPack/doorstop_config.ini" => "./doorstop_config.ini",
203            "BepInExPack/BepInEx/core/BepInEx.Preloader.dll" => "./BepInEx/core/BepInEx.Preloader.dll"
204        ])
205    }
206
207    #[test]
208    fn map_package_files() {
209        assert_maps!(MapFileTester::new(
210            BepInEx::with_default_rules(),
211            PackageRef::new("Author-Name".to_string(), Version::new(1, 0, 0)),
212            false,
213        ), [
214            "README.md" => "BepInEx/plugins/Author-Name/README.md",
215            "nested/file.txt" => "BepInEx/plugins/Author-Name/file.txt",
216            "plugins/nested/file.txt" => "BepInEx/plugins/Author-Name/nested/file.txt",
217            "BepInEx/plugins/file.txt" => "BepInEx/plugins/Author-Name/file.txt",
218            "config/settings.json" => "BepInEx/config/settings.json",
219            "BepInEx/config/myconfig.cfg" => "BepInEx/config/myconfig.cfg",
220            "patchers/patcher.dll" => "BepInEx/patchers/Author-Name/patcher.dll",
221            "core/core.dll" => "BepInEx/core/Author-Name/core.dll",
222            "patch.mm.dll" => "BepInEx/monomod/Author-Name/patch.mm.dll",
223            "nested/patch.mm.dll" => "BepInEx/monomod/Author-Name/patch.mm.dll",
224            "monomod/patch.dll" => "BepInEx/monomod/Author-Name/patch.dll",
225            "monomod/nested/patch.dll" => "BepInEx/monomod/Author-Name/nested/patch.dll"
226        ]);
227    }
228
229    #[test]
230    fn config_files_should_not_link() {
231        let loader = BepInEx::with_default_rules();
232        let rules = loader.package_install_rules();
233
234        assert!(
235            !rules
236                .find_rule_for_path("config/settings.json")
237                .unwrap()
238                .use_links()
239        );
240    }
241
242    #[test]
243    fn other_files_should_link() {
244        let loader = BepInEx::with_default_rules();
245        let rules = loader.package_install_rules();
246
247        assert!(
248            rules
249                .find_rule_for_path("plugins/file.txt")
250                .unwrap()
251                .use_links()
252        );
253
254        assert!(
255            rules
256                .find_rule_for_path("patchers/patch.dll")
257                .unwrap()
258                .use_links()
259        );
260
261        assert!(
262            rules
263                .find_rule_for_path("core/core.dll")
264                .unwrap()
265                .use_links()
266        );
267
268        assert!(
269            rules
270                .find_rule_for_path("patch.mm.dll")
271                .unwrap()
272                .use_links()
273        );
274    }
275
276    #[test]
277    fn package_dir_works() {
278        let loader = BepInEx::with_default_rules();
279        let package = PackageRef::new("Author-Name".to_string(), Version::new(1, 0, 0));
280
281        let package_dir = loader.package_dir(&package).unwrap();
282
283        assert_eq!(package_dir, PathBuf::from("BepInEx/plugins/Author-Name"));
284    }
285}