diff --git a/tools/live/src/AddButton10.java b/tools/live/src/AddButton10.java new file mode 100644 index 0000000..d385032 --- /dev/null +++ b/tools/live/src/AddButton10.java @@ -0,0 +1,803 @@ +import java.lang.instrument.Instrumentation; +import java.lang.reflect.*; +import java.net.URLClassLoader; +import java.util.*; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +/** + * 第三版(点按钮没反应的原因已查明并修掉): + * 1) 弹窗:编辑器自己的 `xl`(标题/正文/名字框/OK/Cancel)× ASM 生成的子类; + * ⚠️ 实测:只调 `xl.D(dt)` 不会把窗口挂进 Stage(parent 一直 null,屏幕上什么都不出现), + * 必须**显式 Stage.addActor(dialog)**(`dt.D(Ldl;)V`,字节码就是 root.addActor), + * 再自己摆位置。中文在编辑器字体里是乱码,所以文字一律用英文。 + * 2) 清掉上一版/实验残留的对话框(类名 com.spineview.live.AttNamePrompt 的那些)。 + * 3) 名字捕获改成**每个对话框实例自己的字段**(不再用共享 static,避免多个等待线程互相串台)。 + * 4) 建对象部分与第二版相同(已干跑验证):当前选中骨骼下 15 根骨骼 + 1 插槽 + 1 附件。 + */ +public final class AddButton10 { + static Instrumentation INST; + static ClassLoader L; + + static Class ARRAY, BONE, SLOT, ATT, GO, HI, SKELETON_C, UH_C, COLOR_C, IZ_C, XL, STAGE_C, + ACTOR_C, GROUP_C, CELL_C, DIALOG_C, eX; + static Object UH_INSTANCE, EDITOR, TREE_VIEW, STAGE, IZ_NORMAL, IZ_ADDITIVE; + static Method ADD_SLOT, ADD_ATT, ADD_BONE, NAME_GEN, REFRESH, REFRESH2, UNDO_BEGIN, UNDO_COMMIT, + ON_RENDER, ADD_ACTOR, SHOW; + static Field DIALOG_PARENT_FIELD, DIALOG_NAME_FIELD, DIALOG_DONE_FIELD, + DIALOG_STAGE_FIELD; + static final int COUNT = 15; + + public static void agentmain(String args, Instrumentation inst) { + INST = inst; + try { + run(); + } catch (Throwable error) { + log("失败: " + error); + error.printStackTrace(System.err); + } + } + + static void log(String m) { System.err.println("[btn10] " + m); } + + static void run() throws Exception { + for (Class c : INST.getAllLoadedClasses()) { + if (c.getClassLoader() instanceof URLClassLoader) { L = c.getClassLoader(); break; } + } + if (L == null) { log("找不到载荷类加载器"); return; } + ARRAY = forName("fI"); + BONE = forName("Gp"); + SLOT = forName("GU"); + ATT = forName("Gn"); + GO = forName("GO"); + HI = forName("Hi"); + SKELETON_C = forName("GP"); + UH_C = forName("UH"); + COLOR_C = forName("com.badlogic.gdx.graphics.Color"); + IZ_C = forName("iz"); + XL = forName("xl"); + STAGE_C = forName("dt"); + ACTOR_C = forName("dl"); + GROUP_C = forName("do"); + CELL_C = forName("dR"); + eX = forName("eX"); + Class og = forName("OG"); + Class undo = forName("un"); + Class vz = forName("VZ"); + UH_INSTANCE = staticFieldOfType(og, UH_C); + EDITOR = staticFieldOfType(og, forName("com.esotericsoftware.spine.editor.Editor")); + TREE_VIEW = staticFieldOfType(og, vz); + STAGE = staticFieldOfType(og, STAGE_C); + ADD_SLOT = find(UH_C, "D", new Class[] {SLOT, BONE, boolean.class, ARRAY}, SLOT); + ADD_ATT = find(UH_C, "D", new Class[] {ATT, SLOT, boolean.class, boolean.class, ARRAY}, ATT); + ADD_BONE = find(SKELETON_C, "D", new Class[] {BONE}, void.class); + NAME_GEN = find(og, "D", new Class[] {String.class, ARRAY, SLOT}, String.class); + REFRESH = find(vz, "D", new Class[] {SKELETON_C, boolean.class, boolean.class}, void.class); + if (REFRESH == null && EDITOR != null) { + REFRESH2 = find(vz, "D", new Class[] {EDITOR.getClass(), SKELETON_C, boolean.class, + boolean.class}, void.class); + } + for (Method m : undo.getDeclaredMethods()) { + if (m.getParameterTypes().length != 0 || m.getReturnType() != void.class) continue; + if (m.getName().equals("D")) UNDO_BEGIN = m; + if (m.getName().equals("B")) UNDO_COMMIT = m; + } + for (Method m : og.getDeclaredMethods()) { + Class[] t = m.getParameterTypes(); + if (m.getName().equals("B") && t.length == 1 && t[0] == Runnable.class) ON_RENDER = m; + } + ON_RENDER.setAccessible(true); + // ⚠️ 实测:`STAGE_C.getMethod("D", ACTOR_C)` 拿到的那个调用后 parent 永远是 null, + // 而"扫描 stage.getClass().getMethods() 里 1 个 Actor 参数 + void"的这个是真能挂上的。 + for (Method m : STAGE.getClass().getMethods()) { + Class[] t = m.getParameterTypes(); + if (t.length != 1) continue; + if (!ACTOR_C.isAssignableFrom(t[0])) continue; + if (m.getReturnType() != void.class) continue; + ADD_ACTOR = m; + ADD_ACTOR.setAccessible(true); + } + log("addActor = " + ADD_ACTOR); + for (Method m : XL.getMethods()) { + Class[] t = m.getParameterTypes(); + if (t.length == 1 && t[0] == STAGE_C) SHOW = m; + } + IZ_NORMAL = IZ_C.getField("D").get(null); + IZ_ADDITIVE = IZ_C.getField("B").get(null); + DIALOG_C = defineDialogClass(); + DIALOG_NAME_FIELD = DIALOG_C.getField("NAME"); + DIALOG_DONE_FIELD = DIALOG_C.getField("DONE"); + DIALOG_PARENT_FIELD = parentFieldOf(ACTOR_C); + DIALOG_STAGE_FIELD = stageFieldOf(ACTOR_C); + if (ADD_SLOT == null || ADD_ATT == null || ADD_BONE == null || NAME_GEN == null + || UNDO_BEGIN == null || UNDO_COMMIT == null) { + log("API 没找齐,停"); + return; + } + onRender(new Runnable() { + @Override public void run() { + cleanupOrphans(); + } + }); + installButton(); + } + + /** + * 清掉前几版留下的对话框(含还留在屏幕上的实验弹窗)。 + * 遍历方式照抄面板自己的 fillDialog:从 Stage 出发按**字段**广搜(按 actor 树走会漏,实测过)。 + */ + static void cleanupOrphans() { + try { + Class actorC = ACTOR_C; + Method remove = null; + for (Method m : actorC.getMethods()) { + if (m.getName().equals("b_") && m.getParameterTypes().length == 0 + && m.getReturnType() == boolean.class) remove = m; + } + remove.setAccessible(true); + Deque queue = new ArrayDeque(); + Set seen = Collections.newSetFromMap(new IdentityHashMap()); + queue.add(STAGE); + int visited = 0, removed = 0; + while (!queue.isEmpty() && visited < 200000) { + Object a = queue.poll(); + if (a == null || !seen.add(a)) continue; + visited++; + String cn = a.getClass().getName(); + if (cn.startsWith("com.spineview.live.")) { + try { remove.invoke(a); removed++; } catch (Throwable ignored) { } + continue; + } + try { + if (cn.startsWith("java.") || cn.startsWith("javax.") || cn.startsWith("sun.")) continue; + if (a instanceof Class || a instanceof String || a instanceof Number) continue; + if (a.getClass().isArray()) { + if (!a.getClass().getComponentType().isPrimitive()) { + Object[] arr = (Object[]) a; + for (Object item : arr) if (item != null) queue.add(item); + } + continue; + } + for (Class c = a.getClass(); c != null && c != Object.class; c = c.getSuperclass()) { + for (Field f : c.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers()) || f.getType().isPrimitive()) continue; + try { + f.setAccessible(true); + Object v = f.get(a); + if (v != null) queue.add(v); + } catch (Throwable ignored) { } + } + } + } catch (Throwable t) { + log("遍历 " + cn + " 出错: " + t); + } + } + log("清理旧对话框 " + removed + " 个(遍历 " + visited + " 个对象)"); + } catch (Throwable e) { + log("清理旧对话框失败: " + e); + } + } + + /** + * 删掉上一版跑出来那批骨骼。 + * ⚠️ 判据必须精确,上一版我把"插槽有两个 Gn 字段"当判据,结果误伤了你原有的骨骼(已撤销恢复)。 + * 这次的判据:**骨架附件表 GP.L 里,同一个插槽被登记了 >=2 个附件** —— 只有我上一版那批是这样 + * (ADD_SLOT 带了模板插槽的附件 + 我又加了一个),原有插槽每个只有 1 个。 + */ + static void cleanupLastBatch() { + try { + java.util.List all = (java.util.List) Class.forName("com.spineview.SceneObjects", true, L) + .getMethod("listAllNodes", ClassLoader.class).invoke(null, L); + java.util.List bones = new ArrayList(); + for (Object o : all) if (o != null && BONE.isInstance(o)) bones.add(o); + if (bones.isEmpty()) return; + Object skeleton = firstFieldOfType(BONE, bones.get(0), SKELETON_C); + if (skeleton == null) return; + Object slotsArr = arrayOfType(SKELETON_C, skeleton, SLOT); + Object attsArr = arrayOfType(SKELETON_C, skeleton, ATT); + // 统计每个插槽登记了几个附件 + Map perSlot = new IdentityHashMap(); + for (int i = 0; i < size(attsArr); i++) { + Object a = get(attsArr, i); + if (a == null) continue; + Object slot = firstFieldOfType(ATT, a, SLOT); + if (slot == null) continue; + Integer c = perSlot.get(slot); + perSlot.put(slot, Integer.valueOf(c == null ? 1 : c.intValue() + 1)); + } + Method del = Class.forName("com.spineview.SceneActions", true, L) + .getMethod("delete", ClassLoader.class, Object.class); + del.setAccessible(true); + int killed = 0; + Set killedSet = Collections.newSetFromMap(new IdentityHashMap()); + for (Map.Entry e : perSlot.entrySet()) { + if (e.getValue().intValue() < 2) continue; // 正常插槽只有 1 个附件 + Object bone = firstFieldOfType(SLOT, e.getKey(), BONE); + if (bone == null || !killedSet.add(bone)) continue; + del.invoke(null, L, bone); + killed++; + } + log("清理上一版残留骨骼 " + killed + " 根(判据:同插槽登记了 2 个以上附件)"); + } catch (Throwable e) { + log("清理上一版失败: " + e); + } + } + + static void installButton() throws Exception { + Class panel = Class.forName("com.spineview.ObjectPanel"); + Field lastF = panel.getDeclaredField("LAST_STATE"); + lastF.setAccessible(true); + Object state = lastF.get(null); + if (state == null) { log("面板还没建"); return; } + Object buttons = stateField(state, "buttons"); + Object skin = stateField(state, "skin"); + // 前几版一共往按钮区塞了 3 个按钮:这里把"第 11 个 TextButton 之后"的全摘掉,只留原来的 10 个 + // ⚠️ Actor.remove() 是 `b_()Z`;`eX.b_()V` 是 invalidate()。按名字随便取会拿到 void 那个 → 删不掉。 + Method remove = null; + for (Method m : ACTOR_C.getMethods()) { + if (m.getName().equals("b_") && m.getParameterTypes().length == 0 + && m.getReturnType() == boolean.class) remove = m; + } + remove.setAccessible(true); + Object kids = childrenOf(buttons); + int removedOld = 0; + for (int i = size(kids) - 1; i >= 0; i--) { + Object child = get(kids, i); + if (child == null || !child.getClass().getName().contains("TextButton")) continue; + if (seenButtonsFromEnd(kids, i) > 10) { + remove.invoke(child); + removedOld++; + } + } + log("移除前几版按钮 " + removedOld + " 个"); + Class textButton = forName("com.badlogic.gdx.scenes.scene2d.ui.TextButton"); + Class skinClass = forName("com.badlogic.gdx.scenes.scene2d.ui.Skin"); + Object button = textButton.getConstructor(String.class, skinClass) + .newInstance("New 15 Bone+Slot+Att", skin); + Method add = null, row = null, left = null; + for (Method m : buttons.getClass().getMethods()) { + Class[] t = m.getParameterTypes(); + if (m.getName().equals("D") && t.length == 1 && t[0] == ACTOR_C + && m.getReturnType() == CELL_C) add = m; + if (m.getName().equals("I") && t.length == 0 && m.getReturnType() == CELL_C) row = m; + } + for (Method m : CELL_C.getMethods()) { + if (m.getName().equals("C") && m.getParameterTypes().length == 0 + && m.getReturnType() == CELL_C) left = m; + } + if (add == null) { log("找不到 Table.add(Actor)Cell"); return; } + add.setAccessible(true); + Object cell = add.invoke(buttons, button); + if (left != null) { left.setAccessible(true); left.invoke(cell); } + if (row != null) { row.setAccessible(true); row.invoke(buttons); } + for (Method m : buttons.getClass().getMethods()) { + if (m.getName().equals("b_") && m.getParameterTypes().length == 0 + && m.getReturnType() == void.class) { m.setAccessible(true); m.invoke(buttons); break; } + } + Method wire = panel.getDeclaredMethod("wire", ClassLoader.class, Object.class, Runnable.class); + wire.setAccessible(true); + wire.invoke(null, L, button, new Runnable() { + @Override public void run() { + Thread t = new Thread(new Runnable() { + @Override public void run() { + try { promptAndCreate(); } catch (Throwable e) { + log("流程失败: " + e); + e.printStackTrace(System.err); + } + } + }, "spineview-prompt15-v3"); + t.setDaemon(true); + t.start(); + } + }); + log("按钮已装好(第 " + size(childrenOf(buttons)) + " 行)并接上点击"); + Thread verify = new Thread(new Runnable() { + @Override public void run() { + try { + Thread.sleep(300L); + Class.forName("AddButton10").getDeclaredMethod("promptAndCreate").invoke(null); + } catch (Throwable e) { log("验证弹窗失败: " + e); } + } + }, "spineview-prompt15-verify"); + verify.setDaemon(true); + verify.start(); + log("已触发一次弹窗验证(居中效果;Cancel 不会有任何改动)"); + } + + /** 从前往后数,这个下标是第几个 TextButton(1 起) */ + static int seenButtonsFromEnd(Object kids, int index) { + int n = 0; + for (int i = 0; i <= index; i++) { + Object c = get(kids, i); + if (c != null && c.getClass().getName().contains("TextButton")) n++; + } + return n; + } + + static Object stateField(Object state, String name) throws Exception { + Field f = state.getClass().getDeclaredField(name); + f.setAccessible(true); + return f.get(state); + } + + /** 弹窗 → 等名字 → 建对象 */ + static void promptAndCreate() throws Exception { + final Object[] holder = new Object[1]; + onRender(new Runnable() { + @Override public void run() { + try { + holder[0] = showPrompt(); + } catch (Throwable e) { + log("弹面板失败: " + e); + e.printStackTrace(System.err); + } + } + }); + Object dialog = holder[0]; + if (dialog == null) { log("弹窗没建出来 → 结束"); return; } + log("弹窗已显示:parent=" + (parentOf(dialog) != null) + " 位置=" + geom(dialog) + + " Stage=" + fl(STAGE, "D") + "x" + fl(STAGE, "B") + + " 在 Stage 孩子里=" + (indexOf(childrenOf(rootGroup()), dialog) >= 0)); + String name = waitForName(dialog, 300000L); + if (name == null || name.trim().isEmpty()) { + log("没拿到名字(取消/超时)→ 结束"); + removeQuietly(dialog); + return; + } + final String finalName = name.trim(); + log("输入的名字 = " + finalName); + removeQuietly(dialog); + onRender(new Runnable() { + @Override public void run() { + try { create15(finalName); } catch (Throwable e) { + log("建对象失败: " + e); + e.printStackTrace(System.err); + } + } + }); + } + + static Object showPrompt() throws Exception { + Object dialog = DIALOG_C.getConstructor(String.class, String.class) + .newInstance("New Bones + Slots + Attachment", + "Enter an existing attachment name in this skeleton:"); + // ① 先挂进 Stage(实测这一步必须用扫描出来的那个 addActor) + ADD_ACTOR.invoke(STAGE, dialog); + // ② 再走编辑器自己的显示流程(它会把键盘焦点给输入框) + try { SHOW.invoke(dialog, STAGE); } catch (Throwable e) { log("xl.D(dt) 失败: " + e); } + if (parentOf(dialog) == null) ADD_ACTOR.invoke(STAGE, dialog); + // ③ 尺寸:默认窗口太小,正文/按钮会被裁。用编辑器自己的 pref 尺寸(拿不到就给下限) + float prefW = fl(dialog, "StackMapTable"); // WidgetGroup.getPrefWidth + float prefH = fl(dialog, "init"); // WidgetGroup.getPrefHeight + float minW = fl(dialog, "LineNumberTable"); // getMinWidth + float minH = fl(dialog, "String"); // getMinHeight + float w = Math.max(Math.max(prefW, minW), 360f); + float h = Math.max(Math.max(prefH, minH), 170f); + setFloatField(dialog, "L", w); // Actor.width (dl.L:F) + setFloatField(dialog, "I", h); // Actor.height (dl.I:F) + for (Method m : eX.getMethods()) { // invalidate() 让内容按新尺寸重新布局 + if (m.getName().equals("b_") && m.getParameterTypes().length == 0 + && m.getReturnType() == void.class) { m.setAccessible(true); m.invoke(dialog); break; } + } + log("窗口尺寸: pref=" + prefW + "x" + prefH + " min=" + minW + "x" + minH + + " -> 设为 " + w + "x" + h); + // ④ 立刻摆到正中心(同一次回调里做完,不会再从左上角/左下角闪一下) + center(dialog); + return dialog; + } + + /** 等用户按 OK / Cancel:OK 时 L(String) 已把名字写进 NAME;Cancel 时 NAME 仍是 null */ + static String waitForName(Object dialog, long timeoutMs) throws Exception { + long end = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < end) { + Object done = DIALOG_DONE_FIELD.get(dialog); + Object name = DIALOG_NAME_FIELD.get(dialog); + if (name != null) return (String) name; + if (Boolean.TRUE.equals(done)) return null; // 点了 Cancel + if (stageField(dialog) == null) return null; // 窗口被关掉/移除 + Thread.sleep(120L); + } + return null; + } + + /** 读对话框自己的 stage 字段(dl.D:Ldt; = Actor.stage;remove() 之后会变 null) */ + static Object stageField(Object actor) { + if (DIALOG_STAGE_FIELD == null) return STAGE; + try { return DIALOG_STAGE_FIELD.get(actor); } catch (Throwable e) { return STAGE; } + } + + /** 按窗口真实尺寸把对话框摆到正中央(用 Actor.setPosition(float,float,int) 方法,不猜字段名) */ + static void center(Object dialog) { + try { + float w = fl(dialog, "L"), h = fl(dialog, "I"); + if (w <= 0f || w > 3000f) w = 300f; + if (h <= 0f || h > 3000f) h = 180f; + // setPosition(x,y,align) 在 align=0 时是"以 (x,y) 为中心",所以直接给 Stage 中心 + float cx = fl(STAGE, "D") / 2f; + float cy = fl(STAGE, "B") / 2f; + Method setPos = null; + for (Method m : ACTOR_C.getMethods()) { + Class[] t = m.getParameterTypes(); + if (t.length == 3 && t[0] == float.class && t[1] == float.class && t[2] == int.class) setPos = m; + } + if (setPos == null) { + for (Method m : ACTOR_C.getMethods()) { + Class[] t = m.getParameterTypes(); + if (t.length == 2 && t[0] == float.class && t[1] == float.class + && m.getReturnType() == void.class) setPos = m; + } + } + if (setPos == null) { log("找不到 setPosition"); return; } + setPos.setAccessible(true); + if (setPos.getParameterTypes().length == 3) { + setPos.invoke(dialog, Float.valueOf(cx), Float.valueOf(cy), Integer.valueOf(0)); + } else { + setPos.invoke(dialog, Float.valueOf(cx), Float.valueOf(cy)); + } + log("已居中(" + setPos.getName() + "):窗口 " + w + "x" + h + " -> (" + cx + "," + cy + ")" + + " 实际位置=" + geom(dialog)); + } catch (Throwable e) { + log("居中失败: " + e); + } + } + + static Object rootGroup() { return firstFieldOfType(STAGE_C, STAGE, GROUP_C); } + + static void removeQuietly(Object dialog) { + try { + Method rm = dialog.getClass().getMethod("b_"); + rm.setAccessible(true); + rm.invoke(dialog); + } catch (Throwable ignored) { } + } + + /** 生成 xl 子类:OK 时把名字写进**自己的** NAME 字段并关窗 */ + static Class defineDialogClass() throws Exception { + final String internal = "com/spineview/live/AttPrompt8"; + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER, internal, null, "xl", null); + cw.visitField(Opcodes.ACC_PUBLIC, "NAME", "Ljava/lang/String;", null, null).visitEnd(); + cw.visitField(Opcodes.ACC_PUBLIC, "DONE", "Z", null, null).visitEnd(); + MethodVisitor c = cw.visitMethod(Opcodes.ACC_PUBLIC, "", + "(Ljava/lang/String;Ljava/lang/String;)V", null, null); + c.visitCode(); + c.visitVarInsn(Opcodes.ALOAD, 0); + c.visitVarInsn(Opcodes.ALOAD, 1); + c.visitVarInsn(Opcodes.ALOAD, 2); + c.visitInsn(Opcodes.ACONST_NULL); + c.visitMethodInsn(Opcodes.INVOKESPECIAL, "xl", "", + "(Ljava/lang/String;Ljava/lang/String;LGe;)V", false); + c.visitInsn(Opcodes.RETURN); + c.visitMaxs(0, 0); + c.visitEnd(); + MethodVisitor h = cw.visitMethod(Opcodes.ACC_PUBLIC, "L", "(Ljava/lang/String;)V", null, null); + h.visitCode(); + h.visitVarInsn(Opcodes.ALOAD, 0); + h.visitVarInsn(Opcodes.ALOAD, 1); + h.visitFieldInsn(Opcodes.PUTFIELD, internal, "NAME", "Ljava/lang/String;"); + h.visitVarInsn(Opcodes.ALOAD, 0); + h.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "dl", "b_", "()Z", false); + h.visitInsn(Opcodes.POP); + h.visitInsn(Opcodes.RETURN); + h.visitMaxs(0, 0); + h.visitEnd(); + // 覆盖 xl.L(Object)(OK 就是 Boolean.TRUE、Cancel 就是 FALSE):不管哪种,跑完就置 DONE + MethodVisitor d = cw.visitMethod(Opcodes.ACC_PUBLIC, "L", "(Ljava/lang/Object;)V", null, null); + d.visitCode(); + d.visitVarInsn(Opcodes.ALOAD, 0); + d.visitVarInsn(Opcodes.ALOAD, 1); + d.visitMethodInsn(Opcodes.INVOKESPECIAL, "xl", "L", "(Ljava/lang/Object;)V", false); + d.visitVarInsn(Opcodes.ALOAD, 0); + d.visitInsn(Opcodes.ICONST_1); + d.visitFieldInsn(Opcodes.PUTFIELD, internal, "DONE", "Z"); + d.visitInsn(Opcodes.RETURN); + d.visitMaxs(0, 0); + d.visitEnd(); + cw.visitEnd(); + final byte[] bytes = cw.toByteArray(); + ClassLoader child = new ClassLoader(L) { + @Override protected Class loadClass(String name, boolean resolve) + throws ClassNotFoundException { + if (name.equals("com.spineview.live.AttPrompt8")) { + 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); + } + }; + return Class.forName("com.spineview.live.AttPrompt8", true, child); + } + + // ==================== 建 15 根骨骼 + 插槽 + 附件 ==================== + + static void create15(String name) throws Exception { + Object selected = Class.forName("com.spineview.SceneObjects", true, L) + .getMethod("selected", ClassLoader.class).invoke(null, L); + if (selected == null) { log("当前没有选中对象 → 结束"); return; } + if (!BONE.isInstance(selected)) { + log("当前选中的不是骨骼(" + selected.getClass().getName() + ")→ 结束"); + return; + } + Object parentBone = selected; + Object skeleton = firstFieldOfType(BONE, parentBone, SKELETON_C); + Object bones = arrayOfType(SKELETON_C, skeleton, BONE); + Object slots = arrayOfType(SKELETON_C, skeleton, SLOT); + Object attachments = arrayOfType(SKELETON_C, skeleton, ATT); + if (bones == null || slots == null || attachments == null) { + log("骨架数组没找齐 → 结束"); + return; + } + Object templateSlot = size(slots) > 0 ? get(slots, 0) : null; + Object template = null; + for (int i = 0; i < size(attachments); i++) { + Object a = get(attachments, i); + if (a == null || !ATT.isInstance(a)) continue; + if (!name.equals(nameOf(a))) continue; + if (!HI.isInstance(a)) { template = a; break; } + if (template == null) template = a; + } + if (template == null) { + log("骨架里没有名为 \"" + name + "\" 的附件 → 结束"); + return; + } + log("模板附件 = " + nameOf(template) + " (" + template.getClass().getName() + + ") 模板插槽 = " + nameOf(templateSlot)); + Random random = new Random(); + Object empty = ARRAY.getConstructor().newInstance(); + UNDO_BEGIN.setAccessible(true); + ADD_BONE.setAccessible(true); + UNDO_BEGIN.invoke(null); + Constructor boneCtor = BONE.getConstructor(BONE, SKELETON_C, BONE); + int made = 0, replaced = 0; + for (int i = 0; i < COUNT; i++) { + Object bone = boneCtor.newInstance(parentBone, skeleton, parentBone); + set(bone, "D", String.class, NAME_GEN.invoke(null, "bone", bones, null)); + setFloatField(bone, "Z", random.nextFloat() * 500f); // x + setFloatField(bone, "V", random.nextFloat() * 500f); // y + setFloatField(bone, "F", 0f); // rotation + setFloatField(bone, "Code", 1f); // scaleX + setFloatField(bone, "SourceFile", 1f); // scaleY + setFloatField(bone, "StackMapTable", 0f); // shearX + setFloatField(bone, "LineNumberTable", 0f); // shearY + ADD_BONE.invoke(skeleton, bone); + Object out = ARRAY.getConstructor().newInstance(); + Object slot = ADD_SLOT.invoke(UH_INSTANCE, templateSlot, bone, Boolean.FALSE, out); + if (slot == null) { log("第 " + i + " 个插槽没建出来"); continue; } + // ⚠️ ADD_SLOT 会把"模板插槽"整个复制一份 —— 包括模板插槽自己挂着的那个附件 + // (实测:新插槽上挂的是 mega_dg4 的副本,而不是我们要的"输入名字"的附件) + Object inherited = readSlotAttachment(slot); + float alpha = 0.5f + random.nextFloat() * 0.5f; + Object color = COLOR_C.getConstructor(float.class, float.class, float.class, float.class) + .newInstance(1f, 1f, 1f, alpha); + set(slot, "B", COLOR_C, color); + set(slot, "D", COLOR_C, color); + set(slot, "D", IZ_C, random.nextBoolean() ? IZ_NORMAL : IZ_ADDITIVE); + Object att = ADD_ATT.invoke(UH_INSTANCE, template, slot, Boolean.FALSE, Boolean.FALSE, empty); + if (att == null) { log("第 " + i + " 个附件没建出来"); continue; } + setFloatField(att, "D", 0f); + setFloatField(att, "B", 0f); + setFloatField(att, "S", 0f); + setFloatField(att, "L", 1f); + setFloatField(att, "I", 1f); + String before = nameOf(att); + if (!name.equals(before)) { + set(att, "D", String.class, name); + log(" 第 " + i + " 个附件名被编辑器改成过 \"" + before + "\",已改回"); + } + // 把插槽的当前附件换成**我们这份**(两个字段都写:setup=B / animate=D) + set(slot, "B", ATT, att); + set(slot, "D", ATT, att); + // 删掉模板插槽带来的那份(保证"一根骨骼 = 1 插槽 + 1 附件") + if (inherited != null && inherited != att) { + deleteAttachment(inherited); + replaced++; + } + made++; + } + UNDO_COMMIT.setAccessible(true); + UNDO_COMMIT.invoke(null); + if (REFRESH != null) { + REFRESH.setAccessible(true); + REFRESH.invoke(TREE_VIEW, skeleton, Boolean.TRUE, Boolean.TRUE); + } else if (REFRESH2 != null) { + REFRESH2.setAccessible(true); + REFRESH2.invoke(TREE_VIEW, EDITOR, skeleton, Boolean.TRUE, Boolean.TRUE); + } + log("完成:在 " + nameOf(parentBone) + " 下建了 " + made + " 根骨骼;每根 1 插槽 1 附件 \"" + name + + "\"(替换掉模板附件 " + replaced + " 个)"); + } + + // ==================== 工具 ==================== + + /** 读插槽当前挂的附件(GU 上两个 Gn 字段随便哪个都指向同一份) */ + static Object readSlotAttachment(Object slot) { + for (Class c = slot.getClass(); c != null && c != Object.class; c = c.getSuperclass()) { + for (Field f : c.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers()) || f.getType() != ATT) continue; + f.setAccessible(true); + try { Object v = f.get(slot); if (v != null) return v; } catch (Throwable ignored) { } + } + } + return null; + } + + /** Gn.L()V = 编辑器自己的"删掉这个附件"(字节码:skeleton.removeAttachment(this,true,true)) */ + static void deleteAttachment(Object att) { + try { + Method del = find(ATT, "L", new Class[0], void.class); + del.setAccessible(true); + del.invoke(att); + } catch (Throwable e) { + log("删除模板附件失败: " + e); + } + } + + static void onRender(Runnable body) throws Exception { ON_RENDER.invoke(null, body); } + + static Class forName(String n) throws ClassNotFoundException { return Class.forName(n, true, L); } + + static String nameOf(Object o) { + try { + return (String) Class.forName("com.spineview.SceneObjects", true, L) + .getMethod("nameOf", Object.class).invoke(null, o); + } catch (Throwable t) { return "?"; } + } + + static Field parentFieldOf(Class actorClass) { + for (Field f : actorClass.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers())) continue; + if (f.getType() == GROUP_C) { f.setAccessible(true); return f; } + } + return null; + } + + static Field stageFieldOf(Class actorClass) { + for (Field f : actorClass.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers())) continue; + if (f.getType() == STAGE_C) { f.setAccessible(true); return f; } + } + return null; + } + + static Object parentOf(Object actor) { + try { return DIALOG_PARENT_FIELD == null ? null : DIALOG_PARENT_FIELD.get(actor); } + catch (Throwable e) { return null; } + } + + static Object staticFieldOfType(Class owner, Class type) { + for (Field f : owner.getDeclaredFields()) { + if (!Modifier.isStatic(f.getModifiers())) continue; + if (f.getType() != type) continue; + f.setAccessible(true); + try { Object v = f.get(null); if (v != null) return v; } catch (Throwable ignored) { } + } + return null; + } + + static Object firstFieldOfType(Class owner, Object target, Class type) { + if (target == null) return null; + for (Class c = owner; c != null && c != Object.class; c = c.getSuperclass()) { + for (Field f : c.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers()) || f.getType() != type) continue; + f.setAccessible(true); + try { Object v = f.get(target); if (v != null) return v; } catch (Throwable ignored) { } + } + } + return null; + } + + static Object arrayOfType(Class owner, Object target, Class elem) { + for (Class c = owner; c != null && c != Object.class; c = c.getSuperclass()) { + for (Field f : c.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers())) continue; + if (!ARRAY.isAssignableFrom(f.getType())) continue; + f.setAccessible(true); + Object v; + try { v = f.get(target); } catch (Throwable ignored) { continue; } + if (v == null) continue; + Class et = elemClass(v); + if (et != null && elem.isAssignableFrom(et)) return v; + } + } + return null; + } + + static Class elemClass(Object array) { + for (int i = 0; i < size(array); i++) { + Object o = get(array, i); + if (o != null) return o.getClass(); + } + return null; + } + + static Object childrenOf(Object group) { + if (group == null) return null; + for (Field f : GROUP_C.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers())) continue; + if (!ARRAY.isAssignableFrom(f.getType())) continue; + f.setAccessible(true); + try { return f.get(group); } catch (Throwable ignored) { } + } + return null; + } + + static int indexOf(Object array, Object target) { + for (int i = 0; i < size(array); i++) if (get(array, i) == target) return i; + return -1; + } + + static void set(Object target, String name, Class type, Object value) throws Exception { + for (Class c = target.getClass(); c != null && c != Object.class; c = c.getSuperclass()) { + for (Field f : c.getDeclaredFields()) { + if (!f.getName().equals(name) || f.getType() != type) continue; + f.setAccessible(true); + f.set(target, value); + return; + } + } + } + + static void setFloatField(Object target, String name, float value) { + try { + set(target, name, float.class, Float.valueOf(value)); + } catch (Throwable ignored) { + // 该类型没这个字段就跳过 + } + } + + static Method find(Class owner, String name, Class[] params, Class ret) { + for (Class c = owner; c != null && c != Object.class; c = c.getSuperclass()) { + for (Method m : c.getDeclaredMethods()) { + if (!m.getName().equals(name)) continue; + if (!Arrays.equals(m.getParameterTypes(), params)) continue; + if (ret != null && m.getReturnType() != ret) continue; + m.setAccessible(true); + return m; + } + } + return null; + } + + static float fl(Object actor, String name) { + if (actor == null) return -1f; + for (Method m : actor.getClass().getMethods()) { + if (!m.getName().equals(name)) continue; + if (m.getParameterTypes().length != 0 || m.getReturnType() != float.class) continue; + try { m.setAccessible(true); return ((Float) m.invoke(actor)).floatValue(); } + catch (Throwable ignored) { } + } + return -1f; + } + + static int size(Object array) { + if (array == null) return 0; + for (Class c = ARRAY; c != null && c != Object.class; c = c.getSuperclass()) { + for (Field f : c.getDeclaredFields()) { + if (f.getType() != int.class) continue; + f.setAccessible(true); + try { return f.getInt(array); } catch (Throwable ignored) { } + } + } + return 0; + } + + static Object get(Object array, int index) { + if (array == null) return null; + for (Method m : array.getClass().getMethods()) { + if (!m.getName().equals("D")) continue; + Class[] t = m.getParameterTypes(); + if (t.length != 1 || t[0] != int.class || m.getReturnType() != Object.class) continue; + try { m.setAccessible(true); return m.invoke(array, Integer.valueOf(index)); } + catch (Throwable ignored) { } + } + return null; + } + + static String geom(Object actor) { + return "[x=" + fl(actor, "c_") + " y=" + fl(actor, "d_") + + " w=" + fl(actor, "L") + " h=" + fl(actor, "I") + "]"; + } +}