安装 Forge 与 NeoForge​

2026-11-01 22:52:33

安装 Forge 与 NeoForge ​Forge / NeoForge 和 Fabric 是两条完全不同的路。Fabric 是「拿一份 profile JSON 合并」,Forge 是「下载官方 installer,然后在本机执行 java -jar installer --installClient」,版本 JSON、库文件、打补丁的客户端全部由 installer 自己产出。这页讲这条链路上每一个参数的来源、退出码怎么判定,以及为什么必须先有原版、先有 launcher_profiles.json。

涉及文件:

Starcloud.App/Views/DownloadPage.xaml.cs:StartDownload_Click、javaMajor 的推导Starcloud.Core/Services/ForgeInstallerService.cs:构建列表、直链、InstallViaInstallerAsyncStarcloud.Core/Services/JavaRequirement.cs:从版本 JSON 推断 Java 大版本Starcloud.Core/Services/InstallerService.cs:前置的原版安装调用链 ​textDownloadPage.StartDownload_Click

├─ url ← 清单;IsInstalled → AskInstanceNameAsync → id = 新目录名

└─ RunInstallAsync

├─ InstallVanillaAsync(id, url, …) // 前置:versions//.json

├─ var javaMajor = 8

│ try { LoadVersionAsync(id) → JavaRequirement.Resolve(id, version.JavaVersion) }

│ catch { /* 读不到就按 8 兜底 */ }

├─ Forge:InstallViaInstallerAsync(

│ ForgeInstallerService.ForgeInstallerUrl(id, forge), id, javaMajor, …)

└─ NeoForge:InstallViaInstallerAsync(

ForgeInstallerService.NeoForgeInstallerUrl(id, neo), id, javaMajor, …)

├─ EnsureJavaAsync(requiredJavaMajor, progress, ct)

├─ tempDir = Path.Combine(Path.GetTempPath(), "starcloud-installer")

├─ name = Path.GetFileName(new Uri(installerUrl).LocalPath)

├─ DownloadFileAsync(installerUrl, tempDir/name, sha1: null, DownloadMaxRetries, ct)

├─ EnsureLauncherProfiles(settings.DotMinecraft)

├─ Process: java -jar --installClient

├─ stdout 逐行 Report;stderr 异步 ReadToEnd

└─ ExitCode != 0 → InvalidOperationExceptionjavaMajor 只服务于 installer:Forge 安装器自己要跑一段 Java 代码。它来自刚装好的原版 JSON 的 javaVersion.majorVersion;读不到就按 8 兜底,后面 EnsureJavaAsync 会自动补运行时。

构建列表:Forge 有两个来源 ​csharpvar meta = await _downloader.GetStringAsync(

"https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml", ct);

var promosJson = await _downloader.GetStringAsync(

"https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json", ct);来源提供什么没有它会怎样maven-metadata.xml全部构建号的平铺列表UI 里只有「推荐/最新」两个版本可装promotions_slim.json每个 MC 版本的 recommended / latest 标记列表一个不缺,但用户不知道该选哪个过滤与排序:

csharpvar prefix = $"{mcVersion}-";

foreach (var tag in SplitVersions(meta))

{

if (tag.StartsWith(prefix, StringComparison.Ordinal) && IsForgeBuildSuffix(tag[prefix.Length..]))

builds.Add(tag);

}

builds.Sort((a, b) => CompareForgeBuilds(b[prefix.Length..], a[prefix.Length..]));SplitVersions 是手写的 / 区间提取,不引入 XML 解析器——maven-metadata 的结构稳定,正则化处理反而更容易出错;前缀必须是 "1.20.1-" 这种带连字符的形式,否则 1.20.1 会把 1.20.10 的构建也吸进来;IsForgeBuildSuffix 要求首字符是 ASCII 数字,其余只允许数字/字母/./-。早期版本用 int.TryParse 判定,现代构建号 47.4.5 必然解析失败,列表恒为空——这就是为什么改成逐字符白名单。排序规则:

csharpstatic int[] Key(string v) =>

v.Split('-')[0].Split('.').Select(p => int.TryParse(p, out var n) ? n : 0).ToArray();Key 先砍掉 - 之后的后缀(旧版 10.13.4.1614-1.7.10 的 -1.7.10 不参与比较),再按 . 分段转数字;比较时逐段对比,段数多者视为更新。builds.Sort((a, b) => CompareForgeBuilds(b, a)) 把参数反过来,得到新 → 旧。

