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
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:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -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())]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: "非组装类失败",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]")
|
||||
}
|
||||
@@ -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(¤t) {
|
||||
current = Cow::Owned(mask_dotted_tokens(¤t));
|
||||
}
|
||||
if has_opaque_run(¤t) {
|
||||
current = Cow::Owned(mask_opaque(¤t));
|
||||
}
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user