Files
Spine_lab/viewpanel/src/com/spineview/Scene2dPanel.java
T
tianmo 8f7b72271f 阶段四收尾:面板两行布局(row/expand/fill)+ 英文输出 + 混淆名对照表
- ViewInstaller:用 Table.row()/Cell.expand()+fill()/setFillParent 排出"
  按钮一行、可滚动文字区一行"的面板;修掉把 setDebug 当成 setFillParent 的误用
- Scene2dPanel:按钮 isChecked/setChecked 与 Label.setText 改用对照表定名,
  修掉"点一次反复触发"与"文字不刷新"两个 bug
- SceneReader:面板输出改英文(编辑器缺中文字形,中文会渲染成方块)
- 新增 tools/obfmap/MapMethods.java:载荷 vs 上游 libGDX 的字节码指纹比对工具
- 新增 混淆名对照表.md:类/方法/字段映射、推导办法、已踩的坑、验证状态
2026-10-16 14:06:42 +08:00

610 lines
25 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.
package com.spineview;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* 把"按钮 + 文字显示"面板挂进 Spine 的 scene2d 场景树。
*
* 编辑器的窗口是 jglfw 建的原生 NSWindow,不是 AWT 窗口,没有 Swing 面板可挂;
* 界面本身是 libGDX scene2d 画出来的,只能加进它的 Stage。
*
* 这套运行时把 libGDX 的方法名混淆了(类名保留):
* Stage 的实现 = uk(继承 dt)、Group = do、Actor = dl、Table = et
* 所以下面全部"按类型/签名找成员",不依赖混淆后的名字。
*
* Stage 实例在 tD 里只是局部变量,任何静态字段都没有;但 libGDX 里每个 Actor
* 都记着自己所在的 Stage,而 Fx.D 是一个静态 Actor —— 从这里就能把 Stage 捞回来。
*/
public final class Scene2dPanel {
private static final String STAGE = "dt";
private static final String GROUP = "do";
private static final String ACTOR = "dl";
private static final String SKIN = "com.badlogic.gdx.scenes.scene2d.ui.Skin";
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 static final String WINDOW = "com.badlogic.gdx.scenes.scene2d.ui.Window";
private Scene2dPanel() {
}
public static void installWhenReady(final ClassLoader loader) {
System.err.println("[panel] 开始等 Stage/Skin…");
if (System.getProperty("spine.dumpStatics") != null) {
try {
Thread.sleep(30000);
} catch (InterruptedException ignored) {
return;
}
dumpStatics(loader, paths((java.net.URLClassLoader) loader));
return;
}
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 240; i++) {
try {
Object stage = stageFromActorAnchor(loader);
Object skin = null;
if (stage != null) {
Object root = firstFieldOfType(stage, GROUP);
if (root != null) skin = skinFromRoot(root);
}
if (i % 10 == 0) {
System.err.println("[panel] 第 " + i + " 轮: stage=" + nameOf(stage)
+ " skin=" + nameOf(skin));
}
if (stage != null && skin != null) {
install(loader, stage, skin, i);
return;
}
} catch (Throwable error) {
System.err.println("[panel] 查找失败: " + error);
error.printStackTrace(System.err);
}
try {
Thread.sleep(500);
} catch (InterruptedException ignored) {
return;
}
}
System.err.println("[panel] 等不到 Stage/Skin");
}
}, "spineview-panel");
thread.setDaemon(true);
thread.start();
}
private static String nameOf(Object value) {
return value == null ? "null" : value.getClass().getName();
}
/**
* 有了 Stage 实例之后挂面板。Skin 从 Stage 根节点的子节点上取
* (libGDX 的 Table 自己存着构造时用的 Skin)。
*/
public static void installForStage(final Object stage, final ClassLoader loader) {
// Stage 是先建出来的,UI 是随后才一层层加进去的,所以要等面板挂上来之后才拿得到 Skin。
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
for (int i = 0; i < 240; i++) {
Object skin = findSkinDeep(stage, 6, 20000);
if (skin != null) {
System.err.println("[panel] 第 " + i + " 轮拿到 Skin: "
+ skin.getClass().getName());
install(loader, stage, skin, i);
return;
}
if (i % 20 == 0) {
System.err.println("[panel] 第 " + i + " 轮还没拿到 Skin(Stage "
+ nameOf(stage) + ")");
}
Thread.sleep(500);
}
System.err.println("[panel] 一直没拿到 Skin");
} catch (Throwable error) {
System.err.println("[panel] installForStage 失败");
error.printStackTrace(System.err);
}
}
}, "spineview-skin");
thread.setDaemon(true);
thread.start();
}
private static void install(final ClassLoader loader, Object stage, Object skin, int waited)
throws Exception {
Class<?> skinClass = Class.forName(SKIN, true, loader);
Class<?> buttonClass = Class.forName(TEXT_BUTTON, true, loader);
Class<?> labelClass = Class.forName(LABEL, true, loader);
Class<?> windowClass = Class.forName(WINDOW, true, loader);
final Object button = buttonClass.getConstructor(String.class, skinClass)
.newInstance("Read Scene Objects", skin);
final Object label = labelClass.getConstructor(CharSequence.class, skinClass)
.newInstance("Click the button to list bones / slots / attachments.", skin);
// setBounds 必须各自在自己的类层次上找:Button 和 Label 只有共同的 Actor 祖先,
// 拿 Button 上找到的方法去 invoke Label 会报"object is not an instance of declaring class"。
// 先挂在场景根节点上(视口左上角,避开编辑器自己的工具条)。
findInstanceMethod(button.getClass(), 4, float.class).invoke(button, 60f, 560f, 200f, 32f);
findInstanceMethod(label.getClass(), 4, float.class).invoke(label, 60f, 300f, 540f, 250f);
Object root = firstFieldOfType(stage, GROUP);
addChild(root, button);
addChild(root, label);
System.err.println("[panel] 已把按钮和文字面板挂到 Stage(等了 " + waited + " 轮)");
wireButton(button, label, loader);
}
/**
* 把"按钮点击 → 读取场景对象 → 更新文字"这条链路接上。
*
* 按钮类的方法名被混淆了,没用名字去猜:Button 里有一对 setChecked(boolean)/isChecked(),
* 我们把每个 setter 设成 true,再看哪个无参 boolean getter 跟着变 true,就标定了这一对。
* libGDX 的 Button 自带一个 ClickListener,点一下就会翻转 isChecked,所以轮询它就等于监听点击。
*/
public static void wireButton(final Object button, final Object label, final ClassLoader loader)
throws Exception {
Class<?> buttonClass = button.getClass();
Class<?> labelClass = label.getClass();
// 优先用对照表里定好的名字:Button 上无参方法叫 "String" 的有两个(boolean 和 float),
// 只按名字取会取错,必须连返回类型一起判。
Method isChecked = findPinned(buttonClass, Obf.BUTTON_IS_CHECKED, boolean.class);
Method setChecked = findPinned(buttonClass, Obf.BUTTON_SET_CHECKED, void.class, boolean.class);
if (isChecked == null || setChecked == null) {
// 名字对不上(换版本)就退回运行期自校准 —— 只在 Button 自己这一层里试,
// 不然会扫到继承来的方法,选出一对语义无关的 setter/getter(现象是点击被反复触发)。
System.err.println("[panel] 对照表里的 isChecked/setChecked 没找到,改用自校准");
Method[] pair = calibrateChecked(button, buttonClass);
if (pair == null) {
System.err.println("[panel] 没能标定 isChecked/setChecked");
return;
}
isChecked = pair[0];
setChecked = pair[1];
}
if (isChecked == null) {
System.err.println("[panel] 没能标定 isChecked/setChecked");
return;
}
System.err.println("[panel] isChecked=" + isChecked.getName()
+ " setChecked=" + setChecked.getName());
final Method readChecked = isChecked;
final Method resetChecked = setChecked;
// setChecked(boolean, boolean) 的第二个参数是"是否派发事件"。复位时用 false,
// 否则把 isChecked 置回 false 这件事本身又会派发一次事件,表现为按一下读两次。
final Method resetQuietly = findPinned(buttonClass, Obf.BUTTON_SET_CHECKED_FIRE, void.class,
boolean.class, boolean.class);
// 必须连返回类型一起判:Label 上 `D(CharSequence)` 有 void(setText)和 boolean
// (textEquals)两个,按参数个数找会挑中 textEquals,表现是文字永远不刷新。
final Method setText = findPinned(labelClass, Obf.LABEL_SET_TEXT, void.class, CharSequence.class);
if (setText == null) {
System.err.println("[panel] 没找到 Label.setText,面板文字不会更新");
return;
}
final ClassLoader classLoader = loader;
Thread poll = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
try {
if (Boolean.TRUE.equals(readChecked.invoke(button))) {
if (resetQuietly != null) {
resetQuietly.invoke(button, Boolean.FALSE, Boolean.FALSE);
} else {
resetChecked.invoke(button, Boolean.FALSE);
}
System.err.println("[panel] 按钮被触发,开始读取场景对象");
// Actor 自己记着所在 Stage,直接问它就行
Object stage = firstFieldOfType(button, STAGE);
String text = SceneReader.read(classLoader, stage);
System.err.println("[panel] 读取完成,共 " + text.length() + " 字符");
setText.invoke(label, text);
}
Thread.sleep(80);
} catch (Throwable error) {
System.err.println("[panel] 轮询异常: " + error);
return;
}
}
}
}, "spineview-poll");
poll.setDaemon(true);
poll.start();
// 验证用:--Dspine.simulateClick=1 时自动把按钮"按"一次,方便无人值守地看结果。
if (System.getProperty("spine.simulateClick") != null) {
Thread auto = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(4000);
resetChecked.invoke(button, Boolean.TRUE);
} catch (Throwable error) {
System.err.println("[panel] 自动触发失败: " + error);
}
}
}, "spineview-autoclick");
auto.setDaemon(true);
auto.start();
}
}
/** 按"名字 + 参数类型 + 返回类型"找方法:混淆后同名方法成堆,少判一个条件就会挑错。 */
private static Method findPinned(Class<?> owner, String name, Class<?> returnType,
Class<?>... parameterTypes) {
for (Method method : owner.getMethods()) {
if (!method.getName().equals(name)) continue;
if (!java.util.Arrays.equals(method.getParameterTypes(), parameterTypes)) continue;
if (returnType != null && method.getReturnType() != returnType) continue;
return method;
}
return null;
}
/**
* 运行期自校准:把每个 boolean setter 设成 true,看哪个无参 boolean getter 跟着变 true。
* 只在按钮类自己这一层(含父类)的方法里找 —— 扫到继承链上无关的方法会配出一对假组合,
* 现象是"点一次按钮被反复触发"。
*/
private static Method[] calibrateChecked(Object button, Class<?> buttonClass) throws Exception {
for (Class<?> type = buttonClass; type != null && type != Object.class;
type = type.getSuperclass()) {
for (Method setter : type.getDeclaredMethods()) {
if (setter.getParameterTypes().length != 1
|| setter.getParameterTypes()[0] != boolean.class
|| setter.getReturnType() != void.class) continue;
setter.setAccessible(true);
setter.invoke(button, Boolean.TRUE);
for (Method getter : type.getDeclaredMethods()) {
if (getter.getParameterTypes().length != 0
|| getter.getReturnType() != boolean.class) continue;
getter.setAccessible(true);
if (Boolean.TRUE.equals(getter.invoke(button))) {
setter.invoke(button, Boolean.FALSE);
return new Method[] {getter, setter};
}
}
setter.invoke(button, Boolean.FALSE);
}
}
return null;
}
/** 从 Fx.D(静态 Actor)出发拿 Stage:libGDX 里每个 Actor 都记着自己所在的 Stage。 */
private static Object stageFromActorAnchor(ClassLoader loader) {
try {
Class<?> holder = Class.forName("Fx", true, loader);
Object anchor = null;
for (Field field : holder.getDeclaredFields()) {
if (!Modifier.isStatic(field.getModifiers())) continue;
if (!field.getType().getName().equals(ACTOR)) continue;
field.setAccessible(true);
anchor = field.get(null);
if (anchor != null) break;
}
if (anchor == null) return null;
return firstFieldOfType(anchor, STAGE);
} catch (Throwable error) {
return null;
}
}
/** Stage 根节点是 do(Group);拿它第一个子节点身上的 Skin(Table 都带 Skin 字段)。 */
@SuppressWarnings("unused")
private static Object skinFromRoot(Object root) throws Exception {
Object children = firstArrayFieldOfType(root, "[L" + ACTOR + ";");
if (!(children instanceof Object[])) return null;
for (Object child : (Object[]) children) {
if (child == null) continue;
Object skin = firstFieldOfType(child, SKIN);
if (skin != null) return skin;
Object deep = skinFromActor(child, 3);
if (deep != null) return deep;
}
return null;
}
/** 顺着 actor 树往下找 Skin,最多 dig 层。 */
private static Object skinFromActor(Object actor, int dig) throws Exception {
if (actor == null || dig < 0) return null;
Object direct = firstFieldOfType(actor, SKIN);
if (direct != null) return direct;
if (dig == 0) return null;
Object children = firstArrayFieldOfType(actor, "[L" + ACTOR + ";");
if (!(children instanceof Object[])) return null;
for (Object child : (Object[]) children) {
Object found = skinFromActor(child, dig - 1);
if (found != null) return found;
}
return null;
}
private static Object firstFieldOfType(Object target, String typeName) throws Exception {
for (Class<?> type = target.getClass(); type != null; type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (!field.getType().getName().equals(typeName)) continue;
field.setAccessible(true);
Object value = field.get(target);
if (value != null) return value;
}
}
return null;
}
/** 从任意对象出发做有界广度搜索,找第一个 Skin 实例。 */
private static Object findSkinDeep(Object start, int maxDepth, int maxNodes) {
java.util.Set<Object> seen = java.util.Collections.newSetFromMap(
new java.util.IdentityHashMap<Object, Boolean>());
java.util.ArrayDeque<Object> queue = new java.util.ArrayDeque<Object>();
java.util.ArrayDeque<Integer> depths = new java.util.ArrayDeque<Integer>();
queue.add(start);
depths.add(Integer.valueOf(0));
int nodes = 0;
while (!queue.isEmpty() && nodes++ < maxNodes) {
Object current = queue.poll();
int depth = depths.poll().intValue();
if (current == null || !seen.add(current)) continue;
if (current.getClass().getName().equals(SKIN)) return current;
if (depth >= maxDepth) continue;
if (current.getClass().isArray()) {
if (!current.getClass().getComponentType().isPrimitive()) {
for (Object item : (Object[]) current) {
if (item != null) {
queue.add(item);
depths.add(Integer.valueOf(depth + 1));
}
}
}
continue;
}
String name = current.getClass().getName();
if (depth > 0 && (name.startsWith("java.") || name.startsWith("javax.")
|| name.startsWith("sun.") || name.startsWith("org.lwjgl"))) continue;
for (Class<?> type = current.getClass(); type != null; type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers())) continue;
if (field.getType().isPrimitive()) continue;
try {
field.setAccessible(true);
Object value = field.get(current);
if (value != null) {
queue.add(value);
depths.add(Integer.valueOf(depth + 1));
}
} catch (Throwable ignored) {
// 跳过读不到的
}
}
}
}
return null;
}
@SuppressWarnings("unused")
private static Object firstFieldOfTypeOld(Object target, String typeName) throws Exception {
for (Class<?> type = target.getClass(); type != null; type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (!field.getType().getName().equals(typeName)) continue;
field.setAccessible(true);
Object value = field.get(target);
if (value != null) return value;
}
}
return null;
}
private static Object firstArrayFieldOfType(Object target, String descriptor)
throws Exception {
for (Class<?> type = target.getClass(); type != null; type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (!field.getType().getName().equals(descriptor)) continue;
field.setAccessible(true);
Object value = field.get(target);
if (value != null) return value;
}
}
return null;
}
/** 按"参数个数 + 参数类型"找方法(名字被混淆了,只能这么找)。 */
/** Table.add(Actor):一个参数、参数能容纳该 actor、返回值非 void。 */
private static Method findAddMethod(Class<?> tableClass, Class<?> actorClass)
throws NoSuchMethodException {
for (Method method : tableClass.getMethods()) {
if (Modifier.isStatic(method.getModifiers())) continue;
Class<?>[] types = method.getParameterTypes();
if (types.length != 1) continue;
if (!types[0].isAssignableFrom(actorClass)) continue;
if (method.getReturnType() == void.class) continue;
return method;
}
throw new NoSuchMethodException("找不到 add");
}
/** Table.row():无参、返回值就是表格自己。 */
private static Method findRowMethod(Class<?> tableClass) throws NoSuchMethodException {
for (Method method : tableClass.getMethods()) {
if (Modifier.isStatic(method.getModifiers())) continue;
if (method.getParameterTypes().length != 0) continue;
// row() 声明在 Table 上、返回 Table;Window 继承来的,所以不能要求返回值正好是 Window
Class<?> returns = method.getReturnType();
if (returns != Object.class && returns.isAssignableFrom(tableClass)) return method;
}
throw new NoSuchMethodException("找不到 row");
}
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);
}
@SuppressWarnings("unused")
private static Method findMethod(Class<?> owner, int parameterCount, Class<?> parameterType)
throws NoSuchMethodException {
for (Method method : owner.getMethods()) {
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);
System.err.println("[panel] 用 " + method.getDeclaringClass().getSimpleName() + "."
+ method.getName() + " 挂上 " + actor.getClass().getSimpleName());
return;
}
throw new NoSuchMethodException("找不到接纳 " + actor.getClass().getName() + " 的单参方法");
}
/** 全量扫描静态字段(用 Unsafe,不触发类初始化)。留作备用入口。 */
static Object scanStaticNoInit(ClassLoader loader, List<String> paths, String typeName) {
try {
Object unsafe = unsafe();
Class<?> unsafeClass = unsafe.getClass();
Method base = unsafeClass.getMethod("staticFieldBase", Field.class);
Method offset = unsafeClass.getMethod("staticFieldOffset", Field.class);
Method getObject = unsafeClass.getMethod("getObject", Object.class, long.class);
for (String path : paths) {
try (ZipFile zip = new ZipFile(path)) {
Enumeration<? extends ZipEntry> entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
String name = entry.getName();
if (!name.endsWith(".class")) continue;
Class<?> type;
try {
type = Class.forName(name.substring(0, name.length() - 6).replace('/', '.'),
false, loader);
} catch (Throwable ignored) {
continue;
}
for (Field field : type.getDeclaredFields()) {
if (!Modifier.isStatic(field.getModifiers())) continue;
if (!field.getType().getName().equals(typeName)) continue;
try {
Object fieldBase = base.invoke(unsafe, field);
long fieldOffset = ((Long) offset.invoke(unsafe, field)).longValue();
Object value = getObject.invoke(unsafe, fieldBase, Long.valueOf(fieldOffset));
if (value != null) return value;
} catch (Throwable ignored) {
// 读不到就跳过
}
}
}
} catch (Throwable ignored) {
// 不是 zip
}
}
} catch (Throwable ignored) {
// 忽略
}
return null;
}
private static Object unsafe() throws Exception {
Class<?> unsafeClass = Class.forName("sun.misc.Unsafe");
Field field = unsafeClass.getDeclaredField("theUnsafe");
field.setAccessible(true);
return field.get(null);
}
/** 把载荷里所有非空的静态字段倒出来(用 Unsafe,不触发类初始化)。 */
static void dumpStatics(ClassLoader loader, List<String> paths) {
try {
Object unsafe = unsafe();
Class<?> unsafeClass = unsafe.getClass();
Method base = unsafeClass.getMethod("staticFieldBase", Field.class);
Method offset = unsafeClass.getMethod("staticFieldOffset", Field.class);
Method getObject = unsafeClass.getMethod("getObject", Object.class, long.class);
for (String path : paths) {
try (ZipFile zip = new ZipFile(path)) {
Enumeration<? extends ZipEntry> entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
String name = entry.getName();
if (!name.endsWith(".class")) continue;
Class<?> type;
try {
type = Class.forName(name.substring(0, name.length() - 6).replace('/', '.'),
false, loader);
} catch (Throwable ignored) {
continue;
}
for (Field field : type.getDeclaredFields()) {
if (!Modifier.isStatic(field.getModifiers())) continue;
if (field.getType().isPrimitive()) continue;
try {
Object fieldBase = base.invoke(unsafe, field);
long fieldOffset = ((Long) offset.invoke(unsafe, field)).longValue();
Object value = getObject.invoke(unsafe, fieldBase, Long.valueOf(fieldOffset));
if (value != null) {
System.err.println("[statics] " + type.getName() + "." + field.getName()
+ " : " + field.getType().getName() + " = " + value.getClass().getName());
}
} catch (Throwable ignored) {
// 跳过
}
}
}
} catch (Throwable ignored) {
// 不是 zip
}
}
} catch (Throwable error) {
System.err.println("[statics] 失败: " + error);
}
}
static List<String> paths(java.net.URLClassLoader loader) {
List<String> paths = new ArrayList<String>();
for (java.net.URL url : loader.getURLs()) {
if (!"file".equals(url.getProtocol())) continue;
try {
paths.add(new java.io.File(url.toURI()).getAbsolutePath());
} catch (Throwable ignored) {
// 跳过
}
}
return paths;
}
}