标记的写法是精确字符串比对,不是模糊匹配:

csharpreturn builds

.Select(v => new LoaderBuild(v[prefix.Length..],

v == $"{prefix}{recommended}" ? "推荐"

: v == $"{prefix}{latest}" ? "最新" : null))

.ToList();推荐优先于最新:同一个构建同时是两者时显示「推荐」。LoaderBuild.Version 存的是去掉 MC 前缀后的构建号(47.4.5),这个值后面会直接拼进安装器直链。

构建列表:NeoForge ​NeoForge 没有 maven-metadata 的便利,走一个 JSON API,并且版本号有三代命名:

csharpvar prefix = NeoForgePrefix(mcVersion);

if (prefix is null) return [];

var legacy = mcVersion == "1.20.1";

var artifact = legacy ? "forge" : "neoforge";

var json = await _downloader.GetStringAsync(

$"https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/{artifact}", ct);NeoForgePrefix 的三种形态:

MC 版本前缀剥离长度 skip说明1.20.11.20.1-47.1.mcVersion.Length + 1(即 7)产物在 legacy 的 forge 构件下,版本号形如 1.20.1-47.1.1061.20.4 / 1.2120.4. / 21.0.prefix.Length1.x 时代构件版本是 20.4.xxx,需要剥掉 20.4. 只留构建号26.2(新命名)26.2.0构件版本形如 26.2.0.57,MC 版本本身就是前缀,不剥离前缀生成本身也会拒绝非法输入:

csharpvar parts = seg.Split('.');

if (parts.Length is < 2 or > 3) return null;

if (!int.TryParse(parts[0], out _) || !int.TryParse(parts[1], out _)) return null;

if (parts.Length == 3 && !int.TryParse(parts[2], out _)) return null;

return $"{seg}.";parts.Length 为 1(例如 24w14a 快照)或 4 时直接返回 null,函数随即 return [],UI 显示「无可用构建」,不会去请求一个注定空的 API。

API 响应有两种形状,代码都兼容:

csharpvar items = root.ValueKind == JsonValueKind.Array

? root.EnumerateArray()

: root.TryGetProperty("versions", out var arr) && arr.ValueKind == JsonValueKind.Array

? arr.EnumerateArray()

: default;

if (items.Equals(default)) return [];排序键把第 3、4 段折进一个位:

csharpreturn (P(parts, 0), P(parts, 1), P(parts, 2) * 1_000_000 + P(parts, 3));这样 26.2.0.57 与三段号的 26.2.1 能放在同一坐标系里比较,且四段号的 patch 段不会因为长度不同而错位。

标记规则:

csharpvar isBeta = v.EndsWith("-beta", StringComparison.Ordinal);

return new LoaderBuild(v, i == 0 && !isBeta ? "最新稳定版" : label);最终展示只有三种:Beta、最新稳定版、空。只有排第一且不是 beta 的才算「最新稳定版」——列表最上面恰好是个 beta 时,第一行显示 Beta,下一个稳定版也不会被标成「最新稳定版」,因为 label 那份计算里 stableLabeled 已经在首个非 beta 时被占用了。

安装器直链 ​csharppublic static string ForgeInstallerUrl(string mcVersion, string build) =>

$"https://maven.minecraftforge.net/net/minecraftforge/forge/{mcVersion}-{build}/forge-{mcVersion}-{build}-installer.jar";mcVersion 是游戏版本(1.21.1),build 是列表里那个去掉前缀的构建号(52.0.28),拼出来是 .../forge/1.21.1-52.0.28/forge-1.21.1-52.0.28-installer.jar。

NeoForge 多一层判断:

csharppublic static string NeoForgeInstallerUrl(string mcVersion, string build)

{

var ver = build.StartsWith($"{mcVersion}-", StringComparison.Ordinal)

? build[(mcVersion.Length + 1)..] : build;

return mcVersion == "1.20.1"

? $"https://maven.neoforged.net/releases/net/neoforged/forge/{mcVersion}-{ver}/forge-{mcVersion}-{ver}-installer.jar"

: $"https://maven.neoforged.net/releases/net/neoforged/neoforge/{ver}/neoforge-{ver}-installer.jar";

}1.20.1 走 legacy 的 forge 构件,路径里带 {mcVersion}-{ver} 双段;其余走 neoforge 构件,路径只有构建号 {ver};build 如果已经被拼过 MC 前缀(例如从某些 UI 状态带过来),先剥一次,避免出现 1.20.1-1.20.1-47.1.106。运行官方安装器 ​csharpvar javaExe = await EnsureJavaAsync(requiredJavaMajor, progress, ct);EnsureJavaAsync 的策略是「先用现成的,没有再下」:

