作为一个广受欢迎的流行PVP客户端 Lunar 以其优异的优化、丰富的功能,以及几乎全Minecraft版本兼容、Forge、Fabric兼容获得了用户的广泛喜爱。
本文将通过一系列逆向分析手段来解析Lunar客户端的启动流程、包括其类修补、模块化加载流程,并以此初步窥探其背后的多版本开发流程。
第一步:分析模块组成
启动参数
首先,我们先启动客户端,并用ProcessHacker(或其他任意相同功能的工具)获取其启动命令行参数:
C:\Users\***\.lunarclient\jre\56e53accb20696f802d92bd011174126b5e3154e\zulu21.30.15-ca-jre21.0.1-win_x64\bin\javaw.exe
[1] --add-modules
[2] jdk.naming.dns
[3] --add-exports
[4] jdk.naming.dns/com.sun.jndi.dns=java.naming
[5] -Dlog4j2.formatMsgNoLookups=true
[6] --add-opens
[7] java.base/java.io=ALL-UNNAMED
[8] -XX:+UseStringDeduplication
[9] -Dichor.filteredGenesisSentries=.*lcqt.*|.*Some of your mods are incompatible with the game or each other.*
[10] -Dlunar.webosr.url=file:index.html
[11] -Xmx3072m
[12] -Dichor.fabric.localModPath=C:\Users\***\.lunarclient\profiles\lunar\1.21\mods
[13] -Djava.library.path=natives
[14] -Dlog4j.configurationFile=C:\Users\***\.lunarclient\profiles\lunar\1.21\logs\config.xml
[15] -Dichor.logsFile=C:\Users\***\.lunarclient\profiles\lunar\1.21\logs\ichor-boot.log
[16] -XX:+DisableAttachMechanism
[17] -XX:-CreateCoredumpOnCrash
[18] -XX:-CreateMinidumpOnCrash
[19] -cp
[20] common-0.1.0-SNAPSHOT-all.jar;lunar-platform-mappings-v1_21_11.jar;genesis-0.1.0-SNAPSHOT-all.jar;lunar-lang.jar;lunar-emote.jar;lunar.jar;modern-0.1.0-SNAPSHOT-all.jar
[21] com.moonsworth.lunar.genesis.Genesis
[22] --version
[23] 1.21.11
[24] --launcherVersion
[25] 3.5.16-ow
[26] --launcherFeatureFlags
[27] {"enabled":["ServersCTA","PlaywireRamp","SocialMessaging","LaunchCancelling","SelectModpack","ServerRecommendedModpack","EmbeddedBrowserOpens","MissionControl","ExternalLinkSSO","HomeAdOverwolf","MissionControlAdOverwolf","Radio","RadioPremium","AMDDriverFix","ExploreModsAdOverwolf","MissionControlTopAdOverwolf","OverwolfCRN"],"disabled":["CustomizableQuickPlay","CommunityServers","NotificationsInbox","ProfileModsExploreCTA","MissionControlChat","LoaderVersionSetting","InstallVCRedistributable","OverwolfOverlay","TopbarOutplayedPromotion"]}
[28] --installationId
[29] 14c6e792-51cf-484e-9bf0-49a160795245
[30] --overwolfMuid
[31] 5f17ca54-f600-481d-a6f9-44853dc84917
[32] --sentryTraceId
[33] 516c8f1a15498ff3f11edfa714ee706b
[34] --launchId
[35] c03dde90-b4d3-468f-b90b-e47f23bcf712
[36] --canaryToken
[37] version-inject
[38] --username
[39] SuperSkidder
[40] --uuid
[41] 0e49e539cbeb47a9acf8a04957ebb9f6
[42] --xuid
[43] 2535421184951631
[44] --accessToken
[45] eyJxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[46] --userProperties
[47] {}
[48] --assetIndex
[49] 1.21
[50] --gameDir
[51] C:\Users\***\AppData\Roaming\.minecraft
[52] --texturesDir
[53] C:\Users\***\.lunarclient\textures
[54] --uiDir
[55] C:\Users\***\.lunarclient\ui
[56] --webosrDir
[57] C:\Users\***\.lunarclient\offline\multiver\natives
[58] --workingDirectory
[59] .
[60] --classpathDir
[61] .
[62] --width
[63] 854
[64] --height
[65] 480
[66] --ipcPort
[67] 28190
[68] --ichorClassPath
[69] common-0.1.0-SNAPSHOT-all.jar,lunar-platform-mappings-v1_21_11.jar,genesis-0.1.0-SNAPSHOT-all.jar,lunar-lang.jar,lunar-emote.jar,lunar.jar,modern-0.1.0-SNAPSHOT-all.jar
[70] --ichorExternalFiles
[71] user-message-patterns.json,kill-sound-chat-patterns.json,tier-tagger.json,vanilla_capes.json,waypoint-patterns.json,hypixel/quickplay.json,hypixel/bedwars.json,hypixel/teamview.json,hypixel/skyblock/kuudra-waypoints.json,hypixel/skyblock/max-levels.json,hypixel/skyblock/autocomplete-warps.json,hypixel/skyblock/item-abilities.json,hypixel/skyblock/metal-detector-locations.json,hypixel/skyblock/commands.json,hypixel/skyblock/hoppity-eggs.json,hypixel/skyblock/mineshaft-corpse-waypoints.json,hypixel/skyblock/chocolate-factory-prices.json,hypixel/skyblock/enchants.json,hypixel/skyblock/important-items.json,hypixel/skyblock/garden.json,hypixel/skyblock/splits.json,hypixel/skyblock/skill-xp.json,hypixel/skyblock/middle-click.json,hypixel/skyblock/glacite-tunnels.json,hypixel/skyblock/vendor-items.json,hypixel/skyblock/item-shop-prices.json,hypixel/skyblock/dungeon/trash-items.json,hypixel/skyblock/dungeon/rooms.json,hypixel/skyblock/dungeon/routes.json,hypixel/skyblock/dungeon/quiz-key.json,hypixel/skyblock/dungeon/waterboard-solutions.json
分析入口
阅读以上的启动参数,我们可以看到一些与类加载可能相关的内容,下面是分析过程:
第一步,看启动入口,并逐层分析
[19] -cp
[20] common-0.1.0-SNAPSHOT-all.jar;lunar-platform-mappings-v1_21_11.jar;genesis-0.1.0-SNAPSHOT-all.jar;lunar-lang.jar;lunar-emote.jar;lunar.jar;modern-0.1.0-SNAPSHOT-all.jar
[21] com.moonsworth.lunar.genesis.Genesis(使用ProcessHacker查看到他的工作目录在:C:\Users\xxx\.lunarclient\offline\multiver)

