feat(presenter): 多根发现(配置根 + 官方启动器标准位置),实例按自己的根解析存档/模组/启动(M274)
This commit is contained in:
1 parent
dfc0f4bcb3
commit
dc8323531b
20 files changed
+943
-121
No files matched your search
@@ -290,10 +290,13 @@ fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, ContentFail
|
||||
|
||||
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 基底档,见 `game::base_mode`)。
|
||||
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, ContentFailure> {
|
||||
let base = super::super::game::base_mode(env, &instance.id, &instance.version_id);
|
||||
// 装进**这个实例自己那个根**的运行目录(M274):官方根里的实例要把 mod 放进官方的
|
||||
// `.minecraft/mods`(或它自己的版本目录),不是 ULL 便携根。
|
||||
let game = super::super::game::game_for(env, &instance.id);
|
||||
let base = super::super::game::base_mode(env, &game, &instance.id, &instance.version_id);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| local(format!("读实例设置失败:{error}")))?;
|
||||
derive_game_dir(&env.game, &instance.version_id, base, config.as_ref(), None)
|
||||
derive_game_dir(&game, &instance.version_id, base, config.as_ref(), None)
|
||||
.map(|derivation| derivation.game_dir)
|
||||
.map_err(|error| local(format!("隔离目录派生失败:{error}")))
|
||||
}
|
||||
|
||||
@@ -286,10 +286,11 @@ pub(super) fn instance_root(env: &HostEnv, id: &InstanceId) -> Result<PathBuf, S
|
||||
.into_iter()
|
||||
.find(|instance| &instance.id == id)
|
||||
.ok_or_else(|| format!("instance not found: {id}"))?;
|
||||
let base = super::super::game::base_mode(env, &instance.id, &instance.version_id);
|
||||
let game = super::super::game::game_for(env, &instance.id);
|
||||
let base = super::super::game::base_mode(env, &game, &instance.id, &instance.version_id);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| format!("read instance config failed: {error}"))?;
|
||||
derive_game_dir(&env.game, &instance.version_id, base, config.as_ref(), None)
|
||||
derive_game_dir(&game, &instance.version_id, base, config.as_ref(), None)
|
||||
.map(|derivation| derivation.game_dir)
|
||||
.map_err(|error| format!("derive game dir failed: {error}"))
|
||||
}
|
||||
@@ -300,8 +301,9 @@ pub(super) fn snapshot_of(env: &HostEnv, id: &InstanceId) -> Option<InstanceSnap
|
||||
let instance = super::super::game::instances(env)
|
||||
.into_iter()
|
||||
.find(|instance| &instance.id == id)?;
|
||||
// 隔离实例的 `versions/` 在全局根下,所以试全局根
|
||||
read_instance_snapshot(env.game.root(), Some(&instance.version_id)).ok()
|
||||
// 隔离实例的 `versions/` 在**它自己那个根**下(M274:官方根里的版本清单不在配置根下)
|
||||
let game = super::super::game::game_for(env, &instance.id);
|
||||
read_instance_snapshot(game.root(), Some(&instance.version_id)).ok()
|
||||
}
|
||||
|
||||
/// 格式视图 → core 的静态格式(MultiMC 只读,不能当导出目标)。
|
||||
|
||||
@@ -30,10 +30,16 @@ pub struct HostEnv {
|
||||
pub data: ResolvedData,
|
||||
/// `.ULL/` 布局
|
||||
pub layout: LauncherLayout,
|
||||
/// `.minecraft/` 布局
|
||||
/// `.minecraft/` 布局(**配置的这个根**;多根发现的判据在 `game/roots.rs`)
|
||||
pub game: GameLayout,
|
||||
/// 平台
|
||||
pub platform: Platform,
|
||||
/// 官方启动器的标准游戏目录(`paths::standard_game_home`):多根发现的**第二个候选**。
|
||||
///
|
||||
/// `None` = 不去发现([`HostEnv::at_root`] 钉根时就是这样:截图/演示/用例钉了一个根,
|
||||
/// 就不该顺手把用户真实的 `%APPDATA%\.minecraft` 也读进来,否则同一份用例在不同机器上
|
||||
/// 结果不同)。生产路径由 [`HostEnv::from_data`] 填。
|
||||
pub standard_game: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl HostEnv {
|
||||
@@ -56,12 +62,15 @@ impl HostEnv {
|
||||
let root = root.as_ref();
|
||||
let request = request_from_portable(root);
|
||||
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
|
||||
Self::from_data(ResolvedData {
|
||||
let mut env = Self::from_data(ResolvedData {
|
||||
mode: DataMode::Portable,
|
||||
launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")),
|
||||
game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")),
|
||||
redirected_from: None,
|
||||
})
|
||||
});
|
||||
// 钉一个根的通道(截图 / 演示 / 用例)**不做多根发现**:见 `standard_game` 的文档。
|
||||
env.standard_game = None;
|
||||
env
|
||||
}
|
||||
|
||||
/// 由解析结果建环境。
|
||||
@@ -74,10 +83,17 @@ impl HostEnv {
|
||||
pub fn from_data(data: ResolvedData) -> Self {
|
||||
let layout = data.layout();
|
||||
unlucky_core::launch::launcher_log::set_dir(layout.logs());
|
||||
let platform = Platform::current();
|
||||
Self {
|
||||
layout,
|
||||
game: GameLayout::new(data.game_home.clone()),
|
||||
platform: Platform::current(),
|
||||
platform,
|
||||
// M274:多根发现的第二个候选永远是**官方启动器**的标准位置(HMCL / PCL 用的是
|
||||
// 同一个地方),与配置的 `game_home` 是不是同一个由 `roots::all` 的去重判据管。
|
||||
standard_game: unlucky_core::platform::paths::standard_game_home(
|
||||
platform,
|
||||
&unlucky_core::platform::paths::ProcessEnv,
|
||||
),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
//! 隔离档位取自实例设置(缺省跟全局 `instances.mode`)。
|
||||
//!
|
||||
//! **列表是并集**(M273):档案 + 盘上有清单但没有档案绑定的版本目录,见 [`instances`] 的文档。
|
||||
//!
|
||||
//! **根是多个**(M274):配置的 `game_home` + 官方启动器的标准位置(`%APPDATA%\.minecraft` 等),
|
||||
//! 规范化真实路径去重([`roots`])。每个自动发现的实例因此带**自己的根**——读版本清单、派生运行
|
||||
//! 目录、列存档 / 模组都走 [`game_for`],不全是 `env.game`。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
@@ -14,7 +18,7 @@ use unlucky_core::game::install::instance::{
|
||||
read_instance_config, read_profiles, write_instance_config, write_profiles,
|
||||
};
|
||||
use unlucky_core::game::isolation::IsolationMode;
|
||||
use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList, VersionJson};
|
||||
use unlucky_core::game::{GameLayout, InstanceProfile, ProfileId, ProfileList, VersionJson};
|
||||
|
||||
use crate::dto::{
|
||||
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView,
|
||||
@@ -23,10 +27,11 @@ use crate::dto::{
|
||||
use super::env::HostEnv;
|
||||
|
||||
mod discovered;
|
||||
mod roots;
|
||||
|
||||
pub(super) use discovered::base_mode;
|
||||
pub(super) use discovered::{base_mode, game_for};
|
||||
|
||||
use discovered::{external_summary, external_version_of, external_versions};
|
||||
use discovered::{display_name, external_summary, external_version_of, external_versions};
|
||||
|
||||
/// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。
|
||||
pub fn global_mode(env: &HostEnv) -> IsolationMode {
|
||||
@@ -59,13 +64,15 @@ pub fn last_played(env: &HostEnv) -> BTreeMap<String, String> {
|
||||
}
|
||||
|
||||
/// 版本 JSON 的继承链根 id + 加载器(读不到返回 `(version_id, 原版)`)。
|
||||
pub fn describe_version(env: &HostEnv, version_id: &str) -> (String, LoaderInfo) {
|
||||
///
|
||||
/// **按传入的那个根读**(M274):外部实例的版本清单在自己那个根下,拿配置根去读会读空。
|
||||
pub fn describe_version(game: &GameLayout, version_id: &str) -> (String, LoaderInfo) {
|
||||
let loader = loader_from_id(version_id);
|
||||
let mut current = version_id.to_string();
|
||||
let mut seen = 0;
|
||||
while seen < 16 {
|
||||
seen += 1;
|
||||
let path = env.game.version_json(¤t);
|
||||
let path = game.version_json(¤t);
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
break;
|
||||
};
|
||||
@@ -115,15 +122,17 @@ fn dto_kind(kind: unlucky_core::game::LoaderKind) -> LoaderKind {
|
||||
}
|
||||
|
||||
/// 实例的隔离档位(实例文件优先,缺省 = [`base_mode`])。
|
||||
pub fn isolation_of(env: &HostEnv, id: &str, version_id: &str) -> String {
|
||||
let base = base_mode(env, id, version_id);
|
||||
///
|
||||
/// `game` 是这个实例**自己那个根**的布局(多根之后同一个版本 id 在两个根里可以有不同基底档)。
|
||||
pub fn isolation_of(env: &HostEnv, game: &GameLayout, id: &str, version_id: &str) -> String {
|
||||
let base = base_mode(env, game, id, version_id);
|
||||
match read_instance_config(&env.layout, id) {
|
||||
Ok(Some(config)) => config.effective_isolation(base).as_str().to_string(),
|
||||
_ => base.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例快照:**档案 ∪ 盘上的版本目录**。
|
||||
/// 实例快照:**档案 ∪ 盘上的版本目录**(M273),版本目录来自**多个游戏根**(M274)。
|
||||
///
|
||||
/// ## 为什么是并集(M273)
|
||||
///
|
||||
@@ -132,20 +141,25 @@ pub fn isolation_of(env: &HostEnv, id: &str, version_id: &str) -> String {
|
||||
/// 存在 `versions/<id>/<id>.json`,**没有**我们的档案记录。只读档案 ⇒ 已装好的实例在界面上
|
||||
/// 不存在,用户以为「读不到这个 .minecraft」。
|
||||
///
|
||||
/// 所以:档案照旧(名字 / 隔离档 / 启动参数都以档案为准),**盘上有清单但没有档案绑定的版本**
|
||||
/// 就地合成一条视图挂上去。判据不在这里另写一套:扫描走
|
||||
/// [`unlucky_core::game::adopt::scan`](`versions/<id>/<id>.json` 能解出 `id`、继承链走到底、
|
||||
/// 符号链接不越界、坏目录逐条报问题),本文件只做 DTO 翻译 + 「有没有档案绑定」这一步。
|
||||
/// ## 为什么是多个根(M274)
|
||||
///
|
||||
/// 官方启动器用的是 `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `<exe>/.minecraft`——**两个
|
||||
/// 不同的目录**。只扫配置根等于「装了官方启动器也看不见它的实例」。所以这里遍历
|
||||
/// `roots::all(env)`:配置根 + 官方启动器的标准位置(规范化真实路径去重,见 `roots::key_of`)。
|
||||
///
|
||||
/// 那一批根里**只有配置根**有档案语义:`profiles.json` 的 `version_id` 指的是配置根下的版本,
|
||||
/// 所以「已被档案绑定 ⇒ 不重复列」这条只对配置根成立;别的根里的同名版本是**另一个实例**
|
||||
/// (id 里带着根,见 `discovered::external_instance_id`)。
|
||||
///
|
||||
/// ## 三条不变量
|
||||
///
|
||||
/// 1. **只读**:合成视图不建目录、不写 `profiles.json`、不碰 `versions/` 里一个字节
|
||||
/// 1. **只读**:合成视图不建目录、不写 `profiles.json`、不碰任何 `versions/` 里一个字节
|
||||
/// (`instances()` 会被每次刷新调用,写盘会变成「一打开启动器就改别人的文件夹」);
|
||||
/// 2. **档案优先**:某个版本已经有档案绑定(不论档案 id 是什么)⇒ 只按档案出**一条**,
|
||||
/// 2. **档案优先**:配置根里某个版本已经有档案绑定(不论档案 id 是什么)⇒ 只按档案出**一条**,
|
||||
/// 不重复挂合成视图(用户改过的名字/隔离档不会因为盘上有同名版本被顶掉);
|
||||
/// 3. **坏东西不当实例**:清单缺 / JSON 坏 / `id` 与目录名不一致 / 缺父版本 / 越界符号链接
|
||||
/// ⇒ 不进列表(`AdoptCandidate::adoptable()`);`versions/` 整个读不出来也只是退回
|
||||
/// 只有档案的旧行为,不让实例页因此报错。
|
||||
/// ⇒ 不进列表(`AdoptCandidate::adoptable()`);某个根整个读不出来也只是少列它那几条,
|
||||
/// 不让实例页因此报错。
|
||||
pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
|
||||
let profiles = profiles(env);
|
||||
let played = last_played(env);
|
||||
@@ -154,14 +168,50 @@ pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
|
||||
.iter()
|
||||
.map(|profile| profile_summary(env, profile, &played))
|
||||
.collect();
|
||||
snapshot.extend(
|
||||
external_versions(env, &profiles)
|
||||
.iter()
|
||||
.map(|version_id| external_summary(env, version_id, &played)),
|
||||
);
|
||||
// 两件「每份档案只算一次」的事(反查要扫盘,放进版本循环里就是 O(档案×版本) 次扫描):
|
||||
// ① 哪些版本被**属于配置根的**档案绑定了 ⇒ 配置根不再为它合成一条;
|
||||
// ② 现有的档案 id ⇒ 同一个 id 不再合成第二条视图。
|
||||
let bound: Vec<&str> = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.filter(|profile| profile_belongs_here(env, profile))
|
||||
.map(|profile| profile.version_id.as_str())
|
||||
.collect();
|
||||
let known: Vec<&str> = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|profile| profile.id.as_str())
|
||||
.collect();
|
||||
for root in roots::all(env) {
|
||||
let configured = root.configured();
|
||||
for version_id in external_versions(env, root.game()) {
|
||||
let id = discovered::external_instance_id(root.key(), &version_id);
|
||||
if known.contains(&id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if configured && bound.contains(&version_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
snapshot.push(external_summary(env, &root, &version_id, &played));
|
||||
}
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// 这条档案代表的是**配置根**里的版本吗(决定它是否「占住」配置根的同名版本)。
|
||||
///
|
||||
/// 有档案就按档案出——但多根之后要分清两种 `ext-…` 档案:
|
||||
/// - 根 B 里的实例被懒创建成档案 ⇒ 它占的是**根 B** 的那个版本,不许连带把配置根里的同名版本
|
||||
/// 也一并抹掉(那是另一个实例,有自己的存档);
|
||||
/// - 反查不到的 id(M273 之前合成的、或那一批根已经不在盘上)⇒ 按旧口径算配置根的,
|
||||
/// 「档案优先」这条因此不会因为升级而失效。
|
||||
fn profile_belongs_here(env: &HostEnv, profile: &InstanceProfile) -> bool {
|
||||
match discovered::locate(env, profile.id.as_str()) {
|
||||
Some((root, _)) => root.configured(),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条档案 → 磁贴(档案路径的唯一翻译点)。
|
||||
fn profile_summary(
|
||||
env: &HostEnv,
|
||||
@@ -169,7 +219,9 @@ fn profile_summary(
|
||||
played: &BTreeMap<String, String>,
|
||||
) -> InstanceSummary {
|
||||
let version_id = profile.version_id.clone();
|
||||
let (mc_version, loader) = describe_version(env, &version_id);
|
||||
// 档案实例属于**配置根**(`profiles.json` 里的 `version_id` 指的就是它)。
|
||||
let game = &env.game;
|
||||
let (mc_version, loader) = describe_version(game, &version_id);
|
||||
let id = profile.id.as_str();
|
||||
InstanceSummary {
|
||||
id: id.to_string(),
|
||||
@@ -179,9 +231,9 @@ fn profile_summary(
|
||||
last_played: played.get(id).cloned(),
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: isolation_of(env, id, &version_id),
|
||||
version_present: env.game.is_version_installed(&version_id),
|
||||
launch: super::launch_args::view(env, id, &version_id),
|
||||
isolation: isolation_of(env, game, id, &version_id),
|
||||
version_present: game.is_version_installed(&version_id),
|
||||
launch: super::launch_args::view(env, game, id, &version_id),
|
||||
version_id,
|
||||
}
|
||||
}
|
||||
@@ -206,12 +258,13 @@ pub(super) fn ensure_profile(env: &HostEnv, id: &str) -> Result<InstanceProfile,
|
||||
{
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
let Some(version_id) = external_version_of(env, &list, id) else {
|
||||
let Some((root, version_id)) = external_version_of(env, &list, id) else {
|
||||
return Err(format!("找不到实例 `{id}`"));
|
||||
};
|
||||
let profile = InstanceProfile {
|
||||
id: ProfileId::new(id),
|
||||
name: version_id.clone(),
|
||||
// 显示名与磁贴同源:别的根里的同名版本带上根路径,否则两条磁贴分不清(M274)。
|
||||
name: display_name(&root, &version_id),
|
||||
version_id,
|
||||
..InstanceProfile::default()
|
||||
};
|
||||
@@ -284,3 +337,6 @@ pub fn short_path(path: &Path) -> String {
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_union;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_multiroot;
|
||||
@@ -1,22 +1,34 @@
|
||||
//! 自动发现的实例(M273):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
|
||||
//! 自动发现的实例(M273 · 多根 M274):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
|
||||
//!
|
||||
//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件的两件事:
|
||||
//!
|
||||
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析、没有被任何档案绑定
|
||||
//! (判据来自 `core::game::adopt::scan`,本文件只做「有没有档案」这一步);
|
||||
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析(判据来自 `core::game::adopt::scan`,
|
||||
//! 本文件只做「哪个根」与「有没有档案」这两步);
|
||||
//! 2. **它们从哪个目录跑**:[`base_mode`]——`versions/<id>/` 里有痕迹(`mods` / `saves` /
|
||||
//! `config` / `options.txt`)就按独立目录,否则按共用目录(游戏根)。
|
||||
//! `config` / `options.txt`)就按独立目录,否则按共用目录(**它自己那个根**)。
|
||||
//!
|
||||
//! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节。
|
||||
//! # 多根(M274):身份 = (根, 版本)
|
||||
//!
|
||||
//! 两个根里都可能有 `1.20.4`,所以版本 id **不再唯一**。身份的落点只有一个:
|
||||
//! [`external_instance_id`](把根的规范化路径喂进哈希)。于是「同一个根 + 同一个版本目录」跨重启
|
||||
//! 拿到同一个 id,而两个根里的同名版本拿到两个不同 id——列表自然出两条。
|
||||
//!
|
||||
//! 反查(id → 根 + 版本)只能靠**重算**:哈希不可逆,所以 [`locate`] 枚举全部根、每个根扫一遍
|
||||
//! `versions/`,逐个比 id。因此它只在「用户动了一次某个实例」时被调用(存档 / 模组 / 启动 / 改设置),
|
||||
//! 不在 `instances()` 的循环里。
|
||||
//!
|
||||
//! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节,
|
||||
//! 更不往官方启动器的根里写任何东西。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unlucky_core::game::isolation::IsolationMode;
|
||||
use unlucky_core::game::ProfileList;
|
||||
use unlucky_core::game::{GameLayout, ProfileList};
|
||||
|
||||
use crate::dto::{InstanceRuntime, InstanceSummary};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::roots::{self, GameRoot};
|
||||
use super::{describe_version, global_mode, isolation_of};
|
||||
|
||||
/// `versions/<version_id>/` 里这些名字出现任何一个,就说明这个版本本来就被当成
|
||||
@@ -27,8 +39,10 @@ use super::{describe_version, global_mode, isolation_of};
|
||||
const RUN_DIR_TRACES: [&str; 4] = ["mods", "saves", "config", "options.txt"];
|
||||
|
||||
/// 版本目录里有没有「它自己用过」的痕迹(只读)。
|
||||
fn has_own_run_dir(env: &HostEnv, version_id: &str) -> bool {
|
||||
let dir = env.game.version_dir(version_id);
|
||||
///
|
||||
/// **必须传这个实例自己的根**:同一个版本 id 在两个根里的痕迹可以不同(官方根里跑过、ULL 根里没有)。
|
||||
fn has_own_run_dir(game: &GameLayout, version_id: &str) -> bool {
|
||||
let dir = game.version_dir(version_id);
|
||||
RUN_DIR_TRACES.iter().any(|name| dir.join(name).exists())
|
||||
}
|
||||
|
||||
@@ -43,38 +57,42 @@ fn has_own_run_dir(env: &HostEnv, version_id: &str) -> bool {
|
||||
/// `versions/<id>/`。一个从没跑过、也没有 `mods`/`saves` 的外部版本若按 `isolated` 处理,
|
||||
/// 第一次启动就会在**别人的版本目录**里新长出 `mods/` `saves/` `config/`(D20 的
|
||||
/// `AdoptTarget` 缺省值正是为了躲开这件事而选 `InstanceDir`)。反过来,盘上已经有这些痕迹的
|
||||
/// 版本本来就是独立目录实例,按 `shared` 处理会让它突然读到 `.minecraft` 根下别人的存档 ——
|
||||
/// 版本本来就是独立目录实例,按 `shared` 处理会让它突然读到根下别人的存档 ——
|
||||
/// 两种错都真实存在,所以判据只能是**盘上的事实**。
|
||||
///
|
||||
/// **M274**:痕迹按**这个实例自己的根**判(`game` 参数)。换根之后判定不变:官方根里那个
|
||||
/// 带 `mods/` 的版本仍是 `isolated`,ULL 根里那个干净的版本仍是 `shared`。
|
||||
///
|
||||
/// 只读:这里不建目录、不写任何文件(写盘只发生在用户第一次改设置时,见 [`super::ensure_profile`])。
|
||||
pub(in crate::host) fn base_mode(env: &HostEnv, id: &str, version_id: &str) -> IsolationMode {
|
||||
pub(in crate::host) fn base_mode(
|
||||
env: &HostEnv,
|
||||
game: &GameLayout,
|
||||
id: &str,
|
||||
version_id: &str,
|
||||
) -> IsolationMode {
|
||||
if !crate::dto::is_discovered_instance(id) {
|
||||
return global_mode(env);
|
||||
}
|
||||
if has_own_run_dir(env, version_id) {
|
||||
if has_own_run_dir(game, version_id) {
|
||||
IsolationMode::Isolated
|
||||
} else {
|
||||
IsolationMode::Shared
|
||||
}
|
||||
}
|
||||
|
||||
/// 盘上有版本、但没有档案绑定的那些版本 id(字典序,来自 core 的扫描)。
|
||||
/// 一个根下「盘上有版本、可登记」的那些版本 id(字典序,来自 core 的扫描)。
|
||||
///
|
||||
/// 扫描失败(`versions/` 读不出来 / 断链联接)时返回空:列表退回「只有档案」的旧行为。
|
||||
/// 这里**不**把错误往上抛——实例页打不开是比「少列几个外部实例」严重得多的故障。
|
||||
pub(super) fn external_versions(env: &HostEnv, profiles: &ProfileList) -> Vec<String> {
|
||||
let Ok(scan) = unlucky_core::game::adopt::scan(&env.game, &env.layout) else {
|
||||
///
|
||||
/// 档案绑定(`profile.version_id`)**不在这一层过滤**:那只对配置根有意义,由 [`super::instances`] 做。
|
||||
pub(in crate::host) fn external_versions(env: &HostEnv, game: &GameLayout) -> Vec<String> {
|
||||
let Ok(scan) = unlucky_core::game::adopt::scan(game, &env.layout) else {
|
||||
return Vec::new();
|
||||
};
|
||||
scan.candidates
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.adoptable())
|
||||
.filter(|candidate| {
|
||||
!profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.any(|profile| profile.version_id == candidate.version_id)
|
||||
})
|
||||
.map(|candidate| candidate.version_id)
|
||||
.collect()
|
||||
}
|
||||
@@ -86,34 +104,53 @@ pub(super) fn external_versions(env: &HostEnv, profiles: &ProfileList) -> Vec<St
|
||||
/// 这个白名单校验),而版本目录名可以是中文——用户实测的那个就是 `樱落怡然客户端`。
|
||||
/// 直接沿用版本 id 会让**启动**在写盘前就死在 `LaunchFailureStage::InstanceConfig`。
|
||||
///
|
||||
/// 前缀 + 稳定哈希 ⇒ 同一个版本每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。
|
||||
/// 前缀 + 稳定哈希 ⇒ 同一个 (根, 版本) 每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。
|
||||
///
|
||||
/// **M274(多根发现)**:两个根里都可能有 `1.20.4`,所以这里是**唯一**该把「根」并进身份的
|
||||
/// 地方——到时把 `root` 也喂进哈希(`external_instance_id(root, version_id)`),
|
||||
/// 列表就自然出两条,不必动 [`instances`] 的其余部分。
|
||||
pub(super) fn external_instance_id(version_id: &str) -> String {
|
||||
/// **M274**:`root_key` 也进哈希(`fnv(root || 0 || version)`)——两个根里的 `1.20.4` 因此是两个
|
||||
/// 实例。分隔符用 `0`:路径里不可能有 NUL,所以 `("ab","c")` 与 `("a","bc")` 不会撞。
|
||||
pub(in crate::host) fn external_instance_id(root_key: &str, version_id: &str) -> String {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in version_id.as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
let bytes = root_key
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(std::iter::once(0))
|
||||
.chain(version_id.as_bytes().iter().copied());
|
||||
for byte in bytes {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("{}{hash:016x}", crate::dto::DISCOVERED_ID_PREFIX)
|
||||
}
|
||||
|
||||
/// 一条外部版本 → 磁贴(显示名 = 版本目录名;**只读**,不落任何文件)。
|
||||
pub(super) fn external_summary(
|
||||
/// 磁贴的显示名:配置根里的版本就是版本目录名;**别的根**里的版本带上根路径。
|
||||
///
|
||||
/// 为什么非带不可:两个根里都可能有一个叫 `A` 的版本,列表上两条同名磁贴,用户无从分辨点哪一条
|
||||
/// (而两条的存档在不同的目录里)。路径是**事实**,直接摆出来;只对配置根保持原样是为了不让
|
||||
/// 绝大多数用户看到一堆长路径。
|
||||
pub(in crate::host) fn display_name(root: &GameRoot, version_id: &str) -> String {
|
||||
if root.configured() {
|
||||
return version_id.to_string();
|
||||
}
|
||||
format!("{version_id} · {}", root.path().display())
|
||||
}
|
||||
|
||||
/// 一条外部版本 → 磁贴(**只读**,不落任何文件)。
|
||||
pub(in crate::host) fn external_summary(
|
||||
env: &HostEnv,
|
||||
root: &GameRoot,
|
||||
version_id: &str,
|
||||
played: &BTreeMap<String, String>,
|
||||
) -> InstanceSummary {
|
||||
let id = external_instance_id(version_id);
|
||||
let (mc_version, loader) = describe_version(env, version_id);
|
||||
let id = external_instance_id(root.key(), version_id);
|
||||
let game = root.game();
|
||||
let (mc_version, loader) = describe_version(game, version_id);
|
||||
InstanceSummary {
|
||||
last_played: played.get(&id).cloned(),
|
||||
isolation: isolation_of(env, &id, version_id),
|
||||
launch: super::super::launch_args::view(env, &id, version_id),
|
||||
version_present: env.game.is_version_installed(version_id),
|
||||
name: version_id.to_string(),
|
||||
isolation: isolation_of(env, game, &id, version_id),
|
||||
launch: super::super::launch_args::view(env, game, &id, version_id),
|
||||
version_present: game.is_version_installed(version_id),
|
||||
name: display_name(root, version_id),
|
||||
version_id: version_id.to_string(),
|
||||
mc_version,
|
||||
loader,
|
||||
@@ -123,16 +160,56 @@ pub(super) fn external_summary(
|
||||
}
|
||||
}
|
||||
|
||||
/// 视图 id → 版本 id 的反查(只认「可登记、且还没有档案绑定」的版本)。
|
||||
/// 视图 id → (所属根, 版本 id):**唯一的反查点**(哈希不可逆,只能重算比对)。
|
||||
///
|
||||
/// 与 [`external_versions`] 同一份判据:合成的视图 id 只在那个集合里有意义,
|
||||
/// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批。
|
||||
pub(super) fn external_version_of(
|
||||
/// 与 [`external_versions`] 同一份判据(可登记 ⇒ 会出现在列表里),但**不**看档案绑定:
|
||||
/// 一个自动发现的实例被懒创建成档案之后,id 仍然是 `ext-…`,而它的根必须还认得出来
|
||||
/// (否则存档 / 模组 / 启动会退回配置根,那正是 M274 要修的错)。
|
||||
///
|
||||
/// 不是 `ext-` 前缀的 id 直接返回 `None`(档案实例属于配置根,不需要反查)。
|
||||
#[must_use]
|
||||
pub(in crate::host) fn locate(env: &HostEnv, id: &str) -> Option<(GameRoot, String)> {
|
||||
if !crate::dto::is_discovered_instance(id) {
|
||||
return None;
|
||||
}
|
||||
for root in roots::all(env) {
|
||||
for version_id in external_versions(env, root.game()) {
|
||||
if external_instance_id(root.key(), &version_id) == id {
|
||||
return Some((root, version_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 实例所属的那个根的布局(读盘路径的唯一入口)。
|
||||
///
|
||||
/// 档案实例、以及「反查不到」的 id 一律退回配置根 `env.game`——**没有第二个答案**:
|
||||
/// 宁可读配置根(旧行为),也不把实例指向一个猜出来的目录。
|
||||
pub(in crate::host) fn game_for(env: &HostEnv, id: &str) -> GameLayout {
|
||||
locate(env, id)
|
||||
.map(|(root, _)| root.game().clone())
|
||||
.unwrap_or_else(|| env.game.clone())
|
||||
}
|
||||
|
||||
/// 视图 id → 版本 id 的反查(只认「可登记、且**还没有档案绑定**」的版本)。
|
||||
///
|
||||
/// 与 `instances()` 同一份判据:合成的视图 id 只在那个集合里有意义,于是「界面上出现过的外部实例」
|
||||
/// 与「能懒创建的外部实例」永远是同一批(有了档案就走档案,不再重复列一条)。
|
||||
#[must_use]
|
||||
pub(in crate::host) fn external_version_of(
|
||||
env: &HostEnv,
|
||||
profiles: &ProfileList,
|
||||
id: &str,
|
||||
) -> Option<String> {
|
||||
external_versions(env, profiles)
|
||||
.into_iter()
|
||||
.find(|version_id| external_instance_id(version_id) == id)
|
||||
) -> Option<(GameRoot, String)> {
|
||||
let (root, version_id) = locate(env, id)?;
|
||||
let bound = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.any(|profile| profile.version_id == version_id);
|
||||
// 档案绑定只对**配置根**有意义(别处的同名版本是另一个实例,同一个 id 也不会撞)。
|
||||
if root.configured() && bound {
|
||||
return None;
|
||||
}
|
||||
Some((root, version_id))
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! 游戏根的发现与去重(M274):**当前配置的根** + **官方启动器的标准位置**。
|
||||
//!
|
||||
//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是
|
||||
//! `%APPDATA%\.minecraft` / `~/Library/Application Support/minecraft` / `~/.minecraft`,
|
||||
//! 而便携模式下的 ULL 用的是 `<exe>/.minecraft`——**两个根,不是一回事**。所以列表要能同时读两边。
|
||||
//!
|
||||
//! # 只有一件事不是显然的:什么算「同一个根」
|
||||
//!
|
||||
//! 同一个目录可以有四种写法:大小写不同、`\` 与 `/` 混用、8.3 短名(`PROGRA~1`)、
|
||||
//! 符号链接 / 目录联接(Windows 的 junction)。按字面量比较会让**同一批实例列两遍**,
|
||||
//! 所以判据落在 [`key_of`] 的**规范化真实路径**上:
|
||||
//!
|
||||
//! 1. `canonicalize`(Windows 上是 `GetFinalPathNameByHandle`:短名展开成长名,联接解析到目标);
|
||||
//! 2. `\` → `/`;
|
||||
//! 3. **去掉 `\\?\` 前缀**——Windows 的 `canonicalize` 一律返回 verbatim 形式(`\\?\C:\…`),
|
||||
//! 不去掉的话「同一路径的两种写法」永远不相等(这是个已知坑,用例
|
||||
//! `junction_to_the_same_folder_is_one_root` 钉住);
|
||||
//! 4. Windows / macOS 折成小写(大小写不敏感的文件系统)。
|
||||
//!
|
||||
//! `canonicalize` 失败(根还不存在)时退化成「字面量归一」:`\`→`/`、去尾斜杠、折小写。
|
||||
//! 尚不存在的目录没有短名/链接可解析,这一步够用。
|
||||
//!
|
||||
//! # 只读
|
||||
//!
|
||||
//! 本模块只 `is_dir()` / `canonicalize()`:不建目录、不写一个字节。官方启动器那个根上尤其要紧
|
||||
//! ——那是**别人的**目录。
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::game::GameLayout;
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
|
||||
/// 一个被发现的游戏根。
|
||||
///
|
||||
/// `configured` 那一格区分「用户在设置里指定的根」与「官方启动器的标准位置」:档案绑定、
|
||||
/// 新建/安装版本都只对前者有意义(我们**从不往官方根写东西**,见 M274 的只读纪律)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(in crate::host) struct GameRoot {
|
||||
key: String,
|
||||
game: GameLayout,
|
||||
configured: bool,
|
||||
}
|
||||
|
||||
impl GameRoot {
|
||||
/// 这个根的布局(`versions/` / `libraries/` / `assets/` 都在它下面)。
|
||||
pub(in crate::host) fn game(&self) -> &GameLayout {
|
||||
&self.game
|
||||
}
|
||||
|
||||
/// 规范化后的比较键(实例 id 的哈希输入之一,见 [`super::discovered::external_instance_id`])。
|
||||
pub(in crate::host) fn key(&self) -> &str {
|
||||
&self.key
|
||||
}
|
||||
|
||||
/// 是不是**当前配置**的那个根(`env.game`)。
|
||||
pub(in crate::host) fn configured(&self) -> bool {
|
||||
self.configured
|
||||
}
|
||||
|
||||
/// 根目录本身(显示用)。
|
||||
pub(in crate::host) fn path(&self) -> &Path {
|
||||
self.game.root()
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部游戏根:**配置根永远第一位**,官方启动器的标准位置(存在、且不是同一个物理目录)第二位。
|
||||
///
|
||||
/// 官方位置不存在 ⇒ 不列。「没装官方启动器」不该在列表上留一个空根——那是「这里本来就没有」,
|
||||
/// 与「有个空目录」是两件事。
|
||||
pub(in crate::host) fn all(env: &HostEnv) -> Vec<GameRoot> {
|
||||
let mut roots = vec![GameRoot {
|
||||
key: key_of(env.game.root()),
|
||||
game: env.game.clone(),
|
||||
configured: true,
|
||||
}];
|
||||
let mut seen: BTreeSet<String> = roots.iter().map(|root| root.key.clone()).collect();
|
||||
for extra in env.standard_game.iter() {
|
||||
if !extra.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let key = key_of(extra);
|
||||
// 同一个物理目录(符号链接 / 目录联接 / 大小写 / 短名)⇒ 只留配置根那一条。
|
||||
if !seen.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
roots.push(GameRoot {
|
||||
key,
|
||||
game: GameLayout::new(extra.clone()),
|
||||
configured: false,
|
||||
});
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// 路径 → **比较键**(判据的完整理由见模块文档)。
|
||||
///
|
||||
/// 同一个物理目录的所有写法必须给同一个键;不同的目录必须给不同的键(大小写不敏感的平台除外,
|
||||
/// 那里它们本来就是同一个目录)。
|
||||
pub(in crate::host) fn key_of(path: &Path) -> String {
|
||||
let resolved: PathBuf = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let text = resolved.to_string_lossy().replace('\\', "/");
|
||||
// verbatim 前缀(`\\?\C:\…` 在上一步已经变成 `//?/C:/…`):不去掉就与普通写法永不相等。
|
||||
let text = text
|
||||
.strip_prefix("//?/")
|
||||
.or_else(|| text.strip_prefix("//./"))
|
||||
.unwrap_or(text.as_str());
|
||||
let text = text.trim_end_matches('/');
|
||||
let text = if text.is_empty() { "/" } else { text };
|
||||
if cfg!(any(windows, target_os = "macos")) {
|
||||
text.to_lowercase()
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_root(tag: &str) -> PathBuf {
|
||||
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).expect("建临时目录");
|
||||
path
|
||||
}
|
||||
|
||||
/// 斜杠方向 / 尾斜杠 / 大小写不影响比较键(`canonicalize` 之后三者本来就该相等)。
|
||||
#[test]
|
||||
fn key_ignores_separators_case_and_trailing_slash() {
|
||||
let root = temp_root("ull-roots-key");
|
||||
let nested = root.join("Minecraft/.minecraft");
|
||||
std::fs::create_dir_all(&nested).expect("建目录");
|
||||
let plain = key_of(&nested);
|
||||
assert_eq!(plain, key_of(&PathBuf::from(format!("{}/", plain))));
|
||||
let mixed = PathBuf::from(plain.replace('/', "\\"));
|
||||
assert_eq!(plain, key_of(&mixed), "反斜杠写法必须是同一个根");
|
||||
assert!(
|
||||
!plain.starts_with("//?/"),
|
||||
"verbatim 前缀没去掉,两种写法永远不相等:{plain}"
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// **去重判据**:`root_b` 是指向 `root_a` 的目录联接(junction)⇒ 两个写法是同一个根。
|
||||
///
|
||||
/// 这一条是整个多根发现最容易假绿的地方:只比字面量时它必然「通过」两条,而用户看到同一批
|
||||
/// 实例列了两遍。Windows 上用 `mklink /J`(不需要管理员),其它平台用符号链接。
|
||||
#[test]
|
||||
fn junction_to_the_same_folder_is_one_root() {
|
||||
let root = temp_root("ull-roots-junction");
|
||||
let target = root.join("official/.minecraft");
|
||||
std::fs::create_dir_all(target.join("versions")).expect("建目录");
|
||||
let link = root.join("linked");
|
||||
if !make_dir_link(&link, &target) {
|
||||
// 造不出链接就**不假绿**:这一条判据在没链接的盘上等于没验,如实标出来。
|
||||
eprintln!("跳过:这个环境造不出目录链接({})", link.display());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
key_of(&target),
|
||||
key_of(&link),
|
||||
"联接与目标是同一个根:{} vs {}",
|
||||
key_of(&target),
|
||||
key_of(&link)
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 造一个目录链接:Windows 用 junction(无管理员权限也能建),Unix 用符号链接。
|
||||
#[cfg(windows)]
|
||||
fn make_dir_link(link: &Path, target: &Path) -> bool {
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/c", "mklink", "/J"])
|
||||
.arg(link)
|
||||
.arg(target)
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn make_dir_link(link: &Path, target: &Path) -> bool {
|
||||
std::os::unix::fs::symlink(target, link).is_ok()
|
||||
}
|
||||
}
|
||||
@@ -190,9 +190,9 @@ fn isolation_write_then_read_round_trips() {
|
||||
let id = profiles.profiles[0].id.as_str().to_string();
|
||||
|
||||
// 有档案的实例:基底档 = 全局档(`isolated`),与 M273 之前一字不差。
|
||||
assert_eq!(isolation_of(&env, &id, "1.20.1"), "isolated");
|
||||
assert_eq!(isolation_of(&env, &env.game, &id, "1.20.1"), "isolated");
|
||||
set_isolation(&env, &id, "shared").expect("切档位");
|
||||
assert_eq!(isolation_of(&env, &id, "1.20.1"), "shared");
|
||||
assert_eq!(isolation_of(&env, &env.game, &id, "1.20.1"), "shared");
|
||||
assert!(set_isolation(&env, &id, "nope").is_err());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
//! M274:**多根发现**(配置根 + 官方启动器的标准位置)的判据用例。
|
||||
//!
|
||||
//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是
|
||||
//! `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `<exe>/.minecraft`——两个不同的目录。
|
||||
//!
|
||||
//! **本机没有装官方启动器**(`paths::standard_game_home()` 解析出的路径不存在),所以这一组的
|
||||
//! 每个根都是**临时目录里造出来的夹具**:`<root>/.minecraft/versions/A/A.json`。这不是缺陷,
|
||||
//! 是环境事实;「真的去读 `%APPDATA%`」那一步**未验**(见报告)。
|
||||
//!
|
||||
//! | 用例 | 抓得住什么 | 抓不住什么 |
|
||||
//! |---|---|---|
|
||||
//! | [`the_same_version_in_two_roots_is_two_instances`] | 两个根里的同名版本 = **2 条**磁贴、id 不同、各自指向自己的根、名字分辨得出来 | 真实官方安装的目录形状(`launcher_profiles.json` / 加密的账户文件一概不读) |
|
||||
//! | [`a_junction_to_the_configured_root_is_not_a_second_root`] | 同一路径的第二种写法(junction / 符号链接)**不许**让同一批实例列两遍 | 8.3 短名(用例盘上造不出来;判据靠 `canonicalize` 的长名展开,见 `roots::key_of` 的文档) |
|
||||
//! | [`saves_come_from_the_instance_own_root`] | 每个实例的存档 / 截图列表只来自**它自己那个根**(不许把外部实例的存档当成当前根的) | 游戏进程正在跑时的文件锁 / 半写状态(未处理,见报告) |
|
||||
//! | [`discovery_writes_nothing_into_the_official_root`] | 只读发现:`instances()` + 列存档前后,官方根里一个条目都不许多 | 「官方启动器同时也在写」的并发(TOCTOU 本来就看不到) |
|
||||
//! | [`a_lazily_created_profile_stays_on_its_own_root`] | 懒创建档案(用户第一次改设置)之后 id → 根的反查不退化,且没把配置根的同名版本一并抹掉 | 档案里**没有**根字段这一事实本身(根只从 id 反查,见 `discovered::locate`) |
|
||||
//! | [`the_launch_reads_the_manifest_from_the_instance_root`] | 启动装配的清单 / 运行目录走实例自己的根 | 真的把游戏拉起来(要真 jvm / 账号 / 网络) |
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::game::GameLayout;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 临时根(每个用例一个目录;断言 panic 时也会删)。
|
||||
mod temp {
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub struct Root(pub PathBuf);
|
||||
|
||||
impl Root {
|
||||
pub fn new(tag: &str) -> Self {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).expect("建临时目录");
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Root {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(Path::new(&self.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录下所有条目(排序后比较,用来做「读盘前后」快照)。
|
||||
pub fn entries_under(dir: &Path) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(current) = stack.pop() {
|
||||
let Ok(read) = std::fs::read_dir(¤t) else {
|
||||
continue;
|
||||
};
|
||||
for entry in read.flatten() {
|
||||
let path = entry.path();
|
||||
if let Ok(relative) = path.strip_prefix(dir) {
|
||||
out.push(relative.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// 造两个根:配置根 = `<root>/.minecraft`,官方位置 = `<root>/official/.minecraft`。
|
||||
fn two_roots(tag: &str) -> (temp::Root, HostEnv) {
|
||||
let root = temp::Root::new(tag);
|
||||
let mut env = HostEnv::at_root(&root.0);
|
||||
let official = root.0.join("official").join(".minecraft");
|
||||
std::fs::create_dir_all(&official).expect("官方根目录");
|
||||
env.standard_game = Some(official);
|
||||
(root, env)
|
||||
}
|
||||
|
||||
/// 官方根(=第二个候选根)的**字面**路径。
|
||||
fn official(env: &HostEnv) -> PathBuf {
|
||||
env.standard_game.clone().expect("用例里一定钉了官方根")
|
||||
}
|
||||
|
||||
fn write_version(game: &GameLayout, id: &str) {
|
||||
let path = game.version_json(id);
|
||||
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
|
||||
std::fs::write(&path, format!(r#"{{"id":"{id}"}}"#)).expect("写版本 JSON");
|
||||
}
|
||||
|
||||
/// 世界目录(`<根>/saves/<name>/level.dat`)。
|
||||
fn write_world(root: &Path, name: &str) {
|
||||
let dir = root.join("saves").join(name);
|
||||
std::fs::create_dir_all(&dir).expect("世界目录");
|
||||
std::fs::write(dir.join("level.dat"), b"fixture").expect("level.dat");
|
||||
}
|
||||
|
||||
/// 目录链接(Windows 用 junction:不需要管理员;其它平台符号链接)。造不出来返回 `false`。
|
||||
#[cfg(windows)]
|
||||
fn link_dir(link: &Path, target: &Path) -> bool {
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/c", "mklink", "/J"])
|
||||
.arg(link)
|
||||
.arg(target)
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn link_dir(link: &Path, target: &Path) -> bool {
|
||||
std::os::unix::fs::symlink(target, link).is_ok()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 判据①:两个根各一条
|
||||
|
||||
/// 两个临时根各放一个同名实例(`versions/A/A.json`)⇒ 列表出 **2 条**,各自指向自己的根。
|
||||
#[test]
|
||||
fn the_same_version_in_two_roots_is_two_instances() {
|
||||
let (_root, env) = two_roots("ull-host-multiroot-two");
|
||||
write_version(&env.game, "A");
|
||||
write_version(&GameLayout::new(official(&env)), "A");
|
||||
|
||||
let snapshot = instances(&env);
|
||||
assert_eq!(
|
||||
snapshot.len(),
|
||||
2,
|
||||
"两个根里的同名版本必须是 2 条:{snapshot:?}"
|
||||
);
|
||||
let ids: std::collections::BTreeSet<&str> =
|
||||
snapshot.iter().map(|tile| tile.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
2,
|
||||
"两个根的 id 撞了(哈希没喂根?):{snapshot:?}"
|
||||
);
|
||||
|
||||
for tile in &snapshot {
|
||||
assert_eq!(tile.version_id, "A");
|
||||
assert!(
|
||||
unlucky_core::platform::layout::is_valid_instance_id(&tile.id),
|
||||
"合成的实例 id 不合法:{}",
|
||||
tile.id
|
||||
);
|
||||
let game = game_for(&env, &tile.id);
|
||||
assert!(
|
||||
game.root() == env.game.root() || game.root() == official(&env),
|
||||
"实例 {} 的根既不是配置根也不是官方根:{}",
|
||||
tile.id,
|
||||
game.root().display()
|
||||
);
|
||||
}
|
||||
|
||||
// 两条磁贴的**名字**必须分辨得出来(否则用户不知道点哪一条,而两条的存档在不同目录里)。
|
||||
assert_ne!(
|
||||
snapshot[0].name, snapshot[1].name,
|
||||
"两条磁贴同名,用户无从分辨:{snapshot:?}"
|
||||
);
|
||||
assert!(
|
||||
snapshot.iter().any(|tile| tile.name == "A"),
|
||||
"配置根里的那条仍叫版本目录名(绝大多数用户看到的就是它):{snapshot:?}"
|
||||
);
|
||||
let other = snapshot
|
||||
.iter()
|
||||
.find(|tile| tile.name != "A")
|
||||
.expect("另一条存在");
|
||||
assert!(
|
||||
other.name.contains("official"),
|
||||
"另一条的名字得带上它的根(现在是 {})",
|
||||
other.name
|
||||
);
|
||||
|
||||
// 同一个 (根, 版本) 反查稳定:换一次快照,id 不变(懒创建幂等的前提)。
|
||||
let again = instances(&env);
|
||||
assert_eq!(
|
||||
again.iter().map(|tile| tile.id.clone()).collect::<Vec<_>>(),
|
||||
snapshot
|
||||
.iter()
|
||||
.map(|tile| tile.id.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
"同一批盘上内容两次快照给了不同 id(跨重启就会长出第二条档案)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **存在但是空的**官方根(本机的真实形状)⇒ 一条实例都不许多,也不许报错。
|
||||
///
|
||||
/// 这条不是假想:开发机上 `%APPDATA%\.minecraft` **目录是在的**,里面只有一个空的 `cache/`
|
||||
/// (没有 `versions/`、没有 `launcher_profiles.json`)。判据必须把「有个空目录」与「装了官方启动器」
|
||||
/// 分开——否则用户会看到一条来路不明的实例(或者实例页整页报错)。
|
||||
#[test]
|
||||
fn an_empty_official_root_adds_no_instances() {
|
||||
let (_root, env) = two_roots("ull-host-multiroot-empty");
|
||||
write_version(&env.game, "A");
|
||||
std::fs::create_dir_all(official(&env).join("cache")).expect("cache 目录");
|
||||
|
||||
let snapshot = instances(&env);
|
||||
assert_eq!(snapshot.len(), 1, "空的官方根不该长出实例:{snapshot:?}");
|
||||
assert_eq!(snapshot[0].version_id, "A");
|
||||
assert_eq!(
|
||||
game_for(&env, &snapshot[0].id).root(),
|
||||
env.game.root(),
|
||||
"唯一那条属于配置根"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- 判据②:同一路径的两种写法只算一个根
|
||||
|
||||
/// 让两个路径指向同一个目录(junction / 符号链接)⇒ 列表只能出 **1 条**。
|
||||
#[test]
|
||||
fn a_junction_to_the_configured_root_is_not_a_second_root() {
|
||||
let root = temp::Root::new("ull-host-multiroot-dedupe");
|
||||
let mut env = HostEnv::at_root(&root.0);
|
||||
write_version(&env.game, "A");
|
||||
let link = root.0.join("linked");
|
||||
if !link_dir(&link, env.game.root()) {
|
||||
// 造不出链接就**不假绿**:这一条判据在造不出链接的盘上等于没验。
|
||||
eprintln!("跳过:这个环境造不出目录链接({})", link.display());
|
||||
return;
|
||||
}
|
||||
env.standard_game = Some(link);
|
||||
assert_eq!(
|
||||
super::roots::all(&env).len(),
|
||||
1,
|
||||
"联接与配置根是同一个物理目录,不该算两个根"
|
||||
);
|
||||
let snapshot = instances(&env);
|
||||
assert_eq!(
|
||||
snapshot.len(),
|
||||
1,
|
||||
"同一批实例列了两遍(去重没落到真实路径上):{snapshot:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- 判据③:存档按各自的根解析
|
||||
|
||||
/// 两个根各一个同名实例 + 各一个世界 ⇒ 每条实例的存档列表只来自**它自己那个根**。
|
||||
#[test]
|
||||
fn saves_come_from_the_instance_own_root() {
|
||||
let (_root, env) = two_roots("ull-host-multiroot-saves");
|
||||
write_version(&env.game, "A");
|
||||
write_version(&GameLayout::new(official(&env)), "A");
|
||||
write_world(env.game.root(), "configured-world");
|
||||
write_world(&official(&env), "official-world");
|
||||
|
||||
let snapshot = instances(&env);
|
||||
assert_eq!(snapshot.len(), 2, "{snapshot:?}");
|
||||
let mut seen: Vec<PathBuf> = Vec::new();
|
||||
for tile in &snapshot {
|
||||
let dir = super::super::saves::run_dir(&env, tile).expect("派生运行目录");
|
||||
let view = super::super::saves::snapshot(tile, &dir, None).expect("列存档");
|
||||
let worlds: Vec<&str> = view.worlds.iter().map(|world| world.dir.as_str()).collect();
|
||||
if dir == env.game.root() {
|
||||
assert_eq!(
|
||||
worlds,
|
||||
vec!["configured-world"],
|
||||
"配置根那条读串了:{view:?}"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(dir, official(&env), "外部实例的运行目录应当是它自己的根");
|
||||
assert_eq!(worlds, vec!["official-world"], "官方根那条读串了:{view:?}");
|
||||
}
|
||||
seen.push(dir);
|
||||
}
|
||||
assert_ne!(
|
||||
seen[0], seen[1],
|
||||
"两个根的实例派生出同一个运行目录(存档会互相覆盖)"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 判据④:只读发现(官方根零写入)
|
||||
|
||||
/// 浏览(列实例 + 列存档)不许在官方根里留下任何东西,也不许凭空写 `profiles.json`。
|
||||
#[test]
|
||||
fn discovery_writes_nothing_into_the_official_root() {
|
||||
let (_root, env) = two_roots("ull-host-multiroot-readonly");
|
||||
write_version(&env.game, "A");
|
||||
write_version(&GameLayout::new(official(&env)), "A");
|
||||
write_world(&official(&env), "official-world");
|
||||
|
||||
let before = temp::entries_under(&official(&env));
|
||||
let snapshot = instances(&env);
|
||||
for tile in &snapshot {
|
||||
if let Ok(dir) = super::super::saves::run_dir(&env, tile) {
|
||||
let _ = super::super::saves::snapshot(tile, &dir, None);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
before,
|
||||
temp::entries_under(&official(&env)),
|
||||
"读一次实例列表 / 存档就在官方启动器的目录里多出了东西"
|
||||
);
|
||||
let profiles = read_profiles(&env.layout).expect("读档案");
|
||||
assert!(
|
||||
profiles.profiles.is_empty(),
|
||||
"浏览不该懒创建档案:{:?}",
|
||||
profiles.profiles
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------- 判据⑤:懒创建的档案仍认自己那个根(不吞同名)
|
||||
|
||||
/// 用户第一次给官方根里的实例改设置 ⇒ 懒创建档案,但:
|
||||
/// 1. id → 根的反查**不退化**(存档 / 模组 / 启动还读官方根);
|
||||
/// 2. 配置根里那个同名版本**还在**(它是另一个实例,档案不该连它一起吞掉)。
|
||||
#[test]
|
||||
fn a_lazily_created_profile_stays_on_its_own_root() {
|
||||
let (_root, env) = two_roots("ull-host-multiroot-lazy");
|
||||
write_version(&env.game, "A");
|
||||
write_version(&GameLayout::new(official(&env)), "A");
|
||||
// 官方根里那个版本带 `mods/`(「版本隔离」形态)⇒ 基底档是 isolated,换根之后不许变。
|
||||
std::fs::create_dir_all(
|
||||
GameLayout::new(official(&env))
|
||||
.version_dir("A")
|
||||
.join("mods"),
|
||||
)
|
||||
.expect("mods 目录");
|
||||
|
||||
let snapshot = instances(&env);
|
||||
let tile = snapshot
|
||||
.iter()
|
||||
.find(|tile| tile.name.contains("official"))
|
||||
.expect("官方根那条在列表里")
|
||||
.clone();
|
||||
let game = game_for(&env, &tile.id);
|
||||
assert_eq!(game.root(), official(&env), "反查没落到官方根上");
|
||||
assert_eq!(
|
||||
isolation_of(&env, &game, &tile.id, "A"),
|
||||
"isolated",
|
||||
"官方根里 `versions/A/mods/` 是「自带数据」的痕迹 ⇒ 独立目录"
|
||||
);
|
||||
|
||||
set_isolation(&env, &tile.id, "shared").expect("第一次改档位 = 懒创建档案");
|
||||
assert_eq!(
|
||||
game_for(&env, &tile.id).root(),
|
||||
official(&env),
|
||||
"懒创建之后反查退回了配置根(存档会读串)"
|
||||
);
|
||||
|
||||
let after = instances(&env);
|
||||
assert_eq!(
|
||||
after.len(),
|
||||
2,
|
||||
"懒创建之后配置根那个同名版本被档案吞掉了:{after:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
after
|
||||
.iter()
|
||||
.filter(|tile| tile.id == tile_id(&snapshot, "official"))
|
||||
.count(),
|
||||
1,
|
||||
"懒创建之后官方根那条被列了两遍(档案 + 合成视图)"
|
||||
);
|
||||
// 档案里的 version_id 只是版本名,**根只从 id 反查**——这条用例钉住的就是这一点。
|
||||
let profiles = read_profiles(&env.layout).expect("读档案");
|
||||
assert_eq!(profiles.profiles.len(), 1);
|
||||
assert_eq!(profiles.profiles[0].version_id, "A");
|
||||
}
|
||||
|
||||
/// 列表里「名字带 `needle` 的那一条」的 id(懒创建前后的同一个实例)。
|
||||
fn tile_id(snapshot: &[crate::dto::InstanceSummary], needle: &str) -> String {
|
||||
snapshot
|
||||
.iter()
|
||||
.find(|tile| tile.name.contains(needle))
|
||||
.expect("那一条在列表里")
|
||||
.id
|
||||
.clone()
|
||||
}
|
||||
|
||||
// ------------------------------------------------ 判据⑥:启动装配按实例自己的根读清单
|
||||
|
||||
/// 清单**只在官方根里** ⇒ 装配仍要成功,且 `gameDir` 是官方根(共用目录档)。
|
||||
#[test]
|
||||
fn the_launch_reads_the_manifest_from_the_instance_root() {
|
||||
let (_root, env) = two_roots("ull-host-multiroot-launch");
|
||||
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
|
||||
write_version(&GameLayout::new(official(&env)), "A");
|
||||
|
||||
let snapshot = instances(&env);
|
||||
assert_eq!(snapshot.len(), 1, "只有官方根里有版本:{snapshot:?}");
|
||||
let tile = &snapshot[0];
|
||||
let target = super::super::launch::LaunchTarget {
|
||||
id: tile.id.clone(),
|
||||
name: tile.name.clone(),
|
||||
version_id: "A".to_string(),
|
||||
account: None,
|
||||
};
|
||||
let (request, _warnings) =
|
||||
super::super::launch::build_request(&env, &target).expect("清单在官方根里,装配应当成功");
|
||||
assert_eq!(
|
||||
request.dirs.game_dir,
|
||||
official(&env),
|
||||
"共用目录档的运行目录应当是**它自己那个根**(不是 ULL 便携根)"
|
||||
);
|
||||
assert_eq!(request.instance.id, tile.id);
|
||||
}
|
||||
@@ -143,14 +143,14 @@ fn external_instance_writes_nothing_until_first_edit() {
|
||||
|
||||
let id = snapshot[0].id.clone();
|
||||
assert_eq!(
|
||||
isolation_of(&env, &id, "A"),
|
||||
isolation_of(&env, &env.game, &id, "A"),
|
||||
"shared",
|
||||
"`versions/A/` 里没有 mods / saves / config / options.txt ⇒ 基底档是 shared(按共用目录读)"
|
||||
);
|
||||
|
||||
set_isolation(&env, &id, "isolated").expect("第一次改档位 = 懒创建档案");
|
||||
assert_eq!(
|
||||
isolation_of(&env, &id, "A"),
|
||||
isolation_of(&env, &env.game, &id, "A"),
|
||||
"isolated",
|
||||
"实例文件里写下的档位优先于基底档"
|
||||
);
|
||||
|
||||
@@ -28,7 +28,9 @@ pub fn target_major(env: &HostEnv) -> u32 {
|
||||
super::game::instances(env)
|
||||
.first()
|
||||
.map(|instance| {
|
||||
java::requirement_for_version(&env.game, &instance.version_id)
|
||||
// 版本清单在**它自己那个根**下(M274):官方根里的版本,配置根里读不到要求。
|
||||
let game = super::game::game_for(env, &instance.id);
|
||||
java::requirement_for_version(&game, &instance.version_id)
|
||||
.minimum
|
||||
.max(8)
|
||||
})
|
||||
|
||||
@@ -63,7 +63,10 @@ pub fn build_request(
|
||||
target: &LaunchTarget,
|
||||
) -> Result<(LaunchRequest, Vec<String>), LaunchFailureView> {
|
||||
let version_id = target.version_id.clone();
|
||||
let manifest_path = env.game.version_json(&version_id);
|
||||
// **这个实例自己的根**(M274):官方根 / 另一个 `.minecraft` 里的版本,清单、库、natives、
|
||||
// assets 全在它自己那个根下。拿 `env.game` 去读会读空(甚至读到同名的另一个版本)。
|
||||
let game = super::game::game_for(env, &target.id);
|
||||
let manifest_path = game.version_json(&version_id);
|
||||
let text = std::fs::read_to_string(&manifest_path).map_err(|error| {
|
||||
LaunchFailureView::new(
|
||||
LaunchFailureStage::VersionManifest,
|
||||
@@ -77,7 +80,7 @@ pub fn build_request(
|
||||
)
|
||||
})?;
|
||||
let source = |id: &str| -> Option<VersionJson> {
|
||||
std::fs::read_to_string(env.game.version_json(id))
|
||||
std::fs::read_to_string(game.version_json(id))
|
||||
.ok()
|
||||
.and_then(|text| VersionJson::from_json_str(&text).ok())
|
||||
};
|
||||
@@ -121,22 +124,22 @@ pub fn build_request(
|
||||
}
|
||||
};
|
||||
let path = if library.is_local() {
|
||||
env.game.version_library(&version_id, &coordinate)
|
||||
game.version_library(&version_id, &coordinate)
|
||||
} else {
|
||||
env.game.library(&coordinate)
|
||||
game.library(&coordinate)
|
||||
};
|
||||
libraries.push(LibraryInput::new(path, name));
|
||||
}
|
||||
let natives = unlucky_core::launch::natives::collect(
|
||||
&env.game,
|
||||
&game,
|
||||
&version_id,
|
||||
resolved.libraries.as_deref().unwrap_or_default(),
|
||||
&environment,
|
||||
);
|
||||
|
||||
let game_dir = run_dir(env, target)?;
|
||||
let game_dir = run_dir(env, &game, target)?;
|
||||
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
|
||||
let assets_root = env.game.assets_dir();
|
||||
let assets_root = game.assets_dir();
|
||||
let assets_index = resolved
|
||||
.assets
|
||||
.clone()
|
||||
@@ -145,7 +148,7 @@ pub fn build_request(
|
||||
// `26w14a-fabric-0.16.5` 这种 id 在 `GameVersion::is_weekly_snapshot`(6 字符形态)上判不出来,
|
||||
// 周快照的 `--graphicsBackend` 会在拼装链里被静默跳过——而详情页的门槛
|
||||
// (`host::launch_args::backend_gate`)与 CLI 用的都是根版本,两边必须同一口径。
|
||||
let (mc_version, _) = super::game::describe_version(env, &version_id);
|
||||
let (mc_version, _) = super::game::describe_version(&game, &version_id);
|
||||
let snapshot = SnapshotInput {
|
||||
version_id: &version_id,
|
||||
version_name: Some(&target.name),
|
||||
@@ -157,11 +160,11 @@ pub fn build_request(
|
||||
legacy_arguments: resolved.minecraft_arguments.as_deref(),
|
||||
libraries: &libraries,
|
||||
natives: &natives,
|
||||
primary_jar: env.game.version_jar(&jar_id),
|
||||
primary_jar: game.version_jar(&jar_id),
|
||||
game_dir,
|
||||
assets_index: assets_index.as_deref(),
|
||||
natives_dir: env.game.natives_dir(&version_id),
|
||||
library_dir: env.game.libraries_dir(),
|
||||
natives_dir: game.natives_dir(&version_id),
|
||||
library_dir: game.libraries_dir(),
|
||||
assets_root: assets_root.clone(),
|
||||
game_assets: assets_root,
|
||||
};
|
||||
@@ -197,18 +200,22 @@ pub fn build_request(
|
||||
///
|
||||
/// 基底档对自动发现的实例是「盘上有没有痕迹」,对其它实例就是全局 `instances.mode`——
|
||||
/// 于是这里不需要知道实例是从哪来的,只有一处判据。
|
||||
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, LaunchFailureView> {
|
||||
let base = super::game::base_mode(env, &target.id, &target.version_id);
|
||||
fn run_dir(
|
||||
env: &HostEnv,
|
||||
game: &unlucky_core::game::GameLayout,
|
||||
target: &LaunchTarget,
|
||||
) -> Result<PathBuf, LaunchFailureView> {
|
||||
let base = super::game::base_mode(env, game, &target.id, &target.version_id);
|
||||
let config = read_instance_config(&env.layout, &target.id).map_err(|error| {
|
||||
LaunchFailureView::new(LaunchFailureStage::InstanceConfig, error.to_string())
|
||||
})?;
|
||||
let derivation = derive_game_dir(&env.game, &target.version_id, base, config.as_ref(), None)
|
||||
let derivation = derive_game_dir(game, &target.version_id, base, config.as_ref(), None)
|
||||
.map_err(|error| {
|
||||
LaunchFailureView::new(LaunchFailureStage::Isolation, error.to_string())
|
||||
})?;
|
||||
if let Some(mut config) = config {
|
||||
let at = format_rfc3339_millis(now());
|
||||
if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) {
|
||||
if observe_mode(&mut config, game, &at, derivation.isolation).unwrap_or(false) {
|
||||
let _ =
|
||||
unlucky_core::game::install::instance::write_instance_config(&env.layout, &config);
|
||||
}
|
||||
@@ -227,11 +234,14 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
let logs_dir = env.layout.logs();
|
||||
launcher_log::set_dir(&logs_dir);
|
||||
let started_at = std::time::SystemTime::now();
|
||||
// 崩溃报告里的「游戏目录」必须是**这个实例自己那个根**(M274):官方根里的实例崩了,
|
||||
// 报告却写着 ULL 便携根的路径,等于把排障指到别人的目录上。
|
||||
let game = super::game::game_for(&env, &target.id);
|
||||
let mut report = ReportContext::new(
|
||||
&target.id,
|
||||
&target.name,
|
||||
&target.version_id,
|
||||
&env.data.game_home,
|
||||
game.root(),
|
||||
&logs_dir,
|
||||
started_at,
|
||||
unlucky_core::platform::os_info::current().summary_line(),
|
||||
@@ -261,7 +271,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
}
|
||||
};
|
||||
report.run_dir = request.dirs.game_dir.clone();
|
||||
report.version_json = Some(env.game.version_json(&target.version_id));
|
||||
report.version_json = Some(game.version_json(&target.version_id));
|
||||
report.java = launcher_log::java_label(&request.java.binary, request.java.major);
|
||||
for warning in &warnings {
|
||||
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
|
||||
|
||||
@@ -18,7 +18,7 @@ use unlucky_core::game::launch_overrides::{
|
||||
resolve as resolve_overrides, GlobalLaunchDefaults, GraphicsBackend, LaunchOverrides,
|
||||
LaunchOverridesPatch, ResolvedLaunch,
|
||||
};
|
||||
use unlucky_core::game::{self, VersionCapabilities, VersionJson};
|
||||
use unlucky_core::game::{self, GameLayout, VersionCapabilities, VersionJson};
|
||||
use unlucky_core::launch::game_args::{graphics_backend_allowed, GRAPHICS_BACKEND_MIN_VERSION};
|
||||
use unlucky_core::launch::game_version::GameVersion;
|
||||
|
||||
@@ -73,11 +73,13 @@ pub fn resolved(env: &HostEnv, instance_id: &str) -> ResolvedLaunch {
|
||||
}
|
||||
|
||||
/// 版本能力位(解析继承链之后取库表;读不到版本 JSON = 什么能力都没有)。
|
||||
fn capabilities(env: &HostEnv, version_id: &str) -> Option<VersionJson> {
|
||||
let text = std::fs::read_to_string(env.game.version_json(version_id)).ok()?;
|
||||
///
|
||||
/// **按实例自己那个根读**(M274):官方根里的版本清单不在配置根下。
|
||||
fn capabilities(game_layout: &GameLayout, version_id: &str) -> Option<VersionJson> {
|
||||
let text = std::fs::read_to_string(game_layout.version_json(version_id)).ok()?;
|
||||
let manifest = VersionJson::from_json_str(&text).ok()?;
|
||||
let source = |id: &str| -> Option<VersionJson> {
|
||||
std::fs::read_to_string(env.game.version_json(id))
|
||||
std::fs::read_to_string(game_layout.version_json(id))
|
||||
.ok()
|
||||
.and_then(|text| VersionJson::from_json_str(&text).ok())
|
||||
};
|
||||
@@ -88,9 +90,9 @@ fn capabilities(env: &HostEnv, version_id: &str) -> Option<VersionJson> {
|
||||
///
|
||||
/// 判据是 core 的两件套(库表能力位 + 版本门槛),**UI 不复制**:
|
||||
/// `graphics_backend_allowed(GameVersion, has_vulkan_backend)`。
|
||||
pub fn backend_gate(env: &HostEnv, version_id: &str) -> (bool, String) {
|
||||
let mc_version = super::game::describe_version(env, version_id).0;
|
||||
let has_vulkan = capabilities(env, version_id)
|
||||
pub fn backend_gate(game_layout: &GameLayout, version_id: &str) -> (bool, String) {
|
||||
let mc_version = super::game::describe_version(game_layout, version_id).0;
|
||||
let has_vulkan = capabilities(game_layout, version_id)
|
||||
.map(|json| VersionCapabilities::from_json(&json).has_vulkan_backend)
|
||||
.unwrap_or(false);
|
||||
let allowed = graphics_backend_allowed(&GameVersion::parse(&mc_version), has_vulkan);
|
||||
@@ -98,10 +100,15 @@ pub fn backend_gate(env: &HostEnv, version_id: &str) -> (bool, String) {
|
||||
}
|
||||
|
||||
/// 详情页「启动参数」区的读取视图。
|
||||
pub fn view(env: &HostEnv, instance_id: &str, version_id: &str) -> LaunchArgsView {
|
||||
pub fn view(
|
||||
env: &HostEnv,
|
||||
game_layout: &GameLayout,
|
||||
instance_id: &str,
|
||||
version_id: &str,
|
||||
) -> LaunchArgsView {
|
||||
let instance = instance_overrides(env, instance_id);
|
||||
let effective = resolved(env, instance_id);
|
||||
let (allowed, mc_version) = backend_gate(env, version_id);
|
||||
let (allowed, mc_version) = backend_gate(game_layout, version_id);
|
||||
LaunchArgsView {
|
||||
instance_id: instance_id.to_string(),
|
||||
jvm_args: instance.jvm_args.unwrap_or_default(),
|
||||
@@ -240,18 +247,18 @@ mod tests {
|
||||
fn gate_uses_core_capability_and_version_threshold() {
|
||||
let (env, _dir) = env();
|
||||
write_version(&env, "26.2-snapshot-2", VULKAN_VERSION);
|
||||
let (allowed, mc) = backend_gate(&env, "26.2-snapshot-2");
|
||||
let (allowed, mc) = backend_gate(&env.game, "26.2-snapshot-2");
|
||||
assert!(allowed, "26.2-snapshot-2 应当放行");
|
||||
assert_eq!(mc, "26.2-snapshot-2");
|
||||
|
||||
// 26.1:版本不够(即使库表里硬塞了 vulkan 也不放行——精确门限优先)。
|
||||
write_version(&env, "26.1", VULKAN_VERSION);
|
||||
let (allowed, mc) = backend_gate(&env, "26.1");
|
||||
let (allowed, mc) = backend_gate(&env.game, "26.1");
|
||||
assert!(!allowed, "26.1 不该放行");
|
||||
assert_eq!(mc, "26.1");
|
||||
|
||||
// 没有版本 JSON:能力位全假 ⇒ 不放行(不 panic)。
|
||||
let (allowed, _) = backend_gate(&env, "1.20.4");
|
||||
let (allowed, _) = backend_gate(&env.game, "1.20.4");
|
||||
assert!(!allowed);
|
||||
}
|
||||
|
||||
@@ -336,7 +343,7 @@ mod tests {
|
||||
&LaunchArgsPatch::backend("vulkan"),
|
||||
)
|
||||
.expect("写回");
|
||||
let view = view(&env, "inst-1", "26.2-snapshot-2");
|
||||
let view = view(&env, &env.game, "inst-1", "26.2-snapshot-2");
|
||||
assert_eq!(view.graphics_backend, "vulkan");
|
||||
assert_eq!(view.graphics_backend_effective, "vulkan");
|
||||
assert!(view.graphics_backend_allowed);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! | 纪律 | 落点 |
|
||||
//! |---|---|
|
||||
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_set_enabled`] 走 `job::spawn`,UI 只收快照 |
|
||||
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
|
||||
//! | 单根语义(D20,M274 起按**实例自己的根**) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生;`super::game::game_for` 先定根,再把那个根喂给派生 |
|
||||
//! | 游戏在跑就不改名 | `running` 标记在 host 这一侧挡:Windows 上进程正持有 jar,rename 会失败,与其让用户读一句 OS 报错不如直说 |
|
||||
//!
|
||||
//! ## 为什么不复用启动路径的 `run_dir()`
|
||||
@@ -186,10 +186,13 @@ fn toggle(env: &HostEnv, id: &str, file: &str, enabled: bool, running: bool) ->
|
||||
|
||||
/// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。
|
||||
fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
|
||||
let base = super::game::base_mode(env, &instance.id, &instance.version_id);
|
||||
// **这个实例自己那个根**(M274):共用目录的运行目录是这个根的根目录;
|
||||
// 拿 `env.game` 去派生,官方根里的实例会读到 ULL 根下的存档 / 模组(用户的原话:串了)。
|
||||
let game = super::game::game_for(env, &instance.id);
|
||||
let base = super::game::base_mode(env, &game, &instance.id, &instance.version_id);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
||||
let derivation = derive_game_dir(&env.game, &instance.version_id, base, config.as_ref(), None)
|
||||
let derivation = derive_game_dir(&game, &instance.version_id, base, config.as_ref(), None)
|
||||
.map_err(|error| format!("派生运行目录失败:{error}"))?;
|
||||
Ok(derivation.game_dir)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! | 纪律 | 落点 |
|
||||
//! |---|---|
|
||||
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] / [`spawn_rename`] / [`spawn_delete`] / [`spawn_backup`] 全走 `job::spawn`,UI 只收快照 |
|
||||
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
|
||||
//! | 单根语义(D20,M274 起按**实例自己的根**) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生;`super::game::game_for` 先定根,再把那个根喂给派生 |
|
||||
//! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
|
||||
//! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) |
|
||||
//! | 游戏在跑就不写 | [`dispatch`] 的 `running` 为真时三条写命令**不 spawn、不碰盘**,直接回 [`crate::dto::SavesFailureKind::GameRunning`] |
|
||||
@@ -242,10 +242,13 @@ pub(super) fn target(
|
||||
|
||||
/// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。
|
||||
pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
|
||||
let base = super::game::base_mode(env, &instance.id, &instance.version_id);
|
||||
// **这个实例自己那个根**(M274):共用目录的运行目录是这个根的根目录;
|
||||
// 拿 `env.game` 去派生,官方根里的实例会读到 ULL 根下的存档 / 模组(用户的原话:串了)。
|
||||
let game = super::game::game_for(env, &instance.id);
|
||||
let base = super::game::base_mode(env, &game, &instance.id, &instance.version_id);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
||||
let derivation = derive_game_dir(&env.game, &instance.version_id, base, config.as_ref(), None)
|
||||
let derivation = derive_game_dir(&game, &instance.version_id, base, config.as_ref(), None)
|
||||
.map_err(|error| format!("派生运行目录失败:{error}"))?;
|
||||
Ok(derivation.game_dir)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user