阶段四收尾:面板两行布局(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:类/方法/字段映射、推导办法、已踩的坑、验证状态
This commit is contained in:
tianmo committed 2026-10-16 14:06:42 +08:00
1 parent 2e7ab7c33c
commit 8f7b72271f
8 files changed
+677 -52

No files matched your search

+18
View File
@@ -22,6 +22,24 @@ import java.net.URLClassLoader;
*/
public final class Launcher {
public static void main(String[] args) throws Exception {
// 诊断:这套运行时自己退出时不留崩溃报告,所以把"谁在什么时候抛了什么"记下来。
// Runtime.halt 不会走关闭钩子,System.exit / 信号会 —— 靠这个区分。
Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread thread, Throwable error) {
System.err.println("[spineview] 未捕获异常 线程=" + thread.getName()
+ " 时间=" + new java.util.Date());
error.printStackTrace(System.err);
}
});
Runtime.getRuntime().addShutdownHook(new Thread("spineview-shutdown") {
@Override
public void run() {
System.err.println("[spineview] 开始关闭(System.exit 或信号触发的钩子)时间="
+ new java.util.Date());
}
});
// 1) 先把探针放起来(它自己会开后台线程,不阻塞)
try {
SceneScan.start();
+123
View File
@@ -0,0 +1,123 @@
package com.spineview;
/**
* Spine 3.8.99 载荷里**被混淆成员的对照表**(推导过程见仓库根目录 `混淆名对照表.md`)。
*
* 推导办法(可复现,不是猜的):
* 1) `tools/obfmap/MapMethods.java`:把载荷类与上游 libGDX 同签名方法做
* "忽略名字的字节码指纹"比对(操作码序列 + 常量 + 成员的描述符);
* 2) 拿上游 libGDX 1.11.0 的源码逐条核对方法体语义。
*
* ⚠️ 这套载荷用了"**同名不同描述符**"的混淆手法:类里同时存在
* `D(Ldl;)LdR;`、`D()LdR;`、`D(F)LdR;`… 全部叫 `D`。所以任何一处都必须
* **名字 + 描述符一起判断**,只看名字一定会读错。
*/
public final class Obf {
// ======================= 类 =======================
/** com.badlogic.gdx.scenes.scene2d.Actor */
public static final String ACTOR = "dl";
/** com.badlogic.gdx.scenes.scene2d.Group(do extends dl) */
public static final String GROUP = "do";
/** com.badlogic.gdx.scenes.scene2d.ui.WidgetGroup(eX extends do) */
public static final String WIDGET_GROUP = "eX";
/** com.badlogic.gdx.scenes.scene2d.ui.Table(et extends eX) */
public static final String TABLE = "et";
/** com.badlogic.gdx.scenes.scene2d.ui.Cell(implements Poolable) */
public static final String CELL = "dR";
/** com.badlogic.gdx.scenes.scene2d.Stage 的实现(uk extends dt) */
public static final String STAGE = "dt";
/** com.badlogic.gdx.scenes.scene2d.ui.Value */
public static final String VALUE = "eN";
/** com.badlogic.gdx.utils.Array */
public static final String ARRAY = "fI";
// ======================= Button =======================
// 类名保留(TextButton 继承它),但方法名被混淆了。
/** com.badlogic.gdx.scenes.scene2d.ui.Button */
public static final String BUTTON = "com.badlogic.gdx.scenes.scene2d.ui.Button";
/** `isChecked() -> boolean`(体是 `return isChecked;`,读第 1 个 boolean 字段) */
public static final String BUTTON_IS_CHECKED = "String";
/** `setChecked(boolean)` */
public static final String BUTTON_SET_CHECKED = "I";
/** `setChecked(boolean, boolean)`(第二个参数是否派发事件) */
public static final String BUTTON_SET_CHECKED_FIRE = "B";
/** `setDisabled(boolean)` */
public static final String BUTTON_SET_DISABLED = "a_";
// ======================= Label =======================
/** com.badlogic.gdx.scenes.scene2d.ui.Label */
public static final String LABEL = "com.badlogic.gdx.scenes.scene2d.ui.Label";
/**
* `setText(CharSequence) -> void`。
* ⚠️ Label 上还有一个 `D(CharSequence) -> boolean`,那是 `textEquals(CharSequence)`
* —— 只按"一个 CharSequence 参数"去找方法会挑中它,现象是**日志说读取成功、面板文字却不变**。
*/
public static final String LABEL_SET_TEXT = "D";
/** `getText() -> StringBuilder` */
public static final String LABEL_GET_TEXT = "D";
// ======================= Table =======================
/** `add(Actor) -> Cell` */
public static final String TABLE_ADD = "D";
/** `add(Actor...) -> Table`(varargs 版,谨慎别选错) */
public static final String TABLE_ADD_VARARGS = "D";
/** `row() -> Cell` —— 换行的关键,不调用的话所有控件都挤在第一行 */
public static final String TABLE_ROW = "I";
/** `setFillParent(boolean)`(WidgetGroup 的,让面板铺满停靠框) */
public static final String TABLE_SET_FILL_PARENT = "Z";
/** `setClip(boolean)` */
public static final String TABLE_SET_CLIP = "C";
/** `setDebug(boolean)` —— 会把单元格描出彩色边框,别误当成 setFillParent */
public static final String TABLE_SET_DEBUG = "B";
/** `top() -> Table`(表格内容整体对齐) */
public static final String TABLE_TOP = "I";
/** `left() -> Table` */
public static final String TABLE_LEFT = "S";
/** `center() / bottom() / right() -> Table` */
public static final String TABLE_CENTER = "L";
public static final String TABLE_BOTTOM = "C";
public static final String TABLE_RIGHT = "Z";
/** `align(int) -> Table` */
public static final String TABLE_ALIGN = "D";
/** `pad(float) / pad(float×4) -> Table` */
public static final String TABLE_PAD = "D";
/** `padTop/padLeft/padBottom/padRight(float) -> Table` */
public static final String TABLE_PAD_TOP = "B";
public static final String TABLE_PAD_LEFT = "L";
public static final String TABLE_PAD_BOTTOM = "I";
public static final String TABLE_PAD_RIGHT = "S";
// ======================= Cell =======================
/** `fill() -> Cell`(fillX = fillY = 1,撑满单元格) */
public static final String CELL_FILL = "D";
/** `fill(boolean)` */
public static final String CELL_FILL_BOOL = "D";
/** `expand() -> Cell`(expandX = expandY = 1,占掉剩余空间) */
public static final String CELL_EXPAND = "StackMapTable";
/** `expandX() / expandY() -> Cell` */
public static final String CELL_EXPAND_X = "init";
public static final String CELL_EXPAND_Y = "LineNumberTable";
/** `fillX() / fillY() -> Cell` */
public static final String CELL_FILL_X = "B";
public static final String CELL_FILL_Y = "L";
/** `grow() / growX() / growY() -> Cell` */
public static final String CELL_GROW = "F";
public static final String CELL_GROW_X = "Code";
public static final String CELL_GROW_Y = "SourceFile";
/**
* 对齐:`top() / left() / bottom() / right() / center()`。
* 载体是 `align |= X & ~Y` 的形式,位掩码取自 libGDX 的 Align:
* center=1、top=2、bottom=4、left=8、right=16。
*/
public static final String CELL_TOP = "S";
public static final String CELL_LEFT = "C";
public static final String CELL_BOTTOM = "Z";
public static final String CELL_RIGHT = "V";
public static final String CELL_CENTER = "I";
/** `align(int) -> Cell` */
public static final String CELL_ALIGN = "D";
private Obf() {
}
}
+71 -19
View File
@@ -156,24 +156,21 @@ public final class Scene2dPanel {
throws Exception {
Class<?> buttonClass = button.getClass();
Class<?> labelClass = label.getClass();
Method setChecked = null;
Method isChecked = null;
for (Method setter : buttonClass.getMethods()) {
if (setter.getParameterTypes().length != 1
|| setter.getParameterTypes()[0] != boolean.class
|| setter.getReturnType() != void.class) continue;
setter.invoke(button, Boolean.TRUE);
for (Method getter : buttonClass.getMethods()) {
if (getter.getParameterTypes().length != 0
|| getter.getReturnType() != boolean.class) continue;
if (Boolean.TRUE.equals(getter.invoke(button))) {
setChecked = setter;
isChecked = getter;
break;
}
// 优先用对照表里定好的名字: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;
}
setter.invoke(button, Boolean.FALSE);
if (isChecked != null) break;
isChecked = pair[0];
setChecked = pair[1];
}
if (isChecked == null) {
System.err.println("[panel] 没能标定 isChecked/setChecked");
@@ -184,7 +181,17 @@ public final class Scene2dPanel {
final Method readChecked = isChecked;
final Method resetChecked = setChecked;
final Method setText = findMethod(labelClass, 1, CharSequence.class);
// 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
@@ -192,7 +199,11 @@ public final class Scene2dPanel {
while (true) {
try {
if (Boolean.TRUE.equals(readChecked.invoke(button))) {
resetChecked.invoke(button, Boolean.FALSE);
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);
@@ -229,6 +240,47 @@ public final class Scene2dPanel {
}
}
/** 按"名字 + 参数类型 + 返回类型"找方法:混淆后同名方法成堆,少判一个条件就会挑错。 */
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 {
+10 -8
View File
@@ -22,13 +22,15 @@ public final class SceneReader {
public static String read(ClassLoader editorLoader, Object stage) {
StringBuilder out = new StringBuilder();
out.append("=== 场景树里的对象 ===\n");
// 面板里的文字全部用英文:编辑器这套运行时只装了 NotoSans 拉丁字形,
// 中文会渲染成一排小方块(tofu)。
out.append("=== Scene actors ===\n");
try {
listActors(stage, out, 0, 0);
} catch (Throwable error) {
out.append("读取失败: ").append(error).append('\n');
out.append("read failed: ").append(error).append('\n');
}
out.append("=== 编辑器进程里的窗口 ===\n");
out.append("=== Editor windows ===\n");
for (Window window : Window.getWindows()) {
if (window.isVisible()) {
out.append(" ").append(window.getClass().getName()).append(" ")
@@ -38,16 +40,16 @@ public final class SceneReader {
}
}
}
out.append("\n=== 编辑器实例可达的自建对象 ===\n");
out.append("\n=== Objects reachable from Editor ===\n");
try {
Object editor = findEditor(editorLoader);
if (editor == null) {
out.append("(还没等到编辑器实例)\n");
out.append("(editor instance not ready yet)\n");
} else {
walk(editor, out);
}
} catch (Throwable error) {
out.append("读取失败: ").append(error).append('\n');
out.append("read failed: ").append(error).append('\n');
}
return out.toString();
}
@@ -90,7 +92,7 @@ public final class SceneReader {
for (java.awt.Component child : container.getComponents()) {
out.append(indent(depth)).append(child.getClass().getName());
if (child instanceof java.awt.Container) {
out.append(" 子组件=").append(((java.awt.Container) child).getComponentCount());
out.append(" children=").append(((java.awt.Container) child).getComponentCount());
}
out.append('\n');
if (child instanceof java.awt.Container) {
@@ -137,7 +139,7 @@ public final class SceneReader {
if (name.startsWith("java.") || name.startsWith("javax.") || name.startsWith("sun.")
|| name.startsWith("org.lwjgl") || name.startsWith("com.badlogic")) continue;
Field[] fields = current.getClass().getDeclaredFields();
out.append(indent(depth)).append(name).append(" 字段数=").append(fields.length)
out.append(indent(depth)).append(name).append(" fields=").append(fields.length)
.append('\n');
printed++;
if (depth >= 2) continue;
+64 -25
View File
@@ -13,10 +13,10 @@ import java.lang.reflect.Modifier;
*/
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 static final String SCROLL_PANE = "com.badlogic.gdx.scenes.scene2d.ui.ScrollPane";
private ViewInstaller() {
}
@@ -189,10 +189,18 @@ public final class ViewInstaller {
return null;
}
/** 面板内容:一个 Table,里面一行按钮、一行文字显示。 */
/**
* 面板内容:第一行按钮(靠左),第二行是能滚动的文字区(撑满剩下的空间)。
*
* 布局调用全部按"名字 + 参数类型 + 返回类型"定位 —— 这套载荷把同名方法塞了一堆
* (Table 上 `I` 既有无参返回 Cell 的 row(),也有无参返回 Table 的 top()),
* 少判一个条件就会调到别的方法上。名字出处见 {@link Obf}。
*/
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<?> tableClass = Class.forName(Obf.TABLE, true, loader);
Class<?> cellClass = Class.forName(Obf.CELL, true, loader);
Class<?> actorClass = Class.forName(Obf.ACTOR, true, loader);
Class<?> buttonClass = Class.forName(TEXT_BUTTON, true, loader);
Class<?> labelClass = Class.forName(LABEL, true, loader);
@@ -202,33 +210,64 @@ public final class ViewInstaller {
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");
// 读出来的东西会很长,包进 ScrollPane 才能滚动查看。
Object scrollBody = label;
try {
Class<?> scrollClass = Class.forName(SCROLL_PANE, true, loader);
scrollBody = scrollClass.getConstructor(actorClass, skinClass).newInstance(label, skin);
System.err.println("[view] 文字区已包进 ScrollPane");
} catch (Throwable error) {
System.err.println("[view] ScrollPane 建不出来,直接用 Label: " + error);
}
// 让面板铺满停靠框(WidgetGroup.setFillParent)。
// 注意别调成 setDebug(boolean) —— 名字都是单字母,调错只会给单元格描上彩色边框。
call(table, Obf.TABLE_SET_FILL_PARENT, new Class<?>[] {boolean.class}, void.class,
Boolean.TRUE);
// 第一行:按钮,靠左。
Object buttonCell = call(table, Obf.TABLE_ADD, new Class<?>[] {actorClass}, cellClass, button);
layoutCell(buttonCell, Obf.CELL_LEFT);
// 换行:不调 row() 的话按钮和文字会并排挤在同一行。
call(table, Obf.TABLE_ROW, new Class<?>[0], cellClass);
// 第二行:文字区,占满剩余空间并撑开单元格。
Object textCell = call(table, Obf.TABLE_ADD, new Class<?>[] {actorClass}, cellClass, scrollBody);
layoutCell(textCell, Obf.CELL_EXPAND, Obf.CELL_FILL, Obf.CELL_TOP);
System.err.println("[view] 面板已按两行布局:按钮 / 可滚动文字区");
Scene2dPanel.wireButton(button, label, loader);
return table;
}
/**
* 在 Cell 上调一串"无参、返回 Cell"的布局方法(fill/expand/top…,名字见 {@link Obf})。
* 单个调用失败只记一行日志,不让整个面板装不上。
*/
private static void layoutCell(Object cell, String... names) {
for (String name : names) {
try {
Method method = cell.getClass().getMethod(name);
method.setAccessible(true);
method.invoke(cell);
} catch (Throwable error) {
System.err.println("[view] 单元格布局 " + name + " 调用失败: " + error);
}
}
}
/** 调用 Table 上的实例方法:名字 + 参数类型 + 返回类型一起判(同名方法太多)。 */
private static Object call(Object target, String name, Class<?>[] parameterTypes,
Class<?> returnType, Object... arguments) throws Exception {
for (Method method : target.getClass().getMethods()) {
if (!method.getName().equals(name)) continue;
if (!java.util.Arrays.equals(method.getParameterTypes(), parameterTypes)) continue;
if (returnType != null && method.getReturnType() != returnType) continue;
method.setAccessible(true);
return method.invoke(target, arguments);
}
throw new NoSuchMethodException(target.getClass().getName() + "." + name
+ " 参数=" + java.util.Arrays.toString(parameterTypes));
}
private static Method findInstanceMethod(Class<?> owner, int parameterCount,
Class<?> parameterType) throws NoSuchMethodException {
for (Method method : owner.getMethods()) {