Yes Steve Model Java层分析报告

2026-05-07

摘要

继 YSMParser 完成对 .ysm 格式的解密之后,本文记录了我们对YSM Mod本体的逆向分析过程。YSM自LegacyYSM开源后长期处于闭源+混淆+Native保护的三重黑盒状态,没有任何公共接口可供调用,对外完全不可见。我们从Jar包开始反混淆和反编译并使用JNI Hook + IDA静态分析的组合,逐个还原了Native层的核心方法,包括客户端初始化、模型渲染(含SSE矩阵运算与骨骼蒙皮)、模型加载(含YSMParser集成)和网络通信。最终我们成功在不依赖原始DLL的情况下让YSM Mod正常启动并进入游戏。

引言

YSMParser项目上线之后,我们现在可以解密被YSM的DRM系统加密的.ysm模型了。

虽然我们已经完全理解了他的DRM是如何工作的,然而我们对YSM Mod内部是如何工作的,如何渲染的,以及这个DRM系统和Java层是如何交互的这几个问题一概不知,YSM自LegacyYSM开源之后一直处于闭源+混淆的状态,且没有任何公共接口可以调用,这让YSM Mod自身成为了一个黑盒,而为了探究他的工作方式,我们对他的Mod展开了研究。

分析Jar包

使用Recaf对Jar进行简单反编译,可以看到所有的符号都被混淆成了由0oO随机排列的长度为24的命名,但是没有其他的混淆手段例如字符串加密或者控制流混淆,所以我们推测这个混淆是使用Proguard+字典混淆的,这相比其他混淆器例如ZKM的逆向难度要低很多,因为我们只需要简单推测语义就行了,不需要解密字符串等。

但是Proguard作为一个非常成熟的混淆器,重命名的覆盖面很广,其他混淆器会排除的Lambda方法、Enum、随机的接口等都被完全覆盖了,这实际上对我们的分析造成了一些阻碍,因为几乎没有任何地方泄露了原始的命名,甚至在他们的自定义工作流中native方法也在编译时适配的加持下被混淆了。

1

反编译一个随机Class,可以看到没有任何可用的符号

初步反混淆

要理解其语义,第一步其实是恢复SRG名,因为在forge的导出jar流程中,reobfJar任务会给Jar中的所有Minecraft引用的字段和方法名混淆,例如m_6259_m_91404_这种无意义的命名。

手动修复这些东西是不现实的,首先我们需要获得这些mapping以便自动处理。在Setup了Forge 1.20.1 MDK之后打开everything搜索mapping,我们可以看到Forge Gradle将Mapping缓存到了mcp_repo文件夹中,这个zip中就包含了所有SRG名到MCP名的对照表。

2 3

但是这个Mapping并不能被Recaf的Mapping功能解析,所以我们需要自己写一个Remapper来实现,我们找到了java-deobfuscator/deobfuscator这个工具,可以轻松的创建自己的 transformer 处理 class,注册一个 transformer 然后遍历所有 MethodInsnNode 和 FieldInsnNode,读入 mapping 之后直接替换所有 name 即可,因为 srg 名不会变更 owner 和 desc。在输入 jar 并写出之后,成功恢复了所有 Minecraft 引用的命名。

4

反混淆

恢复了Minecraft引用的命名之后就可以开始反混淆jar本身了,但是jar中有超过1000个类需要反混淆,很显然不是所有类都是 YSM自己的,也有一些是 shadowJar 进去的 libs,在正式开始 remap 之前第一步是分离出这些 libs 。

Jar 中有一个 licenses 文件夹,包含了 YSM 使用的开源库的开源协议大全,但是很多都是 c/c++库,我们注意到其中的 concentus 和 opus 和 geckolib是 java 库,从 GitHub 下载源码之后,就可以使用 Matcher 完成分离了,Matcher 是一个 FabricMC推出的反混淆工具,可以通过分析类特征从不同版本的混淆 Jar 中分析命名,使用这个工具我们成功分离了 concentus 和 opus,但是我们注意到这个 geckolib 经过了大规模魔改,例如大部分 double 都被替换为了 float,所以这个暂时无法自动的分离。

现在还有超过 600 个类需要反混淆,其中不乏一些非常大的很复杂的类,手动反混淆 600 多个类显然不可能,我们需要一个自动化的反混淆方案。

我们最先注意到的方案是 IntelliJ IDEA 推出了 MCP 服务器可以重命名符号,但是这需要先反编译,但是目前的随机的 Oo0 组合的命名并不适合反编译,因为如果类名以 0 开头会被视为 error,而且也不适合随机 Remap 之后反编译,因为这样做可能会给以后 diff 原始 Jar 修复问题带来麻烦,所以我们最终放弃了这个方案。

