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

@@ -0,0 +1,262 @@
package com.spineview;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
/**
* 被 ViewPatcher 插进 tD.<init> 的调用目标 —— 必须由**编辑器的类加载器**加载,
* 所以运行时要把 viewpanel/build/classes 放进 URLClassLoader 的 URLs。
*
* 它的活:为新的 "Spine View" 视图建一个面板 Actor,塞进 us.SPINEVIEW.D。
* 之后编辑器的停靠/折叠框架就会像对待其它视图一样对待它。
*/
public final class ViewInstaller {
private static final String SKIN = "com.badlogic.gdx.scenes.scene2d.ui.Skin";
private static final String TABLE = "et";
private static final String ACTOR = "dl";
private static final String LABEL = "com.badlogic.gdx.scenes.scene2d.ui.Label";
private static final String TEXT_BUTTON = "com.badlogic.gdx.scenes.scene2d.ui.TextButton";
private ViewInstaller() {
}
/** 判定实验用:只打印一行,不做任何别的事。 */
public static void ping() {
System.err.println("[view] ping 被调用");
}
public static void install() {
// 这是从 tD 的构造函数中途调进来的:这时候编辑器自己的资源(皮肤贴图走 obt: 协议)
// 还在读,我们如果在同一个调用里 Class.forName 触发别的类的初始化,会把流搞坏。
// 所以这里只起个后台线程,等构造函数跑完、界面搭好之后再真正安装。
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException ignored) {
return;
}
doInstall();
}
}, "spineview-view-installer");
thread.setDaemon(true);
thread.start();
}
private static void doInstall() {
try {
ClassLoader loader = ViewInstaller.class.getClassLoader();
installForLoader(loader);
} catch (Throwable error) {
System.err.println("[view] 安装失败");
error.printStackTrace(System.err);
}
}
/**
* 从编辑器外面调用也可以:只要把编辑器那个 URLClassLoader 传进来。
*
* 视图常量已经由 view-agent 注册进 us 枚举,这里只负责把面板 Actor 填进
* us.spineView.D —— 之后编辑器的停靠框架就会像对待其它视图一样对待它。
*/
public static boolean installForLoader(ClassLoader loader) {
try {
Class<?> viewEnum = Class.forName("us", true, loader);
Object view = viewEnum.getMethod("valueOf", String.class).invoke(null, "spineView");
Object skin = findSkin(loader);
if (skin == null) {
System.err.println("[view] 找不到 Skin");
return false;
}
Object panel = buildPanel(loader, skin);
for (Field field : viewEnum.getDeclaredFields()) {
if (!field.getType().getName().equals(ACTOR)) continue;
field.setAccessible(true);
field.set(view, panel);
System.err.println("[view] 已把 Spine View 面板挂到视图 " + view);
return true;
}
System.err.println("[view] 视图枚举里找不到 Actor 字段");
} catch (Throwable error) {
System.err.println("[view] 安装失败(稍后重试): " + error);
}
return false;
}
/** Skin 挂在 OG 的静态字段上(字节码里 OG 有 LSkin;D)。 */
private static Object findSkin(ClassLoader loader) throws Exception {
Class<?> holder = Class.forName("OG", true, loader);
for (Field field : holder.getDeclaredFields()) {
if (!Modifier.isStatic(field.getModifiers())) continue;
if (!field.getType().getName().equals(SKIN)) continue;
field.setAccessible(true);
Object value = field.get(null);
if (value != null) return value;
}
return null;
}
/**
* 编辑器的停靠框架会按惯例去找 "icon-tab" + 视图名 的图标,找不到就直接抛异常把界面带崩。
* 所以要在它建面板之前,把我们的图标别名到已有视图(Tree)的图标上。
* 皮肤是在建面板之前填好的,这里用极短间隔轮询去抢这个时间窗。
*/
public static boolean aliasIcon(ClassLoader loader) {
try {
Class<?> skinClass = Class.forName(SKIN, true, loader);
// libGDX 的 Skin 是"按类型分桶"存的:add(name, obj) 用 obj.getClass() 当桶,
// 而 getDrawable(name) 是去 Drawable.class 这个桶里查 —— 两者对不上就会"找不到"。
// 所以要用三参数的 add(name, obj, Class),并且把该 drawable 的所有接口/父类都注册一遍。
Method addTyped = null;
for (Method method : skinClass.getMethods()) {
Class<?>[] types = method.getParameterTypes();
if (types.length == 3 && types[0] == String.class && types[1] == Object.class
&& types[2] == Class.class && method.getReturnType() == void.class) {
addTyped = method;
break;
}
}
if (addTyped == null) return false;
// 停靠框架会按状态取不同的图标名;后缀没对应的就退回基础图标,
// 反正图标是复用的,不新建任何图片资源。
String[] suffixes = { "", "-over", "-down", "-up", "-checked", "-unchecked",
"-disabled", "-inactive", "-active", "-on", "-off", "-selected", "-open",
"-closed", "-hover", "-pressed" };
for (int attempt = 0; attempt < 30000; attempt++) {
Object skin = findSkin(loader);
if (skin != null) {
boolean any = false;
Object base = getDrawable(skinClass, skin, "icon-tabTree");
if (base == null) continue;
for (String suffix : suffixes) {
Object icon = getDrawable(skinClass, skin, "icon-tabTree" + suffix);
if (icon == null) icon = base;
for (Class<?> type : bucketTypes(icon.getClass())) {
try {
addTyped.invoke(skin, "icon-tabSpineView" + suffix, icon, type);
} catch (Throwable ignored) {
// 某些类型不接受就跳过
}
}
any = true;
}
if (any) {
System.err.println("[view] 已给 SpineView 注册图标别名");
return true;
}
}
Thread.sleep(2);
}
} catch (Throwable error) {
System.err.println("[view] 图标别名失败: " + error);
}
return false;
}
/** drawable 的所有接口 + 父类 —— 把可能当桶用的类型都列出来。 */
private static java.util.List<Class<?>> bucketTypes(Class<?> type) {
java.util.List<Class<?>> list = new java.util.ArrayList<Class<?>>();
for (Class<?> current = type; current != null && current != Object.class;
current = current.getSuperclass()) {
list.add(current);
for (Class<?> iface : current.getInterfaces()) {
if (!list.contains(iface)) list.add(iface);
}
}
return list;
}
/**
* Skin 里有一堆只有一个 String 参数的方法(取字体、取颜色、取 drawable…),名字都被混淆了。
* 这里挨个试,哪个能用 "icon-tabTree" 取到非空对象、又不会抛异常,就是 getDrawable。
*/
private static Object getDrawable(Class<?> skinClass, Object skin, String name) {
for (Method method : skinClass.getMethods()) {
Class<?>[] types = method.getParameterTypes();
if (types.length != 1 || types[0] != String.class) continue;
if (method.getReturnType() == void.class) continue;
if (method.getReturnType() == String.class) continue;
try {
Object value = method.invoke(skin, name);
if (value != null) return value;
} catch (Throwable ignored) {
// 取错类型会抛,继续试下一个
}
}
return null;
}
/** 面板内容:一个 Table,里面一行按钮、一行文字显示。 */
private static Object buildPanel(ClassLoader loader, Object skin) throws Exception {
Class<?> skinClass = Class.forName(SKIN, true, loader);
Class<?> tableClass = Class.forName(TABLE, true, loader);
Class<?> buttonClass = Class.forName(TEXT_BUTTON, true, loader);
Class<?> labelClass = Class.forName(LABEL, true, loader);
Object table = tableClass.getConstructor().newInstance();
Object button = buttonClass.getConstructor(String.class, skinClass)
.newInstance("Read Scene Objects", skin);
Object label = labelClass.getConstructor(CharSequence.class, skinClass)
.newInstance("Click the button to list scene objects.", skin);
// 注意:这里必须用 Table 的单元格布局(add/row),不要换成 addActor + 绝对定位 ——
// 那个版本在停靠框架里会抛 "Tab not found: Spine View",把安装打断。
Method add = null;
Method row = null;
for (Method method : tableClass.getMethods()) {
if (Modifier.isStatic(method.getModifiers())) continue;
Class<?>[] types = method.getParameterTypes();
if (types.length == 1 && types[0].isAssignableFrom(buttonClass)
&& method.getReturnType() != void.class) {
if (add == null) add = method;
}
if (types.length == 0 && method.getReturnType() != Object.class
&& method.getReturnType().isAssignableFrom(tableClass)) {
if (row == null) row = method;
}
}
if (add != null) {
add.invoke(table, button);
if (row != null) row.invoke(table);
add.invoke(table, label);
} else {
System.err.println("[view] 找不到 Table.add");
}
Scene2dPanel.wireButton(button, label, loader);
return table;
}
private static Method findInstanceMethod(Class<?> owner, int parameterCount,
Class<?> parameterType) throws NoSuchMethodException {
for (Method method : owner.getMethods()) {
if (Modifier.isStatic(method.getModifiers())) continue;
Class<?>[] types = method.getParameterTypes();
if (types.length != parameterCount) continue;
boolean match = true;
for (Class<?> type : types) {
if (parameterType != null && type != parameterType) {
match = false;
break;
}
}
if (match) return method;
}
throw new NoSuchMethodException(owner.getName() + " 参数=" + parameterCount);
}
/** Group.addActor(Actor):一个参数、能容纳该 actor、返回 void。 */
private static void addChild(Object group, Object actor) throws Exception {
for (Method method : group.getClass().getMethods()) {
Class<?>[] types = method.getParameterTypes();
if (types.length != 1) continue;
if (!types[0].isAssignableFrom(actor.getClass())) continue;
if (method.getReturnType() != void.class) continue;
method.invoke(group, actor);
return;
}
throw new NoSuchMethodException("找不到接纳 " + actor.getClass().getName() + " 的方法");
}
}