diff --git a/UnityRiftCLI/Options/CLIOptions.cs b/UnityRiftCLI/Options/CLIOptions.cs index fc93768..a81bb78 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,18 @@ 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; SkeletonDataAsset/AtlasAsset + // MonoBehaviours give the authoritative grouping when readable. + o_exportAssetTypes.Value = new List + { + ClassIDType.TextAsset, + ClassIDType.Texture2D, + ClassIDType.MonoBehaviour, + }; + 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..570a485 --- /dev/null +++ b/UnityRiftCLI/Studio.Spine.cs @@ -0,0 +1,63 @@ +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(); + 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); + } + + // 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)."); + 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..269e7db --- /dev/null +++ b/UnityRiftGUI/UnityRiftGUIForm.Spine.cs @@ -0,0 +1,90 @@ +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(); + var monoBehaviours = 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); + } + else if (obj.type == ClassIDType.MonoBehaviour) + { + if (obj.Resolve() is MonoBehaviour mb) monoBehaviours.Add(mb); + } + } + + if (textAssets.Count == 0) + { + StatusStripUpdate("No TextAssets loaded — Spine skeletons/atlases are stored as TextAssets."); + return; + } + + // 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."); + 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..cf529db --- /dev/null +++ b/UnityRiftUtility/SpineExtractor/SpineCollector.cs @@ -0,0 +1,178 @@ +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; + // 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); + 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/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; + } + } + } +} 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. | --- 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: