diff --git a/tools/deobf/Renamer.java b/tools/deobf/Renamer.java new file mode 100644 index 0000000..6d78c6f --- /dev/null +++ b/tools/deobf/Renamer.java @@ -0,0 +1,156 @@ +package com.spineview.deobf; + +import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.commons.ClassRemapper; +import org.objectweb.asm.commons.Remapper; + +/** + * 按"名字映射表"重命名载荷里的**成员**(字段/方法),产出一份新的载荷 zip, + * 专门用来反编译 —— 这样反编译出来的代码里,声明和直接调用都带可识别的名字。 + * + * ⚠️ 只改成员名,**不改类名**:载荷的 invokedynamic 是在运行时用加密串解出 + * "类名 + 方法名 + 描述符"再反射查找的,类名必须保持不变(成员名改了以后这份 zip + * 只用于反编译,不能拿去运行)。 + * + * 映射表(TSV,4 列,`#` 开头是注释): + * ownernamedescnewName + * - owner:内部类名,如 `et`、`com/badlogic/gdx/scenes/scene2d/ui/Tree` + * - name :混淆成员名(字段或方法) + * - desc :描述符;字段如 `F`(float)、`Ljava/lang/String;`; + * 方法如 `(Ldl;)LdR;`。**写 `*` 表示"该 owner 下所有同名成员"**(仅在 + * 确定这一层不会撞车时用) + * - newName:要换成的新名字 + * + * 用法: + * java -cp "asm.jar:asm-tree.jar:asm-commons.jar:build" com.spineview.deobf.Renamer + */ +public final class Renamer { + + public static void main(String[] args) throws Exception { + Path in = Paths.get(args[0]); + Path map = Paths.get(args[1]); + Path out = Paths.get(args[2]); + + Map exact = new HashMap(); + Map wildcard = new HashMap(); + int loaded = 0; + for (String line : Files.readAllLines(map, StandardCharsets.UTF_8)) { + if (line.isEmpty() || line.startsWith("#")) continue; + String[] parts = line.split("\t"); + if (parts.length < 4 || parts[0].isEmpty() || parts[1].isEmpty()) continue; + String owner = parts[0]; + String name = parts[1]; + String desc = parts[2]; + String renamed = parts[3]; + if (renamed.isEmpty()) continue; + if (desc.equals("*")) { + wildcard.put(owner + "#" + name, renamed); + } else { + exact.put(owner + "#" + name + "#" + desc, renamed); + } + loaded++; + } + System.err.println("映射条目 " + loaded + "(具体 " + exact.size() + ",通配 " + wildcard.size() + ")"); + + int classes = 0; + int renamedMembers = 0; + try (ZipFile zip = new ZipFile(in.toFile()); + ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(out))) { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + byte[] raw = readAll(zip.getInputStream(entry)); + if (!entry.getName().endsWith(".class")) { + zipOut.putNextEntry(new ZipEntry(entry.getName())); + zipOut.write(raw); + zipOut.closeEntry(); + continue; + } + try { + ClassReader reader = new ClassReader(raw); + ClassWriter writer = new ClassWriter(0); + final Map exactMap = exact; + final Map wildcardMap = wildcard; + final int[] counter = new int[1]; + Remapper remapper = new Remapper() { + @Override + public String mapFieldName(String owner, String name, String descriptor) { + String renamed = lookup(owner, name, descriptor, exactMap, wildcardMap); + if (renamed == null) return name; + counter[0]++; + return renamed; + } + + @Override + public String mapMethodName(String owner, String name, String descriptor) { + String renamed = lookup(owner, name, descriptor, exactMap, wildcardMap); + if (renamed == null) return name; + counter[0]++; + return renamed; + } + }; + reader.accept(new ClassRemapper(writer, remapper), 0); + byte[] rewritten = writer.toByteArray(); + zipOut.putNextEntry(new ZipEntry(entry.getName())); + zipOut.write(rewritten); + zipOut.closeEntry(); + classes++; + renamedMembers += counter[0]; + } catch (Throwable error) { + zipOut.putNextEntry(new ZipEntry(entry.getName())); + zipOut.write(raw); + zipOut.closeEntry(); + } + } + } + System.err.println("处理 " + classes + " 个类,改名成员引用 " + renamedMembers + " 处 -> " + out); + } + + private static String lookup(String owner, String name, String descriptor, + Map exact, Map wildcard) { + String found = exact.get(owner + "#" + name + "#" + descriptor); + if (found != null) return found; + return wildcard.get(owner + "#" + name); + } + + 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(); + } + + /** 生成"没被用到"的映射条目清单,方便发现写错的 owner/desc。 */ + static List unused(Map hits) { + List out = new java.util.ArrayList(); + for (Map.Entry entry : hits.entrySet()) { + if (entry.getValue() == 0) out.add(entry.getKey()); + } + return out; + } + + static void writeReport(Path path, Map hits) throws Exception { + try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { + for (Map.Entry entry : hits.entrySet()) { + writer.write(entry.getValue() + "\t" + entry.getKey()); + writer.write('\n'); + } + } + } +} diff --git a/tools/deobf/anchors-libgdx.tsv b/tools/deobf/anchors-libgdx.tsv new file mode 100644 index 0000000..1c6e5de --- /dev/null +++ b/tools/deobf/anchors-libgdx.tsv @@ -0,0 +1,180 @@ +# 由 tools/obfmap/MapMethods 指纹比对自动生成(上游 libGDX 1.11.0) +# owner name desc newName +KP B (I)V setSelectedIndex +KP D (Ldt;)V setStage +com/badlogic/gdx/scenes/scene2d/ui/Button I (Z)V setChecked +com/badlogic/gdx/scenes/scene2d/ui/Label D (II)V setAlignment +com/badlogic/gdx/scenes/scene2d/ui/Label D (Lcom/badlogic/gdx/scenes/scene2d/ui/Label$LabelStyle;)V setStyle +com/badlogic/gdx/scenes/scene2d/ui/Label D (Ljava/lang/CharSequence;)Z textEquals +com/badlogic/gdx/scenes/scene2d/ui/Label I (Z)V setEllipsis +com/badlogic/gdx/scenes/scene2d/ui/Label L (Z)V setWrap +com/badlogic/gdx/scenes/scene2d/ui/Label SourceFile ()V invalidate +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane B (ZZ)V setScrollingDisabled +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane D (FFZ)Ldl; hit +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane D (ILdl;)V addActorAt +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane D (Lcom/badlogic/gdx/scenes/scene2d/ui/ScrollPane$ScrollPaneStyle;)V setStyle +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane D (Ldl;)V addActor +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane D (Ldl;Z)Z removeActor +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane L (FFFF)V scrollTo +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane L (Ldl;)Z removeActor +com/badlogic/gdx/scenes/scene2d/ui/Slider D ()Lcom/badlogic/gdx/scenes/scene2d/ui/Slider$SliderStyle; getStyle +com/badlogic/gdx/scenes/scene2d/ui/Slider D ([FF)V setSnapToValues +com/badlogic/gdx/scenes/scene2d/ui/Slider init ()Z isDragging +com/badlogic/gdx/scenes/scene2d/ui/TextButton L (Ljava/lang/String;)V setText +com/badlogic/gdx/scenes/scene2d/ui/TextField B (I)Z withinMaxLength +com/badlogic/gdx/scenes/scene2d/ui/TextField D ()Ldr; createInputListener +com/badlogic/gdx/scenes/scene2d/ui/TextField D (ILjava/lang/CharSequence;Ljava/lang/String;)Ljava/lang/String; insert +com/badlogic/gdx/scenes/scene2d/ui/TextField LineNumberTable ()V selectAll +com/badlogic/gdx/scenes/scene2d/ui/TextField StackMapTable ()F getPrefWidth +com/badlogic/gdx/scenes/scene2d/ui/TextField String ()V clearSelection +com/badlogic/gdx/scenes/scene2d/ui/TextField init ()F getPrefHeight +com/badlogic/gdx/scenes/scene2d/ui/TextField init ()V copy +com/badlogic/gdx/scenes/scene2d/ui/Tree D ()LeM; getSelectedNode +com/badlogic/gdx/scenes/scene2d/ui/Tree D (LfI;)V findExpandedValues +com/badlogic/gdx/scenes/scene2d/ui/Tree D (Ljava/lang/Object;)LeM; findNode +com/badlogic/gdx/scenes/scene2d/ui/Tree I (LfI;)V expandAll +com/badlogic/gdx/scenes/scene2d/ui/Tree L (LeM;)V setOverNode +com/badlogic/gdx/scenes/scene2d/ui/Tree b_ ()V invalidate +com/badlogic/gdx/scenes/scene2d/ui/Tree java ()F plusMinusWidth +com/badlogic/gdx/scenes/scene2d/ui/Window B (I)V setResizeBorder +com/badlogic/gdx/scenes/scene2d/ui/Window D (LJ;FFFFF)V drawStageBackground +com/badlogic/gdx/scenes/scene2d/ui/Window D (Lcom/badlogic/gdx/scenes/scene2d/ui/Window$WindowStyle;)V setStyle +com/badlogic/gdx/scenes/scene2d/ui/Window StackMapTable ()F getPrefWidth +dR B ()V row +dR B (LdR;)V merge +dR B (LeN;)LdR; height +dR B (Z)LdR; uniform +dR C (LeN;)LdR; prefWidth +dR Code (LeN;)LdR; spaceRight +dR D ()LdR; fill +dR D ()V reset +dR D (LdR;)V set +dR D (Ldl;)LdR; setActor +dR D (LeN;)LdR; width +dR D (LeN;LeN;)LdR; size +dR D (Z)LdR; fill +dR D (ZZ)LdR; uniform +dR F ()LdR; grow +dR F (LeN;)LdR; spaceBottom +dR I (LeN;)LdR; minHeight +dR L ()V clear +dR L (LeN;)LdR; minWidth +dR SourceFile (LeN;)LdR; padRight +dR StackMapTable ()LdR; expand +dR String ()LdR; uniform +dR V (LeN;)LdR; space +dR Z (LeN;)LdR; prefHeight +dR toString ()Ljava/lang/String; toString +dl B ()V clearListeners +dl B ()Z hasActions +dl B (FFFF)V setColor +dl B (FI)V setY +dl B (I)F getY +dl B (Lde;)Lde; stageToLocalCoordinates +dl B (Ldl;Lde;)Lde; localToActorCoordinates +dl B (Z)V setDebug +dl C (FF)V setOrigin +dl D ()Ldl; debug +dl D ()Ljava/lang/Object; getUserObject +dl D ()Ljava/lang/String; getName +dl D ()V clearActions +dl D (FFFF)V setBounds +dl D (FFI)V setPosition +dl D (FFZ)Ldl; hit +dl D (FI)V setX +dl D (I)F getX +dl D (LJ;F)V draw +dl D (Lca;)V drawDebug +dl D (Lcom/badlogic/gdx/graphics/Color;)V setColor +dl D (Ldn;)Z addListener +dl D (Ljava/lang/Object;)V setUserObject +dl D (Ljava/lang/String;)V setName +dl D (Z)V setVisible +dl F ()V clipEnd +dl I ()Z isTouchable +dl L ()V clear +dl L ()Z hasParent +dl L (Ldn;)Z addCaptureListener +dl S (FF)V setOrigin +dl S (Lde;)Lde; localToStageCoordinates +dl V ()V toBack +dl Z ()V toFront +dl b_ ()Z remove +do B ()Ldo; debugAll +do Code ()V childrenChanged +do D (F)V act +do D (I)Ldl; getChild +do D (II)Z swapActor +do D (ILdl;)V addActorAt +do D (LJ;)V resetTransform +do D (LJ;F)V draw +do D (LJ;Lcom/badlogic/gdx/math/Matrix4;)V applyTransform +do D (Lca;)V drawDebug +do D (Ldd;)V setCullingArea +do D (Ldl;)V addActor +do D (Ldt;)V setStage +do I (Lca;)V resetTransform +do L (Ldl;)Z removeActor +do L (Z)V setTransform +do LineNumberTable ()Z isTransform +do init ()Z hasChildren +eM C ()V expandTo +eM D ()F getHeight +eM D ()Z hasChildren +eM D (Ljava/lang/Object;)LeM; findNode +eM I ()V collapseAll +eM S ()V expandAll +eX D (LJ;F)V draw +eX Object ()Z needsLayout +eX StackMapTable ()V layout +eX V (Z)V setFillParent +eX b_ ()V invalidate +et B (Lca;)V drawDebugBounds +et B (LeN;)Let; padTop +et B (Z)V setDebug +et C ()Let; bottom +et C (Z)V setClip +et D (F)Let; pad +et D (FFFF)Let; pad +et D (FFZ)Ldl; hit +et D (I)Let; align +et D (LJ;F)V draw +et D (LJ;FFF)V drawBackground +et D (Lca;)V drawDebug +et D (Lcom/badlogic/gdx/scenes/scene2d/ui/Skin;)V setSkin +et D (LeN;)Let; pad +et D (LeN;LeN;LeN;LeN;)Let; pad +et D (Lez;)Let; debug +et D (Lfu;)Let; background +et D (Ljava/lang/CharSequence;)LdR; add +et D (Ljava/lang/CharSequence;Ljava/lang/String;)LdR; add +et D (Ljava/lang/CharSequence;Ljava/lang/String;Lcom/badlogic/gdx/graphics/Color;)LdR; add +et D (Ljava/lang/CharSequence;Ljava/lang/String;Ljava/lang/String;)LdR; add +et D (Ljava/lang/String;)Let; background +et D ([Ldl;)LdR; stack +et D ([Ldl;)Let; add +et I ()Let; top +et I (LeN;)Let; padBottom +et L ()LdR; add +et L ()Let; center +et L (Ldl;)Z removeActor +et L (LeN;)Let; padLeft +et S ()Let; left +et S (LeN;)Let; padRight +et Z ()Let; right +et Z (Z)V setRound +et b_ ()V invalidate +et lang ()Z getClip +fI B ()Ljava/lang/Object; peek +fI B ()Z isEmpty +fI B (I)[Ljava/lang/Object; setSize +fI D ()Ljava/lang/Object; pop +fI D ()Z notEmpty +fI D (LfI;)V addAll +fI D (Ljava/lang/Object;)V add +fI D (Ljava/lang/Object;Z)Z contains +fI D (Ljava/util/Comparator;)V sort +fI D ([Ljava/lang/Object;)LfI; with +fI D ([Ljava/lang/Object;)V addAll +fI L ()Ljava/lang/Object; first +fI L ()V reverse diff --git a/tools/deobf/anchors.tsv b/tools/deobf/anchors.tsv new file mode 100644 index 0000000..b5a34bf --- /dev/null +++ b/tools/deobf/anchors.tsv @@ -0,0 +1,29 @@ +# 名字映射锚点(只放**已确定**的;每条都要能说清依据,依据写进 混淆名对照表.md) +# 列:owner name desc newName (desc 写 * 表示该 owner 下所有同名成员) +# +# ---- libGDX:Table(载荷名 et);依据 Obf.java + 直读字节码 ---- +et D (Ldl;)LdR; add +et D ([Ldl;)Let; addAll +et I ()LdR; row +et Z (Z)V setFillParent +et SourceFile ()V clearChildren +et D (Ljava/lang/CharSequence;)LdR; addLabel +# ---- libGDX:Cell(载荷名 dR)---- +dR StackMapTable ()LdR; expand +dR D ()LdR; fill +dR C ()LdR; left +dR S ()LdR; top +# ---- libGDX:Button / Label(保名类)---- +com/badlogic/gdx/scenes/scene2d/ui/Button String ()Z isChecked +com/badlogic/gdx/scenes/scene2d/ui/Button I (Z)V setChecked +com/badlogic/gdx/scenes/scene2d/ui/Button B (ZZ)V setCheckedAndFire +com/badlogic/gdx/scenes/scene2d/ui/Button a_ (Z)V setDisabled +com/badlogic/gdx/scenes/scene2d/ui/Label D (Ljava/lang/CharSequence;)V setText +com/badlogic/gdx/scenes/scene2d/ui/Label D ()Ljava/lang/StringBuilder; getTextBuilder +# ---- libGDX:SelectBox(载荷名 KP);依据 DumpSites 解出的 (Ljava/lang/String;)V ---- +KP B (Ljava/lang/String;)V setSelected +KP B ()Ljava/lang/String; getSelected +KP B (I)V setSelectedIndex +# ---- 场景模型:Gp = 骨骼;依据 gp_2.java 的 toString()/D(String,boolean)V ---- +Gp D Ljava/lang/String; name +Gp D (Ljava/lang/String;Z)V setName diff --git a/tools/deobf/names.tsv b/tools/deobf/names.tsv new file mode 100644 index 0000000..b57615c --- /dev/null +++ b/tools/deobf/names.tsv @@ -0,0 +1,194 @@ +# 合并后的名字映射表:先由 MapMethods 指纹自动生成,再用手动锚点覆盖 +# owner name desc newName +Gp D (Ljava/lang/String;Z)V setName +Gp D Ljava/lang/String; name +KP B ()Ljava/lang/String; getSelected +KP B (I)V setSelectedIndex +KP B (Ljava/lang/String;)V setSelected +KP D (Ldt;)V setStage +com/badlogic/gdx/scenes/scene2d/ui/Button B (ZZ)V setCheckedAndFire +com/badlogic/gdx/scenes/scene2d/ui/Button I (Z)V setChecked +com/badlogic/gdx/scenes/scene2d/ui/Button String ()Z isChecked +com/badlogic/gdx/scenes/scene2d/ui/Button a_ (Z)V setDisabled +com/badlogic/gdx/scenes/scene2d/ui/Label D ()Ljava/lang/StringBuilder; getTextBuilder +com/badlogic/gdx/scenes/scene2d/ui/Label D (II)V setAlignment +com/badlogic/gdx/scenes/scene2d/ui/Label D (Lcom/badlogic/gdx/scenes/scene2d/ui/Label$LabelStyle;)V setStyle +com/badlogic/gdx/scenes/scene2d/ui/Label D (Ljava/lang/CharSequence;)V setText +com/badlogic/gdx/scenes/scene2d/ui/Label D (Ljava/lang/CharSequence;)Z textEquals +com/badlogic/gdx/scenes/scene2d/ui/Label I (Z)V setEllipsis +com/badlogic/gdx/scenes/scene2d/ui/Label L (Z)V setWrap +com/badlogic/gdx/scenes/scene2d/ui/Label SourceFile ()V invalidate +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane B (ZZ)V setScrollingDisabled +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane D (FFZ)Ldl; hit +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane D (ILdl;)V addActorAt +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane D (Lcom/badlogic/gdx/scenes/scene2d/ui/ScrollPane$ScrollPaneStyle;)V setStyle +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane D (Ldl;)V addActor +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane D (Ldl;Z)Z removeActor +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane L (FFFF)V scrollTo +com/badlogic/gdx/scenes/scene2d/ui/ScrollPane L (Ldl;)Z removeActor +com/badlogic/gdx/scenes/scene2d/ui/Slider D ()Lcom/badlogic/gdx/scenes/scene2d/ui/Slider$SliderStyle; getStyle +com/badlogic/gdx/scenes/scene2d/ui/Slider D ([FF)V setSnapToValues +com/badlogic/gdx/scenes/scene2d/ui/Slider init ()Z isDragging +com/badlogic/gdx/scenes/scene2d/ui/TextButton L (Ljava/lang/String;)V setText +com/badlogic/gdx/scenes/scene2d/ui/TextField B (I)Z withinMaxLength +com/badlogic/gdx/scenes/scene2d/ui/TextField D ()Ldr; createInputListener +com/badlogic/gdx/scenes/scene2d/ui/TextField D (ILjava/lang/CharSequence;Ljava/lang/String;)Ljava/lang/String; insert +com/badlogic/gdx/scenes/scene2d/ui/TextField LineNumberTable ()V selectAll +com/badlogic/gdx/scenes/scene2d/ui/TextField StackMapTable ()F getPrefWidth +com/badlogic/gdx/scenes/scene2d/ui/TextField String ()V clearSelection +com/badlogic/gdx/scenes/scene2d/ui/TextField init ()F getPrefHeight +com/badlogic/gdx/scenes/scene2d/ui/TextField init ()V copy +com/badlogic/gdx/scenes/scene2d/ui/Tree D ()LeM; getSelectedNode +com/badlogic/gdx/scenes/scene2d/ui/Tree D (LfI;)V findExpandedValues +com/badlogic/gdx/scenes/scene2d/ui/Tree D (Ljava/lang/Object;)LeM; findNode +com/badlogic/gdx/scenes/scene2d/ui/Tree I (LfI;)V expandAll +com/badlogic/gdx/scenes/scene2d/ui/Tree L (LeM;)V setOverNode +com/badlogic/gdx/scenes/scene2d/ui/Tree b_ ()V invalidate +com/badlogic/gdx/scenes/scene2d/ui/Tree java ()F plusMinusWidth +com/badlogic/gdx/scenes/scene2d/ui/Window B (I)V setResizeBorder +com/badlogic/gdx/scenes/scene2d/ui/Window D (LJ;FFFFF)V drawStageBackground +com/badlogic/gdx/scenes/scene2d/ui/Window D (Lcom/badlogic/gdx/scenes/scene2d/ui/Window$WindowStyle;)V setStyle +com/badlogic/gdx/scenes/scene2d/ui/Window StackMapTable ()F getPrefWidth +dR B ()V row +dR B (LdR;)V merge +dR B (LeN;)LdR; height +dR B (Z)LdR; uniform +dR C ()LdR; left +dR C (LeN;)LdR; prefWidth +dR Code (LeN;)LdR; spaceRight +dR D ()LdR; fill +dR D ()V reset +dR D (LdR;)V set +dR D (Ldl;)LdR; setActor +dR D (LeN;)LdR; width +dR D (LeN;LeN;)LdR; size +dR D (Z)LdR; fill +dR D (ZZ)LdR; uniform +dR F ()LdR; grow +dR F (LeN;)LdR; spaceBottom +dR I (LeN;)LdR; minHeight +dR L ()V clear +dR L (LeN;)LdR; minWidth +dR S ()LdR; top +dR SourceFile (LeN;)LdR; padRight +dR StackMapTable ()LdR; expand +dR String ()LdR; uniform +dR V (LeN;)LdR; space +dR Z (LeN;)LdR; prefHeight +dR toString ()Ljava/lang/String; toString +dl B ()V clearListeners +dl B ()Z hasActions +dl B (FFFF)V setColor +dl B (FI)V setY +dl B (I)F getY +dl B (Lde;)Lde; stageToLocalCoordinates +dl B (Ldl;Lde;)Lde; localToActorCoordinates +dl B (Z)V setDebug +dl C (FF)V setOrigin +dl D ()Ldl; debug +dl D ()Ljava/lang/Object; getUserObject +dl D ()Ljava/lang/String; getName +dl D ()V clearActions +dl D (FFFF)V setBounds +dl D (FFI)V setPosition +dl D (FFZ)Ldl; hit +dl D (FI)V setX +dl D (I)F getX +dl D (LJ;F)V draw +dl D (Lca;)V drawDebug +dl D (Lcom/badlogic/gdx/graphics/Color;)V setColor +dl D (Ldn;)Z addListener +dl D (Ljava/lang/Object;)V setUserObject +dl D (Ljava/lang/String;)V setName +dl D (Z)V setVisible +dl F ()V clipEnd +dl I ()Z isTouchable +dl L ()V clear +dl L ()Z hasParent +dl L (Ldn;)Z addCaptureListener +dl S (FF)V setOrigin +dl S (Lde;)Lde; localToStageCoordinates +dl V ()V toBack +dl Z ()V toFront +dl b_ ()Z remove +do B ()Ldo; debugAll +do Code ()V childrenChanged +do D (F)V act +do D (I)Ldl; getChild +do D (II)Z swapActor +do D (ILdl;)V addActorAt +do D (LJ;)V resetTransform +do D (LJ;F)V draw +do D (LJ;Lcom/badlogic/gdx/math/Matrix4;)V applyTransform +do D (Lca;)V drawDebug +do D (Ldd;)V setCullingArea +do D (Ldl;)V addActor +do D (Ldt;)V setStage +do I (Lca;)V resetTransform +do L (Ldl;)Z removeActor +do L (Z)V setTransform +do LineNumberTable ()Z isTransform +do init ()Z hasChildren +eM C ()V expandTo +eM D ()F getHeight +eM D ()Z hasChildren +eM D (Ljava/lang/Object;)LeM; findNode +eM I ()V collapseAll +eM S ()V expandAll +eX D (LJ;F)V draw +eX Object ()Z needsLayout +eX StackMapTable ()V layout +eX V (Z)V setFillParent +eX b_ ()V invalidate +et B (Lca;)V drawDebugBounds +et B (LeN;)Let; padTop +et B (Z)V setDebug +et C ()Let; bottom +et C (Z)V setClip +et D (F)Let; pad +et D (FFFF)Let; pad +et D (FFZ)Ldl; hit +et D (I)Let; align +et D (LJ;F)V draw +et D (LJ;FFF)V drawBackground +et D (Lca;)V drawDebug +et D (Lcom/badlogic/gdx/scenes/scene2d/ui/Skin;)V setSkin +et D (Ldl;)LdR; add +et D (LeN;)Let; pad +et D (LeN;LeN;LeN;LeN;)Let; pad +et D (Lez;)Let; debug +et D (Lfu;)Let; background +et D (Ljava/lang/CharSequence;)LdR; addLabel +et D (Ljava/lang/CharSequence;Ljava/lang/String;)LdR; add +et D (Ljava/lang/CharSequence;Ljava/lang/String;Lcom/badlogic/gdx/graphics/Color;)LdR; add +et D (Ljava/lang/CharSequence;Ljava/lang/String;Ljava/lang/String;)LdR; add +et D (Ljava/lang/String;)Let; background +et D ([Ldl;)LdR; stack +et D ([Ldl;)Let; addAll +et I ()LdR; row +et I ()Let; top +et I (LeN;)Let; padBottom +et L ()LdR; add +et L ()Let; center +et L (Ldl;)Z removeActor +et L (LeN;)Let; padLeft +et S ()Let; left +et S (LeN;)Let; padRight +et SourceFile ()V clearChildren +et Z ()Let; right +et Z (Z)V setFillParent +et b_ ()V invalidate +et lang ()Z getClip +fI B ()Ljava/lang/Object; peek +fI B ()Z isEmpty +fI B (I)[Ljava/lang/Object; setSize +fI D ()Ljava/lang/Object; pop +fI D ()Z notEmpty +fI D (LfI;)V addAll +fI D (Ljava/lang/Object;)V add +fI D (Ljava/lang/Object;Z)Z contains +fI D (Ljava/util/Comparator;)V sort +fI D ([Ljava/lang/Object;)LfI; with +fI D ([Ljava/lang/Object;)V addAll +fI L ()Ljava/lang/Object; first +fI L ()V reverse diff --git a/viewpanel/src/com/spineview/Launcher.java b/viewpanel/src/com/spineview/Launcher.java index 5112470..b0f703f 100644 --- a/viewpanel/src/com/spineview/Launcher.java +++ b/viewpanel/src/com/spineview/Launcher.java @@ -143,6 +143,16 @@ public final class Launcher { System.err.println("[probe] ==== " + probe + " ===="); if ("objects".equals(probe)) { System.err.println(SceneObjects.describe(viewLoader)); + } else if ("panels".equals(probe)) { + System.err.println(SceneActions.describePanels(viewLoader)); + } else if ("matrix".equals(probe)) { + System.err.println(SceneActions.probeMatrix(viewLoader)); + } else if ("panel".equals(probe)) { + System.err.println(ObjectPanel.probePanel(viewLoader)); + } else if ("select".equals(probe)) { + System.err.println(SceneActions.probeSelect(viewLoader)); + } else if ("crud".equals(probe)) { + System.err.println(SceneActions.probeCrud(viewLoader)); } else { System.err.println("[probe] 未知探针: " + probe); } diff --git a/viewpanel/src/com/spineview/Obf.java b/viewpanel/src/com/spineview/Obf.java index 0889bc3..776d923 100644 --- a/viewpanel/src/com/spineview/Obf.java +++ b/viewpanel/src/com/spineview/Obf.java @@ -64,6 +64,11 @@ public final class Obf { public static final String TABLE_ADD_VARARGS = "D"; /** `row() -> Cell` —— 换行的关键,不调用的话所有控件都挤在第一行 */ public static final String TABLE_ROW = "I"; + /** + * `clearChildren() -> void`(体 = 逐个 actor.remove() + cells.clear() + rows/columns 归零)。 + * 重建列表时用它把旧行清掉。 + */ + public static final String TABLE_CLEAR_CHILDREN = "SourceFile"; /** `setFillParent(boolean)`(WidgetGroup 的,让面板铺满停靠框) */ public static final String TABLE_SET_FILL_PARENT = "Z"; /** `setClip(boolean)` */ diff --git a/viewpanel/src/com/spineview/ObjectPanel.java b/viewpanel/src/com/spineview/ObjectPanel.java new file mode 100644 index 0000000..ab72840 --- /dev/null +++ b/viewpanel/src/com/spineview/ObjectPanel.java @@ -0,0 +1,650 @@ +package com.spineview; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; + +/** + * "Spine View" 面板的内容:**场景对象的增删改查**。 + * + * 布局(都是英文,编辑器缺中文字形): + * ``` + * [Refresh] [New Bone] + * [New Slot] [Delete] [Rename] + * [新名字 TextField] + * ┌ 对象列表(每个对象一行,点一下就选中它)┐ + * │ skeleton skeleton │ + * │ bone root │ + * │ ... │ + * └─────────────────────────────────────────┘ + * 状态行 + * ``` + * + * 两条硬约束(都在 `编辑器交互与数据API.md` 里): + * 1. **凡是要动编辑器模型/控件的代码,必须在编辑器的渲染线程上跑** —— + * 统一走 {@link SceneActions#onEditorThread}(它用编辑器的 `OG.B(Runnable)`)。 + * 2. **所有动作都作用在"当前选择"上** —— 新建/删除/改名之前先 {@link SceneActions#select}。 + */ +public final class ObjectPanel { + + private ObjectPanel() { + } + + /** 面板状态(静态:按钮回调是另一条线程进来的,靠它记住"当前对着谁操作")。 */ + private static final class State { + ClassLoader loader; + Object skin; + Object listTable; + Object status; + Object nameField; + List objects = new ArrayList(); + Object target; + } + + /** + * 建面板(由 {@link ViewInstaller#buildPanel} 调用)。返回的 Table 会被塞进 us.spineView。 + */ + public static Object build(ClassLoader loader, Object skin) throws Exception { + final State state = new State(); + state.loader = loader; + state.skin = skin; + + 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 scrollClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.ScrollPane", true, loader); + Class skinClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.Skin", true, loader); + Class fieldClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.TextField", true, loader); + + Object root = tableClass.getConstructor().newInstance(); + Object rows = tableClass.getConstructor().newInstance(); + state.listTable = tableClass.getConstructor().newInstance(); + Object scroll = scrollClass.getConstructor(actorClass, skinClass).newInstance(state.listTable, skin); + state.status = label(loader, skin, "Press Refresh to list scene objects."); + state.nameField = fieldClass.getConstructor(String.class, skinClass) + .newInstance("particle_bone", skin); + + // 让面板铺满停靠框(WidgetGroup.setFillParent)。 + call(root, Obf.TABLE_SET_FILL_PARENT, new Class[] {boolean.class}, void.class, Boolean.TRUE); + + Object refresh = button(loader, skin, "Refresh"); + Object newBone = button(loader, skin, "New Bone"); + Object newSlot = button(loader, skin, "New Slot"); + Object newBBox = button(loader, skin, "New BBox"); + Object newPath = button(loader, skin, "New Path"); + Object delete = button(loader, skin, "Delete"); + Object rename = button(loader, skin, "Rename"); + Object inspect = button(loader, skin, "Inspect"); + + Object cell = call(rows, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, refresh); + layoutCell(cell, Obf.CELL_LEFT); + cell = call(rows, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, newBone); + layoutCell(cell, Obf.CELL_LEFT); + call(rows, Obf.TABLE_ROW, new Class[0], cellClass); + cell = call(rows, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, newSlot); + layoutCell(cell, Obf.CELL_LEFT); + cell = call(rows, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, delete); + layoutCell(cell, Obf.CELL_LEFT); + cell = call(rows, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, rename); + layoutCell(cell, Obf.CELL_LEFT); + cell = call(rows, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, inspect); + layoutCell(cell, Obf.CELL_LEFT); + call(rows, Obf.TABLE_ROW, new Class[0], cellClass); + cell = call(rows, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, newBBox); + layoutCell(cell, Obf.CELL_LEFT); + cell = call(rows, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, newPath); + layoutCell(cell, Obf.CELL_LEFT); + call(rows, Obf.TABLE_ROW, new Class[0], cellClass); + cell = call(rows, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, state.nameField); + layoutCell(cell, Obf.CELL_LEFT, Obf.CELL_EXPAND); + call(rows, Obf.TABLE_ROW, new Class[0], cellClass); + + // 第 1 行:按钮区;第 2 行:对象列表(撑满);第 3 行:状态行。 + cell = call(root, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, rows); + layoutCell(cell, Obf.CELL_LEFT, Obf.CELL_FILL); + call(root, Obf.TABLE_ROW, new Class[0], cellClass); + cell = call(root, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, scroll); + layoutCell(cell, Obf.CELL_EXPAND, Obf.CELL_FILL, Obf.CELL_TOP); + call(root, Obf.TABLE_ROW, new Class[0], cellClass); + cell = call(root, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, state.status); + layoutCell(cell, Obf.CELL_LEFT, Obf.CELL_FILL); + + wire(loader, refresh, new Runnable() { + @Override + public void run() { + refreshList(state); + } + }); + wire(loader, newBone, new Runnable() { + @Override + public void run() { + create(state, "Bone"); + } + }); + wire(loader, newSlot, new Runnable() { + @Override + public void run() { + create(state, "Slot"); + } + }); + wire(loader, newBBox, new Runnable() { + @Override + public void run() { + create(state, "Bounding Box"); + } + }); + wire(loader, newPath, new Runnable() { + @Override + public void run() { + create(state, "Path"); + } + }); + wire(loader, inspect, new Runnable() { + @Override + public void run() { + inspect(state); + } + }); + wire(loader, delete, new Runnable() { + @Override + public void run() { + if (state.target == null) { + setStatus(state, "Delete: select an object first."); + return; + } + Object victim = state.target; + String what = SceneObjects.kind(victim) + " " + SceneObjects.nameOf(victim); + state.target = null; + boolean ok = SceneActions.delete(state.loader, victim); + setStatus(state, (ok ? "Deleted " : "Delete failed: ") + what); + refreshList(state); + } + }); + wire(loader, rename, new Runnable() { + @Override + public void run() { + if (state.target == null) { + setStatus(state, "Rename: select an object first."); + return; + } + String name = textFieldText(state.nameField); + boolean ok = SceneActions.rename(state.loader, state.target, name); + setStatus(state, (ok ? "Renamed to " : "Rename failed: ") + name); + refreshList(state); + } + }); + + System.err.println("[panel] 增删改查面板已建好"); + refreshList(state); + return root; + } + + /** 重新读一遍树、重建对象列表(全部在编辑器渲染线程上做)。 */ + private static void refreshList(final State state) { + SceneActions.onEditorThread(state.loader, new Runnable() { + @Override + public void run() { + List objects; + try { + objects = SceneObjects.listAllNodes(state.loader); + } catch (Throwable error) { + setStatus(state, "List failed: " + error); + return; + } + state.objects = objects; + try { + // Table.clearChildren()(载荷名 `SourceFile()V`):把列表里的行全清掉重建。 + call(state.listTable, Obf.TABLE_CLEAR_CHILDREN, new Class[0], void.class); + Class cellClass = Class.forName(Obf.CELL, true, state.loader); + Class actorClass = Class.forName(Obf.ACTOR, true, state.loader); + for (int i = 0; i < objects.size() && i < 300; i++) { + final Object object = objects.get(i); + Object row = button(state.loader, state.skin, + SceneObjects.kind(object) + " " + SceneObjects.nameOf(object)); + Object cell = call(state.listTable, Obf.TABLE_ADD, + new Class[] {actorClass}, cellClass, row); + layoutCell(cell, Obf.CELL_LEFT); + call(state.listTable, Obf.TABLE_ROW, new Class[0], cellClass); + wire(state.loader, row, new Runnable() { + @Override + public void run() { + selectTarget(state, object); + } + }); + } + } catch (Throwable error) { + setStatus(state, "List build failed: " + error); + return; + } + setStatus(state, objects.size() + " scene objects" + + (state.target == null ? "" : "; target = " + SceneObjects.kind(state.target) + + " " + SceneObjects.nameOf(state.target))); + } + }); + } + + /** 点列表里的某一行:在编辑器层级树里选中它,并把它记成"当前操作对象"。 */ + private static void selectTarget(final State state, final Object object) { + state.target = object; + boolean ok = SceneActions.select(state.loader, object); + setStatus(state, (ok ? "Selected " : "Select failed: ") + + SceneObjects.kind(object) + " " + SceneObjects.nameOf(object)); + } + + /** 新建:以当前对象为父级跑编辑器的 `New `。 */ + /** + * 新建:以当前对象为父级跑编辑器的 `New `。 + * 名称输入框里**有字就用它**,没字就让编辑器那套自动化自己生成(`New Slot` 会弹输入框, + * 面板会自动把名字填进去并点 OK,见 SceneActions#fillDialogWhenReady)。 + */ + private static void create(final State state, final String type) { + if (state.target == null) { + setStatus(state, "New " + type + ": select a parent object first."); + return; + } + Object parent = state.target; + String typed = textFieldText(state.nameField); + SceneActions.nextName(typed == null ? "" : typed.trim()); + boolean ok = SceneActions.create(state.loader, parent, type); + String used = SceneActions.lastName(); + setStatus(state, (ok ? "New " + type + " under " : "New " + type + " failed: ") + + SceneObjects.kind(parent) + " " + SceneObjects.nameOf(parent) + + (used == null ? "" : " name=" + used)); + refreshList(state); + } + + /** + * Inspector:把**编辑器里当前选中对象**的字段与方法列到列表区。 + * 字段名是被混淆的(一堆 D/B/L),所以每行都带上类型;对象引用只显示类名。 + */ + private static void inspect(final State state) { + SceneActions.onEditorThread(state.loader, new Runnable() { + @Override + public void run() { + Object object; + try { + object = SceneObjects.selected(state.loader); + } catch (Throwable error) { + setStatus(state, "Inspect failed: " + error); + return; + } + if (object == null) { + setStatus(state, "Inspect: nothing selected (click a row, or select in the editor tree)."); + return; + } + List lines = new ArrayList(); + lines.add("selected: " + SceneObjects.kind(object) + " \"" + SceneObjects.nameOf(object) + + "\" (" + object.getClass().getName() + ")"); + lines.add("--- fields (" + countFields(object) + ") ---"); + for (Class type = object.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + for (java.lang.reflect.Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + String value; + try { + field.setAccessible(true); + Object raw = field.get(object); + value = raw == null ? "null" + : (raw instanceof Number || raw instanceof Boolean || raw instanceof String) + ? String.valueOf(raw) : raw.getClass().getName(); + } catch (Throwable error) { + value = "<" + error.getClass().getSimpleName() + ">"; + } + lines.add(" " + shortType(field.getType().getName()) + " " + field.getName() + + " = " + value + " @" + type.getName()); + } + } + lines.add("--- methods ---"); + for (Method method : object.getClass().getMethods()) { + if (method.getDeclaringClass() == Object.class) continue; + StringBuilder params = new StringBuilder(); + for (Class parameter : method.getParameterTypes()) { + if (params.length() > 0) params.append(", "); + params.append(shortType(parameter.getName())); + } + lines.add(" " + shortType(method.getReturnType().getName()) + " " + method.getName() + + "(" + params + ")"); + } + fillTextRows(state, lines); + setStatus(state, "Inspected " + SceneObjects.kind(object) + " \"" + + SceneObjects.nameOf(object) + "\": " + (lines.size() - 2) + " entries"); + } + }); + } + + private static int countFields(Object object) { + int count = 0; + for (Class type = object.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + for (java.lang.reflect.Field field : type.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers())) count++; + } + } + return count; + } + + private static String shortType(String name) { + int index = name.lastIndexOf('.'); + String simple = index < 0 ? name : name.substring(index + 1); + if (simple.length() == 1) return name; // D/B/L 这种混淆名:带上包名才分得清 + return simple; + } + + /** 用纯文本行填列表区(行不可点)。 */ + private static void fillTextRows(final State state, List lines) { + try { + call(state.listTable, Obf.TABLE_CLEAR_CHILDREN, new Class[0], void.class); + Class cellClass = Class.forName(Obf.CELL, true, state.loader); + Class actorClass = Class.forName(Obf.ACTOR, true, state.loader); + for (String line : lines) { + Object row = button(state.loader, state.skin, line); + Object cell = call(state.listTable, Obf.TABLE_ADD, new Class[] {actorClass}, + cellClass, row); + layoutCell(cell, Obf.CELL_LEFT); + call(state.listTable, Obf.TABLE_ROW, new Class[0], cellClass); + } + } catch (Throwable error) { + System.err.println("[panel] inspector 填充失败: " + error); + } + } + + private static void setStatus(final State state, final String text) { + if (state.status == null) return; + SceneActions.onEditorThread(state.loader, new Runnable() { + @Override + public void run() { + try { + call(state.status, Obf.LABEL_SET_TEXT, new Class[] {CharSequence.class}, + void.class, text); + } catch (Throwable error) { + System.err.println("[panel] 状态行更新失败: " + error); + } + } + }); + } + + /** TextField 里的文字(名字被混淆了,按"无参 + 返回 String"取)。 */ + private static String textFieldText(Object field) { + for (Method method : field.getClass().getMethods()) { + if (method.getParameterTypes().length != 0) continue; + if (method.getReturnType() != String.class) continue; + try { + method.setAccessible(true); + Object value = method.invoke(field); + if (value instanceof String) return (String) value; + } catch (Throwable ignored) { + // 换下一个 + } + } + return "particle_bone"; + } + + private static Object button(ClassLoader loader, Object skin, String text) throws Exception { + Class buttonClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.TextButton", true, loader); + Class skinClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.Skin", true, loader); + return buttonClass.getConstructor(String.class, skinClass).newInstance(text, skin); + } + + private static Object label(ClassLoader loader, Object skin, String text) throws Exception { + Class labelClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.Label", true, loader); + Class skinClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.Skin", true, loader); + return labelClass.getConstructor(CharSequence.class, skinClass).newInstance(text, skin); + } + + // ==================== 按钮点击:一个轮询线程 + 一张路由表 ==================== + // + // libGDX 的 Button 自己带 ClickListener,点一下会翻转 isChecked,所以轮询 isChecked 就等于监听点击。 + // 用轮询而不是 addListener,是因为监听器接口的实例没法用反射 new 出来(Scene2dPanel 里已经踩过)。 + // 一条路由 = { button, action, isCheckedMethod, setCheckedQuietMethod }。 + private static final List ROUTES = new ArrayList(); + private static volatile boolean routerStarted; + + /** 验证用:记住面板上的按钮(标签 → 按钮),探针可以按标签"点"它们。 */ + private static final java.util.Map BUTTONS = + new java.util.LinkedHashMap(); + + /** 建面板的顺序(验证用):0 Refresh 1 New Bone 2 New Slot 3 New BBox 4 New Path 5 Delete 6 Rename。 */ + private static final List ORDER = new ArrayList(); + + static Object buttonAt(int index) { + synchronized (ORDER) { + return index >= 0 && index < ORDER.size() ? ORDER.get(index) : null; + } + } + + static void remember(String label, Object button) { + synchronized (BUTTONS) { + BUTTONS.put(label, button); + ORDER.add(button); + if (ROUTES.size() > 100000) BUTTONS.clear(); + } + } + + static Object panelButton(String label) { + synchronized (BUTTONS) { + return BUTTONS.get(label); + } + } + + static void wire(ClassLoader loader, Object button, Runnable action) { + try { + Method isChecked = null; + Method resetQuiet = null; + Class buttonClass = button.getClass(); + for (Method method : buttonClass.getMethods()) { + if (!method.getName().equals(Obf.BUTTON_IS_CHECKED)) continue; + if (method.getParameterTypes().length != 0) continue; + if (method.getReturnType() != boolean.class) continue; + isChecked = method; + break; + } + for (Method method : buttonClass.getMethods()) { + if (!method.getName().equals(Obf.BUTTON_SET_CHECKED_FIRE)) continue; + if (method.getParameterTypes().length != 2) continue; + if (method.getParameterTypes()[0] != boolean.class + || method.getParameterTypes()[1] != boolean.class) continue; + resetQuiet = method; + break; + } + if (isChecked == null) { + System.err.println("[panel] 找不到 isChecked,按钮不响应: " + button); + return; + } + isChecked.setAccessible(true); + if (resetQuiet != null) resetQuiet.setAccessible(true); + synchronized (ROUTES) { + ROUTES.add(new Object[] {button, action, isChecked, resetQuiet}); + } + remember(buttonLabel(button), button); + startRouter(); + } catch (Throwable error) { + System.err.println("[panel] 接按钮失败: " + error); + } + } + + /** 按钮上的文字(TextButton 自己的 getText;不取 Actor.getName)。 */ + static String buttonLabel(Object button) { + for (Class type = button.getClass(); type != null + && !type.getName().equals(Obf.ACTOR); type = type.getSuperclass()) { + for (Method method : type.getDeclaredMethods()) { + if (method.getParameterTypes().length != 0) continue; + if (method.getReturnType() != String.class) continue; + // 跳过 Object/Actor 那一批(toString 会返回 "TextButton: Refresh" 这种调试串) + if (method.getName().equals("toString")) continue; + if (method.getName().equals("hashCode")) continue; + if (method.getName().equals("equals")) continue; + if (method.getName().equals("getName")) continue; + try { + method.setAccessible(true); + Object value = method.invoke(button); + if (value instanceof String) return (String) value; + } catch (Throwable ignored) { + // 换下一个 + } + } + } + return "?"; + } + + private static void startRouter() { + if (routerStarted) return; + synchronized (ObjectPanel.class) { + if (routerStarted) return; + routerStarted = true; + } + Thread thread = new Thread(new Runnable() { + @Override + public void run() { + while (true) { + try { + List snapshot; + synchronized (ROUTES) { + snapshot = new ArrayList(ROUTES); + } + for (Object[] route : snapshot) { + if (!Boolean.TRUE.equals(((Method) route[2]).invoke(route[0]))) continue; + Method resetQuiet = (Method) route[3]; + if (resetQuiet != null) { + resetQuiet.invoke(route[0], Boolean.FALSE, Boolean.FALSE); + } + try { + ((Runnable) route[1]).run(); + } catch (Throwable error) { + System.err.println("[panel] 动作失败: " + error); + error.printStackTrace(System.err); + } + } + Thread.sleep(80); + } catch (InterruptedException ignored) { + return; + } catch (Throwable error) { + System.err.println("[panel] 轮询异常: " + error); + return; + } + } + } + }, "spineview-crud-router"); + thread.setDaemon(true); + thread.start(); + } + + // ==================== 反射小工具(名字被混淆,一律"名字 + 参数 + 返回类型"三判) ==================== + + /** + * 验证用探针:按顺序"点"面板上的按钮,然后把每一步的结果打出来。 + * 点击 = 调 `setChecked(true, true)`(与真人点击效果一致:路由器看到 isChecked 就执行动作)。 + */ + public static String probePanel(ClassLoader loader) { + StringBuilder out = new StringBuilder(); + out.append("buttons: ").append(buttonLabels()).append('\n'); + press(0, out); // Refresh + out.append("after Refresh: ").append(rows(loader)).append('\n'); + // 列表是 Refresh 之后才建出来的,找到 "bone bone3" 那一行并按它 + press(panelButton("bone bone3"), "row bone bone3", out); + out.append("after select: ").append(rows(loader)).append('\n'); + press(2, out); // New Slot(会弹输入框,看面板能不能自己填+点 OK) + sleepQuiet(3000); + out.append("after New Slot: ").append(rows(loader)).append('\n'); + press(3, out); // New BBox + sleepQuiet(3000); + out.append("after New BBox: ").append(rows(loader)).append('\n'); + return out.toString(); + } + + private static void press(String label, StringBuilder out) { + press(panelButton(label), label, out); + } + + private static void press(int index, StringBuilder out) { + press(buttonAt(index), "#" + index, out); + } + + private static void press(Object button, String label, StringBuilder out) { + if (button == null) { + out.append("press ").append(label).append(" -> button not found\n"); + return; + } + try { + Method setChecked = null; + for (Method method : button.getClass().getMethods()) { + if (!method.getName().equals(Obf.BUTTON_SET_CHECKED_FIRE)) continue; + Class[] types = method.getParameterTypes(); + if (types.length != 2 || types[0] != boolean.class || types[1] != boolean.class) continue; + setChecked = method; + break; + } + if (setChecked == null) { + out.append("press ").append(label).append(" -> no setChecked(boolean,boolean)\n"); + return; + } + setChecked.setAccessible(true); + setChecked.invoke(button, Boolean.TRUE, Boolean.TRUE); + out.append("press ").append(label).append(" ok\n"); + sleepQuiet(600); + } catch (Throwable error) { + out.append("press ").append(label).append(" failed: ").append(error).append('\n'); + } + } + + private static void sleepQuiet(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + + private static String buttonLabels() { + StringBuilder out = new StringBuilder(); + synchronized (BUTTONS) { + for (String label : BUTTONS.keySet()) { + if (out.length() > 0) out.append(", "); + out.append('"').append(label).append('"'); + } + } + return out.toString(); + } + + private static String rows(ClassLoader loader) { + StringBuilder out = new StringBuilder(); + try { + for (Object item : SceneObjects.listAllNodes(loader)) { + out.append(SceneObjects.kind(item)).append(' ').append(SceneObjects.nameOf(item)) + .append(" | "); + } + } catch (Throwable ignored) { + // 换下面那条路 + } + return out.toString(); + } + + static Object call(Object target, String name, Class[] parameterTypes, Class returnType, + Object... arguments) throws Exception { + for (Class type = target.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + for (Method method : type.getMethods()) { + if (!method.getName().equals(name)) continue; + if (Modifier.isStatic(method.getModifiers())) 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)); + } + + 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("[panel] 单元格布局 " + name + " 失败: " + error); + } + } + } +} diff --git a/viewpanel/src/com/spineview/SceneActions.java b/viewpanel/src/com/spineview/SceneActions.java new file mode 100644 index 0000000..abe0fd2 --- /dev/null +++ b/viewpanel/src/com/spineview/SceneActions.java @@ -0,0 +1,904 @@ +package com.spineview; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.List; + +/** + * 场景对象的"增 / 删 / 改"。 + * + * 两条路(都在 `编辑器交互与数据API.md` 里有字节码出处): + * + * 1. **走编辑器菜单命令**(`New Bone` / `Delete` / `Rename` …): + * 路径 = 先 `VZ.D(对象)` 把选择设成目标 → 跑命令。 + * 编辑器自己会改属性面板、插树节点、弹确认框、进撤销栈。**增**走这条。 + * 2. **直接改模型 + 自己包撤销**:`un.D()` … `对象.L()`/`对象.D(名字,true)` … `un.B()`, + * 再 `VZ.D(骨架,true,true)` 重建树节点。**删/改**走这条(不进确认框,但带撤销)。 + * + * 模型对象(载荷名):`GP` 骨架、`Gp` 骨骼、`GU` 插槽、`GT`/`GO` 附件,都 extends `Gj`。 + * `L()V` = 把自己从模型里摘掉;`D(String,boolean)V` = 改名(第二参 = 是否同步子对象名字)。 + */ +public final class SceneActions { + + /** 标记"当前线程就是编辑器渲染线程",避免 `OG.B` 二次投递(它内部会断言不在渲染线程)。 */ + private static final ThreadLocal ON_EDITOR_THREAD = new ThreadLocal(); + + private SceneActions() { + } + + /** + * 把一段动作切到**编辑器的渲染线程**上执行并等待。 + * + * 为什么必须切:编辑器在改模型前会调 `OG.I()`(`javap -c OG`): + * ```java + * if (!Editor.S && Editor.D != null && Editor.D != Thread.currentThread()) + * throw new RuntimeException(); // "不能在非渲染线程动模型" + * ``` + * 而 `OG.B(Runnable)V` / `OG.D(Runnable)V` 就是编辑器自己的"投递到渲染线程"接口 + * (`B` 用 `CountDownLatch` 等它跑完,`D` 不等;内部走 `cOm4.D:LnUl;.D(Runnable)`)。 + * + * 面板按钮本身是 libGDX 事件回调,天然就在渲染线程上;这个方法主要给后台线程/探针用。 + */ + public static void onEditorThread(ClassLoader loader, Runnable body) { + if (Boolean.TRUE.equals(ON_EDITOR_THREAD.get())) { + body.run(); + return; + } + final Runnable wrapped = new Runnable() { + @Override + public void run() { + ON_EDITOR_THREAD.set(Boolean.TRUE); + try { + body.run(); + } finally { + ON_EDITOR_THREAD.remove(); + } + } + }; + try { + Class og = Class.forName("OG", true, loader); + for (Method method : og.getDeclaredMethods()) { + if (!method.getName().equals("B")) continue; + Class[] parameters = method.getParameterTypes(); + if (parameters.length != 1 || parameters[0] != Runnable.class) continue; + method.setAccessible(true); + method.invoke(null, wrapped); + return; + } + System.err.println("[crud] OG.B(Runnable) 没找到,直接在当前线程执行"); + } catch (Throwable error) { + System.err.println("[crud] 切渲染线程失败,直接在当前线程执行: " + error); + } + wrapped.run(); + } + + /** 撤销栈(载荷类 un):`D()` 记一次编辑开始,`B()` 提交。 */ + private static Class undoManager(ClassLoader loader) throws Exception { + return Class.forName("un", true, loader); + } + + /** 调 `un.D()` / `un.B()` 这类无参静态方法(名字被混淆过,按"名字 + 空参数 + void"取)。 */ + private static void undoCall(Class undoClass, String name) throws Exception { + // ⚠️ 不能 getDeclaredMethod(name):`un` 上有 4 个同名无参方法(返回 Z/J/I/V),会抛 NoSuchMethod。 + for (Method method : undoClass.getDeclaredMethods()) { + if (!method.getName().equals(name)) continue; + if (!hasNoArgs(method)) continue; + method.setAccessible(true); + method.invoke(null); + return; + } + throw new NoSuchMethodException("un." + name + "():V"); + } + + private static boolean hasNoArgs(Method method) { + return method.getParameterTypes().length == 0 && method.getReturnType() == void.class + && Modifier.isStatic(method.getModifiers()); + } + + /** 选中某个场景对象(编辑器层级树里高亮它,并切换它的属性面板)。 */ + public static boolean select(ClassLoader loader, Object sceneObject) { + if (sceneObject == null) return false; + final Object target = sceneObject; + final ClassLoader classLoader = loader; + final boolean[] result = new boolean[1]; + // 选中会触发 editor 的事件链路(改属性面板等),同样必须在渲染线程上做。 + onEditorThread(loader, new Runnable() { + @Override + public void run() { + try { + result[0] = SceneObjects.select(classLoader, target); + } catch (Throwable error) { + System.err.println("[crud] 选中失败: " + error); + } + } + }); + return result[0]; + } + + /** + * 新建:先把选择设成父对象,再跑编辑器的 `New ` 命令。 + * + * `type` 用编辑器自己的类型名:`Bone` / `Slot` / `Skin Placeholder` / `Bounding Box` / + * `Path` / `Clipping` / `IK Constraint` / `Transform Constraint` / `Path Constraint`。 + * 命令只在"当前选择是合法父级"时生效(例如选骨骼才能 New Bone / New Slot),否则编辑器什么都不做。 + */ + public static boolean create(ClassLoader loader, Object parent, String type) { + final Object target = parent; + final String typeName = type; + final ClassLoader classLoader = loader; + final boolean[] result = new boolean[1]; + onEditorThread(loader, new Runnable() { + @Override + public void run() { + result[0] = createNow(classLoader, target, typeName); + } + }); + // ⚠️ 输入名字的对话框是**下一帧**才建出来的,只能在渲染线程之外等它出现 + // (在渲染线程里 sleep 会卡住帧,对话框永远不出现)。 + // `New Bone` 是编辑器自动编号、不弹框;其余 New X 都会弹,所以: + // 没给名字 → 自动生成一个不重名的;给了名字 → 用它;然后自动点 OK。 + if (!"Bone".equals(typeName)) { + String pending = PENDING_NAME.get(); + if (pending == null || pending.length() == 0) { + pending = autoName(loader, typeName); + } + LAST_NAME.set(pending); + fillDialogWhenReady(loader, pending); + PENDING_NAME.remove(); + } + return result[0]; + } + + private static boolean createNow(ClassLoader loader, Object parent, String type) { + if (parent == null) { + System.err.println("[crud] 新建失败:没有选中的父对象"); + return false; + } + if (!select(loader, parent)) { + System.err.println("[crud] 新建失败:选不中父对象 " + SceneObjects.nameOf(parent)); + return false; + } + String command = type.startsWith("New ") ? type : "New " + type; + System.err.println("[crud] 新建: " + command + "(父级 = " + SceneObjects.nameOf(parent) + ")"); + // 有些 New X 会弹"输入名字"对话框(New Slot / New Bounding Box / New Path / …), + // 光执行命令不会建对象,对话框由 create() 在本线程轮询后填名字 + 点 OK(见 fillDialogWhenReady)。 + return Commands.run(loader, command); + } + + /** 面板/探针给下一次"会弹对话框的新建"准备好的名字。 */ + private static final ThreadLocal PENDING_NAME = new ThreadLocal(); + /** 这一次新建实际用的名字(面板用来显示)。 */ + private static final ThreadLocal LAST_NAME = new ThreadLocal(); + + public static void nextName(String name) { + PENDING_NAME.set(name); + } + + public static String lastName() { + return LAST_NAME.get(); + } + + /** + * 自动生成一个**不重名**的名字:`Slot` → `slot` / `slot_2` / `slot_3` … + * 判断重名时比的是"同一类对象"的现有名字(骨骼/插槽全局唯一,附件同名也能查出来)。 + */ + static String autoName(ClassLoader loader, String type) { + String base = type.toLowerCase().replace(' ', '_'); + java.util.Set used = new java.util.HashSet(); + try { + for (Object item : SceneObjects.listAllNodes(loader)) { + used.add(SceneObjects.nameOf(item)); + } + } catch (Throwable ignored) { + // 读不到就用 1 + } + if (!used.contains(base)) return base; + for (int i = 2; i < 10000; i++) { + String candidate = base + "_" + i; + if (!used.contains(candidate)) return candidate; + } + return base + "_" + System.nanoTime(); + } + + // ==================== 输入对话框自动化 ==================== + // + // `New Slot` / `New Bounding Box` / `New Path` / `New Clipping` / `New Skin Placeholder` / + // `New IK Constraint` / `New Event` / `New Animation` 都会弹一个"输入名字"的窗口 + // (标题如 "New Slot",正文 "Enter a name to create a new slot:",按钮 OK / Cancel)。 + // 命令本身只负责弹窗,名字与 OK 必须有人点。这里把这一步自动化: + // Stage(OG.D:Ldt;) → 找最后一个 TextField(对话框里的输入框) + // → 找最后一个文字含 "OK" 的 TextButton → 填名字 + 点它。 + + /** 轮询等对话框出现(最多约 3 秒),出现后填名字 + 点 OK。 */ + static boolean fillDialogWhenReady(ClassLoader loader, String name) { + for (int i = 0; i < 30; i++) { + if (fillDialog(loader, name)) { + System.err.println("[crud] 已自动填写对话框并点 OK: " + name); + sleep(300); + return true; + } + sleep(100); + } + System.err.println("[crud] 没等到输入对话框: " + name); + return false; + } + + static boolean fillDialog(ClassLoader loader, String name) { + try { + Object stage = SceneObjects.global(loader, "dt"); + if (stage == null) return false; + // 不顺着"children 数组"找了(编辑器的弹窗并不一定在 Stage 根的孩子里)。 + // 改成:从 Stage 出发按字段广度遍历,找"**有 TextField 字段 + 有 L(Object)V 回调**"的对象 + // —— 载荷里所有这类输入对话框(New Slot / New Bounding Box / New Event / …)都长这样: + // zY(New Slot 对话框)的 OK 回调就是 `L(Object)`,体里只做 + // `String name = TextField.getText(...); …建对象…`。 + Object dialog = findDialog(stage); + if (dialog == null) return false; + Object textField = SceneObjects.fieldOfType(dialog, "com.badlogic.gdx.scenes.scene2d.ui.TextField"); + if (textField == null) return false; + setText(textField, name); + Method ok = findMethod(dialog.getClass(), "L", new Class[] {Object.class}, void.class); + if (ok == null) return false; + ok.setAccessible(true); + ok.invoke(dialog, Boolean.TRUE); + return true; + } catch (Throwable error) { + System.err.println("[crud] 对话框自动化失败: " + error); + return false; + } + } + + /** 广度遍历 Stage 上的对象图,找"带 TextField 且有 L(Object)V"的对话框。 */ + private static Object findDialog(Object stage) { + java.util.Set seen = java.util.Collections.newSetFromMap( + new java.util.IdentityHashMap()); + java.util.ArrayDeque queue = new java.util.ArrayDeque(); + queue.add(stage); + int visited = 0; + while (!queue.isEmpty() && visited++ < 40000) { + Object current = queue.poll(); + if (current == null || !seen.add(current)) continue; + if (isDialog(current)) return current; + if (current.getClass().isArray()) { + if (!current.getClass().getComponentType().isPrimitive()) { + for (Object item : (Object[]) current) queue.add(item); + } + continue; + } + String typeName = current.getClass().getName(); + if (typeName.startsWith("java.") || typeName.startsWith("javax.")) continue; + for (Class type = current.getClass(); type != null && type != Object.class; + 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); + } catch (Throwable ignored) { + // 跳过 + } + } + } + } + return null; + } + + private static boolean isDialog(Object candidate) { + if (SceneObjects.fieldOfType(candidate, "com.badlogic.gdx.scenes.scene2d.ui.TextField") == null) { + return false; + } + return findMethod(candidate.getClass(), "L", new Class[] {Object.class}, void.class) != null; + } + + /** 深度优先遍历 actor 树,记住**最后一个** TextField 和最后一个文字含 OK 的按钮。 */ + private static void walkActors(Object actor, Object[] found, int depth) { + if (actor == null || depth > 24) return; + String type = actor.getClass().getName(); + if (type.equals("com.badlogic.gdx.scenes.scene2d.ui.TextField")) found[0] = actor; + if (type.startsWith("com.badlogic.gdx.scenes.scene2d.ui.TextButton")) { + String label = ObjectPanel.buttonLabel(actor); + if (label != null && label.toUpperCase().contains("OK")) found[1] = actor; + } + for (Object child : children(actor)) walkActors(child, found, depth + 1); + } + + /** Group 的子节点(`do` 上的 Object[] 字段 + 一个 int size)。 */ + private static Object[] children(Object actor) { + for (Class type = actor.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + try { + field.setAccessible(true); + Object value = field.get(actor); + Object[] items = SceneObjects.arrayItems(value); + if (items.length > 0) return items; + } catch (Throwable ignored) { + // 跳过 + } + } + if (type.getName().equals(Obf.GROUP)) break; + } + return new Object[0]; + } + + private static void setText(Object textField, String text) throws Exception { + Method setter = findMethod(textField.getClass(), Obf.LABEL_SET_TEXT, new Class[] {String.class}, void.class); + if (setter == null) { + setter = findMethod(textField.getClass(), Obf.LABEL_SET_TEXT, new Class[] {CharSequence.class}, void.class); + } + if (setter == null) throw new NoSuchMethodException("TextField.setText"); + setter.setAccessible(true); + setter.invoke(textField, text); + } + + private static void pressButton(Object button) throws Exception { + Method setChecked = findMethod(button.getClass(), Obf.BUTTON_SET_CHECKED_FIRE, + new Class[] {boolean.class, boolean.class}, void.class); + if (setChecked == null) { + setChecked = findMethod(button.getClass(), Obf.BUTTON_SET_CHECKED, + new Class[] {boolean.class}, void.class); + setChecked.setAccessible(true); + setChecked.invoke(button, Boolean.TRUE); + return; + } + setChecked.setAccessible(true); + setChecked.invoke(button, Boolean.TRUE, Boolean.TRUE); + } + + /** + * 删除:`un.D()` → `对象.L()` → `un.B()` → 重建树。 + * 返回 true 表示调用链没抛异常(不代表编辑器真的删掉了,删掉与否看树)。 + */ + public static boolean delete(ClassLoader loader, Object sceneObject) { + final Object target = sceneObject; + final ClassLoader classLoader = loader; + final boolean[] result = new boolean[1]; + onEditorThread(loader, new Runnable() { + @Override + public void run() { + result[0] = deleteNow(classLoader, target); + } + }); + return result[0]; + } + + private static boolean deleteNow(ClassLoader loader, Object sceneObject) { + if (sceneObject == null) return false; + try { + Method remove = findMethod(sceneObject.getClass(), "L", new Class[0], void.class); + if (remove == null) { + System.err.println("[crud] 删除失败:" + sceneObject.getClass().getName() + " 上没有 L()V"); + return false; + } + Object skeleton = skeletonOf(sceneObject); + Class undoClass = undoManager(loader); + undoCall(undoClass, "D"); + remove.setAccessible(true); + remove.invoke(sceneObject); + undoCall(undoClass, "B"); + refresh(loader, skeleton); + System.err.println("[crud] 已删除 " + SceneObjects.kind(sceneObject) + " " + + SceneObjects.nameOf(sceneObject)); + return true; + } catch (Throwable error) { + System.err.println("[crud] 删除失败: " + error); + error.printStackTrace(System.err); + return false; + } + } + + /** 改名:`un.D()` → `对象.D(新名字, true)` → `un.B()` → 重建树。 */ + public static boolean rename(ClassLoader loader, Object sceneObject, String newName) { + final Object target = sceneObject; + final String name = newName; + final ClassLoader classLoader = loader; + final boolean[] result = new boolean[1]; + onEditorThread(loader, new Runnable() { + @Override + public void run() { + result[0] = renameNow(classLoader, target, name); + } + }); + return result[0]; + } + + private static boolean renameNow(ClassLoader loader, Object sceneObject, String newName) { + if (sceneObject == null || newName == null || newName.length() == 0) return false; + try { + Method setName = findMethod(sceneObject.getClass(), "D", + new Class[] {String.class, boolean.class}, void.class); + if (setName == null) { + System.err.println("[crud] 改名失败:" + sceneObject.getClass().getName() + + " 上没有 D(String,boolean)V"); + return false; + } + Object skeleton = skeletonOf(sceneObject); + Class undoClass = undoManager(loader); + undoCall(undoClass, "D"); + setName.setAccessible(true); + setName.invoke(sceneObject, newName, Boolean.TRUE); + undoCall(undoClass, "B"); + refresh(loader, skeleton); + System.err.println("[crud] 已改名 -> " + newName); + return true; + } catch (Throwable error) { + System.err.println("[crud] 改名失败: " + error); + error.printStackTrace(System.err); + return false; + } + } + + /** + * 重建某个骨架在层级树里的节点(编辑器删除流程里用的就是这个: + * `VZ.D(LGP;ZZ)V`,见 `vq_0.java:54`)。 + */ + public static boolean refresh(ClassLoader loader, Object skeleton) { + if (skeleton == null) return false; + try { + Object treeView = SceneObjects.global(loader, SceneObjects.TREE_VIEW); + Class skeletonClass = Class.forName("GP", true, loader); + Method rebuild = null; + for (Method method : treeView.getClass().getDeclaredMethods()) { + Class[] types = method.getParameterTypes(); + if (types.length != 3 || types[0] != skeletonClass) continue; + if (types[1] != boolean.class || types[2] != boolean.class) continue; + rebuild = method; + break; + } + if (rebuild == null) { + System.err.println("[crud] 刷新失败:VZ 上没有 D(GP,boolean,boolean)V"); + return false; + } + System.err.println("[crud] 刷新用方法 " + rebuild.getDeclaringClass().getName() + "." + + rebuild.getName() + java.util.Arrays.toString(rebuild.getParameterTypes()) + + " static=" + Modifier.isStatic(rebuild.getModifiers())); + rebuild.setAccessible(true); + // ⚠️ 参数就 3 个(GP, boolean, boolean):editor 不是参数,别多传。 + rebuild.invoke(treeView, skeleton, Boolean.TRUE, Boolean.TRUE); + return true; + } catch (Throwable error) { + System.err.println("[crud] 刷新失败: " + error); + return false; + } + } + + /** 找到所属骨架:骨骼是 `Gp.D:LGP;`,骨架自己就是 `GP`。 */ + public static Object skeletonOf(Object sceneObject) { + if (sceneObject == null) return null; + if (sceneObject.getClass().getName().equals("GP")) return sceneObject; + for (Class type = sceneObject.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (!field.getType().getName().equals("GP")) continue; + try { + field.setAccessible(true); + Object value = field.get(sceneObject); + if (value != null) return value; + } catch (Throwable ignored) { + // 跳过 + } + } + } + return null; + } + + /** 当前选中对象的类型名(给面板显示用)。 */ + public static String selectedKind(ClassLoader loader) { + try { + return SceneObjects.kind(SceneObjects.selected(loader)); + } catch (Throwable error) { + return "?"; + } + } + + /** 当前显示在 Tree 视图里的"属性面板控件"类名(诊断用)。 */ + public static String propertyPanelClass(ClassLoader loader) { + try { + Object treeView = SceneObjects.global(loader, SceneObjects.TREE_VIEW); + Object panel = SceneObjects.fieldOfType(treeView, "VY"); + return panel == null ? "null" : panel.getClass().getName(); + } catch (Throwable error) { + return "err:" + error; + } + } + + /** 按"名字 + 参数 + 返回类型"找实例方法(载荷里同名方法成堆,必须全判)。 */ + static Method findMethod(Class owner, String name, Class[] parameters, + Class returnType) { + for (Class type = owner; type != null && type != Object.class; + type = type.getSuperclass()) { + for (Method method : type.getDeclaredMethods()) { + if (!method.getName().equals(name)) continue; + if (!java.util.Arrays.equals(method.getParameterTypes(), parameters)) continue; + if (returnType != null && method.getReturnType() != returnType) continue; + return method; + } + } + return null; + } + + /** 诊断:把树里每个对象的类型 + 属性面板类名打出来。 */ + static String describePanels(ClassLoader loader) { + StringBuilder out = new StringBuilder(); + try { + List all = SceneObjects.listAllNodes(loader); + for (Object item : all) { + out.append(" ").append(SceneObjects.kind(item)).append(' ') + .append(SceneObjects.nameOf(item)); + // 选中动作必须在编辑器渲染线程上做(否则属性面板不刷新,见 onEditorThread)。 + final Object target = item; + final String[] info = new String[1]; + onEditorThread(loader, new Runnable() { + @Override + public void run() { + boolean ok = select(loader, target); + Object selected; + try { + selected = SceneObjects.selected(loader); + } catch (Throwable error) { + selected = error; + } + info[0] = " select=" + ok + " treeSelected=" + + (selected == null ? "null" : SceneObjects.nameOf(selected)) + + " panel=" + propertyPanelClass(loader); + } + }); + out.append(info[0] == null ? " (no info)" : info[0]); + out.append('\n'); + } + } catch (Throwable error) { + out.append("describePanels failed: ").append(error).append('\n'); + } + return out.toString(); + } + + /** + * 探针:真机跑一遍"选中 → 新建 → 改名 → 删除",每步都把对象列表打出来。 + * 只用于验证,不参与面板逻辑。 + */ + /** 探针:走编辑器自己的 `Select - Bones`,用来确认"选中"到底体现在哪。 */ + public static String probeSelect(final ClassLoader loader) { + final StringBuilder out = new StringBuilder(); + onEditorThread(loader, new Runnable() { + @Override + public void run() { + out.append("before: treeSelected=").append(describeSelection(loader)).append('\n'); + Commands.run(loader, "Select - Bones"); + out.append("after Select - Bones: ").append(describeSelection(loader)).append('\n'); + Object bone = null; + try { + bone = findByName(loader, "bone3"); + } catch (Throwable error) { + out.append("findByName failed: ").append(error).append('\n'); + } + out.append("select(\"bone3\") = ").append(select(loader, bone)).append('\n'); + out.append("after select: ").append(describeSelection(loader)).append('\n'); + } + }); + return out.toString(); + } + + private static String describeSelection(ClassLoader loader) { + StringBuilder out = new StringBuilder(); + try { + Object selected = SceneObjects.selected(loader); + out.append("treeSelected=").append(selected == null ? "null" : SceneObjects.nameOf(selected)); + Object treeView = SceneObjects.global(loader, SceneObjects.TREE_VIEW); + Object selection = SceneObjects.fieldNamed(treeView, "D", "fC"); + out.append(" selectionItems=").append(selection == null ? "noSelection" : String.valueOf( + selectionCount(selection))); + out.append(" lastSelected=").append(selection == null ? "-" : String.valueOf( + lastSelected(selection))); + out.append(" panel=").append(propertyPanelClass(loader)); + } catch (Throwable error) { + out.append(" describeSelection failed: ").append(error); + } + return out.toString(); + } + + private static int selectionCount(Object selection) { + try { + Method items = findMethod(selection.getClass(), "D", new Class[0], + Class.forName("fI", true, selection.getClass().getClassLoader())); + if (items == null) return -1; + items.setAccessible(true); + Object array = items.invoke(selection); + Object[] values = SceneObjects.arrayItems(array); + return values.length; + } catch (Throwable error) { + return -2; + } + } + + private static String lastSelected(Object selection) { + try { + Method last = findMethod(selection.getClass(), "D", new Class[0], Object.class); + if (last == null) return "?"; + last.setAccessible(true); + Object value = last.invoke(selection); + if (value == null) return "null"; + Object object = SceneObjects.nodeObject(value); + return object == null ? String.valueOf(value) : SceneObjects.nameOf(object); + } catch (Throwable error) { + return "err:" + error; + } + } + + public static String probeCrud(ClassLoader loader) { + StringBuilder out = new StringBuilder(); + try { + out.append("--- before ---\n").append(listNames(loader)); + Object parent = findByName(loader, "bone"); + out.append("parent = ").append(parent == null ? "null" : SceneObjects.kind(parent) + + " " + SceneObjects.nameOf(parent)).append('\n'); + if (parent == null) return out.toString(); + select(loader, parent); + out.append("parent panel = ").append(propertyPanelClass(loader)).append('\n'); + + out.append("--- create New Bone under ").append(SceneObjects.nameOf(parent)).append(" ---\n"); + boolean created = create(loader, parent, "Bone"); + sleep(400); + out.append("created=").append(created).append('\n').append(listNames(loader)); + + Object fresh = findByName(loader, "bone3"); + out.append("fresh = ").append(fresh == null ? "null" : SceneObjects.nameOf(fresh)) + .append('\n'); + if (fresh != null) { + out.append("--- rename -> panel_test ---\n"); + boolean renamed = rename(loader, fresh, "panel_test"); + sleep(400); + out.append("renamed=").append(renamed).append('\n').append(listNames(loader)); + Object renamedObject = findByName(loader, "panel_test"); + if (renamedObject != null) { + out.append("--- delete panel_test ---\n"); + boolean deleted = delete(loader, renamedObject); + sleep(400); + out.append("deleted=").append(deleted).append('\n').append(listNames(loader)); + } + } + } catch (Throwable error) { + out.append("probeCrud failed: ").append(error).append('\n'); + error.printStackTrace(System.err); + } + return out.toString(); + } + + private static String listNames(ClassLoader loader) throws Exception { + StringBuilder out = new StringBuilder(); + for (Object item : SceneObjects.listAllNodes(loader)) { + out.append(" ").append(SceneObjects.kind(item)).append(' ') + .append(SceneObjects.nameOf(item)).append('\n'); + } + return out.toString(); + } + + /** 按名字在树里找一个场景对象。 */ + static Object findByName(ClassLoader loader, String name) throws Exception { + for (Object item : SceneObjects.listAllNodes(loader)) { + if (name.equals(SceneObjects.nameOf(item))) return item; + } + return null; + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + + // ==================== 综合矩阵测试:骨骼 / 插槽 / 附件 的增删改查 + 同名规则 ==================== + + /** + * 一次跑完三类对象的增删改查与同名规则,把每一步的**事实**打出来(不做断言)。 + * + * 三类对象的模型 API 不一样,这正是要测的点: + * - 骨骼:`GP.D(String)LGp;`(按名字查,全局唯一) + * - 插槽:`GP.D(String)LGU;`(按名字查,全局唯一) + * - 附件:`GP.D(LGU;String)LGn;`(**先按插槽、再按名字**查 —— 附件名只在自己插槽内唯一) + */ + public static String probeMatrix(final ClassLoader loader) { + final StringBuilder out = new StringBuilder(); + // ⚠️ 不要整段包 onEditorThread:编辑器有些创建是"投递到下一帧"再做的, + // 而 onEditorThread 会一直占着渲染线程等我们返回,帧就跑不起来、对象永远建不出来。 + // 所以:每一步各自切渲染线程(select/create/delete 内部已经切了),sleep 留在本线程。 + { + try { + out.append("== initial ==\n").append(names(loader)); + + // ---- 骨骼 ---- + out.append("\n== bone ==\n"); + Object bone = findByName(loader, "bone"); + Object created = createAndReport(loader, bone, "Bone", out, "New Bone"); + Object created2 = createAndReport(loader, bone, "Bone", out, "New Bone#2"); + out.append(" names like bone*: ").append(countNames(loader, "bone", "bone")).append('\n'); + if (created2 != null) { + // 重名:把第二个新建的骨骼改成第一个的名字 + boolean renamed = rename(loader, created2, SceneObjects.nameOf(created)); + sleep(200); + out.append(" rename(").append(SceneObjects.nameOf(created2)).append(" -> ") + .append(SceneObjects.nameOf(created)).append(") = ").append(renamed) + .append("; 现在叫 ").append(SceneObjects.nameOf(created2)) + .append("; 同名骨骼数=").append(countNames(loader, "bone", + SceneObjects.nameOf(created))).append('\n'); + } + // 按名字查(模型 API) + Object skeleton = findByName(loader, "skeleton"); + out.append(" getBone(\"").append(SceneObjects.nameOf(created)).append("\") = ") + .append(describe(lookup(skeleton, "Gp", SceneObjects.nameOf(created)))).append('\n'); + out.append(" getBone(\"no_such_bone\") = ") + .append(describe(lookup(skeleton, "Gp", "no_such_bone"))).append('\n'); + // 删除 + if (created != null) { + boolean ok = delete(loader, created); + sleep(200); + out.append(" delete(").append(SceneObjects.nameOf(created)).append(") = ").append(ok) + .append("; 还在吗=").append(findByName(loader, SceneObjects.nameOf(created)) != null) + .append('\n'); + } + + // ---- 插槽 ---- + out.append("\n== slot ==\n"); + Object slot = createAndReport(loader, bone, "Slot", out, "New Slot"); + Object slot2 = createAndReport(loader, bone, "Slot", out, "New Slot#2"); + if (slot != null) { + out.append(" getSlot(\"").append(SceneObjects.nameOf(slot)).append("\") = ") + .append(describe(lookup(skeleton, "GU", SceneObjects.nameOf(slot)))).append('\n'); + } + if (slot2 != null && slot != null) { + boolean renamed = rename(loader, slot2, SceneObjects.nameOf(slot)); + sleep(200); + out.append(" rename(slot2 -> \"").append(SceneObjects.nameOf(slot)).append("\") = ") + .append(renamed).append("; 现在叫 ").append(SceneObjects.nameOf(slot2)) + .append("; 同名插槽数=").append(countNames(loader, "slot", + SceneObjects.nameOf(slot))).append('\n'); + } + + // ---- 附件(挂在插槽下)---- + out.append("\n== attachment ==\n"); + if (slot != null) { + out.append(" select slot ").append(SceneObjects.nameOf(slot)).append(" = ") + .append(select(loader, slot)).append('\n'); + sleep(200); + Object att1 = createAndReport(loader, slot, "Bounding Box", out, "New Bounding Box"); + Object att2 = createAndReport(loader, slot, "Path", out, "New Path"); + if (att1 != null && att2 != null) { + out.append(" attachment 类: ").append(att1.getClass().getName()).append(" / ") + .append(att2.getClass().getName()).append('\n'); + boolean renamed = rename(loader, att2, SceneObjects.nameOf(att1)); + sleep(200); + out.append(" rename(att2 -> \"").append(SceneObjects.nameOf(att1)) + .append("\") = ").append(renamed).append("; att2 现在叫 ") + .append(SceneObjects.nameOf(att2)).append('\n'); + out.append(" getAttachment(slot,\"").append(SceneObjects.nameOf(att1)) + .append("\") = ") + .append(describe(lookupAttachment(skeleton, slot, SceneObjects.nameOf(att1)))) + .append('\n'); + out.append(" delete(att1) = ").append(delete(loader, att1)).append('\n'); + sleep(200); + } + } + + // ---- 跨类型同名 ---- + out.append("\n== cross-type duplicate names ==\n"); + if (slot != null && bone != null) { + boolean renamed = rename(loader, slot, SceneObjects.nameOf(bone)); + sleep(200); + out.append(" rename(slot -> bone 的名字 \"").append(SceneObjects.nameOf(bone)) + .append("\") = ").append(renamed).append("; slot 现在叫 ") + .append(SceneObjects.nameOf(slot)).append('\n'); + } + out.append("\n== final ==\n").append(names(loader)); + } catch (Throwable error) { + out.append("probeMatrix failed: ").append(error).append('\n'); + error.printStackTrace(System.err); + } + } + return out.toString(); + } + + /** 建一个对象并把"新建前后的差集"报出来。 */ + private static Object createAndReport(ClassLoader loader, Object parent, String type, + StringBuilder out, String label) { + try { + List before = SceneObjects.listAllNodes(loader); + // 会弹名字对话框的那几种,先把名字准备好(bone 不用,编辑器自己编号)。 + if (!type.equals("Bone")) { + nextName(type.toLowerCase().replace(' ', '_') + "_probe" + + (System.nanoTime() % 1000)); + } + boolean ok = create(loader, parent, type); + sleep(300); + List after = SceneObjects.listAllNodes(loader); + Object fresh = null; + for (Object item : after) { + boolean seen = false; + for (Object old : before) { + if (old == item) { + seen = true; + break; + } + } + if (!seen) { + fresh = item; + break; + } + } + out.append(" ").append(label).append("(parent=").append(SceneObjects.nameOf(parent)) + .append(") -> ok=").append(ok).append(" new=") + .append(fresh == null ? "null" : fresh.getClass().getName() + " \"" + + SceneObjects.nameOf(fresh) + "\"") + .append('\n'); + return fresh; + } catch (Throwable error) { + out.append(" ").append(label).append(" failed: ").append(error).append('\n'); + return null; + } + } + + /** 模型层的按名字查:骨骼 `GP.D(String)LGp;` / 插槽 `GP.D(String)LGU;`。 */ + static Object lookup(Object skeleton, String typeName, String name) { + if (skeleton == null) return null; + for (Method method : skeleton.getClass().getMethods()) { + Class[] types = method.getParameterTypes(); + if (types.length != 1 || types[0] != String.class) continue; + if (!method.getReturnType().getName().equals(typeName)) continue; + try { + method.setAccessible(true); + return method.invoke(skeleton, name); + } catch (Throwable ignored) { + // 换下一个 + } + } + return null; + } + + /** 附件的按名字查要连插槽一起给:`GP.D(LGU;Ljava/lang/String;)LGn;`。 */ + static Object lookupAttachment(Object skeleton, Object slot, String name) { + if (skeleton == null || slot == null) return null; + for (Method method : skeleton.getClass().getMethods()) { + Class[] types = method.getParameterTypes(); + if (types.length != 2 || types[0] != slot.getClass()) continue; + if (types[1] != String.class) continue; + try { + method.setAccessible(true); + return method.invoke(skeleton, slot, name); + } catch (Throwable ignored) { + // 换下一个 + } + } + return null; + } + + private static String describe(Object object) { + return object == null ? "null" : object.getClass().getName() + " \"" + + SceneObjects.nameOf(object) + "\""; + } + + private static String names(ClassLoader loader) throws Exception { + StringBuilder out = new StringBuilder(); + for (Object item : SceneObjects.listAllNodes(loader)) { + String kind = SceneObjects.kind(item); + if (kind.equals("skeleton") || kind.equals("bone") || kind.equals("slot") + || kind.equals("attachment")) { + out.append(" ").append(kind).append(" \"").append(SceneObjects.nameOf(item)) + .append("\"\n"); + } + } + return out.toString(); + } + + private static int countNames(ClassLoader loader, String kind, String name) throws Exception { + int count = 0; + for (Object item : SceneObjects.listAllNodes(loader)) { + if (!SceneObjects.kind(item).equals(kind)) continue; + if (SceneObjects.nameOf(item).equals(name)) count++; + } + return count; + } +} diff --git a/viewpanel/src/com/spineview/SceneObjects.java b/viewpanel/src/com/spineview/SceneObjects.java index 97408ae..adffcb8 100644 --- a/viewpanel/src/com/spineview/SceneObjects.java +++ b/viewpanel/src/com/spineview/SceneObjects.java @@ -114,24 +114,115 @@ public final class SceneObjects { Object treeView = global(loader, TREE_VIEW); if (treeView == null || sceneObject == null) return false; Class nodeClass = Class.forName(NODE, true, loader); + Object node = null; for (Method method : treeView.getClass().getDeclaredMethods()) { Class[] parameters = method.getParameterTypes(); if (parameters.length != 1 || parameters[0] != Object.class) continue; - if (method.getReturnType() != nodeClass) continue; + // 返回类型是 Uo(Tree.Node 的子类),所以只能判"能容纳 eM" + if (!nodeClass.isAssignableFrom(method.getReturnType())) continue; method.setAccessible(true); - return method.invoke(treeView, sceneObject) != null; + node = method.invoke(treeView, sceneObject); + break; + } + if (node == null) return false; + // ⚠️ `VZ.D(Object)` 内部调的是 `fC.L(node)`(不是"设成选中")。 + // 真正"设成选中"的是 `VZ.D(LUo;Z)V`(内部 `fC.B(node)` = Selection.set + 展开 + 刷新)。 + for (Method method : treeView.getClass().getDeclaredMethods()) { + Class[] parameters = method.getParameterTypes(); + if (parameters.length != 2) continue; + if (!nodeClass.isAssignableFrom(parameters[0])) continue; + if (parameters[1] != boolean.class) continue; + method.setAccessible(true); + method.invoke(treeView, node, Boolean.TRUE); + return true; } return false; } - /** 场景对象的中文类型不在需求里(面板一律英文),这里给英文缩写归类。 */ + /** 按类型找一个实例字段(Tree 视图上的 `D:LVY` 就是"当前属性面板")。 */ + static Object fieldOfType(Object target, String typeName) { + if (target == null) return null; + for (Class type = target.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (!field.getType().getName().equals(typeName)) continue; + try { + field.setAccessible(true); + return field.get(target); + } catch (Throwable ignored) { + // 跳过 + } + } + } + return null; + } + + /** 按"字段名 + 类型"取实例字段(`VZ` 上一堆同名字段,两个都要判)。 */ + static Object fieldNamed(Object target, String name, String typeName) { + if (target == null) return null; + for (Class type = target.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (!field.getName().equals(name)) continue; + if (!field.getType().getName().equals(typeName)) continue; + try { + field.setAccessible(true); + return field.get(target); + } catch (Throwable ignored) { + // 跳过 + } + } + } + return null; + } + + /** + * 树里所有节点对象(**扁平**,按树的先序)。 + * 面板的对象列表就是它;要看层级用 {@link #describe}。 + */ + public static List listAllNodes(ClassLoader loader) throws Exception { + Object tree = global(loader, TREE); + if (tree == null) throw new IllegalStateException("拿不到 Tree 控件(OG 上没有 Uq 字段)"); + Class nodeClass = Class.forName(NODE, true, loader); + Class arrayClass = Class.forName(ARRAY, true, loader); + List roots = new ArrayList(); + for (Class type = tree.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (field.getType() != arrayClass) continue; + try { + field.setAccessible(true); + Object value = field.get(tree); + if (value == null) continue; + for (Object item : arrayItems(value)) { + if (item != null && nodeClass.isInstance(item)) roots.add(item); + } + } catch (Throwable ignored) { + // 跳过 + } + } + } + List out = new ArrayList(); + Set seen = java.util.Collections.newSetFromMap(new IdentityHashMap()); + for (Object root : roots) collectNode(root, out, seen); + return out; + } + + private static void collectNode(Object node, List out, Set seen) { + if (node == null || !seen.add(node)) return; + Object object = nodeObject(node); + if (object != null) out.add(object); + for (Object child : nodeChildren(node)) collectNode(child, out, seen); + } + + /** 场景对象的类型归类(面板上显示这个英文名)。 */ public static String kind(Object sceneObject) { if (sceneObject == null) return "null"; String name = sceneObject.getClass().getName(); if (name.equals("GP")) return "skeleton"; if (name.equals("Gp")) return "bone"; if (name.equals("GU")) return "slot"; - if (name.equals("GT")) return "attachment"; + if (name.equals("GT") || name.equals("GO")) return "attachment"; return name; } diff --git a/viewpanel/src/com/spineview/ViewInstaller.java b/viewpanel/src/com/spineview/ViewInstaller.java index 4b0837d..168463c 100644 --- a/viewpanel/src/com/spineview/ViewInstaller.java +++ b/viewpanel/src/com/spineview/ViewInstaller.java @@ -197,45 +197,8 @@ public final class ViewInstaller { * 少判一个条件就会调到别的方法上。名字出处见 {@link Obf}。 */ private static Object buildPanel(ClassLoader loader, Object skin) throws Exception { - Class skinClass = Class.forName(SKIN, 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); - - 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); - - // 读出来的东西会很长,包进 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; + // 面板内容(增删改查)在 ObjectPanel 里,这里只负责把它建出来。 + return ObjectPanel.build(loader, skin); } /**