csharpvar runtimes = await _java.ListRuntimesAsync(ct);

var fit = runtimes

.Where(r => r.MajorVersion >= requiredMajor)

.OrderBy(r => r.MajorVersion)

.FirstOrDefault();

if (fit is not null) return fit.Executable;

Report(progress, $"未找到 Java {requiredJavaMajor}+,正在自动下载运行时…");

return await _java.DownloadRuntimeAsync(requiredJavaMajor, progress, ct);注意是 >= 而不是 ==:装了 Java 21 的用户跑需要 17 的 installer 不会被拦。排序取最小的满足版本,避免为了装 1.16 的 Forge 去用 Java 21 而触发安装器的版本检查。

然后是临时目录与安装器文件:

csharpvar tempDir = Path.Combine(Path.GetTempPath(), "starcloud-installer");

Directory.CreateDirectory(tempDir);

var name = Path.GetFileName(new Uri(installerUrl).LocalPath);

var installerPath = Path.Combine(tempDir, name);

Report(progress, $"下载 {name}…");

await _downloader.DownloadFileAsync(installerUrl, installerPath, null,

settings.DownloadMaxRetries, ct);目录名固定为 starcloud-installer,多次安装互不冲突(文件名里带版本与构建号);Path.GetFileName(new Uri(...).LocalPath) 是为了先做 URI 解码,再把 %20 之类还原成真实文件名;第三个参数传 null(sha1):官方 installer 的 maven 页面不提供稳定可用的哈希,所以这条下载不走完整性校验、也不参与 P2P,失败只靠重试。进程启动:

csharpvar psi = new ProcessStartInfo

{

FileName = javaExe,

UseShellExecute = false,

RedirectStandardOutput = true,

RedirectStandardError = true,

CreateNoWindow = true,

WorkingDirectory = tempDir,

};

psi.ArgumentList.Add("-jar");

psi.ArgumentList.Add(installerPath);

psi.ArgumentList.Add("--installClient");

psi.ArgumentList.Add(settings.DotMinecraft);参数顺序是固定的 java -jar --installClient <游戏目录>,用 ArgumentList 而不是拼字符串——路径里有空格(C:\Users\…\AppData\Roaming\.minecraft)时拼接必炸。

WorkingDirectory = tempDir 也是必须的:Forge installer 会在工作目录写日志与中间文件,指到用户目录会留下垃圾。

输出处理:

csharpvar stdErrTask = p.StandardError.ReadToEndAsync(ct);

string? lastLine = null;

var logTask = Task.Run(async () =>

{

while (await p.StandardOutput.ReadLineAsync(ct) is { } line)

{

if (line.Length > 0)

{

lastLine = line;

Report(progress, line);

}

}

}, ct);

await p.WaitForExitAsync(ct);两个流必须同时读。只读 stdout 而不读 stderr,子进程写满 stderr 缓冲区就会阻塞,表现为 installer 卡死。stderr 用 ReadToEndAsync 一次性收集,stdout 逐行 Report 推给进度条——所以安装 Forge 时进度文案是一行行 installer 日志。

退出码判定:

csharpif (p.ExitCode != 0)

{

var err = await stdErrTask;

throw new InvalidOperationException(

$"安装器执行失败(exit {p.ExitCode}):{(err.Length > 0 ? err : lastLine)}");

}

Report(progress, $"安装器执行完成({mcVersion})");报错信息优先用 stderr,stderr 为空时退回最后一行 stdout。实践中 Forge installer 的失败原因经常只打在 stdout 上(例如 There is no minecraft launcher profile),只取 stderr 会得到一条空错误。

必须先预置 launcher_profiles.json

Forge 官方安装器启动后会读 <游戏目录>/launcher_profiles.json,缺了就直接报

textThere is no minecraft launcher profile...并返回退出码 1。PCL / HMCL 都在跑安装器前自动补这个文件,这里保持一致:

csharppublic static bool EnsureLauncherProfiles(string dotMinecraft)

