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

+370
View File
@@ -0,0 +1,370 @@
package com.spineview.dec;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.InsnNode;
import org.objectweb.asm.tree.LdcInsnNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
/**
* 3.8.99 字符串批量解密。
*
* 原理全部来自字节码,不是猜的:
* 所有解密函数(PrefsPanes$Prefs$ToolbarPosition.f、aaQ$Wm.x 以及若干内联副本)
* 第一步都算同一个 key:
* key = (cpSize + 调用类名 + 调用方法名 + cpSize).hashCode()
* cpSize 取的是**调用解密函数的那个类**的常量池大小(两次都用 st[2])。
*
* 所以只要造一个合成类,让它
* - 类名 == 原始调用方类名
* - 方法名 == 原始调用方法名
* 由它去调解密函数,key 就自然对上;cpSize 由解密函数内部 Class.forName 从真实类读到。
*
* 用法:
* java -cp "asm.jar:本工具:已全 public 化的载荷.zip" com.spineview.dec.DecAll
* <载荷> <映射.tsv> [可读副本.zip]
*/
public final class DecAll {
private static final String DECRYPT_DESC = "(Ljava/lang/Object;)Ljava/lang/String;";
private static final String HOOK_OWNER = "sun/misc/SharedSecrets";
private static final String HOOK_NAME = "getJavaLangAccess";
public static void main(String[] args) throws Exception {
Path payload = Paths.get(args[0]);
Path mapOut = Paths.get(args[1]);
Path readableOut = args.length > 2 ? Paths.get(args[2]) : null;
// 0) 整个载荷读进内存(约 15MB,无压力)
Map<String, byte[]> entries = new LinkedHashMap<String, byte[]>();
List<String> order = new ArrayList<String>();
try (ZipFile zip = new ZipFile(payload.toFile())) {
Enumeration<? extends ZipEntry> it = zip.entries();
while (it.hasMoreElements()) {
ZipEntry entry = it.nextElement();
if (entry.isDirectory()) continue;
entries.put(entry.getName(), readAll(zip.getInputStream(entry)));
order.add(entry.getName());
}
}
System.err.println("载荷条目 " + entries.size());
// 1) 找出所有解密函数:方法体里出现 SharedSecrets.getJavaLangAccess 的 (Object)String 方法
Set<String> decryptors = new HashSet<String>();
Map<String, ClassNode> parsed = new LinkedHashMap<String, ClassNode>();
for (Map.Entry<String, byte[]> entry : entries.entrySet()) {
if (!entry.getKey().endsWith(".class")) continue;
ClassNode node;
try {
node = parse(entry.getValue());
} catch (Throwable error) {
System.err.println(" 跳过 " + entry.getKey() + " : " + error);
continue;
}
parsed.put(entry.getKey(), node);
for (MethodNode method : node.methods) {
if (!DECRYPT_DESC.equals(method.desc)) continue;
if (callsCryptHook(method)) decryptors.add(node.name + "." + method.name);
}
}
System.err.println("找到解密函数 " + decryptors.size() + " 个");
for (String id : decryptors) System.err.println(" " + id);
// 2) 找所有 "ldc 密文; invokestatic 解密函数" 位置,按 (调用类, 调用方法, 解密函数) 分组
Map<String, Group> groups = new LinkedHashMap<String, Group>();
Map<String, List<Site>> sitesByClass = new LinkedHashMap<String, List<Site>>();
for (Map.Entry<String, ClassNode> entry : parsed.entrySet()) {
ClassNode node = entry.getValue();
for (MethodNode method : node.methods) {
for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null;
insn = insn.getNext()) {
if (!(insn instanceof LdcInsnNode)
|| !(((LdcInsnNode) insn).cst instanceof String)) continue;
AbstractInsnNode after = insn.getNext();
while (after != null && (after.getOpcode() == Opcodes.NOP
|| after.getOpcode() == Opcodes.CHECKCAST)) {
after = after.getNext();
}
if (!(after instanceof MethodInsnNode)) continue;
MethodInsnNode call = (MethodInsnNode) after;
if (call.getOpcode() != Opcodes.INVOKESTATIC) continue;
String id = call.owner + "." + call.name;
if (!decryptors.contains(id)) continue;
String key = node.name + '\u0000' + method.name + '\u0000' + id;
Group group = groups.get(key);
if (group == null) {
group = new Group(node.name.replace('/', '.'), method.name, call.owner, call.name);
groups.put(key, group);
}
group.ciphers.add((String) ((LdcInsnNode) insn).cst);
List<Site> list = sitesByClass.get(entry.getKey());
if (list == null) {
list = new ArrayList<Site>();
sitesByClass.put(entry.getKey(), list);
}
list.add(new Site((LdcInsnNode) insn, (MethodInsnNode) after, method, group,
group.ciphers.size() - 1));
}
}
}
int total = 0;
for (Group group : groups.values()) total += group.ciphers.size();
System.err.println("(调用类,调用方法,解密函数) 组合 " + groups.size()
+ ",待解密字符串 " + total + " 个");
// 3) 逐组解密
int done = 0, ok = 0, failed = 0;
try (BufferedWriter writer = Files.newBufferedWriter(mapOut, StandardCharsets.UTF_8)) {
for (Group group : groups.values()) {
String[] plain = group.decrypt();
group.plain = plain;
for (int i = 0; i < group.ciphers.size(); i++) {
String value = plain == null ? null : plain[i];
if (value == null) failed++; else ok++;
writer.write(group.owner);
writer.write('\t');
writer.write(group.method);
writer.write('\t');
writer.write(escape(value == null ? "<失败>" : value));
writer.write('\n');
}
if (++done % 300 == 0) System.err.println(" 进度 " + done + "/" + groups.size());
}
}
System.err.println("解密完成:成功 " + ok + ",失败 " + failed + " -> " + mapOut);
// 4) 产出"字符串已换成明文"的可读副本:ldc 换成明文,解密调用换成 NOP。
// 解密调用是 (Object)->String,净栈效果为 0,换成 NOP 后栈完全平衡,
// 栈帧表和 max_stack 都不用重算。
if (readableOut != null) {
int rewritten = 0;
try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(readableOut))) {
for (String name : order) {
byte[] raw = entries.get(name);
List<Site> sites = sitesByClass.get(name);
if (sites != null && !sites.isEmpty()) {
ClassNode node = parse(raw);
int replaced = 0;
// 和第二步同样的遍历顺序,所以用计数器就能把密文和实际调用点对上
Map<Group, Integer> counter = new LinkedHashMap<Group, Integer>();
for (MethodNode method : node.methods) {
for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null;
insn = insn.getNext()) {
if (!(insn instanceof LdcInsnNode)
|| !(((LdcInsnNode) insn).cst instanceof String)) continue;
AbstractInsnNode after = insn.getNext();
while (after != null && (after.getOpcode() == Opcodes.NOP
|| after.getOpcode() == Opcodes.CHECKCAST)) {
after = after.getNext();
}
if (!(after instanceof MethodInsnNode)) continue;
MethodInsnNode call = (MethodInsnNode) after;
if (call.getOpcode() != Opcodes.INVOKESTATIC) continue;
String key = node.name + '\u0000' + method.name + '\u0000'
+ call.owner + "." + call.name;
Group group = groups.get(key);
if (group == null || group.plain == null) continue;
Integer seen = counter.get(group);
int index = seen == null ? 0 : seen.intValue();
if (index >= group.ciphers.size()) continue;
counter.put(group, Integer.valueOf(index + 1));
((LdcInsnNode) insn).cst = group.plain[index];
method.instructions.set(call, new InsnNode(Opcodes.NOP));
replaced++;
}
}
rewritten += replaced;
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
node.accept(writer);
raw = writer.toByteArray();
}
out.putNextEntry(new ZipEntry(name));
out.write(raw);
out.closeEntry();
}
}
System.err.println("可读副本已写出(替换 " + rewritten + " 处) -> " + readableOut);
}
}
private static boolean callsCryptHook(MethodNode method) {
for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null;
insn = insn.getNext()) {
if (insn instanceof MethodInsnNode) {
MethodInsnNode call = (MethodInsnNode) insn;
if (HOOK_OWNER.equals(call.owner) && HOOK_NAME.equals(call.name)) return true;
}
}
return false;
}
private static ClassNode parse(byte[] raw) {
ClassNode node = new ClassNode();
new ClassReader(raw).accept(node, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
return node;
}
private static String escape(String text) {
// 解出来的可能是任意 char(有些"字符串"其实是二进制数据),一律转成纯 ASCII 转义,
// 免得写 UTF-8 时因为落单代理对炸掉。
StringBuilder builder = new StringBuilder(text.length() + 8);
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c == '\t') builder.append("\\t");
else if (c == '\n') builder.append("\\n");
else if (c == '\r') builder.append("\\r");
else if (c == '\\') builder.append("\\\\");
else if (c >= 0x20 && c <= 0x7E) builder.append(c);
else builder.append(String.format("\\u%04x", Integer.valueOf(c)));
}
return builder.toString();
}
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();
}
private static final class Site {
final LdcInsnNode ldc;
final MethodInsnNode call;
final MethodNode method;
final Group group;
final int index;
Site(LdcInsnNode ldc, MethodInsnNode call, MethodNode method, Group group, int index) {
this.ldc = ldc;
this.call = call;
this.method = method;
this.group = group;
this.index = index;
}
}
private static final class Group {
final String owner;
final String method;
final String decryptorOwner;
final String decryptorName;
final List<String> ciphers = new ArrayList<String>();
String[] plain;
Group(String owner, String method, String decryptorOwner, String decryptorName) {
this.owner = owner;
this.method = method;
this.decryptorOwner = decryptorOwner;
this.decryptorName = decryptorName;
}
String[] decrypt() {
try {
byte[] bytes = build(owner, method, decryptorOwner, decryptorName, ciphers);
ShadowLoader loader = new ShadowLoader(owner, bytes, DecAll.class.getClassLoader());
Class<?> cls = Class.forName(owner, false, loader);
if ("<clinit>".equals(method)) {
Class.forName(owner, true, loader);
} else if ("<init>".equals(method)) {
cls.getDeclaredConstructor().newInstance();
} else {
for (Method candidate : cls.getDeclaredMethods()) {
if (candidate.getName().equals(method)) {
candidate.setAccessible(true);
candidate.invoke(null);
break;
}
}
}
return (String[]) cls.getField("R").get(null);
} catch (Throwable error) {
Throwable cause = error;
while (cause.getCause() != null && cause.getCause() != cause) cause = cause.getCause();
System.err.println(" [失败] " + owner + "." + method + " via " + decryptorOwner + "."
+ decryptorName + " : " + error + (cause == error ? "" : " <- " + cause));
return null;
}
}
}
private static byte[] build(String owner, String method, String decryptorOwner,
String decryptorName, List<String> ciphers) {
String internal = owner.replace('.', '/');
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
writer.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER, internal, null,
"java/lang/Object", null);
writer.visitField(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "R", "[Ljava/lang/String;",
null, null).visitEnd();
int access = Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC;
if ("<clinit>".equals(method)) access = Opcodes.ACC_STATIC;
else if ("<init>".equals(method)) access = Opcodes.ACC_PUBLIC;
MethodVisitor code = writer.visitMethod(access, method, "()V", null, null);
code.visitCode();
if ("<init>".equals(method)) {
code.visitVarInsn(Opcodes.ALOAD, 0);
code.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
}
code.visitLdcInsn(Integer.valueOf(ciphers.size()));
code.visitTypeInsn(Opcodes.ANEWARRAY, "java/lang/String");
code.visitFieldInsn(Opcodes.PUTSTATIC, internal, "R", "[Ljava/lang/String;");
for (int i = 0; i < ciphers.size(); i++) {
code.visitFieldInsn(Opcodes.GETSTATIC, internal, "R", "[Ljava/lang/String;");
code.visitLdcInsn(Integer.valueOf(i));
code.visitLdcInsn(ciphers.get(i));
code.visitMethodInsn(Opcodes.INVOKESTATIC, decryptorOwner, decryptorName, DECRYPT_DESC, false);
code.visitInsn(Opcodes.AASTORE);
}
code.visitInsn(Opcodes.RETURN);
code.visitMaxs(0, 0);
code.visitEnd();
writer.visitEnd();
return writer.toByteArray();
}
private static final class ShadowLoader extends ClassLoader {
private final String shadowed;
private final byte[] bytes;
ShadowLoader(String shadowed, byte[] bytes, ClassLoader parent) {
super(parent);
this.shadowed = shadowed;
this.bytes = bytes;
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (name.equals(shadowed)) {
Class<?> found = findLoadedClass(name);
if (found == null) found = defineClass(name, bytes, 0, bytes.length);
if (resolve) resolveClass(found);
return found;
}
return super.loadClass(name, resolve);
}
}
}