Files
Spine_lab/tools/agent/ViewAgent.java
T
tianmo 2e7ab7c33c 阶段四: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 副本、从载荷派生的批量产物。
2026-10-16 13:34:18 +08:00

189 lines
9.2 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}