From 8df990a058e46f720cb77b8374a8b7a373e756b3 Mon Sep 17 00:00:00 2001 From: tianmo Date: Sat, 17 Oct 2026 10:25:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=8A=20New=2015=20Bone+Slot+Att=20?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E7=A7=BB=E6=A4=8D=E8=BF=9B=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E6=BA=90=E7=A0=81=EF=BC=88=E9=87=8D=E5=90=AF=E5=90=8E=E4=BB=8D?= =?UTF-8?q?=E5=9C=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新类 com.spineview.NewFifteen:按钮 → 编辑器自带名称面板(xl + ASM 子类拿名字) → 按名字匹配附件 → 当前选中骨骼下建 15 根骨骼,每根 1 插槽 1 附件(该名字的副本) 骨骼 x/y 0~500 随机、rotation/scale/shear=0/1/0;插槽 alpha 0.5~1、blend normal/additive 随机; 附件 x/y/rotation=0、scaleX/scaleY=1(并替换掉 ADD_SLOT 带过来的模板附件) 弹窗按 pref 尺寸(>=360x170)撑开、同一次渲染回调里居中 - ObjectPanel.build 里挂一行按钮(复用 ObjectPanel.wire 的点击轮询) - build.sh 的 javac 加 -cp ../tools/asm/asm.jar(NewFifteen 现场生成 xl 子类需要 ASM) 依据:tools/deobf/anchors.tsv + 混淆名对照表.md(全部字节码/实测确认) --- viewpanel/build.sh | 4 +- viewpanel/src/com/spineview/NewFifteen.java | 682 +++++++++++++++++++ viewpanel/src/com/spineview/ObjectPanel.java | 2 + 3 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 viewpanel/src/com/spineview/NewFifteen.java diff --git a/viewpanel/build.sh b/viewpanel/build.sh index c910b65..1611802 100755 --- a/viewpanel/build.sh +++ b/viewpanel/build.sh @@ -20,7 +20,9 @@ mkdir -p build/classes echo "== 编译 Java 侧(javac $("$JAVA_HOME/bin/javac" -version 2>&1))" find src -name '*.class' -delete 2>/dev/null || true -"$JAVA_HOME/bin/javac" --release 8 -encoding UTF-8 -d build/classes $(find src -name '*.java') +# 面板里有一处用了 ASM(NewFifteen 现场生成编辑器对话框子类),编译期也要能看到 asm.jar +"$JAVA_HOME/bin/javac" --release 8 -encoding UTF-8 -cp "../tools/asm/asm.jar" \ + -d build/classes $(find src -name '*.java') # 保名类清单:这套运行时把 libGDX 基类混淆了,但 ui/* 和 editor/* 保名。 # 旧版本的明文载荷里能直接列出这些类名,用它们做静态字段扫描的候选。 diff --git a/viewpanel/src/com/spineview/NewFifteen.java b/viewpanel/src/com/spineview/NewFifteen.java new file mode 100644 index 0000000..77623cf --- /dev/null +++ b/viewpanel/src/com/spineview/NewFifteen.java @@ -0,0 +1,682 @@ +package com.spineview; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Deque; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Random; +import java.util.Set; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +/** + * “New 15 Bone+Slot+Att” 按钮(正式版,重启后仍在)。 + * + * 流程:点一下 → 弹编辑器自带的名称输入面板 → 用输入的名字匹配骨架里的附件 + * → 名字不对/找不到就结束 → 否则在**当前选中骨骼**下建 15 根骨骼, + * 每根 1 个插槽 + 1 个附件(该名字的附件副本)。 + * + * 参数要求(全部按已确认的字段语义写,见 混淆名对照表.md): + * 骨骼:x/y 各 0~500 随机;rotation=0;scaleX=scaleY=1;shearX=shearY=0 + * 插槽:颜色 alpha 0.5~1 随机;blend = normal / additive 随机 + * 附件(挂在插槽上):x=y=rotation=0;scaleX=scaleY=1 + * + * 关键 API(都在 tools/deobf/anchors.tsv 里,附字节码依据): + * UH.D(GU,Gp,Z,fI)GU addSlot ⚠️ 会连模板插槽的附件一起复制 + * UH.D(Gn,GU,Z,Z,fI)Gn addAttachmentCopy(内部 Gn.D(GP) 复制模板附件) + * GP.D(Gp)V addBone + * OG.D(String,fI,GU)String uniqueName + * VZ.D(GP,Z,Z)V refresh + * un.D()/un.B() undoBegin/undoCommit + * Gn.L()V delete + * dt.D(dl)V Stage.addActor + * xl.D(dt) / xl.L(Object) / xl.L(String) 编辑器名称面板:显示 / OK-Cancel 回调 / 拿到名字(抽象) + */ +final class NewFifteen { + private static final int COUNT = 15; + private static final String BUTTON_TEXT = "New 15 Bone+Slot+Att"; + + /** 生成的对话框子类(ASM 现生成:xl 是抽象类,且必须在载荷类加载器那边实现)。 */ + private static Class dialogClass; + private static ClassLoader dialogLoader; + private static boolean installed; + + private NewFifteen() { + } + + /** 由 ObjectPanel.build 调用:往按钮表格里加一行按钮并接上点击。 */ + static void install(final ClassLoader loader, Object skin, Object buttonsTable, + Class tableClass, Class cellClass, Class actorClass) { + if (installed) return; + try { + Class textButton = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.TextButton", true, loader); + Class skinClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.Skin", true, loader); + final Object button = textButton.getConstructor(String.class, skinClass) + .newInstance(BUTTON_TEXT, skin); + Object cell = call(buttonsTable, "D", new Class[] {actorClass}, cellClass, button); + call(cell, "C", new Class[0], cellClass); // Cell.left() + call(buttonsTable, "I", new Class[0], cellClass); // Table.row() + ObjectPanel.wire(loader, button, new Runnable() { + @Override public void run() { + // 面板路由线程里:只负责开线程走流程,别把它堵住(要等用户输入) + Thread thread = new Thread(new Runnable() { + @Override public void run() { + try { + promptThenCreate(loader); + } catch (Throwable error) { + System.err.println("[new15] 流程失败: " + error); + error.printStackTrace(System.err); + } + } + }, "spineview-new15"); + thread.setDaemon(true); + thread.start(); + } + }); + installed = true; + System.err.println("[new15] 按钮已加入: " + BUTTON_TEXT); + } catch (Throwable error) { + System.err.println("[new15] 加按钮失败: " + error); + } + } + + // ==================== 弹面板 → 等名字 → 建对象 ==================== + + private static void promptThenCreate(final ClassLoader loader) throws Exception { + final Object[] holder = new Object[1]; + SceneActions.onEditorThread(loader, new Runnable() { + @Override public void run() { + try { + holder[0] = showPrompt(loader); + } catch (Throwable error) { + System.err.println("[new15] 弹面板失败: " + error); + error.printStackTrace(System.err); + } + } + }); + final Object dialog = holder[0]; + if (dialog == null) { + System.err.println("[new15] 面板没建出来"); + return; + } + String name = waitForName(loader, dialog, 300000L); + if (name == null || name.trim().isEmpty()) { + System.err.println("[new15] 没拿到名字(取消/超时)→ 结束"); + removeQuietly(dialog); + return; + } + final String finalName = name.trim(); + System.err.println("[new15] 输入的名字 = " + finalName); + removeQuietly(dialog); + SceneActions.onEditorThread(loader, new Runnable() { + @Override public void run() { + try { + create(loader, finalName); + } catch (Throwable error) { + System.err.println("[new15] 建对象失败: " + error); + error.printStackTrace(System.err); + } + } + }); + } + + /** 在渲染线程里:建对话框 → 挂进 Stage → 按 pref 撑开 → 居中(都在同一帧里完成,不会先闪别处)。 */ + private static Object showPrompt(ClassLoader loader) throws Exception { + Class xl = Class.forName("xl", true, loader); + Class stageClass = Class.forName("dt", true, loader); + Object stage = global(loader, stageClass); + if (stage == null) throw new IllegalStateException("拿不到 Stage"); + Class dialog = dialogClass(loader); + Object dialogObject = dialog.getConstructor(String.class, String.class) + .newInstance("New Bones + Slots + Attachment", + "Enter an existing attachment name in this skeleton:"); + // ① 挂进 Stage(实测 xl.D(dt) 不会挂;Stage.addActor 用"扫描 getMethods"那个才生效) + addActor(stage, dialogObject); + // ② 走编辑器自己的显示流程(把键盘焦点给名字框) + Method show = null; + for (Method method : xl.getMethods()) { + Class[] types = method.getParameterTypes(); + if (types.length == 1 && types[0] == stageClass) show = method; + } + if (show != null) { + try { + show.setAccessible(true); + show.invoke(dialogObject, stage); + } catch (Throwable error) { + System.err.println("[new15] xl.D(dt) 失败: " + error); + } + } + // ③ 尺寸:默认 150x150 会把正文/按钮裁掉 → 用 pref(下限 360x170) + float prefW = floatOf(dialogObject, "StackMapTable"); // getPrefWidth + float prefH = floatOf(dialogObject, "init"); // getPrefHeight + float minW = floatOf(dialogObject, "LineNumberTable"); // getMinWidth + float minH = floatOf(dialogObject, "String"); // getMinHeight + float width = Math.max(Math.max(prefW, minW), 360f); + float height = Math.max(Math.max(prefH, minH), 170f); + setFloatField(loader, dialogObject, "L", width); // Actor.width + setFloatField(loader, dialogObject, "I", height); // Actor.height + invalidate(loader, dialogObject); + // ④ 居中:setPosition(x,y,align) 在 align=0 时是"以 (x,y) 为中心" + center(stage, dialogObject); + return dialogObject; + } + + /** 轮询用户操作:OK 时 NAME 有值;Cancel 时 DONE=true 且 NAME 为空;窗口被移除时 stage 字段变 null。 */ + private static String waitForName(ClassLoader loader, Object dialog, long timeoutMs) + throws Exception { + Field nameField = dialog.getClass().getField("NAME"); + Field doneField = dialog.getClass().getField("DONE"); + Field stageField = fieldOfType(Class.forName("dl", true, loader), + Class.forName("dt", true, loader)); + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + Object name = nameField.get(dialog); + if (name != null) return (String) name; + if (Boolean.TRUE.equals(doneField.get(dialog))) return null; + if (stageField != null && stageField.get(dialog) == null) return null; + Thread.sleep(120L); + } + return null; + } + + // ==================== 建 15 组对象 ==================== + + private static void create(ClassLoader loader, String name) throws Exception { + Class boneClass = Class.forName("Gp", true, loader); + Class slotClass = Class.forName("GU", true, loader); + Class attClass = Class.forName("Gn", true, loader); + Class hiClass = Class.forName("Hi", true, loader); + Class skeletonClass = Class.forName("GP", true, loader); + Class arrayClass = Class.forName("fI", true, loader); + Class colorClass = Class.forName("com.badlogic.gdx.graphics.Color", true, loader); + Class blendClass = Class.forName("iz", true, loader); + Class uhClass = Class.forName("UH", true, loader); + Class og = Class.forName("OG", true, loader); + Class undoClass = Class.forName("un", true, loader); + Class treeClass = Class.forName("VZ", true, loader); + + Object selected = SceneObjects.selected(loader); + if (selected == null || !boneClass.isInstance(selected)) { + System.err.println("[new15] 当前选中的不是骨骼 → 结束"); + return; + } + Object skeleton = firstFieldOfType(boneClass, selected, skeletonClass); + if (skeleton == null) { + System.err.println("[new15] 拿不到骨架 → 结束"); + return; + } + Object bones = arrayOfType(skeletonClass, skeleton, arrayClass, boneClass); + Object slots = arrayOfType(skeletonClass, skeleton, arrayClass, slotClass); + Object attachments = arrayOfType(skeletonClass, skeleton, arrayClass, attClass); + if (bones == null || slots == null || attachments == null) { + System.err.println("[new15] 骨架的骨骼/插槽/附件表没找齐 → 结束"); + return; + } + // 按名字找附件(优先非 Hi 占位) + Object template = null; + for (int i = 0; i < size(arrayClass, attachments); i++) { + Object candidate = get(arrayClass, attachments, i); + if (candidate == null || !attClass.isInstance(candidate)) continue; + if (!name.equals(SceneObjects.nameOf(candidate))) continue; + if (!hiClass.isInstance(candidate)) { + template = candidate; + break; + } + if (template == null) template = candidate; + } + if (template == null) { + System.err.println("[new15] 骨架里没有名为 \"" + name + "\" 的附件 → 结束"); + return; + } + Object templateSlot = size(arrayClass, slots) > 0 ? get(arrayClass, slots, 0) : null; + + Object uh = staticField(og, uhClass); + if (uh == null) { + System.err.println("[new15] 拿不到编辑器模型助手 UH → 结束"); + return; + } + Method addBone = find(skeletonClass, "D", new Class[] {boneClass}, void.class); + Method addSlot = find(uhClass, "D", new Class[] {slotClass, boneClass, boolean.class, + arrayClass}, slotClass); + Method addAttachment = find(uhClass, "D", new Class[] {attClass, slotClass, boolean.class, + boolean.class, arrayClass}, attClass); + Method nameGen = find(og, "D", new Class[] {String.class, arrayClass, slotClass}, String.class); + Method refresh = find(treeClass, "D", new Class[] {skeletonClass, boolean.class, + boolean.class}, void.class); + Method deleteAttachment = find(attClass, "L", new Class[0], void.class); + Method undoBegin = noArgVoid(undoClass, "D"); + Method undoCommit = noArgVoid(undoClass, "B"); + if (addBone == null || addSlot == null || addAttachment == null || nameGen == null + || undoBegin == null || undoCommit == null) { + System.err.println("[new15] 编辑器 API 没找齐,停止"); + return; + } + Object blendNormal = blendClass.getField("D").get(null); + Object blendAdditive = blendClass.getField("B").get(null); + Constructor boneCtor = boneClass.getConstructor(boneClass, skeletonClass, boneClass); + Object empty = arrayClass.getConstructor().newInstance(); + Random random = new Random(); + int made = 0; + + undoBegin.setAccessible(true); + undoBegin.invoke(null); + for (int i = 0; i < COUNT; i++) { + // ---- 骨骼:x/y 0~500 随机,其余按 0/1 ---- + Object bone = boneCtor.newInstance(selected, skeleton, selected); + set(bone, "D", String.class, nameGen.invoke(null, "bone", bones, null)); + setFloatField(loader, bone, "Z", random.nextFloat() * 500f); // x + setFloatField(loader, bone, "V", random.nextFloat() * 500f); // y + setFloatField(loader, bone, "F", 0f); // rotation + setFloatField(loader, bone, "Code", 1f); // scaleX + setFloatField(loader, bone, "SourceFile", 1f); // scaleY + setFloatField(loader, bone, "StackMapTable", 0f); // shearX + setFloatField(loader, bone, "LineNumberTable", 0f); // shearY + addBone.setAccessible(true); + addBone.invoke(skeleton, bone); + + // ---- 插槽:alpha 0.5~1 随机;blend normal/additive 随机 ---- + Object out = arrayClass.getConstructor().newInstance(); + addSlot.setAccessible(true); + Object slot = addSlot.invoke(uh, templateSlot, bone, Boolean.FALSE, out); + if (slot == null) continue; + // ADD_SLOT 会把模板插槽整个复制,连它挂着的附件一起复制 → 记下来待会删 + Object inherited = readSlotAttachment(slotClass, attClass, slot); + float alpha = 0.5f + random.nextFloat() * 0.5f; + Object color = colorClass.getConstructor(float.class, float.class, float.class, float.class) + .newInstance(1f, 1f, 1f, alpha); + set(slot, "B", colorClass, color); // setup 颜色 + set(slot, "D", colorClass, color); // animate 颜色 + set(slot, "D", blendClass, random.nextBoolean() ? blendNormal : blendAdditive); + + // ---- 附件:复制"输入名字"的附件,变换 0/0/0/1/1 ---- + addAttachment.setAccessible(true); + Object attachment = addAttachment.invoke(uh, template, slot, Boolean.FALSE, Boolean.FALSE, empty); + if (attachment == null) continue; + setFloatField(loader, attachment, "D", 0f); // x + setFloatField(loader, attachment, "B", 0f); // y + setFloatField(loader, attachment, "S", 0f); // rotation + setFloatField(loader, attachment, "L", 1f); // scaleX + setFloatField(loader, attachment, "I", 1f); // scaleY + if (!name.equals(SceneObjects.nameOf(attachment))) { + set(attachment, "D", String.class, name); // 编辑器若自动改名,强制回输入的名字 + } + // 插槽的当前附件 = 我们这份(两个字段都写,setup/animate 都覆盖) + set(slot, "B", attClass, attachment); + set(slot, "D", attClass, attachment); + // 删掉模板插槽带来的那份(保证"一根骨骼 = 1 插槽 + 1 附件") + if (inherited != null && inherited != attachment && deleteAttachment != null) { + deleteAttachment.setAccessible(true); + deleteAttachment.invoke(inherited); + } + made++; + } + undoCommit.setAccessible(true); + undoCommit.invoke(null); + if (refresh != null) { + refresh.setAccessible(true); + refresh.invoke(staticField(og, treeClass), skeleton, Boolean.TRUE, Boolean.TRUE); + } + System.err.println("[new15] 完成:在 " + SceneObjects.nameOf(selected) + " 下建了 " + made + + " 根骨骼(每根 1 插槽 1 附件 \"" + name + "\")"); + } + + // ==================== 对话框子类(ASM) ==================== + + private static synchronized Class dialogClass(final ClassLoader loader) throws Exception { + if (dialogClass != null) return dialogClass; + final String internal = "com/spineview/NewFifteenPrompt"; + ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); + writer.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER, internal, null, "xl", null); + writer.visitField(Opcodes.ACC_PUBLIC, "NAME", "Ljava/lang/String;", null, null).visitEnd(); + writer.visitField(Opcodes.ACC_PUBLIC, "DONE", "Z", null, null).visitEnd(); + // 构造:super(title, body, (Ge) null) + MethodVisitor ctor = writer.visitMethod(Opcodes.ACC_PUBLIC, "", + "(Ljava/lang/String;Ljava/lang/String;)V", null, null); + ctor.visitCode(); + ctor.visitVarInsn(Opcodes.ALOAD, 0); + ctor.visitVarInsn(Opcodes.ALOAD, 1); + ctor.visitVarInsn(Opcodes.ALOAD, 2); + ctor.visitInsn(Opcodes.ACONST_NULL); + ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "xl", "", + "(Ljava/lang/String;Ljava/lang/String;LGe;)V", false); + ctor.visitInsn(Opcodes.RETURN); + ctor.visitMaxs(0, 0); + ctor.visitEnd(); + // 抽象回调:OK 时拿到名字 → 记 NAME,并关窗 + MethodVisitor name = writer.visitMethod(Opcodes.ACC_PUBLIC, "L", "(Ljava/lang/String;)V", + null, null); + name.visitCode(); + name.visitVarInsn(Opcodes.ALOAD, 0); + name.visitVarInsn(Opcodes.ALOAD, 1); + name.visitFieldInsn(Opcodes.PUTFIELD, internal, "NAME", "Ljava/lang/String;"); + name.visitVarInsn(Opcodes.ALOAD, 0); + name.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "dl", "b_", "()Z", false); // remove() + name.visitInsn(Opcodes.POP); + name.visitInsn(Opcodes.RETURN); + name.visitMaxs(0, 0); + name.visitEnd(); + // OK/Cancel 总回调:不管哪个都置 DONE + MethodVisitor done = writer.visitMethod(Opcodes.ACC_PUBLIC, "L", "(Ljava/lang/Object;)V", + null, null); + done.visitCode(); + done.visitVarInsn(Opcodes.ALOAD, 0); + done.visitVarInsn(Opcodes.ALOAD, 1); + done.visitMethodInsn(Opcodes.INVOKESPECIAL, "xl", "L", "(Ljava/lang/Object;)V", false); + done.visitVarInsn(Opcodes.ALOAD, 0); + done.visitInsn(Opcodes.ICONST_1); + done.visitFieldInsn(Opcodes.PUTFIELD, internal, "DONE", "Z"); + done.visitInsn(Opcodes.RETURN); + done.visitMaxs(0, 0); + done.visitEnd(); + writer.visitEnd(); + final byte[] bytes = writer.toByteArray(); + dialogLoader = new ClassLoader(loader) { + @Override protected Class loadClass(String name, boolean resolve) + throws ClassNotFoundException { + if (name.equals("com.spineview.NewFifteenPrompt")) { + Class found = findLoadedClass(name); + if (found == null) found = defineClass(name, bytes, 0, bytes.length); + if (resolve) resolveClass(found); + return found; + } + return super.loadClass(name, resolve); + } + }; + dialogClass = Class.forName("com.spineview.NewFifteenPrompt", true, dialogLoader); + return dialogClass; + } + + // ==================== 小工具 ==================== + + private static Object global(ClassLoader loader, Class type) { + try { + Class og = Class.forName("OG", true, loader); + for (Field field : og.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers()) || field.getType() != type) continue; + field.setAccessible(true); + Object value = field.get(null); + if (value != null) return value; + } + } catch (Throwable ignored) { + // 读不到就返回 null + } + return null; + } + + /** 挂进 Stage:用"扫描 getMethods 里 1 个 Actor 参数 + void"的那个(getMethod 那个实测不生效)。 */ + private static void addActor(Object stage, Object actor) throws Exception { + ClassLoader loader = stage.getClass().getClassLoader(); + Class actorClass = Class.forName("dl", true, loader); + Method add = null; + for (Method method : stage.getClass().getMethods()) { + Class[] types = method.getParameterTypes(); + if (types.length != 1) continue; + if (!actorClass.isAssignableFrom(types[0])) continue; + if (method.getReturnType() != void.class) continue; + add = method; + } + if (add == null) throw new NoSuchMethodException("Stage.addActor"); + add.setAccessible(true); + add.invoke(stage, actor); + } + + private static void center(Object stage, Object dialog) { + try { + float width = floatOf(dialog, "L"); // getWidth + float height = floatOf(dialog, "I"); // getHeight + float cx = floatOf(stage, "D") / 2f; // Stage.getWidth / 2 + float cy = floatOf(stage, "B") / 2f; // Stage.getHeight / 2 + Class actorClass = Class.forName("dl", true, dialog.getClass().getClassLoader()); + Method setPosition = null; + for (Method method : actorClass.getMethods()) { + Class[] types = method.getParameterTypes(); + if (types.length == 3 && types[0] == float.class && types[1] == float.class + && types[2] == int.class) setPosition = method; + } + if (setPosition != null) { + setPosition.setAccessible(true); + // align=0 → 以 (x,y) 为中心 + setPosition.invoke(dialog, Float.valueOf(cx), Float.valueOf(cy), Integer.valueOf(0)); + } + System.err.println("[new15] 弹窗 " + width + "x" + height + " 居中于 (" + cx + "," + cy + ")"); + } catch (Throwable error) { + System.err.println("[new15] 居中失败: " + error); + } + } + + private static void invalidate(ClassLoader loader, Object dialog) { + try { + Class widgetGroup = Class.forName("eX", true, loader); + for (Method method : widgetGroup.getMethods()) { + if (method.getName().equals("b_") && method.getParameterTypes().length == 0 + && method.getReturnType() == void.class) { + method.setAccessible(true); + method.invoke(dialog); + return; + } + } + } catch (Throwable ignored) { + // 没 invalidate 也不致命 + } + } + + private static void removeQuietly(Object dialog) { + try { + Class actorClass = Class.forName("dl", true, dialog.getClass().getClassLoader()); + Method remove = null; + for (Method method : actorClass.getMethods()) { + if (method.getName().equals("b_") && method.getParameterTypes().length == 0 + && method.getReturnType() == boolean.class) remove = method; + } + if (remove != null) { + remove.setAccessible(true); + remove.invoke(dialog); + } + } catch (Throwable ignored) { + // 已经关掉了 + } + } + + private static Object readSlotAttachment(Class slotClass, Class attClass, Object slot) + throws Exception { + for (Class type = slotClass; type != null && type != Object.class; type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.getType() != attClass) continue; + field.setAccessible(true); + Object value = field.get(slot); + if (value != null) return value; + } + } + return null; + } + + private static Field fieldOfType(Class owner, Class type) { + for (Class current = owner; current != null && current != Object.class; + current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.getType() != type) continue; + field.setAccessible(true); + return field; + } + } + return null; + } + + private static Object firstFieldOfType(Class owner, Object target, Class type) { + for (Class current = owner; current != null && current != Object.class; + current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.getType() != type) continue; + field.setAccessible(true); + try { + Object value = field.get(target); + if (value != null) return value; + } catch (Throwable ignored) { + // 下一个 + } + } + } + return null; + } + + private static Object arrayOfType(Class owner, Object target, Class arrayClass, + Class elementType) { + for (Class current = owner; current != null && current != Object.class; + current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || !arrayClass.isAssignableFrom(field.getType())) { + continue; + } + field.setAccessible(true); + Object array; + try { + array = field.get(target); + } catch (Throwable ignored) { + continue; + } + if (array == null) continue; + for (int i = 0; i < size(arrayClass, array); i++) { + Object element = get(arrayClass, array, i); + if (element != null && elementType.isInstance(element)) return array; + } + } + } + return null; + } + + private static void setFloatField(ClassLoader loader, Object target, String name, float value) + throws Exception { + for (Class type = target.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (!field.getName().equals(name) || field.getType() != float.class) continue; + field.setAccessible(true); + field.setFloat(target, value); + return; + } + } + } + + private static void set(Object target, String name, Class type, Object value) throws Exception { + for (Class current = target.getClass(); current != null && current != Object.class; + current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (!field.getName().equals(name) || field.getType() != type) continue; + field.setAccessible(true); + field.set(target, value); + return; + } + } + } + + private static float floatOf(Object target, String name) { + try { + for (Method method : target.getClass().getMethods()) { + if (!method.getName().equals(name)) continue; + if (method.getParameterTypes().length != 0) continue; + if (method.getReturnType() != float.class) continue; + method.setAccessible(true); + return ((Float) method.invoke(target)).floatValue(); + } + } catch (Throwable ignored) { + // 读不到 + } + return -1f; + } + + private static Object call(Object target, String name, Class[] parameterTypes, + Class returnType, Object... args) throws Exception { + for (Class current = target.getClass(); current != null && current != Object.class; + current = current.getSuperclass()) { + for (Method method : current.getDeclaredMethods()) { + if (!method.getName().equals(name)) continue; + if (!Arrays.equals(method.getParameterTypes(), parameterTypes)) continue; + if (returnType != null && method.getReturnType() != returnType) continue; + method.setAccessible(true); + return method.invoke(target, args); + } + } + throw new NoSuchMethodException(target.getClass().getName() + "." + name); + } + + private static Object staticField(Class owner, Class type) { + for (Field field : owner.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers()) || field.getType() != type) continue; + field.setAccessible(true); + try { + Object value = field.get(null); + if (value != null) return value; + } catch (Throwable ignored) { + // 下一个 + } + } + return null; + } + + private static Method find(Class owner, String name, Class[] parameterTypes, + Class returnType) { + for (Class current = owner; current != null && current != Object.class; + current = current.getSuperclass()) { + for (Method method : current.getDeclaredMethods()) { + if (!method.getName().equals(name)) continue; + if (!Arrays.equals(method.getParameterTypes(), parameterTypes)) continue; + if (returnType != null && method.getReturnType() != returnType) continue; + method.setAccessible(true); + return method; + } + } + return null; + } + + private static Method noArgVoid(Class owner, String name) { + for (Method method : owner.getDeclaredMethods()) { + if (!method.getName().equals(name)) continue; + if (method.getParameterTypes().length != 0) continue; + if (method.getReturnType() != void.class) continue; + method.setAccessible(true); + return method; + } + return null; + } + + private static int size(Class arrayClass, Object array) { + if (array == null) return 0; + for (Class current = arrayClass; current != null && current != Object.class; + current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (field.getType() != int.class) continue; + field.setAccessible(true); + try { + return field.getInt(array); + } catch (Throwable ignored) { + // 下一个 + } + } + } + return 0; + } + + private static Object get(Class arrayClass, Object array, int index) { + for (Method method : arrayClass.getMethods()) { + if (!method.getName().equals("D")) continue; + Class[] types = method.getParameterTypes(); + if (types.length != 1 || types[0] != int.class) continue; + if (method.getReturnType() != Object.class) continue; + try { + method.setAccessible(true); + return method.invoke(array, Integer.valueOf(index)); + } catch (Throwable ignored) { + // 下一个 + } + } + return null; + } +} diff --git a/viewpanel/src/com/spineview/ObjectPanel.java b/viewpanel/src/com/spineview/ObjectPanel.java index 5a7ad23..33a8472 100644 --- a/viewpanel/src/com/spineview/ObjectPanel.java +++ b/viewpanel/src/com/spineview/ObjectPanel.java @@ -136,6 +136,8 @@ public final class ObjectPanel { // 每个按钮一行:上段是**一条整带**(不要左右两列),整体读起来才是"上/中/下" call(buttons, Obf.TABLE_ROW, new Class[0], cellClass); } + // 新按钮:点一下弹名称面板 → 按名字匹配附件 → 在当前选中骨骼下建 15 根骨骼(每根 1 插槽 1 附件) + NewFifteen.install(loader, skin, buttons, tableClass, cellClass, actorClass); call(buttons, Obf.TABLE_ROW, new Class[0], cellClass); Object nameCell = call(buttons, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, state.nameField); layoutCell(nameCell, Obf.CELL_LEFT);