在反编译前 Remap Jar 的最著名方案为 Enigma,可以直接 Remap Java 字节码并手动给每个符号命名,但是这款工具没有现有的自动化方案,所以我们需要 clone 源码研究自动化方案。

自动化Enigma

打开Enigma的源码,可以注意到Enigma有一个联机功能(Collab),允许多人协作联机Remap,我们可以借用这个系统为接口实现自动化接入。打开 Enigma-server 项目源码可以看到一个Protocol.md里面阐述了 Enigma 联机的协议结构,可以利用这个协议制作一个自动化客户端,自动的提交命名到 Enigma。

这个协议系统最大的好处就是可以直接从 Enigma 中接收聊天,可以利用这个来命令自动化客户端工作,照猫画虎写出 Enigma 协议客户端之后,就可以接入 Chat Completions API 实现调用 LLM 重命名了,使用 JSON output 功能可以让 LLM 输出标准的 json,然后自动解析这个 json 让 Enigma 协议客户端发包,即可完成自动化 Remap。

5

Remap geckolib和molang

Remap过程中我们发现LLM并不熟悉魔改的geckolib,我们需要找到一份可以参考的代码进行remap,然而我们发现这份魔改 geckolib 在 TartaricAcid 的另一个 mod TouhouLittleMaid中也有使用而 TouhouLittleMaid 是开源的,参考 TouhouLittleMaid 中的魔改 geckolib 源码我们即可手动修复 YSM 中的 geckolib 命名。

而 YSM 使用的 molang 解析器为 mocha,同样可以经过简单对比手动修复命名。

在这套步坦协同的方案执行完成之后,我们获得了恢复了所有语义命名的反混淆 Jar 文件,接下来使用 jadx 反编译即可。

反编译并且将源码放入 MDK 之后我们发现 YSM 添加了致死量的其他模组引用,因为 YSM 的封闭导致其他模组不能去兼容他,只能他来主动兼容其他模组。

在多个平台找到这些模组之后放进 libs,修复反编译错误导致的一些错误,我们就可以在 IDE 中启动 YSM 了,但是由于他的 native 中有 Jar 完整性检查,导致 native 加载失败,所以无法进入游戏。

6

还原Native

在成功反编译了 Java 层之后,我们需要开始还原 Native,很多 Native 方法其实是类似 J2C Native 混淆的模式,这种可以使用 hook 来还原。

编写一个 dll,使用 MinHook 来 Hook 所有 JNI 方法,并输出调用参数,尝试使用 Agentlib 加载之后,我们就可以看到所有 JNI 调用了,结合 IDA 解析了 JNI 头的伪代码查看即可还原 Native 和 Java 的交互逻辑。

7

初始化逻辑

第一个需要还原的 Native 方法为FMLClientSetupEvent下调用的初始化,但是这个方法几乎没有抓到 JNI 调用说明这是一个原生方法而不是 j2c 方法,经过观察包含一些 GL 常数所以应该是 GL 环境检查,在 Hook 了 GLFW 之后我们摸清了大概逻辑,这是一个检查 GL 环境的代码,如果成功返回 null 失败了则返回 Chat Component。

第一步:检测环境,如果以下调用任何一个是 null 都会直接算初始化失败

GetProcAddress(lwjgl_module,"Java_org_lwjgl_opengl_GL11C_nglTexImage2D__IIIIIIIIJ");
GetProcAddress(lwjgl_module,"Java_org_lwjgl_opengl_GL11C_nglTexSubImage2D__IIIIIIIIJ");
GetProcAddress(lwjgl_module,"Java_org_lwjgl_opengl_GL11C_glTexParameteri");
GetProcAddress(lwjgl_module,"Java_org_lwjgl_opengl_GL11C_glTexParameterf");
GetProcAddress(lwjgl_module,"Java_org_lwjgl_opengl_GL11C_glPixelStorei");
GetProcAddress(lwjgl_module,"Java_org_lwjgl_opengl_GL11C_nglGenTextures__IJ");
GetProcAddress(lwjgl_module,"Java_org_lwjgl_opengl_GL11C_nglDeleteTextures__IJ");
GetProcAddress(lwjgl_module,"Java_org_lwjgl_opengl_GL11C_glBindTexture");

第二步:检查GL_MAX_TEXTURE_SIZE

第三步:Shader 是否可用

还原代码如下:

public static Object nativeClientInit() {
    try {
        int maxTexSize = GL11.glGetInteger(GL11.GL_MAX_TEXTURE_SIZE);
        if (maxTexSize <= 0) {
            return Component.literal("YSM: OpenGL context not available");
        }

        try {
            int testShader = GL20.glCreateShader(GL20.GL_VERTEX_SHADER);
            if (testShader != 0) {
                GL20.glDeleteShader(testShader);
            }
        } catch (Exception e) {
            return Component.literal("YSM: GL20 (shaders) not available");
        }

        return null; // 成功
    } catch (Exception e) {
        return Component.literal("YSM Client Init Failed: " + e.getMessage());
    }   
}

