Files
Unlucky-Launcher/crates/core/src/game/loader.rs
T
AskaEth a322cfcbfe refactor(core): M126 r1 返工:词表改由宏单表生成,安装侧解析收口
复审 r1 的三条:

P1(CI 会红):`const MARKERS: [(LoaderKind, &'static str); 6]` 触发
clippy::redundant_static_lifetimes(`error: constants have by default a 'static
lifetime`)。返工直接**删掉 MARKERS**:标记串就取 `LoaderKind::as_str()`,候选顺序取
`LoaderKind::ALL`,认不认标记看 `has_marker()`——解析表既没有独立字面量,也没有独立清单。

P2-1:「漏加 ALL 不会被任何东西拦住」是真话,之前的 doc 覆盖声明是假的。返工用
`loader_kind_vocab!` 宏从**一张表**一次生成枚举 / `ALL` / `as_str` / `has_marker`:
「加了一支却忘了进清单」在物理上不可能(没有第二份清单可写)。尺寸锚随之删除(`[Self; N]`
的宽度本来就由表决定),`parse` 留在宏外的独立 impl 里。

P2-2:「全仓唯一一份 id 字面量」原先不成立——安装侧 `LoaderKind::parse` 还手写了一份反向
匹配表,并带一个只进不出的 `neo-forge` 别名。返工让 install 侧 `parse` **委派**
`core::game::LoaderKind::parse`,只额外收 `neo-forge`(Modrinth 拼法)这一个输入别名,
并新增 `addon::loader::tests::vocabulary` 覆盖「委派没接错 + 别名只进不出 + 装不了的几家
返回 None」。

测试:core 的 `markers_cover_every_kind_that_has_one` 换成
`every_stable_id_parses_back_to_its_kind`(往返 + 顺序优先级),仍是「改名不可见、只有契约
测试能抓」的分工,doc 里写明。

门禁:fmt OK;clippy -p unlucky-core -p unlucky-presenter -p unlucky-cli --all-targets
-- -D warnings 干净;三 crate 测试 core lib 401 / presenter 220 / cli 41 全 ok。
文件行数:loader.rs 397(≤400)。
2026-11-05 18:08:57 +08:00

399 lines
19 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` 全从同一张表长出来,
//! 所以「加了一支却漏进清单」**物理上做不到**(手写枚举 + 手写清单时它既不报错也不会被测试
//! 抓住:测试同样从 `ALL` 出发,新加载器被**静默**当成「认不出」)。安装侧与 presenter 的
//! `as_str` / `parse` 都是**委派**,CLI 直接调;唯一不在词表里的字面量是安装侧收的输入别名
//! `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`、CLI 的
//! `json_rows_expose_loader_kind_and_version`、`ull help instances` 的词表行)**会红**——那
//! 是逼你确认「对外词表真的要多一个 id」,因为 0.2.0 已经发出去过。
//!
//! 分工:**改名**(`neoforge` → `neo_forge`)不会被往返测试抓住(词表与解析同源、改名自洽),
//! 只有第 ③ 步的契约测试会红——那里**故意**再抄一遍七个串,那是唯一抓得住改名的东西;往返
//! 测试抓的是「规则把 id 认错」。
//!
//! # 解析规则(按整段前缀匹配,大小写不敏感,**最靠左的标记胜**)
//!
//! 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 已发布契约);穷尽 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 尾巴」,那一条
/// 要求前面还有内容(`≥2` 段),所以单独的 `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` 后面。
/// 抓不住:**改名**(`neoforge` → `neo_forge`)——词表与解析同源、改名自洽,本条是绿的;
/// 抓改名是对外契约测试的职责(`cli::commands::instances::tests::
/// json_rows_expose_loader_kind_and_version`)。
#[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:?}");
}
}