Files
Unlucky-Launcher/crates/core/src/game/loader.rs
T
AskaEth 321617861a chore(m135): 删掉死代码 launch::loader_label,修 M134 之后变假话的三处文档
M134 复审点名、且都在它 scope 外的收尾。

- `presenter/src/host/launch.rs::loader_label` 删除:全仓零调用点(唯一提及是
  i18n 的一条注释),函数体逐字重复 `dto::LoaderKind::label()` 并硬编码「原版」——
  它既是第二份显示词表,又绕过 i18n;留着等于给下一个人示范「照这样写就行」。
  随之无用的 `LoaderKind` import 一并收掉。
- `presenter/src/lib.rs` 的 dead_code 豁免说明不再点名已删函数(否则那行注释指向
  一个不存在的符号)。
- `i18n/src/bridge.rs:65` 注释改指向唯一那份显示词表(`dto::LoaderKind::label()`)。
- `core/src/game/loader.rs` 与 `presenter/src/dto/instances.rs` 两处「显示路径
  没有守卫(已知缺口)」改成 M134 之后的真形态:显示路径是委派(拿 core 的稳定 id
  反查 DTO 词表,没有手写 match 可糊),新种类先在 `dto::LoaderKind::from_core` 编
  不过,糊错的后果由 `loader_kind_ids_are_the_released_contract` 与按 `ALL` 遍历的
  `every_core_loader_kind_reaches_the_tile_as_itself` 钉住。
- `presenter/src/host/game.rs` 无需改动:M134 当时已把它的文档改成新形态,读下来是
  真话(`dto_kind` 的 doc 与两个测试的 doc 都对得上现在的代码)。

验证:fmt --all --check 通过;`cargo test -p unlucky-presenter -p unlucky-i18n`
= i18n 29 passed / presenter 220 passed(1 ignored);clippy 两 crate --all-targets
`-D warnings` 零告警。删后全仓(含 workspace 外的 crates/app)已无
`launch::loader_label` 引用。
2026-11-05 19:09:53 +08:00

