classpath 与继承链
classpath 决定 JVM 能找到哪些类,由两段拼成:库(依赖)+ 客户端 jar(主类所在)。前者来自版本 JSON 的 libraries,后者是这一局实例自己的产物。
继承链:inheritsFrom 递归合并
加载器实例(Fabric/Quilt)不是完整版本,只声明自己相对原版的差异,靠 inheritsFrom 指向父版本。InstallerService.LoadVersionAsync 沿链递归合并:
if (!string.IsNullOrEmpty(version.InheritsFrom))
{
var parent = await LoadVersionAsync(dotMinecraft, version.InheritsFrom, ct);
return MergeVersions(parent, version);
}合并规则(MergeVersions):
parent.MainClass = string.IsNullOrEmpty(child.MainClass) ? parent.MainClass : child.MainClass;
parent.Libraries.AddRange(child.Libraries);
if (child.Arguments is not null)
{
parent.Arguments.Game.AddRange(child.Arguments.Game);
parent.Arguments.Jvm.InsertRange(0, child.Arguments.Jvm); // 注意是 Insert(0)
}
parent.InheritsFrom = null;- mainClass 覆盖。子版本有就用自己的。Fabric 的
net.fabricmc.loader.impl.launch.knot.KnotClient就是靠这条覆盖掉原版的Main。 - 库追加。接在父版本之后。
libraries里可能有重复条目(NeoForge 的版本 JSON 就有),拼 classpath 时要保序去重。 - Game 参数追加。接在父版本之后。
- Jvm 参数 Insert(0)。插到最前面。Fabric 那些改写主类的参数必须先于父版本的
-cp生效,插在开头才对。
合并完之后 InheritsFrom 被置空,避免上层再误判继承关系。
沿链解析原版版本号是另一条路径,InstanceVersion.BaseGameVersion 走同一条链,但深度封顶 6 层:
private const int MaxDepth = 6;
for (var depth = 0; depth < MaxDepth && !string.IsNullOrEmpty(current); depth++)上限是防环:手改坏的 JSON 可能 A 继承 B、B 继承 A,没有上限就是无限递归。
库规则判定
libraries 每一项都可以带 rules,声明它在什么平台/什么条件下才进 classpath。InstallerService.RulesAllow 是唯一的判定入口:
public static bool RulesAllow(List<OsRule>? rules, string? osName = null)
{
osName ??= PlatformInfo.MojangOsName;
if (rules is null || rules.Count == 0) return true;
var allowed = false;
foreach (var rule in rules)
{
// 带 features 的规则:启动器未启用任何 feature,不匹配(杜绝 --demo 等试玩参数)
if (rule.Features is { Count: > 0 }) continue;
var matches = rule.Os is null || rule.Os.Name is null ||
rule.Os.Name.Equals(osName, StringComparison.OrdinalIgnoreCase);
// 32 位专用规则在 64 位进程上不匹配(如 -Xss1M)
if (matches && rule.Os?.Arch == "x86" && Environment.Is64BitProcess) matches = false;
if (!matches) continue;
if (rule.Action == "allow") allowed = true;
else if (rule.Action == "deny") allowed = false;
}
return allowed;
}allowed初值是 false,只有显式的allow才置真。规则是后写覆盖语义:先 allow 后 deny 就是不通过。rule.Features非空一律跳过。Mojang 的规则里有{"action": "allow", "features": {"is_demo_user": true}}这种——它控制--demo试玩模式。启动器不实现 feature 协商,这类规则一律不匹配不是 bug:认了它就等于宣布"我是试玩用户"。- x86 arch 规则在 64 位进程上不匹配。
-Xss1M这类只对 32 位 JVM 有意义的参数会被跳过。
平台名 osName 可以显式传入——安卓侧用它模拟别的平台,测试也用它做跨平台单测。
Maven 坐标:Fabric/Quilt 的库没有 downloads
Mojang 的库条目带 downloads.artifact(URL + path + sha1),但 Fabric/Quilt 的 launcher-meta profile 只给 name + url + sha1,形状不一样。MavenArtifacts.Resolve 把两者归一:优先取 downloads.artifact,否则按 Maven 约定把 group:artifact:version[:classifier][@ext] 拼成 group/artifact/version/artifact-version[-classifier].ext。
只认 downloads 会丢掉 loader jar
早期安装器和启动命令只认 downloads.artifact,结果 fabric-loader / intermediary / ASM 这些 jar 既没下载也没进 classpath,Java 报"找不到 Fabric 核心启动类"。PCL 能启动是因为它按 Maven 坐标解析。现在安装、预检、classpath 三处统一走 MavenArtifacts.Resolve。
核心坑:实例目录名才是"这次启动的实例"
版本 JSON 里的 id 不一定等于目录名。同一版本装第二份时,安装器生成不冲突的目录名 26.3-2,但 JSON 里的 id 可能还写着 26.3。
// 实例目录名才是「这次启动的实例」:jar 与 natives 都由安装器按实例目录名落盘。
// JSON 里的 id 可能只是原版号(例如第二个 26.3 实例目录叫 26.3-2,JSON id 仍是 26.3),
// 拿它拼路径会指向不存在的目录,jar 被丢掉后 Java 直接报 ClassNotFoundException。
var instanceId = string.IsNullOrWhiteSpace(options.VersionId) ? versionId : options.VersionId;
var versionDir = _installer.VersionDir(dotMc, instanceId);用 JSON id 拼路径 → versions/26.3/26.3.jar 不存在 → jar 被 File.Exists 过滤掉 → classpath 里没有主类 jar → ClassNotFoundException。目录叫 26.3-2 时,正确路径是 versions/26.3-2/26.3-2.jar。
(InstallerService.InstallVanillaAsync 里会调 InstanceLayout.PatchJsonId 同步 JSON 的 id,但那覆盖不了所有来源——手改目录名、从整合包导入、跨版本复制都可能导致 id 与目录名脱节。)
ResolveClientJar 的三级查找
正因为 id 不可靠,客户端 jar 的定位做成三级兜底:
public static string? ResolveClientJar(string dotMinecraft, string instanceId, string? jsonId)
{
// 1) versions/<实例>/<实例>.jar —— 安装器落盘位置
var exact = Path.Combine(dotMinecraft, "versions", instanceId, instanceId + ".jar");
if (File.Exists(exact)) return exact;
// 2) 实例目录里唯一的客户端 jar —— 手工改过目录名的存量实例
var dir = Path.Combine(dotMinecraft, "versions", instanceId);
if (Directory.Exists(dir))
{
var jars = Directory.EnumerateFiles(dir, "*.jar", SearchOption.TopDirectoryOnly)
.Where(j => !IsSideArtifact(j))
.OrderByDescending(f => new FileInfo(f).Length).ToList();
if (jars.Count > 0) return jars[0];
}
// 3) versions/<JSON id>/<JSON id>.jar —— 加载器实例继承原版时兜底
if (!string.IsNullOrWhiteSpace(jsonId) &&
!string.Equals(jsonId, instanceId, StringComparison.OrdinalIgnoreCase))
{
var byJsonId = Path.Combine(dotMinecraft, "versions", jsonId, jsonId + ".jar");
if (File.Exists(byJsonId)) return byJsonId;
}
return null;
}第二级要过滤掉 -sources / -javadoc / -dev 结尾的文件,否则会挑到一个源码包当客户端。三级全落空时启动直接抛异常:
var ownJar = ResolveClientJar(dotMc, instanceId, versionId)
?? throw new InvalidOperationException(
$"实例 {instanceId} 缺少客户端 jar({instanceId}.jar):请先在版本页执行「补全」再启动");拼 classpath
var classpathEntries = version.Libraries
.Where(l => InstallerService.RulesAllow(l.Rules, ov?.OsName))
.Select(MavenArtifacts.Resolve)
.Where(a => a?.Path is { Length: > 0 })
.Select(a => Path.Combine(dotMc, "libraries", a!.Path!.Replace('/', Path.DirectorySeparatorChar)))
.ToList();
var ownJar = ResolveClientJar(dotMc, instanceId, versionId) ?? throw ...;
classpathEntries.Add(ownJar);
var missing = classpathEntries.Where(p => !File.Exists(p)).ToList();
if (missing.Count > 0)
LauncherLog.Write("launch", $"classpath 缺少 {missing.Count} 个文件(先补全版本):" + ...);
classpath = string.Join(PlatformInfo.ClasspathSeparator,
classpathEntries.Where(File.Exists).Distinct(PlatformInfo.PathComparer));三个决定:缺失的库被过滤掉而不是报错(缺一个 LWJGL 库,游戏起不来但至少 JVM 还能启动给出更清晰的报错,日志里记一条"classpath 缺少 N 个文件(先补全版本)");客户端 jar 必须存在,找不到直接抛,它和库不一样,缺了连主类都没有;保序去重(顺序对 classpath 有意义,前面的优先,去重必须保留首次出现的位置)。
分隔符走 PlatformInfo.ClasspathSeparator(Windows ;,类 Unix :),PathComparer 同样平台相关——Windows 文件系统不区分大小写,去重时也不该区分。