这是java的classpath和启动入口类,我们来反编译看下
在此之前,我们先大致查看这几个jar的作用,然后找到它的类加载器,并深入分析(mappings,emote,lang这几个资源类jar就省略不看了)
common-0.1.0-SNAPSHOT-all.jar
这个应该是类加载的一部分
genesis-0.1.0-SNAPSHOT-all.jar
这个就是本文要研究的类加载器了
modern-0.1.0-SNAPSHOT-all.jar
这个初步来看是Lunar客户端的多版本bridge一类的东西
lunar.jar
这个就是Lunar客户端本体以及跨版本的wrapper的代码了
第二步:分析启动流程
重点看genesis-0.1.0-SNAPSHOT-all.jar的相关代码
参数解析
package com.moonsworth.lunar.genesis;
import com.moonsworth.lunar.genesis.COIIRHCCOOOCRCRIHHHRHCRCHROCCC;
import com.moonsworth.lunar.genesis.ClientGameBootstrap;
import com.moonsworth.lunar.genesis.PreLaunchLibraryBootstrap;
import com.moonsworth.lunar.genesis.RCRICCRHHHIOIRCORCCICROHOHIOHR;
import com.moonsworth.lunar.ichor.util.ICHIRCRIRCCIICHCCCIRRROCOOCIRR;
import com.moonsworth.lunar.ichor.util.IIIORROICHCIICOOHIHIHCIOHRIHRC;
import com.moonsworth.lunar.ichor.util.OIIRHCHCRROCOHRCOCRRICOORROIRH;
import io.sentry.Sentry;
import java.io.File;
import java.lang.reflect.InvocationTargetException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.FileAttribute;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import joptsimple.ArgumentAcceptingOptionSpec;
import joptsimple.OptionParser;
import joptsimple.OptionSet;
import joptsimple.OptionSpecBuilder;
public class Genesis {
public static final OIIRHCHCRROCOHRCOCRRICOORROIRH LOGGER = new OIIRHCHCRROCOHRCOCRRICOORROIRH("Genesis").HRIHOOIRHOOHRCICCRICOOHRHIIROH(Paths.get(".ichor/genesis.log", new String[0]));
public static final Path LUNARCLIENT_DATA = Paths.get(System.getProperty("user.home"), ".lunarclient");
public static final Path DUMP_CLASSES_WHITELIST = Paths.get("./dump-whitelist.txt", new String[0]);
public static final Path PRELAUNCH_LIB_DIR = Paths.get(System.getProperty("ichor.prelaunchLibDir", "./.ichor/ichormodule-libs"), new String[0]);
public static final String FILTERED_ERRORS_REGEX = System.getProperty("ichor.filteredGenesisSentries", ".*lcqt.*");
private static final String logs = System.getProperty("ichor.logsFile", "logs/ichor-boot.log");
public static void main(String[] stringArray) {
Locale.setDefault(Locale.ROOT);
System.setOut(new IIIORROICHCIICOOHIHIHCIOHRIHRC(System.out, logs, false));
System.setErr(new IIIORROICHCIICOOHIHIHCIOHRIHRC(System.err, logs, true));
RCRICCRHHHIOIRCORCCICROHOHIOHR.init();
try {
Genesis.run(stringArray);
}
catch (Throwable throwable) {
Throwable throwable2;
if (throwable instanceof InvocationTargetException) {
InvocationTargetException invocationTargetException = (InvocationTargetException)throwable;
throwable2 = invocationTargetException.getCause();
}
System.err.println("An error occurred while launching Lunar Client.");
throwable2.printStackTrace();
int n = Genesis.matchesAnyExceptionMessage(throwable2, string -> string.contains("lcqt")) ? 58 : 1;
Pattern pattern = Pattern.compile(FILTERED_ERRORS_REGEX, 32);
if (n == 1 && !Genesis.matchesAnyExceptionMessage(throwable2, string -> pattern.matcher((CharSequence)string).matches())) {
System.err.println("Sending Sentry with this report...");
Sentry.captureException(throwable2);
} else {
System.err.println("Skipping Sentry report due to filter regex.");
}
System.out.flush();
System.err.flush();
System.exit(n);
}
}
private static boolean matchesAnyExceptionMessage(Throwable throwable, Predicate<String> predicate) {
for (Throwable throwable2 = throwable; throwable2 != null; throwable2 = throwable2.getCause()) {
if (throwable2.getMessage() == null || !predicate.test(throwable2.getMessage())) continue;
return true;
}
return false;
}
public static void run(String[] stringArray) {
OptionParser optionParser = new OptionParser();
optionParser.allowsUnrecognizedOptions();
ArgumentAcceptingOptionSpec<String> argumentAcceptingOptionSpec = optionParser.accepts("version").withRequiredArg().ofType(String.class);
ArgumentAcceptingOptionSpec<String> argumentAcceptingOptionSpec2 = optionParser.accepts("classpathDir").withRequiredArg().ofType(String.class);
ArgumentAcceptingOptionSpec<String> argumentAcceptingOptionSpec3 = optionParser.accepts("workingDirectory").withRequiredArg().ofType(String.class);
OptionSpecBuilder optionSpecBuilder = optionParser.accepts("integrationTest");
ArgumentAcceptingOptionSpec<String> argumentAcceptingOptionSpec4 = optionParser.accepts("ichorClassPath").withRequiredArg().ofType(String.class);
ArgumentAcceptingOptionSpec<String> argumentAcceptingOptionSpec5 = optionParser.accepts("ichorExternalFiles").withRequiredArg().ofType(String.class);
OptionSpecBuilder optionSpecBuilder2 = optionParser.accepts("dumpClasses");
ArgumentAcceptingOptionSpec<String> argumentAcceptingOptionSpec6 = optionParser.accepts("partialJar").withRequiredArg().ofType(String.class);
ArgumentAcceptingOptionSpec<String> argumentAcceptingOptionSpec7 = optionParser.accepts("installationId").withRequiredArg().defaultsTo("not supplied", (String[])new String[0]);
OptionSet optionSet = optionParser.parse(stringArray);
boolean bl = optionSet.has(optionSpecBuilder);
Path path = Paths.get(optionSet.valueOf(argumentAcceptingOptionSpec2), new String[0]);
String string = optionSet.valueOf(argumentAcceptingOptionSpec3);
Path path2 = new File(string).toPath();
Path path3 = path2.resolve("overrides");
String string2 = optionSet.valueOf(argumentAcceptingOptionSpec7);
RCRICCRHHHIOIRCORCCICROHOHIOHR.setUser(string2);
try {
Files.createDirectories(path3, new FileAttribute[0]);
}
catch (Exception exception) {
LOGGER.IOICRRICOIIICOCORHRRCHROHRICRH(OIIRHCHCRROCOHRCOCRRICOORROIRH.IOICRRICOIIICOCORHRRCHROHRICRH.WARN, exception);
}
List<String> list = null;
if (optionSet.has(optionSpecBuilder2) && Files.exists(DUMP_CLASSES_WHITELIST, new LinkOption[0])) {
list = Files.readAllLines(DUMP_CLASSES_WHITELIST);
LOGGER.info("Using whitelist of classes to dump: " + String.valueOf(list), new Object[0]);
}
Path path4 = null;
if (optionSet.has(argumentAcceptingOptionSpec6)) {
path4 = Paths.get(optionSet.valueOf(argumentAcceptingOptionSpec6), new String[0]);
}
String string3 = optionSet.valueOf(argumentAcceptingOptionSpec);
Path path5 = LUNARCLIENT_DATA.resolve("mx-cache");
String[] stringArray2 = null;
String[] stringArray3 = null;
if (optionSet.has(argumentAcceptingOptionSpec4)) {
stringArray2 = optionSet.valueOf(argumentAcceptingOptionSpec4).split(",");
}
if (optionSet.has(argumentAcceptingOptionSpec5)) {
stringArray3 = optionSet.valueOf(argumentAcceptingOptionSpec5).split(",");
for (String object2 : stringArray3) {
LOGGER.info("Found external file: " + object2, new Object[0]);
}
}
String[] stringArray4 = Genesis.getClasspath();
URL[] uRLArray = new URL[stringArray4.length];
for (int i = 0; i < stringArray4.length; ++i) {
Path path6 = Paths.get(stringArray4[i], new String[0]);
if (!string2.equals("not supplied")) {
LOGGER.info("Found classpath URL: " + String.valueOf(path6) + " with checksum " + com.moonsworth.lunar.IICCIHOIHHIOCOCHIORORORRORCIOO.RCRICCRHHHIOIRCORCCICROHOHIOHR.COIIRHCCOOOCRCRIHHHRHCRCHROCCC.COIOCROORIRHHOHIRCRIORHHHROIRH(path6), new Object[0]);
}
uRLArray[i] = path6.toUri().toURL();
}
if (uRLArray.length == 0) {
throw new IllegalStateException("No classpath URLs found from " + System.getProperty("java.class.path"));
}
ClassLoader classLoader = Genesis.class.getClassLoader();
try (COIIRHCCOOOCRCRIHHHRHCRCHROCCC cOIIRHCCOOOCRCRIHHHRHCRCHROCCC = new COIIRHCCOOOCRCRIHHHRHCRCHROCCC("Prelaunch", uRLArray, classLoader);){
PreLaunchLibraryBootstrap.IOICRRICOIIICOCORHRRCHROHRICRH iOICRRICOIIICOCORHRRCHROHRICRH = new PreLaunchLibraryBootstrap.IOICRRICOIIICOCORHRRCHROHRICRH(string3, path5, path, path3, PRELAUNCH_LIB_DIR);
List list2 = (List)cOIIRHCCOOOCRCRIHHHRHCRCHROCCC.COIIRHCCOOOCRCRIHHHRHCRCHROCCC(PreLaunchLibraryBootstrap.class, iOICRRICOIIICOCORHRRCHROHRICRH);
ArrayList arrayList = Arrays.stream(uRLArray).collect(Collectors.toCollection(ArrayList::new));
for (Object object : list2) {
try {
LOGGER.info("Adding pre-launch library to game classpath: " + String.valueOf(object), new Object[0]);
if (!ICHIRCRIRCCIICHCCCIRRROCOOCIRR.CORHOOIICIHHRRRIHIHCIRRIRHOCIC((Path)object)) {
throw new IllegalStateException("Library is not a file: " + String.valueOf(object));
}
arrayList.add(object.toUri().toURL());
}
catch (MalformedURLException malformedURLException) {
throw new RuntimeException(malformedURLException);
}
}
try (COIIRHCCOOOCRCRIHHHRHCRCHROCCC cOIIRHCCOOOCRCRIHHHRHCRCHROCCC2 = new COIIRHCCOOOCRCRIHHHRHCRCHROCCC("Game", arrayList.toArray(new URL[0]), classLoader);){
Object object;
Thread.currentThread().setContextClassLoader(cOIIRHCCOOOCRCRIHHHRHCRCHROCCC2);
object = new ClientGameBootstrap.IOICRRICOIIICOCORHRRCHROHRICRH(stringArray, path, path5, path3, string3, stringArray2, stringArray3, bl, path2, PRELAUNCH_LIB_DIR, path4, list);
cOIIRHCCOOOCRCRIHHHRHCRCHROCCC2.COIIRHCCOOOCRCRIHHHRHCRCHROCCC(ClientGameBootstrap.class, object);
}
}
}
public static String[] getClasspath() {
return System.getProperty("java.class.path").split(File.pathSeparator);
}
}
加载类
除去一些日志、错误报告分析等内容,需要我们关注的核心逻辑如下(我们切换到IntelliJIDEA进行分析,因为可以借助IDE的分析和推断能力):