400 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 版本 id → 加载器:**全仓唯一**的 id 解析入口(M122),外加「种类 → 对外稳定 id」的命名词表
//! **源头**(M126,见 [`LoaderKind::as_str`];副本只有一处,见下)。
//!
//! # 为什么要这一个模块
//!
//! 「这个实例装的什么加载器」在 UI 上是一句话,代码里却有两个来源:版本 JSON 的 `libraries[]`
//! (权威但要多读一次盘)与**版本 id 的字符串**(磁贴/列表最省事的那条)。只要有人按 id 猜就
//! 必须只有**一处**在猜——两处各算一遍迟早一边先漂,用户看到的就是「我刚装的 Fabric 显示成
//! 原版」(M120 复审的老 bug:解析端只认 `fabric-loader-…`,认不出自家生成的
//! `1.20.4-fabric-0.16.5`)。所以 presenter 的实例页、CLI 的实例总览、以后任何「按 id 认加载
//! 器」的地方一律调 [`InstanceLoader::parse`],**不许**再写一份标记匹配。生成端
//! ([`crate::addon::loader::LoaderKind::version_id`] 拼 `<mc>-<kind>-<loader>`)与解析端的一致
//! 性由 [`tests::own_version_ids_parse_back_to_their_loader`] 钉住(**从生成端取 id**,不手抄);
//! OptiFine / LiteLoader 本启动器不能装(见 `presenter::host::install`),只能继承进来。
//!
//! # 命名词表只有一张表(M126)
//!
//! 契约(0.2.0 已发布形状,**不许改名**):`vanilla` / `fabric` / `quilt` / `forge` /
//! `neoforge` / `optifine` / `liteloader`。**生产代码里这份词表有两处,都要改**:① 下面那次
//! `loader_kind_vocab!` 展开——枚举、`ALL`、`as_str`、`has_marker` 全从同一张表长出来,所以
//! 「加了一支却漏进清单」物理上做不到(理由见宏的注释);② 会打印给用户看的
//! `crates/cli/src/cli/help.rs` 里 `ull help instances` 的词表行——**逐字副本**、没有委派,
//! 由 CLI 的 `help_lists_every_stable_id`(按 `ALL` 遍历)守着。安装侧与 presenter 的 `as_str` /
//! `parse` 都是**委派**,CLI 的 `loader.kind` 直接调;唯一不进词表的字面量是安装侧收的输入别名
//! `neo-forge`(Modrinth 拼法,只进不出)。
//!
//! 加一家加载器要动:① `loader_kind_vocab!` 表里加一行(id + 认不认标记)→ 枚举 / `ALL` /
//! `as_str` / `has_marker` / 解析顺序一起长出来,漏不了;② presenter 的 `to_core`·`from_core`
//! **编不过**(逼你决定 DTO 怎么显示它),能装的话安装侧 `as_str`·`from_core` 也各差一支;
//! ③ 契约测试**会红**:presenter 的 `loader_kind_ids_are_the_released_contract` 拿 core 的 `ALL`
//! 与独立手写的七个 id 做**集合比较**,CLI 的 `help_lists_every_stable_id` 按 `ALL` 遍历 help
//! 词表,`json_rows_*` 钉真二进制输出面(0.2.0 已发出去过,所以这步是确认而不是可选)。
//!
//! ⚠️ 分工要看准:**编译器只拦「漏 match」**,新种类在消费方用 `=> Vanilla` 糊过去编得过。
//! ③ 那条集合断言拦的是**对外词表**;**显示路径另有守卫**(M134 起):`presenter/src/host/game.rs::dto_kind`
//! 不再是自己一张手写 match,而是拿 core 的稳定 id 反查 DTO 词表——想在那一支糊 `=> Vanilla` 已经
//! 没有 match 可写,加一家加载器会在 `dto::LoaderKind::from_core` 先编不过;糊错由 presenter 的
//! `loader_kind_ids_are_the_released_contract`、以及按 `ALL` 遍历不手抄清单的
//! `every_core_loader_kind_reaches_the_tile_as_itself` 钉住。**改名**同理:往返测试绿,只有 ③ 会红。
//!
//! # 解析规则(按整段前缀匹配,大小写不敏感,**最靠左的标记胜**)
//!
//! 1. 把 id 按 `-` 切段,从左往右找第一段,它的**前缀**是某个加载器标记
//! (`neoforge` / `fabric` / `quilt` / `forge` / `optifine` / `liteloader`);紧跟的 `loader`
//! 段(`fabric-loader-…`)或同段里的 `_loader` 尾巴算标记的一部分,不是版本段;
//! 2. 该段里标记之后剩下的部分,加上它右边的所有段,按 `-` 拼回版本段
//! (`_` 之类的原样保留,所以 `1.13.2-OptiFine_HD_U_E6` 的版本是 `HD_U_E6`);
//! 3. 例外:版本段 **≥2 段**且末段像 MC 版本(`1.20.1` 这种纯数字点号)时砍掉末段——老 HMCL 的
//! `fabric-loader-<loader>-<mc>` 写法;`0.16.5`(只剩一段)与 `0.20.0-beta.9` 不受影响;
//! 4. 一个标记都没找到 ⇒ 原版(版本段空串)。只把加载器写进版本 JSON、id 里没痕迹的实例
//! (整包导入时被重命名成 `我的整合包` 那种)会**诚实**返回「原版」——那不等于没装加载器。
/// 一张表生成整份词表:枚举、[`LoaderKind::ALL`]、`as_str`、`has_marker` 一起长出来。
///
/// 为什么非要用宏:Rust 没有「枚举有哪几支」的编译期反射,手写枚举 + 手写清单时「加了一支却
/// 忘了进清单」既不报错也不会被测试抓住(测试同样从清单出发),新加载器被**静默**当成「认不
/// 出」;让两者从同一张表展开,这件事在物理上不可能发生。表里的顺序就是标记的匹配优先级;
/// 带 `marker` 的条目参与 id 识别,不带的(原版)不参与。
macro_rules! loader_kind_vocab {
(
$(#[$meta:meta])*
$vis:vis enum $name:ident {
$(
$(#[$variant_doc:meta])*
$variant:ident => $id:literal $(, $marker:ident)?;
)+
}
) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
$vis enum $name {
$(
$(#[$variant_doc])*
$variant,
)+
}
impl $name {
/// 全部种类,**顺序即标记的匹配优先级**;原版没有标记(由 `has_marker` 跳过)。
pub const ALL: [Self; loader_kind_vocab!(@count $($variant)+)] =
[$(Self::$variant,)+];
/// 对外稳定 id(`--json` 的 `loader.kind`、GUI 词表、`--loader` 的取值)。
///
/// 这是**生产代码里 id 的源头表**(0.2.0 已发布契约):别处要么委派到这里,要么是逐字
/// 副本(`ull help instances` 的词表行,由 CLI 的 `help_lists_every_stable_id` 守着)。
/// 穷尽 match:加一支就编不过。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
$(Self::$variant => $id,)+
}
}
/// 这一支会不会从版本 id 的标记里认出来(原版不会)。穷尽 match:加一支必须表态。
#[must_use]
pub const fn has_marker(self) -> bool {
match self {
$(Self::$variant => loader_kind_vocab!(@marker $($marker)?),)+
}
}
}
};
(@marker marker) => { true };
(@marker) => { false };
(@count) => { 0 };
(@count $head:ident $($tail:ident)*) => { 1 + loader_kind_vocab!(@count $($tail)*) };
}
loader_kind_vocab! {
/// 实例面向的加载器种类(比 [`crate::addon::loader::LoaderKind`] 宽)。
///
/// 那个类型只描述**本启动器能装**的四家(装不了就没有下载路线);本类型面向**识别与展示**,
/// 多出「原版」这一支,以及两家只能继承进来的第三方加载器。
pub enum LoaderKind {
/// 原版(id 里没有任何加载器标记)
Vanilla => "vanilla";
/// NeoForge(必须排在 `Forge` 前:两者都是段前缀匹配,顺序写死能防以后宽成「包含」)
NeoForge => "neoforge", marker;
/// Fabric
Fabric => "fabric", marker;
/// Quilt
Quilt => "quilt", marker;
/// Forge
Forge => "forge", marker;
/// OptiFine(第三方,本启动器不装,只继承)
OptiFine => "optifine", marker;
/// LiteLoader(第三方,本启动器不装,只继承)
LiteLoader => "liteloader", marker;
}
}
impl LoaderKind {
/// 稳定 id → 种类([`Self::as_str`] 的反查;整串全等、大小写不敏感、不认前缀与别名)。
///
/// 别和 [`InstanceLoader::parse`] 搞混:那个认的是**版本 id**(切段、认前缀、给版本段)。
/// 别名 `neo-forge` 只有安装侧收,见 [`crate::addon::loader::LoaderKind::parse`]。
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
let wanted = raw.trim();
Self::ALL
.into_iter()
.find(|kind| kind.as_str().eq_ignore_ascii_case(wanted))
}
}
/// 一个版本 id 解析出来的加载器:种类 + 版本段(原版为空串)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstanceLoader {
/// 加载器种类
pub kind: LoaderKind,
/// 加载器版本段(id 里标记之后的那一截);原版或 id 里没写版本时为空串
pub version: String,
}
impl InstanceLoader {
/// 从版本 id 解析加载器(纯字符串计算,不读盘)。
///
/// 规则见模块文档;认不出标记时返回 [`LoaderKind::Vanilla`] + 空版本段。
#[must_use]
pub fn parse(version_id: &str) -> Self {
let segments: Vec<&str> = version_id.trim().split('-').collect();
for (index, segment) in segments.iter().enumerate() {
let Some((kind, rest)) = marker_of(segment) else {
continue;
};
// `fabric-loader-0.16.5-1.20.1` 按 `-` 切完是 `["fabric", "loader", …]`,
// `loader` 自己占一段;版本段要从它后面算,否则会读出 `loader-0.16.5`。
let mut next = index + 1;
while segments
.get(next)
.is_some_and(|segment| segment.eq_ignore_ascii_case("loader"))
{
next += 1;
}
let mut parts: Vec<&str> = Vec::new();
if !rest.is_empty() {
parts.push(rest);
}
parts.extend(segments.iter().skip(next).copied());
let version = join_version(&parts);
return Self { kind, version };
}
Self {
kind: LoaderKind::Vanilla,
version: String::new(),
}
}
}
/// 这一段的**前缀**是哪个加载器标记;命中时连它后面剩下的部分一起给出。
///
/// 标记串 = [`LoaderKind::as_str`],顺序 = [`LoaderKind::ALL`](即优先级),认不认标记由
/// [`LoaderKind::has_marker`] 表决:**解析表没有独立字面量,也没有独立清单**。
fn marker_of(segment: &str) -> Option<(LoaderKind, &str)> {
for kind in LoaderKind::ALL.into_iter().filter(|kind| kind.has_marker()) {
if let Some(rest) = strip_prefix_ascii_ci(segment, kind.as_str()) {
return Some((kind, strip_loader_suffix(rest)));
}
}
None
}
/// 去掉标记后面可选的 `loader` 后缀(版本段从 `0.16.5` 起算,不是 `loader-0.16.5`)。
fn strip_loader_suffix(rest: &str) -> &str {
let trimmed = trim_separators(rest);
match strip_prefix_ascii_ci(trimmed, "loader") {
Some(after) => trim_separators(after),
None => trimmed,
}
}
/// 掐头去尾的 `-` / `_`(`OptiFine_HD_U_E6` 的标记后面紧跟的是 `_`)。
fn trim_separators(text: &str) -> &str {
text.trim_matches(['-', '_'])
}
/// 大小写不敏感的 `strip_prefix`(用的是 ASCII 折叠,版本 id 里不会出现非 ASCII 标记)。
fn strip_prefix_ascii_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
let head = text.get(..prefix.len())?;
if head.eq_ignore_ascii_case(prefix) {
text.get(prefix.len()..)
} else {
None
}
}
/// 把标记右边剩下的段拼成版本段,并按「老 HMCL 的 `<loader>-<mc>` 尾巴」规则砍掉 MC 版本。
fn join_version(parts: &[&str]) -> String {
let mut kept = parts.to_vec();
if kept.len() >= 2 && kept.last().is_some_and(|last| looks_like_mc_version(last)) {
kept.pop();
}
kept.join("-")
}
/// 这一段长得像不像 MC 版本(`1.20.1` 这种全数字 + 点号);只用于「砍 HMCL 尾巴」(要求前面
/// 还有内容,所以 `0.16.5` 与 `0.20.0-beta.9` 都不受影响)。
fn looks_like_mc_version(text: &str) -> bool {
text.contains('.')
&& text
.split('.')
.all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
}
#[cfg(test)]
mod tests {
use super::*;
/// 自家 id 的真值表:**从生成端取 id**,不手抄。
///
/// 抓得住:生成端换了 id 形状而解析端没跟、解析端退回只认老 HMCL 口径(M122 的 bug)。
/// 抓不住:加载器版本自己带 `-` 的**其他**情形(`0.16.5-beta.1` / `0.20.0-beta.9` 两个
/// 样本专为它写;Forge/NeoForge 的真实版本号不带 `-`,出现了只能靠人补样本)。
#[test]
fn own_version_ids_parse_back_to_their_loader() {
use crate::addon::loader::LoaderKind as InstallKind;
let cases: [(InstallKind, LoaderKind, &str, &str); 6] = [
(InstallKind::Fabric, LoaderKind::Fabric, "1.20.4", "0.16.5"),
(
InstallKind::Fabric,
LoaderKind::Fabric,
"1.20.4",
"0.16.5-beta.1",
),
(InstallKind::Quilt, LoaderKind::Quilt, "1.20.4", "0.27.0"),
(
InstallKind::Quilt,
LoaderKind::Quilt,
"1.20.1",
"0.20.0-beta.9",
),
(InstallKind::Forge, LoaderKind::Forge, "1.20.1", "47.2.0"),
(
InstallKind::NeoForge,
LoaderKind::NeoForge,
"1.21.1",
"21.1.80",
),
];
for (install, kind, mc, loader_version) in cases {
let id = install.version_id(mc, loader_version);
let parsed = InstanceLoader::parse(&id);
assert_eq!(parsed.kind, kind, "id `{id}` 认成了 {:?}", parsed.kind);
assert_eq!(parsed.version, loader_version, "id `{id}` 的版本段");
}
}
/// 第三方前缀(OptiFine / LiteLoader):它们**不是** `<mc>-<kind>-<ver>` 口径。
///
/// 样本来源:PCL 的 `Modules/Minecraft/ModDownload.vb:441`(`-OptiFine_…`)与
/// `Pages/PageDownload/ModDownloadLib.vb:793`(`-LiteLoader`,**没有版本段**);`_pre1`
/// 尾巴来自 OptiFine 自己的 preview 命名。
/// 抓得住:标记被当成「必须带 `-`」或「必须小写」(旧口径 `split_once("optifine")`)。
/// 抓不住:OptiFine 的 maven 坐标形式(`optifine:OptiFine:1.12.2_HD_U_C7`),未做特判。
#[test]
fn third_party_prefixes_are_recognized() {
let cases = [
("1.13.2-OptiFine_HD_U_E6", LoaderKind::OptiFine, "HD_U_E6"),
("1.20.1-OptiFine_HD_U_I6", LoaderKind::OptiFine, "HD_U_I6"),
(
"1.20.1-OptiFine_HD_U_I6_pre1",
LoaderKind::OptiFine,
"HD_U_I6_pre1",
),
("1.20.1-OptiFine", LoaderKind::OptiFine, ""),
("1.12.2-LiteLoader", LoaderKind::LiteLoader, ""),
];
for (id, kind, version) in cases {
let parsed = InstanceLoader::parse(id);
assert_eq!(parsed.kind, kind, "id `{id}` 认成了 {:?}", parsed.kind);
assert_eq!(parsed.version, version, "id `{id}` 的版本段");
}
}
/// 老 HMCL / CF 口径(`fabric-loader-<loader>-<mc>`、`forge-<ver>`):存量实例还会按它们
/// 出现,行为不许退化。
///
/// 抓得住:砍 MC 尾巴的规则被删(版本段变成 `0.16.5-1.20.1`)、`loader` 后缀被当成版本段。
/// 抓不住:`forge-47.2.0-1.20.1` 是**手工声明**的口径,不是查到的真实格式(本仓库的老
/// HMCL 分支已改成「清单 + patch」)。
#[test]
fn foreign_loader_style_ids_still_parse() {
let cases = [
("fabric-loader-0.16.5-1.20.1", LoaderKind::Fabric, "0.16.5"),
("quilt-loader-0.24.0-1.20.1", LoaderKind::Quilt, "0.24.0"),
(
"quilt-loader-0.24.0-beta.9-1.20.1",
LoaderKind::Quilt,
"0.24.0-beta.9",
),
("forge-47.2.0", LoaderKind::Forge, "47.2.0"),
("forge-47.2.0-1.20.1", LoaderKind::Forge, "47.2.0"),
];
for (id, kind, version) in cases {
let parsed = InstanceLoader::parse(id);
assert_eq!(parsed.kind, kind, "id `{id}` 认成了 {:?}", parsed.kind);
assert_eq!(parsed.version, version, "id `{id}` 的版本段");
}
}
/// 反向锚:普通版本 id(原版、快照、预发布)**不许**被认成加载器。
///
/// 抓得住:解析端退化成「有 id 就当加载器」、匹配放宽成「包含子串」。
/// 抓不住:**含加载器名字样的原版 id**(真出现 `1.20.4-forge` 这种原版 id 会被认成
/// Forge——本模块按标记优先)。
#[test]
fn plain_version_ids_stay_vanilla() {
for id in [
"1.20.4",
"1.20",
"1.7.10",
"23w45a",
"1.20.4-rc1",
"1.20.1-pre1",
"",
" ",
] {
let parsed = InstanceLoader::parse(id);
assert_eq!(parsed.kind, LoaderKind::Vanilla, "id `{id}`");
assert!(parsed.version.is_empty(), "id `{id}` 的版本段应为空");
}
}
/// 标记按**整段前缀**匹配,不是「包含」:段里出现加载器名字样但不在段首的,不算。
///
/// 抓得住:匹配规则从「段前缀」放宽成「段内包含」(`myforge-pack` 会被认成 Forge)。
/// 抓不住:段首就是标记的误伤(`forgepack-1.0` 会认成 Forge)——这条有意不拦,因为
/// 段首命中正是 OptiFine 的 `OptiFine_HD_U_E6` 与 CF 的 `forge-47.2.0` 依赖的形状。
#[test]
fn markers_only_match_at_segment_start() {
for id in ["1.20.1-myforge-pack", "1.20.1-somequiltish"] {
let parsed = InstanceLoader::parse(id);
assert_eq!(parsed.kind, LoaderKind::Vanilla, "id `{id}`");
}
}
/// 往返锚:**每个对外 id 喂回解析都要认成它自己**,标记顺序也得留优先级。
///
/// 抓得住:标记匹配退化(段前缀变整段全等、`loader` 后缀/MC 尾巴规则被改坏)、标记串与词表
/// 脱节、`neoforge` 被排到 `forge` 后面。抓不住:**改名**——词表与解析同源、改名自洽,本条
/// 是绿的;抓改名是 presenter 的 `loader_kind_ids_are_the_released_contract`。
#[test]
fn every_stable_id_parses_back_to_its_kind() {
for kind in LoaderKind::ALL {
let id = kind.as_str();
assert_eq!(LoaderKind::parse(id), Some(kind), "反查 `{id}`");
let parsed = InstanceLoader::parse(&format!("1.20.4-{id}-0.16.5"));
assert_eq!(parsed.kind, kind, "`{id}` 认成了 {:?}", parsed.kind);
let expected = if kind.has_marker() { "0.16.5" } else { "" };
assert_eq!(parsed.version, expected, "`{id}` 的版本段");
}
let order: Vec<LoaderKind> = LoaderKind::ALL.into_iter().collect();
let neoforge = order.iter().position(|kind| *kind == LoaderKind::NeoForge);
let forge = order.iter().position(|kind| *kind == LoaderKind::Forge);
assert!(neoforge < forge, "`neoforge` 要在 `forge` 前:{order:?}");
}
}