阶段四:Spine 3.8.99 编辑器里挂上自建视图面板

目标:在 Spine 编辑器窗口内部加一个自己的视图面板(按钮 + 文字显示),
点击读取场景对象,并像原生面板一样出现在 Views 菜单、可停靠可折叠。

本轮打通:
1. 编辑器在我们自己的进程里完整启动
   - 类加载器必须是 URLClassLoader 本身(自检要求)
   - Editor.main 必须在 AWT EDT 上调用
   - jvm.cfg 第 21 行要以两个空格结尾 + -DSpineJava
   - Editor.D:I 版本号、fonts/、/tzax 资源、Java8 版 launcher.Launcher 替身
2. 3.8.99 全量解密 → 可读源码
   - 字符串 17251/17461(伪造同名调用帧对齐 key)
   - 混淆动态派发还原 53871 处(调引导方法取 MemberName)
3. 自建视图
   - agent 在内存里给 us 枚举加 spineView 常量(不改磁盘,避开完整性自检)
   - 面板 Actor 由外部反射填进 us.spineView.D
   - 复用已有图标做别名(不新建图片资源)
   最终 "Spine View" 出现在 Views 菜单,可与 Tree 并排停靠、折叠展开

另外:
- ViewPatcher/ViewAgent:视图枚举与面板挂载的字节码插桩
- DecAll/ObMap/rewrite_source:字符串解密、派发还原、可读源码生成
- 阶段四-视图面板挂载.md:完整记录做法与踩过的坑

不纳入版本管理:rea/(独立仓库)、tools 下的 JDK/Ghidra 工具链、
Spine.app 与 spine-lab 副本、从载荷派生的批量产物。
This commit is contained in:
tianmo committed 2026-10-16 13:34:18 +08:00
commit 2e7ab7c33c
113 files changed
+25327

No files matched your search

