From 9246455886c97de4898d0e993ee5e14253c7397c Mon Sep 17 00:00:00 2001 From: Diphome Date: Mon, 7 Sep 2026 21:04:32 +0200 Subject: [PATCH 1/3] Add Spine (esotericsoftware) model detection + export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect Spine models embedded in Unity assets and export the raw Spine files — skeleton (.json/.skel), atlas (.atlas) and texture pages (.png) — into a per-model folder that re-imports into the Spine editor. - UnityRiftUtility/SpineExtractor: shared detector (atlas/skeleton format heuristics + atlas page parsing), collector (groups skeleton+atlas+textures, by explicit SkeletonDataAsset links when available, else by source assets file) and exporter (writes skeleton/atlas verbatim + PNG pages named exactly as the atlas references them). - CLI: new "-m spine" mode (loads TextAssets + Texture2Ds). - GUI: "Export > To Spine (skeleton + atlas + textures)" menu item, added in code (no Designer edits), mirroring the Godot export wiring. - Texture pages are matched by the atlas page name, so it also works when the skeleton/atlas and the texture pages live in different bundles. Tested on a real IL2CPP WebGL sample (samurai_oni): 1 model, 7 atlas pages, a valid Spine 4.3 skeleton + atlas + 7 PNGs. Co-Authored-By: Clue Opus 4.8 --- UnityRiftCLI/Options/CLIOptions.cs | 16 +- UnityRiftCLI/Program.cs | 3 + UnityRiftCLI/Studio.Spine.cs | 54 ++++++ UnityRiftCLI/Studio.cs | 1 + UnityRiftGUI/UnityRiftGUIForm.Spine.cs | 82 ++++++++ UnityRiftGUI/UnityRiftGUIForm.cs | 1 + .../SpineExtractor/SpineCollector.cs | 175 ++++++++++++++++++ .../SpineExtractor/SpineDetector.cs | 111 +++++++++++ .../SpineExtractor/SpineExporter.cs | 97 ++++++++++ UnityRiftUtility/SpineExtractor/SpineModel.cs | 29 +++ docs/EXPORT_GUIDE.md | 1 + 11 files changed, 568 insertions(+), 2 deletions(-) create mode 100644 UnityRiftCLI/Studio.Spine.cs create mode 100644 UnityRiftGUI/UnityRiftGUIForm.Spine.cs create mode 100644 UnityRiftUtility/SpineExtractor/SpineCollector.cs create mode 100644 UnityRiftUtility/SpineExtractor/SpineDetector.cs create mode 100644 UnityRiftUtility/SpineExtractor/SpineExporter.cs create mode 100644 UnityRiftUtility/SpineExtractor/SpineModel.cs diff --git a/UnityRiftCLI/Options/CLIOptions.cs b/UnityRiftCLI/Options/CLIOptions.cs index fc93768..1c9b114 100644 --- a/UnityRiftCLI/Options/CLIOptions.cs +++ b/UnityRiftCLI/Options/CLIOptions.cs @@ -35,6 +35,7 @@ internal enum WorkMode Godot, GodotScene, GodotScripts, + Spine, } internal enum AssetGroupOption @@ -225,7 +226,7 @@ private static void InitOptions() optionDefaultValue: WorkMode.Export, optionName: "-m, --mode ", optionDescription: "Specify working mode\n" + - "\n" + + "\n" + "Extract - Extract(Decompress) asset bundles\n" + "Export - Convert and export assets\n" + "ExportRaw - Export raw assets\n" + @@ -238,7 +239,8 @@ private static void InitOptions() "Il2Cpp - Generate Il2CppDumper-compatible Ghidra helpers (script.json, il2cpp.h) from GameAssembly/libil2cpp\n" + "Godot - Convert materials to Godot 4 scaffolds (.gdshader + .tres) with their textures\n" + "GodotScene - Export the scene as glTF model(s) + a Godot 4 scene (.tscn) that instances them\n" + - "GodotScripts - Generate Godot GDScript stubs from MonoBehaviours (class + serialized fields; Mono & IL2CPP)\n", + "GodotScripts - Generate Godot GDScript stubs from MonoBehaviours (class + serialized fields; Mono & IL2CPP)\n" + + "Spine - Detect and export Spine (esotericsoftware) models (skeleton + atlas + texture pages)\n", optionExample: "Example: \"-m info\"\n", optionHelpGroup: HelpGroups.General ); @@ -891,6 +893,16 @@ public static void ParseArgs(string[] args) ClassIDType.Texture2D, }; break; + case "spine": + o_workMode.Value = WorkMode.Spine; + // Spine skeletons (.json/.skel) and atlases (.atlas) are TextAssets; the + // atlas texture pages are Texture2Ds. Load just those. + o_exportAssetTypes.Value = new List + { + ClassIDType.TextAsset, + ClassIDType.Texture2D, + }; + break; default: Console.WriteLine($"{"Error".Color(brightRed)} during parsing [{option.Color(brightYellow)}] option. Unsupported working mode: [{value.Color(brightRed)}].\n"); ShowOptionDescription(o_workMode); diff --git a/UnityRiftCLI/Program.cs b/UnityRiftCLI/Program.cs index 2d30efc..8b60687 100644 --- a/UnityRiftCLI/Program.cs +++ b/UnityRiftCLI/Program.cs @@ -79,6 +79,9 @@ private static void CLIRun() case WorkMode.GodotScripts: Studio.ExportGodotScripts(); break; + case WorkMode.Spine: + Studio.ExportSpine(); + break; default: Studio.ExportAssets(); break; diff --git a/UnityRiftCLI/Studio.Spine.cs b/UnityRiftCLI/Studio.Spine.cs new file mode 100644 index 0000000..17681ad --- /dev/null +++ b/UnityRiftCLI/Studio.Spine.cs @@ -0,0 +1,54 @@ +using UnityRift; +using UnityRiftCLI.Options; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using SpineExtractor; +using Ansi = UnityRift.ColorConsole; + +namespace UnityRiftCLI +{ + /// + /// "-m spine": detect Spine (esotericsoftware) models embedded in the loaded assets and export + /// the raw Spine files (skeleton + atlas + texture pages) into a per-model folder, ready to + /// re-import into the Spine editor. + /// + internal static partial class Studio + { + public static void ExportSpine() + { + var outRoot = CLIOptions.o_outputFolder.Value; + var overwrite = CLIOptions.f_overwriteExisting.Value; + + var textAssets = new List(); + var textures = new List(); + foreach (var item in parsedAssetsList) + { + if (item.Type == ClassIDType.TextAsset && item.Asset is TextAsset ta) + textAssets.Add(ta); + else if (item.Type == ClassIDType.Texture2D && item.Asset is Texture2D tex) + textures.Add(tex); + } + + var models = SpineCollector.Collect(textAssets, textures, explicitLinks: null, log: msg => Logger.Info(msg)); + if (models.Count == 0) + { + Logger.Warning("No Spine models detected. Spine skeletons (.json/.skel) and atlases (.atlas) are stored as TextAssets; make sure the file/bundle that contains them is loaded (for WebGL builds, point at the extracted data.unity3d)."); + return; + } + + Logger.Info($"Detected {models.Count} Spine model(s). Exporting to \"{outRoot.Color(Ansi.BrightCyan)}\"..."); + Directory.CreateDirectory(outRoot); + + var exported = 0; + foreach (var model in models) + { + var dir = SpineExporter.Export(model, outRoot, overwrite, msg => Logger.Info(msg)); + if (dir != null) + exported++; + } + + Logger.Info($"Finished exporting {exported} Spine model(s) to \"{outRoot.Color(Ansi.BrightCyan)}\"."); + } + } +} diff --git a/UnityRiftCLI/Studio.cs b/UnityRiftCLI/Studio.cs index 5f9ea81..f329979 100644 --- a/UnityRiftCLI/Studio.cs +++ b/UnityRiftCLI/Studio.cs @@ -561,6 +561,7 @@ public static void Filter() case WorkMode.Live2D: case WorkMode.SplitObjects: case WorkMode.Animator: + case WorkMode.Spine: break; default: FilterAssets(); diff --git a/UnityRiftGUI/UnityRiftGUIForm.Spine.cs b/UnityRiftGUI/UnityRiftGUIForm.Spine.cs new file mode 100644 index 0000000..752fd38 --- /dev/null +++ b/UnityRiftGUI/UnityRiftGUIForm.Spine.cs @@ -0,0 +1,82 @@ +using UnityRift; +using SpineExtractor; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows.Forms; +using static UnityRiftGUI.Studio; + +namespace UnityRiftGUI +{ + // Spine (esotericsoftware) export, added in code so no Designer edits are needed. Mirrors the + // CLI "-m spine" mode, reusing the shared detector/exporter in UnityRiftUtility (SpineExtractor). + partial class UnityRiftGUIForm + { + private void InitSpineMenu() + { + var item = new ToolStripMenuItem("To Spine (skeleton + atlas + textures)") + { + Name = "exportSpineMenuItem", + }; + item.Click += exportSpineMenuItem_Click; + exportToolStripMenuItem.DropDownItems.Add(item); + } + + private void exportSpineMenuItem_Click(object sender, EventArgs e) + { + // Gather the Spine inputs. TextAssets are lazy objects, so resolve by type rather than + // pattern-matching the placeholder. + var textAssets = new List(); + var textures = new List(); + foreach (var file in assetsManager.AssetsFileList) + foreach (var obj in file.Objects) + { + if (obj.type == ClassIDType.TextAsset) + { + if (obj.Resolve() is TextAsset ta) textAssets.Add(ta); + } + else if (obj.type == ClassIDType.Texture2D) + { + if (obj.Resolve() is Texture2D tex) textures.Add(tex); + } + } + + if (textAssets.Count == 0) + { + StatusStripUpdate("No TextAssets loaded — Spine skeletons/atlases are stored as TextAssets."); + return; + } + + var models = SpineCollector.Collect(textAssets, textures, explicitLinks: null, log: msg => Logger.Info(msg)); + if (models.Count == 0) + { + StatusStripUpdate("No Spine models detected in the loaded assets."); + return; + } + + var dialog = new OpenFolderDialog { InitialFolder = saveDirectoryBackup }; + if (dialog.ShowDialog(this) != DialogResult.OK) + return; + saveDirectoryBackup = dialog.Folder; + var outRoot = dialog.Folder; + + timer.Stop(); + StatusStripUpdate($"Exporting {models.Count} Spine model(s)..."); + Task.Run(() => + { + var exported = 0; + try + { + foreach (var model in models) + if (SpineExporter.Export(model, outRoot, overwrite: true, log: msg => Logger.Info(msg)) != null) + exported++; + } + catch (Exception ex) + { + Logger.Error($"Spine export failed: {ex.Message}"); + } + StatusStripUpdate($"Spine export done: {exported} model(s) -> {outRoot}"); + }); + } + } +} diff --git a/UnityRiftGUI/UnityRiftGUIForm.cs b/UnityRiftGUI/UnityRiftGUIForm.cs index e81d3f7..467413f 100644 --- a/UnityRiftGUI/UnityRiftGUIForm.cs +++ b/UnityRiftGUI/UnityRiftGUIForm.cs @@ -211,6 +211,7 @@ public UnityRiftGUIForm() InitDotNetTab(); InitRecentProjectsMenu(); InitGodotExportMenu(); + InitSpineMenu(); WrapTreeSearch(); WrapListSearch(); ApplyUiFonts(); diff --git a/UnityRiftUtility/SpineExtractor/SpineCollector.cs b/UnityRiftUtility/SpineExtractor/SpineCollector.cs new file mode 100644 index 0000000..52fa87b --- /dev/null +++ b/UnityRiftUtility/SpineExtractor/SpineCollector.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityRift; + +namespace SpineExtractor +{ + // An explicit skeleton -> atlas(es) association discovered by reading a spine-unity + // SkeletonDataAsset MonoBehaviour (the authoritative grouping). Frontends that can read those + // fields supply these; when none are available the collector falls back to heuristics. + public class SpineLink + { + public string Name; // preferred model name (e.g. SkeletonDataAsset name) + public TextAsset Skeleton; + public List Atlases = new List(); + } + + // Groups classified Spine TextAssets and textures into exportable models. Grouping prefers + // explicit SkeletonDataAsset links (hybrid: the MonoBehaviour path), then falls back to + // grouping by source assets file, so it also works on IL2CPP builds whose MonoBehaviour + // fields cannot be read without generated assemblies. + public static class SpineCollector + { + public static List Collect( + IReadOnlyList textAssets, + IReadOnlyList textures, + IReadOnlyList explicitLinks = null, + Action log = null) + { + var models = new List(); + if (textAssets == null || textAssets.Count == 0) + return models; + + // Build a case-insensitive texture lookup keyed by both the bare name and, if the name + // already carries an extension, its stem. First writer wins on collisions. + var textureByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var tex in textures ?? Array.Empty()) + { + if (tex == null || string.IsNullOrEmpty(tex.m_Name)) continue; + if (!textureByName.ContainsKey(tex.m_Name)) textureByName[tex.m_Name] = tex; + var stem = Path.GetFileNameWithoutExtension(tex.m_Name); + if (!string.IsNullOrEmpty(stem) && !textureByName.ContainsKey(stem)) textureByName[stem] = tex; + } + + // Classify text assets. + var skeletons = new List(); + var atlases = new List(); + foreach (var ta in textAssets) + { + if (ta?.m_Script == null || ta.m_Script.Length == 0) continue; + if (SpineDetector.IsSpineAtlas(ta.m_Script)) + atlases.Add(ta); + else if (SpineDetector.IsSpineSkeletonJson(ta.m_Script) || SpineDetector.IsSpineSkeletonBinaryName(ta.m_Name)) + skeletons.Add(ta); + } + + var usedSkeletons = new HashSet(); + var usedAtlases = new HashSet(); + + // 1) Explicit SkeletonDataAsset links (authoritative). + if (explicitLinks != null) + { + foreach (var link in explicitLinks) + { + if (link?.Skeleton?.m_Script == null || link.Skeleton.m_Script.Length == 0) continue; + var model = BuildModel(link.Name, link.Skeleton, link.Atlases, textureByName, log); + if (model == null) continue; + usedSkeletons.Add(link.Skeleton); + foreach (var a in link.Atlases) usedAtlases.Add(a); + models.Add(model); + } + } + + // 2) Heuristic grouping of the remainder, by source assets file. + var remSkeletons = skeletons.Where(s => !usedSkeletons.Contains(s)).ToList(); + var remAtlases = atlases.Where(a => !usedAtlases.Contains(a)).ToList(); + + foreach (var group in remSkeletons.GroupBy(s => s.assetsFile)) + { + var groupSkeletons = group.ToList(); + var groupAtlases = remAtlases.Where(a => a.assetsFile == group.Key).ToList(); + + if (groupSkeletons.Count == 1) + { + // One skeleton in this file: it owns every atlas in the same file. + var model = BuildModel(null, groupSkeletons[0], groupAtlases, textureByName, log); + if (model != null) models.Add(model); + } + else + { + // Multiple skeletons: pair each with the atlas whose stem best matches, else + // give it any atlas sharing its stem; unmatched atlases attach to the first. + foreach (var sk in groupSkeletons) + { + var stem = Path.GetFileNameWithoutExtension(sk.m_Name); + var matched = groupAtlases + .Where(a => NameStem(a).Equals(stem, StringComparison.OrdinalIgnoreCase)) + .ToList(); + var model = BuildModel(null, sk, matched.Count > 0 ? matched : groupAtlases, textureByName, log); + if (model != null) models.Add(model); + } + } + } + + return models; + } + + private static string NameStem(TextAsset ta) + { + var n = ta.m_Name ?? ""; + // Spine atlas assets are often named "foo.atlas"; strip the trailing ".atlas". + if (n.EndsWith(".atlas", StringComparison.OrdinalIgnoreCase)) + n = n.Substring(0, n.Length - ".atlas".Length); + return Path.GetFileNameWithoutExtension(n); + } + + private static SpineModel BuildModel( + string preferredName, + TextAsset skeleton, + IReadOnlyList atlasTextAssets, + Dictionary textureByName, + Action log) + { + var kind = SpineDetector.IsSpineSkeletonJson(skeleton.m_Script) + ? SpineSkeletonKind.Json + : SpineSkeletonKind.Binary; + + var model = new SpineModel + { + SkeletonData = skeleton.m_Script, + Kind = kind, + }; + + foreach (var ta in atlasTextAssets ?? (IReadOnlyList)Array.Empty()) + { + if (ta?.m_Script == null) continue; + var atlas = new SpineAtlasFile + { + Name = NameStem(ta), + Data = ta.m_Script, + }; + var text = System.Text.Encoding.UTF8.GetString(ta.m_Script); + foreach (var pageName in SpineDetector.ParseAtlasPageNames(text)) + { + var stem = Path.GetFileNameWithoutExtension(pageName); + textureByName.TryGetValue(pageName, out var tex); + if (tex == null) textureByName.TryGetValue(stem, out tex); + if (tex == null) + log?.Invoke($"Spine: atlas page \"{pageName}\" has no matching texture; it will be skipped."); + atlas.Pages.Add(new SpinePage { FileName = pageName, Texture = tex }); + } + model.Atlases.Add(atlas); + } + + // Name the model: explicit name, else the single atlas stem, else the skeleton name. + model.Name = !string.IsNullOrEmpty(preferredName) + ? preferredName + : (model.Atlases.Count == 1 && !string.IsNullOrEmpty(model.Atlases[0].Name) + ? model.Atlases[0].Name + : StemOrDefault(skeleton.m_Name, "spine_model")); + + if (model.Atlases.Count == 0) + log?.Invoke($"Spine: skeleton \"{model.Name}\" has no matching atlas; exporting skeleton only."); + return model; + } + + private static string StemOrDefault(string name, string fallback) + { + if (string.IsNullOrEmpty(name)) return fallback; + var stem = Path.GetFileNameWithoutExtension(name); + return string.IsNullOrEmpty(stem) ? fallback : stem; + } + } +} diff --git a/UnityRiftUtility/SpineExtractor/SpineDetector.cs b/UnityRiftUtility/SpineExtractor/SpineDetector.cs new file mode 100644 index 0000000..e70c40c --- /dev/null +++ b/UnityRiftUtility/SpineExtractor/SpineDetector.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SpineExtractor +{ + public enum SpineSkeletonKind + { + None, + Json, + Binary, + } + + // Format heuristics for detecting Spine (esotericsoftware) assets embedded in Unity as + // TextAssets, and for reading the texture-page names out of a Spine atlas. All checks are + // deliberately conservative (they key off Spine's very specific atlas header lines and the + // JSON skeleton shape) so unrelated TextAssets are not misclassified. + public static class SpineDetector + { + // A Spine JSON skeleton is a JSON object carrying a "skeleton" block plus "bones"/"slots". + public static bool IsSpineSkeletonJson(byte[] data) + { + if (data == null || data.Length < 16) + return false; + + var i = 0; + if (data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF) + i = 3; // skip UTF-8 BOM + while (i < data.Length && (data[i] == ' ' || data[i] == '\t' || data[i] == '\r' || data[i] == '\n')) + i++; + if (i >= data.Length || data[i] != (byte)'{') + return false; + + var head = Encoding.UTF8.GetString(data, 0, Math.Min(data.Length, 8192)); + return head.Contains("\"skeleton\"") && (head.Contains("\"bones\"") || head.Contains("\"slots\"")); + } + + // Spine binary skeletons carry no fixed magic number, so they are detected by the + // conventional file name/extension used by spine-unity exports. + public static bool IsSpineSkeletonBinaryName(string name) + { + if (string.IsNullOrEmpty(name)) + return false; + var n = name.ToLowerInvariant(); + return n.EndsWith(".skel") || n.EndsWith(".skel.bytes") || n.EndsWith(".skel.txt"); + } + + // A Spine atlas is text whose per-page header carries the distinctive "size:", "filter:" + // and "format:"/"repeat:"/"pma:" lines. + public static bool IsSpineAtlas(byte[] data) + { + if (data == null || data.Length < 16) + return false; + + string head; + try + { + head = Encoding.UTF8.GetString(data, 0, Math.Min(data.Length, 16384)); + } + catch + { + return false; + } + + bool hasSize = false, hasFilter = false, hasFormatOrRepeat = false; + foreach (var raw in head.Split('\n')) + { + var line = raw.Trim(); + if (StartsWithKey(line, "size")) hasSize = true; + else if (StartsWithKey(line, "filter")) hasFilter = true; + else if (StartsWithKey(line, "format") || StartsWithKey(line, "repeat") || StartsWithKey(line, "pma")) hasFormatOrRepeat = true; + } + return hasSize && hasFilter && hasFormatOrRepeat; + } + + private static bool StartsWithKey(string line, string key) + { + // matches "key:" and "key :" (some exporters pad before the colon) + if (!line.StartsWith(key, StringComparison.OrdinalIgnoreCase)) + return false; + var rest = line.Substring(key.Length).TrimStart(); + return rest.StartsWith(":"); + } + + // The image page files referenced by an atlas. Pages are the first line of the file and + // any line that immediately follows a blank line; the header/region lines in between are + // skipped. + public static List ParseAtlasPageNames(string atlasText) + { + var pages = new List(); + if (string.IsNullOrEmpty(atlasText)) + return pages; + + var expectPage = true; + foreach (var raw in atlasText.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n')) + { + if (raw.Trim().Length == 0) + { + expectPage = true; + continue; + } + if (expectPage) + { + pages.Add(raw.Trim()); + expectPage = false; + } + } + return pages; + } + } +} diff --git a/UnityRiftUtility/SpineExtractor/SpineExporter.cs b/UnityRiftUtility/SpineExtractor/SpineExporter.cs new file mode 100644 index 0000000..3204b97 --- /dev/null +++ b/UnityRiftUtility/SpineExtractor/SpineExporter.cs @@ -0,0 +1,97 @@ +using System; +using System.IO; +using UnityRift; + +namespace SpineExtractor +{ + // Writes a detected Spine model back out as the raw files Spine can re-import: the skeleton + // (.json or .skel), each atlas (.atlas) verbatim, and one PNG per atlas page named exactly as + // the atlas references it. + public static class SpineExporter + { + // Exports the model into a subfolder of baseDir named after the model. Returns the folder + // path, or null if nothing was written. + public static string Export(SpineModel model, string baseDir, bool overwrite = true, Action log = null) + { + if (model?.SkeletonData == null || model.SkeletonData.Length == 0) + return null; + + var name = Sanitize(model.Name); + var dir = Path.Combine(baseDir, name); + Directory.CreateDirectory(dir); + + var skeletonExt = model.Kind == SpineSkeletonKind.Binary ? ".skel" : ".json"; + WriteFile(Path.Combine(dir, name + skeletonExt), model.SkeletonData, overwrite, log); + + foreach (var atlas in model.Atlases) + { + var atlasName = Sanitize(string.IsNullOrEmpty(atlas.Name) ? name : atlas.Name); + WriteFile(Path.Combine(dir, atlasName + ".atlas"), atlas.Data, overwrite, log); + + foreach (var page in atlas.Pages) + { + if (page.Texture == null) + continue; // already warned during collection + var pageFile = Path.Combine(dir, SanitizeFileName(page.FileName)); + if (!overwrite && File.Exists(pageFile)) + continue; + try + { + using (var stream = page.Texture.ConvertToStream(ImageFormat.Png, flip: true)) + { + if (stream == null) + { + log?.Invoke($"Spine: failed to decode texture for page \"{page.FileName}\"."); + continue; + } + using (var fs = File.OpenWrite(pageFile)) + { + fs.SetLength(0); + stream.Position = 0; + stream.CopyTo(fs); + } + } + } + catch (Exception ex) + { + log?.Invoke($"Spine: error exporting page \"{page.FileName}\": {ex.Message}"); + } + } + } + + log?.Invoke($"Spine: exported model \"{name}\" to \"{dir}\"."); + return dir; + } + + private static void WriteFile(string path, byte[] data, bool overwrite, Action log) + { + try + { + if (!overwrite && File.Exists(path)) + return; + File.WriteAllBytes(path, data); + } + catch (Exception ex) + { + log?.Invoke($"Spine: error writing \"{Path.GetFileName(path)}\": {ex.Message}"); + } + } + + // The atlas page name should be kept as-is (the atlas references it verbatim); only strip + // characters that are illegal in file names. + private static string SanitizeFileName(string name) + { + foreach (var c in Path.GetInvalidFileNameChars()) + name = name.Replace(c, '_'); + return name; + } + + private static string Sanitize(string name) + { + if (string.IsNullOrEmpty(name)) return "spine_model"; + foreach (var c in Path.GetInvalidFileNameChars()) + name = name.Replace(c, '_'); + return name; + } + } +} diff --git a/UnityRiftUtility/SpineExtractor/SpineModel.cs b/UnityRiftUtility/SpineExtractor/SpineModel.cs new file mode 100644 index 0000000..ab0e2f5 --- /dev/null +++ b/UnityRiftUtility/SpineExtractor/SpineModel.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using UnityRift; + +namespace SpineExtractor +{ + // A single texture page referenced by a Spine atlas, resolved to the Unity texture that + // provides its pixels (Texture may be null if no matching texture was found). + public class SpinePage + { + public string FileName; // name exactly as written in the atlas, e.g. "samurai_oni.png" + public Texture2D Texture; // resolved atlas-page texture, or null if unresolved + } + + public class SpineAtlasFile + { + public string Name; // atlas asset name (without extension) + public byte[] Data; // raw atlas text bytes, written verbatim + public List Pages = new List(); + } + + // A detected Spine model: one skeleton plus the atlas(es) and texture pages that belong to it. + public class SpineModel + { + public string Name; // model/output-folder name + public byte[] SkeletonData; // raw skeleton bytes (JSON text or binary .skel) + public SpineSkeletonKind Kind = SpineSkeletonKind.None; + public List Atlases = new List(); + } +} diff --git a/docs/EXPORT_GUIDE.md b/docs/EXPORT_GUIDE.md index 2ed59c6..351bfd9 100644 --- a/docs/EXPORT_GUIDE.md +++ b/docs/EXPORT_GUIDE.md @@ -22,6 +22,7 @@ they are `-m ` and `-t `. | `live2d` | Exports **Live2D Cubism** models (model, textures, motions, physics). | 2D "VTuber"-style animated characters. | | `splitObjects` | Exports **each 3D model object separately** (FBX/glTF/GLB). | Pull individual props/characters out of a scene. | | `animator` | Exports **Animator assets as rigged models** with their AnimationClips. | Characters/objects **with their animations**. | +| `spine` | Detects **Spine (esotericsoftware)** models and writes the raw Spine files — skeleton (`.json`/`.skel`) + atlas (`.atlas`) + texture pages (`.png`) — into a per-model folder. | 2D skeletal characters made in Spine; re-import the folder into the Spine editor. | --- From 5c3be0bc64b619074772ce13a692bc988d1e5897 Mon Sep 17 00:00:00 2001 From: Diphome Date: Mon, 7 Sep 2026 21:24:55 +0200 Subject: [PATCH 2/3] mcp: add spine_export tool Expose the new "-m spine" mode as an MCP tool (mirrors godot_export): detect and export Spine models (skeleton + atlas + texture pages) to a per-model folder. Notes that pages may live in a separate bundle, so pass a folder containing both. Co-Authored-By: Clue Opus 4.8 --- tools/mcp/unityrift-mcp.mjs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tools/mcp/unityrift-mcp.mjs b/tools/mcp/unityrift-mcp.mjs index 62762a1..727b5b9 100644 --- a/tools/mcp/unityrift-mcp.mjs +++ b/tools/mcp/unityrift-mcp.mjs @@ -599,6 +599,40 @@ const tools = [ return resultText(r); }, }, + { + name: "spine_export", + description: + "Detect and export Spine (esotericsoftware) 2D skeletal models (CLI '-m spine'): for each detected " + + "model it writes the raw Spine files into a per-model subfolder — the skeleton (.json or .skel), the " + + "atlas (.atlas), and one .png per atlas page named exactly as the atlas references it — ready to " + + "re-import into the Spine editor. The skeleton and atlas are stored as Unity TextAssets and the pages " + + "as Texture2Ds; point input_path at the file/bundle (or a folder) that contains them. When the pages " + + "live in a different bundle than the skeleton/atlas, put both in one folder and pass that folder.", + inputSchema: { + type: "object", + properties: { + input_path: { type: "string", description: "Path to an asset file or folder that contains the Spine TextAssets and their atlas-page textures." }, + output_path: { type: "string", description: `Output folder (each model goes to /). Default: ${defaultOutDir()}` }, + overwrite: { type: "boolean", description: "Overwrite existing files." }, + unity_version: { type: "string" }, + ...typeTreeDbProp, + log_level: { type: "string", enum: ["verbose", "debug", "info", "warning", "error"] }, + timeout_sec: { type: "number", description: `Timeout in seconds (default ${DEFAULT_TIMEOUT}).` }, + }, + required: ["input_path"], + }, + handler: async (a) => { + const out = a.output_path || defaultOutDir(); + const args = [a.input_path, "-m", "spine", "-o", out]; + if (a.overwrite) args.push("-r"); + if (a.unity_version) args.push("--unity-version", a.unity_version); + if (a.typetree_db) args.push("--typetree-db", a.typetree_db); + if (a.log_level) args.push("--log-level", a.log_level); + const r = await runCli(args, a.timeout_sec || DEFAULT_TIMEOUT); + if (r.ok) r.output += `\n\n[output folder: ${out}]`; + return resultText(r); + }, + }, { name: "list_output", description: From d080327df8fc81f09a674895a7c74c295b71897e Mon Sep 17 00:00:00 2001 From: Diphome Date: Mon, 7 Sep 2026 21:30:41 +0200 Subject: [PATCH 3/3] spine: wire SkeletonDataAsset MonoBehaviour grouping (hybrid) Read spine-unity SkeletonDataAsset/AtlasAsset MonoBehaviours to recover the authoritative skeleton -> atlas grouping, following the Cubism ToType/PPtr pattern: skeletonJSON + atlasAssets[].atlasFile resolve to the skeleton and atlas TextAssets. Fields are read from the serialized type tree when present, else from loaded/generated assemblies; when neither is available (IL2CPP with stripped type trees and no assemblies) it returns nothing and the format heuristics take over. Both frontends now gather MonoBehaviours and pass the links to SpineCollector; the CLI spine mode loads MonoBehaviour too. Verified on the IL2CPP WebGL sample: the MonoBehaviour path can't read the stripped SkeletonDataAsset there, so it falls back to the heuristic with identical, correct output (skeleton + atlas + 7 pages). Co-Authored-By: Clue Opus 4.8 --- UnityRiftCLI/Options/CLIOptions.cs | 4 +- UnityRiftCLI/Studio.Spine.cs | 11 +- UnityRiftGUI/UnityRiftGUIForm.Spine.cs | 10 +- .../SpineExtractor/SpineCollector.cs | 3 + .../SpineMonoBehaviourReader.cs | 153 ++++++++++++++++++ 5 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 UnityRiftUtility/SpineExtractor/SpineMonoBehaviourReader.cs diff --git a/UnityRiftCLI/Options/CLIOptions.cs b/UnityRiftCLI/Options/CLIOptions.cs index 1c9b114..a81bb78 100644 --- a/UnityRiftCLI/Options/CLIOptions.cs +++ b/UnityRiftCLI/Options/CLIOptions.cs @@ -896,11 +896,13 @@ public static void ParseArgs(string[] args) case "spine": o_workMode.Value = WorkMode.Spine; // Spine skeletons (.json/.skel) and atlases (.atlas) are TextAssets; the - // atlas texture pages are Texture2Ds. Load just those. + // atlas texture pages are Texture2Ds; SkeletonDataAsset/AtlasAsset + // MonoBehaviours give the authoritative grouping when readable. o_exportAssetTypes.Value = new List { ClassIDType.TextAsset, ClassIDType.Texture2D, + ClassIDType.MonoBehaviour, }; break; default: diff --git a/UnityRiftCLI/Studio.Spine.cs b/UnityRiftCLI/Studio.Spine.cs index 17681ad..570a485 100644 --- a/UnityRiftCLI/Studio.Spine.cs +++ b/UnityRiftCLI/Studio.Spine.cs @@ -22,15 +22,24 @@ public static void ExportSpine() var textAssets = new List(); var textures = new List(); + var monoBehaviours = new List(); foreach (var item in parsedAssetsList) { if (item.Type == ClassIDType.TextAsset && item.Asset is TextAsset ta) textAssets.Add(ta); else if (item.Type == ClassIDType.Texture2D && item.Asset is Texture2D tex) textures.Add(tex); + else if (item.Type == ClassIDType.MonoBehaviour && item.Asset is MonoBehaviour mb) + monoBehaviours.Add(mb); } - var models = SpineCollector.Collect(textAssets, textures, explicitLinks: null, log: msg => Logger.Info(msg)); + // Hybrid: authoritative SkeletonDataAsset grouping when the MonoBehaviour fields are + // readable (serialized type trees, or loaded/generated assemblies), else heuristics. + var links = SpineMonoBehaviourReader.BuildLinks(monoBehaviours, assemblyLoader, msg => Logger.Info(msg)); + if (links.Count > 0) + Logger.Info($"Spine: grouped {links.Count} model(s) from SkeletonDataAsset MonoBehaviour(s)."); + + var models = SpineCollector.Collect(textAssets, textures, explicitLinks: links, log: msg => Logger.Info(msg)); if (models.Count == 0) { Logger.Warning("No Spine models detected. Spine skeletons (.json/.skel) and atlases (.atlas) are stored as TextAssets; make sure the file/bundle that contains them is loaded (for WebGL builds, point at the extracted data.unity3d)."); diff --git a/UnityRiftGUI/UnityRiftGUIForm.Spine.cs b/UnityRiftGUI/UnityRiftGUIForm.Spine.cs index 752fd38..269e7db 100644 --- a/UnityRiftGUI/UnityRiftGUIForm.Spine.cs +++ b/UnityRiftGUI/UnityRiftGUIForm.Spine.cs @@ -28,6 +28,7 @@ private void exportSpineMenuItem_Click(object sender, EventArgs e) // pattern-matching the placeholder. var textAssets = new List(); var textures = new List(); + var monoBehaviours = new List(); foreach (var file in assetsManager.AssetsFileList) foreach (var obj in file.Objects) { @@ -39,6 +40,10 @@ private void exportSpineMenuItem_Click(object sender, EventArgs e) { if (obj.Resolve() is Texture2D tex) textures.Add(tex); } + else if (obj.type == ClassIDType.MonoBehaviour) + { + if (obj.Resolve() is MonoBehaviour mb) monoBehaviours.Add(mb); + } } if (textAssets.Count == 0) @@ -47,7 +52,10 @@ private void exportSpineMenuItem_Click(object sender, EventArgs e) return; } - var models = SpineCollector.Collect(textAssets, textures, explicitLinks: null, log: msg => Logger.Info(msg)); + // Hybrid: authoritative SkeletonDataAsset grouping when the MonoBehaviour fields are + // readable (serialized type trees, or assemblies loaded via the .NET menu), else heuristics. + var links = SpineMonoBehaviourReader.BuildLinks(monoBehaviours, assemblyLoader, msg => Logger.Info(msg)); + var models = SpineCollector.Collect(textAssets, textures, explicitLinks: links, log: msg => Logger.Info(msg)); if (models.Count == 0) { StatusStripUpdate("No Spine models detected in the loaded assets."); diff --git a/UnityRiftUtility/SpineExtractor/SpineCollector.cs b/UnityRiftUtility/SpineExtractor/SpineCollector.cs index 52fa87b..cf529db 100644 --- a/UnityRiftUtility/SpineExtractor/SpineCollector.cs +++ b/UnityRiftUtility/SpineExtractor/SpineCollector.cs @@ -64,6 +64,9 @@ public static List Collect( foreach (var link in explicitLinks) { if (link?.Skeleton?.m_Script == null || link.Skeleton.m_Script.Length == 0) continue; + // A link with no atlas is worse than the heuristic (which can still attach one + // by source file), so leave it for the fallback. + if (link.Atlases == null || link.Atlases.Count == 0) continue; var model = BuildModel(link.Name, link.Skeleton, link.Atlases, textureByName, log); if (model == null) continue; usedSkeletons.Add(link.Skeleton); diff --git a/UnityRiftUtility/SpineExtractor/SpineMonoBehaviourReader.cs b/UnityRiftUtility/SpineExtractor/SpineMonoBehaviourReader.cs new file mode 100644 index 0000000..97657ce --- /dev/null +++ b/UnityRiftUtility/SpineExtractor/SpineMonoBehaviourReader.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using UnityRift; + +namespace SpineExtractor +{ + // Reads spine-unity SkeletonDataAsset / AtlasAsset MonoBehaviours to recover the authoritative + // skeleton -> atlas grouping (the MonoBehaviour half of the hybrid detector). Fields are read + // through the serialized type tree when present, otherwise from generated assemblies via + // ConvertToTypeTree(assemblyLoader); when neither yields the custom fields (e.g. IL2CPP with + // stripped type trees and no assemblies), reading returns null and the caller falls back to the + // format heuristics. + public static class SpineMonoBehaviourReader + { + public static bool IsSkeletonDataAsset(string className) => + string.Equals(className, "SkeletonDataAsset", StringComparison.Ordinal); + + public static bool IsAtlasAsset(string className) => + string.Equals(className, "AtlasAsset", StringComparison.Ordinal) || + string.Equals(className, "SpineAtlasAsset", StringComparison.Ordinal); + + // Builds explicit links from every SkeletonDataAsset MonoBehaviour in the set. Only links + // that resolve both a skeleton and at least one atlas are returned; anything unreadable is + // left for the heuristic path. + public static List BuildLinks(IEnumerable monoBehaviours, AssemblyLoader assembly, Action log = null) + { + var links = new List(); + if (monoBehaviours == null) + return links; + + foreach (var mb in monoBehaviours) + { + if (mb == null || !mb.m_Script.TryGet(out var script) || !IsSkeletonDataAsset(script.m_ClassName)) + continue; + + SpineLink link; + try + { + link = TryReadSkeletonDataAsset(mb, assembly); + } + catch (Exception ex) + { + log?.Invoke($"Spine: could not read SkeletonDataAsset \"{mb.m_Name}\": {ex.Message}"); + continue; + } + if (link != null && link.Skeleton != null && link.Atlases.Count > 0) + links.Add(link); + } + return links; + } + + private static SpineLink TryReadSkeletonDataAsset(MonoBehaviour mb, AssemblyLoader assembly) + { + var dict = ParseMonoBehaviour(mb, assembly); + if (dict == null) + return null; + + var skeleton = ResolvePPtr(dict, mb.assetsFile, "skeletonJSON", "skeletonDataFile", "skeleton"); + if (skeleton == null) + return null; + + var link = new SpineLink { Skeleton = skeleton, Name = mb.m_Name }; + + var atlasArray = Get(dict, "atlasAssets") as object[]; + if (atlasArray != null) + { + foreach (var el in atlasArray) + { + var atlasMb = ResolvePPtrFromDict(el as OrderedDictionary, mb.assetsFile); + if (atlasMb == null) + continue; + var atlasText = TryReadAtlasAsset(atlasMb, assembly); + if (atlasText != null) + link.Atlases.Add(atlasText); + } + } + return link; + } + + private static TextAsset TryReadAtlasAsset(MonoBehaviour mb, AssemblyLoader assembly) + { + var dict = ParseMonoBehaviour(mb, assembly); + if (dict == null) + return null; + return ResolvePPtr(dict, mb.assetsFile, "atlasFile", "atlasText", "atlas"); + } + + private static OrderedDictionary ParseMonoBehaviour(MonoBehaviour mb, AssemblyLoader assembly) + { + var dict = mb.ToType(); + if (dict != null) + return dict; + // No usable serialized type tree. Only reconstruct from assemblies when some are loaded; + // otherwise skip (avoids noisy failed reads on IL2CPP builds with no assemblies). + if (assembly == null || !assembly.Loaded) + return null; + try + { + var type = mb.ConvertToTypeTree(assembly); + return mb.ToType(type); + } + catch + { + return null; + } + } + + // Case-insensitive lookup: serialized type trees preserve the field name casing, but + // assembly-derived trees can differ, so match keys loosely. + private static object Get(OrderedDictionary dict, string key) + { + if (dict == null) return null; + if (dict.Contains(key)) return dict[key]; + foreach (DictionaryEntry e in dict) + if (e.Key is string k && string.Equals(k, key, StringComparison.OrdinalIgnoreCase)) + return e.Value; + return null; + } + + private static T ResolvePPtr(OrderedDictionary dict, SerializedFile assetsFile, params string[] keys) where T : UnityRift.Object + { + foreach (var key in keys) + { + var result = ResolvePPtrFromDict(Get(dict, key) as OrderedDictionary, assetsFile); + if (result != null) + return result; + } + return null; + } + + private static T ResolvePPtrFromDict(OrderedDictionary pptr, SerializedFile assetsFile) where T : UnityRift.Object + { + if (pptr == null || !pptr.Contains("m_PathID")) + return null; + try + { + var reference = new PPtr + { + m_FileID = Convert.ToInt32(pptr["m_FileID"]), + m_PathID = Convert.ToInt64(pptr["m_PathID"]), + AssetsFile = assetsFile, + }; + return reference.TryGet(out var result) ? result : null; + } + catch + { + return null; + } + } + } +}