Skip to content

classpath 与继承链 ​

classpath 决定 JVM 能找到哪些类,由两段拼成:库(依赖)+ 客户端 jar(主类所在)。前者来自版本 JSON 的 libraries,后者是这一局实例自己的产物。

继承链:inheritsFrom 递归合并 ​

加载器实例(Fabric/Quilt)不是完整版本,只声明自己相对原版的差异,靠 inheritsFrom 指向父版本。InstallerService.LoadVersionAsync 沿链递归合并:

csharp
if (!string.IsNullOrEmpty(version.InheritsFrom))
{
    var parent = await LoadVersionAsync(dotMinecraft, version.InheritsFrom, ct);
    return MergeVersions(parent, version);
}

合并规则(MergeVersions):

csharp
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 层:

csharp
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 是唯一的判定入口:

csharp
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。

csharp
// 实例目录名才是「这次启动的实例」: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 的定位做成三级兜底:

csharp
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 结尾的文件,否则会挑到一个源码包当客户端。三级全落空时启动直接抛异常:

csharp
var ownJar = ResolveClientJar(dotMc, instanceId, versionId)
             ?? throw new InvalidOperationException(
                 $"实例 {instanceId} 缺少客户端 jar({instanceId}.jar):请先在版本页执行「补全」再启动");

拼 classpath ​

csharp
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 文件系统不区分大小写,去重时也不该区分。

相关链接:启动参数怎么构建 · 版本隔离与实例档案 · 从点击到游戏起来

基于 MIT 许可证(有修改)开源