渲染逻辑

还原初始化之后,接下来更大的挑战:所有模型的渲染逻辑都在Native层完成,Java 层只负责传参,真正的渲染工作全部由Native完成。

通过之前编写的 Hook,我们可以看到游戏在每一帧渲染玩家时都会调用一个特定的native方法,在IDA中定位到这个native方法的实现可以看到这是整个Native中最复杂的几个函数之一,清理了大量混淆之后还有 9000 行伪代码。

8

函数开头从传入的模型对象读取了一个long字段,然后用这个值作为 index 去一个全局 map 中查找对应的mesh数据。

auto mesh_it = g_mesh_map.find(mesh_native_ptr);
// ... shared_ptr::_Incref
_InterlockedIncrement((volatile long*)(mesh_inner + 8));

然后使用GetFloatField每次读取一个float字段并存入栈上的float数组,共读取了 16 次,结合字段引用不难推断这是在读取 Matrix4f 的十六个字段m00到m33。

接着又是9次类似的调用,根据入参顺序读取的是PoseStack.Pose.normal的矩阵。

9

接下来是一些剔除逻辑,应该是剔除渲染不需要的部分,这是用于优化渲染速度的,而且代码很复杂,我们暂时跳过,查看下面的真正的渲染代码,可以分为几个流程。

while (current_part < last_part) {
    PerPartRenderData* render_data = ...;
    uint64_t part_addr = parts_begin + 120 * current_part;
    float* vertices = (float*)(g_vertex_buffer + 48 * current_part);

    if (vertices[11] != 0.0f) { 
        // 渲染子部件
    }
    if (vertices[6] == 0 || vertices[7] == 0 || vertices[8] == 0) {
        current_part += *(uint32_t*)(part_addr + 108);
        // 跳过渲染
        continue;
    }
    if (vertices[10] != 0.0f) {
        // Transform
    }
    // 构建矩阵
    current_part++;
}

vertices为一个 float 数组,这个float数组每个 part 占 12 个 float,但每个值代表什么完全没有名字。我们注意到vertices[6..8]被用于任何一个值为 0 来跳过整个part及其子部件,这个就有可能是 scale,因为 scale 为 0 才会消失,position 或者 color 都不会。

vertices[3..5]后面被*-0.0625f,0.0625f是Minecraft的像素转换常数,*负值说明这是pivot的3D世界空间到模型空间转换,所以这可能是pivot(Geometry格式就是这样处理的)。剩下的vertices[0..2]大概是position因为 transform 有调用,vertices[9..10]是 UV 偏移,vertices[11]作为 flag 位。

在拿到父骨骼Transform之后,函数从 vertices 中取出 pivot 和 scale,构建当前part的局部变换矩阵:

float px = vertices[3] * -0.0625f;
float py = vertices[4] * -0.0625f;
float pz = vertices[5] * -0.0625f;

__m128 local_col0 = _mm_set_ps(0, 0, 0, vertices[6]);
__m128 local_col1 = _mm_set_ps(0, 0, vertices[7], 0);
__m128 local_col2 = _mm_set_ps(0, vertices[8], 0, 0);
__m128 local_col3 = _mm_set_ps(1.0f, pz, py, px);

这一段差不多等价于Minecraft 的 ModelPart.translateAndRotate 中 translate 和 scale:

poseStack.translate(this.x / 16.0F, this.y / 16.0F, this.z / 16.0F);
poseStack.scale(sx, sy, sz);

然后是一段重复多次模式的算法,bone × local的矩阵乘法,0x00/0x55/0xAA/0xFF四个常数对应的二进制是 00_00_00_00、01_01_01_01、10_10_10_10、11_11_11_11,每两位一组指定从寄存器选哪个值,所以这四次 shuffle 的效果是把 local_col0 的四个分量分别广播成 {x,x,x,x}、{y,y,y,y}、{z,z,z,z}、{w,w,w,w} 四个向量。

__m128 xxxx0 = _mm_shuffle_ps(local_col0, local_col0, 0x00);
__m128 yyyy0 = _mm_shuffle_ps(local_col0, local_col0, 0x55);
__m128 zzzz0 = _mm_shuffle_ps(local_col0, local_col0, 0xAA);
__m128 wwww0 = _mm_shuffle_ps(local_col0, local_col0, 0xFF);
__m128 res_col0 = _mm_add_ps(
    _mm_add_ps(_mm_mul_ps(render_data->pose_row0, xxxx0),
               _mm_mul_ps(render_data->pose_row1, yyyy0)),
    _mm_add_ps(_mm_mul_ps(render_data->pose_row2, zzzz0),
               _mm_mul_ps(render_data->pose_row3, wwww0)));