这里的

是他的自定义ClassLoader,也就是真正的类加载核心,而前面的其他代码,主要包括了Sentry的遥测集成、读取参数和一些参数的拼接处理等。
我们来看下这个ClassLoader的核心逻辑:
它先是创建了一个var46,传入了所需参数(主要是一个Uri列表和一个ClassLoader)
这其中的一个逻辑就是在findClass处做了一个排除,排除掉它自身,防止回环
下载资源
随后呢,它创建了PreLaunchLibraryBootstrap.IOICRRICOIIICOCORHRRCHROHRICRH var28
通过一些分析,可以看出,这个PreLaunchLibraryBootstrap实际上是一个下载器,用于从网络下载启动所需的所有library,并返回一个Uri列表

这一行的作用其实就是通过反射调用这个apply方法
既然明确了它的功能,那么里面的具体逻辑我们就粗略过一下,只需要知道最终返回的是一个jar的path列表即可
apply方法主要是获取了三个参数的内容ichor.fabricLoaderVersion、ichor.launch.libraries、ichor.prelaunchLibDir
然后传递到download方法中,download方法代码如下:
public static List<Path> download(IRIOIOIRRRCOORHCOOOHHRIHOCOCOO var0, Map<String, String> var1, Path var2) {
try {
Files.createDirectories(var2);
com.moonsworth.lunar.genesis.COIIRHCCOOOCRCRIHHHRHCRCHROCCC var3 = (com.moonsworth.lunar.genesis.COIIRHCCOOOCRCRIHHHRHCRCHROCCC)com.moonsworth.lunar.CRHORCHCOOCCRHORIHOOOIHCIOOHHR.IRIOIOIRRRCOORHCOOOHHRIHOCOCOO.class.getClassLoader();
HashSet var4 = new HashSet();
try {
ServiceLoader.load(com.moonsworth.lunar.CRHORCHCOOCCRHORIHOOOIHCIOOHHR.RCOIOOHHRRCCRHRIIRORHCIRICCIII.class, var3).iterator().forEachRemaining((var1x) -> var4.addAll(Arrays.asList(var1x.RCOIOOHHRRCCRHRIIRORHCIRICCIII())));
} catch (ServiceConfigurationError var6) {
throw new ServiceConfigurationError("Failed to find PreLaunchLibraryRequests! PreLaunchLibrary is loaded in " + var3.getName() + " and PreLaunchLibraryRequest is loaded in " + com.moonsworth.lunar.CRHORCHCOOCCRHORIHOOOIHCIOOHHR.RCOIOOHHRRCCRHRIIRORHCIRICCIII.class.getClassLoader().getName() + " (they should be the same).", var6);
}
return var4.isEmpty() ? List.of() : (List)var4.stream().flatMap((var4x) -> {
Path[] var5 = download(var4x, var0, (String)var1.get(var4x.name()), var2, var3);
return Stream.of(var5);
}).collect(Collectors.toList());
} catch (Throwable var7) {
throw var7;
}
}这里用到了ServiceLoader,这是Java的一个SPI接口,用来实现运行时动态加载接口实现。
换言之,在这里的作用就是:可能有多套download实现,通过ServiceLoader这个spi获取所有的download接口实现,并且在下面用stream逐个执行download后,将返回的Path[] 列表collect在一起然后返回。

