diff --git a/tools/obfmap/MapMethods.java b/tools/obfmap/MapMethods.java new file mode 100644 index 0000000..ddfc34a --- /dev/null +++ b/tools/obfmap/MapMethods.java @@ -0,0 +1,159 @@ +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.IincInsnNode; +import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.IntInsnNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.MultiANewArrayInsnNode; +import org.objectweb.asm.tree.TypeInsnNode; +import org.objectweb.asm.tree.VarInsnNode; + +/** + * 把"载荷里被混淆的类"和"上游 libGDX 的同名类"逐方法对上号。 + * + * 思路:混淆器只改名字,不改描述符(参数/返回值类型)和指令语义。所以给每个方法算一个 + * **忽略名字的字节码指纹**(操作码序列 + 常量 + 成员的描述符),再拿两边的指纹去撞。 + * 指纹唯一的就直接对上;撞车的(比如 expandX/expandY 这类只差一个字段的)按**声明顺序** + * 对齐 —— 前提是载荷保留了上游的成员顺序(对 libGDX 这种"原样编译+混淆"成立)。 + * + * 用法: + * java -cp tools/asm/asm.jar:build MapMethods <上游.class> <载荷.class> + * 输出:每个载荷方法一行 obfName+desc 指纹唯一性 -> 上游名字 + */ +public final class MapMethods { + + public static void main(String[] args) throws Exception { + ClassNode upstream = read(args[0]); + ClassNode payload = read(args[1]); + + Map> byFingerprint = new LinkedHashMap>(); + for (MethodNode method : upstream.methods) { + if (method.name.startsWith("<")) continue; + byFingerprint.computeIfAbsent(fingerprint(method), key -> new ArrayList()) + .add(method); + } + // 撞车的按声明顺序登记已用掉的下标 + Map used = new LinkedHashMap(); + + System.out.println("# " + upstream.name + " (" + upstream.methods.size() + " 个方法) <- " + + payload.name + " (" + payload.methods.size() + " 个方法)"); + int unique = 0; + int ambiguous = 0; + int miss = 0; + for (MethodNode method : payload.methods) { + if (method.name.startsWith("<")) continue; + List candidates = byFingerprint.get(fingerprint(method)); + String from = method.name + method.desc; + if (candidates == null || candidates.isEmpty()) { + System.out.println(String.format("%-28s ?? (上游没有同形状的方法)", from)); + miss++; + continue; + } + if (candidates.size() == 1) { + System.out.println(String.format("%-28s = %s", from, candidates.get(0).name)); + unique++; + continue; + } + // 多个候选:按声明顺序取还没用过的那个 + String key = fingerprint(method); + int index = used.getOrDefault(key, 0); + MethodNode chosen = candidates.get(Math.min(index, candidates.size() - 1)); + used.put(key, index + 1); + StringBuilder all = new StringBuilder(); + for (MethodNode candidate : candidates) all.append(candidate.name).append(' '); + System.out.println(String.format("%-28s ~%d/%-3d %s (候选: %s)", from, + index + 1, candidates.size(), chosen.name, all.toString().trim())); + ambiguous++; + } + System.out.println("# 唯一匹配 " + unique + ",按顺序消歧 " + ambiguous + ",未匹配 " + miss); + } + + private static ClassNode read(String path) throws Exception { + ClassNode node = new ClassNode(); + new ClassReader(new java.io.FileInputStream(path)).accept(node, 0); + return node; + } + + /** + * 忽略名字的指纹:只记操作码、常量、以及成员的**描述符**。 + * 类名(NEW/CHECKCAST 之类指向的类)可能是混淆过的,所以那一部分只记操作码。 + */ + static String fingerprint(MethodNode method) { + // 类名两边不一样(Cell vs dR),所以按"每个方法内部首次出现的顺序"给非 JDK 类编号, + // 编号后的描述符就能直接比。JDK 类(java/*)名保留,不参与编号。 + Map names = new LinkedHashMap(); + StringBuilder out = new StringBuilder(); + out.append(canon(method.desc, names)).append('\u0001'); + for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null; + insn = insn.getNext()) { + int opcode = insn.getOpcode(); + if (opcode < 0) continue; // 标签/行号 + out.append(opcode); + if (insn instanceof FieldInsnNode) { + out.append(':').append(canon(((FieldInsnNode) insn).desc, names)); + } else if (insn instanceof MethodInsnNode) { + out.append(':').append(canon(((MethodInsnNode) insn).desc, names)); + } else if (insn instanceof TypeInsnNode) { + out.append(':').append(canon("L" + ((TypeInsnNode) insn).desc + ";", names)); + } else if (insn instanceof LdcInsnNode) { + Object value = ((LdcInsnNode) insn).cst; + out.append(':').append(value instanceof org.objectweb.asm.Type + ? canon(((org.objectweb.asm.Type) value).getDescriptor(), names) : value); + } else if (insn instanceof IntInsnNode) { + out.append(':').append(((IntInsnNode) insn).operand); + } else if (insn instanceof VarInsnNode) { + out.append(':').append(((VarInsnNode) insn).var); + } else if (insn instanceof IincInsnNode) { + out.append(':').append(((IincInsnNode) insn).var).append('+') + .append(((IincInsnNode) insn).incr); + } else if (insn instanceof MultiANewArrayInsnNode) { + out.append(':').append(((MultiANewArrayInsnNode) insn).dims); + } else if (insn instanceof InsnNode) { + // 无操作数 + } else { + out.append(":?").append(insn.getClass().getSimpleName()); + } + out.append('|'); + } + return out.toString(); + } + + /** 把描述符里出现的类名换成"本方法内第 n 个出现的类",JDK 类原样保留。 */ + private static String canon(String descriptor, Map names) { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < descriptor.length(); i++) { + char c = descriptor.charAt(i); + if (c != 'L') { + out.append(c); + continue; + } + int end = descriptor.indexOf(';', i); + if (end < 0) { + out.append(c); + continue; + } + String type = descriptor.substring(i + 1, end); + if (type.startsWith("java/") || type.startsWith("javax/") || type.startsWith("sun/")) { + out.append('L').append(type).append(';'); + } else { + String slot = names.get(type); + if (slot == null) { + slot = "#" + names.size(); + names.put(type, slot); + } + out.append('L').append(slot).append(';'); + } + i = end; + } + return out.toString(); + } +} diff --git a/viewpanel/src/com/spineview/Launcher.java b/viewpanel/src/com/spineview/Launcher.java index 9962120..7009460 100644 --- a/viewpanel/src/com/spineview/Launcher.java +++ b/viewpanel/src/com/spineview/Launcher.java @@ -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(); diff --git a/viewpanel/src/com/spineview/Obf.java b/viewpanel/src/com/spineview/Obf.java new file mode 100644 index 0000000..0889bc3 --- /dev/null +++ b/viewpanel/src/com/spineview/Obf.java @@ -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() { + } +} diff --git a/viewpanel/src/com/spineview/Scene2dPanel.java b/viewpanel/src/com/spineview/Scene2dPanel.java index d96c57c..f331b7d 100644 --- a/viewpanel/src/com/spineview/Scene2dPanel.java +++ b/viewpanel/src/com/spineview/Scene2dPanel.java @@ -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 { diff --git a/viewpanel/src/com/spineview/SceneReader.java b/viewpanel/src/com/spineview/SceneReader.java index 76adfad..cea20bb 100644 --- a/viewpanel/src/com/spineview/SceneReader.java +++ b/viewpanel/src/com/spineview/SceneReader.java @@ -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; diff --git a/viewpanel/src/com/spineview/ViewInstaller.java b/viewpanel/src/com/spineview/ViewInstaller.java index aaa6c74..4b0837d 100644 --- a/viewpanel/src/com/spineview/ViewInstaller.java +++ b/viewpanel/src/com/spineview/ViewInstaller.java @@ -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()) { diff --git a/混淆名对照表.md b/混淆名对照表.md new file mode 100644 index 0000000..227ad7d --- /dev/null +++ b/混淆名对照表.md @@ -0,0 +1,190 @@ +# 混淆名对照表(Spine 3.8.99 载荷) + +这份文档记录**载荷里被混淆的类名/方法名/字段名分别对应什么**,以及**每一条是怎么推导出来的**。 +以后要动编辑器内部的 scene2d 控件(加面板、读数据、导出),都先查这里,不要重新猜。 + +代码里对应的是 [`viewpanel/src/com/spineview/Obf.java`](viewpanel/src/com/spineview/Obf.java), +里面有同样的常量表,改代码时直接引用。 + +--- + +## 一、推导办法(可复现) + +1. **字节码指纹比对**:`tools/obfmap/MapMethods.java` + - 把载荷类与**上游 libGDX 同签名方法**逐个比对:忽略名字,只比较 + "操作码序列 + 常量 + 成员的描述符"(类名按"方法内首次出现的顺序"编号,绕开两边类名不同的问题)。 + - 用法: + ```bash + CP=tools/asm/asm.jar:tools/asm/asm-tree.jar:tools/obfmap/build + "$JAVA17" -cp "$CP" MapMethods <上游.class> <载荷.class> + # 输出:载荷方法签名 -> 上游方法名(= 唯一匹配,~n/m = 按声明顺序消歧) + ``` + - 编译: + ```bash + "$JAVA17" -cp tools/asm/asm.jar:tools/asm/asm-tree.jar -d tools/obfmap/build tools/obfmap/MapMethods.java + ``` +2. **上游源码核对**:拿 libGDX 1.11.0 的源码逐条看方法体语义 + (`gdx/src/com/badlogic/gdx/scenes/...`),确认指纹给出的结论。 +3. **运行期验证**:面板布局/按钮/文字这些已经在编辑器里真跑过,见文末"验证状态"。 + +> 上游版本怎么挑的:Cell/Table/Actor 的字段结构在 1.9.13 / 1.9.14 / 1.11.0 上一致, +> 指纹命中率也相同,所以用 1.11.0 作为参照(字段类型 `Value`、`Cell.align` 是 `Integer`)。 + +--- + +## 二️、最大的坑:**同名不同描述符** + +JVM 允许同一个类里存在"**名字相同、描述符不同**"的字段和方法,这个载荷把这一招用满了: + +- `dR`(Cell)里有 **30 多个字段**都叫 `D`/`B`/`L`/`I`,靠类型区分 + (`eN D`(Value)、`dl D`(Actor)、`float D`、`et D`(Table)…); +- `et`(Table)上 `D` 这个名字同时是 `add(Actor)`、`align(int)`、`pad(float)`、`pad(float×4)`… + +**所以:定位任何成员都必须"名字 + 描述符"一起判**,只看名字一定踩雷。已经踩过的两个: + +| 踩坑 | 表现 | 原因 | +| --- | --- | --- | +| `Label` 的 `D(CharSequence)` | 日志打印"读取完成 N 字符",**面板文字却一直不刷新** | 有 `D(CharSequence)V`(= `setText`)和 `D(CharSequence)Z`(= `textEquals`)两个,按"1 个 CharSequence 参数"找会挑到 `textEquals` | +| `Table` 的 `I()` | 调到 `top()` 而不是 `row()` | `I()LdR;` = `row()`(返回 Cell)、`I()Let;` = `top()`(返回 Table),必须判返回类型 | + +--- + +## 三、类对照 + +| 载荷名 | 真实类 | 依据 | +| --- | --- | --- | +| `dl` | `com.badlogic.gdx.scenes.scene2d.Actor` | 无父类;`do extends dl`;有 `stage`/`parent` 语义 | +| `do` | `...scene2d.Group` | `extends dl`;children 是 `[Ldl;` | +| `eX` | `...ui.WidgetGroup` | `extends do`;有 `setFillParent` 语义(见 `et.Z(boolean)`) | +| `et` | `...ui.Table` | `extends eX`;有 `cells`(`fI`) / `rowDefaults`(Cell) | +| `dR` | `...ui.Cell` | `et.add(Actor)` 的返回类型;`implements hg`(=Poolable) | +| `dt` | `...scene2d.Stage` | `uk extends dt` | +| `uk` | Stage 的实现(编辑器实际用的那个) | StageHook 挂在它的构造器上 | +| `eN` | `...ui.Value` | 抽象类;`Cell` 的尺寸/间距字段都是它 | +| `fI` | `com.badlogic.gdx.utils.Array` | `Table.cells` 的类型 | +| `fu` | `...scene2d.utils.Drawable` | `Table.background(Drawable)` | +| `hg` | `com.badlogic.gdx.utils.Pool.Poolable` | `Cell implements hg` | +| `OG` | 编辑器单例持有者 | 静态字段 `D` = `com.esotericsoftware.spine.editor.Editor` | +| `Fx` | 静态 Actor 锚点 | 静态字段里有 `dl` 实例,用来倒推 Stage | +| `us` | 视图枚举(`Editor`/`Preview`/`Tree`/…) | `` 里建常量、按 `MV`/`ut` 分组 | +| `tD` | 编辑器 UI 装配类 | 给每个视图 new 出面板 Actor 的地方 | + +--- + +## 四、方法对照 + +### 4.1 `et` = Table + +| 载荷签名 | 真实方法 | 依据 / 备注 | +| --- | --- | --- | +| `D(Ldl;)LdR;` | `add(Actor)` | 返回 Cell;运行期已用于把按钮/文字区放进面板 | +| `D([Ldl;)Let;` | `add(Actor...)` | 返回 Table(varargs 版,别和上面混) | +| `L()LdR;` | `add()` | 体 = `return add((Actor)null)` | +| `I()LdR;` | **`row()`** | 体与上游 `Table.row()` 逐句一致:判 `cells.size`、`endRow()`、`invalidate()`、换 `rowDefaults`;**不调它所有控件都会挤在第一行** | +| `D(I)LdR;` | `getCell(int)` | 不够就补 cell | +| `B(Ldl;)LdR;` | `getCell(Actor)` | 遍历 cells 找 actor | +| `S()LdR;` | `getRowDefaults()`(行默认值 Cell) | 体 = `return rowDefaults` | +| `Z(Z)V` | **`setFillParent(boolean)`** | 体 = `this.C = bl`(纯赋值) | +| `C(Z)V` | `setClip(boolean)` | 体 = `clip = bl; setTransform(bl); invalidate()` | +| `B(Z)V` | `setDebug(boolean)` | 体 = `debug(bl ? Debug.all : Debug.none)`;**调错会让单元格描上彩色边框**(踩过) | +| `I()Let;` | `top()` | 返回 Table | +| `S()Let;` | `left()` | | +| `L()Let;` | `center()` | | +| `C()Let;` | `bottom()` | | +| `Z()Let;` | `right()` | | +| `D(I)Let;` | `align(int)` | | +| `D(F)Let;` | `pad(float)` | | +| `D(FFFF)Let;` | `pad(top,left,bottom,right)` | | +| `B(F)Let;` | `padTop(float)` | 与 `L/I/S(F)Let;` = padLeft/padBottom/padRight 一组(同形状,按声明顺序对) | +| `D(Ljava/lang/String;)Let;` | `background(String)` | | +| `D(Lfu;)Let;` | `background(Drawable)` | | + +### 4.2 `dR` = Cell + +尺寸/间距(参数类型不同,靠描述符区分;都返回 Cell): + +| 载荷签名 | 真实方法 | +| --- | --- | +| `D(dl)LdR;` | `setActor(Actor)` | +| `D(eN,eN)LdR;` | `size(Value width, Value height)` | +| `D(F)LdR;` | `size(float)` | +| `D(FF)LdR;` | `size(float,float)` | +| `D(eN)LdR;` / `B(eN)LdR;` | `width(Value)` / `height(Value)` | +| `B(F)LdR;` / `L(F)LdR;` | `width(float)` / `height(float)` | +| `L(eN)LdR;` / `I(eN)LdR;` | `minWidth(Value)` / `minHeight(Value)` | +| `B(eN,eN)LdR;` / `S(eN)LdR;` | `minSize(Value,Value)` / `minSize(Value)` | +| `C(eN)LdR;` / `Z(eN)LdR;` | `prefWidth(Value)` / `prefHeight(Value)` | +| `S(F)` / `C(F)` / `Z(F)` / `V(F)` | `minWidth(float)` / `minHeight(float)` / `prefWidth(float)` / `prefHeight(float)` | +| `F(F)LdR;` 起连续 5 个 `(F)LdR;` | `maxWidth / maxHeight / padTop / padLeft / padBottom / padRight`(**同形状成组,按声明顺序对,未逐条验证**) | +| `V(eN)LdR;` / `F(eN)LdR;` / `Code(eN)LdR;` | `space(Value)` / `spaceBottom(Value)` / `spaceRight(Value)` | +| `Code(F)` / `SourceFile(F)` / `StackMapTable(F)` / `init(F)` / `LineNumberTable(F)` | `space(float)` / `spaceTop(float)` / `spaceLeft(float)` / `spaceBottom(float)` / `spaceRight(float)` | +| `String(F)LdR;` | `pad(float)` | +| `D(FFFF)LdR;` | `pad(top,left,bottom,right)` | + +布局开关(**这几条是面板能排好版的关键**): + +| 载荷签名 | 真实方法 | 依据 | +| --- | --- | --- | +| `D()LdR;` | `fill()` | 体 = 两个 float 字段 = `1f`(上游 `fillX = fillY = onef`) | +| `B()LdR;` / `L()LdR;` | `fillX()` / `fillY()` | 各设一个 float 字段 | +| `StackMapTable()LdR;` | `expand()` | 体 = 两个 Integer 字段 = `onei`(上游 `expandX = expandY = onei`) | +| `init()LdR;` / `LineNumberTable()LdR;` | `expandX()` / `expandY()` | | +| `F()LdR;` | `grow()` | 指纹唯一命中 | +| `Code()LdR;` / `SourceFile()LdR;` | `growX()` / `growY()` | 同形状成组 | +| `C()LdR;` | `left()` | 体 = `align\|8 & ~16` | +| `S()LdR;` | `top()` | 体 = `align\|2 & ~4` | +| `Z()LdR;` | `bottom()` | 体 = `align\|4 & ~2` | +| `V()LdR;` | `right()` | 体 = `align\|16 & ~8` | +| `I()LdR;` | `center()` | 体 = `align = 1i` | +| `D(I)LdR;` | `align(int)` | | +| `D(Z)LdR;` / `B(Z)LdR;` / `D(ZZ)LdR;` | `fill/uniform` 系(**未逐条验证**) | | +| `D()F`,`B()F`,`L()F`,`I()F` | `getMinWidth/getMinHeight/getPrefWidth/getPrefHeight` | | +| `B()V` / `L()V` / `D()V` | `row()` / `clear()` / `reset()` | | + +位掩码常量(都是 `Integer` 静态字段,值来自 libGDX `Align`): +`C=1`(onei)、`Z=1`(center)、`V=2`(top)、`F=4`(bottom)、`Code=8`(left)、`SourceFile=16`(right)。 + +### 4.3 `com.badlogic.gdx.scenes.scene2d.ui.Button`(类名保留,方法名混淆) + +| 载荷签名 | 真实方法 | +| --- | --- | +| `String()Z` | `isChecked()`(体 = `return isChecked`,读第 1 个 boolean 字段) | +| `I(Z)V` | `setChecked(boolean)`(指纹唯一命中) | +| `B(ZZ)V` | `setChecked(boolean, boolean)` —— 第二参是"是否派发事件",**复位时传 false**,否则点一次会读两次 | +| `a_(Z)V` | `setDisabled(boolean)` | +| `java()Z` | `isDisabled()` | +| `Class()Z` / `Ljava()Z` | `isOver()` / `isPressed()`(体分别是 `clickListener.D()` / `clickListener.B()`,两者顺序未逐条验证) | + +### 4.4 `com.badlogic.gdx.scenes.scene2d.ui.Label`(类名保留,方法名混淆) + +| 载荷签名 | 真实方法 | 备注 | +| --- | --- | --- | +| `D(Ljava/lang/CharSequence;)V` | `setText(CharSequence)` | | +| `D(Ljava/lang/CharSequence;)Z` | `textEquals(CharSequence)` | **陷阱**:长得像 setText,调它文字永远不刷新 | +| `D()LN;` | `getText()`(返回 `StringBuilder`) | | +| `D(LabelStyle)V` / `D()LabelStyle` | `setStyle` / `getStyle` | | + +### 4.5 `uk`/`dt` = Stage、`Fx`/`OG` = 入口 + +| 载荷名 | 真实含义 | 备注 | +| --- | --- | --- | +| `uk.` | Stage 实现类的构造器 | Stage 在编辑器里只是局部变量,只能钩构造器拿实例 | +| `Fx` 的静态 `dl` 字段 | 一个静态 Actor | 由它 `Actor.stage` 反查 Stage(比钩构造器更省事) | +| `OG.D` | 编辑器单例 | `com.esotericsoftware.spine.editor.Editor` | +| `us.` | 视图枚举初始化 | 加新视图必须在 `$VALUES` 组装之后、分组列表生成之前插 | + +--- + +## 五、验证状态 + +| 结论 | 证据 | +| --- | --- | +| `et.D(Ldl;)LdR;` = `add`、`et.I()LdR;` = `row` | 面板从"按钮和文字挤在同一行"变成"按钮一行、文字区一行并铺满"(截图已确认) | +| `dR.C()LdR;` = `left`、`dR.D()LdR;` = `fill`、`dR.StackMapTable()LdR;` = `expand` | 按钮靠左上、文字区撑满并出现滚动条 | +| `et.Z(Z)V` = `setFillParent` | 换成它之后面板铺满停靠框(先前误调 `setDebug` 时只会描出彩色单元格边框) | +| `Button.String()Z` = `isChecked`、`I(Z)V` = `setChecked` | 点击能触发一次读取;用前缀自校准曾配错组合,导致反复触发 | +| `Label.D(CharSequence)V` = `setText` | 修掉 `textEquals` 之后面板文字才真正刷新 | + +未逐条验证的(结构成组、按声明顺序推定):`Cell` 的 max/pad 系列、`Button` 的 `isOver/isPressed`、 +`Cell` 的 `growX/growY`。要用到时,先用 `MapMethods` 重跑一遍,再在编辑器里实测。 + diff --git a/阶段四-视图面板挂载.md b/阶段四-视图面板挂载.md index d166b99..7ab6266 100644 --- a/阶段四-视图面板挂载.md +++ b/阶段四-视图面板挂载.md @@ -225,3 +225,45 @@ PAY="spine-lab/home/Library/Application Support/Spine/updates/3.8.99.orig" 1. 面板内容目前是空的(Table 单元格布局在停靠框里的尺寸还要调)。 2. `SceneReader` 现在读的是 UI 对象树,要换成真正的**骨骼 / 插槽 / 附件**数据。 3. 布局稳定后,再考虑按 4.2.43 复刻(4.x 是加密载荷,这套注入通道不一定通用)。 + +--- + +## 七、面板内容与布局(本轮补齐) + +上面的"下一步 1"已经做完了,而且顺带把两个会让人误判的坑挖了出来。 + +### 1. 布局:按钮一行、可滚动文字区一行 + +面板内容就是一个 `Table`:第一行放 `TextButton`(靠左),第二行放 `ScrollPane(Label)`(撑满)。 +关键调用(混淆名出处见 [`混淆名对照表.md`](混淆名对照表.md)): + +| 目的 | 调用 | 载荷里的名字 | +| --- | --- | --- | +| 面板铺满停靠框 | `Table.setFillParent(true)` | `et.Z(Z)V` | +| 加控件 | `Table.add(Actor)` | `et.D(Ldl;)LdR;` | +| **换行** | `Table.row()` | `et.I()LdR;` —— 不调它按钮和文字就并排挤在一行 | +| 单元格靠左 | `Cell.left()` | `dR.C()LdR;` | +| 单元格占满剩余空间 | `Cell.expand()` + `Cell.fill()` | `dR.StackMapTable()` + `dR.D()` | + +> 坑:`Table` 上还有 `et.B(Z)V` = `setDebug(boolean)`,名字同样是单字母, +> 之前误当成 `setFillParent` 调过 —— 现象是单元格被描上彩色边框,布局没变。 + +### 2. 两个"看名字挑方法"踩出来的 bug + +| 症状 | 真因 | 修法 | +| --- | --- | --- | +| 点一次按钮被**反复触发**(日志刷屏) | 运行期自校准扫到了**继承链上无关的方法**,配出一对假的 setChecked/isChecked | 用对照表定名 `Button.String()`=isChecked、`I(Z)`=setChecked;自校准只作兜底 | +| 日志说"读取完成 N 字符",**面板文字却一直不刷新** | `Label` 上有 `D(CharSequence)V`(=setText) 和 `D(CharSequence)Z`(=textEquals),按"1 个 CharSequence 参数"找会挑中后者 | 连返回类型一起判:`D(CharSequence)` 且返回 void | + +### 3. 面板文字全部用英文 + +编辑器这套运行时只装了 NotoSans 拉丁字形,中文会渲染成一排小方块(tofu)。 +所以 `SceneReader` 的输出改成英文(`=== Scene actors ===` / `=== Objects reachable from Editor ===` 等)。 + +### 4. 现在的状态 + +点按钮 → `SceneReader` 读一遍 → 文字区刷新(可滚动查看)。截图确认:按钮靠左上, +文字区撑满并出现滚动条,内容全是英文。 + +还差的:`SceneReader` 读的还是 **UI 对象树 / 编辑器可达对象**,要换成真正的 +**骨骼 / 插槽 / 附件**(这才是"给对象配粒子发射行为"的输入)。