阶段四:Spine 3.8.99 编辑器里挂上自建视图面板

目标:在 Spine 编辑器窗口内部加一个自己的视图面板(按钮 + 文字显示),
点击读取场景对象,并像原生面板一样出现在 Views 菜单、可停靠可折叠。

本轮打通:
1. 编辑器在我们自己的进程里完整启动
   - 类加载器必须是 URLClassLoader 本身(自检要求)
   - Editor.main 必须在 AWT EDT 上调用
   - jvm.cfg 第 21 行要以两个空格结尾 + -DSpineJava
   - Editor.D:I 版本号、fonts/、/tzax 资源、Java8 版 launcher.Launcher 替身
2. 3.8.99 全量解密 → 可读源码
   - 字符串 17251/17461(伪造同名调用帧对齐 key)
   - 混淆动态派发还原 53871 处(调引导方法取 MemberName)
3. 自建视图
   - agent 在内存里给 us 枚举加 spineView 常量(不改磁盘,避开完整性自检)
   - 面板 Actor 由外部反射填进 us.spineView.D
   - 复用已有图标做别名(不新建图片资源)
   最终 "Spine View" 出现在 Views 菜单,可与 Tree 并排停靠、折叠展开

另外:
- ViewPatcher/ViewAgent:视图枚举与面板挂载的字节码插桩
- DecAll/ObMap/rewrite_source:字符串解密、派发还原、可读源码生成
- 阶段四-视图面板挂载.md:完整记录做法与踩过的坑

不纳入版本管理:rea/(独立仓库)、tools 下的 JDK/Ghidra 工具链、
Spine.app 与 spine-lab 副本、从载荷派生的批量产物。
This commit is contained in:
tianmo committed 2026-10-16 13:34:18 +08:00
commit 2e7ab7c33c
113 files changed
+25327

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.jar.JarOutputStream;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
/**
* 在 Spine 3.8.99 的 launcher.jar 里插一条调用:
* com.esotericsoftware.spine.editor.launcher.Launcher.main(String[])
* 的第一行改成先调 com.spineview.SceneScan.main(String[]),
* 并把我们编译好的类一起打进 jar。
*
* 因为 3.8.99 是明文的,这么做之后我们的代码就和编辑器处在同一个类加载器里,
* 不再有"跨加载器看不到 TextButton"的问题。
*/
public final class Patcher {
/** 目标类 / 方法 / 描述符,从命令行传(args[3..5])。 */
private static String targetClass;
private static String targetEntry;
private static String hookMethod;
private static String hookDesc;
private static final String HOOK_OWNER = "com/spineview/SceneScan";
public static void main(String[] args) throws Exception {
Path source = Paths.get(args[0]);
Path target = Paths.get(args[1]);
Path classesDir = Paths.get(args[2]);
targetClass = args.length > 3 ? args[3] : "com/esotericsoftware/spine/editor/launcher/Launcher";
String methodName = args.length > 4 ? args[4] : "main";
hookMethod = args.length > 5 ? args[5] : "main";
hookDesc = args.length > 6 ? args[6] : "([Ljava/lang/String;)V";
targetEntry = targetClass + ".class";
Map<String, byte[]> entries = new LinkedHashMap<String, byte[]>();
try (JarInputStream in = new JarInputStream(Files.newInputStream(source))) {
JarEntry entry;
while ((entry = in.getNextJarEntry()) != null) {
if (entry.isDirectory()) continue;
entries.put(entry.getName(), readAll(in));
}
}
System.out.println("原 jar 条目数 = " + entries.size());
byte[] original = entries.get(targetEntry);
if (original == null) throw new IllegalStateException("找不到 " + targetEntry);
entries.put(targetEntry, patch(original, methodName));
System.out.println("已给 " + targetClass + "." + methodName + " 插入了 " + HOOK_OWNER + "." + hookMethod);
int added = 0;
try (java.util.stream.Stream<Path> stream = Files.walk(classesDir)) {
for (Path path : (Iterable<Path>) stream::iterator) {
if (!Files.isRegularFile(path)) continue;
String relative = classesDir.relativize(path).toString();
if (!relative.endsWith(".class")) continue;
entries.put(relative.replace('\\', '/'), Files.readAllBytes(path));
added++;
}
}
System.out.println("加进 jar 的类 = " + added);
try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(target))) {
for (Map.Entry<String, byte[]> entry : entries.entrySet()) {
JarEntry jarEntry = new JarEntry(entry.getKey());
jarEntry.setTime(0L);
out.putNextEntry(jarEntry);
out.write(entry.getValue());
out.closeEntry();
}
}
System.out.println("已写出 " + target);
}
private static byte[] patch(byte[] original, final String methodName) {
ClassReader reader = new ClassReader(original);
ClassWriter writer = new ClassWriter(0);
ClassVisitor visitor = new ClassVisitor(Opcodes.ASM9, writer) {
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor,
String signature, String[] exceptions) {
MethodVisitor next = super.visitMethod(access, name, descriptor, signature, exceptions);
if (!methodName.equals(name) || !hookDesc.equals(descriptor)) return next;
return new MethodVisitor(Opcodes.ASM9, next) {
@Override
public void visitCode() {
super.visitCode();
// 我们的钩子是 ()V,不需要压任何参数;栈平衡保持不变。
visitMethodInsn(Opcodes.INVOKESTATIC, HOOK_OWNER, hookMethod, hookDesc, false);
}
};
}
};
reader.accept(visitor, 0);
return writer.toByteArray();
}
private static byte[] readAll(InputStream in) throws Exception {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[16384];
int read;
while ((read = in.read(chunk)) > 0) buffer.write(chunk, 0, read);
return buffer.toByteArray();
}
}