Skip to main content

mc_launcher_core/command/
builder.rs

1//! Launch command construction.
2//!
3//! This module converts merged Minecraft version metadata into a Java
4//! executable, argument list, working directory, and environment block. It does
5//! not spawn the process; callers can inspect or adjust the returned
6//! [`LaunchCommand`] before passing it to [`std::process::Command`].
7
8use std::path::PathBuf;
9
10use crate::{
11    account::Account,
12    compatibility::{apply_compatibility, CompatibilityPolicy},
13    core::{
14        arguments::{evaluate_arguments, ArgumentContext},
15        classpath::{classpath_entries_for_platform, classpath_string},
16        rules::FeatureSet,
17        version::VersionJson,
18    },
19    platform::{Os, Platform},
20    LauncherError, Result,
21};
22
23/// User and process settings used while building a launch command.
24///
25/// Start with [`LaunchOptions::default`] and override only the fields your
26/// launcher exposes. By default the game directory is isolated under
27/// `<minecraft_dir>/versions/<version_id>`, which keeps saves, options, logs,
28/// and mods separate per installed profile.
29#[derive(Debug, Clone)]
30pub struct LaunchOptions {
31    /// Account values substituted into Minecraft's auth placeholders.
32    pub account: Account,
33    /// Java executable to run.
34    ///
35    /// If omitted, the command uses `java` and relies on the caller's `PATH`.
36    pub java_executable: Option<PathBuf>,
37    /// Game directory passed as `${game_directory}` and used as process CWD.
38    ///
39    /// If omitted, a version-isolated directory is used.
40    pub game_directory: Option<PathBuf>,
41    /// Directory containing extracted native libraries.
42    ///
43    /// If omitted, this points at `<minecraft_dir>/versions/<version_id>/natives`.
44    pub natives_directory: Option<PathBuf>,
45    /// Launcher name passed to modern version argument templates.
46    pub launcher_name: String,
47    /// Launcher version passed to modern version argument templates.
48    pub launcher_version: String,
49    /// Optional window size appended as `--width` and `--height`.
50    pub custom_resolution: Option<(u32, u32)>,
51    /// Enables Minecraft demo mode.
52    pub demo: bool,
53    /// Optional multiplayer server and port to join after launch.
54    pub server: Option<(String, Option<u16>)>,
55    /// Appends the modern `--disableMultiplayer` flag.
56    pub disable_multiplayer: bool,
57    /// Appends the modern `--disableChat` flag.
58    pub disable_chat: bool,
59    /// Controls whether known compatibility patches are applied before building.
60    pub compatibility: CompatibilityPolicy,
61}
62
63impl Default for LaunchOptions {
64    fn default() -> Self {
65        Self {
66            account: Account::offline("Steve"),
67            java_executable: None,
68            game_directory: None,
69            natives_directory: None,
70            launcher_name: "mc-launcher-core".to_string(),
71            launcher_version: env!("CARGO_PKG_VERSION").to_string(),
72            custom_resolution: None,
73            demo: false,
74            server: None,
75            disable_multiplayer: false,
76            disable_chat: false,
77            compatibility: CompatibilityPolicy::Auto,
78        }
79    }
80}
81
82/// A Java process description ready to spawn.
83///
84/// The command is intentionally returned as structured parts instead of a shell
85/// string so launchers can avoid quoting bugs across Windows, macOS, and Linux.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct LaunchCommand {
88    /// Java executable path or command name.
89    pub executable: PathBuf,
90    /// JVM, main class, and game arguments in process order.
91    pub args: Vec<String>,
92    /// Directory that should be used as the child process current directory.
93    pub working_dir: PathBuf,
94    /// Environment variables to set on the child process.
95    pub env: Vec<(String, String)>,
96}
97
98impl LaunchCommand {
99    /// Returns the executable and argument list for callers that do not need
100    /// working-directory or environment metadata.
101    pub fn to_process_parts(&self) -> (PathBuf, Vec<String>) {
102        (self.executable.clone(), self.args.clone())
103    }
104}
105
106/// Builds a launch command for the current platform.
107///
108/// This is the lower-level equivalent of
109/// [`crate::launcher::Launcher::build_launch_command_from_version`].
110///
111/// # Errors
112///
113/// Returns [`crate::LauncherError`] if required version fields are missing or if
114/// library coordinates cannot be converted into classpath entries.
115pub fn build_launch_command(
116    version: &VersionJson,
117    minecraft_dir: PathBuf,
118    options: LaunchOptions,
119) -> Result<LaunchCommand> {
120    build_launch_command_for_platform(version, minecraft_dir, options, Platform::current())
121}
122
123/// Builds a launch command for an explicit platform.
124///
125/// This function is mainly useful for tests, planning tools, or launchers that
126/// need to inspect cross-platform output. Normal applications should call
127/// [`build_launch_command`].
128///
129/// # Errors
130///
131/// Returns [`crate::LauncherError`] if the version metadata cannot produce a
132/// complete executable command.
133pub fn build_launch_command_for_platform(
134    version: &VersionJson,
135    minecraft_dir: PathBuf,
136    options: LaunchOptions,
137    platform: Platform,
138) -> Result<LaunchCommand> {
139    let compatibility = apply_compatibility(version, platform, options.compatibility);
140    let version = &compatibility.version;
141    let version_id = version
142        .id
143        .as_deref()
144        .ok_or_else(|| LauncherError::MissingField {
145            context: "version json".to_string(),
146            field: "id".to_string(),
147        })?;
148    let main_class = version
149        .main_class
150        .clone()
151        .ok_or_else(|| LauncherError::MissingField {
152            context: version_id.to_string(),
153            field: "mainClass".to_string(),
154        })?;
155
156    let game_dir = options
157        .game_directory
158        .clone()
159        .unwrap_or_else(|| minecraft_dir.join("versions").join(version_id));
160    let natives_dir = options.natives_directory.clone().unwrap_or_else(|| {
161        minecraft_dir
162            .join("versions")
163            .join(version_id)
164            .join("natives")
165    });
166    let entries = classpath_entries_for_platform(version, &minecraft_dir, platform)?;
167    let classpath = classpath_string(&entries);
168    let assets_index = version.assets.as_deref().unwrap_or(version_id);
169    let version_type = version.r#type.as_deref().unwrap_or("release");
170
171    let features = FeatureSet {
172        demo_user: options.demo,
173        custom_resolution: options.custom_resolution.is_some(),
174        ..Default::default()
175    };
176    let context = ArgumentContext {
177        minecraft_dir: &minecraft_dir,
178        natives_dir: &natives_dir,
179        game_dir: &game_dir,
180        version,
181        account: &options.account,
182        classpath: &classpath,
183        launcher_name: &options.launcher_name,
184        launcher_version: &options.launcher_version,
185        version_type,
186        assets_index,
187        extra: Default::default(),
188    };
189
190    let executable = options
191        .java_executable
192        .unwrap_or_else(|| PathBuf::from("java"));
193    let mut args = evaluate_arguments(&version.arguments.jvm, &context, &features, platform);
194    if args.is_empty() {
195        args.extend(default_legacy_jvm_arguments(
196            &natives_dir,
197            &classpath,
198            platform,
199        ));
200    }
201    args.push(main_class);
202
203    if version.minecraft_arguments.is_some() {
204        let legacy = version
205            .minecraft_arguments
206            .as_deref()
207            .unwrap_or_default()
208            .split(' ')
209            .map(|part| crate::core::arguments::replace_placeholders(part, &context));
210        args.extend(legacy);
211    } else {
212        args.extend(evaluate_arguments(
213            &version.arguments.game,
214            &context,
215            &features,
216            platform,
217        ));
218    }
219
220    if let Some((width, height)) = options.custom_resolution {
221        args.extend([
222            "--width".to_string(),
223            width.to_string(),
224            "--height".to_string(),
225            height.to_string(),
226        ]);
227    }
228    if options.demo {
229        args.push("--demo".to_string());
230    }
231    if let Some((server, port)) = options.server {
232        args.extend(["--server".to_string(), server]);
233        if let Some(port) = port {
234            args.extend(["--port".to_string(), port.to_string()]);
235        }
236    }
237    if options.disable_multiplayer {
238        args.push("--disableMultiplayer".to_string());
239    }
240    if options.disable_chat {
241        args.push("--disableChat".to_string());
242    }
243
244    Ok(LaunchCommand {
245        executable,
246        args,
247        working_dir: game_dir,
248        env: Vec::new(),
249    })
250}
251
252fn default_legacy_jvm_arguments(
253    natives_dir: &std::path::Path,
254    classpath: &str,
255    platform: Platform,
256) -> Vec<String> {
257    let mut args = Vec::new();
258    if platform.os == Os::MacOs {
259        args.push("-XstartOnFirstThread".to_string());
260    }
261    args.push(format!("-Djava.library.path={}", natives_dir.display()));
262    args.push("-cp".to_string());
263    args.push(classpath.to_string());
264    args
265}