这就是上文的依次执行的download()方法,会从maven仓库中下载对应的jar
Transform
在全部下载完jar后,会对jar进行一次transform处理,也就是这段代码:

(这里有做一个缓存,如果不存在才会transform)
这里的var16 OCORRORHIOCOHHICOIHCIHCORRIIOR这个类的定义比较复杂,实现了多种transform逻辑,下面是AI分析给出的总结:
## 1) 类整体职责与核心字段
| 字段 | 推断含义 |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
ICHIOROIOOCRRCICCICCHRRCOCHCHR : List<CRHORCHCOOCCRHORIHOOOIHCIOOHHR>| 注入阶段(InjectStage)列表,决定 class 变换顺序 ||
OHOOHIIHRHHHCCOHCIRRCHIIIIIHHH : Map<String, ICIIOOOCCIIOCOHIOIIOOHOCROICRI>| 已加载的 IchorModule(按 moduleId 索引) ||
RCCHOCRRHHIIIIHRICIOCCRIHRORCR : List<IICCIHOIHHIOCOCHIORORORRORCIOO>| IchorLoader 列表(模块提供的各种加载器/扩展点) ||
CORORCOOOIIHCHCHOCROHIRHCOHIIC : RCRICCRHHHIOIRCORCCICROHOHIOHR| “管线上下文/执行器”,真正负责对 ClassNode/bytes 执行 stage 的 transform ||
OHHICHRIHICIOCRCRHOIOHHRCRCRHH : OIOHHIIHHRCOCIRIIRIORROIOHICII<String,String,String>| 近似三维表:moduleId × fileKey → pathString(外部文件索引) ||
HIRHRIRHROIOCHHCHOCRHRRCIRIOCH : OIOHHIIHHRCOCIRIIRIORROIOHICII<String,String,String>| 近似三维表:namespace × from → to(字符串映射表,来自 json “mappings”) ||
CHOCCOHIORHIHHHIHHIHHHCOHIHROH : Map<String, COIIRHCCOOOCRCRIHHHRHCRCHROCCC>| entrypoint/启动点注册表(按 key 获取 entrypoint) ||
IIHRIIHIOIHIHRHOIIICOIHRHICIIC : Map<Object, RCOIOOHHRRCCRHRIIRORHCIRICCIII>| injection 实例 → injection 元数据 的映射(用于校验/查找注入对象) ||
IIIIOHRIOHHRIIHRCHHOOHOIHHHCOO : IRIOIOIRRRCOORHCOOOHHRIHOCOCOO| 环境/资源管理:classpathDir、overridesDir、缓存、查找已缓存 class 等 ||
IHCOIOOCORRRROIRCIOCOHHROHCRCH : Debug timings holder| 仅在 debug 开启时统计各种耗时 ||
OOHHCRROOOOIORHOHCHRHCRROIOOIC| 系统属性开关ichor.debugPipelineTimings|---
## 2) 方法作用表格(按“功能模块”分组)
### A. 构造/生命周期
| 方法签名(原名) | 推断作用 |
| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
OCORRORHIOCOHHICOIHCIHCORRIIOR(stages, env, classLoader)| 构建 pipeline:校验 stages;创建各种表;通过ServiceLoader扫描并加载IchorModule;收集模块提供的 loaders;收集模块外部文件索引;加载 loaders;注册默认 entrypointfrex_flawless_frames);解析 loader 提供的 json mappings;输出 debug 统计 ||
close()| 关闭上下文/释放引用COROR...close();清理 map/list/set;debug 时清 timings |### B. 动态加载/注册扩展
| 方法 | 推断作用 |
|---|---|
|
IRIOIOIRRRCOORHCOOOHHRIHOCOCOO(loader)| 动态添加一个 loader:登记类名到 set;加入列表;调用loader.loadIchor(context)||
RCHRHRCHIRCRORIIHRCOHHROOCCIIR(key)| 获取/创建 entrypoint 注册器COIIRHCC...)并缓存 ||
IOICRRICOIIICOCORHRRCHROHRICRH(entryKey, cl, type, consumer)| 运行某个 entrypoint:找不到就 log;找到则entrypoint.invoke(cl, type, consumer)|### C. Class 变换(最核心)
| 方法 | 推断作用 |
| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
IOICRRICOIIICOCORHRRCHROHRICRH(className, bytes?, classLoader)→CHRHICIRHCCCOIIRIRHHCHCORCRIOI| 按需获取并变换一个类:处理 stage 重映射导致的名称集合;尝试从缓存/资源/loader(ClassProvider)拿字节码;解析成 ASMClassNode;从某个 stage 索引开始继续执行 transform;最后输出 bytes;找不到则抛ClassNotFoundException;debug 记录耗时 ||
COIIRHCCOOOCRCRIHHHRHCRCHROCCC(classPathOrName, bytes?, cl)→StageResult| 仅执行“阶段链式处理”,返回中间结构(类似“每个 stage 的 result”),用于批处理时先走一遍 stage remap/预处理 ||
IOICRRICOIIICOCORHRRCHROHRICRH(inputPath, outputPath, cl)| 批处理 jar/目录:遍历 class 文件;对每个 class 调用上面的 stage 处理 + transform;失败则降级写原始/中间结果;将最终 class 名可能做 “header/classname 修正” 后写出 |### D. Mixins / 合成类 / 额外类定义
| 方法 | 推断作用 |
|---|---|
|
RROOCCRHHOHHIOOIRIHOCIHRHIROCO(cl)| 让 context 以该 CL 初始化/扫描,然后收集所有启用 mixin runtime 的 mixin/target classes 列表(返回 Set<String>) ||
getSyntheticClasses()| 汇总所有运行时 mixin 产生的 synthetic classes ||
getExtraClassDefinitions()| 汇总所有运行时 mixin 产生的额外 class bytes(Map<className, bytes>) ||
RIIRRIHOHCHRHOIIHOROROHORCICCI()| 进入某种“mixin runtime 激活/冻结”状态:标记 context;把具备 mixin runtime 的 stage 加入一个列表;并把每个 pipeline 子单元标记为 ready/activeIRHRO...=true) ||
IOICRRICOIIICOCORHRRCHROHRICRH(cl, outPath)| 组合动作:获取 mixin 相关 class 集合;写入 outPath;并把所有外部文件(模块提供的 jars/资源)也注入到这个集合/输出(看起来像生成“需要加载/重定向”的集合) |### E. 外部文件索引 / 路径解析 / 模块查询
| 方法 | 推断作用 |
|---|---|
|
HCRRCCRHIHRCRIHICOOCOCHHRIOCHC(fileKey)| 从外部文件表中:按 fileKey 找任意一个路径字符串,解析为 Path ||
OIRRIOCIRHCICCIHOCOCRIRRRRICCH(pathString)| 把模块登记的字符串路径解析成实际 Path:支持absolute::前缀;支持.jar;支持在 overridesDir 中覆盖;否则在 classpathDir/overridesDir 下模糊匹配“以 var1 开头的文件名” ||
CHCOHCOHOHHOOIRCIOHCRROORHIIRH()| 返回所有外部文件(path)列表(排序后) ||
CCOOOCIIRHIIOCCOOCIRHHICOOCCCH(fileKey)| 根据 fileKey 反查“提供该文件的 module”(取 column 的任意 key) ||
hasModule(id)/IICHOOHCHROHOHHCCCHOORCHHHCHOR(id)| 查询模块是否存在 / 获取模块 Optional |### F. 映射(json mappings / lorenz MappingSet)
| 方法 | 推断作用 |
|---|---|
|
CRRCRCCIROCHIRIIRHCCRIIIHROROH()| 扫描某类 loader...IRIOIOIR...)返回的 JsonObject 列表,读取其中"mappings"字段,填充HIRHRIR...表namespace, from -> to||
IOCIRHRCHRRRIHOHIHOROHCHRHOHRC(ns, key)| 查映射:如果 (ns,key) 存在映射返回 mapped,否则返回原 key ||
IOICRRICOIIICOCORHRRCHROHRICRH(mappingRef)| 从 env 的 mappings 仓库里取某个 mapping 文件(返回 Optional<...bytes holder>) ||
IOICRRICOIIICOCORHRRCHROHRICRH(mappingRef, readerFactory)| 取 mapping bytes,用给定 readerFactory 构造MappingsReader,解析为MappingSet||
COIIRHCCOOOCRCRIHHHRHCRCHROCCC(mappingRef)| 同上,但使用固定 readerCHIIIO...createReader)解析为MappingSet||
IOICRRICOIIICOCORHRRCHROHRICRH(loaderType, mappingSet, classProvider)| 若存在某 loader 并实现特定接口,则把 mappings 与 classProvider 注入进去(用于 remap/resolve) |### G. Debug/统计/辅助
| 方法 | 推断作用 |
|---|---|
|
HCOHRICHRCCOHHCOHRHICHHCIOOCCC()| 打印 pipeline debug:总耗时、每个 class transform 耗时 Top、包聚合耗时、remapping 耗时、nectar timings/matches 等 ||
OICHHHIHRIHIIHRRHCOHRIRHHCHOOI(className)| debug 模式下取某个 class 的更详细统计对象 ||
IIIORROICHCIICOOHIHIHCIOHRIHRC(cl)| 切换“当前运行 classloader”;如果 cl 实现 ichor api 接口则把已加载的模块/loader类名集合传给它(类似 allowlist/共享信息) ||
OHHIHHRCCOORRRORRCHCROHCCOCCCC(obj)| obj → injection 元数据;若无则抛异常(校验:这不是一个 pipeline 注册过的 injection) |---
## 3) 复杂方法拆解 + 关键代码片段逐步注释
### 3.1 核心
IOICRRICOIIICOCORHRRCHROHRICRH(String, byte[], ClassLoader)(单类读取+变换)```java
public CHRHICIRHCCCOIIRIRHHCHCORCRIOI getTransformedClass(
String requestedName, @Nullable byte[] providedBytes, ClassLoader runningCl
) {
// 0) module-info 特判:不做 transform,直接包装返回
if (requestedName.endsWith("module-info")) {
return new CHRHICIRHCCCOIIRIRHHCHCORCRIOI(requestedName, providedBytes);
}
// 1) 记录/切换当前运行的 ClassLoader(并可能通知 ichor api 的 classloader)
this.ensureRunningClassLoader(runningCl);
long start = now();
// 2) 计算“可能的类名集合 var7”
// 目的:InjectStage 可能会重映射/改名,所以要把各种可能名字都纳入候选,以便:
// - 从缓存命中
// - 从资源中找到真正的 .class
// - 或从 ClassProvider 拿到
String currentName = requestedName;
Set<String> candidates = new HashSet<>();
candidates.add(requestedName);
// 2.1) 从后往前跑一遍 stage 的“名称映射”,得到最终名字 currentName
for (int i = stages.size() - 1; i >= 0; --i) {
InjectStage st = stages.get(i);
currentName = context.remapName(st, currentName);
candidates.add(currentName);
}
// 2.2) 再从前往后跑一遍:把 candidates 经过每个 stage 的 remap 扩张
// 形成闭包:避免漏掉某些 stage 组合下产生的名字
for (InjectStage st : stages) {
Set<String> extra = new HashSet<>();
for (String c : candidates) {
extra.add(context.remapName(st, c));
}
candidates.addAll(extra);
}
// 3) 看 env/cache 是否已经给出了某个候选类的 bytes 以及“已经跑到哪个 stage”
int startStageIndex = 0;
String effectiveName = currentName;
byte[] bytes = providedBytes;
Optional<CachedHit> hit = env.lookupCachedClassBytes(candidates);
if (hit.isPresent()) {
// 命中缓存:说明该类可能已被处理到某个 stage 之后
startStageIndex = stages.indexOf(hit.stage()) + 1;
effectiveName = hit.className();
bytes = hit.bytes();
}
// 4) 若还没 bytes,则从 runningCl.getResourceAsStream / 自定义 CL / ClassProvider 取
if (bytes == null) {
InputStream in = null;
// 4.1) 优先用自定义 ClassLoader 的“特殊取资源方式”
Function<String, InputStream> resource = (runningCl instanceof SpecialCL)
? ((SpecialCL) runningCl)::getResourceStream
: runningCl::getResourceAsStream;
// 4.2) 尝试 effectiveName -> requestedName -> candidates 逐个找 .class
in = resource.apply(effectiveName.replace('.', '/') + ".class");
if (in == null) in = resource.apply(requestedName.replace('.', '/') + ".class");
if (in == null) {
for (String c : candidates) {
in = resource.apply(c.replace('.', '/') + ".class");
if (in != null) break;
}
}
// 4.3) 如果依然没找到 stream,尝试从 loader 中的 ClassProvider 直接取 bytes
if (in == null) {
for (Loader l : loaders) {
if (l instanceof ClassProvider) {
bytes = ((ClassProvider) l).get(effectiveName.replace('.', '/'));
if (bytes != null) break;
}
}
}
// 4.4) 如果找到 stream,则 readAllBytes
if (bytes == null && in != null) {
bytes = in.readAllBytes();
in.close();
}
}
// 5) 有 bytes 则 parse 成 ASM ClassNode(允许后续 transform)
ClassNode node = null;
if (bytes != null) {
ClassReader cr = new ClassReader(bytes);
node = new ClassNode();
cr.accept(node, 0);
}
// 6) 准备“要执行的 stage 子数组”:从 startStageIndex 开始到结尾
InjectStage[] remainingStages = stages.subList(startStageIndex, stages.size()).toArray(...);
// 7) 特判:如果是 mixin 本身的类(org/spongepowered/asm/mixin/),绕开某部分 transform
if (node == null || !node.name.startsWith("org/spongepowered/asm/mixin/")) {
try {
// 对 ClassNode 做一系列 stage 处理(可能包含重映射、mixin 织入等)
node = context.transformNode(remainingStages, runningCl, true, effectiveName, node);
} catch (Throwable t) {
throw new FatalIchorError("Failed to transform " + requestedName, t);
}
}
// 8) 最后一步:把最终 stage 输出为 bytes(写回/生成)
bytes = context.toBytes(remainingStages[remainingStages.length - 1], runningCl, node, true);
// 9) 如果输出 bytes 还是 null,认为找不到该类
if (bytes == null) {
throw new ClassNotFoundException("IchorPipeline can't find class in " + runningCl.getName()
+ ": " + effectiveName + "(" + requestedName + ")");
}
// 10) 输出 className 以 ClassNode 的 name 为准
String finalName = node.name.replace('/', '.');
// 11) debug 记录耗时
if (debug) timings.classTimes.put(finalName, now()-start);
return new CHRHICIRHCCCOIIRIRHHCHCORCRIOI(finalName, bytes);
}
```
这段方法的核心目的:
- 解决“类名经过多 stage 可能发生变换”的问题(候选集合闭包)
- 多来源获取原始 bytes(cache / ClassLoader resource / ClassProvider)
- 把 class 逐 stage 变换为最终 bytes,并带 debug 计时
---
### 3.2 外部文件路径解析
OIRRIOCIRHCICCIHOCOCRIRRRRICCH(String)```java
public Optional<Path> resolveExternalPath(String spec) {
Path classpath = env.classpathDir();
Path overrides = env.overridesDir();
boolean hasOverrides = overrides != null;
Path result;
// 1) absolute::/xxx/yyy 直接当绝对路径
if (spec.startsWith("absolute::")) {
result = Paths.get(spec.substring("absolute::".length()));
// 2) 以 .jar 结尾:当作相对文件,优先 overridesDir,其次 classpathDir
} else if (spec.endsWith(".jar")) {
if (hasOverrides) {
result = overrides.resolve(spec);
if (Files.notExists(result)) result = classpath.resolve(spec);
} else {
result = classpath.resolve(spec);
}
// 3) 否则当作“前缀匹配文件名”:在目录下 list,找 fileName startsWith(spec)
} else {
result = findFirstStartsWith(spec, classpath);
// overrides 若存在则覆盖
if (hasOverrides) {
Path overrideHit = findFirstStartsWith(spec, overrides);
if (overrideHit != null) result = overrideHit;
}
}
return Optional.ofNullable(result);
}
```
它体现了一个典型的资源覆盖逻辑:**overridesDir > classpathDir**,并支持“前缀匹配”以适应带版本号的文件名(例如
xxx-1.2.3.jar)。---
### 3.3 解析 JSON mappings
CRRCRCCIROCHIRIIRHCCRIIIHROROH()该方法扫描 loader 给的 json,读取结构大致如下:
```json
{
"mappings": {
"namespaceA": { "from1": "to1", "from2": "to2" },
"namespaceB": { "x": "y" }
}
}
```
然后写入表
HIRHRIRHROIOCHHCHOCRHRRCIRIOCH.put(namespace, from, to),用于IOCIRHRCHRRRIHOHIHOROHCHRHOHRC(ns, key)查询。---
## 4) 工作流程(从初始化到变换/输出)
### 4.1 初始化阶段(构造函数)
1. 校验必须至少有一个
InjectStage。2. 初始化各种容器(模块表、loader 列表、外部文件表、entrypoint 表、debug timings 等)。
3. 用
ServiceLoader.load(IchorModule, pipelineClassLoader)扫描模块:- 记录模块类名到
COOCIIICO...(后续可能用于 classloader allowlist/共享信息)-
modulesById.put(moduleId, module)- 从模块取 loaders:加入
RCCHO...,并记录 loader 类名- 从模块取外部文件映射:写入
OHHICHR...(moduleId × fileKey → pathString)4. 对所有 loader 调用
loadIchor(context)完成注册/初始化。5. 注册内置 entrypoint
frex_flawless_frames→FlawlessFrames::register。6. 扫描 loader 的 json 并提取
"mappings",填充字符串映射表HIRHRIR...。7. debug 开启时输出加载耗时、外部文件数、loader 数等。
### 4.2 运行时“按需变换 class”
当外部(通常是自定义 ClassLoader)请求某个类时,会调用:
-
getTransformedClass(name, maybeBytes, runningCL)其内部会:
1. 确认当前运行 CL 并在切换时通知 ichor api classloader(把已加载模块/loader类名集合发给它)。
2. 计算候选类名集合(解决多 stage remap)。
3. 走 cache 命中则跳过已完成的 stage。
4. 否则从 resource / ClassProvider 获取 bytes。
5. 用 ASM 解析为
ClassNode并执行剩余 stages 的 transform。6. 输出最终 bytes,返回
{finalClassName, bytes}。### 4.3 批处理输出(transform jar/目录)
-
transformPath(in, out, cl)会遍历 class 文件:- 先跑一次 stage 预处理(得到可能的新类名/中间结果)
- 再调用单类 transform
- 失败则降级写未完全变换结果(避免整个批处理失败)
### 4.4 mixin runtime 相关能力
- 可以收集 mixin/target class 集合、synthetic classes、extra class definitions
-
RIIRRIHO...()类似“切到运行时织入模式/冻结阶段列表”,为 mixin runtime 做准备。
经过我的阅读,它主要实现的功能是:
将多个jar合并,transform其中需要修改的class后,合并到一个jar里