虽然这段代码看着又臭又长但是其实是SIMD优化给算法铺平了导致的,实际上的等价代码就是一次矩阵的 mul 计算:

Matrix4f result = new Matrix4f(boneMatrix).mul(localMatrix);

然后就是写回骨骼缓存,计算完的bone * local不仅会用于当前part的渲染,还会被写回到骨骼缓存中,供下一次循环找父骨骼Transform,同时还保存了可见状态,如果父骨骼不可见子骨骼也会被一起跳过渲染。

*(__m128*)(cache_entry + 0)  = render_data->pose_row0;
*(__m128*)(cache_entry + 16) = render_data->pose_row1;
*(__m128*)(cache_entry + 32) = render_data->pose_row2;
*(__m128*)(cache_entry + 48) = render_data->pose_row3;
memcpy(cache_entry + 64, &render_data->normal_mat, 36);
*(float*)(cache_entry + 96) = render_data->det_sign;

这些完成之后通过VertexConsumer提交,完成顶点构建。

这一套渲染流程中大部分矩阵方法在 joml 中都有 Java 实现,或者说YSM 的 C++渲染也是从 Java 翻译而来,总之翻译回 Java 并不复杂,还有 joml 中的方法可以复用。

至于剔除算法,我们可以自己实现一个在 Java 中。

模型加载

对于模型加载,LgeacyYSM(不是 Legacy)有很大的参考价值,对于几个 ModelManager 也可以直接复用部分代码,只需要完善新格式适配即可。

然后这个 native 方法的骨架其实很简单:把三个接受的 JNI 参数(path、hash、consumer)各自包装成一个native pointer,组装成一个加载任务,然后提交到后台线程池。

void* task = ysm_alloc(64);
*(void**)((uint8_t*)task + 0)  = path_wrapper;
*(void**)((uint8_t*)task + 8)  = hash_wrapper;
*(void**)((uint8_t*)task + 16) = consumer_wrapper;
submit_load_task(dispatcher, task);

所以我们需要追踪到他实际的 Worker,Worker 方法是一个比渲染还要大的复杂方法,我们逐步开始分析。

对开头进行分析之后,其实是一些 flag 检查确保现在只有一个模型在加载。然后从全局获取 Mod 的加载状态,检查是否设置了 shutdown 标志(如果 Mod 正在卸载就直接退出回调)。

然后按 hash 去重,避免重复加载已经加载过的模型,接下来就进入主要文件操作逻辑了,大概流程是这样的:

  • filesystem::status检查文件状态
  • 通过fs_path_extension获取扩展名
  • 不区分大小写比较是否为.ysm
    • 如果是 YSM 格式则会进入sub_7FFD6D5FFA20也就是解密逻辑
    • 如果不是 YSM 则继续加载
  • 把文件读到std::vector<uint8_t>
  • 如果文件为空,尝试删除
  • 然后进入加载逻辑

对于加密的 YSM 模型,我们早在制作 YSMParser 时候就已经完全理解了,所以我们可以轻松的移植过来,而加载逻辑可以复用 LegacyYSM 中的代码。

至此所有关键的 Native 方法都已经实现,剩下的小方法基本都是这些大方法的辅助函数,而其他方法大部分都是简单的 JNI 调用包装(例如网络部分),推测是只是用于混淆性质让自己的 Native 方法看着多一点罢了,直接使用 JNI Hook 配合简单的静态分析即可全部复刻。

Native 代码的复杂度有相当一部分并非来自真正的算法复杂性,而是来自用C++手动实现Java中现有的库的逻辑,比如 joml,Map 等,剥离这些障眼法之后,就可以很容易的恢复为 Java 代码。

将这些 Native 方法实现之后,成功启动并进入游戏

10

研究成果

经过这次完整的逆向分析,我们获得了以下成果:

我们几乎恢复了全部的语义命名,甚至已经到了可以持续维护和开发的程度,我们也开源了这个项目,作为一份礼物送给我们的支持者和社区。

所有Native方法用纯Java重新实现并集成到完整的Mod中,在没有原始DLL的情况下能够正常启动并进入游戏,模型可以正常加载和渲染。这意味着YSM的Native保护层在功能层面已经完全失效。同时我们也解决了 YSM 一直没有解决的兼容性问题,无论是被清退的 MacOS,从未被支持过的 RISC-V,甚至是手机上也可以运行,兼容性远超原版 YSM,可以说只要可以运行 Minecraft,就能运行我们的 OpenYSM。