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

+231
View File
@@ -0,0 +1,231 @@
package com.spineview.dec;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.lang.invoke.CallSite;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Constructor;
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.HashMap;
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 org.objectweb.asm.ClassReader;
import org.objectweb.asm.Handle;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.InvokeDynamicInsnNode;
import org.objectweb.asm.tree.MethodNode;
/**
* 把 3.8.99 的"混淆化动态派发"整层解出来。
*
* 编辑器里几乎每个方法调用都被编译成 invokedynamic,每个类都有自己的一份引导方法
* (Editor.Ob / AB.Nb / AD.HW ...,共 1421 个),签名统一是
* (Lookup, String 名字, MethodType, 密文描述) -> Object
* 按 JVM 的方式把它调一遍拿回 CallSite,再顺着绑定句柄里的 MemberName,
* 就能问出这次调用真正指向哪个方法。
*
* 用法:
* java -cp "asm.jar:本工具:已全 public 化的载荷.zip" com.spineview.dec.ObMap <载荷> <映射.tsv>
*/
public final class ObMap {
private static final String BSM_DESC =
"(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;";
public static void main(String[] args) throws Exception {
Path payload = Paths.get(args[0]);
Path out = Paths.get(args[1]);
// 1) 收集所有 invokedynamic 站点
List<Site> sites = new ArrayList<Site>();
Set<String> distinctNames = new HashSet<String>();
try (ZipFile zip = new ZipFile(payload.toFile())) {
Enumeration<? extends ZipEntry> entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (!entry.getName().endsWith(".class")) continue;
ClassNode node;
try {
node = parse(readAll(zip.getInputStream(entry)));
} catch (Throwable error) {
continue;
}
for (MethodNode method : node.methods) {
for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null;
insn = insn.getNext()) {
if (!(insn instanceof InvokeDynamicInsnNode)) continue;
InvokeDynamicInsnNode indy = (InvokeDynamicInsnNode) insn;
Handle bsm = indy.bsm;
if (!BSM_DESC.equals(bsm.getDesc())) continue;
sites.add(new Site(node.name, method.name, bsm.getOwner(), bsm.getName(),
indy.name, indy.desc, indy.bsmArgs.length > 0 ? indy.bsmArgs[0] : null));
distinctNames.add(indy.name);
}
}
}
}
System.err.println("invokedynamic 站点 " + sites.size() + ",不同名字 " + distinctNames.size());
// 2) 每个 (引导类,引导方法,名字) 只需解析一次
Map<String, String> resolved = new LinkedHashMap<String, String>();
Map<String, Method> bootstrapCache = new HashMap<String, Method>();
MethodHandles.Lookup[] lookupCache = new MethodHandles.Lookup[1];
int done = 0, failed = 0;
for (Site site : sites) {
String triple = site.bsmOwner + "." + site.bsmName + "#" + site.indyName;
if (resolved.containsKey(triple)) continue;
String target;
try {
target = resolve(site, bootstrapCache, lookupCache);
} catch (Throwable error) {
target = "<失败> " + error;
failed++;
}
resolved.put(triple, target);
if (++done % 2000 == 0) System.err.println(" 已解析 " + done);
}
System.err.println("解析组合 " + resolved.size() + " 个,失败 " + failed);
try (BufferedWriter writer = Files.newBufferedWriter(out, StandardCharsets.UTF_8)) {
for (Site site : sites) {
String triple = site.bsmOwner + "." + site.bsmName + "#" + site.indyName;
writer.write(site.bsmOwner);
writer.write('\t');
writer.write(site.bsmName);
writer.write('\t');
writer.write(site.indyName);
writer.write('\t');
writer.write(resolved.get(triple));
writer.write('\n');
}
}
System.err.println("映射已写出 -> " + out);
}
private static String resolve(Site site, Map<String, Method> bootstrapCache,
MethodHandles.Lookup[] lookupCache) throws Throwable {
Class<?> owner = Class.forName(site.bsmOwner.replace('/', '.'));
String key = site.bsmOwner + "." + site.bsmName;
Method bootstrap = bootstrapCache.get(key);
if (bootstrap == null) {
for (Method candidate : owner.getDeclaredMethods()) {
if (candidate.getName().equals(site.bsmName)
&& candidate.getParameterTypes().length == 4) {
bootstrap = candidate;
break;
}
}
if (bootstrap == null) throw new NoSuchMethodException(site.bsmName);
bootstrap.setAccessible(true);
bootstrapCache.put(key, bootstrap);
}
if (lookupCache[0] == null) lookupCache[0] = trustedLookup();
MethodType type = MethodType.fromMethodDescriptorString(site.desc,
ObMap.class.getClassLoader());
Object callSite = bootstrap.invoke(null, lookupCache[0], site.indyName, type, site.arg);
if (!(callSite instanceof CallSite)) return String.valueOf(callSite);
return describe(((CallSite) callSite).getTarget());
}
/** CallSite 的目标是"真实方法 + 若干个已绑定的参数",往里挖一层就能拿到方法本体。 */
private static String describe(MethodHandle target) {
String direct = memberOf(target);
if (direct != null) return direct;
for (Class<?> type = target.getClass(); type != null; type = type.getSuperclass()) {
for (java.lang.reflect.Field field : type.getDeclaredFields()) {
if (!field.getName().startsWith("argL") && !field.getName().startsWith("argJ")
&& !field.getName().startsWith("argI")) continue;
try {
field.setAccessible(true);
Object value = field.get(target);
if (!(value instanceof MethodHandle)) continue;
String nested = memberOf((MethodHandle) value);
if (nested != null) return nested;
} catch (Throwable ignored) {
// 继续找
}
}
}
return "?" + target.type();
}
/** 直接句柄能拿到 MemberName,也就能知道 类名.方法名 以及是不是静态方法。 */
private static String memberOf(MethodHandle handle) {
try {
Method internal = MethodHandle.class.getDeclaredMethod("internalMemberName");
internal.setAccessible(true);
Object member = internal.invoke(handle);
if (member == null) return null;
Class<?> memberClass = member.getClass();
Method getDeclaring = memberClass.getMethod("getDeclaringClass");
Method getName = memberClass.getMethod("getName");
Method isStatic = memberClass.getMethod("isStatic");
getDeclaring.setAccessible(true);
getName.setAccessible(true);
isStatic.setAccessible(true);
Object declaring = getDeclaring.invoke(member);
String owner = String.valueOf(declaring).replace("class ", "").replace("interface ", "");
return owner + "." + getName.invoke(member)
+ (Boolean.TRUE.equals(isStatic.invoke(member)) ? " (static)" : "");
} catch (Throwable error) {
return null;
}
}
/** Java 8 上的经典手法:造一个权限全开的 Lookup。 */
private static MethodHandles.Lookup trustedLookup() throws Exception {
Constructor<MethodHandles.Lookup> constructor =
MethodHandles.Lookup.class.getDeclaredConstructor(Class.class, int.class);
constructor.setAccessible(true);
return constructor.newInstance(Object.class, Integer.valueOf(-1));
}
private static final class Site {
final String owner;
final String method;
final String bsmOwner;
final String bsmName;
final String indyName;
final String desc;
final Object arg;
Site(String owner, String method, String bsmOwner, String bsmName, String indyName,
String desc, Object arg) {
this.owner = owner;
this.method = method;
this.bsmOwner = bsmOwner;
this.bsmName = bsmName;
this.indyName = indyName;
this.desc = desc;
this.arg = arg;
}
}
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 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();
}
}