Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+368
View File
@@ -0,0 +1,368 @@
//! game 快照 → [`LaunchRequest`] 的适配层。
//!
//! launch 不 import `crate::game` 的**清单类型**(避免对 M14 的模型产生编译期耦合,见
//! `DevDocs/SubAgentsTalk/launch-接口约定.md` §6):本模块只吃 game 侧**已经算好**的纯数据——
//! `arguments.expand(&env)` 的展开结果(rules 已在 game 求值)、库文件路径、目录布局,
//! 把它们装配成命令行组装所需的结构。
//!
//! 唯一例外是 [`crate::game::capability`] 里的 `&str → bool` 纯谓词:能力位的判据属于
//! 版本 JSON 的语义、必须只有一处实现,抄第二份必然漂移。它不带任何 game 结构体。
//!
//! 三个职责:
//! 1. classpath 过滤 + **缺失库显式记录**(analyse/02 §8.4-3:不再静默跳过,否则只能靠
//! `NoClassDefFoundError` 事后猜);
//! 2. features 开关的派生(`has_custom_resolution` / `has_quick_plays_support`);
//! 3. 组装 `LaunchRequest`(内存/账户/加载器/分辨率/QuickPlay/代理/日志配置)。
use std::path::PathBuf;
use crate::game::capability::library_names_have_vulkan;
use crate::launch::argument::ArgumentEntry;
use crate::launch::error::MissingComponent;
use crate::launch::game_version::GameVersion;
use crate::launch::natives::{NativeJar, NativesEra, NativesPlan};
use crate::launch::request::{
AuthPlaceholders, FeatureSet, InstanceInfo, JavaSelection, LaunchDirs, LaunchRequest,
LauncherInfo, Log4jOptions, ManifestArgs, MemoryOptions, ModLoaderKind, OptimizationOptions,
QuickPlay, RendererOptions, Resolution, SocksProxy, UserArgs,
};
/// 一个待进 classpath 的库(game 侧已按环境过滤)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LibraryInput {
/// 落盘路径(`GameLayout::library(coord)` 的结果)。
pub path: PathBuf,
/// 坐标或展示名(缺失时报错用,例 `org.lwjgl:lwjgl:3.3.1`)。
pub name: String,
}
impl LibraryInput {
/// 构造。
pub fn new(path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
Self {
path: path.into(),
name: name.into(),
}
}
}
/// 版本快照里 launch 需要的部分(全部是纯数据,不依赖 game 类型)。
#[derive(Debug, Clone)]
pub struct SnapshotInput<'a> {
/// 版本 id(`versions/<id>/`)。
pub version_id: &'a str,
/// 展示名(缺省用 version_id)。
pub version_name: Option<&'a str>,
/// 版本类型(`release` / `snapshot`)。
pub version_type: Option<&'a str>,
/// 游戏版本串(门限判断用;缺省用 version_id)。
pub game_version: Option<&'a str>,
/// 主类。
pub main_class: Option<&'a str>,
/// `arguments.jvm` 展开后的 token。
pub jvm_arguments: &'a [String],
/// `arguments.game` 展开后的 token。
pub game_arguments: &'a [String],
/// 旧版 `minecraftArguments` 整串。
pub legacy_arguments: Option<&'a str>,
/// 待进 classpath 的库(已过滤环境、不含 native)。
pub libraries: &'a [LibraryInput],
/// natives 分类器构件(`natives` 表命中的 jar;1.19+ 为空,natives jar 在 classpath 里)。
pub natives: &'a [NativeJar],
/// 主 jar 路径。
pub primary_jar: PathBuf,
/// 运行目录(`${game_directory}`)。
pub game_dir: PathBuf,
/// 资产索引 id。
pub assets_index: Option<&'a str>,
/// 原生库目录。
pub natives_dir: PathBuf,
/// 库根目录。
pub library_dir: PathBuf,
/// 资产根。
pub assets_root: PathBuf,
/// 虚拟资产目录(`${game_assets}`;无虚拟化时等于 assets_root)。
pub game_assets: PathBuf,
}
impl<'a> SnapshotInput<'a> {
/// 最小构造:只给版本 id 与主类,其余留空(测试/CLI 组装用)。
pub fn minimal(version_id: &'a str, main_class: &'a str) -> Self {
Self {
version_id,
version_name: None,
version_type: None,
game_version: None,
main_class: Some(main_class),
jvm_arguments: &[],
game_arguments: &[],
legacy_arguments: None,
libraries: &[],
natives: &[],
primary_jar: PathBuf::new(),
game_dir: PathBuf::new(),
assets_index: None,
natives_dir: PathBuf::new(),
library_dir: PathBuf::new(),
assets_root: PathBuf::new(),
game_assets: PathBuf::new(),
}
}
fn resolved_version(&self) -> GameVersion {
GameVersion::parse(self.game_version.unwrap_or(self.version_id))
}
}
/// 适配选项(账户/内存/加载器/一键进入等运行期选择)。
#[derive(Debug, Clone, Default)]
pub struct AdapterOptions {
/// Java 运行时。
pub java: JavaSelection,
/// 内存。
pub memory: MemoryOptions,
/// 账户占位符。
pub auth: AuthPlaceholders,
/// 启动器名(`${launcher_name}`)。
pub launcher_name: String,
/// 启动器版本(`${launcher_version}`)。
pub launcher_version: String,
/// 模组加载器。
pub mod_loader: ModLoaderKind,
/// 分辨率(`Some` 时自动打开 `has_custom_resolution`)。
pub resolution: Option<Resolution>,
/// 全屏。
pub fullscreen: bool,
/// 一键进入。
pub quick_play: Option<QuickPlay>,
/// SOCKS 代理。
pub proxy: Option<SocksProxy>,
/// 图形后端。
pub graphics_backend: Option<String>,
/// log4j 配置文件(launch 侧注入)。
pub log4j_config: Option<PathBuf>,
/// `-Dfile.encoding` 指定值。
pub encoding: Option<String>,
/// 界面语言。
pub language: String,
/// 额外环境变量。
pub environment: Vec<(String, String)>,
/// JVM 自动优化。
pub optimization: OptimizationOptions,
/// 渲染器开关。
pub renderer: RendererOptions,
/// 用户自定义参数。
pub user: UserArgs,
/// 是否要求库文件真实存在。
///
/// - `true`(默认,严格):缺库/缺主 jar → 记 `AdapterWarning::MissingLibrary` 并填进
/// `missing_components`,组装前显式失败(analyse/02 §8.4-3);
/// - `false`(宽松,HMCL 行为):**不检查存在性、不跳过、不告警**,库照进 classpath,
/// 交给运行时(`NoClassDefFoundError`)——仅在用户明确选“跳过校验”时用。
pub check_classpath: bool,
}
impl AdapterOptions {
/// 默认严格模式:库必须存在(analyse/02 §8.4-3)。
pub fn strict() -> Self {
Self {
check_classpath: true,
language: "zh-CN".to_string(),
..Self::default()
}
}
}
/// 适配告警(非致命)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdapterWarning {
/// 库文件缺失(严格模式下同时会变成 `missing_components` → 组装失败)。
MissingLibrary {
/// 坐标/展示名。
name: String,
/// 期望路径。
path: PathBuf,
},
/// 既没有展开后的游戏参数,也没有 legacy 整串。
MissingGameArguments,
/// 没有资产索引 id(`${assets_index_name}` 会是空串)。
MissingAssetsIndex,
/// 主类缺失(组装一定会报错,这里提前给出中文提示)。
MissingMainClass,
/// natives 分类器 jar 缺失(严格模式下同时会变成 `missing_components` → 组装失败)。
MissingNativeJar {
/// 坐标 + 分类器。
name: String,
/// 期望路径。
path: PathBuf,
},
}
/// 适配结果。
#[derive(Debug, Clone)]
pub struct AdapterOutput {
/// 组装输入。
pub request: LaunchRequest,
/// 告警。
pub warnings: Vec<AdapterWarning>,
/// 实际进 classpath 的条目数(库 + 主 jar)。
pub classpath_entries: usize,
/// 缺失库清单。
pub missing: Vec<MissingComponent>,
}
/// 把快照 + 选项装配成 [`LaunchRequest`]。
pub fn build_request(snapshot: &SnapshotInput<'_>, options: AdapterOptions) -> AdapterOutput {
let mut warnings = Vec::new();
let mut missing = Vec::new();
let mut classpath = Vec::new();
for library in snapshot.libraries {
if options.check_classpath && !library.path.is_file() {
warnings.push(AdapterWarning::MissingLibrary {
name: library.name.clone(),
path: library.path.clone(),
});
missing.push(MissingComponent::at_path(
library.name.clone(),
library.path.clone(),
));
continue;
}
if options.check_classpath && classpath.contains(&library.path) {
continue;
}
classpath.push(library.path.clone());
}
if !snapshot.primary_jar.as_os_str().is_empty()
&& options.check_classpath
&& !snapshot.primary_jar.is_file()
{
let name = snapshot
.primary_jar
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "client.jar".to_string());
warnings.push(AdapterWarning::MissingLibrary {
name: name.clone(),
path: snapshot.primary_jar.clone(),
});
missing.push(MissingComponent::at_path(
name,
snapshot.primary_jar.clone(),
));
}
if snapshot.main_class.is_none() {
warnings.push(AdapterWarning::MissingMainClass);
}
if snapshot.assets_index.is_none() {
warnings.push(AdapterWarning::MissingAssetsIndex);
}
if snapshot.game_arguments.is_empty() && snapshot.legacy_arguments.is_none() {
warnings.push(AdapterWarning::MissingGameArguments);
}
// natives jar 缺失同样属「组件缺失」:解压期才发现会只剩一句 UnsatisfiedLinkError。
for native in snapshot.natives {
if options.check_classpath && !native.path.is_file() {
warnings.push(AdapterWarning::MissingNativeJar {
name: native.name.clone(),
path: native.path.clone(),
});
missing.push(MissingComponent::at_path(
native.name.clone(),
native.path.clone(),
));
}
}
let version = snapshot.resolved_version();
let features = FeatureSet {
has_custom_resolution: options.resolution.is_some(),
is_demo_user: false,
has_quick_plays_support: version.is_at_least("1.20"),
};
let manifest = ManifestArgs {
main_class: snapshot.main_class.map(str::to_string),
jvm: entries(snapshot.jvm_arguments),
game: entries(snapshot.game_arguments),
legacy_arguments: snapshot.legacy_arguments.map(str::to_string),
has_vulkan_backend: library_names_have_vulkan(
snapshot
.libraries
.iter()
.map(|library| library.name.as_str()),
),
};
let classpath_entries =
classpath.len() + usize::from(!snapshot.primary_jar.as_os_str().is_empty());
let era = NativesEra::detect(snapshot.jvm_arguments.len(), snapshot.natives.len());
let natives = NativesPlan {
jars: snapshot.natives.to_vec(),
era,
custom: false,
};
let request = LaunchRequest {
instance: InstanceInfo {
id: snapshot.version_id.to_string(),
name: snapshot
.version_name
.unwrap_or(snapshot.version_id)
.to_string(),
version,
version_type: snapshot.version_type.unwrap_or("release").to_string(),
},
launcher: LauncherInfo {
name: options.launcher_name,
version: options.launcher_version,
},
java: options.java,
memory: options.memory,
manifest,
dirs: LaunchDirs {
game_dir: snapshot.game_dir.clone(),
assets_root: snapshot.assets_root.clone(),
game_assets: snapshot.game_assets.clone(),
assets_index: snapshot.assets_index.unwrap_or("legacy").to_string(),
natives_dir: snapshot.natives_dir.clone(),
library_dir: snapshot.library_dir.clone(),
primary_jar: snapshot.primary_jar.clone(),
},
natives,
auth: options.auth,
user: options.user,
features,
resolution: options.resolution,
quick_play: options.quick_play,
proxy: options.proxy,
graphics_backend: options.graphics_backend,
fullscreen: options.fullscreen,
mod_loader: options.mod_loader,
log4j: Log4jOptions {
enabled: true,
config_file: options.log4j_config,
debug: false,
},
renderer: options.renderer,
optimization: options.optimization,
encoding: options.encoding,
language: options.language,
classpath,
missing_components: missing.clone(),
extra_jvm: Vec::new(),
..LaunchRequest::default()
};
AdapterOutput {
request,
warnings,
classpath_entries,
missing,
}
}
fn entries(tokens: &[String]) -> Vec<ArgumentEntry> {
if tokens.is_empty() {
Vec::new()
} else {
vec![ArgumentEntry::plain(tokens.iter().cloned())]
}
}
+253
View File
@@ -0,0 +1,253 @@
//! 命令行组装总表:`analyse/02` §4.1 的 33 项顺序表的 Rust 化。
//!
//! 顺序本身有语义(后加的默认值会被先加的显式值抑制):
//!
//! | # | 内容 | 本文件落点 |
//! |---|------|-----------|
//! | 1~3 | `nice` / 用户包装器 / java 可执行文件 | [`assemble`] |
//! | 4 | 最高优先级 JVM 参数(含 `@argfile` 展开) | [`assemble`] |
//! | 5~7 | `-Xmx`/`-Xms`、Metaspace、用户 JVM 参数(按默认值) | [`assemble`] |
//! | 8~17 | 编码、Log4j、主 jar、代理、自动优化、版本开关、FML、渲染器 | `jvm_args` |
//! | 18~19 | classpath 计算与占位符表(`${classpath}` 等 26 项) | [`assemble`] + `placeholders` |
//! | 20~32 | 清单 JVM 参数、主类、游戏参数、QuickPlay、代理、图形后端、用户参数 | `game_args` |
//! | 33 | 删除被禁止参数(`-Xincgc`) | [`assemble`] |
//!
//! 组装是**纯函数**:给定 `LaunchRequest` + `PlatformCtx` 一定得到同样的命令行,
//! 因此可以整体快照测试(`crates/core/tests/launch/cmdline_snapshot.rs`)。
use std::fmt;
use std::path::PathBuf;
use crate::launch::command_builder::{quote_for_display, CommandBuilder};
use crate::launch::defaults;
use crate::launch::error::{AssemblyError, Result};
use crate::launch::game_args;
use crate::launch::jvm_args;
use crate::launch::placeholders::{join_classpath, PlaceholderMap};
use crate::launch::platform::PlatformCtx;
use crate::launch::request::{LaunchRequest, MemoryOptions};
/// 组装过程中的“非致命”告警(不中断启动,但要告诉用户我们改了什么)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssemblyWarning {
/// 注入了 log4j 参数却没有配置文件。
Log4jConfigMissing,
/// 用户自己写了代理参数,我们跳过自动代理参数。
ProxyJvmArgsSkipped,
/// `-Xms` 大于 `-Xmx`,已忽略 `-Xms`。
MemoryMinIgnored {
/// 请求的最小堆。
min_mb: u32,
/// 请求的最大堆。
max_mb: u32,
},
/// 版本不支持 QuickPlay,已退化成 `--server/--port`。
QuickPlayDegraded {
/// 原始地址。
address: String,
},
/// 服务器地址非法,QuickPlay 被丢弃。
QuickPlayAddressInvalid {
/// 原始地址。
address: String,
},
/// 版本不支持 QuickPlay 且无法退化(存档/Realms)。
QuickPlayUnsupported {
/// 游戏版本。
version: String,
},
/// 版本用不了 `--graphicsBackend`,已丢弃。
///
/// 两种来源:可解析版本低于 `26.2-snapshot-2`;或周快照(`26w14a`)但版本库表里
/// 没有 `lwjgl-vulkan`(见 `game_args::graphics_backend_allowed`)。
GraphicsBackendSkipped {
/// 游戏版本。
version: String,
},
}
impl fmt::Display for AssemblyWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Log4jConfigMissing => f.write_str("log4j 注入已启用但缺少配置文件,已跳过"),
Self::ProxyJvmArgsSkipped => f.write_str("用户已指定代理参数,跳过自动代理 JVM 参数"),
Self::MemoryMinIgnored { min_mb, max_mb } => {
write!(f, "-Xms{min_mb}m 大于 -Xmx{max_mb}m,已忽略 -Xms")
}
Self::QuickPlayDegraded { address } => {
write!(f, "版本不支持 QuickPlay,已退化为 --server {address}")
}
Self::QuickPlayAddressInvalid { address } => {
write!(f, "服务器地址非法,已丢弃 QuickPlay:{address}")
}
Self::QuickPlayUnsupported { version } => {
write!(f, "版本 {version} 不支持该 QuickPlay 目标,已丢弃")
}
Self::GraphicsBackendSkipped { version } => {
write!(f, "版本 {version} 不支持 --graphicsBackend,已丢弃")
}
}
}
}
/// 组装结果。
#[derive(Debug, Clone)]
pub struct AssembledCommand {
/// 实际要 spawn 的程序(通常是 java,带包装器时是包装器)。
pub program: PathBuf,
/// 参数(不含 `program`)。
pub args: Vec<String>,
/// 完整 classpath(库 + 主 jar 末位)。
pub classpath: Vec<PathBuf>,
/// 日志解码编码(决定 pump 用什么解码子进程输出)。
pub encoding: String,
/// 内存设置(与 `-Xmx`/`-Xms` 一致)。
pub memory: MemoryOptions,
/// 环境变量(实例标识/渲染/加载器/用户自定义)。
pub env: Vec<(String, String)>,
/// 组装告警。
pub warnings: Vec<AssemblyWarning>,
/// 主类。
pub main_class: String,
}
impl AssembledCommand {
/// 完整 argv(`program` 在第一个)。
pub fn argv(&self) -> Vec<String> {
let mut argv = Vec::with_capacity(self.args.len() + 1);
argv.push(self.program.display().to_string());
argv.extend(self.args.iter().cloned());
argv
}
/// 人读形式(含空格的参数加引号),用于日志与快照测试。
pub fn display(&self) -> String {
let mut parts = Vec::with_capacity(self.args.len() + 1);
parts.push(quote_for_display(&self.program.display().to_string()));
for arg in &self.args {
parts.push(quote_for_display(arg));
}
parts.join(" ")
}
}
/// 组装完整命令行。
pub fn assemble(req: &LaunchRequest, ctx: &PlatformCtx) -> Result<AssembledCommand> {
let main_class = req
.manifest
.main_class
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(AssemblyError::MissingMainClass)?
.to_string();
if req.java.binary.as_os_str().is_empty() {
return Err(AssemblyError::EmptyJavaPath.into());
}
if !req.missing_components.is_empty() {
return Err(AssemblyError::MissingComponents(req.missing_components.clone()).into());
}
let classpath = effective_classpath(req);
if classpath.is_empty() {
return Err(AssemblyError::EmptyClasspath.into());
}
// 第 19 项:占位符表(`${classpath}` 用最终 classpath,主 jar 已在末位)。
let mut placeholders = PlaceholderMap::from_request(req, ctx);
placeholders.insert(
"classpath",
join_classpath(&classpath, ctx.path_separator()),
);
// 第 1~3 项:优先级包装 → 用户包装器 → java 可执行文件。
let mut head: Vec<String> = Vec::new();
if let Some(nice) = req.nice {
if ctx.os.is_posix() {
head.push("nice".to_string());
head.push("-n".to_string());
head.push(nice.to_string());
}
}
head.extend(req.wrapper.iter().cloned());
head.push(req.java.binary.display().to_string());
let program = PathBuf::from(head.remove(0));
let mut builder = CommandBuilder::with_placeholders(placeholders);
for token in &head {
builder.add_without_parsing(token.clone());
}
// 第 4 项:最高优先级 JVM 参数(原样追加,且登记 @argfile 展开结果)。
builder.add_all_without_parsing_and_read_external(
&req.user.override_jvm,
&crate::launch::command_builder::read_argfile,
);
let mut warnings = Vec::new();
// 第 5 项:`-Xmx` / `-Xms`(`-Xms` 必须 ≤ `-Xmx`)。
if let Some(max_mb) = req.memory.max_mb {
let _ = builder.add_default("-Xmx", &format!("{max_mb}m"));
}
if let Some(min_mb) = req.memory.min_mb {
match req.memory.max_mb {
Some(max_mb) if min_mb > max_mb => {
warnings.push(AssemblyWarning::MemoryMinIgnored { min_mb, max_mb });
}
_ => {
let _ = builder.add_default("-Xms", &format!("{min_mb}m"));
}
}
}
// 第 6 项:Metaspace(Java ≥8)或 PermSize(更早)。
if req.java.major >= 8 {
let _ = builder.add_default("-XX:MetaspaceSize=", "64m");
} else {
let _ = builder.add_default("-XX:PermSize=", "64m");
}
// 第 7 项:用户 JVM 参数——整体按“默认值”处理。
builder.add_all_default(&req.user.jvm);
// 第 8~17 项。
let (encoding, jvm_warnings) = jvm_args::apply_jvm_arguments(req, ctx, &mut builder);
warnings.extend(jvm_warnings);
// 第 20~32 项。
warnings.extend(game_args::apply_game_arguments(req, ctx, &mut builder));
// 第 33 项:删除禁用参数。
builder.remove_forbidden(&defaults::forbidden_args(req.java.major));
let env = defaults::environment(req, ctx.os);
Ok(AssembledCommand {
program,
args: builder.into_args(),
classpath,
encoding,
memory: req.memory,
env,
warnings,
main_class,
})
}
/// 最终 classpath:库按给定顺序去重,主 jar 永远追加在末位(Mojang 惯例,
/// 影响同名类优先级,`analyse/02` §4.5)。
pub fn effective_classpath(req: &LaunchRequest) -> Vec<PathBuf> {
let mut classpath: Vec<PathBuf> = Vec::new();
let candidates = req
.classpath
.iter()
.chain(std::iter::once(&req.dirs.primary_jar));
for path in candidates {
if path.as_os_str().is_empty() {
continue;
}
if !classpath.contains(path) {
classpath.push(path.clone());
}
}
classpath
}
+205
View File
@@ -0,0 +1,205 @@
//! 清单参数模型:`arguments.jvm` / `arguments.game` 的 `{rules, value}` 求值。
//!
//! 平移自 `analyse/02` §4.7:`Argument` 是 `StringArgument | RuledArgument`,
//! `CompatibilityRule` 默认 `DISALLOW`、按顺序取最后一个命中的 action;
//! `os` 不匹配的规则整体“无效”(不是“不满足”),`features` 必须逐项相等。
use crate::launch::request::FeatureSet;
/// 规则动作。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleAction {
/// 允许。
Allow,
/// 禁止。
Disallow,
}
/// `rules.os` 子句。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OsRule {
/// `windows` / `osx` / `linux`。
pub name: Option<String>,
/// `x86` / `x86_64` / `arm64`。
pub arch: Option<String>,
/// 系统版本模式(Mojang 用正则,这里只做前缀/子串匹配)。
pub version: Option<String>,
}
/// 单条规则。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rule {
/// 命中后的动作。
pub action: RuleAction,
/// 系统条件。
pub os: Option<OsRule>,
/// 特性条件(逐项相等才算命中)。
pub features: Vec<(String, bool)>,
}
impl Rule {
/// 允许规则。
pub fn allow() -> Self {
Self {
action: RuleAction::Allow,
os: None,
features: Vec::new(),
}
}
/// 禁止规则。
pub fn disallow() -> Self {
Self {
action: RuleAction::Disallow,
os: None,
features: Vec::new(),
}
}
/// 加系统条件。
pub fn with_os_name(mut self, name: impl Into<String>) -> Self {
let mut os = self.os.take().unwrap_or_default();
os.name = Some(name.into());
self.os = Some(os);
self
}
/// 加特性条件。
pub fn with_feature(mut self, name: impl Into<String>, expected: bool) -> Self {
self.features.push((name.into(), expected));
self
}
/// 求值:`Some(true)` 命中且允许,`Some(false)` 命中且禁止,`None` 不适用(规则无效)。
pub fn evaluate(&self, ctx: &RuleContext<'_>) -> Option<bool> {
if let Some(os) = &self.os {
if let Some(name) = &os.name {
if name != ctx.os_name {
return None;
}
}
if let Some(arch) = &os.arch {
if !arch_matches(arch, ctx.os_arch) {
return None;
}
}
if let Some(pattern) = &os.version {
if !version_matches(pattern, ctx.os_version) {
return None;
}
}
}
for (name, expected) in &self.features {
if ctx.features.get(name) != Some(*expected) {
return None;
}
}
Some(self.action == RuleAction::Allow)
}
}
/// 一个参数项:一组规则 + 一组取值(取值里可含 `${...}` 占位符)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ArgumentEntry {
/// 规则(空表示无条件适用)。
pub rules: Vec<Rule>,
/// 取值(每个元素是一个独立命令行 token)。
pub values: Vec<String>,
}
impl ArgumentEntry {
/// 无条件参数项。
pub fn plain(values: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
rules: Vec::new(),
values: values.into_iter().map(Into::into).collect(),
}
}
/// 带规则参数项。
pub fn ruled(rules: Vec<Rule>, values: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
rules,
values: values.into_iter().map(Into::into).collect(),
}
}
/// 是否在当前环境下适用。
pub fn applies(&self, ctx: &RuleContext<'_>) -> bool {
if self.rules.is_empty() {
return true;
}
let mut allowed = false;
for rule in &self.rules {
if let Some(action) = rule.evaluate(ctx) {
allowed = action;
}
}
allowed
}
}
/// rules 求值上下文。
#[derive(Debug, Clone, Copy)]
pub struct RuleContext<'a> {
/// `rules.os.name` 取值。
pub os_name: &'a str,
/// `rules.os.arch` 取值。
pub os_arch: &'a str,
/// `rules.os.version` 取值。
pub os_version: &'a str,
/// 特性开关。
pub features: &'a FeatureSet,
}
impl<'a> RuleContext<'a> {
/// 构造。
pub fn new(
os_name: &'a str,
os_arch: &'a str,
os_version: &'a str,
features: &'a FeatureSet,
) -> Self {
Self {
os_name,
os_arch,
os_version,
features,
}
}
}
fn arch_matches(rule_arch: &str, actual: &str) -> bool {
if rule_arch == actual {
return true;
}
// Mojang 用 "x86" 表示 32 位,我们的实际值可能是 "i686" 之类。
matches!(
(rule_arch, actual),
("x86", "i686") | ("x86_64", "amd64") | ("arm64", "aarch64")
)
}
/// 只做保守匹配:支持 `^10\.` / `^10\.15` 这类前缀模式与纯子串,无法识别的模式按“不匹配”处理
/// (宁可少加一个参数,也不要给错误平台加参数)。
///
/// 处理:去掉前置 `^` 与结尾 `$`、`\.` 反转义成 `.`、去掉结尾的点(`^10\.` 的语义就是
/// “以 `10.` 开头”,点本身不参与前缀比较)。含 `[ ( { | * + ?` 等真需要正则的模式一律不匹配。
fn version_matches(pattern: &str, actual: &str) -> bool {
if actual.is_empty() {
return false;
}
let trimmed = pattern.trim();
let stripped = trimmed.strip_prefix('^').unwrap_or(trimmed);
if stripped.contains(['[', '(', '{', '|', '*', '+', '?']) {
return false;
}
let mut literal = stripped.replace("\\.", ".");
while literal.ends_with(['$', '.']) {
literal.pop();
}
if literal.is_empty() {
return false;
}
actual.starts_with(&literal) || actual.contains(&literal)
}
+335
View File
@@ -0,0 +1,335 @@
//! `CommandBuilder`:命令行组装器与“默认值抑制”语义。
//!
//! 平移自 `analyse/02` §4.2(`CommandBuilder.java`)——这是 HMCL 命令行正确性的基石,
//! 也是“用户参数永远优先、我们只补缺”的唯一可靠实现:
//!
//! - [`CommandBuilder::add`] 无条件追加(占位符已替换);
//! - [`CommandBuilder::add_without_parsing`] 原样追加(已带引号或变量的串);
//! - [`CommandBuilder::add_default`] 若已存在同前缀参数则不追加(opt=前缀,如 `-Xmx`);
//! - [`CommandBuilder::add_unstable_flag`] / [`CommandBuilder::add_unstable_default`]
//! 按 `-XX:key=value` 与 `-XX:±key` 两种形态判重,避免同时出现 `+UseG1GC` 与 `-UseG1GC`;
//! - [`CommandBuilder::add_all_default`] 把一组参数整体按“默认值”处理(`-D` 拆键值、
//! `-XX:` 走 unstable 分支、`-Xmx/-Xms/-Xmn/-Xss` 拆前缀、`-cp` 成对处理,其余要求完全相同);
//! - `@argfile` 感知:Java 9+ 的 `@options-file` 展开后的参数登记进 `external`,
//! 让后续默认值同样被抑制。
//!
//! 与 HMCL 的差异(已记录在 `DevDocs/SubAgentsTalk/launch-接口约定.md` §7):我们直接 spawn
//! 子进程、不经过 shell,因此不做批处理/Shell 转义;转义只在“生成启动脚本”(P1)时需要。
use std::path::Path;
use crate::launch::placeholders::PlaceholderMap;
/// 命令行组装器。
#[derive(Debug, Clone, Default)]
pub struct CommandBuilder {
args: Vec<String>,
external: Vec<String>,
placeholders: PlaceholderMap,
}
impl CommandBuilder {
/// 空构造器。
pub fn new() -> Self {
Self::default()
}
/// 带占位符表的构造器。
pub fn with_placeholders(placeholders: PlaceholderMap) -> Self {
Self {
args: Vec::new(),
external: Vec::new(),
placeholders,
}
}
/// 占位符表。
pub fn placeholders(&self) -> &PlaceholderMap {
&self.placeholders
}
/// 追加一个参数(替换占位符)。
pub fn add(&mut self, arg: &str) {
let rendered = self.placeholders.render(arg);
self.args.push(rendered);
}
/// 追加一组参数(替换占位符)。
pub fn add_all(&mut self, args: &[String]) {
for arg in args {
self.add(arg);
}
}
/// 原样追加(不替换占位符)。
pub fn add_without_parsing(&mut self, arg: impl Into<String>) {
self.args.push(arg.into());
}
/// 原样追加一组。
pub fn add_all_without_parsing(&mut self, args: &[String]) {
for arg in args {
self.add_without_parsing(arg.clone());
}
}
/// 原样追加一组,并读取其中的 `@argfile`:
/// 文件里展开出来的参数登记为 `external`,后续 [`Self::add_default`] 会被它们抑制。
pub fn add_all_without_parsing_and_read_external(
&mut self,
args: &[String],
read_argfile: &dyn Fn(&str) -> Option<String>,
) {
for arg in args {
self.add_without_parsing(arg.clone());
if let Some(path) = arg.strip_prefix('@') {
if let Some(contents) = read_argfile(path) {
for extra in parse_argfile(&contents) {
self.external.push(extra);
}
}
}
}
}
/// 已存在以 `prefix` 开头的参数则返回它,否则追加 `prefix + value` 并返回 `None`。
///
/// 返回值的语义与 HMCL 一致:`None` = “是我加的”,`Some(已有值)` = “用户已经指定过”。
pub fn add_default(&mut self, prefix: &str, value: &str) -> Option<String> {
if let Some(existing) = self.find_starting_with(prefix) {
return Some(existing.to_string());
}
let rendered = self.placeholders.render(&format!("{prefix}{value}"));
self.args.push(rendered);
None
}
/// `-XX:key=value` 形态的默认值;已存在该 key 的任何形态则不追加。
pub fn add_unstable_default(&mut self, key: &str, value: &str) -> Option<String> {
if let Some(existing) = self.find_unstable(key) {
return Some(existing.to_string());
}
let rendered = self.placeholders.render(&format!("-XX:{key}={value}"));
self.args.push(rendered);
None
}
/// `-XX:+key` / `-XX:-key` 形态的默认值;已存在该 key 的任何形态则不追加。
pub fn add_unstable_flag(&mut self, key: &str, enabled: bool) -> Option<String> {
if let Some(existing) = self.find_unstable(key) {
return Some(existing.to_string());
}
let sign = if enabled { '+' } else { '-' };
self.args.push(format!("-XX:{sign}{key}"));
None
}
/// 一组参数整体按“默认值”处理。
pub fn add_all_default(&mut self, args: &[String]) {
let mut index = 0;
while index < args.len() {
let raw = args[index].trim();
if raw.is_empty() {
index += 1;
continue;
}
let resolved = self.placeholders.render(raw);
if matches!(raw, "-cp" | "-classpath") {
let exists = self.find_starting_with(raw).is_some();
if !exists {
self.args.push(resolved);
if let Some(value) = args.get(index + 1) {
let value = self.placeholders.render(value);
self.args.push(value);
}
}
index += 2;
continue;
}
if self.try_add_key_value_default(&resolved) {
index += 1;
continue;
}
let exists = self
.args
.iter()
.chain(self.external.iter())
.any(|arg| arg == &resolved);
if !exists {
self.args.push(resolved);
}
index += 1;
}
}
/// 已存在的、以 `prefix` 开头的参数。
pub fn find_starting_with(&self, prefix: &str) -> Option<&str> {
self.args
.iter()
.chain(self.external.iter())
.find(|arg| arg.starts_with(prefix))
.map(String::as_str)
}
/// 已存在的、提到 `key` 的 `-XX:` 参数(两种形态都算)。
pub fn find_unstable(&self, key: &str) -> Option<&str> {
self.args
.iter()
.chain(self.external.iter())
.find(|arg| unstable_key(arg) == Some(key))
.map(String::as_str)
}
/// 是否已存在完全相同的参数。
pub fn contains_exact(&self, arg: &str) -> bool {
self.args.iter().any(|existing| existing == arg)
}
/// 删除禁用参数(例如 Java ≥9 上的 `-Xincgc`),返回被删掉的项。
pub fn remove_forbidden(&mut self, forbidden: &[&str]) -> Vec<String> {
let mut removed = Vec::new();
self.args.retain(|arg| {
if forbidden.contains(&arg.as_str()) {
removed.push(arg.clone());
false
} else {
true
}
});
removed
}
/// 当前参数列表。
pub fn args(&self) -> &[String] {
&self.args
}
/// 取走参数列表。
pub fn into_args(self) -> Vec<String> {
self.args
}
/// 人读形式(含空格的参数加双引号),用于日志与快照测试。
pub fn display_args(&self) -> String {
self.args
.iter()
.map(|arg| quote_for_display(arg))
.collect::<Vec<_>>()
.join(" ")
}
fn try_add_key_value_default(&mut self, resolved: &str) -> bool {
if let Some(rest) = resolved.strip_prefix("-D") {
if let Some(eq) = rest.find('=') {
let split = 2 + eq + 1;
let prefix = resolved[..split].to_string();
let value = resolved[split..].to_string();
let _ = self.add_default(&prefix, &value);
return true;
}
}
if let Some(rest) = resolved.strip_prefix("-XX:") {
if let Some(eq) = rest.find('=') {
let _ = self.add_unstable_default(&rest[..eq], &rest[eq + 1..]);
return true;
}
if let Some(signless) = rest.strip_prefix(['+', '-']) {
let _ = self.add_unstable_flag(signless, rest.starts_with('+'));
return true;
}
}
for prefix in ["-Xmx", "-Xms", "-Xmn", "-Xss"] {
if let Some(value) = resolved.strip_prefix(prefix) {
let _ = self.add_default(prefix, value);
return true;
}
}
false
}
}
fn unstable_key(arg: &str) -> Option<&str> {
let rest = arg.strip_prefix("-XX:")?;
let body = rest.strip_prefix(['+', '-']).unwrap_or(rest);
Some(body.split('=').next().unwrap_or(body))
}
/// 把 `minecraftArguments` 之类的一整串拆成参数(引号感知,反斜杠转义 `"` 与 `\`)。
pub fn tokenize(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut has_token = false;
let mut in_single = false;
let mut in_double = false;
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\'' if !in_double => {
in_single = !in_single;
has_token = true;
}
'"' if !in_single => {
in_double = !in_double;
has_token = true;
}
'\\' if !in_single => match chars.peek() {
Some(&next) if next == '"' || next == '\\' => {
current.push(next);
chars.next();
has_token = true;
}
_ => {
current.push(ch);
has_token = true;
}
},
c if c.is_whitespace() && !in_single && !in_double => {
if has_token {
out.push(std::mem::take(&mut current));
has_token = false;
}
}
c => {
current.push(c);
has_token = true;
}
}
}
if has_token {
out.push(current);
}
out
}
/// 解析 `@argfile` 内容:忽略空行与 `#` 注释行,每行再走 [`tokenize`]。
pub fn parse_argfile(contents: &str) -> Vec<String> {
let mut out = Vec::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
out.extend(tokenize(line));
}
out
}
/// 从磁盘读取 `@argfile`(读不到就当没有;不因用户文件缺失而中断启动)。
pub fn read_argfile(path: &str) -> Option<String> {
let path = path.strip_prefix('@').unwrap_or(path);
std::fs::read(Path::new(path))
.ok()
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
}
/// 人读引用:含空白或引号的参数加双引号并转义内部引号。
pub fn quote_for_display(arg: &str) -> String {
if arg.is_empty() {
return "\"\"".to_string();
}
if arg.contains([' ', '\t', '"']) {
format!("\"{}\"", arg.replace('"', "\\\""))
} else {
arg.to_string()
}
}
+152
View File
@@ -0,0 +1,152 @@
//! 崩溃现场扫描:`crash-reports/*.txt`、`hs_err_pid*.log`、`logs/latest.log`。
//!
//! 按 `DevDocs/SubAgentsTalk/launch-接口约定.md` §3:`crash_found` 事件的 `path` 是
//! **权威字段**(M15 按路径自己读盘,`excerpt` 仅供人读)。所以这里只做“找到并给出路径”,
//! 不做解析——解析(hs_err 的失败帧/内存段)是 M15 的 `hs_err` 解析器。
//!
//! 只收**本次会话之后**动过的文件(`since` = 进程启动时刻),避免把上一局的崩溃报告
//! 算到这一局头上(这是 HMCL 会犯的错)。
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
/// 崩溃现场文件来源。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrashSource {
/// `crash-reports/crash-*.txt`(游戏自己写的权威 Description + 栈)。
CrashReport,
/// JVM 致命错误 `hs_err_pid*.log`(HMCL 完全不解析的空白区)。
HsErr,
/// `logs/latest.log`(内存缓冲不够长时的兜底全文)。
LatestLog,
}
impl CrashSource {
/// 事件流里的字符串。
pub fn as_str(self) -> &'static str {
match self {
Self::CrashReport => "crash_report",
Self::HsErr => "hs_err",
Self::LatestLog => "latest_log",
}
}
}
/// 一个现场文件。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrashArtifact {
/// 来源类型。
pub source: CrashSource,
/// 绝对/相对路径(交给 M15)。
pub path: PathBuf,
/// 供人读的开头片段(`latest.log` 不给,太大)。见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §4(写入前脱敏)。
pub excerpt: Option<String>,
}
/// 默认摘录上限(字符)。
pub const MAX_EXCERPT_CHARS: usize = 2000;
/// 扫描一次运行目录,返回按修改时间倒序的现场文件(最多 `limit` 个)。
///
/// `since` 之前的文件视为上一局遗留,忽略;给 `since` 留 1 秒容差以吸收文件系统时间精度。
pub fn scan(run_dir: &Path, since: SystemTime, limit: usize) -> Vec<CrashArtifact> {
let since = since.checked_sub(Duration::from_secs(1)).unwrap_or(since);
let mut found: Vec<(SystemTime, CrashArtifact)> = Vec::new();
collect_matching(
&run_dir.join("crash-reports"),
CrashSource::CrashReport,
since,
&mut found,
);
collect_matching(run_dir, CrashSource::HsErr, since, &mut found);
if let Some(artifact) = latest_log(run_dir, since) {
let modified = artifact_time(&artifact.path).unwrap_or(since);
found.push((modified, artifact));
}
found.sort_by_key(|item| std::cmp::Reverse(item.0));
found
.into_iter()
.take(limit)
.map(|(_, artifact)| artifact)
.collect()
}
fn collect_matching(
dir: &Path,
source: CrashSource,
since: SystemTime,
out: &mut Vec<(SystemTime, CrashArtifact)>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !is_candidate(&path, source) {
continue;
}
let Some(modified) = artifact_time(&path) else {
continue;
};
if modified < since {
continue;
}
out.push((
modified,
CrashArtifact {
source,
path: path.clone(),
excerpt: read_excerpt(&path, MAX_EXCERPT_CHARS),
},
));
}
}
fn is_candidate(path: &Path, source: CrashSource) -> bool {
if !path.is_file() {
return false;
}
let name = path
.file_name()
.map(|name| name.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
match source {
CrashSource::CrashReport => name.ends_with(".txt"),
CrashSource::HsErr => name.starts_with("hs_err_pid") && name.ends_with(".log"),
CrashSource::LatestLog => false,
}
}
fn latest_log(run_dir: &Path, since: SystemTime) -> Option<CrashArtifact> {
let path = run_dir.join("logs").join("latest.log");
let modified = artifact_time(&path)?;
if modified < since {
return None;
}
Some(CrashArtifact {
source: CrashSource::LatestLog,
path,
excerpt: None,
})
}
fn artifact_time(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
/// 读文件开头 `max_chars` 个字符(读不到就 `None`,不因权限/编码问题中断收尾)。
pub fn read_excerpt(path: &Path, max_chars: usize) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
let text = String::from_utf8_lossy(&bytes);
let mut excerpt = String::new();
for (count, ch) in text.chars().enumerate() {
if count >= max_chars {
excerpt.push('…');
break;
}
excerpt.push(ch);
}
Some(excerpt)
}
+203
View File
@@ -0,0 +1,203 @@
//! 默认参数表:JVM 优化、编码、log4j、禁用参数、环境变量。
//!
//! 平移自 `analyse/02` §4.3(自动优化)、§4.4(编码/Log4j)、§4.1 #33(禁用参数)、
//! §4.10(环境变量)。所有函数都是纯函数——同样的输入永远给同样的参数,便于快照测试。
use crate::launch::platform::OsFamily;
use crate::launch::request::{LaunchRequest, ModLoaderKind, RendererOptions, Resolution};
/// 旧版清单的默认 JVM 参数(`analyse/02` §4.7 的 `DEFAULT_JVM_ARGUMENTS`)。
///
/// 现代清单把这两项写在自己的 `arguments.jvm` 里,只有 legacy 需要补。
pub fn legacy_jvm_arguments() -> Vec<String> {
vec![
"-Djava.library.path=${natives_directory}".to_string(),
"-cp".to_string(),
"${classpath}".to_string(),
]
}
/// stdout/stderr 编码参数。
///
/// Java ≥19 用 `-Dstdout.encoding`,更早用 `-Dsun.stdout.encoding`(`analyse/02` §4.4:
/// 用户指定的 `-Dfile.encoding` 会决定日志泵的解码方式,这里必须跟随)。
pub fn stream_encoding_args(java_major: u32, encoding: &str) -> Vec<String> {
if java_major >= 19 {
vec![
format!("-Dstdout.encoding={encoding}"),
format!("-Dstderr.encoding={encoding}"),
]
} else {
vec![
format!("-Dsun.stdout.encoding={encoding}"),
format!("-Dsun.stderr.encoding={encoding}"),
]
}
}
/// Log4j2 RCE 缓解三件套(`analyse/02` §4.1 #9)。
pub fn log4j_mitigation_args() -> Vec<String> {
vec![
"-Djava.rmi.server.useCodebaseOnly=true".to_string(),
"-Dcom.sun.jndi.rmi.object.trustURLCodebase=false".to_string(),
"-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false".to_string(),
]
}
/// 自动优化参数(`analyse/02` §4.3)。
///
/// `user_specified_gc` 为真时不注入 G1 整组(用户显式挑了 GC 就别打架);
/// 其它每一条都还会被 `CommandBuilder` 的默认值抑制再筛一遍。
pub fn optimization_args(
java_major: u32,
bits: u32,
physical_memory_mb: Option<u64>,
user_specified_gc: bool,
) -> Vec<String> {
let mut args = vec![
"-XX:+UnlockExperimentalVMOptions".to_string(),
"-XX:+UnlockDiagnosticVMOptions".to_string(),
];
if java_major >= 8 && !user_specified_gc {
for arg in [
"-XX:+UseG1GC",
"-XX:G1MixedGCCountTarget=5",
"-XX:G1NewSizePercent=20",
"-XX:G1ReservePercent=20",
"-XX:MaxGCPauseMillis=50",
"-XX:G1HeapRegionSize=32m",
] {
args.push(arg.to_string());
}
}
// 崩溃诊断关键项:关掉“快速抛出时省略栈”,保证日志里有完整堆栈。
args.push("-XX:-OmitStackTraceInFastThrow".to_string());
if java_major <= 8 {
args.push("-XX:MaxInlineLevel=15".to_string());
}
if bits >= 64 && physical_memory_mb.is_some_and(|mb| mb > 4096) {
for arg in [
"-XX:-DontCompileHugeMethods",
"-XX:MaxNodeLimit=240000",
"-XX:NodeLimitFudgeFactor=8000",
"-XX:TieredCompileTaskTimeout=10000",
"-XX:ReservedCodeCacheSize=400M",
"-XX:NonNMethodCodeHeapSize=12M",
"-XX:ProfiledCodeHeapSize=194M",
"-XX:NmethodSweepActivity=1",
] {
args.push(arg.to_string());
}
}
if bits >= 64 && (25..=26).contains(&java_major) {
args.push("-XX:+UseCompactObjectHeaders".to_string());
}
args
}
/// 版本特定的 JVM 开关(`analyse/02` §4.1 #15)。
pub fn version_specific_args(java_major: u32) -> Vec<String> {
match java_major {
16 => vec!["--illegal-access=permit".to_string()],
24 | 25 => vec!["--sun-misc-unsafe-memory-access=allow".to_string()],
_ => Vec::new(),
}
}
/// Forge/NeoForge 的证书校验豁免(`analyse/02` §4.1 #16)。
pub fn fml_args() -> Vec<String> {
vec![
"-Dfml.ignoreInvalidMinecraftCertificates=true".to_string(),
"-Dfml.ignorePatchDiscrepancies=true".to_string(),
]
}
/// 渲染器相关 `-D` 参数(`analyse/02` §4.1 #17,v1 只做 native 路径覆盖)。
///
/// 软件渲染的细节开关走环境变量([`environment`]),因为 Mesa/llvmpipe 读的是 env。
pub fn renderer_args(renderer: &RendererOptions) -> Vec<String> {
let mut args = Vec::new();
if let Some(dir) = &renderer.mesa_native_dir {
args.push(format!("-Dorg.lwjgl.librarypath={}", dir.display()));
}
args
}
/// 被禁止的参数(最后统一清理,`analyse/02` §4.1 #33)。
pub fn forbidden_args(java_major: u32) -> Vec<&'static str> {
if java_major >= 9 {
vec!["-Xincgc"]
} else {
Vec::new()
}
}
/// 旧版清单的默认游戏参数(分辨率,`analyse/02` §4.1 #26)。
pub fn legacy_game_args(resolution: Option<&Resolution>) -> Vec<String> {
let mut args = Vec::new();
if let Some(res) = resolution {
args.push("--width".to_string());
args.push(res.width.to_string());
args.push("--height".to_string());
args.push(res.height.to_string());
}
args
}
/// 进程环境变量(`analyse/02` §4.10)。
///
/// 顺序:实例标识 → 渲染器开关 → 加载器标记 → 用户自定义(最后覆盖)。
pub fn environment(req: &LaunchRequest, os: OsFamily) -> Vec<(String, String)> {
let mut env: Vec<(String, String)> = Vec::new();
set_env(&mut env, "INST_NAME", &req.instance.name);
set_env(&mut env, "INST_ID", &req.instance.id);
set_env(
&mut env,
"INST_DIR",
&req.dirs.game_dir.display().to_string(),
);
set_env(
&mut env,
"INST_MC_DIR",
&req.dirs.game_dir.display().to_string(),
);
set_env(
&mut env,
"INST_JAVA",
&req.java.binary.display().to_string(),
);
if req.mod_loader == ModLoaderKind::OptiFine {
set_env(&mut env, "INST_OPTIFINE", "1");
}
if req.renderer.software {
set_env(&mut env, "LIBGL_ALWAYS_SOFTWARE", "1");
set_env(&mut env, "MESA_LOADER_DRIVER_OVERRIDE", "llvmpipe");
set_env(&mut env, "GALLIUM_DRIVER", "llvmpipe");
}
if os == OsFamily::MacOs {
if let Some(icd) = &req.renderer.vulkan_icd {
set_env(&mut env, "VK_ICD_FILENAMES", &icd.display().to_string());
set_env(&mut env, "VK_DRIVER_FILES", &icd.display().to_string());
}
}
match req.mod_loader {
ModLoaderKind::Forge => set_env(&mut env, "FORGE_INSTALL", "1"),
ModLoaderKind::NeoForge => set_env(&mut env, "NEOFORGE_INSTALL", "1"),
ModLoaderKind::Fabric => set_env(&mut env, "FABRIC_INSTALL", "1"),
ModLoaderKind::Quilt => set_env(&mut env, "QUILT_INSTALL", "1"),
ModLoaderKind::LiteLoader => set_env(&mut env, "LITELOADER_INSTALL", "1"),
ModLoaderKind::OptiFine | ModLoaderKind::Vanilla => {}
}
for (key, value) in &req.user.environment {
set_env(&mut env, key, value);
}
env
}
fn set_env(env: &mut Vec<(String, String)>, key: &str, value: &str) {
if let Some(slot) = env.iter_mut().find(|(existing, _)| existing == key) {
slot.1 = value.to_string();
} else {
env.push((key.to_string(), value.to_string()));
}
}
+97
View File
@@ -0,0 +1,97 @@
//! 诊断挂钩:launch 与 M15(`core::diagnose`)之间的唯一接缝。
//!
//! 分工(`DevDocs/SubAgentsTalk/launch-接口约定.md` §3):**launch 报事实、M15 下结论**。
//! launch 在进程结束后把「退出判定 + 日志环形缓冲 + 崩溃现场文件 + 环境快照」打包成
//! [`DiagnosisRequest`] 交给挂钩;挂钩返回结构化 payload(`candidates[]` 等),由 launch
//! 脱敏后写进同一个 JSONL(单一真相源)。
//!
//! 这样 M15 不需要 import launch 的类型,launch 也不需要 import diagnose 的类型——
//! 阶段 B 的 diagnose 尚未合并,P0 先用 [`NoopDiagnosis`] 占位。
use std::path::PathBuf;
use serde_json::Value;
use crate::launch::crash_scan::CrashArtifact;
use crate::launch::exit_analysis::ExitAnalysis;
use crate::launch::pump::LogLine;
/// 诊断挂钩。
pub trait DiagnosisHook: Send + Sync {
/// 需要诊断时调用;返回 `None` 表示“这次没有结论”(事件流里就不会有 `diagnosis` 事件)。
fn analyze(&self, request: &DiagnosisRequest) -> Option<Value>;
}
/// 空挂钩:不诊断(占位 / 用户关闭 AI)。
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopDiagnosis;
impl DiagnosisHook for NoopDiagnosis {
fn analyze(&self, _request: &DiagnosisRequest) -> Option<Value> {
None
}
}
/// 交给诊断挂钩的完整事实包。
#[derive(Debug, Clone)]
pub struct DiagnosisRequest {
/// 会话 id(M15 可据此回读 jsonl)。
pub session_id: String,
/// 实例 id。
pub instance_id: String,
/// 实例名。
pub instance_name: String,
/// 游戏版本。
pub game_version: String,
/// 退出判定。
pub exit: ExitAnalysis,
/// 存活毫秒数。
pub uptime_ms: u64,
/// 环形缓冲里的日志行(按时间顺序,已脱敏)。
pub log_lines: Vec<LogLine>,
/// 崩溃现场文件(crash-report / hs_err / latest.log)。
pub artifacts: Vec<CrashArtifact>,
/// 脱敏后的完整 argv。
pub argv: Vec<String>,
/// 最大堆(MiB)。
pub memory_mb: Option<u32>,
/// Java 主版本。
pub java_major: u32,
/// Java 可执行文件。
pub java_binary: PathBuf,
/// 运行目录。
pub game_dir: PathBuf,
}
impl DiagnosisRequest {
/// 错误级日志行(M15 规则匹配的主输入)。
pub fn error_lines(&self) -> Vec<String> {
self.log_lines
.iter()
.filter(|line| line.is_error())
.map(|line| line.text.clone())
.collect()
}
/// 全部日志拼成文本(M15 自己的 `LogBuffer` 也能直接用 `log_lines`)。
pub fn log_text(&self) -> String {
let mut text = String::new();
for line in &self.log_lines {
text.push_str(&line.text);
text.push('\n');
}
text
}
/// 一句话摘要(日志/UI 展示)。
pub fn summary(&self) -> String {
format!(
"会话 {} 退出:[{}];错误行 {};现场文件 {};需诊断={}",
self.session_id,
self.exit.summary(),
self.error_lines().len(),
self.artifacts.len(),
self.exit.needs_diagnosis
)
}
}
+165
View File
@@ -0,0 +1,165 @@
//! launch 模块统一错误类型。
//!
//! 设计依据:`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1、`analyse/02` §8.4-3
//! (缺库/缺组件必须结构化报错并显式失败,而不是交给运行时 `NoClassDefFoundError`)。
use std::fmt;
use std::path::PathBuf;
/// launch 相关操作的统一结果类型。
pub type Result<T> = std::result::Result<T, LaunchError>;
/// 缺失的组件(库 / natives / 资产),供 UI 与 agent 直接展示与修复。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MissingComponent {
/// 逻辑名,例如 `org.lwjgl:lwjgl:3.3.1` 或 `libraries/foo/bar.jar`。
pub name: String,
/// 期望落盘路径(未知时为 `None`)。
pub path: Option<PathBuf>,
}
impl MissingComponent {
/// 只按名字构造(路径未知)。
pub fn named(name: impl Into<String>) -> Self {
Self {
name: name.into(),
path: None,
}
}
/// 带期望路径构造。
pub fn at_path(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
Self {
name: name.into(),
path: Some(path.into()),
}
}
}
/// 命令行组装阶段的失败原因(全部是“启动前就能确定”的错误)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssemblyError {
/// 清单里没有主类,无法启动(HMCL 在此直接抛异常)。
MissingMainClass,
/// classpath 为空(一个库和主 jar 都没有)。
EmptyClasspath,
/// Java 可执行文件路径为空。
EmptyJavaPath,
/// 组件不完整:启动前显式失败,附结构化清单。
MissingComponents(Vec<MissingComponent>),
/// 清单参数畸形(例如空参数项)。
MalformedArgument {
/// 原始参数项文本。
entry: String,
/// 原因说明。
reason: &'static str,
},
/// natives 解压失败(jar 缺失/损坏、目录不可写)。
///
/// 归到「启动前失败」而不是运行时错误:native 库没落地时游戏只会给出
/// `UnsatisfiedLinkError`,那时候已经离「到底哪个 jar 坏了」太远了。
NativesExtraction {
/// 人读原因(含失败路径)。
message: String,
},
}
impl fmt::Display for AssemblyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingMainClass => f.write_str("版本清单缺少主类(mainClass)"),
Self::EmptyClasspath => f.write_str("classpath 为空:没有任何库或主 jar"),
Self::EmptyJavaPath => f.write_str("Java 可执行文件路径为空"),
Self::MissingComponents(items) => {
write!(f, "组件缺失 {} 项", items.len())?;
for item in items.iter().take(8) {
write!(f, "; {}", item.name)?;
}
Ok(())
}
Self::MalformedArgument { entry, reason } => {
write!(f, "参数畸形({reason}):{entry}")
}
Self::NativesExtraction { message } => {
write!(f, "natives 解压失败:{message}")
}
}
}
}
impl std::error::Error for AssemblyError {}
/// launch 模块的统一错误。
#[derive(Debug)]
pub enum LaunchError {
/// 命令行组装失败(纯计算,可单测)。
Assembly(AssemblyError),
/// 文件系统操作失败。
Io {
/// 操作名(`create_session` / `read_argfile` 等)。
op: &'static str,
/// 相关路径。
path: PathBuf,
/// 底层错误。
source: std::io::Error,
},
/// 进程派生失败(Java 不存在、权限不足等)。
Spawn {
/// 被执行的程序。
program: PathBuf,
/// 底层错误。
source: std::io::Error,
},
/// 会话事件流写入/解析失败。
Session {
/// 会话文件路径。
path: PathBuf,
/// 原因说明。
reason: String,
},
/// 进程树 kill 失败。
Kill {
/// 目标进程号。
pid: u32,
/// 底层错误。
source: std::io::Error,
},
}
impl fmt::Display for LaunchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Assembly(err) => write!(f, "命令行组装失败:{err}"),
Self::Io { op, path, source } => {
write!(f, "{op} 失败({}):{source}", path.display())
}
Self::Spawn { program, source } => {
write!(f, "启动进程失败({}):{source}", program.display())
}
Self::Session { path, reason } => {
write!(f, "会话事件流错误({}):{reason}", path.display())
}
Self::Kill { pid, source } => {
write!(f, "结束进程树失败(pid {pid}):{source}")
}
}
}
}
impl std::error::Error for LaunchError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Assembly(err) => Some(err),
Self::Io { source, .. } | Self::Spawn { source, .. } | Self::Kill { source, .. } => {
Some(source)
}
Self::Session { .. } => None,
}
}
}
impl From<AssemblyError> for LaunchError {
fn from(err: AssemblyError) -> Self {
Self::Assembly(err)
}
}
+264
View File
@@ -0,0 +1,264 @@
//! 退出分析:退出码语义 + `exit 0` 静默失败检测。
//!
//! 平移自 `analyse/02` §6.3/§7.2(`ExitWaiter` 的三源判定),并按 §8.4-2 / §7.10-10 做两处升级:
//!
//! 1. **完整退出码表**:不只认 Linux 的 `137`,Windows 的 NTSTATUS
//! (`0xC0000005` 等)也要能说清是什么;
//! 2. **静默失败单独立标**:退出码 0 但存活 <30s 或 stderr 带错误特征时标记
//! `silent_failure`——HMCL 会判 NORMAL 放过(LaunchWrapper 吞异常后 `exit 0` 是
//! 真实存在的失败形态)。
use crate::launch::pump::LogLine;
/// 退出判定类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitType {
/// 正常退出。
Normal,
/// 应用层崩溃(游戏起来了但崩了)。
ApplicationError,
/// JVM 都没起来(`-Xmx` 过大、JVM 参数非法等)。
JvmError,
/// 被 `SIGKILL` 结束(POSIX 137)。
SigKill,
/// 被 `SIGTERM` 结束(POSIX 143)。
SigTerm,
/// 被启动器主动结束(用户点取消 / 看门狗强制)。
Killed,
/// 等待被中断。
Interrupted,
}
impl ExitType {
/// 事件流里的字符串形式(与 M15 `ExitType` 刻意保持一致)。
pub fn as_str(self) -> &'static str {
match self {
Self::Normal => "normal",
Self::ApplicationError => "application_error",
Self::JvmError => "jvm_error",
Self::SigKill => "sigkill",
Self::SigTerm => "sigterm",
Self::Killed => "killed",
Self::Interrupted => "interrupted",
}
}
/// 是否属于“非正常退出”。
pub fn is_abnormal(self) -> bool {
self != Self::Normal
}
}
/// JVM 未能启动的三条关键字(`analyse/02` §7.2)。
pub const JVM_FAILURE_KEYWORDS: &[&str] = &[
"Could not create the Java Virtual Machine.",
"Error occurred during initialization of VM",
"A fatal exception has occurred. Program will exit.",
];
/// 应用层崩溃的五条关键字(`analyse/02` §7.2,覆盖三种崩法)。
pub const CRASH_KEYWORDS: &[&str] = &[
"Crash report saved to",
"Could not save crash report to",
"This crash report has been saved to:",
"Unable to launch",
"An exception was thrown, the game will display an error screen and halt.",
];
/// 静默失败的存活阈值(毫秒):<30s 视为“没真正跑起来”。
pub const SHORT_LIVED_MS: u64 = 30_000;
/// 退出分析的输入。
#[derive(Debug, Clone, Copy)]
pub struct ExitSignals<'a> {
/// 退出码。
pub code: i32,
/// 进程存活毫秒数。
pub uptime_ms: u64,
/// 环形缓冲里的日志行。
pub lines: &'a [LogLine],
/// 是否启动器主动结束。
pub killed: bool,
/// 等待是否被中断。
pub interrupted: bool,
/// 是否 POSIX(决定 137/143 是否按信号解读)。
pub is_posix: bool,
}
impl<'a> ExitSignals<'a> {
/// 构造。
pub fn new(code: i32, uptime_ms: u64, lines: &'a [LogLine]) -> Self {
Self {
code,
uptime_ms,
lines,
killed: false,
interrupted: false,
is_posix: cfg!(unix),
}
}
}
/// 静默失败标记。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SilentFailure {
/// 存活时间过短。
pub short_lived: bool,
/// stderr/日志里有错误特征。
pub error_output: bool,
}
impl SilentFailure {
/// 人读说明。
pub fn describe(&self) -> String {
match (self.short_lived, self.error_output) {
(true, true) => "退出码 0,但存活不足 30 秒且输出含错误特征".to_string(),
(true, false) => "退出码 0,但存活不足 30 秒".to_string(),
(false, true) => "退出码 0,但输出含错误特征".to_string(),
(false, false) => "退出码 0".to_string(),
}
}
}
/// 退出分析结果(`exit` 事件的 payload 来源)。
#[derive(Debug, Clone)]
pub struct ExitAnalysis {
/// 退出码。
pub code: i32,
/// 判定类型。
pub exit_type: ExitType,
/// 存活毫秒数。
pub uptime_ms: u64,
/// 错误级日志行数。
pub error_lines: u32,
/// 命中的崩溃关键字。
pub crash_keywords: Vec<String>,
/// 命中的 JVM 失败关键字。
pub jvm_keywords: Vec<String>,
/// 静默失败标记(仅 `Normal` 时可能非空)。
pub silent_failure: Option<SilentFailure>,
/// Windows NTSTATUS 解释(例 `0xC0000005 ACCESS_VIOLATION`)。
pub windows_status: Option<String>,
/// 是否应该进入诊断流水线(M15)。
pub needs_diagnosis: bool,
}
impl ExitAnalysis {
/// 一句话摘要(UI/agent 可直接展示)。
pub fn summary(&self) -> String {
let mut text = format!(
"退出码 {}({}),存活 {:.1}s,错误行 {}",
self.code,
self.exit_type.as_str(),
self.uptime_ms as f64 / 1000.0,
self.error_lines
);
if let Some(status) = &self.windows_status {
text.push_str(&format!(",{status}"));
}
if !self.crash_keywords.is_empty() {
text.push_str(&format!(",崩溃关键字 {}", self.crash_keywords.len()));
}
if let Some(silent) = &self.silent_failure {
text.push_str(&format!(",{}", silent.describe()));
}
text
}
}
/// 做退出分析。
pub fn analyze(signals: &ExitSignals<'_>) -> ExitAnalysis {
let error_lines = signals.lines.iter().filter(|line| line.is_error()).count();
let crash_keywords = matched_keywords(signals.lines, CRASH_KEYWORDS);
let jvm_keywords = matched_keywords(signals.lines, JVM_FAILURE_KEYWORDS);
let error_output = signals
.lines
.iter()
.any(|line| line.is_error() && looks_like_failure(&line.text));
let exit_type = if signals.interrupted {
ExitType::Interrupted
} else if signals.killed {
ExitType::Killed
} else if signals.code != 0 && !jvm_keywords.is_empty() {
ExitType::JvmError
} else if signals.is_posix && signals.code == 137 {
ExitType::SigKill
} else if signals.is_posix && signals.code == 143 {
ExitType::SigTerm
} else if signals.code != 0 || !crash_keywords.is_empty() {
ExitType::ApplicationError
} else {
ExitType::Normal
};
let silent_failure = if exit_type == ExitType::Normal {
let short_lived = signals.uptime_ms < SHORT_LIVED_MS;
if short_lived || error_output {
Some(SilentFailure {
short_lived,
error_output,
})
} else {
None
}
} else {
None
};
ExitAnalysis {
code: signals.code,
exit_type,
uptime_ms: signals.uptime_ms,
error_lines: u32::try_from(error_lines).unwrap_or(u32::MAX),
crash_keywords: crash_keywords.clone(),
jvm_keywords,
silent_failure,
windows_status: windows_status_name(signals.code),
needs_diagnosis: exit_type.is_abnormal()
|| silent_failure.is_some()
|| !crash_keywords.is_empty(),
}
}
/// Windows NTSTATUS → 解释。
pub fn windows_status_name(code: i32) -> Option<String> {
let (hex, name) = match code {
-1_073_741_819 => ("0xC0000005", "ACCESS_VIOLATION"),
-1_073_740_791 => ("0xC0000409", "STACK_BUFFER_OVERRUN"),
-1_073_741_515 => ("0xC0000135", "DLL_NOT_FOUND"),
-1_073_741_571 => ("0xC00000FD", "STACK_OVERFLOW"),
-1_073_741_510 => ("0xC000013A", "CONTROL_C_EXIT"),
_ => return None,
};
Some(format!("{hex} {name}"))
}
/// 退出码的人工提示(附录 C 速查表的裁剪版)。
pub fn exit_code_hint(code: i32, is_posix: bool) -> Option<&'static str> {
match code {
0 => Some("正常退出"),
1 => Some("通用失败(含 JVM 启动失败)"),
137 if is_posix => Some("SIGKILL:被强杀"),
143 if is_posix => Some("SIGTERM:被请求退出"),
_ => None,
}
}
fn matched_keywords(lines: &[LogLine], keywords: &[&str]) -> Vec<String> {
keywords
.iter()
.filter(|keyword| lines.iter().any(|line| line.text.contains(**keyword)))
.map(|keyword| (*keyword).to_string())
.collect()
}
/// “像失败的输出”:显式异常/错误特征(不是任何 stderr 输出都算)。
fn looks_like_failure(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
lower.contains("exception")
|| lower.contains("caused by")
|| lower.contains("fatal")
|| lower.contains("error:")
|| lower.contains("failed")
}
+206
View File
@@ -0,0 +1,206 @@
//! 参数后段:清单 JVM 参数尾部(第 20~23 项)与游戏参数(第 24~32 项)。
//!
//! 顺序本身有语义(`analyse/02` §4.1):清单 JVM 参数在用户 `javaArguments` 之后
//! **无条件追加**,因此能覆盖用户参数,但覆盖不了用户的 override 参数;
//! 用户自定义游戏参数永远最后追加,是可以覆盖前面一切游戏参数的“逃生门”。
use crate::launch::args_table::AssemblyWarning;
use crate::launch::argument::{ArgumentEntry, RuleContext};
use crate::launch::command_builder::{tokenize, CommandBuilder};
use crate::launch::defaults;
use crate::launch::game_version::GameVersion;
use crate::launch::platform::PlatformCtx;
use crate::launch::request::{LaunchRequest, QuickPlay};
/// `--graphicsBackend` 的最低版本(实测首发:`26.2-snapshot-2`,2026-04-09)。
pub const GRAPHICS_BACKEND_MIN_VERSION: &str = "26.2-snapshot-2";
/// `--graphicsBackend` 能否下发给这个版本。
///
/// 两条判据**不要合并**(`research/05-mc-vulkan.md` §3.2/§4.2 的实测结论):
///
/// - **可解析版本**走精确门限 `26.2-snapshot-2`。`26.2-snapshot-1` 已经有 `lwjgl-vulkan`
/// 库但 `Main.class` 还没声明这个参数,用能力位放行会让它 `UnrecognizedOptionException`
/// 直接起不来——库早了两天。
/// - **周快照**(`26w14a` 形态)无法与 `26.2-snapshot-N` 排序,只能信版本库表:
/// 带 `lwjgl-vulkan` 就放行(26.1 全线含周快照都没有该库,所以这个推断安全)。
pub fn graphics_backend_allowed(version: &GameVersion, has_vulkan_backend: bool) -> bool {
if version.is_known() {
return version.is_at_least(GRAPHICS_BACKEND_MIN_VERSION);
}
version.is_weekly_snapshot() && has_vulkan_backend
}
/// 应用第 20~32 项,返回告警。
pub(crate) fn apply_game_arguments(
req: &LaunchRequest,
ctx: &PlatformCtx,
builder: &mut CommandBuilder,
) -> Vec<AssemblyWarning> {
let mut warnings = Vec::new();
let rule_ctx = RuleContext::new(
ctx.os.manifest_name(),
&ctx.arch,
&ctx.os_version,
&req.features,
);
// 第 20 项:清单 JVM 参数(现代)或 legacy 默认 JVM 参数。
if req.manifest.jvm.is_empty() {
builder.add_all_default(&defaults::legacy_jvm_arguments());
} else {
let values = collect_applicable(&req.manifest.jvm, &rule_ctx);
builder.add_all(&values);
}
// 第 21 项:登录源提供的 JVM 参数(authlib-injector 等)。
for arg in &req.auth_jvm_args {
builder.add(arg);
}
// 第 22 项:`-javaagent:` × N。
for agent in &req.java_agents {
builder.add(&format!("-javaagent:{agent}"));
}
// 第 23 项:主类(args_table 已保证非空)。
if let Some(main_class) = &req.manifest.main_class {
builder.add(main_class);
}
// 第 24 项:legacy `minecraftArguments` 整串分词。
if let Some(legacy) = &req.manifest.legacy_arguments {
for token in tokenize(legacy) {
builder.add(&token);
}
}
// 第 25 项:现代 `arguments.game`(带 rules/features)。
if !req.manifest.game.is_empty() {
let values = collect_applicable(&req.manifest.game, &rule_ctx);
builder.add_all(&values);
}
// 第 26 项:legacy 默认游戏参数(分辨率)。
if req.manifest.legacy_arguments.is_some() {
builder.add_all(&defaults::legacy_game_args(req.resolution.as_ref()));
}
// 第 27 项:登录源提供的游戏参数。
for arg in &req.auth_game_args {
builder.add(arg);
}
// 第 28 项:QuickPlay / `--server --port`。
apply_quick_play(req, builder, &mut warnings);
// 第 29 项:全屏。
if req.fullscreen {
builder.add("--fullscreen");
}
// 第 30 项:SOCKS 代理的游戏侧参数。
if let Some(proxy) = &req.proxy {
builder.add("--proxyHost");
builder.add(&proxy.host);
builder.add("--proxyPort");
builder.add(&proxy.port.to_string());
if let Some(username) = &proxy.username {
builder.add("--proxyUser");
builder.add(username);
}
if let Some(password) = &proxy.password {
builder.add("--proxyPass");
builder.add(password);
}
}
// 第 31 项:图形后端(精确门限 ≥26.2-snapshot-2,周快照走库表兜底)。
if let Some(backend) = &req.graphics_backend {
if graphics_backend_allowed(&req.instance.version, req.manifest.has_vulkan_backend) {
builder.add("--graphicsBackend");
builder.add(backend);
} else {
warnings.push(AssemblyWarning::GraphicsBackendSkipped {
version: req.instance.version.raw().to_string(),
});
}
}
// 第 32 项:用户自定义游戏参数(最后追加,可覆盖一切)。
builder.add_all(&req.user.game);
warnings
}
fn apply_quick_play(
req: &LaunchRequest,
builder: &mut CommandBuilder,
warnings: &mut Vec<AssemblyWarning>,
) {
let Some(quick_play) = &req.quick_play else {
return;
};
let supports = req.instance.version.is_at_least("1.20");
match quick_play {
QuickPlay::Singleplayer { world } if supports => {
builder.add("--quickPlaySingleplayer");
builder.add(world);
}
QuickPlay::Multiplayer { address } if supports => {
builder.add("--quickPlayMultiplayer");
builder.add(address);
}
QuickPlay::Realms { realm_id } if supports => {
builder.add("--quickPlayRealms");
builder.add(realm_id);
}
QuickPlay::Multiplayer { address } => match parse_server_address(address) {
Some((host, port)) => {
builder.add("--server");
builder.add(&host);
if let Some(port) = port {
builder.add("--port");
builder.add(&port.to_string());
}
warnings.push(AssemblyWarning::QuickPlayDegraded {
address: address.clone(),
});
}
None => warnings.push(AssemblyWarning::QuickPlayAddressInvalid {
address: address.clone(),
}),
},
QuickPlay::Singleplayer { .. } | QuickPlay::Realms { .. } => {
warnings.push(AssemblyWarning::QuickPlayUnsupported {
version: req.instance.version.raw().to_string(),
});
}
}
}
/// 解析 `host[:port]`;端口非法或为空时该段视为不存在。
pub fn parse_server_address(address: &str) -> Option<(String, Option<u16>)> {
let trimmed = address.trim();
if trimmed.is_empty() {
return None;
}
match trimmed.rsplit_once(':') {
Some((host, port)) if !host.is_empty() => match port.parse::<u16>() {
Ok(port) if port > 0 => Some((host.to_string(), Some(port))),
_ => None,
},
Some(_) => None,
None => Some((trimmed.to_string(), None)),
}
}
fn collect_applicable(entries: &[ArgumentEntry], ctx: &RuleContext<'_>) -> Vec<String> {
let mut values = Vec::new();
for entry in entries {
if entry.applies(ctx) {
values.extend(entry.values.iter().cloned());
}
}
values
}
+178
View File
@@ -0,0 +1,178 @@
//! 游戏版本号解析与比较(供命令行组装的分支门限使用)。
//!
//! 依据 `analyse/02` §5.2(版本号驱动的分支:legacy/modern 参数模板、log4j 注入、
//! `--quickPlay*` 门限、`--graphicsBackend` 门限)。
//!
//! 说明:这是 launch 侧的最小比较器。若 M14(game)后续提供权威 `GameVersion` 比较器,
//! 只需替换本文件内部实现,`is_at_least` / `is_before` 的调用点不用动
//! (见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §7-3)。
use std::cmp::Ordering;
use std::fmt;
/// 版本的发布阶段。派生 `Ord` 的变体顺序即排序顺序:Snapshot < PreRelease < ReleaseCandidate < Release。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Stage {
/// 快照(`26.2-snapshot-2`),数字为快照序号。
Snapshot(u32),
/// 预发布(`1.20-pre1`)。
PreRelease(u32),
/// 候选发布(`1.20-rc1`)。
ReleaseCandidate(u32),
/// 正式版。
Release,
}
/// 游戏版本号。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GameVersion {
raw: String,
parts: Vec<u32>,
stage: Stage,
}
impl GameVersion {
/// 解析版本串;无法识别(如 `20w14a`)时返回 [`GameVersion::unknown`]。
pub fn parse(raw: &str) -> Self {
let trimmed = raw.trim();
let (base, stage) = split_stage(trimmed);
let parts = base
.split('.')
.map_while(|piece| piece.parse::<u32>().ok())
.collect::<Vec<_>>();
if parts.is_empty() {
return Self::unknown(trimmed);
}
Self {
raw: trimmed.to_string(),
parts,
stage,
}
}
/// 未知/无法解析的版本(比较结果为“不满足任何门限”,由调用方给默认分支)。
pub fn unknown(raw: &str) -> Self {
Self {
raw: raw.to_string(),
parts: Vec::new(),
stage: Stage::Release,
}
}
/// 原始版本串。
pub fn raw(&self) -> &str {
&self.raw
}
/// 是否解析出了数字版本(`false` 表示未知形状,例如周快照)。
pub fn is_known(&self) -> bool {
!self.parts.is_empty()
}
/// 是否是**周快照**形态(`20w14a` / `26w14a`)。
///
/// 周快照是「年(2 位) + `w` + 周(2 位) + 字母」的独立命名族,与 `26.2-snapshot-N`
/// 分属两套编号,**无法**可靠排序;所以 [`Self::is_known`] 对它恒为 `false`
/// (不猜,免得误触发「老版本」分支去套 legacy 参数模板)。要判能力请改看版本 JSON 的
/// 库表([`crate::game::capability`]),别在这里编一套排序规则。
pub fn is_weekly_snapshot(&self) -> bool {
is_weekly_snapshot_str(&self.raw)
}
/// 主版本号(`1.20.1` → 1,`26.2` → 26)。
pub fn major(&self) -> u32 {
self.parts.first().copied().unwrap_or(0)
}
/// 是否 `>= other`。自身或 `other` 不可解析时返回 `false`。
pub fn is_at_least(&self, other: &str) -> bool {
let other = Self::parse(other);
if !self.is_known() || !other.is_known() {
return false;
}
self.cmp(&other) != Ordering::Less
}
/// 是否 `< other`。自身不可解析时返回 `false`(未知版本不触发“老版本”分支)。
pub fn is_before(&self, other: &str) -> bool {
let other = Self::parse(other);
if !self.is_known() || !other.is_known() {
return false;
}
self.cmp(&other) == Ordering::Less
}
/// 去掉尾部 0 后的分量序列(`1.20.0` 与 `1.20` 等价)。
fn trimmed_parts(&self) -> &[u32] {
let mut end = self.parts.len();
while end > 0 && self.parts[end - 1] == 0 {
end -= 1;
}
&self.parts[..end]
}
}
impl Ord for GameVersion {
fn cmp(&self, other: &Self) -> Ordering {
self.trimmed_parts()
.cmp(other.trimmed_parts())
.then_with(|| self.stage.cmp(&other.stage))
}
}
impl PartialOrd for GameVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl fmt::Display for GameVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.raw)
}
}
impl Default for GameVersion {
fn default() -> Self {
Self::unknown("")
}
}
fn split_stage(raw: &str) -> (&str, Stage) {
for marker in ["-snapshot", " snapshot"] {
if let Some(pos) = raw.find(marker) {
let number = trailing_number(&raw[pos + marker.len()..]);
return (&raw[..pos], Stage::Snapshot(number));
}
}
for marker in ["-pre", "-preview"] {
if let Some(pos) = raw.find(marker) {
let number = trailing_number(&raw[pos + marker.len()..]);
return (&raw[..pos], Stage::PreRelease(number));
}
}
if let Some(pos) = raw.find("-rc") {
let number = trailing_number(&raw[pos + 3..]);
return (&raw[..pos], Stage::ReleaseCandidate(number));
}
(raw, Stage::Release)
}
fn trailing_number(text: &str) -> u32 {
let digits = text.trim_start_matches(['-', '.', '_']);
digits
.split(|ch: char| !ch.is_ascii_digit())
.next()
.and_then(|piece| piece.parse::<u32>().ok())
.unwrap_or(0)
}
/// `20w14a` / `26w14a` 形态判定(纯函数,便于单测)。
fn is_weekly_snapshot_str(raw: &str) -> bool {
let bytes = raw.trim().as_bytes();
bytes.len() == 6
&& bytes[..2].iter().all(u8::is_ascii_digit)
&& bytes[2].eq_ignore_ascii_case(&b'w')
&& bytes[3..5].iter().all(u8::is_ascii_digit)
&& bytes[5].is_ascii_alphabetic()
}
+149
View File
@@ -0,0 +1,149 @@
//! JVM 参数段(`analyse/02` §4.1 的第 8~17 项)。
//!
//! 这一段全部走“默认值抑制”:用户/清单显式写过的键我们一律不覆盖。
//! 返回最终生效的日志解码编码——它要传给日志泵(`analyse/02` §4.4)。
use crate::launch::args_table::AssemblyWarning;
use crate::launch::command_builder::CommandBuilder;
use crate::launch::defaults;
use crate::launch::platform::{OsFamily, PlatformCtx};
use crate::launch::request::LaunchRequest;
/// 应用第 8~17 项,返回(日志解码编码,告警)。
pub(crate) fn apply_jvm_arguments(
req: &LaunchRequest,
ctx: &PlatformCtx,
builder: &mut CommandBuilder,
) -> (String, Vec<AssemblyWarning>) {
let mut warnings = Vec::new();
let java = &req.java;
// 第 8 项:编码(`-Dfile.encoding=` + stdout/stderr 编码)。
let requested = match req.encoding.as_deref() {
Some(value) if !value.eq_ignore_ascii_case("COMPAT") => value.to_string(),
_ => ctx.native_charset.clone(),
};
let existing = builder.add_default("-Dfile.encoding=", &requested);
let mut encoding = existing
.as_deref()
.and_then(|arg| arg.strip_prefix("-Dfile.encoding="))
.unwrap_or(&requested)
.to_string();
if encoding.eq_ignore_ascii_case("COMPAT") {
encoding = ctx.native_charset.clone();
}
builder.add_all_default(&defaults::stream_encoding_args(java.major, &encoding));
// 第 9 项:Log4j2 RCE 缓解三件套。
builder.add_all_default(&defaults::log4j_mitigation_args());
// 第 10 项:formatMsgNoLookups + 日志配置注入。
let lookups = builder.add_default("-Dlog4j2.formatMsgNoLookups=", "true");
let user_disabled_lookups = lookups.as_deref() == Some("-Dlog4j2.formatMsgNoLookups=false");
if req.log4j.enabled && (!user_disabled_lookups || req.log4j.debug) {
match &req.log4j.config_file {
Some(path) => {
let value = path.display().to_string();
let _ = builder.add_default("-Dlog4j.configurationFile=", &value);
}
None => warnings.push(AssemblyWarning::Log4jConfigMissing),
}
}
// 第 11 项:品牌/特性钩子注入的额外 JVM 参数。
for arg in &req.extra_jvm {
builder.add(arg);
}
// 第 12 项:主 jar、macOS dock、`-Duser.home`(仅非 Windows)。
let primary_jar = req.dirs.primary_jar.display().to_string();
let _ = builder.add_default("-Dminecraft.client.jar=", &primary_jar);
if ctx.os == OsFamily::MacOs && !req.launcher.name.is_empty() {
let name = req.launcher.name.clone();
let _ = builder.add_default("-Xdock:name=", &name);
}
if ctx.os != OsFamily::Windows {
if let Some(home) = &ctx.home_dir {
let value = home.display().to_string();
let _ = builder.add_default("-Duser.home=", &value);
}
}
// 第 13 项:代理(用户已写任意代理参数则整体跳过,`analyse/02` §4.1 #13)。
if let Some(proxy) = &req.proxy {
if user_has_proxy_args(req) {
warnings.push(AssemblyWarning::ProxyJvmArgsSkipped);
} else {
apply_proxy(builder, proxy);
}
}
// 第 14 项:自动优化。
if req.optimization.enabled {
let args = defaults::optimization_args(
java.major,
java.bits,
req.optimization.physical_memory_mb,
user_specified_gc(req),
);
builder.add_all_default(&args);
}
// 第 15 项:版本特定开关。
builder.add_all_default(&defaults::version_specific_args(java.major));
// 第 16 项:FML 证书豁免。
if req.mod_loader.is_fml_like() {
builder.add_all_default(&defaults::fml_args());
}
// 第 17 项:渲染器。
builder.add_all_default(&defaults::renderer_args(&req.renderer));
(encoding, warnings)
}
fn apply_proxy(builder: &mut CommandBuilder, proxy: &crate::launch::request::SocksProxy) {
let port = proxy.port.to_string();
for key in ["http.proxyHost", "https.proxyHost", "socksProxyHost"] {
let _ = builder.add_default(&format!("-D{key}="), &proxy.host);
}
for key in ["http.proxyPort", "https.proxyPort", "socksProxyPort"] {
let _ = builder.add_default(&format!("-D{key}="), &port);
}
if let Some(username) = &proxy.username {
for key in ["http.proxyUser", "https.proxyUser"] {
let _ = builder.add_default(&format!("-D{key}="), username);
}
}
if let Some(password) = &proxy.password {
for key in ["http.proxyPassword", "https.proxyPassword"] {
let _ = builder.add_default(&format!("-D{key}="), password);
}
}
}
fn user_has_proxy_args(req: &LaunchRequest) -> bool {
req.user
.override_jvm
.iter()
.chain(req.user.jvm.iter())
.any(|arg| arg.starts_with("-D") && arg.to_ascii_lowercase().contains("proxy"))
}
fn user_specified_gc(req: &LaunchRequest) -> bool {
req.user
.override_jvm
.iter()
.chain(req.user.jvm.iter())
.chain(
req.manifest
.jvm
.iter()
.flat_map(|entry| entry.values.iter()),
)
.any(|arg| {
let lower = arg.to_ascii_lowercase();
lower.starts_with("-xx:+use") || lower.starts_with("-xx:-use")
})
}
+329
View File
@@ -0,0 +1,329 @@
//! 运行中的启动会话与收尾流水线。
//!
//! 一次启动的生命周期(`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1 的第 7~10 阶段):
//! `spawn` → 用户可能 [`LaunchSession::stop`] → [`LaunchSession::finish`]
//! (等进程 → 等日志泵(有预算)→ 退出分析 → 扫崩溃现场 → 诊断挂钩 → 写事件 + 索引)。
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use serde_json::Value;
use crate::launch::args_table::AssembledCommand;
use crate::launch::crash_scan::{self, CrashArtifact};
use crate::launch::diagnosis_hook::{DiagnosisHook, DiagnosisRequest};
use crate::launch::error::Result;
use crate::launch::exit_analysis::{self, ExitAnalysis, ExitSignals};
use crate::launch::launcher::LauncherConfig;
use crate::launch::process::{self, RunningProcess};
use crate::launch::pump::{LineSink, LogLine};
use crate::launch::redact::Redactor;
use crate::launch::request::LaunchRequest;
use crate::launch::session::{EndReason, SessionSink};
use crate::launch::session_index::SessionIndexEntry;
/// 一次运行中的启动会话。
pub struct LaunchSession {
config: LauncherConfig,
hook: Arc<dyn DiagnosisHook>,
redactor: Redactor,
request: LaunchRequest,
session_id: String,
log_path: PathBuf,
command: AssembledCommand,
pid: u32,
process: RunningProcess,
sink: Arc<SessionSink>,
started_wall: SystemTime,
killed: bool,
}
impl std::fmt::Debug for LaunchSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LaunchSession")
.field("session_id", &self.session_id)
.field("pid", &self.pid)
.field("log_path", &self.log_path)
.finish()
}
}
impl LaunchSession {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
config: LauncherConfig,
hook: Arc<dyn DiagnosisHook>,
redactor: Redactor,
request: LaunchRequest,
session_id: String,
log_path: PathBuf,
command: AssembledCommand,
pid: u32,
process: RunningProcess,
sink: Arc<SessionSink>,
) -> Self {
Self {
config,
hook,
redactor,
request,
session_id,
log_path,
command,
pid,
process,
sink,
started_wall: SystemTime::now(),
killed: false,
}
}
/// 进程号。
pub fn pid(&self) -> u32 {
self.pid
}
/// 会话 id。
pub fn session_id(&self) -> &str {
&self.session_id
}
/// 事件流文件路径。
pub fn log_path(&self) -> &Path {
&self.log_path
}
/// 组装结果。
pub fn command(&self) -> &AssembledCommand {
&self.command
}
/// 是否已请求停止。
pub fn is_killed(&self) -> bool {
self.killed
}
/// 日志 sink(可再叠加 UI/测试消费者,见 [`crate::launch::sink`])。
pub fn sink(&self) -> Arc<dyn LineSink> {
self.sink.clone() as Arc<dyn LineSink>
}
/// 已运行毫秒数。
pub fn uptime_ms(&self) -> u64 {
self.process.uptime_ms()
}
/// 用户/看门狗请求停止:三段式回收进程树,并记 `killed` 事件。
pub fn stop(&mut self) -> Result<()> {
if self.killed {
return Ok(());
}
self.killed = true;
if let Some(result) = self.sink.with_writer(|writer| writer.killed("user", None)) {
let _ = result;
}
self.process.stop()
}
/// 收尾:等进程 → 等日志泵 → 退出分析 → 现场扫描 → 诊断 → 事件 + 索引。
pub fn finish(mut self) -> Result<SessionHandle> {
let status = self.process.wait()?;
let code = process::exit_code(&status);
// uptime 必须在等日志泵之前取(泵的等待预算不算进游戏存活时间)。
let uptime_ms = self.process.uptime_ms();
let verdict = self.process.supervise();
let pump_stalled = !verdict.is_complete();
let lines = self.process.lines();
let mut signals = ExitSignals::new(code, uptime_ms, &lines);
signals.killed = self.killed;
signals.is_posix = self.config.platform.os.is_posix();
let exit = exit_analysis::analyze(&signals);
let artifacts = self.collect_artifacts();
// 脱敏只算一次:诊断包与 `SessionHandle::redacted_argv()` 共用。
let redacted_argv = self.redactor.scrub_all(&self.command.argv());
let diagnosis_request = self.build_diagnosis_request(
&exit,
&lines,
&artifacts,
uptime_ms,
redacted_argv.clone(),
);
let should_diagnose = exit.needs_diagnosis || !artifacts.is_empty();
let diagnosis = if should_diagnose {
self.hook.analyze(&diagnosis_request)
} else {
None
};
let mut event_write_errors = Vec::new();
let mut diagnostics = None;
let mut index = None;
let end_reason = if self.killed {
EndReason::Killed
} else {
EndReason::Exit
};
let completed = self.sink.with_writer(|writer| {
if let Err(err) = writer.exit(&exit) {
event_write_errors.push(err.to_string());
}
for artifact in &artifacts {
let result = writer.crash_found(
artifact.source.as_str(),
&artifact.path,
artifact.excerpt.as_deref(),
);
if let Err(err) = result {
event_write_errors.push(err.to_string());
}
}
if let Some(record) = diagnosis {
if let Err(err) = writer.diagnosis(record.clone()) {
event_write_errors.push(err.to_string());
}
diagnostics = Some(record);
}
match writer.finish(end_reason) {
Ok(entry) => index = Some(entry),
Err(err) => event_write_errors.push(err.to_string()),
}
});
if completed.is_none() {
event_write_errors.push("会话写入器不可用(锁中毒)".to_string());
}
Ok(SessionHandle {
session_id: self.session_id,
log_path: self.log_path,
command: self.command,
redacted_argv,
exit,
diagnostics,
index,
artifacts,
uptime_ms,
pump_stalled,
event_write_errors,
})
}
fn collect_artifacts(&self) -> Vec<CrashArtifact> {
if !self.config.scan_crash_reports {
return Vec::new();
}
let run_dir = self.run_dir();
crash_scan::scan(&run_dir, self.started_wall, self.config.max_crash_artifacts)
}
fn build_diagnosis_request(
&self,
exit: &ExitAnalysis,
lines: &[LogLine],
artifacts: &[CrashArtifact],
uptime_ms: u64,
argv: Vec<String>,
) -> DiagnosisRequest {
DiagnosisRequest {
session_id: self.session_id.clone(),
instance_id: self.request.instance.id.clone(),
instance_name: self.request.instance.name.clone(),
game_version: self.request.instance.version.raw().to_string(),
exit: exit.clone(),
uptime_ms,
log_lines: lines
.iter()
.map(|line| LogLine::new(line.stream, self.redactor.scrub(&line.text).into_owned()))
.collect(),
artifacts: artifacts.to_vec(),
argv,
memory_mb: self.command.memory.max_mb,
java_major: self.request.java.major,
java_binary: self.request.java.binary.clone(),
game_dir: self.run_dir(),
}
}
fn run_dir(&self) -> PathBuf {
if self.request.dirs.game_dir.as_os_str().is_empty() {
std::env::current_dir().unwrap_or_default()
} else {
self.request.dirs.game_dir.clone()
}
}
}
/// 一次启动的结果。
#[derive(Debug, Clone)]
pub struct SessionHandle {
/// 会话 id。
pub session_id: String,
/// 事件流文件。
pub log_path: PathBuf,
/// 组装结果(`argv()` 含凭据明文;对外展示/上报请用 [`Self::redacted_argv`])。
pub command: AssembledCommand,
/// 脱敏后的完整 argv(accessToken → `<redacted:token>`,主目录 → `<home>`)。
redacted_argv: Vec<String>,
/// 退出判定。
pub exit: ExitAnalysis,
/// 诊断结论(M15 产出,已写入事件流)。
pub diagnostics: Option<Value>,
/// 索引条目(写入 `index.jsonl` 的那一行)。
pub index: Option<SessionIndexEntry>,
/// 崩溃现场文件。
pub artifacts: Vec<CrashArtifact>,
/// 游戏存活毫秒数。
pub uptime_ms: u64,
/// 日志泵是否超时(watchdog 触发 = 有子进程没关 stdout)。
pub pump_stalled: bool,
/// 会话写入错误(磁盘问题;不影响进程结果)。
pub event_write_errors: Vec<String>,
}
impl SessionHandle {
/// 脱敏后的完整 argv(UI/日志/上报都用它,别直接外发 `command.argv()`)。
pub fn redacted_argv(&self) -> &[String] {
&self.redacted_argv
}
/// 脱敏后的一行人读命令行。
pub fn redacted_display(&self) -> String {
self.redacted_argv
.iter()
.map(|arg| crate::launch::command_builder::quote_for_display(arg))
.collect::<Vec<_>>()
.join(" ")
}
/// 是否“正常且无异常”:退出判定 NORMAL 且没有静默失败标记。
pub fn succeeded(&self) -> bool {
!self.exit.exit_type.is_abnormal() && self.exit.silent_failure.is_none()
}
/// 是否需要进诊断(等价于 `exit.needs_diagnosis`,保留给上层判空)。
pub fn needs_diagnosis(&self) -> bool {
self.exit.needs_diagnosis
}
/// 一句话摘要。
pub fn summary(&self) -> String {
let mut text = format!("会话 {}:{}", self.session_id, self.exit.summary());
if self.pump_stalled {
text.push_str(";日志泵超时");
}
if let Some(top) = &self.diagnostics {
if let Some(id) = top
.get("candidates")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("id"))
.and_then(Value::as_str)
{
text.push_str(&format!(";诊断首因 {id}"));
}
}
text
}
}
+298
View File
@@ -0,0 +1,298 @@
//! 顶层启动管线:组装 → 会话 → 进程 → 退出分析 → 诊断 → 索引。
//!
//! 对应 `DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1 的 10 阶段:
//! 1~2(快照/账户)由调用方与 `adapter` 完成,3(Java 选择)由 D13 给出 `JavaSelection`,
//! 4(组件就绪检查)由 download/game 给出 `missing_components`,6(环境准备的 natives 段)
//! 由 [`crate::launch::natives`] 在本模块收口,5、7~10 见下。
//!
//! 两个入口:
//! - [`Launcher::dry_run`](D18 接线点):只组装、不 spawn、不写会话文件,返回完整命令行;
//! - [`Launcher::launch`]:完整跑一局,返回 [`SessionHandle`](退出判定 + 诊断结论 + 索引条目)。
//!
//! 阶段 B 说明:diagnose(M15)尚未合并,诊断走 [`DiagnosisHook`] 接缝,默认 [`NoopDiagnosis`]。
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use crate::launch::args_table::{assemble, AssembledCommand};
use crate::launch::diagnosis_hook::{DiagnosisHook, NoopDiagnosis};
use crate::launch::error::{AssemblyError, Result};
use crate::launch::launch_session::{LaunchSession, SessionHandle};
use crate::launch::natives::{self, NativesReport};
use crate::launch::platform::PlatformCtx;
use crate::launch::process::{ProcessConfig, RunningProcess};
use crate::launch::redact::Redactor;
use crate::launch::request::LaunchRequest;
use crate::launch::session::{EndReason, SessionMeta, SessionSink, SessionWriter};
/// 启动器配置。
#[derive(Debug, Clone)]
pub struct LauncherConfig {
/// 会话文件根目录(`<data_root>/agent/sessions`)。
pub sessions_root: PathBuf,
/// 平台上下文(命令行分支 + 退出码语义)。
pub platform: PlatformCtx,
/// 进程管理配置(看门狗/宽限期/环形缓冲)。
pub process: ProcessConfig,
/// 启动器名(`${launcher_name}`,请求里非空时以请求为准)。
pub launcher_name: String,
/// 启动器版本。
pub launcher_version: String,
/// 用户主目录(脱敏用)。
pub home_dir: Option<PathBuf>,
/// 结束后是否扫描崩溃现场文件。
pub scan_crash_reports: bool,
/// 现场文件最多收几个。
pub max_crash_artifacts: usize,
}
impl LauncherConfig {
/// 以会话根目录构造(其余取默认)。
pub fn new(sessions_root: impl Into<PathBuf>) -> Self {
let platform = PlatformCtx::current();
Self {
sessions_root: sessions_root.into(),
home_dir: platform.home_dir.clone(),
platform,
process: ProcessConfig::default(),
launcher_name: "UnluckyLauncher".to_string(),
launcher_version: "0.1.0".to_string(),
scan_crash_reports: true,
max_crash_artifacts: 4,
}
}
/// 补启动器名/版本。
pub fn with_launcher(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
self.launcher_name = name.into();
self.launcher_version = version.into();
self
}
}
/// `--dry-run` 的输出(D18):完整命令行,但**不 spawn、不写会话文件**。
///
/// 会做的事与真实启动**一致到「环境准备」为止**:natives 解压照做(幂等,`natives` 字段
/// 回报结果),因为「装完能不能起」的一半答案就在「native 库有没有落地」上。
#[derive(Debug, Clone)]
pub struct DryRun {
/// 实际要执行的程序。
pub program: PathBuf,
/// 参数(已脱敏:accessToken 一定是 `<redacted:token>`)。
pub argv: Vec<String>,
/// 人读单行(可直接 `println!`,含空格的参数带引号)。
pub display: String,
/// 组装告警。
pub warnings: Vec<String>,
/// classpath 条目数。
pub classpath_entries: usize,
/// 日志解码编码。
pub encoding: String,
/// natives 解压结果(没有 natives 目录、即请求未配置时为 `None`)。
pub natives: Option<NativesReport>,
}
impl DryRun {
/// 一行命令行(D18 打印用)。
pub fn line(&self) -> &str {
&self.display
}
}
/// 启动器。
#[derive(Clone)]
pub struct Launcher {
config: LauncherConfig,
hook: Arc<dyn DiagnosisHook>,
}
impl std::fmt::Debug for Launcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Launcher")
.field("config", &self.config)
.field("hook", &"<diagnosis hook>")
.finish()
}
}
impl Launcher {
/// 构造(无诊断挂钩)。
pub fn new(config: LauncherConfig) -> Self {
Self {
config,
hook: Arc::new(NoopDiagnosis),
}
}
/// 接上诊断挂钩(M15 适配器)。
pub fn with_diagnosis_hook(mut self, hook: Arc<dyn DiagnosisHook>) -> Self {
self.hook = hook;
self
}
/// 配置。
pub fn config(&self) -> &LauncherConfig {
&self.config
}
/// 只组装命令行(纯计算,可单测/可透传)。
pub fn plan(&self, request: &LaunchRequest) -> Result<AssembledCommand> {
assemble(request, &self.config.platform)
}
/// 环境准备(管线第 6 段):natives 解压。
///
/// 目标目录取 `request.dirs.natives_dir`(与 `${natives_directory}` 同一真相源);
/// 目录为空视为调用方不需要,返回 `Ok(None)`。
fn prepare_environment(&self, request: &LaunchRequest) -> Result<Option<NativesReport>> {
if request.dirs.natives_dir.as_os_str().is_empty() {
return Ok(None);
}
natives::extract(&request.dirs.natives_dir, &request.natives)
.map(Some)
.map_err(|err| AssemblyError::NativesExtraction {
message: err.message(),
})
.map_err(Into::into)
}
/// D18:组装完整命令行并返回,**不 spawn 进程、不写会话文件**。
///
/// 供 `unlucky-launcher --dry-run <instance>` 打印一行命令行后退出;
/// accessToken 已脱敏(绝不把凭据打到终端/日志里)。
pub fn dry_run(&self, request: &LaunchRequest) -> Result<DryRun> {
let natives = self.prepare_environment(request)?;
let command = self.plan(request)?;
let redactor = self.redactor(request);
let warnings = command
.warnings
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
Ok(DryRun {
program: command.program.clone(),
argv: redactor.scrub_all(&command.argv()),
display: redactor.scrub(&command.display()).into_owned(),
warnings,
classpath_entries: command.classpath.len(),
encoding: command.encoding.clone(),
natives,
})
}
/// 启动一局并返回会话(可中途 [`LaunchSession::stop`])。
pub fn spawn(&self, request: &LaunchRequest) -> Result<LaunchSession> {
let redactor = self.redactor(request);
let meta = self.session_meta(request);
let mut writer = SessionWriter::create(&self.config.sessions_root, meta, redactor.clone())?;
let session_id = writer.session_id().to_string();
let log_path = writer.path().to_path_buf();
writer.session_start()?;
// 阶段 6「环境准备」:natives 必须落到 java.library.path 指向的目录,且在组装前完成
// (HMCL 同序),失败即启动前失败——native 库缺了游戏只会给 UnsatisfiedLinkError。
let natives = match self.prepare_environment(request) {
Ok(report) => report,
Err(err) => {
let _ = writer.assembly_failed(&launch_error_assembly(&err));
let _ = writer.finish(EndReason::AssemblyFailed);
return Err(err);
}
};
if let Some(report) = &natives {
writer.natives_extracted(report)?;
}
let command = match self.plan(request) {
Ok(command) => command,
Err(err) => {
// 启动前失败也要留下证据:assembly_failed + session_end + 索引。
let _ = writer.assembly_failed(&launch_error_assembly(&err));
let _ = writer.finish(EndReason::AssemblyFailed);
return Err(err);
}
};
writer.cmd_built(&command)?;
let sink = Arc::new(SessionSink::with_start(writer, Instant::now()));
let working_dir = if request.dirs.game_dir.as_os_str().is_empty() {
None
} else {
Some(request.dirs.game_dir.as_path())
};
let process =
match RunningProcess::spawn(&command, working_dir, sink.clone(), self.config.process) {
Ok(process) => process,
Err(err) => {
let _ = sink.with_writer(|writer| writer.finish(EndReason::SpawnFailed));
return Err(err);
}
};
let pid = process.pid();
if let Some(result) = sink.with_writer(|writer| writer.proc_start(pid)) {
result?;
}
Ok(LaunchSession::new(
self.config.clone(),
self.hook.clone(),
redactor,
request.clone(),
session_id,
log_path,
command,
pid,
process,
sink,
))
}
/// 启动并阻塞到进程结束(P0 主入口)。
pub fn launch(&self, request: &LaunchRequest) -> Result<SessionHandle> {
self.spawn(request)?.finish()
}
fn redactor(&self, request: &LaunchRequest) -> Redactor {
let mut redactor = Redactor::new().with_secret(request.auth.access_token.clone());
if let Some(home) = &self.config.home_dir {
redactor = redactor.with_home(home);
}
if !request.auth.uuid.is_empty() {
redactor = redactor.with_secret(request.auth.uuid.clone());
}
redactor
}
fn session_meta(&self, request: &LaunchRequest) -> SessionMeta {
let launcher_name = if request.launcher.name.is_empty() {
self.config.launcher_name.clone()
} else {
request.launcher.name.clone()
};
let launcher_version = if request.launcher.version.is_empty() {
self.config.launcher_version.clone()
} else {
request.launcher.version.clone()
};
SessionMeta::new(
request.instance.id.clone(),
request.instance.name.clone(),
request.instance.version.raw().to_string(),
)
.with_launcher(launcher_name, launcher_version)
.with_platform(&self.config.platform)
}
}
/// 把错误拆成 `assembly_failed` 事件需要的结构化原因。
pub(crate) fn launch_error_assembly(
error: &crate::launch::error::LaunchError,
) -> crate::launch::error::AssemblyError {
match error {
crate::launch::error::LaunchError::Assembly(assembly) => assembly.clone(),
other => crate::launch::error::AssemblyError::MalformedArgument {
entry: other.to_string(),
reason: "非组装类失败",
},
}
}
+114
View File
@@ -0,0 +1,114 @@
//! 日志级别猜测与“关键行”判定。
//!
//! 平移自 `analyse/02` §6.4(`Log4jLevel.guessLevel`):先认 log4j 注入的
//! `[HH:mm:ss] [thread/LEVEL]` 格式,再退到 JUL 的 `[INFO]` 形态;`stderr` 一律当
//! ERROR(`[authlib-injector]` 例外)。
//!
//! 目的有两个:给事件流打 `level`,以及让环形缓冲能“关键行永久保留”
//! (`analyse/02` §7.10-3:2000 行截断会把根因行挤掉)。
/// 日志级别(认知顺序:Error 最严重)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Level {
/// 错误。
Error,
/// 警告。
Warn,
/// 信息。
Info,
/// 调试。
Debug,
/// 无法判断。
Unknown,
}
impl Level {
/// 事件流里用的字符串形式。
pub fn as_str(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warn => "warn",
Self::Info => "info",
Self::Debug => "debug",
Self::Unknown => "unknown",
}
}
/// 是否错误级(退出分析筛行用)。
pub fn is_error(self) -> bool {
self == Self::Error
}
}
/// 猜测一行日志的级别。
pub fn guess(is_stderr: bool, line: &str) -> Level {
let trimmed = line.trim_start();
if is_stderr {
// authlib-injector 往 stderr 打的都是正常信息(`analyse/02` §6.4)。
if trimmed.starts_with("[authlib-injector]") {
return Level::Info;
}
return Level::Error;
}
if let Some(level) = log4j_level(trimmed) {
return level;
}
jul_level(trimmed).unwrap_or(Level::Unknown)
}
/// 是否“关键行”:命中这些特征的行不随环形淘汰(`Exception|Error|Caused by|OutOfMemory`)。
pub fn is_key_line(line: &str) -> bool {
let lower = line.to_ascii_lowercase();
lower.contains("exception")
|| lower.contains("error")
|| lower.contains("caused by")
|| lower.contains("outofmemory")
|| lower.contains("fatal")
}
/// 从 `[12:34:56] [Render thread/INFO]: ...` 里取级别。
///
/// 逐段扫描方括号内容,取第一段形如 `<线程名>/<LEVEL>` 的级别,最多看 3 段。
fn log4j_level(line: &str) -> Option<Level> {
let mut rest = line;
for _ in 0..3 {
let open = rest.find('[')?;
let tail = &rest[open + 1..];
let close = tail.find(']')?;
let segment = &tail[..close];
if let Some(slash) = segment.rfind('/') {
if let Some(level) = level_from_name(&segment[slash + 1..]) {
return Some(level);
}
}
rest = &tail[close + 1..];
}
None
}
fn level_from_name(name: &str) -> Option<Level> {
match name.trim() {
"ERROR" | "FATAL" => Some(Level::Error),
"WARN" | "WARNING" => Some(Level::Warn),
"INFO" | "NOTICE" => Some(Level::Info),
"DEBUG" | "TRACE" | "FINE" | "FINER" | "FINEST" => Some(Level::Debug),
_ => None,
}
}
/// JUL/裸标记形态:`[INFO]`、`[SEVERE]`、`[WARNING]`。
fn jul_level(line: &str) -> Option<Level> {
const MARKERS: &[(&str, Level)] = &[
("[SEVERE]", Level::Error),
("[ERROR]", Level::Error),
("[WARNING]", Level::Warn),
("[WARN]", Level::Warn),
("[INFO]", Level::Info),
("[DEBUG]", Level::Debug),
("[FINE]", Level::Debug),
];
MARKERS
.iter()
.find(|(marker, _)| line.starts_with(marker))
.map(|(_, level)| *level)
}
+75
View File
@@ -0,0 +1,75 @@
//! 启动管线:10 阶段命令行组装、Java 选择、子进程管理、结构化 JSONL 事件流。
//!
//! 设计定稿:`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1(管线)与 §3(事件流),
//! Java 侧约束见 `DevDocs/PlanDoc/01d-language-choice.md` §3(JDK 17 基准 + 版本映射提示)。
//!
//! 事件流是 core → app 的唯一启动反馈通道(另一条是快照发布),
//! 不做 200ms 批量刷 UI 式的隐式耦合。
//!
//! # 公共面
//!
//! - [`Launcher`]:`dry_run`(D18,只组装命令行)、`spawn`/`launch`(完整管线);
//! - [`LaunchRequest`] 是命令行组装的**纯数据输入**,由 [`adapter`] 从 game 快照映射而来;
//! - [`AssembledCommand`] 是组装结果(program + argv + classpath + 环境变量 + 告警);
//! - 事件流([`session`])与索引([`session_index`])是 M15 诊断采集器与
//! M16 `read_game_log`/`list_game_sessions` 的数据源,schema 见
//! `DevDocs/SubAgentsTalk/launch-接口约定.md`;
//! - [`DiagnosisHook`] 是接 M15 的唯一接缝(launch 报事实、M15 下结论)。
//!
//! # 与 HMCL 的三处差异(`analyse/02` §8.5)
//!
//! 1. `exit 0` 静默失败单独立标([`exit_analysis`]);
//! 2. 日志泵 join 有预算([`watchdog`]),进程树三段式回收([`tree_kill`],P1 换 Job Object);
//! 3. 窗口出现检测带启发式名称/耗时/证据([`window_detect`])。
//!
//! 零 UI 依赖:本模块只产出结构化事件与纯数据,不 import 任何 UI 类型。
pub mod adapter;
pub mod args_table;
pub mod argument;
pub mod command_builder;
pub mod crash_scan;
pub mod defaults;
pub mod diagnosis_hook;
pub mod error;
pub mod exit_analysis;
pub mod game_args;
pub mod game_version;
pub mod jvm_args;
pub mod launch_session;
pub mod launcher;
pub mod log_level;
pub mod natives;
pub mod placeholders;
pub mod platform;
pub mod process;
pub mod pump;
pub mod redact;
pub mod request;
pub mod session;
pub mod session_index;
pub mod session_meta;
pub mod session_payload;
pub mod session_sink;
pub mod sink;
pub mod time;
pub mod tree_kill;
pub mod watchdog;
pub mod window_detect;
pub use adapter::{
build_request, AdapterOptions, AdapterOutput, AdapterWarning, LibraryInput, SnapshotInput,
};
pub use args_table::{assemble, AssembledCommand, AssemblyWarning};
pub use crash_scan::{CrashArtifact, CrashSource};
pub use diagnosis_hook::{DiagnosisHook, DiagnosisRequest, NoopDiagnosis};
pub use error::{LaunchError, MissingComponent, Result};
pub use exit_analysis::{ExitAnalysis, ExitType, SilentFailure};
pub use launch_session::{LaunchSession, SessionHandle};
pub use launcher::{DryRun, Launcher, LauncherConfig};
pub use natives::{NativeJar, NativesEra, NativesPlan, NativesReport};
pub use process::{ProcessConfig, RunningProcess};
pub use request::LaunchRequest;
pub use session::{EndReason, SessionMeta, SessionSink, SessionWriter};
pub use session_index::SessionIndexEntry;
pub use sink::{CompositeSink, CountingSink, FnSink};
+250
View File
@@ -0,0 +1,250 @@
//! natives jar 的读取细节:zip 遍历、条目筛选、目录清理、戳记读写。
//!
//! 与 [`super`] 的分工:`super` 管「计划与报告」,这里管「怎么把一个 jar 里的原生库落到盘上」。
use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use serde::{Deserialize, Serialize};
use zip::ZipArchive;
use super::{NativeJar, NativesError, NativesReport, STAMP_FILE};
/// 戳记 schema 版本(改字段语义才升)。
const STAMP_VERSION: u8 = 1;
/// 内置排除前缀:Mojang 清单惯例,manifest 没写 `extract` 规则时也这么办。
const DEFAULT_EXCLUDES: &[&str] = &["META-INF/"];
/// 原生库扩展名白名单(Windows / Linux / macOS;`jnilib` 是 JDK6 时代的老 mac 后缀)。
const NATIVE_EXTENSIONS: &[&str] = &["dll", "so", "dylib", "jnilib"];
/// 签名 / 校验附属文件扩展名:即使在 natives jar 根目录也绝不落地。
const SIGNATURE_EXTENSIONS: &[&str] = &["sf", "rsa", "dsa", "ec", "asc", "sha1", "sha256", "git"];
/// 戳记的一条:源 jar 的「路径 + 字节数 + mtime」指纹。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct JarStamp {
/// 源 jar 绝对路径(同一目录换了别的版本时必须能区分开)。
pub path: String,
/// 字节数。
pub len: u64,
/// 修改时间(Unix 毫秒;读不到时 0,等价于「每次都当它变了」)。
pub mtime_ms: u64,
}
/// 目录戳记文件内容。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Stamp {
v: u8,
files: usize,
jars: Vec<JarStamp>,
}
impl Stamp {
/// 戳记是否仍然对应当前的 jar 集(版本 + 指纹逐一相等)。
pub(crate) fn matches(&self, stamps: &[JarStamp]) -> bool {
self.v == STAMP_VERSION && self.jars == stamps
}
/// 上次解压的文件数。
pub(crate) fn files(&self) -> usize {
self.files
}
}
/// 构造 `NativesError::Io`。
pub(crate) fn io_error(
op: &'static str,
path: impl Into<PathBuf>,
source: io::Error,
) -> NativesError {
NativesError::Io {
op,
path: path.into(),
source,
}
}
/// 解压单个 jar:排除前缀 → 原生库白名单 → zip-slip 防护 → 落盘。
pub(crate) fn extract_jar(
native: &NativeJar,
dir: &Path,
report: &mut NativesReport,
) -> Result<(), NativesError> {
let file = File::open(&native.path)
.map_err(|source| io_error("open_native_jar", &native.path, source))?;
let mut archive = ZipArchive::new(file).map_err(|err| NativesError::Zip {
path: native.path.clone(),
reason: err.to_string(),
})?;
let excludes: Vec<&str> = DEFAULT_EXCLUDES
.iter()
.copied()
.chain(native.exclude.iter().map(String::as_str))
.collect();
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(|err| NativesError::Zip {
path: native.path.clone(),
reason: err.to_string(),
})?;
if entry.is_dir() {
continue;
}
// `enclosed_name` 拒绝 `..` / 绝对路径条目(zip-slip)。
let Some(relative) = entry.enclosed_name() else {
report.ignored += 1;
continue;
};
let name = relative.to_string_lossy().replace('\\', "/");
if !should_extract(&name, &excludes) {
report.ignored += 1;
continue;
}
let dest = dir.join(&relative);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.map_err(|source| io_error("create_natives_subdir", parent, source))?;
}
let mut out =
File::create(&dest).map_err(|source| io_error("create_native_file", &dest, source))?;
io::copy(&mut entry, &mut out)
.map_err(|source| io_error("write_native_file", &dest, source))?;
report.files += 1;
}
Ok(())
}
/// 该条目要不要解压:排除前缀命中、签名文件、非原生库扩展名 → 跳过。
pub(crate) fn should_extract(name: &str, excludes: &[&str]) -> bool {
if excludes.iter().any(|prefix| name.starts_with(prefix)) {
return false;
}
let lower = name.to_ascii_lowercase();
let extension = lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("");
if SIGNATURE_EXTENSIONS.contains(&extension) {
return false;
}
NATIVE_EXTENSIONS.contains(&extension)
}
/// 清掉目录里已有的原生库与戳记(尽力而为:删不掉的留着,后面覆盖写会报错)。
pub(crate) fn clear_natives(dir: &Path) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
continue;
}
let name = path
.file_name()
.map(|name| name.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if name == STAMP_FILE {
let _ = fs::remove_file(&path);
continue;
}
let is_native = name
.rsplit_once('.')
.map(|(_, ext)| NATIVE_EXTENSIONS.contains(&ext))
.unwrap_or(false);
if is_native {
let _ = fs::remove_file(&path);
}
}
}
/// 源 jar 指纹(路径 + 字节数 + mtime)。
pub(crate) fn jar_stamps(jars: &[NativeJar]) -> Vec<JarStamp> {
jars.iter()
.map(|native| {
let metadata = fs::metadata(&native.path);
let len = metadata.as_ref().map(|meta| meta.len()).unwrap_or(0);
let mtime_ms = metadata
.ok()
.and_then(|meta| meta.modified().ok())
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|elapsed| elapsed.as_millis() as u64)
.unwrap_or(0);
JarStamp {
path: native.path.display().to_string(),
len,
mtime_ms,
}
})
.collect()
}
/// 读戳记(不存在 / 损坏 / 版本不符都当作「没有戳记」)。
pub(crate) fn read_stamp(dir: &Path) -> Option<Stamp> {
let text = fs::read_to_string(dir.join(STAMP_FILE)).ok()?;
let stamp: Stamp = serde_json::from_str(&text).ok()?;
if stamp.v == STAMP_VERSION {
Some(stamp)
} else {
None
}
}
/// 写戳记(直接覆盖写:半截文件下次解析失败即视为「没有戳记」,重解一遍就行)。
pub(crate) fn write_stamp(dir: &Path, files: usize, jars: &[JarStamp]) -> Result<(), NativesError> {
let path = dir.join(STAMP_FILE);
let stamp = Stamp {
v: STAMP_VERSION,
files,
jars: jars.to_vec(),
};
let text = serde_json::to_string_pretty(&stamp).map_err(|err| NativesError::Zip {
path: path.clone(),
reason: err.to_string(),
})?;
fs::write(&path, text).map_err(|source| io_error("write_natives_stamp", &path, source))
}
#[cfg(test)]
mod tests {
use super::{should_extract, DEFAULT_EXCLUDES};
#[test]
fn native_library_extensions_pass() {
for name in [
"lwjgl.dll",
"OpenAL.dll",
"liblwjgl.so",
"liblwjgl.dylib",
"liblwjgl.jnilib",
] {
assert!(should_extract(name, DEFAULT_EXCLUDES), "{name} 应解压");
}
}
#[test]
fn metadata_and_signature_entries_are_skipped() {
for name in [
"META-INF/MANIFEST.MF",
"META-INF/FOO.SF",
"META-INF/FOO.RSA",
"lwjgl.dll.sha1",
"lwjgl.dll.sha256",
"README.txt",
"LICENSE",
// 无扩展名的文件一律不进 java.library.path。
"lwjgl",
] {
assert!(!should_extract(name, DEFAULT_EXCLUDES), "{name} 不该解压");
}
}
#[test]
fn manifest_exclude_prefixes_apply_on_top_of_builtin() {
assert!(!should_extract("extra/x.dll", &["extra/"]));
assert!(should_extract("x.dll", &["extra/"]));
// 大小写不敏感只对扩展名生效(排除前缀按清单原样比较,与 HMCL 一致)。
assert!(should_extract("X.DLL", DEFAULT_EXCLUDES));
}
}
+355
View File
@@ -0,0 +1,355 @@
//! natives 解压:把版本链里的 natives 分类器构件解到 `versions/<id>/natives/`。
//!
//! 这是 10 阶段管线里**第 6 段「环境准备」**的落盘部分(`PlanDoc/06` §1),发生顺序在
//! 命令行组装之前(HMCL 也在组装期做:`DefaultLauncher.decompressNatives`,
//! `analyse/03` §6.4-(7)),因此 `--dry-run` 也能验证「装完到底能不能起」。
//!
//! # 三个时代(`SubAgentsTalk/launch-natives-解压与时代分界.md`,piston-meta 实测)
//!
//! | 版本区间 | natives 形态 | 谁负责 `-Djava.library.path` |
//! |---|---|---|
//! | ≤1.12.2(LWJGL 2.9) | `natives` 表 + `downloads.classifiers` | 启动器补(`defaults::legacy_jvm_arguments`) |
//! | 1.13–1.18.2(LWJGL 3.1/3.2) | 同上(另有同坐标的主构件条目) | 清单自己写 `arguments.jvm` |
//! | 1.19+(LWJGL 3.3) | **没有 `natives` 表**:natives jar 是普通 classpath 库 | 清单写 4 个目录属性,LWJGL 运行期自解压 |
//!
//! 所以三个时代走**同一条代码路径**:有 jar 就解压,没 jar 就只把目录建出来
//! (1.19+ 的 `-Dorg.lwjgl.system.SharedLibraryExtractPath` 指向它;目录不存在时
//! LWJGL 会静默走到「找不到 native」)。
//!
//! # 幂等
//!
//! 解压完在目录里写一份戳记 [`jar::STAMP_FILE`](每个源 jar 的路径 + 字节数 + mtime)。
//! 下次启动:戳记与当前 jar 集完全一致 → 目录视为就绪,**不读 jar、不写盘**直接跳过;
//! 不一致(换版本 / 库升级 / 用户删了目录)→ 清掉旧原生库再全量解压,避免两代 dll
//! 混在同一个 `java.library.path` 里(HMCL 也是每次清目录,这里只把「没变就不动」补上)。
pub(crate) mod jar;
use std::path::{Path, PathBuf};
use crate::game::arguments::Environment;
use crate::game::layout::GameLayout;
use crate::game::library::Library;
use crate::launch::natives::jar::{
clear_natives, extract_jar, jar_stamps, read_stamp, write_stamp,
};
/// 戳记文件名:放在 natives 目录里,跟着目录一起删 / 建。
///
/// 公开出来是为了「一键修复」类功能能认出自己的痕迹(判断目录里哪些文件是我们解出来的)。
pub const STAMP_FILE: &str = ".ull-natives.json";
/// 一个待解压的 natives 构件(版本链里 `natives` 分类器命中的 jar)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NativeJar {
/// 磁盘路径(`libraries/<group>/.../<artifact>-<ver>-natives-<os>.jar`)。
pub path: PathBuf,
/// 坐标 + 分类器(`org.lwjgl:lwjgl:3.2.2:natives-windows`,报错与事件里用)。
pub name: String,
/// 清单 `extract.exclude` 的排除前缀(`["META-INF/"]`)。
pub exclude: Vec<String>,
}
impl NativeJar {
/// 构造(无排除规则)。
pub fn new(path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
Self {
path: path.into(),
name: name.into(),
exclude: Vec::new(),
}
}
/// 带清单排除规则构造。
pub fn with_exclude(
path: impl Into<PathBuf>,
name: impl Into<String>,
exclude: Vec<String>,
) -> Self {
Self {
path: path.into(),
name: name.into(),
exclude,
}
}
}
/// natives 处理的时代(决定「要不要解压」以及「`-Djava.library.path` 从哪来」)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NativesEra {
/// ≤1.12.2:清单没有 `arguments.jvm`,启动器自己补 `-Djava.library.path`。
#[default]
Legacy,
/// 1.13–1.18.2:清单有 `arguments.jvm`,natives 走分类器 jar(需要解压)。
Lwjgl3,
/// 1.19+:natives jar 是普通 classpath 库,LWJGL 3.3 运行期自解压(只需目录存在)。
ClasspathNative,
}
impl NativesEra {
/// 按「清单有没有 `arguments.jvm`」+「有没有 natives 分类器 jar」判时代。
pub fn detect(manifest_jvm_args: usize, natives_jars: usize) -> Self {
if manifest_jvm_args == 0 {
Self::Legacy
} else if natives_jars == 0 {
Self::ClasspathNative
} else {
Self::Lwjgl3
}
}
/// 事件/日志里的稳定字符串。
pub fn as_str(self) -> &'static str {
match self {
Self::Legacy => "legacy",
Self::Lwjgl3 => "lwjgl3",
Self::ClasspathNative => "classpath_native",
}
}
}
/// 解压计划(`LaunchRequest` 的一部分,纯数据)。
///
/// 目标目录**不在**这里:唯一真相源是 `LaunchDirs::natives_dir`(`${natives_directory}` 也用它),
/// 免得出现「参数指向 A、dll 解到 B」的静默错位。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NativesPlan {
/// 待解压的 natives 分类器构件。
pub jars: Vec<NativeJar>,
/// 时代(只影响报告字段;解压路径三时代统一)。
pub era: NativesEra,
/// 目录是否用户自定义(事件 payload 的 `custom` 字段,给「自备 dll 被覆盖」留证据)。
pub custom: bool,
}
impl Default for NativesPlan {
fn default() -> Self {
Self {
jars: Vec::new(),
era: NativesEra::ClasspathNative,
custom: false,
}
}
}
impl NativesPlan {
/// 以 jar 清单构造:有 jar 按 [`NativesEra::Lwjgl3`],没有按 [`NativesEra::ClasspathNative`]。
///
/// 这一层分不出「≤1.12.2 的 LWJGL 2」与「1.13–1.18.2 的 LWJGL 3」(两者都有分类器 jar),
/// 所以适配层用 [`NativesPlan::with_era`] 明确给值;直接构造只适合测试与脚本。
pub fn new(jars: Vec<NativeJar>) -> Self {
let era = NativesEra::detect(usize::from(!jars.is_empty()), jars.len());
Self {
jars,
era,
custom: false,
}
}
/// 指定时代。
pub fn with_era(mut self, era: NativesEra) -> Self {
self.era = era;
self
}
/// 标记目录为用户自定义。
pub fn with_custom(mut self, custom: bool) -> Self {
self.custom = custom;
self
}
/// 没有任何 natives 构件(1.19+ 的常态)。
pub fn is_empty(&self) -> bool {
self.jars.is_empty()
}
}
/// 解压结果(进 `natives_extracted` 事件与 `--dry-run` 输出)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NativesReport {
/// 目标目录。
pub dir: PathBuf,
/// 时代。
pub era: NativesEra,
/// 参与的 jar 数。
pub jars: usize,
/// 本次写盘的原生库文件数。
pub files: usize,
/// 按戳记跳过、未重新解压的文件数(= 上次解压的文件数)。
pub skipped: usize,
/// 被排除 / 非原生库而忽略的条目数(`META-INF/`、签名文件)。
pub ignored: usize,
/// 目录是否用户自定义。
pub custom: bool,
/// 是否按戳记判定「目录已就绪」而整体跳过。
pub up_to_date: bool,
}
impl NativesReport {
/// 人读一行(CLI / 日志用)。
pub fn summary(&self) -> String {
if self.jars == 0 {
return format!(
"natives 目录已就绪(该版本无需解压,LWJGL 运行期自取)→ {}",
self.dir.display()
);
}
if self.up_to_date {
return format!(
"natives 已就绪({} 个原生库,{} 个 jar 未变)",
self.skipped, self.jars
);
}
format!(
"natives 解压 {} 个原生库({} 个 jar,忽略 {} 个条目)→ {}",
self.files,
self.jars,
self.ignored,
self.dir.display()
)
}
}
/// 解压失败原因。
#[derive(Debug)]
pub enum NativesError {
/// 目标目录为空(调用方漏填 `natives_dir`)。
NoDir,
/// 文件系统操作失败。
Io {
/// 操作名。
op: &'static str,
/// 相关路径。
path: PathBuf,
/// 底层错误。
source: std::io::Error,
},
/// natives jar 打不开 / 不是合法 zip(下载损坏的最常见形态)。
Zip {
/// jar 路径。
path: PathBuf,
/// 原因。
reason: String,
},
}
impl NativesError {
/// 一行人读描述。
pub fn message(&self) -> String {
match self {
Self::NoDir => "natives 目录未配置(natives_dir 为空)".to_string(),
Self::Io { op, path, source } => {
format!("{op} 失败({}):{source}", path.display())
}
Self::Zip { path, reason } => {
format!(
"natives jar 无法作为 zip 打开({}):{reason}",
path.display()
)
}
}
}
}
impl std::fmt::Display for NativesError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message())
}
}
impl std::error::Error for NativesError {}
/// 从版本链库表收集 natives 分类器构件(adapter / app / CLI 共用这一段,别各写一份)。
///
/// 口径与安装层 `game::install::libraries::plan` **除 `hint=local` 落点外**逐条对齐:
/// 1. 先按环境过滤(`Library::applies`);
/// 2. `natives` 表命中当前 OS 才取分类器(1.19+ 没有 `natives` 表 → 返回空表);
/// 3. 落点优先用清单 `downloads.classifiers.<classifier>.path`,缺了才按 Maven 坐标推导
/// (`Library::native_relative_path`);`hint=local` 的库在实例私有目录里
/// (`analyse/03` §6.8 的 HMCL 口径)。
///
/// ⚠️ 安装层当前的 natives 分类器分支硬编码共享 `libraries/`、不认 `hint=local`(主构件认),
/// 所以「`hint=local` + `natives` 同体」的 manifest(只有 HMCL 补丁导入才会有)会出现
/// 「装到共享目录、解压去实例目录」→ 由 [`crate::launch::adapter::AdapterWarning::MissingNativeJar`]
/// 转成启动前 `missing_components` 显式失败。此处仍按 HMCL 权威口径实现,等 install 侧对齐。
pub fn collect(
layout: &GameLayout,
version_id: &str,
libraries: &[Library],
environment: &Environment,
) -> Vec<NativeJar> {
let mut natives = Vec::new();
for library in libraries {
if !library.applies(environment) {
continue;
}
let Some(classifier) = library.native_classifier(environment) else {
continue;
};
let Ok(relative) = library.native_relative_path(&classifier) else {
continue;
};
// 安装层当前对 natives 不做这条分支(见上方警告);这里按 HMCL 口径落实例私有目录。
let base = if library.is_local() {
layout.version_libraries_dir(version_id)
} else {
layout.libraries_dir()
};
let exclude = library
.extract
.as_ref()
.map(|extract| extract.exclude.clone())
.unwrap_or_default();
natives.push(NativeJar::with_exclude(
base.join(relative),
format!("{}:{classifier}", library.name),
exclude,
));
}
natives
}
/// 解压一个计划(幂等:目录与源 jar 都没变时零写盘)。
///
/// # Errors
///
/// 目录为空、建目录 / 写文件失败、jar 不是合法 zip 时返回 [`NativesError`]。
pub fn extract(dir: &Path, plan: &NativesPlan) -> Result<NativesReport, NativesError> {
if dir.as_os_str().is_empty() {
return Err(NativesError::NoDir);
}
let mut report = NativesReport {
dir: dir.to_path_buf(),
era: plan.era,
jars: plan.jars.len(),
files: 0,
skipped: 0,
ignored: 0,
custom: plan.custom,
up_to_date: false,
};
// 1.19+ 没有 jar 也要建目录:LWJGL 3.3 往 SharedLibraryExtractPath 里解压。
std::fs::create_dir_all(dir)
.map_err(|source| jar::io_error("create_natives_dir", dir, source))?;
if plan.jars.is_empty() {
report.up_to_date = true;
return Ok(report);
}
let stamps = jar_stamps(&plan.jars);
if let Some(stamp) = read_stamp(dir) {
if stamp.matches(&stamps) {
report.up_to_date = true;
report.skipped = stamp.files();
return Ok(report);
}
}
// 源变了:先清旧原生库(避免两代 dll 混在同一个 java.library.path 里),再全量解。
clear_natives(dir);
for native in &plan.jars {
extract_jar(native, dir, &mut report)?;
}
write_stamp(dir, report.files, &stamps)?;
Ok(report)
}
+173
View File
@@ -0,0 +1,173 @@
//! 参数占位符字典与替换。
//!
//! 占位符清单平移自 `analyse/02` §4.8(`DefaultLauncher.getConfigurations()`):
//! 版本清单里的 `arguments.*` 用 `${...}` 引用运行时值,启动器负责在组装时替换。
//! 替换语义与 HMCL 一致:**单遍、不递归**;未定义的占位符原样保留(便于发现清单漏项)。
use std::collections::BTreeMap;
use std::path::Path;
use crate::launch::platform::PlatformCtx;
use crate::launch::request::LaunchRequest;
/// 我们支持的占位符键(不含 `${}` 包裹)。
pub const PLACEHOLDER_KEYS: &[&str] = &[
"auth_player_name",
"auth_session",
"auth_access_token",
"auth_uuid",
"user_type",
"user_properties",
"version_name",
"version_type",
"profile_name",
"game_directory",
"assets_index_name",
"game_assets",
"assets_root",
"natives_directory",
"classpath",
"classpath_separator",
"launcher_name",
"launcher_version",
"library_directory",
"libraries_directory",
"primary_jar",
"primary_jar_name",
"file_separator",
"resolution_width",
"resolution_height",
"language",
];
/// 占位符表。
#[derive(Debug, Clone, Default)]
pub struct PlaceholderMap {
entries: BTreeMap<String, String>,
}
impl PlaceholderMap {
/// 空表。
pub fn new() -> Self {
Self::default()
}
/// 插入一个占位符(键不含 `${}`)。
pub fn insert(&mut self, key: &str, value: impl Into<String>) {
self.entries.insert(key.to_string(), value.into());
}
/// 查询。
pub fn get(&self, key: &str) -> Option<&str> {
self.entries.get(key).map(String::as_str)
}
/// 已登记的键数。
pub fn len(&self) -> usize {
self.entries.len()
}
/// 是否为空。
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// 单遍替换 `${key}`;未定义键原样保留。
pub fn render(&self, template: &str) -> String {
if !template.contains("${") {
return template.to_string();
}
let mut out = String::with_capacity(template.len() + 16);
let mut rest = template;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
match after.find('}') {
Some(end) => {
let key = &after[..end];
match self.entries.get(key) {
Some(value) => out.push_str(value),
None => {
out.push_str("${");
out.push_str(key);
out.push('}');
}
}
rest = &after[end + 1..];
}
None => {
out.push_str(&rest[start..]);
rest = "";
}
}
}
out.push_str(rest);
out
}
/// 由启动请求构造完整占位符表。
pub fn from_request(req: &LaunchRequest, ctx: &PlatformCtx) -> Self {
let mut map = Self::new();
let auth = &req.auth;
map.insert("auth_player_name", auth.player_name.clone());
map.insert("auth_session", auth.access_token.clone());
map.insert("auth_access_token", auth.access_token.clone());
map.insert("auth_uuid", auth.uuid.clone());
map.insert("user_type", auth.user_type.clone());
map.insert("user_properties", auth.user_properties.clone());
map.insert("version_name", req.instance.name.clone());
map.insert("version_type", req.instance.version_type.clone());
map.insert("profile_name", req.instance.id.clone());
let dirs = &req.dirs;
map.insert("game_directory", path_str(&dirs.game_dir));
map.insert("assets_index_name", dirs.assets_index.clone());
map.insert("game_assets", path_str(&dirs.game_assets));
map.insert("assets_root", path_str(&dirs.assets_root));
map.insert("natives_directory", path_str(&dirs.natives_dir));
map.insert(
"classpath",
join_classpath(&req.classpath, ctx.path_separator()),
);
map.insert("classpath_separator", ctx.path_separator().to_string());
map.insert("launcher_name", req.launcher.name.clone());
map.insert("launcher_version", req.launcher.version.clone());
map.insert("library_directory", path_str(&dirs.library_dir));
map.insert("libraries_directory", path_str(&dirs.library_dir));
map.insert("primary_jar", path_str(&dirs.primary_jar));
map.insert(
"primary_jar_name",
dirs.primary_jar
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default(),
);
map.insert("file_separator", ctx.file_separator().to_string());
match &req.resolution {
Some(res) => {
map.insert("resolution_width", res.width.to_string());
map.insert("resolution_height", res.height.to_string());
}
None => {
map.insert("resolution_width", String::new());
map.insert("resolution_height", String::new());
}
}
map.insert("language", req.language.clone());
map
}
}
/// 按平台分隔符拼接 classpath。
pub fn join_classpath(paths: &[std::path::PathBuf], separator: char) -> String {
paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(&separator.to_string())
}
fn path_str(path: &Path) -> String {
path.display().to_string()
}
+95
View File
@@ -0,0 +1,95 @@
//! 平台上下文:命令行组装与进程管理需要知道的操作系统事实。
//!
//! 依据 `analyse/02` §4.1(Linux/macOS `nice`)、§4.4(原生编码)、§4.6(原生库路径)、
//! §4.10(环境变量,`APPDATA` 重定向只在 Windows 有意义)。
//!
//! `os_version` 不再是空占位:`current()` 从 [`crate::platform::os_info`] 的运行期探测取值,
//! 清单里带 `rules.os.version` 的参数项因此能真正命中(D19 · `PlanDoc/11-os-detection.md` §2)。
use std::path::PathBuf;
pub use crate::platform::os_info::OsFamily;
/// 平台上下文。
#[derive(Debug, Clone)]
pub struct PlatformCtx {
/// 操作系统家族。
pub os: OsFamily,
/// `rules.os.arch` 用的架构名。
pub arch: String,
/// 系统原生编码(`-Dfile.encoding` 的默认值来源)。
pub native_charset: String,
/// 主目录(脱敏与 `-Duser.home` 用)。
pub home_dir: Option<PathBuf>,
/// 系统版本串(`rules.os.version` 用,例如 `10.0.19045`)。
pub os_version: String,
}
impl PlatformCtx {
/// 按当前编译目标推断,版本串取运行期真实探测值。
pub fn current() -> Self {
let info = crate::platform::os_info::current();
Self {
os: info.family,
arch: info.arch.clone(),
native_charset: native_charset().to_string(),
home_dir: home_dir(),
os_version: info.os_version().to_string(),
}
}
/// 测试用:显式指定系统与架构,版本串留空(规则求值走保守分支)。
pub fn new(os: OsFamily, arch: &str) -> Self {
Self {
os,
arch: arch.to_string(),
native_charset: native_charset().to_string(),
home_dir: None,
os_version: String::new(),
}
}
/// 显式指定版本串(测试与适配层注入用)。
pub fn with_os_version(mut self, version: impl Into<String>) -> Self {
self.os_version = version.into();
self
}
/// classpath 分隔符(Windows `;`,POSIX `:`)。
pub fn path_separator(&self) -> char {
if self.os == OsFamily::Windows {
';'
} else {
':'
}
}
/// 文件名分隔符的字符串形式(供 `${file_separator}` 占位符)。
pub fn file_separator(&self) -> &'static str {
if self.os == OsFamily::Windows {
"\\"
} else {
"/"
}
}
}
impl Default for PlatformCtx {
fn default() -> Self {
Self::current()
}
}
/// 系统原生编码名。
///
/// 中文 Windows 上系统编码是 GBK,但 Java 18+ 默认 UTF-8;这里统一给 `UTF-8`,
/// 需要跟随系统时由用户在 `-Dfile.encoding=COMPAT` 语义下覆盖(`analyse/02` §4.4)。
fn native_charset() -> &'static str {
"UTF-8"
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
}
+222
View File
@@ -0,0 +1,222 @@
//! 进程管理:spawn + stdout/stderr 泵 + 进程树回收。
//!
//! 对应 `analyse/02` §6 的四条线程模型(`stdout-pump` / `stderr-pump` / `exit-waiter`),
//! 但线程生命周期的收尾由 [`crate::launch::watchdog`] 兜底,不无条件 `join`。
//!
//! 本模块只管“把命令行跑起来并收集输出”,不做组件检查、不做诊断——
//! 那些是 `launch` 上层流水线(阶段B)与 M15 的职责。
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::launch::args_table::AssembledCommand;
use crate::launch::error::{LaunchError, Result};
use crate::launch::pump::{LineSink, LogLine, PumpGroup, RingBuffer, RingBufferConfig};
use crate::launch::tree_kill;
use crate::launch::watchdog::{self, PumpVerdict, WatchdogConfig};
/// 强制结束后等进程消失的预算。
const FORCE_KILL_BUDGET: Duration = Duration::from_secs(5);
/// 轮询进程状态的间隔。
const POLL_INTERVAL: Duration = Duration::from_millis(20);
/// 进程管理配置。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProcessConfig {
/// 日志泵等待预算。
pub watchdog: WatchdogConfig,
/// 停止时温和关闭的宽限期。
pub graceful_stop: Duration,
/// 环形缓冲配置。
pub buffer: RingBufferConfig,
}
impl Default for ProcessConfig {
fn default() -> Self {
Self {
watchdog: WatchdogConfig::default(),
graceful_stop: Duration::from_secs(3),
buffer: RingBufferConfig::default(),
}
}
}
/// 已启动的游戏进程。
#[derive(Debug)]
pub struct RunningProcess {
child: Child,
pump: PumpGroup,
buffer: Arc<Mutex<RingBuffer>>,
started_at: Instant,
pid: u32,
config: ProcessConfig,
}
impl RunningProcess {
/// 启动进程并接管两条输出流。
pub fn spawn(
command: &AssembledCommand,
working_dir: Option<&Path>,
sink: Arc<dyn LineSink>,
config: ProcessConfig,
) -> Result<Self> {
let mut process = Command::new(&command.program);
process.args(&command.args);
for (key, value) in &command.env {
process.env(key, value);
}
if let Some(dir) = working_dir {
process.current_dir(dir);
}
process
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
/// 独立进程组:便于 `taskkill /T` 递归回收。
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
process.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
let mut child = process.spawn().map_err(|source| LaunchError::Spawn {
program: command.program.clone(),
source,
})?;
let pid = child.id();
let buffer = Arc::new(Mutex::new(RingBuffer::new(config.buffer)));
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let pump = PumpGroup::spawn(stdout, stderr, buffer.clone(), sink);
Ok(Self {
child,
pump,
buffer,
started_at: Instant::now(),
pid,
config,
})
}
/// 进程号。
pub fn pid(&self) -> u32 {
self.pid
}
/// 已运行毫秒数。
pub fn uptime_ms(&self) -> u64 {
u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX)
}
/// 启动时刻(窗口检测计时用)。
pub fn started_at(&self) -> Instant {
self.started_at
}
/// 共享的日志环形缓冲。
pub fn buffer(&self) -> Arc<Mutex<RingBuffer>> {
self.buffer.clone()
}
/// 当前可读日志快照。
pub fn lines(&self) -> Vec<LogLine> {
match self.buffer.lock() {
Ok(guard) => guard.lines().cloned().collect(),
Err(_) => Vec::new(),
}
}
/// 等待进程退出(不等待日志泵)。
pub fn wait(&mut self) -> Result<ExitStatus> {
self.child.wait().map_err(|source| LaunchError::Io {
op: "wait_child",
path: PathBuf::from(self.pid.to_string()),
source,
})
}
/// 进程退出后,按预算收尾日志泵。
pub fn supervise(&mut self) -> PumpVerdict {
watchdog::wait_for_pumps(&mut self.pump, &self.config.watchdog)
}
/// 进程是否仍在运行。
pub fn is_running(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
}
/// 三段式停止整棵进程树(温和 → 宽限 → 强制)。
///
/// 判定标准是**进程是否真的没了**,不是 `taskkill`/`kill` 的退出码:
/// 外部命令对“已经退出的进程”会返回非 0(Windows 上 `taskkill` 报 128),
/// 把它当失败会误报——尤其是温和阶段已经把进程关掉的情况。
pub fn stop(&mut self) -> Result<()> {
if !self.is_running() {
return Ok(());
}
let _ = tree_kill::terminate(self.pid);
if self.wait_until_exit(self.config.graceful_stop) {
return Ok(());
}
let _ = tree_kill::force_kill(self.pid);
if self.wait_until_exit(FORCE_KILL_BUDGET) {
return Ok(());
}
Err(LaunchError::Kill {
pid: self.pid,
source: std::io::Error::other("强制结束后进程仍在运行"),
})
}
/// 直接强杀(看门狗/崩溃兜底路径)。
pub fn force_kill(&mut self) -> Result<()> {
if !self.is_running() {
return Ok(());
}
let _ = tree_kill::force_kill(self.pid);
if self.wait_until_exit(FORCE_KILL_BUDGET) {
Ok(())
} else {
Err(LaunchError::Kill {
pid: self.pid,
source: std::io::Error::other("强制结束后进程仍在运行"),
})
}
}
fn wait_until_exit(&mut self, budget: Duration) -> bool {
let deadline = Instant::now() + budget;
loop {
match self.child.try_wait() {
Ok(Some(_)) => return true,
Ok(None) => {}
Err(_) => return false,
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(POLL_INTERVAL);
}
}
}
/// 从退出状态取整数退出码。
///
/// POSIX 上被子进程信号杀死没有 `code()`,按 shell 惯例补 `128 + signal`
/// (`SIGKILL` → 137、`SIGTERM` → 143,与 `analyse/02` 附录 C 一致)。
pub fn exit_code(status: &ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
return 128 + signal;
}
}
-1
}
+379
View File
@@ -0,0 +1,379 @@
//! stdout/stderr 泵与日志环形缓冲。
//!
//! 平移自 `analyse/02` §6.1/§6.2(`StreamPump` + `ManagedProcess.lines`),并补两处改进:
//! 缓冲按“行数 + 字节数”双上限(§8.4-9),且命中 `Exception|Error|Caused by|OutOfMemory`
//! 的关键行**不随环形淘汰**——2000 行截断会把根因行挤掉(§7.10-3)。
//!
//! 解码:我们强制给子进程下发 `-Dstdout.encoding=UTF-8`(`-Dsun.stdout.encoding`
//! for Java <19),因此泵按 UTF-8 宽松解码;用户把编码改成 GBK 时会出现替换字符,
//! 阶段B 再视需要引入 `encoding_rs`(记录在 `launch-接口约定.md` §7)。
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Read};
use std::process::{ChildStderr, ChildStdout};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use crate::launch::log_level;
/// 默认环形缓冲行数(与 HMCL 一致)。
pub const DEFAULT_MAX_LINES: usize = 2000;
/// 默认环形缓冲字节上限。
pub const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024;
/// 单行最大字符数(超出截断并标注,避免单行撑爆事件流)。
pub const MAX_LINE_CHARS: usize = 8192;
/// 输出流。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stream {
/// 标准输出。
Stdout,
/// 标准错误。
Stderr,
}
impl Stream {
/// 事件流里的字符串形式(`"stdout"` / `"stderr"`)。
pub fn as_str(self) -> &'static str {
match self {
Self::Stdout => "stdout",
Self::Stderr => "stderr",
}
}
/// 是否标准错误。
pub fn is_stderr(self) -> bool {
self == Self::Stderr
}
}
/// 一行日志。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogLine {
/// 来源流。
pub stream: Stream,
/// 文本(已去行尾、超长已截断)。
pub text: String,
}
impl LogLine {
/// 构造。
pub fn new(stream: Stream, text: impl Into<String>) -> Self {
Self {
stream,
text: text.into(),
}
}
/// 猜测的级别。
pub fn level(&self) -> log_level::Level {
log_level::guess(self.stream.is_stderr(), &self.text)
}
/// 是否错误级。
pub fn is_error(&self) -> bool {
self.level().is_error()
}
}
/// 日志观察者(会话事件流、M15 采集器适配层都实现它)。
pub trait LineSink: Send + Sync {
/// 收到一行日志。
fn on_line(&self, line: &LogLine);
}
/// 丢弃所有行的 sink(测试/无监听场景)。
#[derive(Debug, Default, Clone, Copy)]
pub struct NullSink;
impl LineSink for NullSink {
fn on_line(&self, _line: &LogLine) {}
}
/// 环形缓冲配置。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RingBufferConfig {
/// 行数上限。
pub max_lines: usize,
/// 字节上限。
pub max_bytes: usize,
}
impl Default for RingBufferConfig {
fn default() -> Self {
Self {
max_lines: DEFAULT_MAX_LINES,
max_bytes: DEFAULT_MAX_BYTES,
}
}
}
/// 环形日志缓冲:近期行 + 永久保留的关键行。
#[derive(Debug)]
pub struct RingBuffer {
config: RingBufferConfig,
recent: VecDeque<LogLine>,
retained: Vec<LogLine>,
bytes: usize,
pushed: u64,
}
impl RingBuffer {
/// 构造。
pub fn new(config: RingBufferConfig) -> Self {
Self {
config,
recent: VecDeque::new(),
retained: Vec::new(),
bytes: 0,
pushed: 0,
}
}
/// 压入一行,必要时淘汰最早的行(关键行转入 `retained`)。
pub fn push(&mut self, line: LogLine) {
self.pushed += 1;
let size = line.text.len();
self.bytes += size;
self.recent.push_back(line);
while self.recent.len() > self.config.max_lines || self.bytes > self.config.max_bytes {
let Some(evicted) = self.recent.pop_front() else {
break;
};
self.bytes = self.bytes.saturating_sub(evicted.text.len());
if log_level::is_key_line(&evicted.text) {
self.retained.push(evicted);
}
}
}
/// 按时间顺序取全部可读行(保留的关键行在前)。
pub fn lines(&self) -> impl Iterator<Item = &LogLine> {
self.retained.iter().chain(self.recent.iter())
}
/// 拼接成文本(喂给规则引擎/日志切片)。
pub fn text(&self) -> String {
let mut out = String::with_capacity(self.bytes.min(64 * 1024));
for line in self.lines() {
out.push_str(&line.text);
out.push('\n');
}
out
}
/// 只要错误级行(退出分析的输入)。
pub fn error_lines(&self) -> Vec<&LogLine> {
self.lines().filter(|line| line.is_error()).collect()
}
/// 当前可读行数。
pub fn len(&self) -> usize {
self.retained.len() + self.recent.len()
}
/// 是否为空。
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// 累计压入行数(含已被淘汰的)。
pub fn total_pushed(&self) -> u64 {
self.pushed
}
}
impl Default for RingBuffer {
fn default() -> Self {
Self::new(RingBufferConfig::default())
}
}
/// 单条流结束的回执。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamFinished {
/// 哪条流。
pub stream: Stream,
/// 累计行数。
pub line_count: u64,
/// 读取错误(有的话)。
pub error: Option<String>,
}
/// 一次等待 PumpGroup 的结果。
#[derive(Debug, Clone, Default)]
pub struct PumpCompletion {
/// 已结束的流。
pub finished: Vec<StreamFinished>,
/// 超时仍未结束的流。
pub stalled: Vec<Stream>,
}
impl PumpCompletion {
/// 是否全部结束。
pub fn is_complete(&self) -> bool {
self.stalled.is_empty()
}
/// 累计行数。
pub fn total_lines(&self) -> u64 {
self.finished.iter().map(|item| item.line_count).sum()
}
}
/// 一组泵线程(stdout + stderr)。
#[derive(Debug)]
pub struct PumpGroup {
handles: Vec<JoinHandle<()>>,
receiver: Receiver<StreamFinished>,
streams: Vec<Stream>,
completed: Vec<Stream>,
}
impl PumpGroup {
/// 为子进程的输出流派生泵线程。
///
/// `buffer` 与 `sink` 同时被两个泵线程共享:缓冲区用于“最后 N 行”,
/// sink 用于事件流落盘。
pub fn spawn(
stdout: Option<ChildStdout>,
stderr: Option<ChildStderr>,
buffer: Arc<Mutex<RingBuffer>>,
sink: Arc<dyn LineSink>,
) -> Self {
let (sender, receiver) = mpsc::channel();
let mut handles = Vec::new();
let mut streams = Vec::new();
if let Some(stdout) = stdout {
streams.push(Stream::Stdout);
handles.push(spawn_one(
Box::new(stdout),
Stream::Stdout,
buffer.clone(),
sink.clone(),
sender.clone(),
));
}
if let Some(stderr) = stderr {
streams.push(Stream::Stderr);
handles.push(spawn_one(
Box::new(stderr),
Stream::Stderr,
buffer,
sink,
sender.clone(),
));
}
drop(sender);
Self {
handles,
receiver,
streams,
completed: Vec::new(),
}
}
/// 期望的流数。
pub fn expected(&self) -> usize {
self.streams.len()
}
/// 等待所有泵结束(带超时,避免 HMCL `pump.join()` 永久挂死的风险)。
pub fn wait(&mut self, timeout: Duration) -> PumpCompletion {
let deadline = Instant::now() + timeout;
let mut finished = Vec::new();
while self.completed.len() < self.streams.len() {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
match self.receiver.recv_timeout(remaining) {
Ok(item) => {
if !self.completed.contains(&item.stream) {
self.completed.push(item.stream);
}
finished.push(item);
}
Err(_) => break,
}
}
let stalled = self
.streams
.iter()
.copied()
.filter(|stream| !self.completed.contains(stream))
.collect();
PumpCompletion { finished, stalled }
}
/// 强制 join(可能永久阻塞——只在上层已经确认流已关闭时使用)。
pub fn join_all(&mut self) {
for handle in self.handles.drain(..) {
let _ = handle.join();
}
}
}
type BoxedReader = Box<dyn Read + Send>;
fn spawn_one(
reader: BoxedReader,
stream: Stream,
buffer: Arc<Mutex<RingBuffer>>,
sink: Arc<dyn LineSink>,
sender: Sender<StreamFinished>,
) -> JoinHandle<()> {
thread::Builder::new()
.name(format!("launch-pump-{}", stream.as_str()))
.spawn(move || {
let mut reader = BufReader::new(reader);
let mut raw = Vec::with_capacity(4096);
let mut line_count = 0u64;
let mut error = None;
loop {
raw.clear();
match reader.read_until(b'\n', &mut raw) {
Ok(0) => break,
Ok(_) => {
let text = decode_line(&raw);
if text.is_empty() {
continue;
}
line_count += 1;
let line = LogLine::new(stream, text);
if let Ok(mut guard) = buffer.lock() {
guard.push(line.clone());
}
sink.on_line(&line);
}
Err(err) => {
error = Some(err.to_string());
break;
}
}
}
let _ = sender.send(StreamFinished {
stream,
line_count,
error,
});
})
.expect("spawn log pump thread")
}
fn decode_line(raw: &[u8]) -> String {
let text = String::from_utf8_lossy(raw);
let trimmed = text.trim_end_matches(['\r', '\n']);
truncate_line(trimmed)
}
fn truncate_line(text: &str) -> String {
if text.chars().count() <= MAX_LINE_CHARS {
return text.to_string();
}
let head: String = text.chars().take(MAX_LINE_CHARS).collect();
let extra = text.chars().count() - MAX_LINE_CHARS;
format!("{head}…[truncated {extra} chars]")
}
+299
View File
@@ -0,0 +1,299 @@
//! 日志与事件流的脱敏(写入前执行,落盘即脱敏)。
//!
//! 规则见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §4:
//! token 整串替换、用户主目录替换、长度 ≥64 的不透明串(base64/hex token)替换。
//! 依据 `analyse/02` §6.4 / §8.4-5(HMCL 只过滤 access token,我们要求导出前全量脱敏)。
//!
//! 第四条规则(M40 从 `auth::body` 上提):**带点 token** 整串替换。JWT 形状是
//! `header.payload.signature`(XSTS/XBL 票据正是这个),点号把连续段切开,单看
//! 「连续不透明串 ≥64」这条会整串原样放过。规则只在这里实现一份,所有调用方
//! (launch 会话 / 诊断 / auth 错误摘要 …)共用,避免各自补一套后漂移。
use std::borrow::Cow;
use std::path::Path;
use serde_json::Value;
/// access token / session token 的替换标记。
pub const TOKEN_PLACEHOLDER: &str = "<redacted:token>";
/// 用户主目录的替换标记。
pub const HOME_PLACEHOLDER: &str = "<home>";
/// 形似 token 的长不透明串的替换标记。
pub const OPAQUE_PLACEHOLDER: &str = "<redacted:opaque>";
/// 判定“像 token”的最短连续长度。
const MAX_OPAQUE_RUN: usize = 64;
/// 「带点 token」(JWT 三段式)判定的最短总长。
const DOTTED_TOKEN_MIN_TOTAL: usize = 40;
/// 「带点 token」每一段的最短长度(挡住 `Service.Unavailable.today` 这类正常文案)。
const DOTTED_TOKEN_MIN_SEGMENT: usize = 8;
/// 脱敏器:持有需要抹掉的敏感值(token、主目录)。
#[derive(Debug, Clone, Default)]
pub struct Redactor {
secrets: Vec<String>,
homes: Vec<String>,
}
impl Redactor {
/// 空脱敏器(不做任何替换)。
pub fn new() -> Self {
Self::default()
}
/// 登记一个敏感值(access token、userhash、xuid 等)。
///
/// 太短的值(< 6 字节)会被忽略:替换一个 3 字符串会把正常日志搅乱。
pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
let secret = secret.into();
if secret.trim().len() >= 6 && !self.secrets.contains(&secret) {
self.secrets.push(secret);
}
self
}
/// 登记一个需要抹掉的主目录(替换成 `<home>`)。
pub fn with_home(mut self, home: impl AsRef<Path>) -> Self {
let home = home.as_ref().to_string_lossy().into_owned();
if home.len() > 3 && !self.homes.contains(&home) {
self.homes.push(home);
}
self
}
/// 登记一个主目录字符串。
pub fn with_home_str(mut self, home: impl Into<String>) -> Self {
let home = home.into();
if home.len() > 3 && !self.homes.contains(&home) {
self.homes.push(home);
}
self
}
/// 是否登记了任何规则。
pub fn is_empty(&self) -> bool {
self.secrets.is_empty() && self.homes.is_empty()
}
/// 对文本脱敏;无需修改时返回借用,避免无谓拷贝。
pub fn scrub<'a>(&self, text: &'a str) -> Cow<'a, str> {
let mut current: Cow<'a, str> = Cow::Borrowed(text);
for secret in &self.secrets {
if current.contains(secret.as_str()) {
current = Cow::Owned(current.replace(secret.as_str(), TOKEN_PLACEHOLDER));
}
}
for home in &self.homes {
if current.contains(home.as_str()) {
current = Cow::Owned(current.replace(home.as_str(), HOME_PLACEHOLDER));
}
}
if has_dotted_token(&current) {
current = Cow::Owned(mask_dotted_tokens(&current));
}
if has_opaque_run(&current) {
current = Cow::Owned(mask_opaque(&current));
}
current
}
/// 对整条命令行脱敏(`cmd_built.argv` 必然含 accessToken,必须走这里)。
pub fn scrub_all(&self, values: &[String]) -> Vec<String> {
values
.iter()
.map(|value| self.scrub(value).into_owned())
.collect()
}
/// 递归脱敏一个 JSON 值。
///
/// **只改字符串**:数值/布尔/null 与字段名原样保留——满足「launch 只做脱敏、不改写字段」
/// 的契约(`DevDocs/SubAgentsTalk/launch-接口约定.md` §9.3)。诊断结论里的证据行常带
/// 崩溃报告/`hs_err` 的绝对路径,正是要过 `<home>` 规则的地方。
pub fn scrub_json(&self, value: &Value) -> Value {
match value {
Value::String(text) => Value::String(self.scrub(text).into_owned()),
Value::Array(items) => {
Value::Array(items.iter().map(|item| self.scrub_json(item)).collect())
}
Value::Object(map) => Value::Object(
map.iter()
.map(|(key, value)| (key.clone(), self.scrub_json(value)))
.collect(),
),
other => other.clone(),
}
}
}
/// 文本里是否含长度 ≥64 的“不透明串”(base64/hex token 的特征)。
pub fn has_opaque_run(text: &str) -> bool {
let mut run = 0usize;
for ch in text.chars() {
if is_opaque_char(ch) {
run += 1;
if run >= MAX_OPAQUE_RUN {
return true;
}
} else {
run = 0;
}
}
false
}
/// 把长度 ≥64 的不透明串替换成 [`OPAQUE_PLACEHOLDER`]。
pub fn mask_opaque(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut run_start: Option<usize> = None;
for (idx, ch) in text.char_indices() {
if is_opaque_char(ch) {
if run_start.is_none() {
run_start = Some(idx);
}
} else {
if let Some(start) = run_start.take() {
push_masked(&mut out, &text[start..idx]);
}
out.push(ch);
}
}
if let Some(start) = run_start {
push_masked(&mut out, &text[start..]);
}
out
}
fn push_masked(out: &mut String, run: &str) {
if run.len() >= MAX_OPAQUE_RUN {
out.push_str(OPAQUE_PLACEHOLDER);
} else {
out.push_str(run);
}
}
fn is_opaque_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '_' | '-')
}
/// 文本里是否含「带点 token」(≥3 段、每段 ≥8、总长 ≥40)。
pub fn has_dotted_token(text: &str) -> bool {
let mut start: Option<usize> = None;
for (idx, ch) in text.char_indices() {
if is_dotted_token_char(ch) {
start.get_or_insert(idx);
continue;
}
if let Some(from) = start.take() {
if is_dotted_token(&text[from..idx]) {
return true;
}
}
}
start.is_some_and(|from| is_dotted_token(&text[from..]))
}
/// 把「带点 token」整串替换成 [`TOKEN_PLACEHOLDER`](段外文本原样保留)。
pub fn mask_dotted_tokens(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut start: Option<usize> = None;
for (idx, ch) in text.char_indices() {
if is_dotted_token_char(ch) {
start.get_or_insert(idx);
continue;
}
if let Some(from) = start.take() {
push_dotted(&mut out, &text[from..idx]);
}
out.push(ch);
}
if let Some(from) = start {
push_dotted(&mut out, &text[from..]);
}
out
}
fn push_dotted(out: &mut String, run: &str) {
if is_dotted_token(run) {
out.push_str(TOKEN_PLACEHOLDER);
} else {
out.push_str(run);
}
}
/// 「带点 token」的字符集:比不透明串多一个 `.`(JWT 的分段符)。
fn is_dotted_token_char(ch: char) -> bool {
is_opaque_char(ch) || ch == '.'
}
/// 单段连写(无空白)的串是否像 JWT:段数 ≥3、每段 ≥8 字节、总长 ≥40。
fn is_dotted_token(run: &str) -> bool {
run.len() >= DOTTED_TOKEN_MIN_TOTAL
&& run.split('.').count() >= 3
&& run
.split('.')
.all(|segment| segment.len() >= DOTTED_TOKEN_MIN_SEGMENT)
}
#[cfg(test)]
mod tests {
use super::*;
/// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
#[test]
fn jwt_token_is_masked_without_any_registered_secret() {
let scrubbed = Redactor::new().scrub(JWT).into_owned();
assert!(scrubbed.contains(TOKEN_PLACEHOLDER), "{scrubbed}");
assert!(!scrubbed.contains("eyJhbGci"), "{scrubbed}");
// 嵌在别的内容里也只换 token 本体。
let masked = Redactor::new()
.scrub(&format!("authorization: Bearer {JWT}, retry=1"))
.into_owned();
assert_eq!(
masked,
format!("authorization: Bearer {TOKEN_PLACEHOLDER}, retry=1")
);
}
#[test]
fn dotted_token_rule_does_not_eat_normal_text() {
// 分段太短、总长不够、段数不够:三种都不算 token。
for text in [
"Service.Unavailable.today",
"1.2.0",
"xbl.xsts.entitlements.profile",
"eyJhbGciOiJIUzI1NiJ9.short.abc",
] {
assert!(!has_dotted_token(text), "{text}");
assert_eq!(Redactor::new().scrub(text), text, "{text}");
}
}
#[test]
fn existing_rules_still_apply() {
let redactor = Redactor::new()
.with_secret("SECRET-TOKEN-123456")
.with_home_str("/home/alice");
let scrubbed = redactor
.scrub("/home/alice/game.log: token=SECRET-TOKEN-123456")
.into_owned();
assert!(scrubbed.contains(HOME_PLACEHOLDER), "{scrubbed}");
assert!(scrubbed.contains(TOKEN_PLACEHOLDER), "{scrubbed}");
assert!(
!scrubbed.contains("alice") && !scrubbed.contains("SECRET"),
"{scrubbed}"
);
// 没有点号的长不透明串仍走 ≥64 规则,替换标记也不同。
let long = "A".repeat(MAX_OPAQUE_RUN);
assert!(has_opaque_run(&long));
assert!(!has_dotted_token(&long));
assert_eq!(
Redactor::new().scrub(&long).into_owned(),
OPAQUE_PLACEHOLDER.to_string()
);
}
}
+358
View File
@@ -0,0 +1,358 @@
//! 启动请求:命令行组装的输入模型(纯数据,无 IO,便于快照测试)。
//!
//! 与 `crate::game` / `crate::download` 的映射关系见
//! `DevDocs/SubAgentsTalk/launch-接口约定.md` §6:launch 阶段只消费本结构,
//! 由薄适配层把 game 的快照搬进来,避免 launch 对 manifest 类型产生编译期依赖。
use std::path::PathBuf;
use crate::launch::argument::ArgumentEntry;
use crate::launch::error::MissingComponent;
use crate::launch::game_version::GameVersion;
use crate::launch::natives::NativesPlan;
/// 实例信息。
#[derive(Debug, Clone, Default)]
pub struct InstanceInfo {
/// 实例/版本 id(稳定 UUID 或版本目录名)。
pub id: String,
/// 展示名(`${version_name}`)。
pub name: String,
/// 游戏版本号(分支门限用)。
pub version: GameVersion,
/// 版本类型(`release` / `snapshot`;`${version_type}`)。
pub version_type: String,
}
/// 启动器自身信息(`${launcher_name}` / `${launcher_version}`)。
#[derive(Debug, Clone, Default)]
pub struct LauncherInfo {
/// 名称。
pub name: String,
/// 版本。
pub version: String,
}
/// 选定的 Java 运行时。
#[derive(Debug, Clone, Default)]
pub struct JavaSelection {
/// `java` 可执行文件路径。
pub binary: PathBuf,
/// 主版本号(8 / 17 / 21 / 25 …)。
pub major: u32,
/// 位数(32 / 64)。
pub bits: u32,
}
impl JavaSelection {
/// 构造。
pub fn new(binary: impl Into<PathBuf>, major: u32, bits: u32) -> Self {
Self {
binary: binary.into(),
major,
bits,
}
}
/// 是否 64 位 JVM。
pub fn is_64bit(&self) -> bool {
self.bits == 64
}
}
/// 内存设置(MiB)。
#[derive(Debug, Clone, Copy, Default)]
pub struct MemoryOptions {
/// 初始堆 `-Xms`。
pub min_mb: Option<u32>,
/// 最大堆 `-Xmx`。
pub max_mb: Option<u32>,
}
/// 版本清单里的参数(`arguments.jvm` / `arguments.game` / 旧版 `minecraftArguments`)。
#[derive(Debug, Clone, Default)]
pub struct ManifestArgs {
/// 主类。
pub main_class: Option<String>,
/// 现代清单的 JVM 参数(带 rules)。
pub jvm: Vec<ArgumentEntry>,
/// 现代清单的游戏参数(带 rules)。
pub game: Vec<ArgumentEntry>,
/// 旧版清单的整串游戏参数(1.12 及更早)。
pub legacy_arguments: Option<String>,
/// 版本库表里带 LWJGL 的 Vulkan 绑定(`org.lwjgl:lwjgl-vulkan`)。
///
/// 只作「无法排序的版本」(周快照)的 `--graphicsBackend` 兜底判据与 UI 提示;
/// 可解析版本的下发门限仍是 `26.2-snapshot-2`(见
/// [`graphics_backend_allowed`](crate::launch::game_args::graphics_backend_allowed))。
pub has_vulkan_backend: bool,
}
impl ManifestArgs {
/// 是否走旧版参数模板(只有 `minecraftArguments`,没有 `arguments`)。
pub fn is_legacy(&self) -> bool {
self.jvm.is_empty() && self.legacy_arguments.is_some()
}
}
/// 目录布局。
#[derive(Debug, Clone, Default)]
pub struct LaunchDirs {
/// 运行目录(`${game_directory}`,同时是子进程的 CWD)。
///
/// D20:这个值是**隔离覆盖链的派生结果**(`game::isolation::derive_game_dir`:
/// 临时参数 > 实例 `isolation` > 全局 `instances.mode`)。launch 侧只消费、不派生,
/// 也不读配置——路径语义的真相在 `core::game`。
pub game_dir: PathBuf,
/// 资产根。
pub assets_root: PathBuf,
/// 重建后的虚拟资产目录(`${game_assets}`)。
pub game_assets: PathBuf,
/// 资产索引 id(`${assets_index_name}`)。
pub assets_index: String,
/// 原生库目录(按 Java 平台选择,`analyse/02` §4.6)。
pub natives_dir: PathBuf,
/// 库根目录。
pub library_dir: PathBuf,
/// 主 jar。
pub primary_jar: PathBuf,
}
/// 账户占位符值。
#[derive(Debug, Clone, Default)]
pub struct AuthPlaceholders {
/// 玩家名。
pub player_name: String,
/// 无连字符 UUID。
pub uuid: String,
/// 访问令牌(落盘前必须脱敏)。
pub access_token: String,
/// `msa` / `legacy`。
pub user_type: String,
/// `user_properties` JSON 串。
pub user_properties: String,
}
/// 用户自定义参数。
#[derive(Debug, Clone, Default)]
pub struct UserArgs {
/// 最高优先级 JVM 参数(不解析转义,会读 `@argfile`)。
pub override_jvm: Vec<String>,
/// 普通 JVM 参数(整体按“默认值”处理)。
pub jvm: Vec<String>,
/// 追加在最后、可覆盖前面一切的 game 参数。
pub game: Vec<String>,
/// 额外环境变量(用户自定义,最后覆盖)。
pub environment: Vec<(String, String)>,
}
/// 清单参数 rules 求值所需的特性开关。
#[derive(Debug, Clone, Copy, Default)]
pub struct FeatureSet {
/// 用户指定了分辨率(`--width/--height` 的开关)。
pub has_custom_resolution: bool,
/// 演示模式。
pub is_demo_user: bool,
/// 支持 QuickPlay(1.20+)。
pub has_quick_plays_support: bool,
}
impl FeatureSet {
/// 按特性名查询(未知特性为 `None`)。
pub fn get(&self, name: &str) -> Option<bool> {
match name {
"has_custom_resolution" => Some(self.has_custom_resolution),
"is_demo_user" => Some(self.is_demo_user),
"has_quick_plays_support" => Some(self.has_quick_plays_support),
_ => None,
}
}
}
/// 窗口分辨率。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Resolution {
/// 宽。
pub width: u32,
/// 高。
pub height: u32,
}
/// 一键进入目标。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QuickPlay {
/// 直入存档。
Singleplayer {
/// 存档目录名。
world: String,
},
/// 直入服务器。
Multiplayer {
/// `host:port` 形式地址。
address: String,
},
/// 直入 Realms。
Realms {
/// Realm id。
realm_id: String,
},
}
/// SOCKS 代理。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SocksProxy {
/// 主机。
pub host: String,
/// 端口。
pub port: u16,
/// 用户名。
pub username: Option<String>,
/// 密码。
pub password: Option<String>,
}
/// 模组加载器种类(决定 Forge 专属参数与环境变量)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ModLoaderKind {
/// 原版。
#[default]
Vanilla,
/// Forge。
Forge,
/// NeoForge。
NeoForge,
/// Fabric。
Fabric,
/// Quilt。
Quilt,
/// LiteLoader。
LiteLoader,
/// OptiFine(独立安装器)。
OptiFine,
}
impl ModLoaderKind {
/// 是否使用 FML/LaunchWrapper(决定 `-Dfml.ignore*` 参数)。
pub fn is_fml_like(self) -> bool {
matches!(self, Self::Forge | Self::NeoForge | Self::LiteLoader)
}
}
/// Log4j 注入选项。
#[derive(Debug, Clone)]
pub struct Log4jOptions {
/// 是否注入日志配置(HMCL:版本 ≥1.7 即注入,探测失败按 1.7 兜底)。
pub enabled: bool,
/// 配置文件路径(`-Dlog4j.configurationFile=`)。
pub config_file: Option<PathBuf>,
/// 调试日志变体。
pub debug: bool,
}
impl Default for Log4jOptions {
fn default() -> Self {
Self {
enabled: true,
config_file: None,
debug: false,
}
}
}
/// 渲染器相关开关(`analyse/02` §4.1 #17、§4.10)。
#[derive(Debug, Clone, Default)]
pub struct RendererOptions {
/// 软件渲染(Mesa/llvmpipe)。
pub software: bool,
/// Windows 上 mesa 的 native 目录。
pub mesa_native_dir: Option<PathBuf>,
/// macOS 上的 Vulkan ICD JSON。
pub vulkan_icd: Option<PathBuf>,
}
/// JVM 自动优化选项。
#[derive(Debug, Clone)]
pub struct OptimizationOptions {
/// 是否注入自动优化参数(用户可关)。
pub enabled: bool,
/// 物理内存 MiB(决定 CodeCache 一组参数的开关)。
pub physical_memory_mb: Option<u64>,
}
impl Default for OptimizationOptions {
fn default() -> Self {
Self {
enabled: true,
physical_memory_mb: None,
}
}
}
/// 一次启动的完整输入。
#[derive(Debug, Clone, Default)]
pub struct LaunchRequest {
/// 实例信息。
pub instance: InstanceInfo,
/// 启动器信息。
pub launcher: LauncherInfo,
/// Java 运行时。
pub java: JavaSelection,
/// 内存。
pub memory: MemoryOptions,
/// 清单参数。
pub manifest: ManifestArgs,
/// 目录布局。
pub dirs: LaunchDirs,
/// natives 解压计划(`dirs.natives_dir` 为目标目录,`jars` 为分类器构件清单)。
///
/// 解压发生在**组装期**(`Launcher::dry_run`/`spawn` 的「环境准备」段),
/// 见 [`crate::launch::natives`] 与 `SubAgentsTalk/launch-natives-解压与时代分界.md`。
pub natives: NativesPlan,
/// 账户。
pub auth: AuthPlaceholders,
/// 用户参数。
pub user: UserArgs,
/// 特性开关。
pub features: FeatureSet,
/// 分辨率(`None` 表示不指定)。
pub resolution: Option<Resolution>,
/// 一键进入。
pub quick_play: Option<QuickPlay>,
/// SOCKS 代理。
pub proxy: Option<SocksProxy>,
/// 图形后端(`default` / `opengl` / `vulkan`)。
///
/// 下发门限见 [`graphics_backend_allowed`](crate::launch::game_args::graphics_backend_allowed):
/// 可解析版本 ≥ `26.2-snapshot-2`,周快照另按版本库表的能力位判。
pub graphics_backend: Option<String>,
/// 全屏。
pub fullscreen: bool,
/// 进程优先级(POSIX `nice -n`,Windows 忽略)。
pub nice: Option<i32>,
/// 命令包装器(`optirun`、`prime-run` 等)。
pub wrapper: Vec<String>,
/// `-javaagent:` 列表。
pub java_agents: Vec<String>,
/// 登录源提供的 JVM 参数(authlib-injector)。
pub auth_jvm_args: Vec<String>,
/// 登录源提供的游戏参数。
pub auth_game_args: Vec<String>,
/// 模组加载器。
pub mod_loader: ModLoaderKind,
/// Log4j 注入。
pub log4j: Log4jOptions,
/// 渲染器开关。
pub renderer: RendererOptions,
/// JVM 自动优化。
pub optimization: OptimizationOptions,
/// `-Dfile.encoding` 指定值(`COMPAT` 表示跟随系统)。
pub encoding: Option<String>,
/// 界面语言(`${language}`)。
pub language: String,
/// 已过滤为“确实存在”的 classpath(主 jar 由组装器追加到末位)。
pub classpath: Vec<PathBuf>,
/// 组件就绪检查发现的缺失项(非空则组装直接失败)。
pub missing_components: Vec<MissingComponent>,
/// 额外注入的 JVM 参数(品牌/特性钩子,`appendJvmArgs` 的替代者)。
pub extra_jvm: Vec<String>,
}
+375
View File
@@ -0,0 +1,375 @@
//! JSONL 会话事件流:`<sessions_root>/game/<session-id>.jsonl`。
//!
//! schema 见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§3(v1)——M15 诊断采集器与
//! M16 `read_game_log`/`list_game_sessions` 消费同一份文件,是**单一真相源**。
//!
//! 两条纪律写死在实现里:
//! 1. 脱敏在写入前做([`Redactor`]),`cmd_built.argv` 也不例外(命令行必含 accessToken);
//! 2. 「launch 报事实、M15 下结论」:`diagnosis` 事件由 M15 产出、由本模块落盘。
use std::fs::{self, File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::Value;
use crate::launch::args_table::AssembledCommand;
use crate::launch::error::{AssemblyError, LaunchError, Result};
use crate::launch::exit_analysis::ExitAnalysis;
use crate::launch::natives::NativesReport;
use crate::launch::pump::{LogLine, Stream};
use crate::launch::redact::Redactor;
use crate::launch::session_index::{self, SessionIndexEntry};
use crate::launch::session_payload as payload;
use crate::launch::time::{self, SessionClock};
use crate::launch::window_detect::Detection;
/// 事件 schema 版本。
pub const SCHEMA_VERSION: u8 = 1;
/// 事件类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventKind {
/// 会话开始。
SessionStart,
/// Java 选择结果。
JavaSelected,
/// 命令行组装完成。
CmdBuilt,
/// natives 解压结果。
NativesExtracted,
/// 启动前失败(没有进程)。
AssemblyFailed,
/// 进程已启动。
ProcStart,
/// stdout 行。
Stdout,
/// stderr 行。
Stderr,
/// 检测到游戏窗口。
WindowDetected,
/// 进程退出。
Exit,
/// 发现崩溃报告 / hs_err。
CrashFound,
/// 诊断结论(M15 产出)。
Diagnosis,
/// 被主动结束。
Killed,
/// 会话结束。
SessionEnd,
}
impl EventKind {
/// JSONL 里的 `kind` 字符串。
pub fn as_str(self) -> &'static str {
match self {
Self::SessionStart => "session_start",
Self::JavaSelected => "java_selected",
Self::CmdBuilt => "cmd_built",
Self::NativesExtracted => "natives_extracted",
Self::AssemblyFailed => "assembly_failed",
Self::ProcStart => "proc_start",
Self::Stdout => "stdout",
Self::Stderr => "stderr",
Self::WindowDetected => "window_detected",
Self::Exit => "exit",
Self::CrashFound => "crash_found",
Self::Diagnosis => "diagnosis",
Self::Killed => "killed",
Self::SessionEnd => "session_end",
}
}
}
/// 会话结束原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndReason {
/// 进程自然退出。
Exit,
/// 被主动结束。
Killed,
/// 启动前失败(组装/组件检查)。
AssemblyFailed,
/// 进程派生失败。
SpawnFailed,
}
impl EndReason {
/// JSONL 里的 `reason` 字符串。
pub fn as_str(self) -> &'static str {
match self {
Self::Exit => "exit",
Self::Killed => "killed",
Self::AssemblyFailed => "assembly_failed",
Self::SpawnFailed => "spawn_failed",
}
}
}
pub use crate::launch::session_meta::SessionMeta;
pub use crate::launch::session_sink::SessionSink;
#[derive(Serialize)]
struct EventLine<'a> {
v: u8,
seq: u64,
ts: String,
t_ms: u64,
kind: &'a str,
session_id: &'a str,
payload: &'a Value,
}
/// 会话事件流写入器。
#[derive(Debug)]
pub struct SessionWriter {
session_id: String,
clock: SessionClock,
file: BufWriter<File>,
path: PathBuf,
index_path: PathBuf,
seq: u64,
event_count: u64,
redactor: Redactor,
meta: SessionMeta,
started_at: String,
window_detected: bool,
crash_found: bool,
exit_code: Option<i32>,
exit_type: Option<String>,
silent_failure: bool,
diagnosis_top: Option<String>,
finished: bool,
}
impl SessionWriter {
/// 新建会话(id 由时间 + pid 生成)。
pub fn create(sessions_root: &Path, meta: SessionMeta, redactor: Redactor) -> Result<Self> {
let session_id = time::make_session_id(time::now(), std::process::id());
Self::create_with_id(sessions_root, &session_id, meta, redactor)
}
/// 用指定 id 新建会话(测试与“续写”场景)。
pub fn create_with_id(
sessions_root: &Path,
session_id: &str,
meta: SessionMeta,
redactor: Redactor,
) -> Result<Self> {
let dir = sessions_root.join("game");
fs::create_dir_all(&dir).map_err(|source| LaunchError::Io {
op: "create_session_dir",
path: dir.clone(),
source,
})?;
let path = dir.join(format!("{session_id}.jsonl"));
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|source| LaunchError::Io {
op: "create_session_file",
path: path.clone(),
source,
})?;
let clock = SessionClock::start_new();
let started_at = clock.started_at_iso();
Ok(Self {
session_id: session_id.to_string(),
clock,
file: BufWriter::new(file),
path,
index_path: session_index::index_path(sessions_root),
seq: 0,
event_count: 0,
redactor,
meta,
started_at,
window_detected: false,
crash_found: false,
exit_code: None,
exit_type: None,
silent_failure: false,
diagnosis_top: None,
finished: false,
})
}
/// 会话 id。
pub fn session_id(&self) -> &str {
&self.session_id
}
/// 事件文件路径。
pub fn path(&self) -> &Path {
&self.path
}
/// 已写入事件数。
pub fn event_count(&self) -> u64 {
self.event_count
}
/// 是否已结束。
pub fn is_finished(&self) -> bool {
self.finished
}
/// 写一条事件。
pub fn record(&mut self, kind: EventKind, payload: Value) -> Result<()> {
let seq = self.seq;
let line = EventLine {
v: SCHEMA_VERSION,
seq,
ts: self.clock.now_iso(),
t_ms: self.clock.elapsed_ms(),
kind: kind.as_str(),
session_id: &self.session_id,
payload: &payload,
};
let serialized = serde_json::to_string(&line).map_err(|err| LaunchError::Session {
path: self.path.clone(),
reason: err.to_string(),
})?;
self.file
.write_all(serialized.as_bytes())
.and_then(|()| self.file.write_all(b"\n"))
.and_then(|()| self.file.flush())
.map_err(|source| LaunchError::Io {
op: "write_session_event",
path: self.path.clone(),
source,
})?;
self.seq += 1;
self.event_count += 1;
Ok(())
}
/// `session_start`。
pub fn session_start(&mut self) -> Result<()> {
let payload = payload::session_start(&self.meta, SCHEMA_VERSION);
self.record(EventKind::SessionStart, payload)
}
/// `cmd_built`:完整 argv(已脱敏)+ classpath 摘要。
pub fn cmd_built(&mut self, command: &AssembledCommand) -> Result<()> {
let payload = payload::cmd_built(command, &self.redactor);
self.record(EventKind::CmdBuilt, payload)
}
/// `assembly_failed`:启动前失败,附结构化缺失清单(M15 据此下结论)。
pub fn assembly_failed(&mut self, error: &AssemblyError) -> Result<()> {
let payload = payload::assembly_failed(error);
self.record(EventKind::AssemblyFailed, payload)
}
/// `proc_start`。
pub fn proc_start(&mut self, pid: u32) -> Result<()> {
let payload = payload::proc_start(pid, &self.clock.now_iso());
self.record(EventKind::ProcStart, payload)
}
/// `natives_extracted`:natives 解压结果(启动期 `UnsatisfiedLinkError` 的第一嫌疑)。
pub fn natives_extracted(&mut self, report: &NativesReport) -> Result<()> {
let payload = payload::natives_extracted(report, &self.redactor);
self.record(EventKind::NativesExtracted, payload)
}
/// `stdout` / `stderr` 行事件。
pub fn line(&mut self, line: &LogLine) -> Result<()> {
let kind = match line.stream {
Stream::Stdout => EventKind::Stdout,
Stream::Stderr => EventKind::Stderr,
};
let text = self.redactor.scrub(&line.text).into_owned();
let payload = payload::log_line(&text, line.level());
self.record(kind, payload)
}
/// `window_detected`。
pub fn window_detected(&mut self, detection: &Detection) -> Result<()> {
self.window_detected = true;
let payload = payload::window_detected(detection, &self.redactor);
self.record(EventKind::WindowDetected, payload)
}
/// 是否已检测到游戏窗口(索引摘要用)。
pub fn is_window_detected(&self) -> bool {
self.window_detected
}
/// `killed`。
pub fn killed(&mut self, by: &str, signal: Option<&str>) -> Result<()> {
let payload = payload::killed(by, signal);
self.record(EventKind::Killed, payload)
}
/// `exit`。
pub fn exit(&mut self, analysis: &ExitAnalysis) -> Result<()> {
self.exit_code = Some(analysis.code);
self.exit_type = Some(analysis.exit_type.as_str().to_string());
self.silent_failure = analysis.silent_failure.is_some();
let payload = payload::exit(analysis);
self.record(EventKind::Exit, payload)
}
/// `crash_found`:以 `path` 为权威字段(M15 自己读盘)。
pub fn crash_found(&mut self, source: &str, path: &Path, excerpt: Option<&str>) -> Result<()> {
self.crash_found = true;
let payload = payload::crash_found(source, path, excerpt, &self.redactor);
self.record(EventKind::CrashFound, payload)
}
/// `diagnosis`:M15 产出的结构化结论,由 launch 脱敏后落盘(单一真相源)。
///
/// M15 会按 `crash_found.path` 自己读盘再组 payload,崩溃报告/`latest.log` 里的
/// 用户主目录路径与 token 会由此带进结论,所以这里必须再过一遍 [`Redactor`]
/// (只改字符串,字段名/数值/布尔不动——`launch-接口约定.md` §9.3)。
pub fn diagnosis(&mut self, record: Value) -> Result<()> {
let record = self.redactor.scrub_json(&record);
if self.diagnosis_top.is_none() {
self.diagnosis_top = record
.get("candidates")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("id"))
.and_then(Value::as_str)
.map(str::to_string);
}
self.record(EventKind::Diagnosis, record)
}
/// `session_end` + 追加索引,返回索引条目。
pub fn finish(&mut self, reason: EndReason) -> Result<SessionIndexEntry> {
let duration_ms = self.clock.elapsed_ms();
let payload = payload::session_end(reason.as_str(), duration_ms);
self.record(EventKind::SessionEnd, payload)?;
self.finished = true;
let entry = SessionIndexEntry {
v: SCHEMA_VERSION,
session_id: self.session_id.clone(),
instance_id: self.meta.instance_id.clone(),
instance_name: self.meta.instance_name.clone(),
game_version: self.meta.game_version.clone(),
started_at: self.started_at.clone(),
ended_at: self.clock.now_iso(),
duration_ms,
exit_code: self.exit_code,
exit_type: self.exit_type.clone(),
silent_failure: self.silent_failure,
window_detected: self.window_detected,
event_count: self.event_count,
log_path: format!("game/{}.jsonl", self.session_id),
crash_found: self.crash_found,
diagnosis_top: self.diagnosis_top.clone(),
};
session_index::append(&self.index_path, &entry).map_err(|source| LaunchError::Io {
op: "append_session_index",
path: self.index_path.clone(),
source,
})?;
Ok(entry)
}
}
+120
View File
@@ -0,0 +1,120 @@
//! 会话记录索引:`list_game_sessions` 的数据源。
//!
//! 文件:`<sessions_root>/game/index.jsonl`,每会话一行摘要,会话结束时追加。
//! 读取约定(`DevDocs/SubAgentsTalk/launch-接口约定.md` §2):同一 `session_id`
//! 可能出现多行(崩溃后重写索引),读取方**按 id 去重取最后一行**,再按 `started_at` 倒序。
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// 索引条目的 schema 版本。
pub const INDEX_VERSION: u8 = 1;
/// 一次会话的摘要。
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionIndexEntry {
/// schema 版本。
#[serde(default)]
pub v: u8,
/// 会话 id。
pub session_id: String,
/// 实例 id。
pub instance_id: String,
/// 实例名。
pub instance_name: String,
/// 游戏版本。
pub game_version: String,
/// 开始时间(RFC3339 UTC)。
pub started_at: String,
/// 结束时间(RFC3339 UTC)。
pub ended_at: String,
/// 时长毫秒。
pub duration_ms: u64,
/// 退出码(未跑到进程时为 `None`)。
pub exit_code: Option<i32>,
/// 退出判定字符串。
pub exit_type: Option<String>,
/// 是否静默失败。
pub silent_failure: bool,
/// 是否检测到游戏窗口。
pub window_detected: bool,
/// 事件条数。
pub event_count: u64,
/// 相对 `game/` 的日志文件名。
pub log_path: String,
/// 是否抓到崩溃报告/hs_err。
pub crash_found: bool,
/// 诊断首因(M15 写入的第一个候选 id)。
pub diagnosis_top: Option<String>,
}
/// 索引文件路径(`<sessions_root>/game/index.jsonl`)。
pub fn index_path(sessions_root: &Path) -> PathBuf {
sessions_root.join("game").join("index.jsonl")
}
/// 追加一行摘要。
pub fn append(index: &Path, entry: &SessionIndexEntry) -> std::io::Result<()> {
if let Some(parent) = index.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new().create(true).append(true).open(index)?;
let line = serde_json::to_string(entry)
.map_err(|err| std::io::Error::other(format!("序列化索引失败:{err}")))?;
file.write_all(line.as_bytes())?;
file.write_all(b"\n")?;
file.flush()
}
/// 读取全部条目(同一 id 取最后一行,按开始时间倒序)。
pub fn read_all(index: &Path) -> std::io::Result<Vec<SessionIndexEntry>> {
let file = match File::open(index) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err),
};
let mut entries: Vec<SessionIndexEntry> = Vec::new();
for line in BufReader::new(file).lines() {
let line = match line {
Ok(line) => line,
Err(_) => break,
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// 容忍坏行(半截 JSON):索引是“尽力而为”的数据源,不能因为一行坏数据丢掉整份列表。
let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(trimmed) else {
continue;
};
match entries
.iter_mut()
.find(|item| item.session_id == entry.session_id)
{
Some(slot) => *slot = entry,
None => entries.push(entry),
}
}
entries.sort_by(|left, right| right.started_at.cmp(&left.started_at));
Ok(entries)
}
/// `list_game_sessions` 语义:可选实例过滤 + 条数上限,按开始时间倒序。
pub fn list(
index: &Path,
limit: usize,
instance_id: Option<&str>,
) -> std::io::Result<Vec<SessionIndexEntry>> {
let entries = read_all(index)?;
Ok(entries
.into_iter()
.filter(|entry| match instance_id {
Some(id) => entry.instance_id == id,
None => true,
})
.take(limit)
.collect())
}
+62
View File
@@ -0,0 +1,62 @@
//! 会话元信息(`session_start` 的 payload 来源 + 索引摘要字段)。
//!
//! 从 `session.rs` 拆出以守住“单文件 ≤400 行”的硬约束;公开路径仍是
//! `launch::session::SessionMeta`(在 `session.rs` 里 re-export)。
use crate::launch::platform::PlatformCtx;
/// 会话元信息(`session_start` 的 payload 来源)。
#[derive(Debug, Clone, Default)]
pub struct SessionMeta {
/// 实例 id。
pub instance_id: String,
/// 实例名。
pub instance_name: String,
/// 游戏版本。
pub game_version: String,
/// 启动器名。
pub launcher_name: String,
/// 启动器版本。
pub launcher_version: String,
/// 系统名。
pub os_name: String,
/// 架构。
pub os_arch: String,
/// 位数。
pub os_bits: u32,
}
impl SessionMeta {
/// 基本构造。
pub fn new(
instance_id: impl Into<String>,
instance_name: impl Into<String>,
game_version: impl Into<String>,
) -> Self {
Self {
instance_id: instance_id.into(),
instance_name: instance_name.into(),
game_version: game_version.into(),
..Self::default()
}
}
/// 补启动器信息。
pub fn with_launcher(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
self.launcher_name = name.into();
self.launcher_version = version.into();
self
}
/// 补平台信息。
pub fn with_platform(mut self, ctx: &PlatformCtx) -> Self {
self.os_name = ctx.os.manifest_name().to_string();
self.os_arch = ctx.arch.clone();
self.os_bits = if cfg!(target_pointer_width = "64") {
64
} else {
32
};
self
}
}
+159
View File
@@ -0,0 +1,159 @@
//! 会话事件 payload 构造(schema 见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §3)。
//!
//! 拆出来是为了让 [`crate::launch::session::SessionWriter`] 只关心“写文件 + seq + 索引”,
//! 字段定义集中在一处,评审时一眼能对完整张表。
use std::path::Path;
use serde_json::{json, Value};
use crate::launch::args_table::AssembledCommand;
use crate::launch::error::AssemblyError;
use crate::launch::exit_analysis::ExitAnalysis;
use crate::launch::log_level::Level;
use crate::launch::natives::NativesReport;
use crate::launch::redact::Redactor;
use crate::launch::session::SessionMeta;
use crate::launch::window_detect::Detection;
/// `session_start`。
pub(crate) fn session_start(meta: &SessionMeta, schema: u8) -> Value {
json!({
"instance_id": meta.instance_id,
"instance_name": meta.instance_name,
"game_version": meta.game_version,
"launcher": {
"name": meta.launcher_name,
"version": meta.launcher_version,
},
"os": {
"name": meta.os_name,
"arch": meta.os_arch,
"bits": meta.os_bits,
},
"schema": schema,
})
}
/// `cmd_built`:完整 argv(脱敏)+ classpath 摘要 + 组装告警。
pub(crate) fn cmd_built(command: &AssembledCommand, redactor: &Redactor) -> Value {
let argv = redactor.scrub_all(&command.argv());
let program = redactor
.scrub(&command.program.display().to_string())
.into_owned();
json!({
"program": program,
"argv": argv,
"main_class": command.main_class,
"classpath_entries": command.classpath.len(),
"classpath_missing": Vec::<String>::new(),
"memory": {
"xmx_mb": command.memory.max_mb,
"xms_mb": command.memory.min_mb,
},
"encoding": command.encoding,
"warnings": command.warnings.iter().map(ToString::to_string).collect::<Vec<_>>(),
})
}
/// `assembly_failed`:启动前失败的结构化事实(结论由 M15 下)。
pub(crate) fn assembly_failed(error: &AssemblyError) -> Value {
let (code, components) = match error {
AssemblyError::MissingMainClass => ("missing_main_class", Vec::new()),
AssemblyError::EmptyClasspath => ("empty_classpath", Vec::new()),
AssemblyError::EmptyJavaPath => ("empty_java_path", Vec::new()),
AssemblyError::MalformedArgument { .. } => ("malformed_argument", Vec::new()),
AssemblyError::NativesExtraction { .. } => ("natives_extraction", Vec::new()),
AssemblyError::MissingComponents(items) => (
"missing_components",
items
.iter()
.map(|item| {
json!({
"name": item.name,
"path": item.path.as_ref().map(|path| path.display().to_string()),
})
})
.collect::<Vec<_>>(),
),
};
json!({
"error_code": code,
"message": error.to_string(),
"missing_components": components,
})
}
/// `proc_start`。
pub(crate) fn proc_start(pid: u32, started_at: &str) -> Value {
json!({ "pid": pid, "started_at": started_at })
}
/// `natives_extracted`:解压事实(`files`/`skipped`/`custom` 是既有约定字段,
/// 其余为 v1 追加字段,读取方按「未知字段忽略」处理)。
pub(crate) fn natives_extracted(report: &NativesReport, redactor: &Redactor) -> Value {
json!({
"dir": redactor.scrub(&report.dir.display().to_string()).into_owned(),
"files": report.files,
"skipped": report.skipped,
"custom": report.custom,
"jars": report.jars,
"ignored": report.ignored,
"up_to_date": report.up_to_date,
"era": report.era.as_str(),
})
}
/// `killed`。
pub(crate) fn killed(by: &str, signal: Option<&str>) -> Value {
json!({ "by": by, "signal": signal })
}
/// `stdout` / `stderr` 行事件。
pub(crate) fn log_line(text: &str, level: Level) -> Value {
json!({ "line": text, "level": level.as_str() })
}
/// `window_detected`。
pub(crate) fn window_detected(detection: &Detection, redactor: &Redactor) -> Value {
json!({
"heuristic": detection.heuristic,
"took_ms": detection.took_ms,
"evidence": redactor.scrub(&detection.evidence),
})
}
/// `exit`。
pub(crate) fn exit(analysis: &ExitAnalysis) -> Value {
json!({
"code": analysis.code,
"exit_type": analysis.exit_type.as_str(),
"uptime_ms": analysis.uptime_ms,
"error_lines": analysis.error_lines,
"crash_keywords": analysis.crash_keywords,
"jvm_keywords": analysis.jvm_keywords,
"silent_failure": analysis.silent_failure.is_some(),
"windows_status": analysis.windows_status,
"needs_diagnosis": analysis.needs_diagnosis,
"summary": analysis.summary(),
})
}
/// `crash_found`:`path` 是权威字段,`excerpt` 仅供人读。
pub(crate) fn crash_found(
source: &str,
path: &Path,
excerpt: Option<&str>,
redactor: &Redactor,
) -> Value {
json!({
"source": source,
"path": path.display().to_string(),
"excerpt": excerpt.map(|text| redactor.scrub(text).into_owned()),
})
}
/// `session_end`。
pub(crate) fn session_end(reason: &str, duration_ms: u64) -> Value {
json!({ "reason": reason, "duration_ms": duration_ms })
}
+64
View File
@@ -0,0 +1,64 @@
//! 把会话事件流接到日志泵上:逐行落盘 + 自动窗口出现检测。
//!
//! 放在单独文件是为了让 [`crate::launch::session`] 保持“只写事件、只管索引”的单一职责,
//! 也避免单文件超过 400 行的硬约束。
use std::sync::Mutex;
use crate::launch::pump::{LineSink, LogLine};
use crate::launch::session::SessionWriter;
use crate::launch::window_detect::WindowDetector;
/// 会话日志 sink。
#[derive(Debug)]
pub struct SessionSink {
writer: Mutex<SessionWriter>,
detector: Mutex<WindowDetector>,
}
impl SessionSink {
/// 以进程启动时刻为窗口检测起点。
pub fn new(writer: SessionWriter) -> Self {
Self {
writer: Mutex::new(writer),
detector: Mutex::new(WindowDetector::new()),
}
}
/// 指定窗口检测计时起点(进程 spawn 时刻)。
pub fn with_start(writer: SessionWriter, started_at: std::time::Instant) -> Self {
Self {
writer: Mutex::new(writer),
detector: Mutex::new(WindowDetector::with_start(started_at)),
}
}
/// 借用写入器(退出分析、结束会话等需要可变访问的场合)。
pub fn with_writer<R>(&self, action: impl FnOnce(&mut SessionWriter) -> R) -> Option<R> {
self.writer.lock().ok().map(|mut guard| action(&mut guard))
}
/// 是否已检测到游戏窗口。
pub fn window_detected(&self) -> bool {
match self.writer.lock() {
Ok(writer) => writer.is_window_detected(),
Err(_) => false,
}
}
}
impl LineSink for SessionSink {
fn on_line(&self, line: &LogLine) {
let detection = match self.detector.lock() {
Ok(mut detector) => detector.observe(&line.text),
Err(_) => None,
};
let Ok(mut writer) = self.writer.lock() else {
return;
};
if let Some(detection) = detection {
let _ = writer.window_detected(&detection);
}
let _ = writer.line(line);
}
}
+138
View File
@@ -0,0 +1,138 @@
//! 日志 sink 组合:把同一个进程的输出同时喂给多个消费者。
//!
//! 设计依据 `DevDocs/SubAgentsTalk/launch-接口约定.md` §3(单一真相源)与 tower 批复的
//! “复合 sink”方案:会话事件流(落盘)是必需的一个 sink,M15 采集器 / UI 日志页 /
//! 测试断言各自挂自己的 sink,彼此不感知。
//!
//! 接 M15 的姿态:diagnose 不解析 jsonl、也不 import launch 类型,所以它只需要一个
//! `FnSink` 包一层 `LogBuffer::push(stream, line)`;这里只提供通用的组合原语。
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use crate::launch::pump::{LineSink, LogLine, Stream};
/// 把一条日志广播给多个 sink(顺序 = 注册顺序)。
#[derive(Default)]
pub struct CompositeSink {
sinks: Vec<Arc<dyn LineSink>>,
}
impl CompositeSink {
/// 空组合。
pub fn new() -> Self {
Self::default()
}
/// 追加一个 sink(builder 风格)。
pub fn with(mut self, sink: Arc<dyn LineSink>) -> Self {
self.sinks.push(sink);
self
}
/// 追加一个 sink。
pub fn push(&mut self, sink: Arc<dyn LineSink>) {
self.sinks.push(sink);
}
/// 已注册的 sink 数。
pub fn len(&self) -> usize {
self.sinks.len()
}
/// 是否为空。
pub fn is_empty(&self) -> bool {
self.sinks.is_empty()
}
}
impl std::fmt::Debug for CompositeSink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompositeSink")
.field("sinks", &self.sinks.len())
.finish()
}
}
impl LineSink for CompositeSink {
fn on_line(&self, line: &LogLine) {
for sink in &self.sinks {
sink.on_line(line);
}
}
}
/// 闭包 sink:把 M15 采集器、UI 通道、测试断言接进来的最轻方式。
///
/// ```ignore
/// let sink = FnSink::new(move |line: &LogLine| {
/// bundle.log.push(stream_kind(line.stream), &line.text);
/// });
/// ```
pub struct FnSink<F: Fn(&LogLine) + Send + Sync> {
action: F,
}
impl<F: Fn(&LogLine) + Send + Sync> FnSink<F> {
/// 用闭包构造。
pub fn new(action: F) -> Self {
Self { action }
}
}
impl<F: Fn(&LogLine) + Send + Sync + 'static> FnSink<F> {
/// 转成 `Arc<dyn LineSink>`(可直接塞进 [`CompositeSink`] / `RunningProcess`)。
pub fn shared(self) -> Arc<dyn LineSink> {
Arc::new(self)
}
}
impl<F: Fn(&LogLine) + Send + Sync> std::fmt::Debug for FnSink<F> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("FnSink")
}
}
impl<F: Fn(&LogLine) + Send + Sync> LineSink for FnSink<F> {
fn on_line(&self, line: &LogLine) {
(self.action)(line);
}
}
/// 计数 sink:给进度条/测试一个“已收多少行”的廉价探针。
#[derive(Debug, Default)]
pub struct CountingSink {
stdout: AtomicU64,
stderr: AtomicU64,
}
impl CountingSink {
/// 新计数器。
pub fn new() -> Self {
Self::default()
}
/// 标准输出行数。
pub fn stdout_lines(&self) -> u64 {
self.stdout.load(Ordering::Relaxed)
}
/// 标准错误行数。
pub fn stderr_lines(&self) -> u64 {
self.stderr.load(Ordering::Relaxed)
}
/// 总行数。
pub fn total_lines(&self) -> u64 {
self.stdout_lines() + self.stderr_lines()
}
}
impl LineSink for CountingSink {
fn on_line(&self, line: &LogLine) {
match line.stream {
Stream::Stdout => self.stdout.fetch_add(1, Ordering::Relaxed),
Stream::Stderr => self.stderr.fetch_add(1, Ordering::Relaxed),
};
}
}
+118
View File
@@ -0,0 +1,118 @@
//! 时间工具:RFC3339(毫秒,UTC)格式化与会话时钟。
//!
//! 会话事件流的 `ts` / `t_ms` 字段由这里产出(schema 见
//! `DevDocs/SubAgentsTalk/launch-接口约定.md` §2)。
//! 只用 `std::time`,不引入 chrono/time 依赖。
use std::time::{Instant, SystemTime, UNIX_EPOCH};
/// 把 `SystemTime` 格式化成 RFC3339 UTC 毫秒精度串,例 `2026-10-29T12:45:01.123Z`。
///
/// 早于 Unix 纪元的时间按纪元处理(不会 panic:事件流宁可有 1970 的时间戳也不能丢事件)。
pub fn format_rfc3339_millis(time: SystemTime) -> String {
let since_epoch = time.duration_since(UNIX_EPOCH).unwrap_or_default();
let secs = since_epoch.as_secs();
let millis = since_epoch.subsec_millis();
let days = i64::try_from(secs / 86_400).unwrap_or(i64::MAX);
let secs_of_day = secs % 86_400;
let (year, month, day) = civil_from_days(days);
let hour = secs_of_day / 3_600;
let minute = (secs_of_day % 3_600) / 60;
let second = secs_of_day % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z")
}
/// 当前墙上时间(便于测试替换的单一入口)。
pub fn now() -> SystemTime {
SystemTime::now()
}
/// 由“自 1970-01-01 起的天数”还原公历年月日(Howard Hinnant 的 civil_from_days)。
pub fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { year + 1 } else { year };
(
year,
u32::try_from(month).unwrap_or(1),
u32::try_from(day).unwrap_or(1),
)
}
/// 会话时钟:同时记录单调时刻(`t_ms`)与墙上时刻(`ts`)。
#[derive(Debug, Clone)]
pub struct SessionClock {
start_instant: Instant,
started_at: SystemTime,
}
impl SessionClock {
/// 以“现在”为起点创建时钟。
pub fn start_new() -> Self {
Self {
start_instant: Instant::now(),
started_at: now(),
}
}
/// 用给定的墙上时间与单调时刻创建(测试可注入)。
pub fn started_at(start_instant: Instant, started_at: SystemTime) -> Self {
Self {
start_instant,
started_at,
}
}
/// 会话开始的墙上时间。
pub fn started_at_wall(&self) -> SystemTime {
self.started_at
}
/// 会话开始时刻的 RFC3339 串。
pub fn started_at_iso(&self) -> String {
format_rfc3339_millis(self.started_at)
}
/// 距会话开始的毫秒数(`t_ms`)。
pub fn elapsed_ms(&self) -> u64 {
u64::try_from(self.start_instant.elapsed().as_millis()).unwrap_or(u64::MAX)
}
/// 当前墙上时间的 RFC3339 串(`ts`)。
pub fn now_iso(&self) -> String {
format_rfc3339_millis(now())
}
}
impl Default for SessionClock {
fn default() -> Self {
Self::start_new()
}
}
/// 生成会话 id:`YYYYMMDDTHHMMSS-<6 位十六进制>`。
///
/// 十六进制段由墙上时间与 pid 混合而来——不引入 rand 依赖,同一秒同一进程内也能区分
/// (调用方可在冲突时追加序号)。
pub fn make_session_id(wall: SystemTime, pid: u32) -> String {
let since_epoch = wall.duration_since(UNIX_EPOCH).unwrap_or_default();
let secs = since_epoch.as_secs();
let days = i64::try_from(secs / 86_400).unwrap_or(i64::MAX);
let (year, month, day) = civil_from_days(days);
let secs_of_day = secs % 86_400;
let hour = secs_of_day / 3_600;
let minute = (secs_of_day % 3_600) / 60;
let second = secs_of_day % 60;
let mut hash: u64 = u64::from(since_epoch.subsec_nanos()).wrapping_add(u64::from(pid));
hash ^= secs.rotate_left(17);
hash = hash.wrapping_mul(0x9E37_79B9_7F4A_7C15);
let suffix = (hash >> 40) & 0xFF_FFFF;
format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}-{suffix:06x}")
}
+116
View File
@@ -0,0 +1,116 @@
//! 进程树回收:`SIGTERM → 宽限 → SIGKILL` 三段式关闭。
//!
//! HMCL 只 `process.destroy()` 单进程(`analyse/02` §6.8),子进程会残留、日志尾部丢失。
//! 这里做进程**树**回收:
//!
//! - Windows:`taskkill /T /F /PID`(`/T` 递归子树)。P0 不引入 `windows-sys`,因此没有
//! Job Object——Job Object(`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)留 P1,替换点就在本文件
//! (tower 已批准,见 `launch-接口约定.md` §7-1)。
//! - POSIX:`kill -TERM -<pid>`(命不中是常态,因为我们没有 `setsid`——`unsafe` pre_exec 被
//! workspace `unsafe_code = deny` 挡住)→ 宽限 → `kill -KILL -<pid>`,再退到单 pid。
//!
//! 两个平台都先尝试“温和”,宽限期后再“强制”。
//!
//! ⚠️ 外部命令的退出码**不能**用来判断“进程是否已被结束”:温和阶段成功关掉进程后,
//! 再发 `taskkill /F` 会得到 128(“没有这个运行实例”)。所以 [`crate::launch::process`]
//! 的实际停止流程以“进程是否真的消失”为准(`try_wait` 轮询),本模块只提供发送动作。
use std::io;
use std::process::Command;
use std::thread;
use std::time::Duration;
/// 温和关闭(Windows 无 `/F`;POSIX `SIGTERM`)。
pub fn terminate(pid: u32) -> io::Result<()> {
imp::signal(pid, false)
}
/// 强制关闭(Windows `/F`;POSIX `SIGKILL`),并尽量覆盖整棵进程树。
pub fn force_kill(pid: u32) -> io::Result<()> {
imp::signal(pid, true)
}
/// 三段式:温和 → 宽限 → 强制。返回**强制阶段**的结果(温和阶段失败不致命)。
///
/// 注意:返回值不代表“进程已经死了”(见模块注释)。需要准确判定请用
/// [`crate::launch::process::RunningProcess::stop`]。
pub fn kill_tree(pid: u32, grace: Duration) -> io::Result<()> {
if pid == 0 {
return Err(io::Error::other("无效的 pid 0"));
}
let _ = terminate(pid);
if !grace.is_zero() {
thread::sleep(grace);
}
force_kill(pid)
}
#[cfg(windows)]
mod imp {
use super::*;
use std::os::windows::process::CommandExt;
/// 不弹控制台窗口(否则 kill 子进程会闪一个黑框)。
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> {
let mut command = Command::new("taskkill");
command.arg("/PID").arg(pid.to_string()).arg("/T");
if force {
command.arg("/F");
}
command.creation_flags(CREATE_NO_WINDOW);
let status = command
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()?;
if status.success() {
Ok(())
} else {
// taskkill 对已退出的进程返回非 0,这不值得中断关闭流程。
Err(io::Error::other(format!("taskkill 退出码 {status}")))
}
}
}
#[cfg(unix)]
mod imp {
use super::*;
pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> {
let flag = if force { "-KILL" } else { "-TERM" };
// 先按进程组(`-<pid>`);没有 setsid 时通常失败,再退到单 pid。
if run_kill(flag, &format!("-{pid}"))?.is_some() {
return Ok(());
}
match run_kill(flag, &pid.to_string())? {
Some(status) if status.success() => Ok(()),
Some(status) => Err(io::Error::other(format!("kill 退出码 {status}"))),
None => Err(io::Error::other("找不到 kill 命令")),
}
}
fn run_kill(flag: &str, target: &str) -> io::Result<Option<std::process::ExitStatus>> {
match Command::new("kill")
.arg(flag)
.arg(target)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
Ok(status) if status.success() => Ok(Some(status)),
Ok(_) => Ok(None),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
}
#[cfg(not(any(windows, unix)))]
mod imp {
use super::*;
pub(super) fn signal(_pid: u32, _force: bool) -> io::Result<()> {
Err(io::Error::other("当前平台不支持进程树 kill"))
}
}
+71
View File
@@ -0,0 +1,71 @@
//! 看门狗:给 pump 的 join 加超时。
//!
//! HMCL 的 `ExitWaiter` 在 `waitFor()` 之后无条件 `join` 两条泵线程
//! (`analyse/02` §6.1、§8.5):若子进程自己不关闭 stdout(例如它又拉起了不退出的
//! 子进程),`onExit` 永远不回调,UI 卡在“正在启动”。这里把“等日志落盘”改成
//! 有预算的等待——超时就带着已收集到的日志继续做退出分析,绝不无限等待。
use std::time::Duration;
use crate::launch::pump::{PumpCompletion, PumpGroup, Stream};
/// 看门狗配置。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WatchdogConfig {
/// 进程退出后等待日志泵结束的预算。
pub pump_timeout: Duration,
}
impl Default for WatchdogConfig {
fn default() -> Self {
Self {
pump_timeout: Duration::from_secs(10),
}
}
}
/// 看门狗判定。
#[derive(Debug, Clone)]
pub enum PumpVerdict {
/// 两条流都正常结束。
Complete(PumpCompletion),
/// 超时:仍有流未结束(附带是哪几条)。
Stalled {
/// 等待结果(含已完成流与行数)。
completion: PumpCompletion,
/// 卡住的流。
streams: Vec<Stream>,
/// 等待了多久。
waited: Duration,
},
}
impl PumpVerdict {
/// 是否一切正常。
pub fn is_complete(&self) -> bool {
matches!(self, Self::Complete(_))
}
/// 等待结果(两种情形都有)。
pub fn completion(&self) -> &PumpCompletion {
match self {
Self::Complete(completion) => completion,
Self::Stalled { completion, .. } => completion,
}
}
}
/// 用配置里的预算等待日志泵结束。
pub fn wait_for_pumps(group: &mut PumpGroup, config: &WatchdogConfig) -> PumpVerdict {
let completion = group.wait(config.pump_timeout);
if completion.is_complete() {
PumpVerdict::Complete(completion)
} else {
let streams = completion.stalled.clone();
PumpVerdict::Stalled {
completion,
streams,
waited: config.pump_timeout,
}
}
}
+86
View File
@@ -0,0 +1,86 @@
//! “游戏窗口是否出现”的检测(日志启发式)。
//!
//! HMCL 只看两条小写子串(`analyse/02` §6.5:`lwjgl version` / `lwjgl openal`),
//! 换日志格式就失效。v1 保持同样的启发式但做成**可扩展模式表 + 命中证据 + 耗时**,
//! 这样 `window_detected` 事件能告诉 AI“靠哪一行判定的、花了多久”;
//! 进程/窗口级信号(Windows `EnumWindows` 按 PID)留 P1(`PlanDoc/06` §1 升级点 3)。
use std::time::Instant;
/// 一次成功检测。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Detection {
/// 启发式名称(事件流 `window_detected.heuristic`)。
pub heuristic: String,
/// 从进程启动到命中的毫秒数。
pub took_ms: u64,
/// 命中的日志行(事件流 `evidence`)。
pub evidence: String,
}
/// 启发式模式表:(小写子串,启发式名称)。
pub const HEURISTICS: &[(&str, &str)] = &[
("lwjgl version", "lwjgl_version"),
("lwjgl openal", "lwjgl_openal"),
("sound engine started", "sound_engine"),
];
/// 窗口检测器。
#[derive(Debug, Clone)]
pub struct WindowDetector {
started_at: Instant,
detection: Option<Detection>,
}
impl WindowDetector {
/// 以“现在”为计时起点。
pub fn new() -> Self {
Self {
started_at: Instant::now(),
detection: None,
}
}
/// 指定计时起点(进程 spawn 时刻)。
pub fn with_start(started_at: Instant) -> Self {
Self {
started_at,
detection: None,
}
}
/// 观察一行日志;首次命中返回检测结果。
pub fn observe(&mut self, line: &str) -> Option<Detection> {
if self.detection.is_some() {
return None;
}
let lower = line.to_ascii_lowercase();
let (needle, name) = HEURISTICS
.iter()
.find(|(needle, _)| lower.contains(needle))?;
let _ = needle;
let detection = Detection {
heuristic: (*name).to_string(),
took_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
evidence: line.trim().to_string(),
};
self.detection = Some(detection.clone());
Some(detection)
}
/// 是否已经检测到窗口。
pub fn is_detected(&self) -> bool {
self.detection.is_some()
}
/// 检测结果。
pub fn detection(&self) -> Option<&Detection> {
self.detection.as_ref()
}
}
impl Default for WindowDetector {
fn default() -> Self {
Self::new()
}
}