这其中tranformer的逻辑有这几个:


这个方法比较长,主要的作用是remap以及执行pipeline的多道injection逻辑,其中包括一些类的特殊处理,比如mixin等等


会对classnode进行变换,变换完成后又通过
这个方法转换回bytes然后写入
具体的transform逻辑比较复杂,我这里就简单概括一下,它做的事有下面几个:
1. 使用mapping进行remap
2. 初始化mixin环境
3. inject fabric的trigger触发代码
4. 其他的一些兼容性处理
总结
类加载
lunar客户端的启动主要依赖包名为com.moonsworth.lunar.ichor的类加载器
启动游戏后只加载了少数几个启动jar包,随后genesis包会从启动参数中获取需要加载的所有jar,然后执行到PreLaunchLibraryBootstrap中时,通过maven仓库下载完整的游戏启动jars
随后进入到类加载逻辑:
genesis会通过多个classloader,执行多道工序(包括初始化mixin,使其生效),最终实现的目的是:对游戏的类进行一次”烘焙“
它会将所有的类在通过RCRICCRHHHIOIRCORCCICROHOHIOHR这个类进行pipeline处理后的bytes,通过OCORRORHIOCOHHICOIHCIHCORRIIOR这个类合并成一个jar,以便加速后续启动(烘焙后的结果保存在.lunarclient\offline\multiver\cache)
跨版本
跨版本的实现主要依靠 各版本单独的mixin实现 + wrapper(lunar实现的跨版本api,也就是包名为com.lunarclient的部分) + 模块化加载 实现。
有关Lunar的跨版本wrapper设计,我会在另外的文章中解析。