File diff suppressed because it is too large. Load diff
+139
View File
@@ -0,0 +1,139 @@
// 在类定义入口抓字节:判断编辑器的类是在哪个环节被解密的。
//
// 用法(attach 到正在跑的编辑器,或 spawn):
// rea instrument-with-frida attach <pid> --script-file frida-dump-classes.js --duration-ms 60000
//
// 输出:
// /tmp/spine-classes/plain/*.class —— 在入口就已经是 cafebabe 的类(直接可用)
// /tmp/spine-classes/meta.txt —— 每次调用的名字 + 前 8 字节 + 长度
const OUT = "/tmp/spine-classes";
function ensureDirs() {
try { new File(OUT, "r"); } catch (e) { new File(OUT, "w").close(); }
const plain = OUT + "/plain";
try { new File(plain, "r"); } catch (e) { new File(plain, "w").close(); }
}
let metaFile = null;
function meta(line) {
try {
if (metaFile === null) metaFile = new File(OUT + "/meta.txt", "a");
metaFile.write(line + "\n");
metaFile.flush();
} catch (e) { }
}
let dumped = 0;
let seen = 0;
function hex(bytes, count) {
let s = "";
for (let i = 0; i < count && i < bytes.length; i++) {
s += ("0" + bytes[i].toString(16)).slice(-2);
}
return s;
}
function handle(namePtr, bufPtr, len, where) {
seen++;
if (bufPtr.isNull() || len <= 8 || len > 8 * 1024 * 1024) return;
let name = "";
try { name = namePtr.isNull() ? "(null)" : namePtr.readUtf8String(); } catch (e) { name = "(?)"; }
let head;
try { head = Memory.readByteArray(bufPtr, Math.min(len, 8)); } catch (e) { return; }
const bytes = new Uint8Array(head);
const tag = hex(bytes, 4);
if (seen < 40) send({ where: where, name: name, len: len, head: hex(bytes, 8) });
if (tag === "cafebabe") {
dumped++;
try {
const data = Memory.readByteArray(bufPtr, len);
const file = new File(OUT + "/plain/" + (name ? name.replace(/[\\/]/g, "_") : ("anon" + dumped)) + ".class", "wb");
file.write(data);
file.close();
} catch (e) { }
} else if (seen < 40) {
meta(where + " " + name + " len=" + len + " head=" + hex(bytes, 8));
}
}
// Frida 17 去掉了 Module.findExportByName,这里把新旧几套 API 都试一遍。
function exportOf(module, name) {
try {
if (typeof module.findExportByName === "function") return module.findExportByName(name);
} catch (e) { }
try {
if (typeof module.getExportByName === "function") return module.getExportByName(name);
} catch (e) { }
try {
if (typeof Module.findExportByName === "function") return Module.findExportByName(module.name, name);
} catch (e) { }
try {
if (typeof Module.getExportByName === "function") return Module.getExportByName(module.name, name);
} catch (e) { }
return null;
}
function hookModule() {
const libjvm = Process.findModuleByName("libjvm.dylib");
if (!libjvm) {
send({ error: "libjvm.dylib 未加载" });
return false;
}
send({ module: libjvm.name, base: libjvm.base.toString(), size: libjvm.size });
const targets = [
"JVM_DefineClassWithSource",
"JVM_DefineClass",
"JVM_LookupDefineClass",
"_JVM_DefineClassWithSource",
"_JVM_DefineClass",
"_JVM_LookupDefineClass",
];
let hooked = 0;
for (const name of targets) {
const addr = exportOf(libjvm, name);
if (!addr) continue;
hooked++;
send({ hooked: name, addr: addr.toString() });
Interceptor.attach(addr, {
onEnter(args) {
// (JNIEnv*, const char* name, jobject loader, const jbyte* buf, jsize len, jobject pd[, const char* source])
try {
handle(args[1], args[3], args[4].toInt32(), name);
} catch (e) { }
},
});
}
send({ hookedCount: hooked });
return hooked > 0;
}
ensureDirs();
if (!hookModule()) {
// libjvm 还没加载:用模块加载通知(拿不到就退回到高频轮询),
// 因为类定义紧接着 libjvm 加载就开始了,晚了就什么都抓不到。
let done = false;
try {
Process.attachModuleObserver({
onAdded(module) {
if (done || module.name !== "libjvm.dylib") return;
done = true;
hookModule();
},
});
} catch (e) { }
const waiter = setInterval(function () {
if (done) { clearInterval(waiter); return; }
if (Process.findModuleByName("libjvm.dylib")) {
done = true;
clearInterval(waiter);
hookModule();
}
}, 20);
}
setInterval(function () {
send({ seen: seen, dumped: dumped });
}, 10000);
Whitespace-only changes.
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@
{"A": {"name": "A", "super": "java.lang.Enum", "interfaces": [], "flags": 16433, "fields": [{"name": "D", "type": "A", "flags": 16409}, {"name": "B", "type": "A", "flags": 16409}, {"name": "L", "type": "A", "flags": 16394}, {"name": "I", "type": "A", "flags": 16394}, {"name": "S", "type": "A", "flags": 16394}, {"name": "C", "type": "A", "flags": 16394}, {"name": "Z", "type": "A", "flags": 16394}, {"name": "D", "type": "int", "flags": 17}, {"name": "D", "type": "A[]", "flags": 4122}], "methods": [{"name": "values", "params": [], "returns": "A[]", "flags": 9}, {"name": "valueOf", "params": ["java.lang.String"], "returns": "A", "flags": 9}, {"name": "<init>", "params": ["java.lang.String", "int", "int"], "returns": "void", "flags": 2}, {"name": "<clinit>", "params": [], "returns": "void", "flags": 8}], "path": "/Volumes/Spine398/A.class"}, "AA": {"name": "AA", "super": "java.lang.Enum", "interfaces": [], "flags": 16433, "fields": [{"name": "D", "type": "AA", "flags": 16409}, {"name": "B", "type": "AA", "flags": 16409}, {"name": "L", "type": "AA", "flags": 16409}, {"name": "I", "type": "AA", "flags": 16409}, {"name": "S", "type": "AA", "flags": 16409}, {"name": "D", "type": "AA[]", "flags": 4122}], "methods": [{"name": "values", "params": [], "returns": "AA[]", "flags": 9}, {"name": "valueOf", "params": ["java.lang.String"], "returns": "AA", "flags": 9}, {"name": "<init>", "params": ["java.lang.String", "int"], "returns": "void", "flags": 2}, {"name": "<clinit>", "params": [], "returns": "void", "flags": 8}], "path": "/Volumes/Spine398/AA.class"}, "AB": {"name": "AB", "super": "et", "interfaces": [], "flags": 1057, "fields": [{"name": "D", "type": "Ah", "flags": 17}, {"name": "D", "type": "JS", "flags": 1}, {"name": "B", "type": "boolean", "flags": 0}], "methods": [{"name": "<init>", "params": ["Ah", "JS"], "returns": "void", "flags": 1}, {"name": "L", "params": ["java.lang.String"], "returns": "void", "flags": 20}, {"name": "D", "params": ["AB"], "returns": "void", "flags": 1}, {"name": "B", "params": ["AB"], "returns": "void", "flags": 1}, {"name": "D", "params": [], "returns": "java.lang.Exception", "flags": 1025}, {"name": "D", "params": ["com.esotericsoftware.spine.editor.export.ExportSettings"], "returns": "void", "flags": 1025}, {"name": "D", "params": [], "returns": "com.esotericsoftware.spine.editor.export.ExportSettings", "flags": 1025}, {"name": "D", "params": ["java.lang.String"], "returns": "boolean", "flags": 17}, {"name": "D", "params": ["java.lang.Class", "java.lang.String"], "returns": "java.io.InputStream", "flags": 8}, {"name": "Nb", "params": ["java.lang.Object", "java.lang.Object", "java.lang.Object", "java.lang.Object"], "returns": "java.lang.Object", "flags": 10}], "path": "/Volumes/Spine398/AB.class"}, "AC": {"name": "AC", "super": "vv", "interfaces": [], "flags": 48, "fields": [{"name": "D", "type": "AB", "flags": 4098}], "methods": [{"name": "<init>", "params": ["AB", "java.lang.String"], "returns": "void", "flags": 0}, {"name": "L", "params": ["java.lang.Object"], "returns": "void", "flags": 17}, {"name": "xp", "params": ["java.lang.Object", "java.lang.Object", "java.lang.Object", "java.lang.Object"], "returns": "java.lang.Object", "flags": 10}], "path": "/Volumes/Spine398/AC.class"}, "AD": {"name": "AD", "super": "et", "interfaces": ["java.lang.Runnable"], "flags": 49, "fields": [{"name": "D", "type": "Ah", "flags": 16}, {"name": "D", "type": "s", "flags": 1}, {"name": "D", "type": "JX", "flags": 1}, {"name": "D", "type": "dl", "flags": 1}, {"name": "D", "type": "df", "flags": 16}, {"name": "B", "type": "df", "flags": 2}, {"name": "D", "type": "float[]", "flags": 16}, {"name": "B", "type": "int", "flags": 0}, {"name": "Code", "type": "float", "flags": 0}, {"name": "SourceFile", "type": "float", "flags": 0}, {"name": "StackMapTable", "type": "float", "flags": 0}, {"name": "init", "type": "float", "flags": 0}, {"name": "LineNumberTable", "type": "float", "flags": 0}, {"name": "String", "type": "float", "flags": 0}, {"name": "Class", "type": "float", "flags": 0}, {"name": "Ljava", "type": "float", "flags": 0}, {"name": "java", "type": "float", "flags": 0}, {"name": "lang", "type": "float", "flags": 0}, {"name": "Object", "type": "float", "flags": 0}, {"name": "invoke", "type": "float", "flags": 0}, {"name": "MethodHandle", "type": "float", "flags": 0}, {"name": "Lcom", "type": "float", "flags": 0}, {"name": "CallSite", "type": "float", "flags": 0}, {"name": "ConstantCallSite", "type": "float", "flags": 0}, {"name": "Exceptions", "type": "float", "flags": 0}, {"name": "util", "type": "float", "flags": 0}, {"name": "B", "type": "boolean", "flags": 0}, {"name": "L", "type": "boolean", "flags": 0}, {"name": "D", "type": "de", "flags": 2}, {"name": "D", "type": "long", "flags": 2}, {"name": "D", "type": "java.util.concurrent.ThreadPoolExecutor", "flags": 16}, {"name": "D", "type": "z", "flags": 0}, {"name": "I", "type": "boolean", "flags": 0}, {"name": "S", "type": "boolean", "flags": 0}, {Line truncated
@@ -0,0 +1 @@
{"evidence_id":"ev_e367b51c6019f1891f6453ad38ede6785bbe937bb983126847ece3b99ae05bd3","subject":{"name":"libspine-editorarm64.dylib","digest":{"sha256":"2549d0b07be0926995628e0e887e72cf0812c80438a7cfb9d1935c88cad28c57"},"local_path":"/private/var/folders/jl/m50cxy0d7zzfd0448wzhj2bc0000gn/T/.spine/tianmokeji/4.3.26/libspine-editorarm64.dylib","format":"mach-o","architecture":"arm64"},"provider":{"id":"rea-workflow","name":"REA composed investigation workflow","version":"1"},"analysis_profile":{"provider":{"id":"rea-workflow","name":"REA composed investigation workflow","version":"1"},"parameters":{"upstream_analysis_profile":{"provider":{"id":"ghidra","name":"Ghidra","version":"12.1.4"},"parameters":{"target_kind":"executable","target_format":"mach-o","architecture":"arm64","available_architectures":["arm64"],"import_mode":"ephemeral-source-immutable","annotation_policy":"atomic-function-entry-metadata-v1","load_image_observations":"source-mappings-entry-context-v2","function_body_evidence":"complete-inclusive-ranges-v1","function_references":"complete-body-and-entry-reference-manager-v2","call_relationship_coverage":"function-body-call-coverage-v5","function_dossier_coverage":"observed-facets-and-call-relationships-v1","string_inventory_evidence":"defined-data-coverage-v1","location_resolution":"explicit-address-exact-entry-symbol-first-v3","procedure_file_offset_resolution":"address-first-unique-target-source-mapping-v2","instruction_flow_evidence":"decoded-return-pcode-v1","instruction_references":"exact-address-incoming-v1","function_boundary_observations":"ghidra-function-boundary-limitations-v3","no_return_repair":"returning-imports-decoded-return-v2","process_launch":"inspected-jvm-launch-support-v1","jump_table_evidence":"typed-case-default-blocks-v1","decompiler_jump_loads":true,"loader":"auto-from-header","language_id":"auto-from-header","compiler_spec_id":"auto-default","analyzer_preset":"ghidra-default"},"digest":"966e6016724421ad282356f583a9d5bf24577db4ac77a75c7099d236b88bcc28"},"workflow_revision":3},"digest":"36e03fb12d9b7fb24826a311c442f39020ecdc72441154c33bf99b49256605a8"},"predicate_type":"rea.analysis","operation":"binary_overview","parameters":{},"raw_result":null,"normalized_result":{"document":"target-2549d0b07be0.dylib","image_base":"0x0","segments":[{"name":"__TEXT","start":"0x0","end":"0x1d70","length":7536,"image_base":"0x0","address_space":"ram"},{"name":"__text","start":"0x1d70","end":"0xb6f74","length":741892,"image_base":"0x0","address_space":"ram"},{"name":"__stubs","start":"0xb6f74","end":"0xc40f0","length":53628,"image_base":"0x0","address_space":"ram"},{"name":"__stub_helper","start":"0xc40f0","end":"0xc4420","length":816,"image_base":"0x0","address_space":"ram"},{"name":"__objc_stubs","start":"0xc4420","end":"0xc45a0","length":384,"image_base":"0x0","address_space":"ram"},{"name":"__gcc_except_tab","start":"0xc45a0","end":"0xc6ca0","length":9984,"image_base":"0x0","address_space":"ram"},{"name":"__const","start":"0xc6ca0","end":"0xc6dd8","length":312,"image_base":"0x0","address_space":"ram"},{"name":"__cstring","start":"0xc6dd8","end":"0xc739a","length":1474,"image_base":"0x0","address_space":"ram"},{"name":"__objc_methname","start":"0xc739a","end":"0xc7458","length":190,"image_base":"0x0","address_space":"ram"},{"name":"__unwind_info","start":"0xc7458","end":"0xcc1e0","length":19848,"image_base":"0x0","address_space":"ram"},{"name":"__eh_frame","start":"0xcc1e0","end":"0x118000","length":310816,"image_base":"0x0","address_space":"ram"},{"name":"__got","start":"0x118000","end":"0x118118","length":280,"image_base":"0x0","address_space":"ram"},{"name":"__la_symbol_ptr","start":"0x118118","end":"0x120cc0","length":35752,"image_base":"0x0","address_space":"ram"},{"name":"__const","start":"0x120cc0","end":"0x120e30","length":368,"image_base":"0x0","address_space":"ram"},{"name":"__objc_imageinfo","start":"0x120e30","end":"0x120e38","length":8,"image_base":"0x0","address_space":"ram"},{"name":"__objc_selrefs","start":"0x120e38","end":"0x120e98","length":96,"image_base":"0x0","address_space":"ram"},{"name":"__objc_classrefs","start":"0x120e98","end":"0x120ea0","length":8,"image_base":"0x0","address_space":"ram"},{"name":"__data","start":"0x120ea0","end":"0x120f18","length":120,"image_base":"0x0","address_space":"ram"},{"name":"__bss","start":"0x120f18","end":"0x120f1c","length":4,"image_base":"0x0","address_space":"ram"},{"name":"__DATA","start":"0x120f1c","end":"0x124000","length":12516,"image_base":"0x0","address_space":"ram"},{"name":"__LINKEDIT","start":"0x124000","end":"0x2e8010","length":1851408,"image_base":"0x0","address_space":"ram"},{"name":"__LINKEDIT","start":"0x2e8010","end":"0x2ec000","length":16368,"image_base":"0x0","address_space":"ram"},{"name":"EXTERNAL","start":"0x2ec000","end":"0x2edf00","length":7936,"image_base":"0x0","address_space":"ram"}],"segment_count":23,"procedure_count":17006,"string_count":16920},"confidence":"derived","authority":"shipped-artifact"Line truncated
View File
Whitespace-only changes.
@@ -0,0 +1 @@
{"binding":{"analysis_profile":{"digest":"966e6016724421ad282356f583a9d5bf24577db4ac77a75c7099d236b88bcc28","parameters":{"analyzer_preset":"ghidra-default","annotation_policy":"atomic-function-entry-metadata-v1","architecture":"arm64","available_architectures":["arm64"],"call_relationship_coverage":"function-body-call-coverage-v5","compiler_spec_id":"auto-default","decompiler_jump_loads":true,"function_body_evidence":"complete-inclusive-ranges-v1","function_boundary_observations":"ghidra-function-boundary-limitations-v3","function_dossier_coverage":"observed-facets-and-call-relationships-v1","function_references":"complete-body-and-entry-reference-manager-v2","import_mode":"ephemeral-source-immutable","instruction_flow_evidence":"decoded-return-pcode-v1","instruction_references":"exact-address-incoming-v1","jump_table_evidence":"typed-case-default-blocks-v1","language_id":"auto-from-header","load_image_observations":"source-mappings-entry-context-v2","loader":"auto-from-header","location_resolution":"explicit-address-exact-entry-symbol-first-v3","no_return_repair":"returning-imports-decoded-return-v2","procedure_file_offset_resolution":"address-first-unique-target-source-mapping-v2","process_launch":"inspected-jvm-launch-support-v1","string_inventory_evidence":"defined-data-coverage-v1","target_format":"mach-o","target_kind":"executable"},"provider":{"id":"ghidra","name":"Ghidra","version":"12.1.4"}},"provider":{"id":"ghidra","name":"Ghidra","version":"12.1.4"}},"entries":[],"evidence_bundle":{"artifacts":[{"architecture":"arm64","digest":{"sha256":"2549d0b07be0926995628e0e887e72cf0812c80438a7cfb9d1935c88cad28c57"},"format":"mach-o"}],"captures":[],"environments":[],"providers":[{"id":"rea-workflow","name":"REA composed investigation workflow","version":"1"}],"records":[{"analysis_profile":{"digest":"36e03fb12d9b7fb24826a311c442f39020ecdc72441154c33bf99b49256605a8","parameters":{"upstream_analysis_profile":{"digest":"966e6016724421ad282356f583a9d5bf24577db4ac77a75c7099d236b88bcc28","parameters":{"analyzer_preset":"ghidra-default","annotation_policy":"atomic-function-entry-metadata-v1","architecture":"arm64","available_architectures":["arm64"],"call_relationship_coverage":"function-body-call-coverage-v5","compiler_spec_id":"auto-default","decompiler_jump_loads":true,"function_body_evidence":"complete-inclusive-ranges-v1","function_boundary_observations":"ghidra-function-boundary-limitations-v3","function_dossier_coverage":"observed-facets-and-call-relationships-v1","function_references":"complete-body-and-entry-reference-manager-v2","import_mode":"ephemeral-source-immutable","instruction_flow_evidence":"decoded-return-pcode-v1","instruction_references":"exact-address-incoming-v1","jump_table_evidence":"typed-case-default-blocks-v1","language_id":"auto-from-header","load_image_observations":"source-mappings-entry-context-v2","loader":"auto-from-header","location_resolution":"explicit-address-exact-entry-symbol-first-v3","no_return_repair":"returning-imports-decoded-return-v2","procedure_file_offset_resolution":"address-first-unique-target-source-mapping-v2","process_launch":"inspected-jvm-launch-support-v1","string_inventory_evidence":"defined-data-coverage-v1","target_format":"mach-o","target_kind":"executable"},"provider":{"id":"ghidra","name":"Ghidra","version":"12.1.4"}},"workflow_revision":3},"provider":{"id":"rea-workflow","name":"REA composed investigation workflow","version":"1"}},"authority":"shipped-artifact","confidence":"derived","environment":null,"evidence_id":"ev_e367b51c6019f1891f6453ad38ede6785bbe937bb983126847ece3b99ae05bd3","evidence_links":[],"limitations":["Derived by an REA composed workflow.","A headless Ghidra session contains exactly one imported Program, unlike Hopper's multi-document GUI session.","Cancelling an active Ghidra request terminates its ephemeral database and discards session annotations. The selected target remains open; the next provider query imports the original artifact again.","Charset is reported, while a non-missing terminator cannot distinguish a present terminator from a fixed or Pascal layout.","Default-space addresses use lowercase 0x-prefixed hexadecimal; other address spaces use <percent-encoded-space>:0x<hex>.","External functions and local thunks are distinct; procedure metadata identifies both and preserves a thunk target when Ghidra resolves one.","Memory-block end addresses are exclusive; permissions come from Ghidra MemoryBlock flags rather than inference from section names.","Only Ghidra-defined string Data is observed; packed or undefined string bytes may be absent. Completeness applies to that inventory, and an empty search does not establish that text is absent from the artifact. If an address is known, use read_bytes and xrefs to inspect bytes and references.","The imported Program and temporary project are ephemeral and deleted on close. Annotation operations edit session database metadata; original executable bytes are never written.","The seLine truncated
+46
View File
@@ -0,0 +1,46 @@
#!/bin/zsh
# 编译 ViewPanel:注入器(C,无 UI 依赖)+ 面板(纯 Java / Scene2D 反射)
set -e
HERE="$(cd "$(dirname "$0")" && pwd)"
cd "$HERE"
# 找一个 JDK(编辑器运行时是 OpenJDK;本机现成的是 /tmp 下的 Temurin 17)
if [[ -z "$JAVA_HOME" ]]; then
for candidate in /tmp/jdk-17.0.20.1+1/Contents/Home /tmp/jdk*/Contents/Home; do
[[ -x "$candidate/bin/javac" ]] && JAVA_HOME="$candidate" && break
done
fi
if [[ -z "$JAVA_HOME" || ! -x "$JAVA_HOME/bin/javac" ]]; then
echo "找不到 JDK:请设置 JAVA_HOME" >&2
exit 1
fi
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')
# 保名类清单:这套运行时把 libGDX 基类混淆了,但 ui/* 和 editor/* 保名。
# 旧版本的明文载荷里能直接列出这些类名,用它们做静态字段扫描的候选。
UPDATES="$HOME/Library/Application Support/Spine/updates"
if [[ -f "$UPDATES/3.8.99" ]]; then
unzip -l "$UPDATES/3.8.99" 2>/dev/null \
| awk '{print $4}' | grep -E '\.class$' | grep -E '^(com|org)/' \
| sed 's#/#.#g; s#\.class$##' | sort -u > build/kept-classes.txt
echo "保名类清单: $(wc -l < build/kept-classes.txt) 个"
fi
echo "== 编译注入器"
# 编译成通用二进制:3.8.99 的编辑器是 x86_64 的 Spine_x86(Rosetta),
# 只带 arm64 的 dylib 被继承到那个进程会直接 dyld 报错。
clang -dynamiclib -O2 -Wall -Wextra -Wno-unused-parameter \
-arch arm64 -arch x86_64 \
-I"$JAVA_HOME/include" -I"$JAVA_HOME/include/darwin" \
-o build/inject.dylib \
native/inject.c
echo "== 产物"
ls -la build/inject.dylib
find build/classes -name '*.class' | sed 's/^/ /'
@@ -0,0 +1,41 @@
package com.esotericsoftware.spine.editor.launcher;
/**
* 3.8.99 编辑器会反射去读启动器类 com.esotericsoftware.spine.editor.launcher.Launcher
* 上的若干静态成员(clsid / clr / dat / info / dll)。
*
* 官方那个类在 4.3.06 的 launcher.jar 里,而且是 Java 10 编译的(class 54),
* JDK8 加载直接 UnsupportedClassVersionError,会把 Prefs/sZ 的初始化打断、编辑器起不来。
*
* 所以这里放一个 Java 8 的替身:字段和同名静态方法都提供,反射用哪种都能读到。
*/
public final class Launcher {
public static String clsid = "pro";
public static String clr = "false";
public static String dll = "spine$12345";
public static String dat = "";
public static Object info = null;
private Launcher() {
}
public static String clsid() {
return clsid;
}
public static String clr() {
return clr;
}
public static String dll() {
return dll;
}
public static String dat() {
return dat;
}
public static Object info() {
return info;
}
}
+481
View File
@@ -0,0 +1,481 @@
/* ViewPanel —— 最小注入器
*
* 依据(REA 对 Contents/MacOS/Spine 的实测):
* · 该二进制导入 _dlopen/_dlsym/_dlerror,__la_symbol_ptr 里有 _dlsym 槽位,
* 且没有直接链接 _JNI_CreateJavaVM —— JNI 入口是运行期 dlopen+dlsym 拿的,
* 所以重绑定 _dlsym 是有效钩子点。
* · ad-hoc 签名、无 hardened runtime、无 entitlements —— DYLD_INSERT_LIBRARIES 可用。
*
* 本文件只做三件事:
* 1) 重绑定 _dlsym(含后加载的 libjvm)
* 2) 拦 JNI_CreateJavaVM
* 3) JVM 起来后,把我们的类 DefineClass 进编辑器自己的类加载器,再调用它的 main
*
* 这里没有任何 UI 代码:面板是纯 Java / Scene2D 实现的。
*/
#include <dirent.h>
#include <dlfcn.h>
#include <jni.h>
#include <mach-o/dyld.h>
#include <mach-o/loader.h>
#include <mach-o/nlist.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#define VP_LOG "/tmp/spine-viewpanel.log"
#define VP_DEFAULT_CLASSES "/tmp/spine-viewpanel/classes"
#define VP_DEFAULT_MAIN "com/spineview/SceneScan"
static void vp_log(const char *format, ...) {
FILE *file = fopen(VP_LOG, "a");
if (!file) return;
fprintf(file, "[pid %d] ", (int)getpid());
va_list arguments;
va_start(arguments, format);
vfprintf(file, format, arguments);
va_end(arguments);
fputc('\n', file);
fclose(file);
}
typedef void *(*dlsym_fn)(void *, const char *);
typedef jint (*create_vm_fn)(JavaVM **, void **, void *);
static dlsym_fn real_dlsym = NULL;
static create_vm_fn real_create_vm = NULL;
static JavaVM *g_vm = NULL;
static jobject g_editor_loader = NULL; /* 编辑器自己的 ClassLoader 实例 */
static jclass g_panel_class = NULL; /* com.spineview.SceneScan 的全局引用 */
static jmethodID g_on_render = NULL; /* SceneScan.onRenderThread() */
static char g_classes_dir[4096];
static char g_main_class[512];
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
typedef void (*swap_fn)(JNIEnv *, jclass, jlong);
static swap_fn real_swap = NULL;
static int g_swap_calls = 0;
/* 编辑器用它自己的类加载器加载了这些保名的 libGDX 控件类(REA 运行时证据),
* 拿它们的 Class 对象就能反推出编辑器类加载器。 */
static const char *kEditorAnchorClasses[] = {
"com/badlogic/gdx/scenes/scene2d/ui/TextButton",
"com/badlogic/gdx/scenes/scene2d/ui/Label",
"com/badlogic/gdx/scenes/scene2d/ui/ImageTextButton",
"com/badlogic/gdx/scenes/scene2d/ui/Window",
"com/badlogic/jglfw/Glfw",
};
/* ---------- 读取我们的 .class ---------- */
static jclass vp_define_file(JNIEnv *env, jobject loader, const char *path) {
FILE *file = fopen(path, "rb");
if (!file) return NULL;
fseek(file, 0, SEEK_END);
long size = ftell(file);
fseek(file, 0, SEEK_SET);
if (size <= 0) { fclose(file); return NULL; }
unsigned char *buffer = malloc((size_t)size);
if (!buffer) { fclose(file); return NULL; }
size_t read = fread(buffer, 1, (size_t)size, file);
fclose(file);
if (read != (size_t)size) { free(buffer); return NULL; }
jclass defined = (*env)->DefineClass(env, NULL, loader, (const jbyte *)buffer, (jsize)size);
free(buffer);
if ((*env)->ExceptionCheck(env)) {
vp_log("DefineClass 抛出异常: %s", path);
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
return NULL;
}
return defined;
}
static jclass vp_define_directory(JNIEnv *env, jobject loader, const char *root,
const char *relative, const char *main_internal) {
jclass found = NULL;
char path[4096];
if (relative[0]) snprintf(path, sizeof(path), "%s/%s", root, relative);
else snprintf(path, sizeof(path), "%s", root);
DIR *directory = opendir(path);
if (!directory) return NULL;
struct dirent *entry;
while ((entry = readdir(directory)) != NULL) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue;
char child_relative[4096];
if (relative[0]) snprintf(child_relative, sizeof(child_relative), "%s/%s", relative, entry->d_name);
else snprintf(child_relative, sizeof(child_relative), "%s", entry->d_name);
char child_path[4096];
snprintf(child_path, sizeof(child_path), "%s/%s", root, child_relative);
struct stat info;
if (stat(child_path, &info) != 0) continue;
if (S_ISDIR(info.st_mode)) {
jclass nested = vp_define_directory(env, loader, root, child_relative, main_internal);
if (nested) found = nested;
continue;
}
size_t length = strlen(child_relative);
if (length <= 6 || strcmp(child_relative + length - 6, ".class") != 0) continue;
char internal_name[4096];
snprintf(internal_name, sizeof(internal_name), "%s", child_relative);
internal_name[length - 6] = '\0';
jclass defined = vp_define_file(env, loader, child_path);
vp_log("define %s -> %s", internal_name, defined ? "ok" : "failed");
if (defined && main_internal && strcmp(internal_name, main_internal) == 0) found = defined;
}
closedir(directory);
return found;
}
/* ---------- 在编辑器自己的类加载器里加载并执行我们的类 ---------- */
/* 把我们的类定义进调用方传入的加载器(也就是编辑器自己的那个),返回入口类。
* 目的是让我们的代码和编辑器处在同一个类命名空间里,能直接按名字找它的类。 */
static jclass VPC_defineIntoLoader(JNIEnv *env, jclass clazz, jobject loader) {
(void)clazz;
if (!env || !loader) return NULL;
jclass defined = vp_define_directory(env, loader, g_classes_dir, "", g_main_class);
vp_log("defineIntoLoader: %s", defined ? "已定义" : "失败");
return defined;
}
static void *vp_run_thread(void *unused) {
(void)unused;
/* 等编辑器把保名的控件类加载进来(启动后几百毫秒到几秒)。 */
jobject loader = NULL;
for (int attempt = 0; attempt < 120 && !loader; attempt++) {
pthread_mutex_lock(&g_lock);
loader = g_editor_loader;
pthread_mutex_unlock(&g_lock);
if (!loader) usleep(200000);
}
JNIEnv *env = NULL;
if (!g_vm || (*g_vm)->AttachCurrentThread(g_vm, (void **)&env, NULL) != JNI_OK || !env) {
vp_log("run: AttachCurrentThread 失败");
return NULL;
}
if (!loader) {
/* 有些进程不触发 dlsym("Java_..."),抓不到编辑器加载器。
* 退一步用系统类加载器:我们的类定义进去照样能跑,只是看不到编辑器的私有类。 */
jclass class_loader_class = (*env)->FindClass(env, "java/lang/ClassLoader");
jmethodID get_system = class_loader_class
? (*env)->GetStaticMethodID(env, class_loader_class, "getSystemClassLoader",
"()Ljava/lang/ClassLoader;") : NULL;
if (get_system) loader = (*env)->CallStaticObjectMethod(env, class_loader_class, get_system);
if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionDescribe(env); (*env)->ExceptionClear(env); loader = NULL; }
vp_log("run: 没抓到编辑器加载器,改用系统类加载器 -> %p", loader);
}
if (!loader) { vp_log("run: 拿不到任何类加载器,放弃"); return NULL; }
{
jclass object_class = (*env)->FindClass(env, "java/lang/Object");
jmethodID to_string = object_class
? (*env)->GetMethodID(env, object_class, "toString", "()Ljava/lang/String;") : NULL;
jstring text = to_string ? (jstring)(*env)->CallObjectMethod(env, loader, to_string) : NULL;
const char *chars = text ? (*env)->GetStringUTFChars(env, text, NULL) : NULL;
vp_log("run: 编辑器类加载器 = %s", chars ? chars : "(未知)");
if (chars) (*env)->ReleaseStringUTFChars(env, text, chars);
}
jclass main_class = vp_define_directory(env, loader, g_classes_dir, "", g_main_class);
if (!main_class) { vp_log("run: 没有定义到入口类 %s", g_main_class); return NULL; }
{
JNINativeMethod methods[] = {
{ "defineIntoLoader", "(Ljava/lang/ClassLoader;)Ljava/lang/Class;", (void *)VPC_defineIntoLoader },
};
if ((*env)->RegisterNatives(env, main_class, methods, 1) != JNI_OK) {
vp_log("run: 注册 defineIntoLoader 失败");
if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionDescribe(env); (*env)->ExceptionClear(env); }
} else {
vp_log("run: 已注册 defineIntoLoader");
}
}
g_panel_class = (jclass)(*env)->NewGlobalRef(env, main_class);
g_on_render = (*env)->GetStaticMethodID(env, main_class, "onRenderThread", "()V");
if (!g_on_render) {
vp_log("run: 找不到 onRenderThread");
if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionDescribe(env); (*env)->ExceptionClear(env); }
}
/* 把进程号写进去:日志里要能区分启动器进程和编辑器进程。 */
{
jfieldID pid_field = (*env)->GetStaticFieldID(env, main_class, "pid", "J");
if (pid_field) {
(*env)->SetStaticLongField(env, main_class, pid_field, (jlong)getpid());
vp_log("run: 已把 pid=%d 写进 Java", (int)getpid());
} else {
if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
vp_log("run: 没找到 pid 静态字段");
}
}
jclass string_class = (*env)->FindClass(env, "java/lang/String");
jobjectArray arguments = (*env)->NewObjectArray(env, 0, string_class, NULL);
jmethodID main_method = (*env)->GetStaticMethodID(env, main_class, "main", "([Ljava/lang/String;)V");
if (!main_method) {
vp_log("run: 找不到 main([Ljava/lang/String;)V");
if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionDescribe(env); (*env)->ExceptionClear(env); }
return NULL;
}
vp_log("run: 调用 %s.main", g_main_class);
(*env)->CallStaticVoidMethod(env, main_class, main_method, arguments);
if ((*env)->ExceptionCheck(env)) {
vp_log("run: main 抛出异常");
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
}
return NULL;
}
static void vp_start_thread(void) {
pthread_t thread;
if (pthread_create(&thread, NULL, vp_run_thread, NULL) == 0) {
pthread_detach(thread);
vp_log("已启动注入线程");
} else {
vp_log("线程创建失败");
}
}
typedef jint (*get_created_vms_fn)(JavaVM **, jsize, jsize *);
/* 有些进程(例如 3.8.99 的 x86 编辑器)是从启动器进程继承 JVM 的,
* 根本不会调用 JNI_CreateJavaVM —— 钩子自然不会触发。
* 这里改用 JNI_GetCreatedJavaVMs 去取已经存在的 VM。 */
static void *vp_find_existing_vm_thread(void *unused) {
(void)unused;
for (int attempt = 0; attempt < 1200; attempt++) {
if (g_vm) return NULL;
get_created_vms_fn get_vms = real_dlsym
? (get_created_vms_fn)real_dlsym(RTLD_DEFAULT, "JNI_GetCreatedJavaVMs") : NULL;
if (get_vms) {
JavaVM *vm = NULL;
jsize count = 0;
if (get_vms(&vm, 1, &count) == JNI_OK && count > 0 && vm) {
g_vm = vm;
vp_log("用 JNI_GetCreatedJavaVMs 拿到已存在的 JVM,开始注入");
vp_start_thread();
return NULL;
}
}
usleep(500000);
}
return NULL;
}
static void vp_start_vm_watch(void) {
pthread_t thread;
if (pthread_create(&thread, NULL, vp_find_existing_vm_thread, NULL) == 0) pthread_detach(thread);
}
/* ---------- 抓编辑器类加载器 ---------- */
/* 编辑器在给自己绑定原生方法时会 dlsym("Java_...");此刻调用 FindClass,
* 正好落在编辑器自己的类加载上下文里,用它反推编辑器类加载器。只观察,不改返回值。 */
static void vp_try_capture(const char *symbol) {
pthread_mutex_lock(&g_lock);
int need = (g_editor_loader == NULL);
pthread_mutex_unlock(&g_lock);
if (!need || !g_vm) return;
JNIEnv *env = NULL;
if ((*g_vm)->GetEnv(g_vm, (void **)&env, JNI_VERSION_1_6) != JNI_OK || !env) return;
jclass class_class = (*env)->FindClass(env, "java/lang/Class");
jmethodID get_class_loader = class_class
? (*env)->GetMethodID(env, class_class, "getClassLoader", "()Ljava/lang/ClassLoader;") : NULL;
for (size_t index = 0; index < sizeof(kEditorAnchorClasses) / sizeof(kEditorAnchorClasses[0]); index++) {
jclass found = (*env)->FindClass(env, kEditorAnchorClasses[index]);
if (!found) { (*env)->ExceptionClear(env); continue; }
jobject loader = get_class_loader ? (*env)->CallObjectMethod(env, found, get_class_loader) : NULL;
if (!loader || (*env)->ExceptionCheck(env)) {
(*env)->ExceptionClear(env);
/* FindClass 落在 bootstrap/系统加载器上时 getClassLoader() 会是 null,
* 这时改用当前 Java 线程的上下文加载器(编辑器线程的上下文就是编辑器加载器)。 */
jclass thread_class = (*env)->FindClass(env, "java/lang/Thread");
jmethodID current = thread_class
? (*env)->GetStaticMethodID(env, thread_class, "currentThread", "()Ljava/lang/Thread;") : NULL;
jmethodID context = thread_class
? (*env)->GetMethodID(env, thread_class, "getContextClassLoader", "()Ljava/lang/ClassLoader;") : NULL;
if (current && context) {
jobject thread = (*env)->CallStaticObjectMethod(env, thread_class, current);
if (thread) loader = (*env)->CallObjectMethod(env, thread, context);
if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); loader = NULL; }
}
}
if (!loader) {
vp_log("锚点 %s 的加载器为空,继续试下一个", kEditorAnchorClasses[index]);
continue;
}
pthread_mutex_lock(&g_lock);
if (!g_editor_loader) {
g_editor_loader = (*env)->NewGlobalRef(env, loader);
char message[512];
snprintf(message, sizeof(message), "捕获编辑器类加载器(锚点 %s,触发符号 %s)",
kEditorAnchorClasses[index], symbol ? symbol : "(null)");
pthread_mutex_unlock(&g_lock);
vp_log("%s", message);
return;
}
pthread_mutex_unlock(&g_lock);
return;
}
}
/* ---------- JVM 创建拦截 ---------- */
static jint vp_create_vm(JavaVM **pvm, void **penv, void *args) {
vp_log("拦截到 JNI_CreateJavaVM");
jint result = real_create_vm ? real_create_vm(pvm, penv, args) : JNI_ERR;
if (result != JNI_OK || !pvm || !*pvm) {
vp_log("JNI_CreateJavaVM 失败: %d", (int)result);
return result;
}
g_vm = *pvm;
vp_log("JVM 已创建,等编辑器类加载器");
vp_start_thread();
return result;
}
/* ---------- _dlsym 重绑定 ---------- */
/* 渲染线程上的钩子:编辑器每帧调 glfwSwapBuffers 时,先问一下 Java 侧要不要装配面板。
* 只在前若干帧回调,之后彻底安静。 */
static void vp_swap(JNIEnv *env, jclass clazz, jlong window) {
const char *no_call = getenv("SPINE_VIEWPANEL_NO_JCALL");
if (g_panel_class && g_on_render && env && g_swap_calls < 20000
&& !(no_call && no_call[0] == '1')) {
g_swap_calls++;
(*env)->CallStaticVoidMethod(env, g_panel_class, g_on_render);
if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
}
}
if (real_swap) real_swap(env, clazz, window);
}
static void *vp_dlsym(void *handle, const char *name) {
if (name && strcmp(name, "JNI_CreateJavaVM") == 0) {
if (!real_create_vm && real_dlsym) real_create_vm = (create_vm_fn)real_dlsym(handle, name);
vp_log("dlsym: 返回注入版 JNI_CreateJavaVM");
return (void *)vp_create_vm;
}
/* 只截 JNI 方法,不碰 C 函数 glfwSwapBuffers。
* libjglfw 里两个符号都有:C 函数的签名是 (GLFWwindow*),JNI 方法是 (JNIEnv*,jclass,jlong)。
* 截错那个会让包装函数用错签名,直接把渲染搞坏(实测窗口全黑)。 */
if (name && strncmp(name, "Java_", 5) == 0 && strstr(name, "glfwSwapBuffers") != NULL) {
void *real = real_dlsym ? real_dlsym(handle, name) : NULL;
if (!real) {
vp_log("dlsym: %s 拿不到真实实现,不挂钩(否则会没有交换缓冲 → 黑屏)", name);
return NULL;
}
/* 实测:替换这个 JNI 方法会让编辑器整窗黑屏(启动器没事),所以默认不挂。
* 需要时用 SPINE_VIEWPANEL_SWAP=1 显式打开。 */
const char *enable = getenv("SPINE_VIEWPANEL_SWAP");
if (!enable || enable[0] != '1') return real;
if (getenv("SPINE_VIEWPANEL_NO_SWAP")) return real;
if (!real_swap) {
real_swap = (swap_fn)real;
void *via_default = real_dlsym(RTLD_DEFAULT, name);
void *via_next = real_dlsym(RTLD_NEXT, name);
vp_log("dlsym: 截住 %s 真实=%p RTLD_DEFAULT=%p RTLD_NEXT=%p",
name, real, via_default, via_next);
}
return (void *)vp_swap;
}
if (name && strncmp(name, "Java_", 5) == 0) vp_try_capture(name);
return real_dlsym ? real_dlsym(handle, name) : NULL;
}
static void vp_make_writable(void *address, size_t size) {
uintptr_t page = (uintptr_t)address & ~((uintptr_t)getpagesize() - 1);
uintptr_t end = ((uintptr_t)address + size + getpagesize() - 1) & ~((uintptr_t)getpagesize() - 1);
mprotect((void *)page, end - page, PROT_READ | PROT_WRITE);
}
static void vp_rebind(const struct mach_header *header, intptr_t slide) {
if (!header || header->magic != MH_MAGIC_64) return;
const uint8_t *image = (const uint8_t *)header;
const struct load_command *cmd = (const struct load_command *)(image + sizeof(struct mach_header_64));
const struct symtab_command *symtab = NULL;
const struct dysymtab_command *dysymtab = NULL;
const struct segment_command_64 *linkedit = NULL;
for (uint32_t index = 0; index < header->ncmds; index++) {
if (cmd->cmd == LC_SYMTAB) symtab = (const struct symtab_command *)cmd;
else if (cmd->cmd == LC_DYSYMTAB) dysymtab = (const struct dysymtab_command *)cmd;
else if (cmd->cmd == LC_SEGMENT_64) {
const struct segment_command_64 *segment = (const struct segment_command_64 *)cmd;
if (strcmp(segment->segname, "__LINKEDIT") == 0) linkedit = segment;
}
cmd = (const struct load_command *)((const uint8_t *)cmd + cmd->cmdsize);
}
if (!symtab || !dysymtab || !linkedit) return;
const uintptr_t linkedit_base = (uintptr_t)(linkedit->vmaddr - linkedit->fileoff + slide);
const struct nlist_64 *symbols = (const struct nlist_64 *)(linkedit_base + symtab->symoff);
const char *strings = (const char *)(linkedit_base + symtab->stroff);
const uint32_t *indirect = (const uint32_t *)(linkedit_base + dysymtab->indirectsymoff);
cmd = (const struct load_command *)(image + sizeof(struct mach_header_64));
for (uint32_t index = 0; index < header->ncmds; index++) {
if (cmd->cmd != LC_SEGMENT_64) {
cmd = (const struct load_command *)((const uint8_t *)cmd + cmd->cmdsize);
continue;
}
const struct segment_command_64 *segment = (const struct segment_command_64 *)cmd;
const struct section_64 *sections =
(const struct section_64 *)((const uint8_t *)segment + sizeof(struct segment_command_64));
for (uint32_t section_index = 0; section_index < segment->nsects; section_index++) {
const struct section_64 *section = &sections[section_index];
if (strcmp(section->sectname, "__la_symbol_ptr") != 0 &&
strcmp(section->sectname, "__got") != 0) continue;
if (section->reserved1 == 0) continue;
const size_t count = section->size / sizeof(void *);
for (size_t entry = 0; entry < count; entry++) {
uint32_t symbol_index = indirect[section->reserved1 + entry];
if (symbol_index >= symtab->nsyms) continue;
const char *name = strings + symbols[symbol_index].n_un.n_strx;
if (name && strcmp(name, "_dlsym") == 0) {
void **slot = (void **)((uintptr_t)section->addr + slide + entry * sizeof(void *));
vp_make_writable(slot, sizeof(void *));
*slot = vp_dlsym;
}
}
}
cmd = (const struct load_command *)((const uint8_t *)cmd + cmd->cmdsize);
}
}
static void vp_image_added(const struct mach_header *header, intptr_t slide) {
vp_rebind(header, slide);
}
__attribute__((constructor))
static void vp_install(void) {
real_dlsym = (dlsym_fn)dlsym(RTLD_DEFAULT, "dlsym");
const char *dir = getenv("SPINE_VIEWPANEL_CLASSES");
snprintf(g_classes_dir, sizeof(g_classes_dir), "%s",
(dir && dir[0]) ? dir : VP_DEFAULT_CLASSES);
const char *main_class = getenv("SPINE_VIEWPANEL_MAIN");
snprintf(g_main_class, sizeof(g_main_class), "%s",
(main_class && main_class[0]) ? main_class : VP_DEFAULT_MAIN);
vp_log("=== inject.dylib 已载入 pid=%d classes=%s main=%s ===",
(int)getpid(), g_classes_dir, g_main_class);
uint32_t count = _dyld_image_count();
for (uint32_t index = 0; index < count; index++) {
vp_rebind(_dyld_get_image_header(index), _dyld_get_image_vmaddr_slide(index));
}
_dyld_register_func_for_add_image(vp_image_added);
/* 万一这个进程不创建 JVM(继承来的),也要能注入。 */
vp_start_vm_watch();
}
+133
View File
@@ -0,0 +1,133 @@
/* 内存扫描:找进程里已经解密成明文的 class 字节。
*
* 为什么走这条路:实测 Frida 的内联挂钩(Interceptor.attach 到 JVM_DefineClass*)
* 会让这套运行时直接 SIGSEGV,attach 又太晚(类早加载完了)。
* 扫描内存不改任何代码,风险最低。
*/
#include <mach/mach.h>
#include <mach/vm_region.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#define SCAN_LOG "/tmp/spine-viewpanel.log"
#define SCAN_DIR "/tmp/spine-classes/scan"
static void scan_log(const char *message) {
FILE *file = fopen(SCAN_LOG, "a");
if (file) { fprintf(file, "%s\n", message); fclose(file); }
}
/* 用常量池长度算出一个 class 文件到底多少字节;不是合法 class 就返回 -1。 */
static int class_file_length(const unsigned char *p, size_t avail) {
if (avail < 10) return -1;
if (!(p[0] == 0xCA && p[1] == 0xFE && p[2] == 0xBA && p[3] == 0xBE)) return -1;
uint16_t cp_count = (uint16_t)((p[8] << 8) | p[9]);
size_t i = 10;
for (uint16_t index = 1; index < cp_count; index++) {
if (i >= avail) return -1;
uint8_t tag = p[i++];
switch (tag) {
case 1: {
if (i + 2 > avail) return -1;
uint16_t len = (uint16_t)((p[i] << 8) | p[i + 1]);
i += 2 + len;
break;
}
case 3: case 4: i += 4; break;
case 5: case 6: i += 8; index++; break;
case 7: case 8: case 16: case 19: case 20: i += 2; break;
case 9: case 10: case 11: case 12: case 17: case 18: i += 4; break;
case 15: i += 3; break;
default: return -1;
}
if (i > avail) return -1;
}
if (i + 8 > avail) return -1;
uint16_t interfaces = (uint16_t)((p[i + 6] << 8) | p[i + 7]);
i += 8 + (size_t)interfaces * 2;
for (int section = 0; section < 3; section++) {
if (i + 2 > avail) return -1;
uint16_t count = (uint16_t)((p[i] << 8) | p[i + 1]);
i += 2;
for (uint16_t k = 0; k < count; k++) {
if (i + 8 > avail) return -1;
uint16_t attributes = (uint16_t)((p[i + 6] << 8) | p[i + 7]);
i += 8;
for (uint16_t a = 0; a < attributes; a++) {
if (i + 6 > avail) return -1;
uint32_t len = ((uint32_t)p[i + 2] << 24) | ((uint32_t)p[i + 3] << 16)
| ((uint32_t)p[i + 4] << 8) | p[i + 5];
i += 6 + len;
if (i > avail) return -1;
}
}
}
return (int)i;
}
static void scan_memory(void) {
mkdir("/tmp/spine-classes", 0755);
mkdir(SCAN_DIR, 0755);
int dumped = 0;
uint64_t scanned = 0;
vm_address_t address = 0;
while (dumped < 6000) {
vm_size_t size = 0;
vm_region_basic_info_data_64_t info;
mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64;
mach_port_t object = MACH_PORT_NULL;
kern_return_t kr = vm_region_64(mach_task_self(), &address, &size, VM_REGION_BASIC_INFO_64,
(vm_region_info_t)&info, &count, &object);
if (kr != KERN_SUCCESS) break;
int readable = (info.protection & VM_PROT_READ) != 0;
/* 只扫进程自己分配的区域,跳过文件映射和共享缓存,减少误报与耗时。 */
int is_shared = info.shared;
if (readable && !is_shared && size >= 4096 && size <= (vm_size_t)256 * 1024 * 1024) {
const unsigned char *base = (const unsigned char *)address;
const unsigned char *end = base + size;
scanned += size;
for (const unsigned char *p = base; p + 4 < end; p++) {
if (p[0] != 0xCA || p[1] != 0xFE || p[2] != 0xBA || p[3] != 0xBE) continue;
int length = class_file_length(p, (size_t)(end - p));
if (length < 100 || length > 4 * 1024 * 1024) continue;
char path[256];
snprintf(path, sizeof(path), SCAN_DIR "/mem_%05d.class", dumped);
FILE *file = fopen(path, "wb");
if (file) {
fwrite(p, 1, (size_t)length, file);
fclose(file);
dumped++;
}
if (dumped >= 6000) break;
}
}
address += size;
}
char message[256];
snprintf(message, sizeof(message), "内存扫描:扫了 %.0f MB,dump 出 %d 个 class",
(double)scanned / 1048576.0, dumped);
scan_log(message);
}
static void *scan_thread(void *unused) {
(void)unused;
sleep(20);
scan_memory();
return NULL;
}
__attribute__((constructor))
static void scan_install(void) {
const char *enable = getenv("SPINE_VIEWPANEL_DUMP");
if (!enable || enable[0] != '1') return;
scan_log("=== memscan.dylib 已载入,20 秒后开始扫描 ===");
pthread_t thread;
if (pthread_create(&thread, NULL, scan_thread, NULL) == 0) pthread_detach(thread);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+30
View File
@@ -0,0 +1,30 @@
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-128-CBC,800E9517EC57E30629C255E77732F416
UYC1bkuQiSC8cAajmmgwowyRzkUf39oZ2/EfVG1eHQkHxGwz4TY9m2PrioSNeILD
z7fj6uDdo3YHgUfGw4mxoLV4vdUlDyTg8thorPP7K0gr4xCzCxb8gkcyd3Wtyqon
jbDUqM6/fcG6OJn+44ZPFM46y6rnIzM8aGbAsS+pXNsb351I1d61Ky02ReIZQxNc
L1rx083W91mzybDDn32FN9r1YP+jIKq15XT1MA1+P1ayMiPY0963FLU6STs3gyiM
mqWq+PQ3aqjUXEFbNZVNNWIPD7fizuxSfWNdGY+9oz9zvDlrapYMLXNZEkB7r5LG
si08C8OVpo96lkX4NfTQP02B0vzDtbiEAxdbKv0Y9kOkIN3yJDF8yTQO+XdYkqlC
PEd5bqix2t+kOVscAOdGM5eo6BKR8EZ744HIPOqToQGFDrLzZiUNE6CH1O8okKBa
ormcuKE4S7SrYpSi5SSt5/o6VVULDVNbRZ96H0lCErCLbzQXVF8CQTreUNpCT3oD
+ZBV/tMB3mfYERLDXCHibMoosCfEmAWgk35PSoEfFlxE9f26yt4dnUwobjEC5x81
rMQvzDApcZ2hP5pow6crDjz1Xf5/6Vi5N2ajU9MYmmINIts6va2JYwFbKABa4NP5
Hzo0cvBfg9rvok/bnlppOIoY7phMX6PSSjKozkrCe/6ds/KgTZPjImgxMsVfOja+
MRe9/iG1GjplEjmu1yXgjabnKmKTc0szEsaOyR+ibykn1/pkAMXWXCD+cDAKh8ZX
Xz4wbi1RHHbXg+zRY6v/TalJv2mSj0Sixx2FoXW1qSIX5ztRfiNa5qKGVYaibZmK
MiXhj6P4l+/vrDkzV61cwfJ/9VKhB/FwIHvsbhjkH9bshhr5h9U4DiE/X82VUbT5
Fnot1RFeRYyJBzATxQKg00uoj8JuJlLCpT3TUO4HAlfMquz37lURCoAhRW4YXZ96
xxJADt+ud+kXqGroTOMff5mFCEMS2J+FOHws+6LCVUA0XLQl71L/1BIxq2dPWrl4
835fsxeXu5fgDy8VRYEXbQQABmCSVBJbcBLZdyd1NuJ33XtjZNuU8O1KA8118HA0
fWNyOGXLTw2+jLe+cz9a4cPSH8m9pt9dS1KF9JNTgHOLnFW8HcuCUfklDJx5S1Q3
xa43kEBI2tbzeHV575hc5SRMyU8bZ8KFBwJauHLVUhdh1f+25nHndATzRdAAMtJN
OLuPSWbvx+NOU80N030qohKF9q8cIQPjayRDeA3old2JM3mSG4Bh6akYwjyICmqW
YB7sZUiinH/WPftQ4hGaNf+je0+BHQtKezRmP0EQaSbkUPH5UUVmKVLbG2QFF0Wz
Bf9mhjc/Pw+dD57fV3kfpslJq7ynECpdKjHfIfW0MqfiW3YO7I7LmfafxgqFGQ9n
no/Mvk6UIs3J2h9vxuakTbr6d/YRkmQri4zcVdLsWdTHEDXi3ngGSoVZGud6acYa
cDkEI6TAa2vvJNb1VK+i/RMHjhE5od55nxGo8FYgMoZ1BRPj2Z22MAGhDvyAw/Ap
28UdENl1xUVePd5bT1aAkMLr+Zi2dXCX9Nh1Gz5G486d0JpSMmfb63qm8nnaHYUN
-----END RSA PRIVATE KEY-----
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

+33
View File
@@ -0,0 +1,33 @@
#!/bin/zsh
# 启动实验室副本并注入 ViewPanel。
# 用 LaunchServices(open -n)启动,这样进程有启动时间,REA 的运行时 UI 观测才认。
set -e
HERE="$(cd "$(dirname "$0")" && pwd)"
LAB="$HERE/../spine-lab"
[[ -f "$HERE/build/inject.dylib" ]] || { echo "先跑 ./build.sh" >&2; exit 1; }
# 先关掉旧的实验室实例,避免测试时越开越多。
for pid in $(/bin/ps -eo pid,command | grep "$LAB/Spine.app/Contents/MacOS/Spine" | grep -v grep | awk '{print $1}'); do
kill "$pid" 2>/dev/null && echo "关掉旧实例 pid=$pid"
done
sleep 2
echo "注入器 : $HERE/build/inject.dylib"
echo "面板类 : $HERE/build/classes"
echo "日志 : /tmp/spine-viewpanel.log"
# 可选:把工程文件一起打开(第一个参数)
PROJECT_ARGS=()
if [[ -n "$1" ]]; then
PROJECT_ARGS=(--args "$1")
echo "工程 : $1"
fi
open -n \
--env "DYLD_INSERT_LIBRARIES=$HERE/build/inject.dylib" \
--env "SPINE_VIEWPANEL_CLASSES=$HERE/build/classes" \
--env "SPINE_VIEWPANEL_MAIN=com/spineview/SceneScan" \
--env "SPINE_USER_HOME=$LAB/home" \
"$LAB/Spine.app" "${PROJECT_ARGS[@]}"
+203
View File
@@ -0,0 +1,203 @@
package com.spineview;
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
/**
* 3.8.99 专用入口:先启动探针,再把控制权交给编辑器。
*
* 之所以能这么做,是因为 3.8.99 的载荷是明文的 —— 我们可以把它当作普通 classpath 用,
* 自己当入口类,完全不需要改任何字节码、也不需要注入。
*
* 用法:
* <x86_64 JDK>/bin/java -cp <本目录 class> \
* -Dspine.payload=<载荷 zip> -Dspine.classes=<本目录 class> \
* com.spineview.Launcher <project.spine>
*
* 两个关键点(都是从反编译源码里读出来的,不是猜的):
* 1) Editor.main 开头有 `if (!EventQueue.isDispatchThread()) return;`,必须在 AWT 事件线程上调用。
* 2) 编辑器自检里有 `if (getClassLoader(Editor.class).getClass() != URLClassLoader.class) 报错退出;`
* —— 所以不能用 -cp(那是 AppClassLoader),必须自己 new 一个 URLClassLoader。
*/
public final class Launcher {
public static void main(String[] args) throws Exception {
// 1) 先把探针放起来(它自己会开后台线程,不阻塞)
try {
SceneScan.start();
} catch (Throwable error) {
System.err.println("[spineview] 启动探针失败: " + error);
}
// 2) 再把控制权交给真正的编辑器。
// Editor.main 里有一句 `if (!EventQueue.isDispatchThread()) return;`,
// 所以必须在 AWT 事件分发线程上调用它,否则它开门就返回、什么窗口都不建。
String payloadPath = System.getProperty("spine.payload");
if (payloadPath == null) throw new IllegalStateException("缺少 -Dspine.payload=<载荷 zip>");
URL payload = new File(payloadPath).toURI().toURL();
String classesPath = System.getProperty("spine.classes");
// 编辑器会去 /tzax/*.png 找皮肤贴图,而它在启动器的 launcher.jar 里,载荷里没有。
// 注意:整个 launcher.jar 不能上类路径 —— 它里面的类是 Java 10 编译的(class 54),
// JDK8 加载会抛 UnsupportedClassVersionError,把 Prefs 初始化打断、编辑器根本建不出窗口。
// 所以只用从它里面抽出来的**资源目录**。
String resources = System.getProperty("spine.resources");
String compat = System.getProperty("spine.compat");
String launcherJar = System.getProperty("spine.launcher.jar");
java.util.List<URL> urls = new java.util.ArrayList<URL>();
urls.add(payload);
if (classesPath != null) urls.add(new File(classesPath).toURI().toURL());
if (compat != null) urls.add(new File(compat).toURI().toURL());
if (resources != null) urls.add(new File(resources).toURI().toURL());
if (launcherJar != null) urls.add(new File(launcherJar).toURI().toURL());
URLClassLoader editorLoader = new URLClassLoader(urls.toArray(new URL[0]), null);
System.err.println("[spineview] 编辑器类加载器 = " + editorLoader.getClass().getName());
System.err.println("[spineview] 皮肤资源 /tzax/down-ur.png = "
+ editorLoader.findResource("tzax/down-ur.png"));
final Class<?> editor = Class.forName("com.esotericsoftware.spine.editor.Editor", true, editorLoader);
// Editor.D:I 是"启动器版本号",反编译源码里 `Editor.D(kM)` 一开头就是:
// if (D < 19 && !options.D) { 弹"安装已过时"对话框; return; }
// 官方是原生库/启动器把这个字段填上的,我们自己跑就得补一下,否则永远进不到编辑器。
java.util.List<java.lang.reflect.Field> versionFields =
new java.util.ArrayList<java.lang.reflect.Field>();
for (java.lang.reflect.Field field : editor.getDeclaredFields()) {
java.lang.reflect.Modifier.toString(field.getModifiers());
if (field.getType() != int.class) continue;
if (!java.lang.reflect.Modifier.isStatic(field.getModifiers())) continue;
field.setAccessible(true);
System.err.println("[spineview] Editor 静态 int 字段: " + field.getName()
+ " = " + field.getInt(null));
if (field.getName().equals("D") || field.getName().startsWith("launcher")) {
field.setInt(null, 100);
versionFields.add(field);
}
}
System.err.println("[spineview] 已把 " + versionFields.size() + " 个 Editor.D:I 设为 100");
final String[] arguments = args;
javax.swing.SwingUtilities.invokeAndWait(new Runnable() {
@Override
public void run() {
try {
editor.getMethod("main", String[].class).invoke(null, (Object) arguments);
} catch (Throwable error) {
error.printStackTrace(System.err);
}
}
});
for (java.lang.reflect.Field field : versionFields) {
System.err.println("[spineview] main 返回后 Editor.D:I = " + field.getInt(null));
}
// 视图已经由 view-agent 在内存里注册成"Spine View"(Views 菜单里能勾选/展开),
// 这里不再额外往场景上贴悬浮控件,避免和正式视图重复。
// 等编辑器界面搭好之后,把面板 Actor 填进 us.spineView.D(视图常量已经注册好了)。
final ClassLoader viewLoader = editorLoader;
Thread viewThread = new Thread(new Runnable() {
@Override
public void run() {
// 皮肤一就绪就立刻补图标别名 + 把面板 Actor 填进 us.spineView.D。
// 必须在编辑器"建面板"那一步之前完成,否则框架拿到的 Actor 是 null,
// 点击 Views 菜单里的 Spine View 就不响应。
if (!ViewInstaller.aliasIcon(viewLoader)) {
System.err.println("[view] 没能抢到皮肤时间窗");
return;
}
// 皮肤是一点点填的:图标有了不代表按钮样式也有了,所以失败就隔一会儿重试,
// 但要尽快成功——框架"建面板"那一步之前必须把 Actor 填好。
for (int i = 0; i < 200; i++) {
if (ViewInstaller.installForLoader(viewLoader)) return;
try {
Thread.sleep(50);
} catch (InterruptedException ignored) {
return;
}
}
System.err.println("[view] 重试 200 次仍未装上");
}
}, "spineview-install-view");
viewThread.setDaemon(true);
viewThread.start();
// Editor.main 可能建完窗口就返回;主线程得挂住,否则 JVM 会因为没有非守护线程直接退出。
new java.util.concurrent.CountDownLatch(1).await();
}
/** OG.D 就是编辑器单例(Editor.<clinit> 里 new Editor() 挂上去的)。 */
private static Object findEditorInstance(ClassLoader loader) throws Exception {
Class<?> holder = Class.forName("OG", true, loader);
for (java.lang.reflect.Field field : holder.getDeclaredFields()) {
if (!field.getName().equals("D")) continue;
if (!java.lang.reflect.Modifier.isStatic(field.getModifiers())) continue;
field.setAccessible(true);
Object value = field.get(null);
if (value != null && value.getClass().getName()
.equals("com.esotericsoftware.spine.editor.Editor")) {
return value;
}
}
return null;
}
private static void dump(String label, Object target, int depth) {
if (target == null || depth > 1) return;
System.err.println("[probe] " + label + " : " + target.getClass().getName());
for (java.lang.reflect.Field field : target.getClass().getDeclaredFields()) {
try {
field.setAccessible(true);
Object value = field.get(target);
String text = value == null ? "null" : value.getClass().getName();
if (value instanceof Number || value instanceof Boolean || value instanceof String) {
text = String.valueOf(value);
}
System.err.println("[probe] " + field.getType().getSimpleName() + " "
+ field.getName() + " = " + text);
} catch (Throwable error) {
// 忽略
}
}
}
/** 有界广度遍历,把编辑器实例能摸到的"自家对象"列出来,用来看谁是场景数据。 */
private static void walk(Object root) {
java.util.Set<Object> seen = java.util.Collections.newSetFromMap(
new java.util.IdentityHashMap<Object, Boolean>());
java.util.ArrayDeque<Object> queue = new java.util.ArrayDeque<Object>();
java.util.ArrayDeque<Integer> depths = new java.util.ArrayDeque<Integer>();
queue.add(root);
depths.add(Integer.valueOf(0));
int printed = 0;
while (!queue.isEmpty() && printed < 400) {
Object current = queue.poll();
int depth = depths.poll().intValue();
if (current == null || !seen.add(current)) continue;
if (current.getClass().isArray()) continue;
String name = current.getClass().getName();
if (name.startsWith("java.") || name.startsWith("javax.") || name.startsWith("sun.")
|| name.startsWith("org.lwjgl") || name.startsWith("com.badlogic")) continue;
java.lang.reflect.Field[] fields = current.getClass().getDeclaredFields();
StringBuilder shape = new StringBuilder();
for (java.lang.reflect.Field field : fields) {
shape.append(field.getType().getSimpleName()).append(' ');
}
System.err.println("[walk] " + depth + " " + name + " 字段数=" + fields.length
+ " 类型集合=" + shape);
printed++;
if (depth >= 2) continue;
for (java.lang.reflect.Field field : fields) {
if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) continue;
try {
field.setAccessible(true);
Object value = field.get(current);
if (value == null) continue;
queue.add(value);
depths.add(Integer.valueOf(depth + 1));
} catch (Throwable ignored) {
// 跳过
}
}
}
}
}
@@ -0,0 +1,557 @@
package com.spineview;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* 把"按钮 + 文字显示"面板挂进 Spine 的 scene2d 场景树。
*
* 编辑器的窗口是 jglfw 建的原生 NSWindow,不是 AWT 窗口,没有 Swing 面板可挂;
* 界面本身是 libGDX scene2d 画出来的,只能加进它的 Stage。
*
* 这套运行时把 libGDX 的方法名混淆了(类名保留):
* Stage 的实现 = uk(继承 dt)、Group = do、Actor = dl、Table = et
* 所以下面全部"按类型/签名找成员",不依赖混淆后的名字。
*
* Stage 实例在 tD 里只是局部变量,任何静态字段都没有;但 libGDX 里每个 Actor
* 都记着自己所在的 Stage,而 Fx.D 是一个静态 Actor —— 从这里就能把 Stage 捞回来。
*/
public final class Scene2dPanel {
private static final String STAGE = "dt";
private static final String GROUP = "do";
private static final String ACTOR = "dl";
private static final String SKIN = "com.badlogic.gdx.scenes.scene2d.ui.Skin";
private static final String LABEL = "com.badlogic.gdx.scenes.scene2d.ui.Label";
private static final String TEXT_BUTTON = "com.badlogic.gdx.scenes.scene2d.ui.TextButton";
private static final String WINDOW = "com.badlogic.gdx.scenes.scene2d.ui.Window";
private Scene2dPanel() {
}
public static void installWhenReady(final ClassLoader loader) {
System.err.println("[panel] 开始等 Stage/Skin…");
if (System.getProperty("spine.dumpStatics") != null) {
try {
Thread.sleep(30000);
} catch (InterruptedException ignored) {
return;
}
dumpStatics(loader, paths((java.net.URLClassLoader) loader));
return;
}
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 240; i++) {
try {
Object stage = stageFromActorAnchor(loader);
Object skin = null;
if (stage != null) {
Object root = firstFieldOfType(stage, GROUP);
if (root != null) skin = skinFromRoot(root);
}
if (i % 10 == 0) {
System.err.println("[panel] 第 " + i + " 轮: stage=" + nameOf(stage)
+ " skin=" + nameOf(skin));
}
if (stage != null && skin != null) {
install(loader, stage, skin, i);
return;
}
} catch (Throwable error) {
System.err.println("[panel] 查找失败: " + error);
error.printStackTrace(System.err);
}
try {
Thread.sleep(500);
} catch (InterruptedException ignored) {
return;
}
}
System.err.println("[panel] 等不到 Stage/Skin");
}
}, "spineview-panel");
thread.setDaemon(true);
thread.start();
}
private static String nameOf(Object value) {
return value == null ? "null" : value.getClass().getName();
}
/**
* 有了 Stage 实例之后挂面板。Skin 从 Stage 根节点的子节点上取
* (libGDX 的 Table 自己存着构造时用的 Skin)。
*/
public static void installForStage(final Object stage, final ClassLoader loader) {
// Stage 是先建出来的,UI 是随后才一层层加进去的,所以要等面板挂上来之后才拿得到 Skin。
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
for (int i = 0; i < 240; i++) {
Object skin = findSkinDeep(stage, 6, 20000);
if (skin != null) {
System.err.println("[panel] 第 " + i + " 轮拿到 Skin: "
+ skin.getClass().getName());
install(loader, stage, skin, i);
return;
}
if (i % 20 == 0) {
System.err.println("[panel] 第 " + i + " 轮还没拿到 Skin(Stage "
+ nameOf(stage) + ")");
}
Thread.sleep(500);
}
System.err.println("[panel] 一直没拿到 Skin");
} catch (Throwable error) {
System.err.println("[panel] installForStage 失败");
error.printStackTrace(System.err);
}
}
}, "spineview-skin");
thread.setDaemon(true);
thread.start();
}
private static void install(final ClassLoader loader, Object stage, Object skin, int waited)
throws Exception {
Class<?> skinClass = Class.forName(SKIN, true, loader);
Class<?> buttonClass = Class.forName(TEXT_BUTTON, true, loader);
Class<?> labelClass = Class.forName(LABEL, true, loader);
Class<?> windowClass = Class.forName(WINDOW, true, loader);
final Object button = buttonClass.getConstructor(String.class, skinClass)
.newInstance("Read Scene Objects", skin);
final Object label = labelClass.getConstructor(CharSequence.class, skinClass)
.newInstance("Click the button to list bones / slots / attachments.", skin);
// setBounds 必须各自在自己的类层次上找:Button 和 Label 只有共同的 Actor 祖先,
// 拿 Button 上找到的方法去 invoke Label 会报"object is not an instance of declaring class"。
// 先挂在场景根节点上(视口左上角,避开编辑器自己的工具条)。
findInstanceMethod(button.getClass(), 4, float.class).invoke(button, 60f, 560f, 200f, 32f);
findInstanceMethod(label.getClass(), 4, float.class).invoke(label, 60f, 300f, 540f, 250f);
Object root = firstFieldOfType(stage, GROUP);
addChild(root, button);
addChild(root, label);
System.err.println("[panel] 已把按钮和文字面板挂到 Stage(等了 " + waited + " 轮)");
wireButton(button, label, loader);
}
/**
* 把"按钮点击 → 读取场景对象 → 更新文字"这条链路接上。
*
* 按钮类的方法名被混淆了,没用名字去猜:Button 里有一对 setChecked(boolean)/isChecked(),
* 我们把每个 setter 设成 true,再看哪个无参 boolean getter 跟着变 true,就标定了这一对。
* libGDX 的 Button 自带一个 ClickListener,点一下就会翻转 isChecked,所以轮询它就等于监听点击。
*/
public static void wireButton(final Object button, final Object label, final ClassLoader loader)
throws Exception {
Class<?> buttonClass = button.getClass();
Class<?> labelClass = label.getClass();
Method setChecked = null;
Method isChecked = null;
for (Method setter : buttonClass.getMethods()) {
if (setter.getParameterTypes().length != 1
|| setter.getParameterTypes()[0] != boolean.class
|| setter.getReturnType() != void.class) continue;
setter.invoke(button, Boolean.TRUE);
for (Method getter : buttonClass.getMethods()) {
if (getter.getParameterTypes().length != 0
|| getter.getReturnType() != boolean.class) continue;
if (Boolean.TRUE.equals(getter.invoke(button))) {
setChecked = setter;
isChecked = getter;
break;
}
}
setter.invoke(button, Boolean.FALSE);
if (isChecked != null) break;
}
if (isChecked == null) {
System.err.println("[panel] 没能标定 isChecked/setChecked");
return;
}
System.err.println("[panel] isChecked=" + isChecked.getName()
+ " setChecked=" + setChecked.getName());
final Method readChecked = isChecked;
final Method resetChecked = setChecked;
final Method setText = findMethod(labelClass, 1, CharSequence.class);
final ClassLoader classLoader = loader;
Thread poll = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
try {
if (Boolean.TRUE.equals(readChecked.invoke(button))) {
resetChecked.invoke(button, Boolean.FALSE);
System.err.println("[panel] 按钮被触发,开始读取场景对象");
// Actor 自己记着所在 Stage,直接问它就行
Object stage = firstFieldOfType(button, STAGE);
String text = SceneReader.read(classLoader, stage);
System.err.println("[panel] 读取完成,共 " + text.length() + " 字符");
setText.invoke(label, text);
}
Thread.sleep(80);
} catch (Throwable error) {
System.err.println("[panel] 轮询异常: " + error);
return;
}
}
}
}, "spineview-poll");
poll.setDaemon(true);
poll.start();
// 验证用:--Dspine.simulateClick=1 时自动把按钮"按"一次,方便无人值守地看结果。
if (System.getProperty("spine.simulateClick") != null) {
Thread auto = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(4000);
resetChecked.invoke(button, Boolean.TRUE);
} catch (Throwable error) {
System.err.println("[panel] 自动触发失败: " + error);
}
}
}, "spineview-autoclick");
auto.setDaemon(true);
auto.start();
}
}
/** 从 Fx.D(静态 Actor)出发拿 Stage:libGDX 里每个 Actor 都记着自己所在的 Stage。 */
private static Object stageFromActorAnchor(ClassLoader loader) {
try {
Class<?> holder = Class.forName("Fx", true, loader);
Object anchor = null;
for (Field field : holder.getDeclaredFields()) {
if (!Modifier.isStatic(field.getModifiers())) continue;
if (!field.getType().getName().equals(ACTOR)) continue;
field.setAccessible(true);
anchor = field.get(null);
if (anchor != null) break;
}
if (anchor == null) return null;
return firstFieldOfType(anchor, STAGE);
} catch (Throwable error) {
return null;
}
}
/** Stage 根节点是 do(Group);拿它第一个子节点身上的 Skin(Table 都带 Skin 字段)。 */
@SuppressWarnings("unused")
private static Object skinFromRoot(Object root) throws Exception {
Object children = firstArrayFieldOfType(root, "[L" + ACTOR + ";");
if (!(children instanceof Object[])) return null;
for (Object child : (Object[]) children) {
if (child == null) continue;
Object skin = firstFieldOfType(child, SKIN);
if (skin != null) return skin;
Object deep = skinFromActor(child, 3);
if (deep != null) return deep;
}
return null;
}
/** 顺着 actor 树往下找 Skin,最多 dig 层。 */
private static Object skinFromActor(Object actor, int dig) throws Exception {
if (actor == null || dig < 0) return null;
Object direct = firstFieldOfType(actor, SKIN);
if (direct != null) return direct;
if (dig == 0) return null;
Object children = firstArrayFieldOfType(actor, "[L" + ACTOR + ";");
if (!(children instanceof Object[])) return null;
for (Object child : (Object[]) children) {
Object found = skinFromActor(child, dig - 1);
if (found != null) return found;
}
return null;
}
private static Object firstFieldOfType(Object target, String typeName) throws Exception {
for (Class<?> type = target.getClass(); type != null; type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (!field.getType().getName().equals(typeName)) continue;
field.setAccessible(true);
Object value = field.get(target);
if (value != null) return value;
}
}
return null;
}
/** 从任意对象出发做有界广度搜索,找第一个 Skin 实例。 */
private static Object findSkinDeep(Object start, int maxDepth, int maxNodes) {
java.util.Set<Object> seen = java.util.Collections.newSetFromMap(
new java.util.IdentityHashMap<Object, Boolean>());
java.util.ArrayDeque<Object> queue = new java.util.ArrayDeque<Object>();
java.util.ArrayDeque<Integer> depths = new java.util.ArrayDeque<Integer>();
queue.add(start);
depths.add(Integer.valueOf(0));
int nodes = 0;
while (!queue.isEmpty() && nodes++ < maxNodes) {
Object current = queue.poll();
int depth = depths.poll().intValue();
if (current == null || !seen.add(current)) continue;
if (current.getClass().getName().equals(SKIN)) return current;
if (depth >= maxDepth) continue;
if (current.getClass().isArray()) {
if (!current.getClass().getComponentType().isPrimitive()) {
for (Object item : (Object[]) current) {
if (item != null) {
queue.add(item);
depths.add(Integer.valueOf(depth + 1));
}
}
}
continue;
}
String name = current.getClass().getName();
if (depth > 0 && (name.startsWith("java.") || name.startsWith("javax.")
|| name.startsWith("sun.") || name.startsWith("org.lwjgl"))) continue;
for (Class<?> type = current.getClass(); type != null; 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);
depths.add(Integer.valueOf(depth + 1));
}
} catch (Throwable ignored) {
// 跳过读不到的
}
}
}
}
return null;
}
@SuppressWarnings("unused")
private static Object firstFieldOfTypeOld(Object target, String typeName) throws Exception {
for (Class<?> type = target.getClass(); type != null; type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (!field.getType().getName().equals(typeName)) continue;
field.setAccessible(true);
Object value = field.get(target);
if (value != null) return value;
}
}
return null;
}
private static Object firstArrayFieldOfType(Object target, String descriptor)
throws Exception {
for (Class<?> type = target.getClass(); type != null; type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (!field.getType().getName().equals(descriptor)) continue;
field.setAccessible(true);
Object value = field.get(target);
if (value != null) return value;
}
}
return null;
}
/** 按"参数个数 + 参数类型"找方法(名字被混淆了,只能这么找)。 */
/** Table.add(Actor):一个参数、参数能容纳该 actor、返回值非 void。 */
private static Method findAddMethod(Class<?> tableClass, Class<?> actorClass)
throws NoSuchMethodException {
for (Method method : tableClass.getMethods()) {
if (Modifier.isStatic(method.getModifiers())) continue;
Class<?>[] types = method.getParameterTypes();
if (types.length != 1) continue;
if (!types[0].isAssignableFrom(actorClass)) continue;
if (method.getReturnType() == void.class) continue;
return method;
}
throw new NoSuchMethodException("找不到 add");
}
/** Table.row():无参、返回值就是表格自己。 */
private static Method findRowMethod(Class<?> tableClass) throws NoSuchMethodException {
for (Method method : tableClass.getMethods()) {
if (Modifier.isStatic(method.getModifiers())) continue;
if (method.getParameterTypes().length != 0) continue;
// row() 声明在 Table 上、返回 Table;Window 继承来的,所以不能要求返回值正好是 Window
Class<?> returns = method.getReturnType();
if (returns != Object.class && returns.isAssignableFrom(tableClass)) return method;
}
throw new NoSuchMethodException("找不到 row");
}
private static Method findInstanceMethod(Class<?> owner, int parameterCount,
Class<?> parameterType) throws NoSuchMethodException {
for (Method method : owner.getMethods()) {
if (Modifier.isStatic(method.getModifiers())) continue;
Class<?>[] types = method.getParameterTypes();
if (types.length != parameterCount) continue;
boolean match = true;
for (Class<?> type : types) {
if (parameterType != null && type != parameterType) {
match = false;
break;
}
}
if (match) return method;
}
throw new NoSuchMethodException(owner.getName() + " 参数=" + parameterCount);
}
@SuppressWarnings("unused")
private static Method findMethod(Class<?> owner, int parameterCount, Class<?> parameterType)
throws NoSuchMethodException {
for (Method method : owner.getMethods()) {
Class<?>[] types = method.getParameterTypes();
if (types.length != parameterCount) continue;
boolean match = true;
for (Class<?> type : types) {
if (parameterType != null && type != parameterType) {
match = false;
break;
}
}
if (match) return method;
}
throw new NoSuchMethodException(owner.getName() + " 参数=" + parameterCount);
}
/** Group 的 addActor(Actor):找一个只有一个参数、且参数能容纳该 actor 的 void 方法。 */
private static void addChild(Object group, Object actor) throws Exception {
for (Method method : group.getClass().getMethods()) {
Class<?>[] types = method.getParameterTypes();
if (types.length != 1) continue;
if (!types[0].isAssignableFrom(actor.getClass())) continue;
if (method.getReturnType() != void.class) continue;
method.invoke(group, actor);
System.err.println("[panel] 用 " + method.getDeclaringClass().getSimpleName() + "."
+ method.getName() + " 挂上 " + actor.getClass().getSimpleName());
return;
}
throw new NoSuchMethodException("找不到接纳 " + actor.getClass().getName() + " 的单参方法");
}
/** 全量扫描静态字段(用 Unsafe,不触发类初始化)。留作备用入口。 */
static Object scanStaticNoInit(ClassLoader loader, List<String> paths, String typeName) {
try {
Object unsafe = unsafe();
Class<?> unsafeClass = unsafe.getClass();
Method base = unsafeClass.getMethod("staticFieldBase", Field.class);
Method offset = unsafeClass.getMethod("staticFieldOffset", Field.class);
Method getObject = unsafeClass.getMethod("getObject", Object.class, long.class);
for (String path : paths) {
try (ZipFile zip = new ZipFile(path)) {
Enumeration<? extends ZipEntry> entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
String name = entry.getName();
if (!name.endsWith(".class")) continue;
Class<?> type;
try {
type = Class.forName(name.substring(0, name.length() - 6).replace('/', '.'),
false, loader);
} catch (Throwable ignored) {
continue;
}
for (Field field : type.getDeclaredFields()) {
if (!Modifier.isStatic(field.getModifiers())) continue;
if (!field.getType().getName().equals(typeName)) continue;
try {
Object fieldBase = base.invoke(unsafe, field);
long fieldOffset = ((Long) offset.invoke(unsafe, field)).longValue();
Object value = getObject.invoke(unsafe, fieldBase, Long.valueOf(fieldOffset));
if (value != null) return value;
} catch (Throwable ignored) {
// 读不到就跳过
}
}
}
} catch (Throwable ignored) {
// 不是 zip
}
}
} catch (Throwable ignored) {
// 忽略
}
return null;
}
private static Object unsafe() throws Exception {
Class<?> unsafeClass = Class.forName("sun.misc.Unsafe");
Field field = unsafeClass.getDeclaredField("theUnsafe");
field.setAccessible(true);
return field.get(null);
}
/** 把载荷里所有非空的静态字段倒出来(用 Unsafe,不触发类初始化)。 */
static void dumpStatics(ClassLoader loader, List<String> paths) {
try {
Object unsafe = unsafe();
Class<?> unsafeClass = unsafe.getClass();
Method base = unsafeClass.getMethod("staticFieldBase", Field.class);
Method offset = unsafeClass.getMethod("staticFieldOffset", Field.class);
Method getObject = unsafeClass.getMethod("getObject", Object.class, long.class);
for (String path : paths) {
try (ZipFile zip = new ZipFile(path)) {
Enumeration<? extends ZipEntry> entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
String name = entry.getName();
if (!name.endsWith(".class")) continue;
Class<?> type;
try {
type = Class.forName(name.substring(0, name.length() - 6).replace('/', '.'),
false, loader);
} catch (Throwable ignored) {
continue;
}
for (Field field : type.getDeclaredFields()) {
if (!Modifier.isStatic(field.getModifiers())) continue;
if (field.getType().isPrimitive()) continue;
try {
Object fieldBase = base.invoke(unsafe, field);
long fieldOffset = ((Long) offset.invoke(unsafe, field)).longValue();
Object value = getObject.invoke(unsafe, fieldBase, Long.valueOf(fieldOffset));
if (value != null) {
System.err.println("[statics] " + type.getName() + "." + field.getName()
+ " : " + field.getType().getName() + " = " + value.getClass().getName());
}
} catch (Throwable ignored) {
// 跳过
}
}
}
} catch (Throwable ignored) {
// 不是 zip
}
}
} catch (Throwable error) {
System.err.println("[statics] 失败: " + error);
}
}
static List<String> paths(java.net.URLClassLoader loader) {
List<String> paths = new ArrayList<String>();
for (java.net.URL url : loader.getURLs()) {
if (!"file".equals(url.getProtocol())) continue;
try {
paths.add(new java.io.File(url.toURI()).getAbsolutePath());
} catch (Throwable ignored) {
// 跳过
}
}
return paths;
}
}
@@ -0,0 +1,158 @@
package com.spineview;
import java.awt.Window;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
/**
* 点按钮时执行的读取动作。
*
* 现在只做"能拿到什么就先列什么":先把编辑器窗口里的真实组件树、以及编辑器进程里
* 自建的对象图列出来,确认通道打通;随后再把它换成真正的"骨骼 / 插槽 / 附件"场景对象。
*/
public final class SceneReader {
private SceneReader() {
}
public static String read(ClassLoader editorLoader, Object stage) {
StringBuilder out = new StringBuilder();
out.append("=== 场景树里的对象 ===\n");
try {
listActors(stage, out, 0, 0);
} catch (Throwable error) {
out.append("读取失败: ").append(error).append('\n');
}
out.append("=== 编辑器进程里的窗口 ===\n");
for (Window window : Window.getWindows()) {
if (window.isVisible()) {
out.append(" ").append(window.getClass().getName()).append(" ")
.append(window.getWidth()).append('x').append(window.getHeight()).append('\n');
if (window instanceof java.awt.Container) {
collectComponents((java.awt.Container) window, out, 1);
}
}
}
out.append("\n=== 编辑器实例可达的自建对象 ===\n");
try {
Object editor = findEditor(editorLoader);
if (editor == null) {
out.append("(还没等到编辑器实例)\n");
} else {
walk(editor, out);
}
} catch (Throwable error) {
out.append("读取失败: ").append(error).append('\n');
}
return out.toString();
}
/** 顺着 Stage 的 actor 树把对象列出来(scene2d 里的一切都是 Actor)。 */
private static int listActors(Object actor, StringBuilder out, int depth, int count) {
if (actor == null || depth > 3 || count > 80) return count;
StringBuilder pad = new StringBuilder();
for (int i = 0; i < depth; i++) pad.append(" ");
out.append(pad).append(actor.getClass().getName()).append('\n');
count++;
Object children = children(actor);
if (children instanceof Object[]) {
for (Object child : (Object[]) children) {
count = listActors(child, out, depth + 1, count);
}
}
return count;
}
private static Object children(Object actor) {
for (Class<?> type = actor.getClass(); type != null; type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
String name = field.getType().getName();
if (!name.startsWith("[L") || !name.endsWith("dl;")) continue;
try {
field.setAccessible(true);
Object value = field.get(actor);
if (value instanceof Object[]) return value;
} catch (Throwable ignored) {
// 跳过
}
}
}
return null;
}
private static void collectComponents(java.awt.Container container, StringBuilder out, int depth) {
if (container == null || depth > 3) return;
for (java.awt.Component child : container.getComponents()) {
out.append(indent(depth)).append(child.getClass().getName());
if (child instanceof java.awt.Container) {
out.append(" 子组件=").append(((java.awt.Container) child).getComponentCount());
}
out.append('\n');
if (child instanceof java.awt.Container) {
collectComponents((java.awt.Container) child, out, depth + 1);
}
}
}
private static String indent(int depth) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < depth; i++) builder.append(" ");
return builder.toString();
}
/** OG.D 是编辑器单例(Editor.<clinit> 里 new Editor() 挂上去的)。 */
static Object findEditor(ClassLoader loader) throws Exception {
Class<?> holder = Class.forName("OG", true, loader);
for (Field field : holder.getDeclaredFields()) {
if (!field.getName().equals("D") || !Modifier.isStatic(field.getModifiers())) continue;
field.setAccessible(true);
Object value = field.get(null);
if (value != null && value.getClass().getName()
.equals("com.esotericsoftware.spine.editor.Editor")) {
return value;
}
}
return null;
}
private static void walk(Object root, StringBuilder out) {
Set<Object> seen = Collections.newSetFromMap(new IdentityHashMap<Object, Boolean>());
List<Object> queue = new ArrayList<Object>();
List<Integer> depths = new ArrayList<Integer>();
queue.add(root);
depths.add(Integer.valueOf(0));
int head = 0;
int printed = 0;
while (head < queue.size() && printed < 120) {
Object current = queue.get(head);
int depth = depths.get(head).intValue();
head++;
if (current == null || !seen.add(current) || current.getClass().isArray()) continue;
String name = current.getClass().getName();
if (name.startsWith("java.") || name.startsWith("javax.") || name.startsWith("sun.")
|| name.startsWith("org.lwjgl") || name.startsWith("com.badlogic")) continue;
Field[] fields = current.getClass().getDeclaredFields();
out.append(indent(depth)).append(name).append(" 字段数=").append(fields.length)
.append('\n');
printed++;
if (depth >= 2) continue;
for (Field field : fields) {
if (Modifier.isStatic(field.getModifiers())) continue;
try {
field.setAccessible(true);
Object value = field.get(current);
if (value == null) continue;
queue.add(value);
depths.add(Integer.valueOf(depth + 1));
} catch (Throwable ignored) {
// 跳过
}
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,23 @@
package com.spineview;
/**
* 这个类必须由**编辑器的类加载器**加载(所以命令里要把 viewpanel/build/classes
* 一起放进 URLClassLoader 的 URLs),因为它是被插进 uk.<init> 里的调用目标。
*
* Stage 在编辑器里只是 tD 里的一个局部变量,任何静态字段都摸不到它,
* 唯一稳的入口就是在它构造完成的那一刻把它接住。
*/
public final class StageHook {
private StageHook() {
}
public static void onStage(Object stage) {
try {
System.err.println("[panel] 接住 Stage: " + stage.getClass().getName());
Scene2dPanel.installForStage(stage, StageHook.class.getClassLoader());
} catch (Throwable error) {
System.err.println("[panel] StageHook 失败");
error.printStackTrace(System.err);
}
}
}
@@ -0,0 +1,262 @@
package com.spineview;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
/**
* 被 ViewPatcher 插进 tD.<init> 的调用目标 —— 必须由**编辑器的类加载器**加载,
* 所以运行时要把 viewpanel/build/classes 放进 URLClassLoader 的 URLs。
*
* 它的活:为新的 "Spine View" 视图建一个面板 Actor,塞进 us.SPINEVIEW.D。
* 之后编辑器的停靠/折叠框架就会像对待其它视图一样对待它。
*/
public final class ViewInstaller {
private static final String SKIN = "com.badlogic.gdx.scenes.scene2d.ui.Skin";
private static final String TABLE = "et";
private static final String ACTOR = "dl";
private static final String LABEL = "com.badlogic.gdx.scenes.scene2d.ui.Label";
private static final String TEXT_BUTTON = "com.badlogic.gdx.scenes.scene2d.ui.TextButton";
private ViewInstaller() {
}
/** 判定实验用:只打印一行,不做任何别的事。 */
public static void ping() {
System.err.println("[view] ping 被调用");
}
public static void install() {
// 这是从 tD 的构造函数中途调进来的:这时候编辑器自己的资源(皮肤贴图走 obt: 协议)
// 还在读,我们如果在同一个调用里 Class.forName 触发别的类的初始化,会把流搞坏。
// 所以这里只起个后台线程,等构造函数跑完、界面搭好之后再真正安装。
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException ignored) {
return;
}
doInstall();
}
}, "spineview-view-installer");
thread.setDaemon(true);
thread.start();
}
private static void doInstall() {
try {
ClassLoader loader = ViewInstaller.class.getClassLoader();
installForLoader(loader);
} catch (Throwable error) {
System.err.println("[view] 安装失败");
error.printStackTrace(System.err);
}
}
/**
* 从编辑器外面调用也可以:只要把编辑器那个 URLClassLoader 传进来。
*
* 视图常量已经由 view-agent 注册进 us 枚举,这里只负责把面板 Actor 填进
* us.spineView.D —— 之后编辑器的停靠框架就会像对待其它视图一样对待它。
*/
public static boolean installForLoader(ClassLoader loader) {
try {
Class<?> viewEnum = Class.forName("us", true, loader);
Object view = viewEnum.getMethod("valueOf", String.class).invoke(null, "spineView");
Object skin = findSkin(loader);
if (skin == null) {
System.err.println("[view] 找不到 Skin");
return false;
}
Object panel = buildPanel(loader, skin);
for (Field field : viewEnum.getDeclaredFields()) {
if (!field.getType().getName().equals(ACTOR)) continue;
field.setAccessible(true);
field.set(view, panel);
System.err.println("[view] 已把 Spine View 面板挂到视图 " + view);
return true;
}
System.err.println("[view] 视图枚举里找不到 Actor 字段");
} catch (Throwable error) {
System.err.println("[view] 安装失败(稍后重试): " + error);
}
return false;
}
/** Skin 挂在 OG 的静态字段上(字节码里 OG 有 LSkin;D)。 */
private static Object findSkin(ClassLoader loader) throws Exception {
Class<?> holder = Class.forName("OG", true, loader);
for (Field field : holder.getDeclaredFields()) {
if (!Modifier.isStatic(field.getModifiers())) continue;
if (!field.getType().getName().equals(SKIN)) continue;
field.setAccessible(true);
Object value = field.get(null);
if (value != null) return value;
}
return null;
}
/**
* 编辑器的停靠框架会按惯例去找 "icon-tab" + 视图名 的图标,找不到就直接抛异常把界面带崩。
* 所以要在它建面板之前,把我们的图标别名到已有视图(Tree)的图标上。
* 皮肤是在建面板之前填好的,这里用极短间隔轮询去抢这个时间窗。
*/
public static boolean aliasIcon(ClassLoader loader) {
try {
Class<?> skinClass = Class.forName(SKIN, true, loader);
// libGDX 的 Skin 是"按类型分桶"存的:add(name, obj) 用 obj.getClass() 当桶,
// 而 getDrawable(name) 是去 Drawable.class 这个桶里查 —— 两者对不上就会"找不到"。
// 所以要用三参数的 add(name, obj, Class),并且把该 drawable 的所有接口/父类都注册一遍。
Method addTyped = null;
for (Method method : skinClass.getMethods()) {
Class<?>[] types = method.getParameterTypes();
if (types.length == 3 && types[0] == String.class && types[1] == Object.class
&& types[2] == Class.class && method.getReturnType() == void.class) {
addTyped = method;
break;
}
}
if (addTyped == null) return false;
// 停靠框架会按状态取不同的图标名;后缀没对应的就退回基础图标,
// 反正图标是复用的,不新建任何图片资源。
String[] suffixes = { "", "-over", "-down", "-up", "-checked", "-unchecked",
"-disabled", "-inactive", "-active", "-on", "-off", "-selected", "-open",
"-closed", "-hover", "-pressed" };
for (int attempt = 0; attempt < 30000; attempt++) {
Object skin = findSkin(loader);
if (skin != null) {
boolean any = false;
Object base = getDrawable(skinClass, skin, "icon-tabTree");
if (base == null) continue;
for (String suffix : suffixes) {
Object icon = getDrawable(skinClass, skin, "icon-tabTree" + suffix);
if (icon == null) icon = base;
for (Class<?> type : bucketTypes(icon.getClass())) {
try {
addTyped.invoke(skin, "icon-tabSpineView" + suffix, icon, type);
} catch (Throwable ignored) {
// 某些类型不接受就跳过
}
}
any = true;
}
if (any) {
System.err.println("[view] 已给 SpineView 注册图标别名");
return true;
}
}
Thread.sleep(2);
}
} catch (Throwable error) {
System.err.println("[view] 图标别名失败: " + error);
}
return false;
}
/** drawable 的所有接口 + 父类 —— 把可能当桶用的类型都列出来。 */
private static java.util.List<Class<?>> bucketTypes(Class<?> type) {
java.util.List<Class<?>> list = new java.util.ArrayList<Class<?>>();
for (Class<?> current = type; current != null && current != Object.class;
current = current.getSuperclass()) {
list.add(current);
for (Class<?> iface : current.getInterfaces()) {
if (!list.contains(iface)) list.add(iface);
}
}
return list;
}
/**
* Skin 里有一堆只有一个 String 参数的方法(取字体、取颜色、取 drawable…),名字都被混淆了。
* 这里挨个试,哪个能用 "icon-tabTree" 取到非空对象、又不会抛异常,就是 getDrawable。
*/
private static Object getDrawable(Class<?> skinClass, Object skin, String name) {
for (Method method : skinClass.getMethods()) {
Class<?>[] types = method.getParameterTypes();
if (types.length != 1 || types[0] != String.class) continue;
if (method.getReturnType() == void.class) continue;
if (method.getReturnType() == String.class) continue;
try {
Object value = method.invoke(skin, name);
if (value != null) return value;
} catch (Throwable ignored) {
// 取错类型会抛,继续试下一个
}
}
return null;
}
/** 面板内容:一个 Table,里面一行按钮、一行文字显示。 */
private static Object buildPanel(ClassLoader loader, Object skin) throws Exception {
Class<?> skinClass = Class.forName(SKIN, true, loader);
Class<?> tableClass = Class.forName(TABLE, true, loader);
Class<?> 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);
// 注意:这里必须用 Table 的单元格布局(add/row),不要换成 addActor + 绝对定位 ——
// 那个版本在停靠框架里会抛 "Tab not found: Spine View",把安装打断。
Method add = null;
Method row = null;
for (Method method : tableClass.getMethods()) {
if (Modifier.isStatic(method.getModifiers())) continue;
Class<?>[] types = method.getParameterTypes();
if (types.length == 1 && types[0].isAssignableFrom(buttonClass)
&& method.getReturnType() != void.class) {
if (add == null) add = method;
}
if (types.length == 0 && method.getReturnType() != Object.class
&& method.getReturnType().isAssignableFrom(tableClass)) {
if (row == null) row = method;
}
}
if (add != null) {
add.invoke(table, button);
if (row != null) row.invoke(table);
add.invoke(table, label);
} else {
System.err.println("[view] 找不到 Table.add");
}
Scene2dPanel.wireButton(button, label, loader);
return table;
}
private static Method findInstanceMethod(Class<?> owner, int parameterCount,
Class<?> parameterType) throws NoSuchMethodException {
for (Method method : owner.getMethods()) {
if (Modifier.isStatic(method.getModifiers())) continue;
Class<?>[] types = method.getParameterTypes();
if (types.length != parameterCount) continue;
boolean match = true;
for (Class<?> type : types) {
if (parameterType != null && type != parameterType) {
match = false;
break;
}
}
if (match) return method;
}
throw new NoSuchMethodException(owner.getName() + " 参数=" + parameterCount);
}
/** Group.addActor(Actor):一个参数、能容纳该 actor、返回 void。 */
private static void addChild(Object group, Object actor) throws Exception {
for (Method method : group.getClass().getMethods()) {
Class<?>[] types = method.getParameterTypes();
if (types.length != 1) continue;
if (!types[0].isAssignableFrom(actor.getClass())) continue;
if (method.getReturnType() != void.class) continue;
method.invoke(group, actor);
return;
}
throw new NoSuchMethodException("找不到接纳 " + actor.getClass().getName() + " 的方法");
}
}
+85
View File
@@ -0,0 +1,85 @@
# ViewPanel 进展与结论(2026-10-15)
目标:在 Spine 4.2.43 编辑器窗口内新增一个面板(一个按钮 + 一块文字),点击后读取编辑器里的对象。
## 一、已经打通的部分(都有实测证据)
| 环节 | 状态 | 关键证据 |
| --- | --- | --- |
| 注入编辑器进程 | ✅ | `DYLD_INSERT_LIBRARIES` + `_dlsym` 重绑定 + 拦 `JNI_CreateJavaVM` |
| **把我们的类定义进编辑器的类加载器** | ✅ | `LPt4.mc`(2370 条类名)→ `defineIntoLoader` → `class com.spineview.SceneScan` |
| 在编辑器命名空间里按名字找类 | ✅ | `s.AmK` / `s.Xgs` / `s.nHS` / `s.heZ` / `s.fSy` / `Editor` / `TextButton` 全部找到 |
| 找到编辑器真实 UI 根 | ✅ | `s.Xgs` 子树含 `s.XDB "Editor"/"Preview"/"Weights"/"Tree"`、`s.nHS "mainMenu"` |
| 确认是 Scene2D + Stage(不是 Swing) | ✅ | scene2d 类 33 个;`swing/awt` 应用类 0;AWT 窗口 0 |
| 拿到渲染线程执行权 | ✅ | 编辑器自己的 `Actions.run(Runnable)`,Action 每帧在渲染线程跑 |
| **在渲染线程上构造出编辑器原生控件** | ✅ | `*** 编辑器侧(渲染线程):按钮+文字已挂上` |
## 二、两个曾经卡了很久的坑(已定位并修掉)
1. **编辑器和启动器是两套类加载器**。同名类不是同一个 Class:
`Class.forName("s.nHS")` 会抛 `ClassNotFoundException`,而对象的 `getClass().getName()` 就是 `s.nHS`。
→ 必须把我们的类 `DefineClass` 进编辑器的加载器,或至少保证"控件类与样式取自同一个节点"。
2. **控件必须在渲染线程上构造**。`Label.<init>` → `getPrefHeight` → 字形生成 → `glBindTexture`,
没有当前 GL 上下文就是 SIGSEGV(`libGL.dylib+0x4bc`)。离线建、大栈线程建都不行。
附带:样式若从"对话框"里的控件取,会让字体类 `s.QjK` 自递归(`<init>` ↔ `I` 无限循环)。
正确做法是从**主界面正在渲染的控件**上取样式。
## 三、当前唯一剩下的问题
控件已经建好、也调用了 `addActor` 挂到编辑器 Stage 根(`s.Xgs`),**但紧接着进程就退出**,
而且 `spine.log` 里**没有崩溃报告**(只有正常启动输出),说明更像是渲染线程抛异常后被应用自身的错误处理结束掉。
已经排除的:
- 构造阶段(已移到渲染线程,不再 SIGSEGV)
- 挂载点选错(自动扫描挂过 40 个容器,都不显示)
## 四、下一步的两个方向
1. **隔离挂在哪个动作上**:只构造不 `addActor` → 再只 `addActor` 不设文字 → 再设文字,逐步定位是
"绘制我们的控件"还是"改 Stage 结构"导致的退出。
2. **换宿主**:不挂 Stage 根,改挂到 `s.azP`(工具条容器)或 `s.KJW`(面板容器),
看是否是 Stage 根不被直接绘制。
## 五、复现方式
```zsh
./viewpanel/build.sh
./viewpanel/run.sh /path/to/project.spine # 或 open -n --env ... Spine.app --args -u 4.2.43 <project>
# 日志:/tmp/spine-viewpanel.log
```
关键代码:
- `viewpanel/native/inject.c` — 注入器;`VPC_defineIntoLoader` 是"定义进编辑器加载器"的入口
- `viewpanel/src/com/spineview/SceneScan.java` — 探针、命名空间识别、渲染线程装配
---
## 六、最终实验结果(项目在此停止)
| 实验 | 做法 | 结果 |
| --- | --- | --- |
| A | 渲染线程上构造按钮+文字,**不挂载** | ✅ **进程存活** —— 构造阶段完全没问题 |
| C | 把界面里**已有**的 `s.nHS` 重新 `addActor` 到同一处 | ❌ **进程退出** —— 不是我们造的控件的问题,是"加子节点"这个动作本身 |
| B | 换宿主 `s.azP` / `s.KJW` / `s.nHS` | ⚠️ 这些类**只有类名没有实例**,不在可达对象里 |
| D | 从 Editor 实例(`com.esotericsoftware.spine.editor.Editor._`)出发找 Stage | ❌ 死路:Editor 直接继承 `Object`,**0 个实例字段**、只有 3 个静态字段(一个 long、一个 null 的 Object[]、它自己),是空壳 |
**结论**:注入链路、类命名空间、渲染线程、原生控件构造全部打通;唯一失败的是
「往 `s.Xgs` 加子节点」这个动作(连挂已有控件也会退出),而 Editor 实例这条路又因为它是空壳而断掉。
### 还没试过的两条路(留给后续)
1. **找到编辑器每帧真正绘制的那棵 UI 根**:`s.Xgs` 很可能只是骨架容器,不是被绘制的那层。
需要继续在 2370 个类名的类表里找"持有 `s.azP` / `s.KJW` 实例的类",从它的静态字段里拿实例。
2. **接管一个已有面板**:不新增节点,改为把某个现有面板的内容替换成我们的按钮+文字
(阶段一验证过"改现有控件的文字"是会渲染的)。
### 已确认可用的能力(后续可直接复用)
- `viewpanel/native/inject.c` 的 `VPC_defineIntoLoader`:把任意类定义进编辑器加载器 `LPt4`
- 类表:`s.LPt4.mc`(`ConcurrentHashMap`,约 2370 个类名),用它能按名字解析出编辑器全部 2500 个类
- 编辑器侧命名空间里可解析:`s.AmK`(Actor) / `s.Xgs`(Group) / `s.nHS` / `s.heZ`(Stage) /
`com.esotericsoftware.spine.editor.Editor` / `com.badlogic.gdx.scenes.scene2d.ui.TextButton` 等
- 渲染线程入口:编辑器自己的 `Actions.run(Runnable)`
- 控件构造:必须在渲染线程上;样式必须取自主界面正在渲染的控件(否则字体类 `s.QjK` 自递归)