{

var path = Path.Combine(dotMinecraft, "launcher_profiles.json");

if (File.Exists(path)) return false;

Directory.CreateDirectory(dotMinecraft);

const string json = """

{

"profiles": {},

"settings": {

"enableSnapshots": false,

"keepLauncherOpen": false,

"showGameLog": false,

"soundOn": false

},

"version": 3

}

""";

File.WriteAllText(path, json, new System.Text.UTF8Encoding(false));

…

}两个细节:

已存在就返回 false,绝不覆盖。用户可能真的用官方启动器,那份文件里有他的账号档案与配置。必须 UTF-8 无 BOM。Forge 用 Gson 解析,带 BOM 会直接解析失败——new UTF8Encoding(false) 的构造参数就是「不写 BOM」。最小结构 profiles: {} 就够:安装器只要求文件存在且是合法 JSON,不需要里面有对应版本的档案。

安装器到底写了什么 ​InstallViaInstallerAsync 自己没有写任何版本 JSON。--installClient 的产出是:

versions/-forge-/ 下的版本 JSON 与 patched jar(源码注释写作 versions/-forge-,id 即传入的 mcVersion);/libraries/ 下补齐 Forge 运行时依赖与 installer 生成的反混淆映射;某些版本还会在 libraries 里放 client-extra、forge-…-client.jar 这类产物。也就是说:装完 Forge 后, 那个目录仍然是原版,Forge 是另一个独立版本目录。这是 Forge 安装器的既定行为,不是启动器漏了合并——自己拼一份 merged JSON 是做不到的,因为 installer 还要执行 jar patch、生成映射这些步骤。

已知边界:从下载页装第二份带 Forge 的副本会失败

StartDownload_Click 在目标已安装时会把 id 换成用户输入的新副本名,随后:

csharpForgeInstallerUrl(id, forge) // → .../forge/1.20.1-2-47.4.0/forge-1.20.1-2-47.4.0-installer.jar

NeoForgeInstallerUrl(id, neo) // → 同样多出一段 -2maven 上不存在这种构件,DownloadFileAsync 直接 404,安装中断。副本请用版本页的「复制实例」,见版本隔离与实例档案。

失败分支 ​失败点触发条件表现构建列表请求失败maven / promotions 不可达DownloadPage 的 SafeBuildsAsync 捕获后返回空列表,UI 显示「无可用构建」,安装按钮仍可用但无版本可选构建列表解析异常promotions_slim.json 缺 promos 字段GetProperty 抛 KeyNotFoundException,同样被上层吞成空列表安装器 404构建号与 MC 版本对不上、或 id 被改名DownloadFileAsync 重试 DownloadMaxRetries 次后抛出Java 缺失且下载失败无网络 / Adoptium 不可达DownloadRuntimeAsync 抛出,安装未开始launcher_profiles.json 缺失未调用 EnsureLauncherProfiles 或被用户删掉installer 退出码 1,错误文本落进 ErrorBarlauncher_profiles.json 带 BOM外部工具写入Gson 解析失败,同样是退出码 1,但报错在 stdout 上installer 非零退出Java 版本过低、磁盘满、网络中断安装器执行失败(exit {code}):{stderr 或最后一行 stdout}用户取消ct 被取消WaitForExitAsync 抛 OperationCanceledException;子进程不保证被杀掉,日志里可能还有 installer 输出原版 JSON 缺失用户先删了 versions//.jsoninstaller 找不到 base 版本,退出码非零排查顺序建议:先看 ErrorBar 里的 exit code 与那行文本 → 再看 %TEMP%\starcloud-installer\ 下 installer 自己写的日志 → 最后看 /logs/ 与 starcloud 日志里的 install 分类记录。

为什么这样设计 ​不自己拼 Forge 的版本 JSON。Forge 的安装包含 jar patch、映射生成、processor 执行,靠下载文件 + 写 JSON 复刻不出来。官方 installer 是唯一可靠来源,代价是必须依赖 Java 与 launcher_profiles.json。前置原版安装不可省。installer 要从 versions// 读原版 JSON 与 jar 作为输入;不做这一步的报错很隐晦(installer 直接找不到 base)。Java 用「最小满足版本」。Forge installer 对 Java 版本敏感,拿最新的 JDK 去跑老版本的 installer 反而容易失败。stdout 当进度、stderr 当错误。installer 不提供结构化进度,逐行转发是唯一能反映「它还在动」的方式;而失败原因经常只在其中一路出现,所以两路都要留。相关链接:安装原版、安装 Fabric 与 Quilt、加载器版本怎么选、版本隔离与实例档案、安装 Java、常见坑