阶段四: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

+90
View File
@@ -0,0 +1,90 @@
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
import java.util.ListIterator;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.InsnList;
import org.objectweb.asm.tree.InsnNode;
import org.objectweb.asm.tree.InvokeDynamicInsnNode;
import org.objectweb.asm.tree.JumpInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.objectweb.asm.tree.VarInsnNode;
/**
* 3.8.99 载荷跑在自备 JDK8 上时,Editor.main 里有一道
* "请使用 Spine 自带的 Java 运行时" 的校验,不通过就 Runtime.halt(1)。
*
* 字节码里的形状是:
* iload 2
* ifne <成功分支>
* goto <报错 + halt>
*
* 这个 agent 把那段 iload 前面塞上 iconst_1 / istore 2,
* 让分支永远走成功那一条。只作用于内存中的类,不动磁盘文件。
*/
public final class ForceAgent {
private static final String TARGET = "com/esotericsoftware/spine/editor/Editor";
private static boolean patched;
private static boolean skipLicencePatch;
public static void premain(String arguments, Instrumentation instrumentation) {
skipLicencePatch = arguments != null && arguments.contains("wyridonly");
System.err.println("[force-agent] premain 已加载" + (skipLicencePatch ? "(只改 WYRiDdid)" : ""));
instrumentation.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> beingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
if (!TARGET.equals(className)) return null;
try {
return patch(classfileBuffer);
} catch (Throwable error) {
System.err.println("[force-agent] 打补丁失败: " + error);
return null;
}
}
});
}
private static byte[] patch(byte[] data) {
ClassNode node = new ClassNode();
new ClassReader(data).accept(node, 0);
for (MethodNode method : node.methods) {
if (!"main".equals(method.name) || !"([Ljava/lang/String;)V".equals(method.desc)) continue;
InsnList list = method.instructions;
for (ListIterator<AbstractInsnNode> it = list.iterator(); it.hasNext();) {
AbstractInsnNode insn = it.next();
// 第二道门:invokedynamic WYRiDdid()Z 为 false 就直接 return。
// 直接把这次调用的结果换成常量 true —— 一对一替换,栈效果完全一样,
// 所以栈帧表和 max_stack 都不用动(改成跳转就会破坏栈帧)。
if (insn instanceof InvokeDynamicInsnNode
&& "WYRiDdid".equals(((InvokeDynamicInsnNode) insn).name)) {
list.set(insn, new InsnNode(Opcodes.ICONST_1));
System.err.println("[force-agent] 已把 WYRiDdid() 守卫改成永远通过");
}
if (insn.getOpcode() != Opcodes.ILOAD || ((VarInsnNode) insn).var != 2) continue;
if (skipLicencePatch) continue;
AbstractInsnNode next = insn.getNext();
if (next == null || next.getOpcode() != Opcodes.IFNE) continue;
AbstractInsnNode after = next.getNext();
if (after == null || after.getOpcode() != Opcodes.GOTO) continue;
InsnList force = new InsnList();
force.add(new InsnNode(Opcodes.ICONST_1));
force.add(new VarInsnNode(Opcodes.ISTORE, 2));
list.insertBefore(insn, force);
patched = true;
System.err.println("[force-agent] 已把 Editor.main 的运行时校验改成永远通过");
}
}
if (!patched) return null;
// 只插两条不入栈的指令,帧信息不变,用 COMPUTE_MAXS 就够了。
// 用 COMPUTE_FRAMES 的话 ASM 会在定义类的过程中反射加载同一个类,
// 触发 "attempted duplicate class definition"。
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
node.accept(writer);
return writer.toByteArray();
}
}
+60
View File
@@ -0,0 +1,60 @@
import java.lang.instrument.Instrumentation;
import java.security.Permission;
/**
* 极小 javaagent:拦 System.exit,把调用栈打出来。
*
* 目的:3.8.99 编辑器在"必须用捆绑运行时"那道检查后会退出,
* 拿到 checkExit 的调用栈就知道检查写在哪、校验的是什么,不用再猜。
*
* 用法:-javaagent:rea-agent.jar
*/
public final class ReaAgent {
public static void premain(String arguments, Instrumentation instrumentation) {
System.err.println("[rea-agent] premain 已加载");
// JVM 退出时把主线程等的位置打出来。进程是"静默退出"还是"崩在别处",
// 看一次栈就够,不用再猜。
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
System.err.println("[rea-agent] JVM 正在退出,当前线程栈:");
for (java.util.Map.Entry<Thread, StackTraceElement[]> entry
: Thread.getAllStackTraces().entrySet()) {
Thread thread = entry.getKey();
if (!thread.isAlive()) continue;
System.err.println("[rea-agent] 线程 " + thread.getName()
+ (thread.isDaemon() ? " (daemon)" : ""));
for (StackTraceElement frame : entry.getValue()) {
System.err.println("[rea-agent] " + frame);
}
}
}
});
Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread thread, Throwable error) {
System.err.println("[rea-agent] 未捕获异常,线程 " + thread.getName());
error.printStackTrace(System.err);
}
});
System.setSecurityManager(new SecurityManager() {
@Override
public void checkPermission(Permission permission) {
// 全部放行,只观察。
}
@Override
public void checkExit(int status) {
Throwable trace = new Throwable("checkExit(" + status + ")");
StringBuilder builder = new StringBuilder("[rea-agent] System.exit(" + status + ") 调用栈:");
for (StackTraceElement frame : trace.getStackTrace()) {
builder.append("\n ").append(frame);
}
System.err.println(builder.toString());
// 不抛异常 = 允许退出,这样行为和原来一致。
}
});
}
}
+69
View File
@@ -0,0 +1,69 @@
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.InsnList;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.objectweb.asm.tree.VarInsnNode;
/**
* 在 uk(编辑器 Stage 的实现类,继承 dt)的构造函数末尾插一条:
* com.spineview.StageHook.onStage(this);
*
* 这样 Stage 一建出来我们就能拿到它,把自定义面板挂进场景树。
* 之所以必须插桩:Stage 只是 tD 里的局部变量,载荷里没有任何静态字段引用它。
*/
public final class StageAgent {
private static final String TARGET = "uk";
public static void premain(String arguments, Instrumentation instrumentation) {
System.err.println("[stage-agent] premain 已加载");
instrumentation.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> beingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
if (className != null && ("uk".equals(className) || "dt".equals(className)
|| "tD".equals(className))) {
System.err.println("[stage-agent] 看到类 " + className + " loader=" + loader);
}
if (!TARGET.equals(className)) return null;
try {
return patch(classfileBuffer);
} catch (Throwable error) {
System.err.println("[stage-agent] 插桩失败: " + error);
return null;
}
}
});
}
private static byte[] patch(byte[] data) {
ClassNode node = new ClassNode();
new ClassReader(data).accept(node, 0);
int patched = 0;
for (MethodNode method : node.methods) {
if (!"<init>".equals(method.name)) continue;
AbstractInsnNode last = method.instructions.getLast();
while (last != null && last.getOpcode() != Opcodes.RETURN) {
last = last.getPrevious();
}
if (last == null) continue;
InsnList hook = new InsnList();
hook.add(new VarInsnNode(Opcodes.ALOAD, 0));
hook.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "com/spineview/StageHook", "onStage",
"(Ljava/lang/Object;)V", false));
method.instructions.insertBefore(last, hook);
patched++;
}
if (patched == 0) return null;
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
node.accept(writer);
System.err.println("[stage-agent] 已给 uk 的 " + patched + " 个构造器插上 StageHook");
return writer.toByteArray();
}
}
+188
View File
@@ -0,0 +1,188 @@
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.FieldInsnNode;
import org.objectweb.asm.tree.FieldNode;
import org.objectweb.asm.tree.InsnList;
import org.objectweb.asm.tree.InsnNode;
import org.objectweb.asm.tree.IntInsnNode;
import org.objectweb.asm.tree.LdcInsnNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.objectweb.asm.tree.TypeInsnNode;
import org.objectweb.asm.tree.VarInsnNode;
/**
* 在**内存里**给编辑器加一个正式视图("Spine View"),磁盘上的载荷一个字节都不改。
*
* 为什么必须走内存:编辑器启动时会自检——把它自己 jar 里所有类的 SHA1 和签名里的清单比对,
* 一旦有出入就报 "Spine's integrity check failed."。所以任何磁盘改动都不行。
*
* 两处插桩(与 tools/patch_view/ViewPatcher 完全同源):
* us.<clinit> :造一个新枚举常量 spineView,并在 $VALUES 组装完之后追加进数组
* (必须在分组列表生成之前,否则 Views 菜单里看不到它)
* tD.<init> :最后一个 `putfield us.D:Ldl;`(即给视图塞面板 Actor)之后,
* 插一条 com.spineview.ViewInstaller.install()
*/
public final class ViewAgent {
private static final String VIEW_ENUM = "us";
private static final String UI_CLASS = "tD";
private static final String VIEW_NAME = "spineView";
private static final String VIEW_TITLE = "Spine View";
private static final String VIEW_FIELD = "SPINEVIEW";
private static boolean enumDone;
private static boolean uiDone;
public static void premain(String arguments, Instrumentation instrumentation) {
System.err.println("[view-agent] premain 已加载");
instrumentation.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> beingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
try {
if (VIEW_ENUM.equals(className) && !enumDone
&& !"0".equals(System.getProperty("viewagent.enum"))) {
enumDone = true;
return patchEnum(classfileBuffer);
}
// 默认不再动 tD:这个混淆过的构造函数对插入指令极其敏感,
// 插一条 NOP 没事,插任何带操作数的指令都会把它的初始化流程弄坏。
// 视图面板改由 ViewInstaller 在编辑器起来之后从外部填进 us.spineView.D。
if (UI_CLASS.equals(className) && !uiDone
&& "1".equals(System.getProperty("viewagent.ui"))) {
uiDone = true;
return patchUi(classfileBuffer);
}
} catch (Throwable error) {
System.err.println("[view-agent] 插桩 " + className + " 失败: " + error);
}
return null;
}
});
}
private static byte[] patchEnum(byte[] data) {
ClassNode node = new ClassNode();
new ClassReader(data).accept(node, 0);
boolean hasField = false;
for (FieldNode field : node.fields) {
if (VIEW_FIELD.equals(field.name)) hasField = true;
}
if (!hasField) {
node.fields.add(new FieldNode(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, VIEW_FIELD,
"L" + VIEW_ENUM + ";", null, null));
}
for (MethodNode method : node.methods) {
if (!"<clinit>".equals(method.name)) continue;
InsnList head = new InsnList();
head.add(new TypeInsnNode(Opcodes.NEW, VIEW_ENUM));
head.add(new InsnNode(Opcodes.DUP));
head.add(new LdcInsnNode(VIEW_NAME));
head.add(new IntInsnNode(Opcodes.BIPUSH, 16));
head.add(new LdcInsnNode(VIEW_TITLE));
head.add(new FieldInsnNode(Opcodes.GETSTATIC, "MV", "L", "I"));
head.add(new FieldInsnNode(Opcodes.GETSTATIC, "ut", "B", "Lut;"));
head.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, VIEW_ENUM, "<init>",
"(Ljava/lang/String;ILjava/lang/String;ILut;)V", false));
head.add(new FieldInsnNode(Opcodes.PUTSTATIC, VIEW_ENUM, VIEW_FIELD,
"L" + VIEW_ENUM + ";"));
method.instructions.insert(head);
AbstractInsnNode anchor = null;
for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null;
insn = insn.getNext()) {
if (insn instanceof FieldInsnNode) {
FieldInsnNode field = (FieldInsnNode) insn;
if (field.getOpcode() == Opcodes.PUTSTATIC && VIEW_ENUM.equals(field.owner)
&& "B".equals(field.name) && ("[L" + VIEW_ENUM + ";").equals(field.desc)) {
anchor = insn;
}
}
}
if (anchor == null) throw new IllegalStateException("找不到 $VALUES 组装点");
InsnList extend = new InsnList();
extend.add(new FieldInsnNode(Opcodes.GETSTATIC, VIEW_ENUM, "B", "[L" + VIEW_ENUM + ";"));
extend.add(new VarInsnNode(Opcodes.ASTORE, 40));
extend.add(new VarInsnNode(Opcodes.ALOAD, 40));
extend.add(new InsnNode(Opcodes.ARRAYLENGTH));
extend.add(new InsnNode(Opcodes.ICONST_1));
extend.add(new InsnNode(Opcodes.IADD));
extend.add(new TypeInsnNode(Opcodes.ANEWARRAY, VIEW_ENUM));
extend.add(new VarInsnNode(Opcodes.ASTORE, 41));
extend.add(new VarInsnNode(Opcodes.ALOAD, 40));
extend.add(new InsnNode(Opcodes.ICONST_0));
extend.add(new VarInsnNode(Opcodes.ALOAD, 41));
extend.add(new InsnNode(Opcodes.ICONST_0));
extend.add(new VarInsnNode(Opcodes.ALOAD, 40));
extend.add(new InsnNode(Opcodes.ARRAYLENGTH));
extend.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "arraycopy",
"(Ljava/lang/Object;ILjava/lang/Object;II)V", false));
extend.add(new VarInsnNode(Opcodes.ALOAD, 41));
extend.add(new VarInsnNode(Opcodes.ALOAD, 40));
extend.add(new InsnNode(Opcodes.ARRAYLENGTH));
extend.add(new FieldInsnNode(Opcodes.GETSTATIC, VIEW_ENUM, VIEW_FIELD,
"L" + VIEW_ENUM + ";"));
extend.add(new InsnNode(Opcodes.AASTORE));
extend.add(new VarInsnNode(Opcodes.ALOAD, 41));
extend.add(new FieldInsnNode(Opcodes.PUTSTATIC, VIEW_ENUM, "B", "[L" + VIEW_ENUM + ";"));
method.instructions.insert(anchor, extend);
System.err.println("[view-agent] us.<clinit> 已插入视图常量 " + VIEW_NAME);
}
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
node.accept(writer);
return writer.toByteArray();
}
private static byte[] patchUi(byte[] data) {
ClassNode node = new ClassNode();
ClassReader reader = new ClassReader(data);
reader.accept(node, 0);
for (MethodNode method : node.methods) {
if (!"<init>".equals(method.name)) continue;
// 插在构造函数最末尾(最后一条 return 之前):那时皮肤、贴图都读完了,
// 期间去加载别的类不会干扰编辑器自己的资源流。
AbstractInsnNode end = method.instructions.getLast();
while (end != null && end.getOpcode() != Opcodes.RETURN) end = end.getPrevious();
if (end == null) continue;
InsnList hook = new InsnList();
if ("1".equals(System.getProperty("viewagent.mark"))) {
// 只碰 JDK 类,不加载我们自己的任何类(判定实验)
hook.add(new LdcInsnNode("spineview.tD"));
hook.add(new LdcInsnNode("1"));
hook.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "setProperty",
"(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", false));
hook.add(new InsnNode(Opcodes.POP));
} else if ("1".equals(System.getProperty("viewagent.ping"))) {
hook.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "com/spineview/ViewInstaller",
"ping", "()V", false));
} else if (!"1".equals(System.getProperty("viewagent.nop"))) {
hook.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "com/spineview/ViewInstaller",
"install", "()V", false));
} else {
hook.add(new InsnNode(Opcodes.NOP));
System.err.println("[view-agent] 只插 NOP(判定实验)");
}
method.instructions.insertBefore(end, hook);
// 用 ClassWriter(reader, 0) 时不重算 max_stack,必须自己抬高,
// 否则插入的代码会把操作栈写爆(表现为编辑器初始化随机的 IOException)。
// 多给几格,反正只影响这一帧的栈分配。
method.maxStack += 8;
System.err.println("[view-agent] " + method.name + method.desc
+ " 已插入 ViewInstaller.install()");
}
// 关键:用 ClassWriter(reader, 0),未改动的部分原样复制原始字节码。
// 之前用 COMPUTE_MAXS 全量重写这个 14 万字节、混淆得很厉害的类,
// 会把它的初始化流程弄坏(表现为构造器里资源流出 IOException)。
// 我们只插了一条净栈效果为 0 的 invokestatic,max_stack / 栈帧都不用动。
ClassWriter writer = new ClassWriter(reader, 0);
node.accept(writer);
return writer.toByteArray();
}
}
+3
View File
@@ -0,0 +1,3 @@
Premain-Class: ReaAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
+1
View File
@@ -0,0 +1 @@
Premain-Class: ForceAgent
+1
View File
@@ -0,0 +1 @@
Premain-Class: StageAgent
+1
View File
@@ -0,0 +1 @@
Premain-Class: ViewAgent
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.