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

+196
View File
@@ -0,0 +1,196 @@
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.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 3.8.99 的编辑器载荷加一个"正式视图"。
*
* 视图系统的情况(全部从字节码读出来的):
* - us 是视图枚举:us("meshTools", 4, "Mesh Tools", MV.L, ut.B)
* <clinit> 里建好全部常量、组装 $VALUES(字段 B:[Lus;),再按 MV/ut 分成左右两组列表。
* - tD.<init> 给每个视图 new 一个 Actor 并挂到 us.X.D。
*
* 所以两处补丁:
* 1) us:<clinit> 开头造一个新常量 "spineView",并在 $VALUES 组装完之后把它追加进数组
* (必须在分组列表生成之前追加,否则 Views 菜单里看不到)。
* 2) tD:<init> 里最后一个 us.X.D 赋值之后,插一条调用
* com.spineview.ViewInstaller.install(),由我们把面板 Actor 塞进 us.SPINEVIEW.D。
*
* 用法:java -cp asm.jar:... ViewPatcher <源载荷> <目标载荷>
*/
public final class ViewPatcher {
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";
public static void main(String[] args) throws Exception {
Path source = Paths.get(args[0]);
Path target = Paths.get(args[1]);
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("载荷条目 " + entries.size());
entries.put(VIEW_ENUM + ".class", patchEnum(entries.get(VIEW_ENUM + ".class")));
entries.put(UI_CLASS + ".class", patchUi(entries.get(UI_CLASS + ".class")));
try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(target))) {
for (Map.Entry<String, byte[]> entry : entries.entrySet()) {
out.putNextEntry(new JarEntry(entry.getKey()));
out.write(entry.getValue());
out.closeEntry();
}
}
System.out.println("已写出 " + target);
}
/** 给 us 加一个新枚举常量。 */
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);
// 找到 $VALUES 的组装点(PUTSTATIC us.B:[Lus;),在它后面把新常量追加进数组
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.out.println("us.<clinit> 已插入新视图常量 " + VIEW_NAME);
}
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
node.accept(writer);
return writer.toByteArray();
}
/** 在 tD.<init> 里最后一个 us.X.D 赋值之后,插一条调用去建我们视图的面板。 */
private static byte[] patchUi(byte[] data) {
ClassNode node = new ClassNode();
new ClassReader(data).accept(node, 0);
int inserted = 0;
for (MethodNode method : node.methods) {
AbstractInsnNode last = null;
for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null;
insn = insn.getNext()) {
if (insn instanceof FieldInsnNode) {
FieldInsnNode field = (FieldInsnNode) insn;
// 真正的写法是:getstatic us.StackMapTable:Lus; ... putfield us.D:Ldl;
// 也就是"先取视图常量,再把面板 Actor 写进它的 D 字段"。
if (field.getOpcode() == Opcodes.PUTFIELD && VIEW_ENUM.equals(field.owner)
&& "Ldl;".equals(field.desc)) {
last = insn;
}
}
}
if (last == null) continue;
InsnList hook = new InsnList();
hook.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "com/spineview/ViewInstaller",
"install", "()V", false));
method.instructions.insert(last, hook);
inserted++;
System.out.println("在 " + method.name + method.desc + " 里插入了调用");
}
if (inserted == 0) throw new IllegalStateException("tD 里没找到挂视图的地方");
System.out.println("tD.<init> 已插入 ViewInstaller.install()");
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
node.accept(writer);
return writer.toByteArray();
}
private static byte[] readAll(InputStream in) throws java.io.IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[65536];
int n;
while ((n = in.read(chunk)) > 0) buffer.write(chunk, 0, n);
return buffer.toByteArray();
}
}