From a408af725316d11ec3f366ac5d6622d6d8b778b9 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sat, 27 Jun 2026 18:51:23 +0800 Subject: [PATCH 01/56] Update Rely --- AnimeStudio.CLI/AnimeStudio.CLI.csproj | 4 ++-- AnimeStudio.GUI/AnimeStudio.GUI.csproj | 2 +- AnimeStudio.Utility/AnimeStudio.Utility.csproj | 4 ++-- AnimeStudio/AnimeStudio.csproj | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/AnimeStudio.CLI/AnimeStudio.CLI.csproj b/AnimeStudio.CLI/AnimeStudio.CLI.csproj index 708f5d83..8121d701 100644 --- a/AnimeStudio.CLI/AnimeStudio.CLI.csproj +++ b/AnimeStudio.CLI/AnimeStudio.CLI.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/AnimeStudio.GUI/AnimeStudio.GUI.csproj b/AnimeStudio.GUI/AnimeStudio.GUI.csproj index 7afe47f7..60d0c4c8 100644 --- a/AnimeStudio.GUI/AnimeStudio.GUI.csproj +++ b/AnimeStudio.GUI/AnimeStudio.GUI.csproj @@ -127,7 +127,7 @@ - + diff --git a/AnimeStudio.Utility/AnimeStudio.Utility.csproj b/AnimeStudio.Utility/AnimeStudio.Utility.csproj index 9cb6d201..67fa28a5 100644 --- a/AnimeStudio.Utility/AnimeStudio.Utility.csproj +++ b/AnimeStudio.Utility/AnimeStudio.Utility.csproj @@ -11,9 +11,9 @@ - + - + diff --git a/AnimeStudio/AnimeStudio.csproj b/AnimeStudio/AnimeStudio.csproj index 9db8812b..1100f62b 100644 --- a/AnimeStudio/AnimeStudio.csproj +++ b/AnimeStudio/AnimeStudio.csproj @@ -8,12 +8,12 @@ - - - + + + - - + + From df22ba95981b06ce23f3a8fbc052d1b9f64b2e59 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sat, 27 Jun 2026 18:51:23 +0800 Subject: [PATCH 02/56] Update Rely --- AnimeStudio.CLI/AnimeStudio.CLI.csproj | 4 ++-- AnimeStudio/AnimeStudio.csproj | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AnimeStudio.CLI/AnimeStudio.CLI.csproj b/AnimeStudio.CLI/AnimeStudio.CLI.csproj index 8121d701..8c1e0229 100644 --- a/AnimeStudio.CLI/AnimeStudio.CLI.csproj +++ b/AnimeStudio.CLI/AnimeStudio.CLI.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/AnimeStudio/AnimeStudio.csproj b/AnimeStudio/AnimeStudio.csproj index 1100f62b..c93afcf5 100644 --- a/AnimeStudio/AnimeStudio.csproj +++ b/AnimeStudio/AnimeStudio.csproj @@ -13,7 +13,7 @@ - + From 3934caee37e46908222b4c8920f38834715530c5 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sat, 27 Jun 2026 20:53:28 +0800 Subject: [PATCH 03/56] add memorypack --- AnimeStudio/AnimeStudio.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/AnimeStudio/AnimeStudio.csproj b/AnimeStudio/AnimeStudio.csproj index c93afcf5..2514990f 100644 --- a/AnimeStudio/AnimeStudio.csproj +++ b/AnimeStudio/AnimeStudio.csproj @@ -10,6 +10,7 @@ + From 516f469d8d106ce1da5589b5c13baf1666a0519b Mon Sep 17 00:00:00 2001 From: bbdd Date: Sat, 27 Jun 2026 22:20:48 +0800 Subject: [PATCH 04/56] add ExportListType.MessagePack to ParseAssetMap and BuildAssetMap metod --- AnimeStudio/AnimeStudio.csproj | 2 + AnimeStudio/AssetMap.cs | 26 ++-- AnimeStudio/AssetsHelper.cs | 221 ++++++++++++++++++++------------- AnimeStudio/ExportTypeList.cs | 3 +- 4 files changed, 159 insertions(+), 93 deletions(-) diff --git a/AnimeStudio/AnimeStudio.csproj b/AnimeStudio/AnimeStudio.csproj index 2514990f..caabf245 100644 --- a/AnimeStudio/AnimeStudio.csproj +++ b/AnimeStudio/AnimeStudio.csproj @@ -11,8 +11,10 @@ + + diff --git a/AnimeStudio/AssetMap.cs b/AnimeStudio/AssetMap.cs index e05517fa..82ca4769 100644 --- a/AnimeStudio/AssetMap.cs +++ b/AnimeStudio/AssetMap.cs @@ -1,14 +1,16 @@ -using MessagePack; -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using MemoryPack; +using MessagePack; namespace AnimeStudio { public static class StringCache { private static readonly HashSet _cache = new(StringComparer.Ordinal); + public static string Get(string value) { if (value == null) return null; @@ -21,46 +23,54 @@ public static string Get(string value) } } - [MessagePackObject] - public record AssetMap + [MessagePackObject, MemoryPackable] + public partial record AssetMap { [Key(0)] public GameType GameType { get; set; } + [Key(1)] public List AssetEntries { get; set; } } - [MessagePackObject] - public record AssetEntry + + [MessagePackObject, MemoryPackable] + public partial record AssetEntry { - private string _name; private string _container; - private string _source; private string _hash; + private string _name; + private string _source; [Key(0)] public string Name { get => _name; set => _name = StringCache.Get(value); } + [Key(1)] public string Container { get => _container; set => _container = StringCache.Get(value); } + [Key(2)] public string Source { get => _source; set => _source = StringCache.Get(value); } + [Key(3)] public long PathID { get; set; } + [Key(4)] public ClassIDType Type { get; set; } + [Key(5)] public string Hash { get => _hash; set => _hash = StringCache.Get(value); } + [Key(6)] public long Offset { get; set; } = -1; diff --git a/AnimeStudio/AssetsHelper.cs b/AnimeStudio/AssetsHelper.cs index 4a1d030e..63e50c6a 100644 --- a/AnimeStudio/AssetsHelper.cs +++ b/AnimeStudio/AssetsHelper.cs @@ -1,16 +1,18 @@ using System; -using System.IO; -using System.Linq; using System.Collections.Generic; -using System.Threading; using System.Globalization; -using Newtonsoft.Json.Converters; -using Newtonsoft.Json; +using System.IO; +using System.Linq; +using System.Text; using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; using System.Xml; -using System.Text; +using MemoryPack.Streaming; using MessagePack; -using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Formatting = Newtonsoft.Json.Formatting; namespace AnimeStudio { @@ -18,7 +20,7 @@ public static class AssetsHelper { public const string MapName = "Maps"; - public static bool Minimal = true; + public static bool Minimal = true; public static CancellationTokenSource tokenSource = new CancellationTokenSource(); private static string BaseFolder = ""; @@ -28,13 +30,6 @@ public static class AssetsHelper public static Dictionary Paths { get; set; } = new Dictionary(); - public record Entry - { - public string Path { get; set; } - public long Offset { get; set; } - public List Dependencies { get; set; } - } - public static void SetUnityVersion(string version) { assetsManager.SpecifyUnityVersion = version; @@ -317,7 +312,7 @@ private static void ParseCABMap(BinaryReader reader) }; CABMap.Add(cab, entry); } - } + } public static async Task BuildAssetMap(string[] files, string mapName, Game game, string savePath, ExportListType exportListType, ClassIDType[] typeFilters = null, Regex[] nameFilters = null, Regex[] containerFilters = null) { @@ -527,7 +522,12 @@ private static void BuildAssetMap(string file, List assets, ClassIDT })); } - public static string[] ParseAssetMap(string mapName, ExportListType mapType, ClassIDType[] typeFilter, Regex[] nameFilter, Regex[] containerFilter) + public static string[] ParseAssetMap + (string mapName, + ExportListType mapType, + ClassIDType[] typeFilter, + Regex[] nameFilter, + Regex[] containerFilter) { var matches = new HashSet(); @@ -595,7 +595,7 @@ public static string[] ParseAssetMap(string mapName, ExportListType mapType, Cla using var file = new StreamReader(stream); using var reader = new JsonTextReader(file); - var serializer = new JsonSerializer() { Formatting = Newtonsoft.Json.Formatting.Indented }; + var serializer = new JsonSerializer { Formatting = Formatting.Indented }; serializer.Converters.Add(new StringEnumConverter()); var entries = serializer.Deserialize>(reader); @@ -610,10 +610,34 @@ public static string[] ParseAssetMap(string mapName, ExportListType mapType, Cla } } } + + break; + case ExportListType.MemoryPack: + { + using FileStream stream = File.OpenRead(mapName); + ValueTask> assetMaps = MemoryPackStreamingSerializer.DeserializeAsync + (stream).ToListAsync(); + foreach (AssetMap assetMap in assetMaps.GetAwaiter().GetResult()) + { + foreach (AssetEntry entry in assetMap.AssetEntries) + { + if(entry == null) continue; + + bool isNameMatch = nameFilter.Length == 0 || nameFilter.Any + (x => x.IsMatch(entry.Name ?? string.Empty)); + bool isContainerMatch = containerFilter.Length == 0 || containerFilter.Any + (x => x.IsMatch(entry.Container ?? string.Empty)); + bool isTypeMatch = typeFilter.Length == 0 || typeFilter.Any(x => x == entry.Type); + + if(isNameMatch && isContainerMatch && isTypeMatch) + matches.Add(entry.Source ?? string.Empty); + } + } + } break; } - + return matches.ToArray(); } @@ -648,72 +672,90 @@ private static void UpdateContainers(List assets, Game game) private static Task ExportAssetsMap(List toExportAssets, Game game, string name, string savePath, ExportListType exportListType) { - return Task.Run(() => - { - Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US"); - - Progress.Reset(); - - string filename = string.Empty; - if (exportListType.Equals(ExportListType.None)) - { - Logger.Info($"No export list type has been selected, skipping..."); - } - else - { - if (exportListType.HasFlag(ExportListType.XML)) - { - filename = Path.Combine(savePath, $"{name}.xml"); - var xmlSettings = new XmlWriterSettings() { Indent = true }; - using XmlWriter writer = XmlWriter.Create(filename, xmlSettings); - writer.WriteStartDocument(); - writer.WriteStartElement("Assets"); - writer.WriteAttributeString("filename", filename); - writer.WriteAttributeString("createdAt", DateTime.UtcNow.ToString("s")); - foreach (var asset in toExportAssets) - { - writer.WriteStartElement("Asset"); - writer.WriteElementString("Name", asset.Name); - writer.WriteElementString("Container", asset.Container); - writer.WriteStartElement("Type"); - writer.WriteAttributeString("id", ((int)asset.Type).ToString()); - writer.WriteValue(asset.Type.ToString()); - writer.WriteEndElement(); - writer.WriteElementString("PathID", asset.PathID.ToString()); - writer.WriteElementString("Source", asset.Source); - writer.WriteEndElement(); - } - writer.WriteEndElement(); - writer.WriteEndDocument(); - } - if (exportListType.HasFlag(ExportListType.JSON)) - { - filename = Path.Combine(savePath, $"{name}.json"); - using StreamWriter file = File.CreateText(filename); - var serializer = new JsonSerializer() { Formatting = Newtonsoft.Json.Formatting.Indented }; - serializer.Converters.Add(new StringEnumConverter()); - serializer.Serialize(file, new - { - GameType = game.Type, - AssetEntries = toExportAssets - }); - } - if (exportListType.HasFlag(ExportListType.MessagePack)) - { - filename = Path.Combine(savePath, $"{name}.map"); - using var file = File.Create(filename); - var assetMap = new AssetMap - { - GameType = game.Type, - AssetEntries = toExportAssets - }; - MessagePackSerializer.Serialize(file, assetMap, MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray)); - } - - Logger.Info($"Finished buidling AssetMap with {toExportAssets.Count} assets."); - } - }); + return Task.Run + (async () => + { + Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US"); + + Progress.Reset(); + + string filename = string.Empty; + if (exportListType.Equals(ExportListType.None)) + { + Logger.Info($"No export list type has been selected, skipping..."); + } + else + { + if (exportListType.HasFlag(ExportListType.XML)) + { + filename = Path.Combine(savePath, $"{name}.xml"); + var xmlSettings = new XmlWriterSettings() { Indent = true }; + using XmlWriter writer = XmlWriter.Create(filename, xmlSettings); + writer.WriteStartDocument(); + writer.WriteStartElement("Assets"); + writer.WriteAttributeString("filename", filename); + writer.WriteAttributeString("createdAt", DateTime.UtcNow.ToString("s")); + foreach (var asset in toExportAssets) + { + writer.WriteStartElement("Asset"); + writer.WriteElementString("Name", asset.Name); + writer.WriteElementString("Container", asset.Container); + writer.WriteStartElement("Type"); + writer.WriteAttributeString("id", ((int)asset.Type).ToString()); + writer.WriteValue(asset.Type.ToString()); + writer.WriteEndElement(); + writer.WriteElementString("PathID", asset.PathID.ToString()); + writer.WriteElementString("Source", asset.Source); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + if (exportListType.HasFlag(ExportListType.JSON)) + { + filename = Path.Combine(savePath, $"{name}.json"); + using StreamWriter file = File.CreateText(filename); + var serializer = new JsonSerializer { Formatting = Formatting.Indented }; + serializer.Converters.Add(new StringEnumConverter()); + serializer.Serialize(file, new + { + GameType = game.Type, + AssetEntries = toExportAssets + }); + } + if (exportListType.HasFlag(ExportListType.MessagePack)) + { + filename = Path.Combine(savePath, $"{name}.map"); + using var file = File.Create(filename); + var assetMap = new AssetMap + { + GameType = game.Type, + AssetEntries = toExportAssets + }; + MessagePackSerializer.Serialize(file, assetMap, MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray)); + } + + if(exportListType.HasFlag(ExportListType.MemoryPack)) + { + filename = Path.Combine(savePath, $"{name}.memory"); + using FileStream file = File.Create(filename); + var assetMap = new AssetMap + { + GameType = game.Type, + AssetEntries = toExportAssets + }; + await MemoryPackStreamingSerializer.SerializeAsync + (file, + toExportAssets.Count, + toExportAssets, + 10000); + } + + Logger.Info($"Finished buidling AssetMap with {toExportAssets.Count} assets."); + } + }); } + public static async Task BuildBoth(string[] files, string mapName, string baseFolder, Game game, string savePath, ExportListType exportListType, ClassIDType[] typeFilters = null, Regex[] nameFilters = null, Regex[] containerFilters = null) { Logger.Info($"Building Both..."); @@ -735,5 +777,16 @@ public static async Task BuildBoth(string[] files, string mapName, string baseFo Logger.Info($"Map build successfully !! {collision} collisions found"); await ExportAssetsMap(assets, game, mapName, savePath, exportListType); } + + #region Nested type: Entry + + public record Entry + { + public string Path { get; set; } + public long Offset { get; set; } + public List Dependencies { get; set; } + } + + #endregion } } diff --git a/AnimeStudio/ExportTypeList.cs b/AnimeStudio/ExportTypeList.cs index 557c39de..71adfddf 100644 --- a/AnimeStudio/ExportTypeList.cs +++ b/AnimeStudio/ExportTypeList.cs @@ -8,6 +8,7 @@ public enum ExportListType None, MessagePack, XML, - JSON = 4, + JSON = 4, + MemoryPack = 8 } } From 0c074a05192e03519a79b2fe5be114c6a1114306 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sat, 27 Jun 2026 23:53:13 +0800 Subject: [PATCH 05/56] supported memorypack fin --- AnimeStudio.GUI/AssetBrowser.cs | 40 +++-- AnimeStudio.GUI/MainForm.cs | 249 +++++++++++++++++--------------- AnimeStudio/AssetsHelper.cs | 23 ++- AnimeStudio/ResourceMap.cs | 58 +++++--- 4 files changed, 212 insertions(+), 158 deletions(-) diff --git a/AnimeStudio.GUI/AssetBrowser.cs b/AnimeStudio.GUI/AssetBrowser.cs index 7e53c6f2..e9c903db 100644 --- a/AnimeStudio.GUI/AssetBrowser.cs +++ b/AnimeStudio.GUI/AssetBrowser.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Data; using System.Drawing; using System.Globalization; using System.IO; @@ -16,17 +15,17 @@ namespace AnimeStudio.GUI { partial class AssetBrowser : Form { - private readonly MainForm _parent; - private readonly List _assetEntries; - private readonly List _backupAssetEntries; - private readonly List _firstAssetEntries; - private readonly List _secondAssetEntries; + private readonly List _assetEntries; + private readonly List _backupAssetEntries; private readonly Dictionary _filters; + private readonly List _firstAssetEntries; + private readonly MainForm _parent; + private readonly List _secondAssetEntries; + private DataGridViewColumn _sortedColumn; - private SortOrder _sortOrder; - private DataGridViewColumn _sortedColumn; - private List types = new(); + private SortOrder _sortOrder; private List selectedTypes = new(); + private List types = new List(); public AssetBrowser(MainForm form) { @@ -44,7 +43,11 @@ private async void loadAssetMap_Click(object sender, EventArgs e) { loadAssetMap.Enabled = false; - var openFileDialog = new OpenFileDialog() { Multiselect = false, Filter = "MessagePack AssetMap File|*.map|JSON AssetMap File|*.json" }; + var openFileDialog = new OpenFileDialog + { + Multiselect = false, Filter = "MessagePack AssetMap File|*" + + ".map|JSON AssetMap File|*.json|MemoryPack AssetMap File|*.memory" + }; if (openFileDialog.ShowDialog(this) == DialogResult.OK) { try @@ -172,12 +175,14 @@ private void bringMainToFront() _parent.TopMost = false; _parent.Focus(); } + private void clear_Click(object sender, EventArgs e) { Clear(); updateButtons(); Logger.Info($"Cleared !!"); } + private void loadSelected_Click(object sender, EventArgs e) { var files = assetDataGridView.SelectedRows.Cast() @@ -210,6 +215,7 @@ private void loadSelected_Click(object sender, EventArgs e) _parent.Invoke(() => _parent.LoadPaths(files, filePaths.ToArray())); } } + private async void exportSelected_Click(object sender, EventArgs e) { var saveFolderDialog = new OpenFolderDialog(); @@ -245,6 +251,7 @@ await Task.Run(async () => StatusStripUpdate = statusStripUpdate; } } + private void BuildAssetData(List exportableAssets, AssetEntry[] entries) { var objectAssetItemDic = new Dictionary(); @@ -284,6 +291,7 @@ private void BuildAssetData(List exportableAssets, AssetEntry[] entri exportableAssets.Clear(); exportableAssets.AddRange(matches); } + private void ProcessAssetData(Object asset, List exportableAssets, Dictionary objectAssetItemDic, List<(PPtr, string)> mihoyoBinDataNames, List<(PPtr, string)> containers) { var assetItem = new AssetItem(asset); @@ -388,6 +396,7 @@ private void FilterAssetDataGrid() assetDataGridView.RowCount = _assetEntries.Count; assetDataGridView.Refresh(); } + private void TryAddFilter(string name, string value) { Regex regex; @@ -410,6 +419,7 @@ private void TryAddFilter(string name, string value) _filters[name] = regex; } } + private void NameTextBox_KeyPress(object sender, KeyPressEventArgs e) { if (sender is TextBox textBox && e.KeyChar == (char)Keys.Enter) @@ -417,6 +427,7 @@ private void NameTextBox_KeyPress(object sender, KeyPressEventArgs e) FilterAssetDataGrid(); } } + private void ContainerTextBox_KeyPress(object sender, KeyPressEventArgs e) { if (sender is TextBox textBox && e.KeyChar == (char)Keys.Enter) @@ -424,6 +435,7 @@ private void ContainerTextBox_KeyPress(object sender, KeyPressEventArgs e) FilterAssetDataGrid(); } } + private void SourceTextBox_KeyPress(object sender, KeyPressEventArgs e) { if (sender is TextBox textBox && e.KeyChar == (char)Keys.Enter) @@ -431,6 +443,7 @@ private void SourceTextBox_KeyPress(object sender, KeyPressEventArgs e) FilterAssetDataGrid(); } } + private void PathTextBox_KeyPress(object sender, KeyPressEventArgs e) { if (sender is TextBox textBox && e.KeyChar == (char)Keys.Enter) @@ -438,6 +451,7 @@ private void PathTextBox_KeyPress(object sender, KeyPressEventArgs e) FilterAssetDataGrid(); } } + private void HashTextBox_KeyPress(object sender, KeyPressEventArgs e) { if (sender is TextBox textBox && e.KeyChar == (char)Keys.Enter) @@ -445,6 +459,7 @@ private void HashTextBox_KeyPress(object sender, KeyPressEventArgs e) FilterAssetDataGrid(); } } + private void AssetDataGridView_CellValueNeeded(object sender, DataGridViewCellValueEventArgs e) { if (_assetEntries.Count != 0 && e.RowIndex <= _assetEntries.Count) @@ -462,6 +477,7 @@ private void AssetDataGridView_CellValueNeeded(object sender, DataGridViewCellVa }; } } + private void AssetListView_ColumnHeaderMouseClick(object sender, DataGridViewCellMouseEventArgs e) { if (e.ColumnIndex <= assetDataGridView.Columns.Count) @@ -511,11 +527,13 @@ private void AssetListView_ColumnHeaderMouseClick(object sender, DataGridViewCel assetDataGridView.Refresh(); } } + private void AssetBrowser_FormClosing(object sender, FormClosingEventArgs e) { Clear(); base.OnClosing(e); } + public void Clear() { ResourceMap.Clear(); @@ -699,7 +717,5 @@ private void filterSelectTypesBtn_Click(object sender, EventArgs e) popup.ShowDialog(this); } - - } } diff --git a/AnimeStudio.GUI/MainForm.cs b/AnimeStudio.GUI/MainForm.cs index 946b8401..63a3cf1c 100644 --- a/AnimeStudio.GUI/MainForm.cs +++ b/AnimeStudio.GUI/MainForm.cs @@ -29,66 +29,36 @@ namespace AnimeStudio.GUI { partial class MainForm : Form { - private AssetItem lastSelectedItem; + private AboutForm aboutForm; private AssetBrowser assetBrowser; - private AboutForm aboutForm; + private FMOD.Channel channel; + private uint FMODlenms; + private float FMODVolume = 0.8f; private GameSelector gameSelector; - private UnityCNEdit unityCNEdit; private DirectBitmap imageTexture; - private string tempClipboard; + private AssetItem lastSelectedItem; - private FMOD.System system; - private FMOD.Sound sound; - private FMOD.Channel channel; + private GUILogger logger; + private FMOD.MODE loopMode = FMOD.MODE.LOOP_OFF; private FMOD.SoundGroup masterSoundGroup; - private FMOD.MODE loopMode = FMOD.MODE.LOOP_OFF; - private uint FMODlenms; - private float FMODVolume = 0.8f; - - private bool themeIsFirstLaunch = true; - - #region TexControl - private static char[] textureChannelNames = new[] { 'B', 'G', 'R', 'A' }; - private bool[] textureChannels = new[] { true, true, true, true }; - #endregion - - #region GLControl - private bool glControlLoaded; - private int mdx, mdy; - private bool lmdown, rmdown; - private int pgmID, pgmColorID, pgmBlackID; - private int attributeVertexPosition; - private int attributeNormalDirection; - private int attributeVertexColor; - private int uniformModelMatrix; - private int uniformViewMatrix; - private int uniformProjMatrix; - private int vao; - private OpenTK.Mathematics.Vector3[] vertexData; - private OpenTK.Mathematics.Vector3[] normalData; - private OpenTK.Mathematics.Vector3[] normal2Data; - private OpenTK.Mathematics.Vector4[] colorData; - private Matrix4 modelMatrixData; - private Matrix4 viewMatrixData; - private Matrix4 projMatrixData; - private int[] indiceData; - private int wireFrameMode; - private int shadeMode; - private int normalMode; - #endregion - - //asset list sorting - private int sortColumn = -1; - private bool reverseSort; //tree search private int nextGObject; - private List treeSrcResults = new List(); private string openDirectoryBackup = string.Empty; + private bool reverseSort; private string saveDirectoryBackup = string.Empty; - private GUILogger logger; + //asset list sorting + private int sortColumn = -1; + private FMOD.Sound sound; + + private FMOD.System system; + private string tempClipboard; + + private bool themeIsFirstLaunch = true; + private List treeSrcResults = new List(); + private UnityCNEdit unityCNEdit; public MainForm() { @@ -303,6 +273,7 @@ private void InitalizeOptions() } } } + private void MainForm_DragEnter(object sender, DragEventArgs e) { if (e.Data.GetDataPresent(DataFormats.FileDrop)) @@ -714,6 +685,7 @@ private void enablePreview_Check(object sender, EventArgs e) Properties.Settings.Default.enablePreview = enablePreview.Checked; Properties.Settings.Default.Save(); } + private void displayAssetInfo_Check(object sender, EventArgs e) { if (displayInfo.Checked && assetInfoLabel.Text != null) @@ -1510,6 +1482,7 @@ private void PreviewGameObject(GameObject m_GameObject) var model = new ModelConverter(m_GameObject, options, Array.Empty()); PreviewModel(model); } + private void PreviewAnimator(Animator m_Animator) { var options = new ModelConverter.Options() @@ -1984,6 +1957,7 @@ private void toolStripMenuItem9_Click(object sender, EventArgs e) { ExportAssets(ExportFilter.Filtered, ExportType.Dump); } + private void toolStripMenuItem17_Click(object sender, EventArgs e) { ExportAssets(ExportFilter.All, ExportType.JSON); @@ -2173,6 +2147,7 @@ private void toolStripMenuItem15_Click(object sender, EventArgs e) { logger.ShowErrorMessage = toolStripMenuItem15.Checked; } + private async void toolStripMenuItem19_DropDownOpening(object sender, EventArgs e) { if (specifyAIVersion.Enabled && await AIVersionManager.FetchVersions()) @@ -2286,6 +2261,7 @@ private void tabControl2_SelectedIndexChanged(object sender, EventArgs e) dumpTextBox.Text = DumpAsset(lastSelectedItem.Asset); } } + private void enableResolveDependencies_CheckedChanged(object sender, EventArgs e) { Properties.Settings.Default.enableResolveDependencies = enableResolveDependencies.Checked; @@ -2293,16 +2269,19 @@ private void enableResolveDependencies_CheckedChanged(object sender, EventArgs e assetsManager.ResolveDependencies = enableResolveDependencies.Checked; } + private void allowDuplicates_CheckedChanged(object sender, EventArgs e) { Properties.Settings.Default.allowDuplicates = allowDuplicates.Checked; Properties.Settings.Default.Save(); } + private void UseBundleContainerNameToolStripMenuItem_CheckedChanged(object sender, EventArgs e) { Properties.Settings.Default.useBundleContainerName = useBundleContainerNameToolStripMenuItem.Checked; Properties.Settings.Default.Save(); } + private void skipContainer_CheckedChanged(object sender, EventArgs e) { Properties.Settings.Default.skipContainer = skipContainer.Checked; @@ -2310,6 +2289,7 @@ private void skipContainer_CheckedChanged(object sender, EventArgs e) SkipContainer = skipContainer.Checked; } + private void assetMapTypeMenuItem_DropDownItemClicked(object sender, ToolStripItemClickedEventArgs e) { var assetMapType = Properties.Settings.Default.assetMapType; @@ -2329,6 +2309,7 @@ private void assetMapTypeMenuItem_DropDownItemClicked(object sender, ToolStripIt } } + private void modelsOnly_CheckedChanged(object sender, EventArgs e) { Properties.Settings.Default.modelsOnly = modelsOnly.Checked; @@ -2339,6 +2320,7 @@ private void modelsOnly_CheckedChanged(object sender, EventArgs e) FilterAssetList(); } } + private void enableModelPreview_CheckedChanged(object sender, EventArgs e) { Properties.Settings.Default.enableModelPreview = enableModelPreview.Checked; @@ -2663,9 +2645,9 @@ private async void buildAssetMapToolStripMenuItem_Click(object sender, EventArgs var saveFileDialog = new SaveFileDialog() { - Filter = "Map file (*.map)|*.map", - DefaultExt = "map", - Title = "Select Output File (format will auto adjust according to what you selected)", + Filter = "Map file (*.map)|*.map|MemoryPack AssetMap File|*.memory", + DefaultExt = "map", + Title = "Select Output File (format will auto adjust according to what you selected)", InitialDirectory = saveDirectory, }; @@ -2711,7 +2693,104 @@ private void loadAssetMapToolStripMenuItem_Click(object sender, EventArgs e) assetBrowser.Show(); } + private void exitToolStripMenuItem_Click(object sender, EventArgs e) { Application.Exit(); } + + private void modelsObjectsExportAll_Click(object sender, EventArgs e) + { + this.exportAllObjectssplitToolStripMenuItem1_Click(sender, e); + } + + private void modelsObjectsExportSelected_Click(object sender, EventArgs e) + { + bool includeAnimationClips = modelsIncludeAnimationClips.Checked; + bool mergeObjects = modelsMerge.Checked; + + switch (includeAnimationClips, mergeObjects) + { + case (false, false): + this.exportSelectedObjectsToolStripMenuItem_Click(sender, e); + break; + case (false, true): + this.exportSelectedObjectsmergeToolStripMenuItem_Click(sender, e); + break; + case (true, false): + this.exportObjectswithAnimationClipMenuItem_Click(sender, e); + break; + case (true, true): + this.exportSelectedObjectsmergeWithAnimationClipToolStripMenuItem_Click(sender, e); + break; + } + } + + private void modelsNodesExportSelected_Click(object sender, EventArgs e) + { + bool includeAnimationClips = modelsIncludeAnimationClips.Checked; + + switch(includeAnimationClips) + { + case true: + this.exportSelectedNodessplitSelectedAnimationClipsToolStripMenuItem_Click(sender, e); + break; + case false: + this.exportSelectedNodessplitToolStripMenuItem_Click(sender, e); + break; + } + } + + private void aboutToolStripMenuItem_Click(object sender, EventArgs e) + { + aboutForm = new AboutForm(); + aboutForm.ShowDialog(); + } + + private void gameSelectToolStripMenuItem_Click(object sender, EventArgs e) + { + gameSelector = new GameSelector(this); + gameSelector.ShowDialog(); + } + + private void editUnityCNKeysToolStripMenuItem_Click(object sender, EventArgs e) + { + unityCNEdit = new UnityCNEdit(); + unityCNEdit.ShowDialog(); + } + + #region TexControl + + private static char[] textureChannelNames = new[] { 'B', 'G', 'R', 'A' }; + private bool[] textureChannels = new[] { true, true, true, true }; + + #endregion + + #region GLControl + + private bool glControlLoaded; + private int mdx, mdy; + private bool lmdown, rmdown; + private int pgmID, pgmColorID, pgmBlackID; + private int attributeVertexPosition; + private int attributeNormalDirection; + private int attributeVertexColor; + private int uniformModelMatrix; + private int uniformViewMatrix; + private int uniformProjMatrix; + private int vao; + private OpenTK.Mathematics.Vector3[] vertexData; + private OpenTK.Mathematics.Vector3[] normalData; + private OpenTK.Mathematics.Vector3[] normal2Data; + private OpenTK.Mathematics.Vector4[] colorData; + private Matrix4 modelMatrixData; + private Matrix4 viewMatrixData; + private Matrix4 projMatrixData; + private int[] indiceData; + private int wireFrameMode; + private int shadeMode; + private int normalMode; + + #endregion + #region FMOD + private void FMODinit() { FMODreset(); @@ -2979,9 +3058,11 @@ private bool ERRCHECK(FMOD.RESULT result) } return false; } + #endregion #region GLControl + private void InitOpenTK() { ChangeGLSize(glControl.Size); @@ -3198,71 +3279,7 @@ private void glControl_MouseUp(object sender, MouseEventArgs e) rmdown = false; } } - #endregion - - private void exitToolStripMenuItem_Click(object sender, EventArgs e) - { - Application.Exit(); - } - - private void modelsObjectsExportAll_Click(object sender, EventArgs e) - { - exportAllObjectssplitToolStripMenuItem1_Click(sender, e); - } - - private void modelsObjectsExportSelected_Click(object sender, EventArgs e) - { - bool includeAnimationClips = modelsIncludeAnimationClips.Checked; - bool mergeObjects = modelsMerge.Checked; - switch ((includeAnimationClips, mergeObjects)) - { - case (false, false): - exportSelectedObjectsToolStripMenuItem_Click(sender, e); - break; - case (false, true): - exportSelectedObjectsmergeToolStripMenuItem_Click(sender, e); - break; - case (true, false): - exportObjectswithAnimationClipMenuItem_Click(sender, e); - break; - case (true, true): - exportSelectedObjectsmergeWithAnimationClipToolStripMenuItem_Click(sender, e); - break; - } - } - - private void modelsNodesExportSelected_Click(object sender, EventArgs e) - { - bool includeAnimationClips = modelsIncludeAnimationClips.Checked; - - switch (includeAnimationClips) - { - case true: - exportSelectedNodessplitSelectedAnimationClipsToolStripMenuItem_Click(sender, e); - break; - case false: - exportSelectedNodessplitToolStripMenuItem_Click(sender, e); - break; - } - } - - private void aboutToolStripMenuItem_Click(object sender, EventArgs e) - { - aboutForm = new AboutForm(); - aboutForm.ShowDialog(); - } - - private void gameSelectToolStripMenuItem_Click(object sender, EventArgs e) - { - gameSelector = new GameSelector(this); - gameSelector.ShowDialog(); - } - - private void editUnityCNKeysToolStripMenuItem_Click(object sender, EventArgs e) - { - unityCNEdit = new UnityCNEdit(); - unityCNEdit.ShowDialog(); - } + #endregion } } diff --git a/AnimeStudio/AssetsHelper.cs b/AnimeStudio/AssetsHelper.cs index 63e50c6a..28b9c643 100644 --- a/AnimeStudio/AssetsHelper.cs +++ b/AnimeStudio/AssetsHelper.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using System.Xml; +using MemoryPack; using MemoryPack.Streaming; using MessagePack; using Newtonsoft.Json; @@ -615,12 +616,10 @@ public static string[] ParseAssetMap case ExportListType.MemoryPack: { using FileStream stream = File.OpenRead(mapName); - ValueTask> assetMaps = MemoryPackStreamingSerializer.DeserializeAsync - (stream).ToListAsync(); + AssetMap assetMap = MemoryPackStreamingSerializer.DeserializeAsync + (stream).FirstAsync().GetAwaiter().GetResult(); - foreach (AssetMap assetMap in assetMaps.GetAwaiter().GetResult()) - { - foreach (AssetEntry entry in assetMap.AssetEntries) + foreach (AssetEntry entry in assetMap.AssetEntries) { if(entry == null) continue; @@ -633,7 +632,7 @@ public static string[] ParseAssetMap if(isNameMatch && isContainerMatch && isTypeMatch) matches.Add(entry.Source ?? string.Empty); } - } + } break; } @@ -738,17 +737,17 @@ private static Task ExportAssetsMap(List toExportAssets, Game game, if(exportListType.HasFlag(ExportListType.MemoryPack)) { filename = Path.Combine(savePath, $"{name}.memory"); - using FileStream file = File.Create(filename); var assetMap = new AssetMap { GameType = game.Type, AssetEntries = toExportAssets }; - await MemoryPackStreamingSerializer.SerializeAsync - (file, - toExportAssets.Count, - toExportAssets, - 10000); + + var assetMaps = new List(); + assetMaps.Add(assetMap); + + byte[] data = MemoryPackSerializer.Serialize(assetMaps); + File.WriteAllBytes(filename, data); } Logger.Info($"Finished buidling AssetMap with {toExportAssets.Count} assets."); diff --git a/AnimeStudio/ResourceMap.cs b/AnimeStudio/ResourceMap.cs index 41211ac7..301430e0 100644 --- a/AnimeStudio/ResourceMap.cs +++ b/AnimeStudio/ResourceMap.cs @@ -3,6 +3,9 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using MemoryPack; +using MemoryPack.Streaming; namespace AnimeStudio { @@ -31,27 +34,46 @@ public static int FromFile(string path) Logger.Info(string.Format("Parsing....")); try { - var extension = Path.GetExtension(path).ToLower(); - using var stream = File.OpenRead(path); - - if (extension == ".map") - { - // Deserialize map - Instance = MessagePackSerializer.Deserialize(stream, MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray)); - } - else if (extension == ".json") + string extension = Path.GetExtension(path).ToLower(); + using FileStream stream = File.OpenRead(path); + ReadOnlySpan bytes = File.ReadAllBytes(path); + + switch(extension) { - // Deserialize json - using var reader = new StreamReader(stream); - var jsonContent = reader.ReadToEnd(); - var parsed = JsonConvert.DeserializeObject(jsonContent); + case ".map": + { + // Deserialize map + Instance = MessagePackSerializer.Deserialize + (stream, + MessagePackSerializerOptions.Standard.WithCompression + (MessagePackCompression.Lz4BlockArray)); + break; + } - Instance = new AssetMap + case ".json": { - GameType = parsed.GameType, - AssetEntries = parsed.AssetEntries - }; - } + // Deserialize json + using var reader = new StreamReader(stream); + string jsonContent = reader.ReadToEnd(); + AssetMap parsed = JsonConvert.DeserializeObject(jsonContent); + + Instance = new AssetMap + { + GameType = parsed.GameType, + AssetEntries = parsed.AssetEntries + }; + break; + } + case ".memory": + { + List assetMaps = MemoryPackSerializer.Deserialize> + (bytes); + + AssetMap assetMap = assetMaps.FirstOrDefault(); + Instance = assetMap; + break; + } + } } catch (Exception e) { From de9c4d8e07ffa7d7a6727d58fcc2c6f492338da3 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 17:36:14 +0800 Subject: [PATCH 06/56] perf: optimize Matches method filtering performance - Replace switch pattern matching with static dictionary PropertyExtractors, converting string comparisons to O(1) dictionary lookups - Remove List allocation and implement short-circuit evaluation: return false immediately when any filter fails - Retain support for "SHA256Hash" alias, behavior remains equivalent to original - Unknown filter keys now return false instead of throwing an exception (safer) - No changes required for callers --- AnimeStudio/AssetMap.cs | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/AnimeStudio/AssetMap.cs b/AnimeStudio/AssetMap.cs index 82ca4769..65d74a26 100644 --- a/AnimeStudio/AssetMap.cs +++ b/AnimeStudio/AssetMap.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Text.RegularExpressions; using MemoryPack; using MessagePack; @@ -36,6 +35,18 @@ public partial record AssetMap [MessagePackObject, MemoryPackable] public partial record AssetEntry { + private static readonly Dictionary> PropertyExtractors = new + Dictionary>(StringComparer.OrdinalIgnoreCase) + { + { nameof(Name), r => r.Name }, + { nameof(Container), r => r.Container }, + { nameof(Source), r => r.Source }, + { nameof(PathID), r => r.PathID.ToString() }, + { nameof(Type), r => r.Type.ToString() }, + { nameof(Hash), r => r.Hash ?? string.Empty }, + { "SHA256Hash", r => r.Hash ?? string.Empty } + }; + private string _container; private string _hash; private string _name; @@ -76,20 +87,20 @@ public string Hash { public bool Matches(Dictionary filters) { - var matches = new List(); - foreach(var filter in filters) + if(filters is null || filters.Count == 0) + return true; + + foreach (KeyValuePair kvp in filters) { - matches.Add(filter.Key switch - { - string value when value.Equals(nameof(Name), StringComparison.OrdinalIgnoreCase) => filter.Value.IsMatch(Name), - string value when value.Equals(nameof(Container), StringComparison.OrdinalIgnoreCase) => filter.Value.IsMatch(Container), - string value when value.Equals(nameof(Source), StringComparison.OrdinalIgnoreCase) => filter.Value.IsMatch(Source), - string value when value.Equals(nameof(PathID), StringComparison.OrdinalIgnoreCase) => filter.Value.IsMatch(PathID.ToString()), - string value when value.Equals(nameof (Type), StringComparison.OrdinalIgnoreCase) => filter.Value.IsMatch(Type.ToString()), - string value when value.Equals(nameof(Hash), StringComparison.OrdinalIgnoreCase) || value.Equals("SHA256Hash", StringComparison.OrdinalIgnoreCase) => filter.Value.IsMatch(Hash ?? String.Empty) - }); + if(!PropertyExtractors.TryGetValue(kvp.Key, out Func extractor)) + return false; + + + if(!kvp.Value.IsMatch(extractor(this))) + return false; } - return matches.Count(x => x == true) == filters.Count; + + return true; } } } From 232c938d7ae2d089185c6f76330f40c0517295c3 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 21:25:45 +0800 Subject: [PATCH 07/56] getrawdata --- AnimeStudio/Classes/Object.cs | 37 ++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/AnimeStudio/Classes/Object.cs b/AnimeStudio/Classes/Object.cs index 5cabb59d..16731f5c 100644 --- a/AnimeStudio/Classes/Object.cs +++ b/AnimeStudio/Classes/Object.cs @@ -1,7 +1,6 @@ -using K4os.Hash.xxHash; +using System.Collections.Specialized; +using K4os.Hash.xxHash; using Newtonsoft.Json; -using System; -using System.Collections.Specialized; namespace AnimeStudio { @@ -9,24 +8,25 @@ public class Object { [JsonIgnore] public SerializedFile assetsFile; - [JsonIgnore] - public ObjectReader reader; - [JsonIgnore] - public long m_PathID; - [JsonIgnore] - public int[] version; + [JsonIgnore] protected BuildType buildType; + + [JsonIgnore] public uint byteSize; + + [JsonIgnore] public long m_PathID; + [JsonIgnore] public BuildTarget platform; - [JsonIgnore] - public ClassIDType type; + + [JsonIgnore] public ObjectReader reader; + [JsonIgnore] public SerializedType serializedType; - [JsonIgnore] - public uint byteSize; - public virtual string Name => string.Empty; + [JsonIgnore] public ClassIDType type; + + [JsonIgnore] public int[] version; public Object(ObjectReader reader) { @@ -49,6 +49,11 @@ public Object(ObjectReader reader) } } + public virtual string Name + { + get => string.Empty; + } + public string GetHash(bool overrides = false) { if (type == ClassIDType.Texture2D && overrides) @@ -104,8 +109,8 @@ public byte[] GetRawData() { Logger.Verbose($"Dumping raw bytes of the object with {m_PathID} in file {assetsFile.fileName}..."); long pos = reader.Position; - reader.Reset(); - byte[] data = reader.ReadBytes((int)byteSize); + reader.Position = 0; + byte[] data = reader.ReadBytes((int) reader.Remaining); reader.Position = pos; return data; } From a142e31313948e4c412e6efcbcd27e57f77c8b3c Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 21:48:45 +0800 Subject: [PATCH 08/56] no fail shader --- AnimeStudio/ObjectReader.cs | 38 +++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/AnimeStudio/ObjectReader.cs b/AnimeStudio/ObjectReader.cs index 25d39ada..a6415f0a 100644 --- a/AnimeStudio/ObjectReader.cs +++ b/AnimeStudio/ObjectReader.cs @@ -1,22 +1,18 @@ using System; -using System.IO; namespace AnimeStudio { public class ObjectReader : EndianBinaryReader { - public SerializedFile assetsFile; - public Game Game; - public long m_PathID; - public long byteStart; - public uint byteSize; - public ClassIDType type; - public SerializedType serializedType; - public BuildTarget platform; + public SerializedFile assetsFile; + public uint byteSize; + public long byteStart; + public Game Game; + public long m_PathID; public SerializedFileFormatVersion m_Version; - - public int[] version => assetsFile.version; - public BuildType buildType => assetsFile.buildType; + public BuildTarget platform; + public SerializedType serializedType; + public ClassIDType type; public ObjectReader(EndianBinaryReader reader, SerializedFile assetsFile, ObjectInfo objectInfo, Game game) : base(reader.BaseStream, reader.Endian) { @@ -41,13 +37,23 @@ public ObjectReader(EndianBinaryReader reader, SerializedFile assetsFile, Object Logger.Verbose($"Initialized reader for {type} object with {m_PathID} in file {assetsFile.fileName} !!"); } + public int[] version + { + get => assetsFile.version; + } + + public BuildType buildType + { + get => assetsFile.buildType; + } + public override int Read(byte[] buffer, int index, int count) { var pos = Position - byteStart; - if (pos + count > byteSize) - { - throw new EndOfStreamException("Unable to read beyond the end of the stream."); - } + //if (pos + count > byteSize) + //{ + //throw new EndOfStreamException("Unable to read beyond the end of the stream."); + //} return base.Read(buffer, index, count); } From c0adeb9560ad65d2740c318932671172d86f56c4 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:03:41 +0800 Subject: [PATCH 09/56] Refactor asset data list building --- AnimeStudio.GUI/Studio.cs | 429 ++++++++++++++++++++------------------ 1 file changed, 231 insertions(+), 198 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index 4081a2e0..9bb3525f 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -300,201 +300,40 @@ public static void UpdateContainers() } } + private sealed class AssetDataBuildContext + { + public int ObjectCount { get; } + public Dictionary ObjectAssetItems { get; } + public List<(PPtr PPtr, string Name)> MiHoYoBinDataNames { get; } = new List<(PPtr, string)>(); + public List<(PPtr PPtr, string Container)> Containers { get; } = new List<(PPtr, string)>(); + public HashSet FastAssetItemFilterData { get; } + public string AssetBundleName { get; set; } = ""; + public string ProductName { get; set; } + + public AssetDataBuildContext() + { + ObjectCount = assetsManager.assetsFileList.Sum(x => x.Objects.Count); + ObjectAssetItems = new Dictionary(ObjectCount); + FastAssetItemFilterData = new HashSet(assetsManager.FilterData.Items, new AssetFilterDataItemEqualityComparer()); + } + } + public static (string, List) BuildAssetData() { StatusStripUpdate("Building asset list..."); - int i = 0; - string productName = null; - var objectCount = assetsManager.assetsFileList.Sum(x => x.Objects.Count); - var objectAssetItemDic = new Dictionary(objectCount); - var mihoyoBinDataNames = new List<(PPtr, string)>(); - var containers = new List<(PPtr, string)>(); + var context = new AssetDataBuildContext(); Progress.Reset(); - Logger.Info($"Loading {objectCount} objects from {assetsManager.assetsFileList.Count} files."); - var assetBundleName = ""; + Logger.Info($"Loading {context.ObjectCount} objects from {assetsManager.assetsFileList.Count} files."); - var fastAssetItemFilterData = new HashSet(assetsManager.FilterData.Items, new AssetFilterDataItemEqualityComparer()); - foreach (var assetsFile in assetsManager.assetsFileList) - { - foreach (var asset in assetsFile.Objects) - { - if (assetsManager.tokenSource.IsCancellationRequested) - { - Logger.Info("Building asset list has been cancelled !!"); - return (string.Empty, Array.Empty().ToList()); - } - - var assetItem = new AssetItem(asset); - - if (asset is not AssetBundle && asset is not ResourceManager) - { - if (fastAssetItemFilterData.Count > 0 && !fastAssetItemFilterData.Contains(new AssetFilterDataItem { Source = assetItem.SourceFile.fullName, Name = assetItem.Text, PathID = assetItem.m_PathID, Type = assetItem.Type })) - { - Logger.Verbose($"Skipped {(assetItem.Text.Length > 0 ? assetItem.Text : "an asset")} because filter data was set and it was missing from it"); - continue; - } - } - - objectAssetItemDic.Add(asset, assetItem); - assetItem.UniqueID = "#" + i; - var exportable = false; - switch (asset) - { - case Texture2D m_Texture2D: - if (!string.IsNullOrEmpty(m_Texture2D.m_StreamData?.path)) - assetItem.FullSize = asset.byteSize + m_Texture2D.m_StreamData.size; - exportable = ClassIDType.Texture2D.CanExport(); - break; - case AudioClip m_AudioClip: - if (!string.IsNullOrEmpty(m_AudioClip.m_Source)) - assetItem.FullSize = asset.byteSize + m_AudioClip.m_Size; - exportable = ClassIDType.AudioClip.CanExport(); - break; - case VideoClip m_VideoClip: - if (!string.IsNullOrEmpty(m_VideoClip.m_OriginalPath)) - assetItem.FullSize = asset.byteSize + m_VideoClip.m_ExternalResources.m_Size; - exportable = ClassIDType.VideoClip.CanExport(); - break; - case PlayerSettings m_PlayerSettings: - productName = m_PlayerSettings.productName; - exportable = ClassIDType.PlayerSettings.CanExport(); - break; - case AssetBundle m_AssetBundle: - assetBundleName = m_AssetBundle.Name; - if (!SkipContainer) - { - foreach (var m_Container in m_AssetBundle.m_Container) - { - var preloadIndex = m_Container.Value.preloadIndex; - var preloadSize = m_Container.Value.preloadSize; - var preloadEnd = preloadIndex + preloadSize; - - switch (preloadIndex) - { - case int n when n < 0: - Logger.Warning($"preloadIndex {preloadIndex} is out of preloadTable range"); - break; - default: - for (int k = preloadIndex; k < preloadEnd; k++) - { - string containerName = m_Container.Key; - if (int.TryParse(m_Container.Key, out _) && Properties.Settings.Default.useBundleContainerName) - { - containerName = assetBundleName; - } - try - { - containers.Add((m_AssetBundle.m_PreloadTable[k], m_Container.Key)); - } catch - { - Logger.Info($"Failed to add container {m_Container.Key}"); - } - } - break; - } - } - } - - exportable = ClassIDType.AssetBundle.CanExport(); - break; - case IndexObject m_IndexObject: - foreach(var index in m_IndexObject.AssetMap) - { - mihoyoBinDataNames.Add((index.Value.Object, index.Key)); - } - - exportable = ClassIDType.IndexObject.CanExport(); - break; - case ResourceManager m_ResourceManager: - foreach (var m_Container in m_ResourceManager.m_Container) - { - containers.Add((m_Container.Value, m_Container.Key)); - } - - exportable = ClassIDType.ResourceManager.CanExport(); - break; - case Mesh _ when ClassIDType.Mesh.CanExport(): - case TextAsset _ when ClassIDType.TextAsset.CanExport(): - case AnimationClip _ when ClassIDType.AnimationClip.CanExport(): - case Font _ when ClassIDType.Font.CanExport(): - case MovieTexture _ when ClassIDType.MovieTexture.CanExport(): - case Sprite _ when ClassIDType.Sprite.CanExport(): - case Material _ when ClassIDType.Material.CanExport(): - case MiHoYoBinData _ when ClassIDType.MiHoYoBinData.CanExport(): - case NapAssetBundleIndexAsset _ when ClassIDType.NapAssetBundleIndexAsset.CanExport(): - case Shader _ when ClassIDType.Shader.CanExport(): - case Animator _ when ClassIDType.Animator.CanExport(): - case MonoBehaviour _ when ClassIDType.MonoBehaviour.CanExport(): - exportable = true; - break; - } - if (assetItem.Text == "") - { - assetItem.Text = assetItem.TypeString + assetItem.UniqueID; - } - if (Properties.Settings.Default.displayAll || exportable) - { - exportableAssets.Add(assetItem); - } - Progress.Report(++i, objectCount); - } - } - foreach((var pptr, var name) in mihoyoBinDataNames) + if (!BuildExportableAssetItems(context) || !ApplyAssetPostProcessing(context)) { - if (assetsManager.tokenSource.IsCancellationRequested) - { - Logger.Info("Processing asset names has been cancelled !!"); - return (string.Empty, Array.Empty().ToList()); - } - if (pptr.TryGet(out var obj)) - { - var assetItem = objectAssetItemDic[obj]; - if (int.TryParse(name, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hash)) - { - assetItem.Text = name; - assetItem.Container = Properties.Settings.Default.useBundleContainerName ? assetBundleName : hash.ToString(); - } - else assetItem.Text = $"BinFile #{assetItem.m_PathID}"; - } - } - if (!SkipContainer) - { - foreach ((var pptr, var container) in containers) - { - if (assetsManager.tokenSource.IsCancellationRequested) - { - Logger.Info("Processing containers been cancelled !!"); - return (string.Empty, Array.Empty().ToList()); - } - if (pptr.TryGet(out var obj)) - { - if (objectAssetItemDic.ContainsKey(obj)) - { - objectAssetItemDic[obj].Container = container; - } - } - } - containers.Clear(); - if (Game.Type.IsGISubGroup() || Game.Type.IsZZZ()) - { - UpdateContainers(); - } - } - foreach (var tmp in exportableAssets) - { - if (assetsManager.tokenSource.IsCancellationRequested) - { - Logger.Info("Processing subitems been cancelled !!"); - return (string.Empty, Array.Empty().ToList()); - } - tmp.SetSubItems(); + return (string.Empty, Array.Empty().ToList()); } visibleAssets = exportableAssets; StatusStripUpdate("Building tree structure..."); - var treeNodeCollection = new List(); var treeNodeDictionary = new Dictionary(); int j = 0; @@ -518,13 +357,9 @@ public static (string, List) BuildAssetData() var assetItem = new AssetItem(obj); - if (obj is not GameObject) + if (obj is not GameObject && IsMissingFromFilter(assetItem, context.FastAssetItemFilterData)) { - if (fastAssetItemFilterData.Count > 0 && !fastAssetItemFilterData.Contains(new AssetFilterDataItem { Source = assetItem.SourceFile.fullName, Name = assetItem.Text, PathID = assetItem.m_PathID, Type = assetItem.Type })) - { - Logger.Verbose($"Skipped {(assetItem.Text.Length > 0 ? assetItem.Text : "an asset")} because filter data was set and it was missing from it"); - continue; - } + continue; } if (obj is GameObject m_GameObject) @@ -539,18 +374,18 @@ public static (string, List) BuildAssetData() { if (pptr.TryGet(out var m_Component)) { - if (objectAssetItemDic.ContainsKey(m_Component)) + if (context.ObjectAssetItems.ContainsKey(m_Component)) { - objectAssetItemDic[m_Component].TreeNode = currentNode; + context.ObjectAssetItems[m_Component].TreeNode = currentNode; } - + if (m_Component is MeshFilter m_MeshFilter) { if (m_MeshFilter.m_Mesh.TryGet(out var m_Mesh)) { - if (objectAssetItemDic.ContainsKey(m_Mesh)) + if (context.ObjectAssetItems.ContainsKey(m_Mesh)) { - objectAssetItemDic[m_Mesh].TreeNode = currentNode; + context.ObjectAssetItems[m_Mesh].TreeNode = currentNode; } } } @@ -558,9 +393,9 @@ public static (string, List) BuildAssetData() { if (m_SkinnedMeshRenderer.m_Mesh.TryGet(out var m_Mesh)) { - if (objectAssetItemDic.ContainsKey(m_Mesh)) + if (context.ObjectAssetItems.ContainsKey(m_Mesh)) { - objectAssetItemDic[m_Mesh].TreeNode = currentNode; + context.ObjectAssetItems[m_Mesh].TreeNode = currentNode; } } } @@ -613,9 +448,207 @@ public static (string, List) BuildAssetData() } treeNodeDictionary.Clear(); - objectAssetItemDic.Clear(); + context.ObjectAssetItems.Clear(); + + return (context.ProductName, treeNodeCollection); + } + + private static bool BuildExportableAssetItems(AssetDataBuildContext context) + { + int i = 0; + foreach (var assetsFile in assetsManager.assetsFileList) + { + foreach (var asset in assetsFile.Objects) + { + if (assetsManager.tokenSource.IsCancellationRequested) + { + Logger.Info("Building asset list has been cancelled !!"); + return false; + } + + var assetItem = new AssetItem(asset); + + if (asset is not AssetBundle && asset is not ResourceManager && IsMissingFromFilter(assetItem, context.FastAssetItemFilterData)) + { + continue; + } + + context.ObjectAssetItems.Add(asset, assetItem); + assetItem.UniqueID = "#" + i; + var exportable = ConfigureAssetItemAndReturnExportable(asset, assetItem, context); + + if (assetItem.Text == "") + { + assetItem.Text = assetItem.TypeString + assetItem.UniqueID; + } + if (Properties.Settings.Default.displayAll || exportable) + { + exportableAssets.Add(assetItem); + } + Progress.Report(++i, context.ObjectCount); + } + } + + return true; + } + + private static bool ApplyAssetPostProcessing(AssetDataBuildContext context) + { + foreach ((var pptr, var name) in context.MiHoYoBinDataNames) + { + if (assetsManager.tokenSource.IsCancellationRequested) + { + Logger.Info("Processing asset names has been cancelled !!"); + return false; + } + if (pptr.TryGet(out var obj)) + { + var assetItem = context.ObjectAssetItems[obj]; + if (int.TryParse(name, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hash)) + { + assetItem.Text = name; + assetItem.Container = Properties.Settings.Default.useBundleContainerName ? context.AssetBundleName : hash.ToString(); + } + else assetItem.Text = $"BinFile #{assetItem.m_PathID}"; + } + } + if (!SkipContainer) + { + foreach ((var pptr, var container) in context.Containers) + { + if (assetsManager.tokenSource.IsCancellationRequested) + { + Logger.Info("Processing containers been cancelled !!"); + return false; + } + if (pptr.TryGet(out var obj)) + { + if (context.ObjectAssetItems.ContainsKey(obj)) + { + context.ObjectAssetItems[obj].Container = container; + } + } + } + context.Containers.Clear(); + if (Game.Type.IsGISubGroup() || Game.Type.IsZZZ()) + { + UpdateContainers(); + } + } + foreach (var tmp in exportableAssets) + { + if (assetsManager.tokenSource.IsCancellationRequested) + { + Logger.Info("Processing subitems been cancelled !!"); + return false; + } + tmp.SetSubItems(); + } + + return true; + } + + private static bool ConfigureAssetItemAndReturnExportable(Object asset, AssetItem assetItem, AssetDataBuildContext context) + { + switch (asset) + { + case Texture2D m_Texture2D: + if (!string.IsNullOrEmpty(m_Texture2D.m_StreamData?.path)) + assetItem.FullSize = asset.byteSize + m_Texture2D.m_StreamData.size; + return ClassIDType.Texture2D.CanExport(); + case AudioClip m_AudioClip: + if (!string.IsNullOrEmpty(m_AudioClip.m_Source)) + assetItem.FullSize = asset.byteSize + m_AudioClip.m_Size; + return ClassIDType.AudioClip.CanExport(); + case VideoClip m_VideoClip: + if (!string.IsNullOrEmpty(m_VideoClip.m_OriginalPath)) + assetItem.FullSize = asset.byteSize + m_VideoClip.m_ExternalResources.m_Size; + return ClassIDType.VideoClip.CanExport(); + case PlayerSettings m_PlayerSettings: + context.ProductName = m_PlayerSettings.productName; + return ClassIDType.PlayerSettings.CanExport(); + case AssetBundle m_AssetBundle: + context.AssetBundleName = m_AssetBundle.Name; + if (!SkipContainer) + { + foreach (var m_Container in m_AssetBundle.m_Container) + { + var preloadIndex = m_Container.Value.preloadIndex; + var preloadSize = m_Container.Value.preloadSize; + var preloadEnd = preloadIndex + preloadSize; + + switch (preloadIndex) + { + case int n when n < 0: + Logger.Warning($"preloadIndex {preloadIndex} is out of preloadTable range"); + break; + default: + for (int k = preloadIndex; k < preloadEnd; k++) + { + string containerName = m_Container.Key; + if (int.TryParse(m_Container.Key, out _) && Properties.Settings.Default.useBundleContainerName) + { + containerName = context.AssetBundleName; + } + try + { + context.Containers.Add((m_AssetBundle.m_PreloadTable[k], m_Container.Key)); + } catch + { + Logger.Info($"Failed to add container {m_Container.Key}"); + } + } + break; + } + } + } + + return ClassIDType.AssetBundle.CanExport(); + case IndexObject m_IndexObject: + foreach(var index in m_IndexObject.AssetMap) + { + context.MiHoYoBinDataNames.Add((index.Value.Object, index.Key)); + } + + return ClassIDType.IndexObject.CanExport(); + case ResourceManager m_ResourceManager: + foreach (var m_Container in m_ResourceManager.m_Container) + { + context.Containers.Add((m_Container.Value, m_Container.Key)); + } + + return ClassIDType.ResourceManager.CanExport(); + case Mesh _ when ClassIDType.Mesh.CanExport(): + case TextAsset _ when ClassIDType.TextAsset.CanExport(): + case AnimationClip _ when ClassIDType.AnimationClip.CanExport(): + case Font _ when ClassIDType.Font.CanExport(): + case MovieTexture _ when ClassIDType.MovieTexture.CanExport(): + case Sprite _ when ClassIDType.Sprite.CanExport(): + case Material _ when ClassIDType.Material.CanExport(): + case MiHoYoBinData _ when ClassIDType.MiHoYoBinData.CanExport(): + case NapAssetBundleIndexAsset _ when ClassIDType.NapAssetBundleIndexAsset.CanExport(): + case Shader _ when ClassIDType.Shader.CanExport(): + case Animator _ when ClassIDType.Animator.CanExport(): + case MonoBehaviour _ when ClassIDType.MonoBehaviour.CanExport(): + return true; + default: + return false; + } + } + + private static bool IsMissingFromFilter(AssetItem assetItem, HashSet fastAssetItemFilterData) + { + if (fastAssetItemFilterData.Count == 0) + { + return false; + } + if (fastAssetItemFilterData.Contains(new AssetFilterDataItem { Source = assetItem.SourceFile.fullName, Name = assetItem.Text, PathID = assetItem.m_PathID, Type = assetItem.Type })) + { + return false; + } - return (productName, treeNodeCollection); + Logger.Verbose($"Skipped {(assetItem.Text.Length > 0 ? assetItem.Text : "an asset")} because filter data was set and it was missing from it"); + return true; } public static Dictionary> BuildClassStructure() From c62688d91d1d2eb956f3f1a332737fa04338a579 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:04:15 +0800 Subject: [PATCH 10/56] Extract asset data scene tree build --- AnimeStudio.GUI/Studio.cs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index 9bb3525f..c979ebbf 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -334,6 +334,19 @@ public static (string, List) BuildAssetData() visibleAssets = exportableAssets; StatusStripUpdate("Building tree structure..."); + var treeNodeCollection = BuildSceneTree(context); + context.ObjectAssetItems.Clear(); + + if (treeNodeCollection == null) + { + return (string.Empty, Array.Empty().ToList()); + } + + return (context.ProductName, treeNodeCollection); + } + + private static List BuildSceneTree(AssetDataBuildContext context) + { var treeNodeCollection = new List(); var treeNodeDictionary = new Dictionary(); int j = 0; @@ -352,7 +365,7 @@ public static (string, List) BuildAssetData() if (assetsManager.tokenSource.IsCancellationRequested) { Logger.Info("Building tree structure been cancelled !!"); - return (string.Empty, Array.Empty().ToList()); + return null; } var assetItem = new AssetItem(obj); @@ -448,9 +461,7 @@ public static (string, List) BuildAssetData() } treeNodeDictionary.Clear(); - context.ObjectAssetItems.Clear(); - - return (context.ProductName, treeNodeCollection); + return treeNodeCollection; } private static bool BuildExportableAssetItems(AssetDataBuildContext context) From bfc1c5d89a4013ec8df0a3bb5475b8f3985662b3 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:08:36 +0800 Subject: [PATCH 11/56] Extract asset data tree helpers --- AnimeStudio.GUI/Studio.cs | 195 ++++++++++++++++++++------------------ 1 file changed, 105 insertions(+), 90 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index c979ebbf..b5d5fd60 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -377,62 +377,9 @@ private static List BuildSceneTree(AssetDataBuildContext context) if (obj is GameObject m_GameObject) { - if (!treeNodeDictionary.TryGetValue(m_GameObject, out var currentNode)) - { - currentNode = new GameObjectTreeNode(m_GameObject); - treeNodeDictionary.Add(m_GameObject, currentNode); - } - - foreach (var pptr in m_GameObject.m_Components) - { - if (pptr.TryGet(out var m_Component)) - { - if (context.ObjectAssetItems.ContainsKey(m_Component)) - { - context.ObjectAssetItems[m_Component].TreeNode = currentNode; - } - - if (m_Component is MeshFilter m_MeshFilter) - { - if (m_MeshFilter.m_Mesh.TryGet(out var m_Mesh)) - { - if (context.ObjectAssetItems.ContainsKey(m_Mesh)) - { - context.ObjectAssetItems[m_Mesh].TreeNode = currentNode; - } - } - } - else if (m_Component is SkinnedMeshRenderer m_SkinnedMeshRenderer) - { - if (m_SkinnedMeshRenderer.m_Mesh.TryGet(out var m_Mesh)) - { - if (context.ObjectAssetItems.ContainsKey(m_Mesh)) - { - context.ObjectAssetItems[m_Mesh].TreeNode = currentNode; - } - } - } - } - } - - var parentNode = assetsFileNode; - - if (m_GameObject.m_Transform != null) - { - if (m_GameObject.m_Transform.m_Father.TryGet(out var m_Father)) - { - if (m_Father.m_GameObject.TryGet(out var parentGameObject)) - { - if (!treeNodeDictionary.TryGetValue(parentGameObject, out var parentGameObjectNode)) - { - parentGameObjectNode = new GameObjectTreeNode(parentGameObject); - treeNodeDictionary.Add(parentGameObject, parentGameObjectNode); - } - parentNode = parentGameObjectNode; - } - } - } - + var currentNode = GetOrCreateGameObjectNode(m_GameObject, treeNodeDictionary); + AssignAssetTreeNodes(m_GameObject, currentNode, context); + var parentNode = GetParentTreeNode(m_GameObject, assetsFileNode, treeNodeDictionary); parentNode.Nodes.Add(currentNode); } } @@ -464,6 +411,68 @@ private static List BuildSceneTree(AssetDataBuildContext context) return treeNodeCollection; } + private static GameObjectTreeNode GetOrCreateGameObjectNode(GameObject gameObject, Dictionary treeNodeDictionary) + { + if (!treeNodeDictionary.TryGetValue(gameObject, out var currentNode)) + { + currentNode = new GameObjectTreeNode(gameObject); + treeNodeDictionary.Add(gameObject, currentNode); + } + + return currentNode; + } + + private static void AssignAssetTreeNodes(GameObject gameObject, GameObjectTreeNode currentNode, AssetDataBuildContext context) + { + foreach (var pptr in gameObject.m_Components) + { + if (pptr.TryGet(out var m_Component)) + { + if (context.ObjectAssetItems.ContainsKey(m_Component)) + { + context.ObjectAssetItems[m_Component].TreeNode = currentNode; + } + + if (m_Component is MeshFilter m_MeshFilter) + { + if (m_MeshFilter.m_Mesh.TryGet(out var m_Mesh)) + { + if (context.ObjectAssetItems.ContainsKey(m_Mesh)) + { + context.ObjectAssetItems[m_Mesh].TreeNode = currentNode; + } + } + } + else if (m_Component is SkinnedMeshRenderer m_SkinnedMeshRenderer) + { + if (m_SkinnedMeshRenderer.m_Mesh.TryGet(out var m_Mesh)) + { + if (context.ObjectAssetItems.ContainsKey(m_Mesh)) + { + context.ObjectAssetItems[m_Mesh].TreeNode = currentNode; + } + } + } + } + } + } + + private static TreeNode GetParentTreeNode(GameObject gameObject, TreeNode assetsFileNode, Dictionary treeNodeDictionary) + { + if (gameObject.m_Transform != null) + { + if (gameObject.m_Transform.m_Father.TryGet(out var m_Father)) + { + if (m_Father.m_GameObject.TryGet(out var parentGameObject)) + { + return GetOrCreateGameObjectNode(parentGameObject, treeNodeDictionary); + } + } + } + + return assetsFileNode; + } + private static bool BuildExportableAssetItems(AssetDataBuildContext context) { int i = 0; @@ -580,40 +589,7 @@ private static bool ConfigureAssetItemAndReturnExportable(Object asset, AssetIte return ClassIDType.PlayerSettings.CanExport(); case AssetBundle m_AssetBundle: context.AssetBundleName = m_AssetBundle.Name; - if (!SkipContainer) - { - foreach (var m_Container in m_AssetBundle.m_Container) - { - var preloadIndex = m_Container.Value.preloadIndex; - var preloadSize = m_Container.Value.preloadSize; - var preloadEnd = preloadIndex + preloadSize; - - switch (preloadIndex) - { - case int n when n < 0: - Logger.Warning($"preloadIndex {preloadIndex} is out of preloadTable range"); - break; - default: - for (int k = preloadIndex; k < preloadEnd; k++) - { - string containerName = m_Container.Key; - if (int.TryParse(m_Container.Key, out _) && Properties.Settings.Default.useBundleContainerName) - { - containerName = context.AssetBundleName; - } - try - { - context.Containers.Add((m_AssetBundle.m_PreloadTable[k], m_Container.Key)); - } catch - { - Logger.Info($"Failed to add container {m_Container.Key}"); - } - } - break; - } - } - } - + AddAssetBundleContainers(m_AssetBundle, context); return ClassIDType.AssetBundle.CanExport(); case IndexObject m_IndexObject: foreach(var index in m_IndexObject.AssetMap) @@ -647,6 +623,45 @@ private static bool ConfigureAssetItemAndReturnExportable(Object asset, AssetIte } } + private static void AddAssetBundleContainers(AssetBundle assetBundle, AssetDataBuildContext context) + { + if (SkipContainer) + { + return; + } + + foreach (var m_Container in assetBundle.m_Container) + { + var preloadIndex = m_Container.Value.preloadIndex; + var preloadSize = m_Container.Value.preloadSize; + var preloadEnd = preloadIndex + preloadSize; + + switch (preloadIndex) + { + case int n when n < 0: + Logger.Warning($"preloadIndex {preloadIndex} is out of preloadTable range"); + break; + default: + for (int k = preloadIndex; k < preloadEnd; k++) + { + string containerName = m_Container.Key; + if (int.TryParse(m_Container.Key, out _) && Properties.Settings.Default.useBundleContainerName) + { + containerName = context.AssetBundleName; + } + try + { + context.Containers.Add((assetBundle.m_PreloadTable[k], m_Container.Key)); + } catch + { + Logger.Info($"Failed to add container {m_Container.Key}"); + } + } + break; + } + } + } + private static bool IsMissingFromFilter(AssetItem assetItem, HashSet fastAssetItemFilterData) { if (fastAssetItemFilterData.Count == 0) From 4524c10500c2b7e8546324b51c7a26b8be613e51 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:12:39 +0800 Subject: [PATCH 12/56] Optimize asset filter lookups --- AnimeStudio.GUI/Studio.cs | 49 ++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index b5d5fd60..9f972942 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -306,7 +306,7 @@ private sealed class AssetDataBuildContext public Dictionary ObjectAssetItems { get; } public List<(PPtr PPtr, string Name)> MiHoYoBinDataNames { get; } = new List<(PPtr, string)>(); public List<(PPtr PPtr, string Container)> Containers { get; } = new List<(PPtr, string)>(); - public HashSet FastAssetItemFilterData { get; } + public HashSet FastAssetFilterKeys { get; } public string AssetBundleName { get; set; } = ""; public string ProductName { get; set; } @@ -314,7 +314,40 @@ public AssetDataBuildContext() { ObjectCount = assetsManager.assetsFileList.Sum(x => x.Objects.Count); ObjectAssetItems = new Dictionary(ObjectCount); - FastAssetItemFilterData = new HashSet(assetsManager.FilterData.Items, new AssetFilterDataItemEqualityComparer()); + FastAssetFilterKeys = assetsManager.FilterData.Items + .Select(x => new AssetFilterKey(x.Name, x.PathID, x.Type)) + .ToHashSet(); + } + } + + private readonly struct AssetFilterKey : IEquatable + { + private readonly string name; + private readonly long pathID; + private readonly ClassIDType type; + + public AssetFilterKey(string name, long pathID, ClassIDType type) + { + this.name = name; + this.pathID = pathID; + this.type = type; + } + + public bool Equals(AssetFilterKey other) + { + return type == other.type + && pathID == other.pathID + && string.Equals(name, other.name, StringComparison.OrdinalIgnoreCase); + } + + public override bool Equals(object obj) + { + return obj is AssetFilterKey other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(StringComparer.OrdinalIgnoreCase.GetHashCode(name ?? string.Empty), pathID, type); } } @@ -370,7 +403,7 @@ private static List BuildSceneTree(AssetDataBuildContext context) var assetItem = new AssetItem(obj); - if (obj is not GameObject && IsMissingFromFilter(assetItem, context.FastAssetItemFilterData)) + if (obj is not GameObject && IsMissingFromFilter(assetItem.SourceFile.fullName, assetItem.Text, assetItem.m_PathID, assetItem.Type, context.FastAssetFilterKeys)) { continue; } @@ -488,7 +521,7 @@ private static bool BuildExportableAssetItems(AssetDataBuildContext context) var assetItem = new AssetItem(asset); - if (asset is not AssetBundle && asset is not ResourceManager && IsMissingFromFilter(assetItem, context.FastAssetItemFilterData)) + if (asset is not AssetBundle && asset is not ResourceManager && IsMissingFromFilter(assetItem.SourceFile.fullName, assetItem.Text, assetItem.m_PathID, assetItem.Type, context.FastAssetFilterKeys)) { continue; } @@ -662,18 +695,18 @@ private static void AddAssetBundleContainers(AssetBundle assetBundle, AssetDataB } } - private static bool IsMissingFromFilter(AssetItem assetItem, HashSet fastAssetItemFilterData) + private static bool IsMissingFromFilter(string source, string name, long pathID, ClassIDType type, HashSet fastAssetFilterKeys) { - if (fastAssetItemFilterData.Count == 0) + if (fastAssetFilterKeys.Count == 0) { return false; } - if (fastAssetItemFilterData.Contains(new AssetFilterDataItem { Source = assetItem.SourceFile.fullName, Name = assetItem.Text, PathID = assetItem.m_PathID, Type = assetItem.Type })) + if (fastAssetFilterKeys.Contains(new AssetFilterKey(name, pathID, type))) { return false; } - Logger.Verbose($"Skipped {(assetItem.Text.Length > 0 ? assetItem.Text : "an asset")} because filter data was set and it was missing from it"); + Logger.Verbose($"Skipped {(name.Length > 0 ? name : "an asset")} because filter data was set and it was missing from it"); return true; } From e075e559d34eec3817db8c741f8abff385d646d7 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:13:13 +0800 Subject: [PATCH 13/56] Avoid tree filter asset item allocations --- AnimeStudio.GUI/Studio.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index 9f972942..f58825ff 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -401,9 +401,7 @@ private static List BuildSceneTree(AssetDataBuildContext context) return null; } - var assetItem = new AssetItem(obj); - - if (obj is not GameObject && IsMissingFromFilter(assetItem.SourceFile.fullName, assetItem.Text, assetItem.m_PathID, assetItem.Type, context.FastAssetFilterKeys)) + if (obj is not GameObject && IsMissingFromFilter(obj.assetsFile.fullName, obj.Name, obj.m_PathID, obj.type, context.FastAssetFilterKeys)) { continue; } From 3168210cbf3d11180bbd43fb35d19f979a7e6750 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:13:46 +0800 Subject: [PATCH 14/56] Reduce asset item dictionary lookups --- AnimeStudio.GUI/Studio.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index f58825ff..381f8e9a 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -459,18 +459,18 @@ private static void AssignAssetTreeNodes(GameObject gameObject, GameObjectTreeNo { if (pptr.TryGet(out var m_Component)) { - if (context.ObjectAssetItems.ContainsKey(m_Component)) + if (context.ObjectAssetItems.TryGetValue(m_Component, out var componentAssetItem)) { - context.ObjectAssetItems[m_Component].TreeNode = currentNode; + componentAssetItem.TreeNode = currentNode; } if (m_Component is MeshFilter m_MeshFilter) { if (m_MeshFilter.m_Mesh.TryGet(out var m_Mesh)) { - if (context.ObjectAssetItems.ContainsKey(m_Mesh)) + if (context.ObjectAssetItems.TryGetValue(m_Mesh, out var meshAssetItem)) { - context.ObjectAssetItems[m_Mesh].TreeNode = currentNode; + meshAssetItem.TreeNode = currentNode; } } } @@ -478,9 +478,9 @@ private static void AssignAssetTreeNodes(GameObject gameObject, GameObjectTreeNo { if (m_SkinnedMeshRenderer.m_Mesh.TryGet(out var m_Mesh)) { - if (context.ObjectAssetItems.ContainsKey(m_Mesh)) + if (context.ObjectAssetItems.TryGetValue(m_Mesh, out var meshAssetItem)) { - context.ObjectAssetItems[m_Mesh].TreeNode = currentNode; + meshAssetItem.TreeNode = currentNode; } } } @@ -574,9 +574,9 @@ private static bool ApplyAssetPostProcessing(AssetDataBuildContext context) } if (pptr.TryGet(out var obj)) { - if (context.ObjectAssetItems.ContainsKey(obj)) + if (context.ObjectAssetItems.TryGetValue(obj, out var assetItem)) { - context.ObjectAssetItems[obj].Container = container; + assetItem.Container = container; } } } From 0cc3504f234da342ecf178044c2b2ca5c46224a0 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:14:17 +0800 Subject: [PATCH 15/56] Reduce scene tree grouping allocations --- AnimeStudio.GUI/Studio.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index 381f8e9a..ab754a11 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -384,12 +384,13 @@ private static List BuildSceneTree(AssetDataBuildContext context) var treeNodeDictionary = new Dictionary(); int j = 0; Progress.Reset(); - var files = assetsManager.assetsFileList.GroupBy(x => x.originalPath ?? string.Empty).OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.ToList()); - foreach (var (file, assetsFiles) in files) + var files = assetsManager.assetsFileList.GroupBy(x => x.originalPath ?? string.Empty).OrderBy(x => x.Key).ToList(); + foreach (var fileGroup in files) { + var file = fileGroup.Key; var fileNode = !string.IsNullOrEmpty(file) ? new TreeNode(Path.GetFileName(file)) : null; //RootNode - foreach (var assetsFile in assetsFiles) + foreach (var assetsFile in fileGroup) { var assetsFileNode = new TreeNode(assetsFile.fileName); From 9bd77165d15aaea61bd8bcd3c481a211bca08013 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:14:45 +0800 Subject: [PATCH 16/56] Remove unused asset bundle container name calculation --- AnimeStudio.GUI/Studio.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index ab754a11..b411846d 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -676,11 +676,6 @@ private static void AddAssetBundleContainers(AssetBundle assetBundle, AssetDataB default: for (int k = preloadIndex; k < preloadEnd; k++) { - string containerName = m_Container.Key; - if (int.TryParse(m_Container.Key, out _) && Properties.Settings.Default.useBundleContainerName) - { - containerName = context.AssetBundleName; - } try { context.Containers.Add((assetBundle.m_PreloadTable[k], m_Container.Key)); From 33b5569562d5edb2a8a3448e8b789371bb6dddf0 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:21:53 +0800 Subject: [PATCH 17/56] Optimize exportable asset item loop --- AnimeStudio.GUI/Studio.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index b411846d..4214be0d 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -508,6 +508,13 @@ private static TreeNode GetParentTreeNode(GameObject gameObject, TreeNode assets private static bool BuildExportableAssetItems(AssetDataBuildContext context) { int i = 0; + var displayAll = Properties.Settings.Default.displayAll; + var hasFilterData = context.FastAssetFilterKeys.Count > 0; + if (displayAll && exportableAssets.Capacity < context.ObjectCount) + { + exportableAssets.Capacity = context.ObjectCount; + } + foreach (var assetsFile in assetsManager.assetsFileList) { foreach (var asset in assetsFile.Objects) @@ -518,13 +525,13 @@ private static bool BuildExportableAssetItems(AssetDataBuildContext context) return false; } - var assetItem = new AssetItem(asset); - - if (asset is not AssetBundle && asset is not ResourceManager && IsMissingFromFilter(assetItem.SourceFile.fullName, assetItem.Text, assetItem.m_PathID, assetItem.Type, context.FastAssetFilterKeys)) + if (hasFilterData && asset is not AssetBundle && asset is not ResourceManager && IsMissingFromFilter(asset.assetsFile.fullName, asset.Name, asset.m_PathID, asset.type, context.FastAssetFilterKeys)) { continue; } + var assetItem = new AssetItem(asset); + context.ObjectAssetItems.Add(asset, assetItem); assetItem.UniqueID = "#" + i; var exportable = ConfigureAssetItemAndReturnExportable(asset, assetItem, context); @@ -533,7 +540,7 @@ private static bool BuildExportableAssetItems(AssetDataBuildContext context) { assetItem.Text = assetItem.TypeString + assetItem.UniqueID; } - if (Properties.Settings.Default.displayAll || exportable) + if (displayAll || exportable) { exportableAssets.Add(assetItem); } From 48efbdbb4110054c3bd86f838dc0762c0e922250 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:23:51 +0800 Subject: [PATCH 18/56] Cache export type flags during asset build --- AnimeStudio.GUI/Studio.cs | 69 ++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index 4214be0d..68c9be5b 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -307,6 +307,7 @@ private sealed class AssetDataBuildContext public List<(PPtr PPtr, string Name)> MiHoYoBinDataNames { get; } = new List<(PPtr, string)>(); public List<(PPtr PPtr, string Container)> Containers { get; } = new List<(PPtr, string)>(); public HashSet FastAssetFilterKeys { get; } + private readonly Dictionary canExportCache = new Dictionary(); public string AssetBundleName { get; set; } = ""; public string ProductName { get; set; } @@ -318,6 +319,17 @@ public AssetDataBuildContext() .Select(x => new AssetFilterKey(x.Name, x.PathID, x.Type)) .ToHashSet(); } + + public bool CanExport(ClassIDType type) + { + if (!canExportCache.TryGetValue(type, out var canExport)) + { + canExport = type.CanExport(); + canExportCache.Add(type, canExport); + } + + return canExport; + } } private readonly struct AssetFilterKey : IEquatable @@ -402,7 +414,7 @@ private static List BuildSceneTree(AssetDataBuildContext context) return null; } - if (obj is not GameObject && IsMissingFromFilter(obj.assetsFile.fullName, obj.Name, obj.m_PathID, obj.type, context.FastAssetFilterKeys)) + if (obj is not GameObject && IsMissingFromFilter(obj.Name, obj.m_PathID, obj.type, context.FastAssetFilterKeys)) { continue; } @@ -525,7 +537,7 @@ private static bool BuildExportableAssetItems(AssetDataBuildContext context) return false; } - if (hasFilterData && asset is not AssetBundle && asset is not ResourceManager && IsMissingFromFilter(asset.assetsFile.fullName, asset.Name, asset.m_PathID, asset.type, context.FastAssetFilterKeys)) + if (hasFilterData && asset is not AssetBundle && asset is not ResourceManager && IsMissingFromFilter(asset.Name, asset.m_PathID, asset.type, context.FastAssetFilterKeys)) { continue; } @@ -614,49 +626,60 @@ private static bool ConfigureAssetItemAndReturnExportable(Object asset, AssetIte case Texture2D m_Texture2D: if (!string.IsNullOrEmpty(m_Texture2D.m_StreamData?.path)) assetItem.FullSize = asset.byteSize + m_Texture2D.m_StreamData.size; - return ClassIDType.Texture2D.CanExport(); + return context.CanExport(ClassIDType.Texture2D); case AudioClip m_AudioClip: if (!string.IsNullOrEmpty(m_AudioClip.m_Source)) assetItem.FullSize = asset.byteSize + m_AudioClip.m_Size; - return ClassIDType.AudioClip.CanExport(); + return context.CanExport(ClassIDType.AudioClip); case VideoClip m_VideoClip: if (!string.IsNullOrEmpty(m_VideoClip.m_OriginalPath)) assetItem.FullSize = asset.byteSize + m_VideoClip.m_ExternalResources.m_Size; - return ClassIDType.VideoClip.CanExport(); + return context.CanExport(ClassIDType.VideoClip); case PlayerSettings m_PlayerSettings: context.ProductName = m_PlayerSettings.productName; - return ClassIDType.PlayerSettings.CanExport(); + return context.CanExport(ClassIDType.PlayerSettings); case AssetBundle m_AssetBundle: context.AssetBundleName = m_AssetBundle.Name; AddAssetBundleContainers(m_AssetBundle, context); - return ClassIDType.AssetBundle.CanExport(); + return context.CanExport(ClassIDType.AssetBundle); case IndexObject m_IndexObject: foreach(var index in m_IndexObject.AssetMap) { context.MiHoYoBinDataNames.Add((index.Value.Object, index.Key)); } - return ClassIDType.IndexObject.CanExport(); + return context.CanExport(ClassIDType.IndexObject); case ResourceManager m_ResourceManager: foreach (var m_Container in m_ResourceManager.m_Container) { context.Containers.Add((m_Container.Value, m_Container.Key)); } - return ClassIDType.ResourceManager.CanExport(); - case Mesh _ when ClassIDType.Mesh.CanExport(): - case TextAsset _ when ClassIDType.TextAsset.CanExport(): - case AnimationClip _ when ClassIDType.AnimationClip.CanExport(): - case Font _ when ClassIDType.Font.CanExport(): - case MovieTexture _ when ClassIDType.MovieTexture.CanExport(): - case Sprite _ when ClassIDType.Sprite.CanExport(): - case Material _ when ClassIDType.Material.CanExport(): - case MiHoYoBinData _ when ClassIDType.MiHoYoBinData.CanExport(): - case NapAssetBundleIndexAsset _ when ClassIDType.NapAssetBundleIndexAsset.CanExport(): - case Shader _ when ClassIDType.Shader.CanExport(): - case Animator _ when ClassIDType.Animator.CanExport(): - case MonoBehaviour _ when ClassIDType.MonoBehaviour.CanExport(): - return true; + return context.CanExport(ClassIDType.ResourceManager); + case Mesh _: + return context.CanExport(ClassIDType.Mesh); + case TextAsset _: + return context.CanExport(ClassIDType.TextAsset); + case AnimationClip _: + return context.CanExport(ClassIDType.AnimationClip); + case Font _: + return context.CanExport(ClassIDType.Font); + case MovieTexture _: + return context.CanExport(ClassIDType.MovieTexture); + case Sprite _: + return context.CanExport(ClassIDType.Sprite); + case Material _: + return context.CanExport(ClassIDType.Material); + case MiHoYoBinData _: + return context.CanExport(ClassIDType.MiHoYoBinData); + case NapAssetBundleIndexAsset _: + return context.CanExport(ClassIDType.NapAssetBundleIndexAsset); + case Shader _: + return context.CanExport(ClassIDType.Shader); + case Animator _: + return context.CanExport(ClassIDType.Animator); + case MonoBehaviour _: + return context.CanExport(ClassIDType.MonoBehaviour); default: return false; } @@ -696,7 +719,7 @@ private static void AddAssetBundleContainers(AssetBundle assetBundle, AssetDataB } } - private static bool IsMissingFromFilter(string source, string name, long pathID, ClassIDType type, HashSet fastAssetFilterKeys) + private static bool IsMissingFromFilter(string name, long pathID, ClassIDType type, HashSet fastAssetFilterKeys) { if (fastAssetFilterKeys.Count == 0) { From 00a82b00e700df46ae19bb5e9645e88e45436d97 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:29:56 +0800 Subject: [PATCH 19/56] Optimize asset entry filter matching --- AnimeStudio/AssetMap.cs | 59 ++++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/AnimeStudio/AssetMap.cs b/AnimeStudio/AssetMap.cs index 65d74a26..1b7647d8 100644 --- a/AnimeStudio/AssetMap.cs +++ b/AnimeStudio/AssetMap.cs @@ -35,18 +35,6 @@ public partial record AssetMap [MessagePackObject, MemoryPackable] public partial record AssetEntry { - private static readonly Dictionary> PropertyExtractors = new - Dictionary>(StringComparer.OrdinalIgnoreCase) - { - { nameof(Name), r => r.Name }, - { nameof(Container), r => r.Container }, - { nameof(Source), r => r.Source }, - { nameof(PathID), r => r.PathID.ToString() }, - { nameof(Type), r => r.Type.ToString() }, - { nameof(Hash), r => r.Hash ?? string.Empty }, - { "SHA256Hash", r => r.Hash ?? string.Empty } - }; - private string _container; private string _hash; private string _name; @@ -92,15 +80,56 @@ public bool Matches(Dictionary filters) foreach (KeyValuePair kvp in filters) { - if(!PropertyExtractors.TryGetValue(kvp.Key, out Func extractor)) - return false; + var regex = kvp.Value; + if (string.IsNullOrEmpty(regex.ToString())) + continue; + if(!TryGetFilterValue(kvp.Key, out var value)) + return false; - if(!kvp.Value.IsMatch(extractor(this))) + if(!regex.IsMatch(value)) return false; } return true; } + + private bool TryGetFilterValue(string key, out string value) + { + if (string.Equals(key, nameof(Name), StringComparison.OrdinalIgnoreCase)) + { + value = Name; + return true; + } + if (string.Equals(key, nameof(Container), StringComparison.OrdinalIgnoreCase)) + { + value = Container; + return true; + } + if (string.Equals(key, nameof(Source), StringComparison.OrdinalIgnoreCase)) + { + value = Source; + return true; + } + if (string.Equals(key, nameof(PathID), StringComparison.OrdinalIgnoreCase)) + { + value = PathID.ToString(); + return true; + } + if (string.Equals(key, nameof(Type), StringComparison.OrdinalIgnoreCase)) + { + value = Type.ToString(); + return true; + } + if (string.Equals(key, nameof(Hash), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "SHA256Hash", StringComparison.OrdinalIgnoreCase)) + { + value = Hash ?? string.Empty; + return true; + } + + value = null; + return false; + } } } From ffc22fd71d3e1e5309f72be3eb186f57f2b82ee3 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:34:25 +0800 Subject: [PATCH 20/56] Skip non game objects faster in scene tree build --- AnimeStudio.GUI/Studio.cs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index 68c9be5b..5a84993e 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -394,6 +394,7 @@ private static List BuildSceneTree(AssetDataBuildContext context) { var treeNodeCollection = new List(); var treeNodeDictionary = new Dictionary(); + var hasFilterData = context.FastAssetFilterKeys.Count > 0; int j = 0; Progress.Reset(); var files = assetsManager.assetsFileList.GroupBy(x => x.originalPath ?? string.Empty).OrderBy(x => x.Key).ToList(); @@ -414,18 +415,21 @@ private static List BuildSceneTree(AssetDataBuildContext context) return null; } - if (obj is not GameObject && IsMissingFromFilter(obj.Name, obj.m_PathID, obj.type, context.FastAssetFilterKeys)) + if (obj is not GameObject) { + if (hasFilterData && IsMissingFromFilter(obj.Name, obj.m_PathID, obj.type, context.FastAssetFilterKeys)) + { + continue; + } + continue; } - if (obj is GameObject m_GameObject) - { - var currentNode = GetOrCreateGameObjectNode(m_GameObject, treeNodeDictionary); - AssignAssetTreeNodes(m_GameObject, currentNode, context); - var parentNode = GetParentTreeNode(m_GameObject, assetsFileNode, treeNodeDictionary); - parentNode.Nodes.Add(currentNode); - } + var m_GameObject = (GameObject)obj; + var currentNode = GetOrCreateGameObjectNode(m_GameObject, treeNodeDictionary); + AssignAssetTreeNodes(m_GameObject, currentNode, context); + var parentNode = GetParentTreeNode(m_GameObject, assetsFileNode, treeNodeDictionary); + parentNode.Nodes.Add(currentNode); } // TODO: need to do proper cleaning of list to only include filtered assets when required From 7729003dbf569052e2f72d86d5376a593ebc16dd Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:35:00 +0800 Subject: [PATCH 21/56] Preallocate scene tree node map --- AnimeStudio.GUI/Studio.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index 5a84993e..78c9061d 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -303,6 +303,7 @@ public static void UpdateContainers() private sealed class AssetDataBuildContext { public int ObjectCount { get; } + public int GameObjectCount { get; } public Dictionary ObjectAssetItems { get; } public List<(PPtr PPtr, string Name)> MiHoYoBinDataNames { get; } = new List<(PPtr, string)>(); public List<(PPtr PPtr, string Container)> Containers { get; } = new List<(PPtr, string)>(); @@ -313,7 +314,18 @@ private sealed class AssetDataBuildContext public AssetDataBuildContext() { - ObjectCount = assetsManager.assetsFileList.Sum(x => x.Objects.Count); + foreach (var assetsFile in assetsManager.assetsFileList) + { + ObjectCount += assetsFile.Objects.Count; + foreach (var asset in assetsFile.Objects) + { + if (asset is GameObject) + { + GameObjectCount++; + } + } + } + ObjectAssetItems = new Dictionary(ObjectCount); FastAssetFilterKeys = assetsManager.FilterData.Items .Select(x => new AssetFilterKey(x.Name, x.PathID, x.Type)) @@ -393,7 +405,7 @@ public static (string, List) BuildAssetData() private static List BuildSceneTree(AssetDataBuildContext context) { var treeNodeCollection = new List(); - var treeNodeDictionary = new Dictionary(); + var treeNodeDictionary = new Dictionary(context.GameObjectCount); var hasFilterData = context.FastAssetFilterKeys.Count > 0; int j = 0; Progress.Reset(); From afcf76a3ab9623d020d4c1bdc76be81c8b7abdee Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:37:03 +0800 Subject: [PATCH 22/56] Simplify asset tree node assignment --- AnimeStudio.GUI/Studio.cs | 51 +++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index 78c9061d..5500d49b 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -486,34 +486,33 @@ private static void AssignAssetTreeNodes(GameObject gameObject, GameObjectTreeNo { foreach (var pptr in gameObject.m_Components) { - if (pptr.TryGet(out var m_Component)) + if (!pptr.TryGet(out var m_Component)) { - if (context.ObjectAssetItems.TryGetValue(m_Component, out var componentAssetItem)) - { - componentAssetItem.TreeNode = currentNode; - } - - if (m_Component is MeshFilter m_MeshFilter) - { - if (m_MeshFilter.m_Mesh.TryGet(out var m_Mesh)) - { - if (context.ObjectAssetItems.TryGetValue(m_Mesh, out var meshAssetItem)) - { - meshAssetItem.TreeNode = currentNode; - } - } - } - else if (m_Component is SkinnedMeshRenderer m_SkinnedMeshRenderer) - { - if (m_SkinnedMeshRenderer.m_Mesh.TryGet(out var m_Mesh)) - { - if (context.ObjectAssetItems.TryGetValue(m_Mesh, out var meshAssetItem)) - { - meshAssetItem.TreeNode = currentNode; - } - } - } + continue; } + + AssignTreeNode(m_Component, currentNode, context); + AssignMeshTreeNode(m_Component, currentNode, context); + } + } + + private static void AssignTreeNode(Object asset, GameObjectTreeNode currentNode, AssetDataBuildContext context) + { + if (context.ObjectAssetItems.TryGetValue(asset, out var assetItem)) + { + assetItem.TreeNode = currentNode; + } + } + + private static void AssignMeshTreeNode(Object component, GameObjectTreeNode currentNode, AssetDataBuildContext context) + { + if (component is MeshFilter m_MeshFilter && m_MeshFilter.m_Mesh.TryGet(out var meshFromFilter)) + { + AssignTreeNode(meshFromFilter, currentNode, context); + } + else if (component is SkinnedMeshRenderer m_SkinnedMeshRenderer && m_SkinnedMeshRenderer.m_Mesh.TryGet(out var meshFromRenderer)) + { + AssignTreeNode(meshFromRenderer, currentNode, context); } } From e7ad5b2076344d7ec11b308593e09660979273d5 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:37:39 +0800 Subject: [PATCH 23/56] Simplify exportable asset item loop --- AnimeStudio.GUI/Studio.cs | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index 5500d49b..6149c822 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -557,20 +557,7 @@ private static bool BuildExportableAssetItems(AssetDataBuildContext context) continue; } - var assetItem = new AssetItem(asset); - - context.ObjectAssetItems.Add(asset, assetItem); - assetItem.UniqueID = "#" + i; - var exportable = ConfigureAssetItemAndReturnExportable(asset, assetItem, context); - - if (assetItem.Text == "") - { - assetItem.Text = assetItem.TypeString + assetItem.UniqueID; - } - if (displayAll || exportable) - { - exportableAssets.Add(assetItem); - } + AddAssetItem(asset, context, displayAll, i); Progress.Report(++i, context.ObjectCount); } } @@ -578,6 +565,24 @@ private static bool BuildExportableAssetItems(AssetDataBuildContext context) return true; } + private static void AddAssetItem(Object asset, AssetDataBuildContext context, bool displayAll, int index) + { + var assetItem = new AssetItem(asset); + + context.ObjectAssetItems.Add(asset, assetItem); + assetItem.UniqueID = "#" + index; + var exportable = ConfigureAssetItemAndReturnExportable(asset, assetItem, context); + + if (assetItem.Text == "") + { + assetItem.Text = assetItem.TypeString + assetItem.UniqueID; + } + if (displayAll || exportable) + { + exportableAssets.Add(assetItem); + } + } + private static bool ApplyAssetPostProcessing(AssetDataBuildContext context) { foreach ((var pptr, var name) in context.MiHoYoBinDataNames) From d253ec0ec8c6ec9c094b4569ceaf8f411b8a6dd0 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:40:16 +0800 Subject: [PATCH 24/56] Refactor asset export workflow --- AnimeStudio.GUI/Studio.cs | 164 ++++++++++++++++++++++---------------- 1 file changed, 94 insertions(+), 70 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index 6149c822..bbd7d732 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -300,6 +300,8 @@ public static void UpdateContainers() } } + #region Build asset data + private sealed class AssetDataBuildContext { public int ObjectCount { get; } @@ -754,6 +756,8 @@ private static bool IsMissingFromFilter(string name, long pathID, ClassIDType ty return true; } + #endregion + public static Dictionary> BuildClassStructure() { var typeMap = new Dictionary>(); @@ -795,6 +799,8 @@ public static Dictionary> BuildClass return typeMap; } + #region Export assets + public static Task ExportAssets(string savePath, List toExportAssets, ExportType exportType, bool openAfterExport) { return Task.Run(() => @@ -805,94 +811,112 @@ public static Task ExportAssets(string savePath, List toExportAssets, int exportedCount = 0; int i = 0; Progress.Reset(); + var assetGroupOption = (AssetGroupOption)Properties.Settings.Default.assetGroupOption; foreach (var asset in toExportAssets) { - string exportPath; - switch ((AssetGroupOption)Properties.Settings.Default.assetGroupOption) - { - case AssetGroupOption.ByType: //type name - exportPath = Path.Combine(savePath, asset.TypeString); - break; - case AssetGroupOption.ByContainer: //container path - if (!string.IsNullOrEmpty(asset.Container)) - { - exportPath = Path.HasExtension(asset.Container) ? Path.Combine(savePath, Path.GetDirectoryName(asset.Container)) : Path.Combine(savePath, asset.Container); - } - else - { - exportPath = savePath; - } - break; - case AssetGroupOption.BySource: //source file - if (string.IsNullOrEmpty(asset.SourceFile.originalPath)) - { - exportPath = Path.Combine(savePath, asset.SourceFile.fileName + "_export"); - } - else - { - exportPath = Path.Combine(savePath, Path.GetFileName(asset.SourceFile.originalPath) + "_export", asset.SourceFile.fileName); - } - break; - default: - exportPath = savePath; - break; - } - exportPath += Path.DirectorySeparatorChar; + var exportPath = GetAssetExportPath(savePath, asset, assetGroupOption); StatusStripUpdate($"[{exportedCount}/{toExportCount}] Exporting {asset.TypeString}: {asset.Text}"); - try - { - switch (exportType) - { - case ExportType.Raw: - if (ExportRawFile(asset, exportPath)) - { - exportedCount++; - } - break; - case ExportType.Dump: - if (ExportDumpFile(asset, exportPath)) - { - exportedCount++; - } - break; - case ExportType.Convert: - if (ExportConvertFile(asset, exportPath)) - { - exportedCount++; - } - break; - case ExportType.JSON: - if (ExportJSONFile(asset, exportPath)) - { - exportedCount++; - } - break; - } - } - catch (Exception ex) + + if (TryExportAsset(asset, exportPath, exportType)) { - Logger.Error($"Export {asset.Type}:{asset.Text} error\r\n{ex.Message}\r\n{ex.StackTrace}"); + exportedCount++; } Progress.Report(++i, toExportCount); } - var statusText = exportedCount == 0 ? "Nothing exported." : $"Finished exporting {exportedCount} assets."; + StatusStripUpdate(GetExportAssetsStatus(toExportCount, exportedCount)); - if (toExportCount > exportedCount) + if (openAfterExport && exportedCount > 0) { - statusText += $" {toExportCount - exportedCount} assets skipped (not extractable or files already exist)"; + OpenFolderInExplorer(savePath); } + }); + } - StatusStripUpdate(statusText); + private static string GetAssetExportPath(string savePath, AssetItem asset, AssetGroupOption assetGroupOption) + { + string exportPath; + switch (assetGroupOption) + { + case AssetGroupOption.ByType: + exportPath = Path.Combine(savePath, asset.TypeString); + break; + case AssetGroupOption.ByContainer: + exportPath = GetContainerExportPath(savePath, asset); + break; + case AssetGroupOption.BySource: + exportPath = GetSourceExportPath(savePath, asset); + break; + default: + exportPath = savePath; + break; + } - if (openAfterExport && exportedCount > 0) + return exportPath + Path.DirectorySeparatorChar; + } + + private static string GetContainerExportPath(string savePath, AssetItem asset) + { + if (string.IsNullOrEmpty(asset.Container)) + { + return savePath; + } + + return Path.HasExtension(asset.Container) + ? Path.Combine(savePath, Path.GetDirectoryName(asset.Container)) + : Path.Combine(savePath, asset.Container); + } + + private static string GetSourceExportPath(string savePath, AssetItem asset) + { + if (string.IsNullOrEmpty(asset.SourceFile.originalPath)) + { + return Path.Combine(savePath, asset.SourceFile.fileName + "_export"); + } + + return Path.Combine(savePath, Path.GetFileName(asset.SourceFile.originalPath) + "_export", asset.SourceFile.fileName); + } + + private static bool TryExportAsset(AssetItem asset, string exportPath, ExportType exportType) + { + try + { + switch (exportType) { - OpenFolderInExplorer(savePath); + case ExportType.Raw: + return ExportRawFile(asset, exportPath); + case ExportType.Dump: + return ExportDumpFile(asset, exportPath); + case ExportType.Convert: + return ExportConvertFile(asset, exportPath); + case ExportType.JSON: + return ExportJSONFile(asset, exportPath); + default: + return false; } - }); + } + catch (Exception ex) + { + Logger.Error($"Export {asset.Type}:{asset.Text} error\r\n{ex.Message}\r\n{ex.StackTrace}"); + return false; + } } + private static string GetExportAssetsStatus(int toExportCount, int exportedCount) + { + var statusText = exportedCount == 0 ? "Nothing exported." : $"Finished exporting {exportedCount} assets."; + + if (toExportCount > exportedCount) + { + statusText += $" {toExportCount - exportedCount} assets skipped (not extractable or files already exist)"; + } + + return statusText; + } + + #endregion + public static Task ExportAssetsList(string savePath, List toExportAssets, ExportListType exportListType) { return Task.Run(() => From 394f2b84434eb810376bb181821d2229c6a6662d Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:41:15 +0800 Subject: [PATCH 25/56] Refactor split object export workflow --- AnimeStudio.GUI/Studio.cs | 138 ++++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 64 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index bbd7d732..798452f9 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -967,89 +967,97 @@ public static Task ExportAssetsList(string savePath, List toExportAss }); } + #region Export split objects + public static Task ExportSplitObjects(string savePath, TreeNodeCollection nodes) { return Task.Run(() => { - var exportNodes = GetNodes(nodes); - var count = exportNodes.Cast().Sum(x => x.Nodes.Count); + var exportNodes = GetSplitExportNodes(nodes).ToList(); + var count = exportNodes.Sum(x => x.Nodes.Count); int k = 0; Progress.Reset(); - foreach (TreeNode node in exportNodes) + + foreach (var node in exportNodes) { - //遍历一级子节点 - foreach (GameObjectTreeNode j in node.Nodes) + foreach (GameObjectTreeNode childNode in node.Nodes) { - //收集所有子节点 - var gameObjects = new List(); - CollectNode(j, gameObjects); - //跳过一些不需要导出的object - if (gameObjects.All(x => x.m_SkinnedMeshRenderer == null && x.m_MeshFilter == null)) - { - Progress.Report(++k, count); - continue; - } - //处理非法文件名 - var filename = FixFileName(j.Text); - if (node.Parent != null) - { - filename = Path.Combine(FixFileName(node.Parent.Text), filename); - } - //每个文件存放在单独的文件夹 - var targetPath = $"{savePath}{filename}{Path.DirectorySeparatorChar}"; - //重名文件处理 - for (int i = 1; ; i++) - { - if (Directory.Exists(targetPath)) - { - targetPath = $"{savePath}{filename} ({i}){Path.DirectorySeparatorChar}"; - } - else - { - break; - } - } - Directory.CreateDirectory(targetPath); - //导出FBX - StatusStripUpdate($"Exporting {filename}.fbx"); - try - { - ExportGameObject(j.gameObject, targetPath); - } - catch (Exception ex) - { - Logger.Error($"Export GameObject:{j.Text} error\r\n{ex.Message}\r\n{ex.StackTrace}"); - } - + ExportSplitObject(savePath, node, childNode); Progress.Report(++k, count); - StatusStripUpdate($"Finished exporting {filename}.fbx"); } } + if (Properties.Settings.Default.openAfterExport) { OpenFolderInExplorer(savePath); } StatusStripUpdate("Finished"); + }); + } - IEnumerable GetNodes(TreeNodeCollection nodes) + private static IEnumerable GetSplitExportNodes(TreeNodeCollection nodes) + { + foreach (TreeNode node in nodes) + { + if (node.Nodes.Count == 0) { - foreach(TreeNode node in nodes) - { - var subNodes = node.Nodes.OfType().ToArray(); - if (subNodes.Length == 0) - { - yield return node; - } - else - { - foreach (TreeNode subNode in subNodes) - { - yield return subNode; - } - } - } + yield return node; + continue; } - }); + + foreach (TreeNode subNode in node.Nodes) + { + yield return subNode; + } + } + } + + private static void ExportSplitObject(string savePath, TreeNode parentNode, GameObjectTreeNode node) + { + var gameObjects = new List(); + CollectNode(node, gameObjects); + if (gameObjects.All(x => x.m_SkinnedMeshRenderer == null && x.m_MeshFilter == null)) + { + return; + } + + var filename = GetSplitObjectFileName(parentNode, node); + var targetPath = GetUniqueSplitObjectTargetPath(savePath, filename); + Directory.CreateDirectory(targetPath); + + StatusStripUpdate($"Exporting {filename}.fbx"); + try + { + ExportGameObject(node.gameObject, targetPath); + } + catch (Exception ex) + { + Logger.Error($"Export GameObject:{node.Text} error\r\n{ex.Message}\r\n{ex.StackTrace}"); + } + + StatusStripUpdate($"Finished exporting {filename}.fbx"); + } + + private static string GetSplitObjectFileName(TreeNode parentNode, GameObjectTreeNode node) + { + var filename = FixFileName(node.Text); + if (parentNode.Parent != null) + { + filename = Path.Combine(FixFileName(parentNode.Parent.Text), filename); + } + + return filename; + } + + private static string GetUniqueSplitObjectTargetPath(string savePath, string filename) + { + var targetPath = $"{savePath}{filename}{Path.DirectorySeparatorChar}"; + for (int i = 1; Directory.Exists(targetPath); i++) + { + targetPath = $"{savePath}{filename} ({i}){Path.DirectorySeparatorChar}"; + } + + return targetPath; } private static void CollectNode(GameObjectTreeNode node, List gameObjects) @@ -1061,6 +1069,8 @@ private static void CollectNode(GameObjectTreeNode node, List gameOb } } + #endregion + public static Task ExportAnimatorWithAnimationClip(AssetItem animator, List animationList, string exportPath) { return Task.Run(() => From d067b11a254fe7de37e6172568045e38d701cbe2 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:43:30 +0800 Subject: [PATCH 26/56] Refactor container updates --- AnimeStudio.GUI/Studio.cs | 94 ++++++++++++++++++++++++++++----------- 1 file changed, 68 insertions(+), 26 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index 798452f9..bcf507e5 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -268,38 +268,80 @@ private static int ExtractStreamFile(string extractPath, List fileLi public static void UpdateContainers() { - if (exportableAssets.Count > 0) + if (exportableAssets.Count == 0) { - Logger.Info("Updating Containers..."); - foreach (var asset in exportableAssets) + return; + } + + Logger.Info("Updating Containers..."); + var isZZZ = Game.Type.IsZZZ(); + var sourceFileIds = new Dictionary(); + + foreach (var asset in exportableAssets) + { + if (isZZZ && TryUpdateZZZContainer(asset)) { - if (Game.Type.IsZZZ() && ulong.TryParse(asset.Container, out var hash) && Paths.TryGetValue(hash, out var z3Path)) - { - asset.Container = z3Path; - continue; - } - if (int.TryParse(asset.Container, out var value)) - { - var last = unchecked((uint)value); - var name = Path.GetFileNameWithoutExtension(asset.SourceFile.originalPath); - if (uint.TryParse(name, out var id)) - { - var path = ResourceIndex.GetContainer(id, last); - if (!string.IsNullOrEmpty(path)) - { - asset.Container = path; - if (asset.Type == ClassIDType.MiHoYoBinData) - { - asset.Text = Path.GetFileNameWithoutExtension(path); - } - } - } - } + continue; } - Logger.Info("Updated !!"); + + TryUpdateIndexedContainer(asset, sourceFileIds); + } + + Logger.Info("Updated !!"); + } + + private static bool TryUpdateZZZContainer(AssetItem asset) + { + if (!ulong.TryParse(asset.Container, out var hash) || !Paths.TryGetValue(hash, out var z3Path)) + { + return false; + } + + asset.Container = z3Path; + return true; + } + + private static void TryUpdateIndexedContainer(AssetItem asset, Dictionary sourceFileIds) + { + if (!int.TryParse(asset.Container, out var value)) + { + return; + } + + var id = GetSourceFileId(asset.SourceFile.originalPath, sourceFileIds); + if (!id.HasValue) + { + return; + } + + var last = unchecked((uint)value); + var path = ResourceIndex.GetContainer(id.Value, last); + if (string.IsNullOrEmpty(path)) + { + return; + } + + asset.Container = path; + if (asset.Type == ClassIDType.MiHoYoBinData) + { + asset.Text = Path.GetFileNameWithoutExtension(path); } } + private static uint? GetSourceFileId(string originalPath, Dictionary sourceFileIds) + { + originalPath ??= string.Empty; + if (sourceFileIds.TryGetValue(originalPath, out var id)) + { + return id; + } + + var name = Path.GetFileNameWithoutExtension(originalPath); + id = uint.TryParse(name, out var parsedId) ? parsedId : null; + sourceFileIds.Add(originalPath, id); + return id; + } + #region Build asset data private sealed class AssetDataBuildContext From 0f4c154d0ea200838b273ea1d8696c47b0993366 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:53:28 +0800 Subject: [PATCH 27/56] Clear asset map string cache --- AnimeStudio/AssetMap.cs | 7 ++++++- AnimeStudio/ResourceMap.cs | 17 ++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/AnimeStudio/AssetMap.cs b/AnimeStudio/AssetMap.cs index 1b7647d8..6e27d22e 100644 --- a/AnimeStudio/AssetMap.cs +++ b/AnimeStudio/AssetMap.cs @@ -20,6 +20,11 @@ public static string Get(string value) _cache.Add(value); return value; } + + public static void Clear() + { + _cache.Clear(); + } } [MessagePackObject, MemoryPackable] @@ -67,7 +72,7 @@ public string Source { [Key(5)] public string Hash { get => _hash; - set => _hash = StringCache.Get(value); + set => _hash = value; } [Key(6)] diff --git a/AnimeStudio/ResourceMap.cs b/AnimeStudio/ResourceMap.cs index 301430e0..04cbf20f 100644 --- a/AnimeStudio/ResourceMap.cs +++ b/AnimeStudio/ResourceMap.cs @@ -37,13 +37,14 @@ public static int FromFile(string path) string extension = Path.GetExtension(path).ToLower(); using FileStream stream = File.OpenRead(path); ReadOnlySpan bytes = File.ReadAllBytes(path); + AssetMap newMap = null; switch(extension) { case ".map": { // Deserialize map - Instance = MessagePackSerializer.Deserialize + newMap = MessagePackSerializer.Deserialize (stream, MessagePackSerializerOptions.Standard.WithCompression (MessagePackCompression.Lz4BlockArray)); @@ -57,7 +58,7 @@ public static int FromFile(string path) string jsonContent = reader.ReadToEnd(); AssetMap parsed = JsonConvert.DeserializeObject(jsonContent); - Instance = new AssetMap + newMap = new AssetMap { GameType = parsed.GameType, AssetEntries = parsed.AssetEntries @@ -70,10 +71,19 @@ public static int FromFile(string path) (bytes); AssetMap assetMap = assetMaps.FirstOrDefault(); - Instance = assetMap; + newMap = assetMap; break; } } + + if (newMap == null) + { + Logger.Error("AssetMap was not loaded"); + return -1; + } + + StringCache.Clear(); + Instance = newMap; } catch (Exception e) { @@ -94,6 +104,7 @@ public static void Clear() { Instance.GameType = GameType.Normal; Instance.AssetEntries = new List(); + StringCache.Clear(); } } } From 55319cdde0855957907816e85537143dd53d10c3 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:53:51 +0800 Subject: [PATCH 28/56] Reduce asset map load peak memory --- AnimeStudio/ResourceMap.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AnimeStudio/ResourceMap.cs b/AnimeStudio/ResourceMap.cs index 04cbf20f..298f52b0 100644 --- a/AnimeStudio/ResourceMap.cs +++ b/AnimeStudio/ResourceMap.cs @@ -36,7 +36,6 @@ public static int FromFile(string path) { string extension = Path.GetExtension(path).ToLower(); using FileStream stream = File.OpenRead(path); - ReadOnlySpan bytes = File.ReadAllBytes(path); AssetMap newMap = null; switch(extension) @@ -67,6 +66,7 @@ public static int FromFile(string path) } case ".memory": { + ReadOnlySpan bytes = File.ReadAllBytes(path); List assetMaps = MemoryPackSerializer.Deserialize> (bytes); From fa19cca15d5166d5d663f4847ca2f58b681dd963 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:54:28 +0800 Subject: [PATCH 29/56] Clear asset browser retained lists --- AnimeStudio.GUI/AssetBrowser.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AnimeStudio.GUI/AssetBrowser.cs b/AnimeStudio.GUI/AssetBrowser.cs index e9c903db..5efa5cbc 100644 --- a/AnimeStudio.GUI/AssetBrowser.cs +++ b/AnimeStudio.GUI/AssetBrowser.cs @@ -64,6 +64,8 @@ private async void loadAssetMap_Click(object sender, EventArgs e) _sortedColumn = null; _firstAssetEntries.Clear(); + _secondAssetEntries.Clear(); + _backupAssetEntries.Clear(); _firstAssetEntries.AddRange(ResourceMap.GetEntries()); updateDisplay(); @@ -538,6 +540,11 @@ public void Clear() { ResourceMap.Clear(); _assetEntries.Clear(); + _backupAssetEntries.Clear(); + _firstAssetEntries.Clear(); + _secondAssetEntries.Clear(); + selectedTypes.Clear(); + types.Clear(); assetDataGridView.Rows.Clear(); } From fed4128b385b65b857f14015787112420dbc5514 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:56:13 +0800 Subject: [PATCH 30/56] Make asset list display values lazy --- AnimeStudio.GUI/Components/AssetItem.cs | 28 +++++++++++++++++++++++++ AnimeStudio.GUI/MainForm.cs | 18 +++++++++------- AnimeStudio.GUI/Studio.cs | 10 --------- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/AnimeStudio.GUI/Components/AssetItem.cs b/AnimeStudio.GUI/Components/AssetItem.cs index aabfffcc..5a3c764d 100644 --- a/AnimeStudio.GUI/Components/AssetItem.cs +++ b/AnimeStudio.GUI/Components/AssetItem.cs @@ -15,6 +15,7 @@ public class AssetItem : ListViewItem public string InfoText; public string UniqueID; public GameObjectTreeNode TreeNode; + private bool subItemsInitialized; public AssetItem(Object asset) { @@ -30,6 +31,11 @@ public AssetItem(Object asset) public void SetSubItems() { + if (subItemsInitialized) + { + return; + } + SubItems.AddRange(new[] { Container, //Container @@ -38,6 +44,28 @@ public void SetSubItems() FullSize.ToString(), //Size Hash, //Hash }); + subItemsInitialized = true; + } + + public string GetColumnText(int column) + { + return column switch + { + 0 => Text, + 1 => Container, + 2 => TypeString, + 3 => m_PathID.ToString(), + 4 => FullSize.ToString(), + 5 => Hash, + _ => string.Empty, + }; + } + + public bool MatchesListSearch(System.Text.RegularExpressions.Regex regex) + { + return regex.IsMatch(Text) + || regex.IsMatch(Container) + || regex.IsMatch(m_PathID.ToString()); } } } diff --git a/AnimeStudio.GUI/MainForm.cs b/AnimeStudio.GUI/MainForm.cs index 63a3cf1c..65053584 100644 --- a/AnimeStudio.GUI/MainForm.cs +++ b/AnimeStudio.GUI/MainForm.cs @@ -716,7 +716,9 @@ private void assetListView_RetrieveVirtualItem(object sender, RetrieveVirtualIte { if (e.ItemIndex < visibleAssets.Count) { - e.Item = visibleAssets[e.ItemIndex]; + var item = visibleAssets[e.ItemIndex]; + item.SetSubItems(); + e.Item = item; } } @@ -914,8 +916,8 @@ private void assetListView_ColumnClick(object sender, ColumnClickEventArgs e) { visibleAssets.Sort((a, b) => { - var at = a.SubItems[sortColumn].Text; - var bt = b.SubItems[sortColumn].Text; + var at = a.GetColumnText(sortColumn); + var bt = b.GetColumnText(sortColumn); return reverseSort ? bt.CompareTo(at) : at.CompareTo(bt); }); } @@ -2068,10 +2070,7 @@ private void FilterAssetList() listSearch.Text = ""; } var regex = new Regex(listSearch.Text, RegexOptions.IgnoreCase); - visibleAssets = visibleAssets.FindAll( - x => regex.IsMatch(x.Text) || - regex.IsMatch(x.SubItems[1].Text) || - regex.IsMatch(x.SubItems[3].Text)); + visibleAssets = visibleAssets.FindAll(x => x.MatchesListSearch(regex)); } assetListView.VirtualListSize = visibleAssets.Count; assetListView.EndUpdate(); @@ -2228,7 +2227,10 @@ private void UpdateContainers() if (!string.IsNullOrEmpty(path)) { asset.Container = path; - asset.SubItems[1].Text = path; + if (asset.SubItems.Count > 1) + { + asset.SubItems[1].Text = path; + } if (asset.Type == ClassIDType.MiHoYoBinData) { asset.Text = Path.GetFileNameWithoutExtension(path); diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index bcf507e5..a5e3de32 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -670,16 +670,6 @@ private static bool ApplyAssetPostProcessing(AssetDataBuildContext context) UpdateContainers(); } } - foreach (var tmp in exportableAssets) - { - if (assetsManager.tokenSource.IsCancellationRequested) - { - Logger.Info("Processing subitems been cancelled !!"); - return false; - } - tmp.SetSubItems(); - } - return true; } From 0f595e1980dd77b2cb4d0347c5a5f1e276bf3180 Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:56:57 +0800 Subject: [PATCH 31/56] Lazy compute asset hashes --- AnimeStudio.GUI/Components/AssetItem.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AnimeStudio.GUI/Components/AssetItem.cs b/AnimeStudio.GUI/Components/AssetItem.cs index 5a3c764d..efeb8965 100644 --- a/AnimeStudio.GUI/Components/AssetItem.cs +++ b/AnimeStudio.GUI/Components/AssetItem.cs @@ -11,12 +11,14 @@ public class AssetItem : ListViewItem public long m_PathID; public long FullSize; public ClassIDType Type; - public string Hash; public string InfoText; public string UniqueID; public GameObjectTreeNode TreeNode; + private string hash; private bool subItemsInitialized; + public string Hash => hash ??= Asset.GetHash(); + public AssetItem(Object asset) { Asset = asset; @@ -26,7 +28,6 @@ public AssetItem(Object asset) TypeString = Type.ToString(); m_PathID = asset.m_PathID; FullSize = asset.byteSize; - Hash = asset.GetHash(); } public void SetSubItems() From f8295bf5869d0a3f08dd4306236a5c2f7fb5501a Mon Sep 17 00:00:00 2001 From: bbdd Date: Sun, 28 Jun 2026 22:57:49 +0800 Subject: [PATCH 32/56] Tighten null-terminated string reads --- AnimeStudio/EndianBinaryReader.cs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/AnimeStudio/EndianBinaryReader.cs b/AnimeStudio/EndianBinaryReader.cs index c06942ca..dd6246ed 100644 --- a/AnimeStudio/EndianBinaryReader.cs +++ b/AnimeStudio/EndianBinaryReader.cs @@ -165,19 +165,32 @@ public string ReadAlignedString() public string ReadStringToNull(int maxLength = 32767) { - var bytes = new List(); - int count = 0; - while (Remaining > 0 && count < maxLength) + var length = (int)Math.Min(Remaining, maxLength); + if (length <= 0) { - var b = ReadByte(); - if (b == 0) + return string.Empty; + } + + var bytes = ArrayPool.Shared.Rent(length); + var count = 0; + try + { + while (count < length) { - break; + var b = ReadByte(); + if (b == 0) + { + break; + } + bytes[count++] = b; } - bytes.Add(b); - count++; + + return Encoding.UTF8.GetString(bytes, 0, count); + } + finally + { + ArrayPool.Shared.Return(bytes); } - return Encoding.UTF8.GetString(bytes.ToArray()); } public Quaternion ReadQuaternion() From 91ce38c1f12a0247c4df19bb3ea8f87c715fcdd4 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 14:34:57 +0800 Subject: [PATCH 33/56] Refactor class structure building --- AnimeStudio.GUI/Studio.cs | 59 ++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index a5e3de32..1bc81363 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -800,35 +800,44 @@ public static Dictionary> BuildClass Logger.Info("Processing class structure been cancelled !!"); return new Dictionary>(); } - if (typeMap.TryGetValue(assetsFile.unityVersion, out var curVer)) - { - foreach (var type in assetsFile.m_Types.Where(x => x.m_Type != null)) - { - var key = type.classID; - if (type.m_ScriptTypeIndex >= 0) - { - key = -1 - type.m_ScriptTypeIndex; - } - curVer[key] = new TypeTreeItem(key, type.m_Type); - } - } - else + + var items = GetOrCreateTypeTreeItems(typeMap, assetsFile.unityVersion); + AddTypeTreeItems(items, assetsFile.m_Types); + } + + return typeMap; + } + + private static SortedDictionary GetOrCreateTypeTreeItems(Dictionary> typeMap, string unityVersion) + { + if (!typeMap.TryGetValue(unityVersion, out var items)) + { + items = new SortedDictionary(); + typeMap.Add(unityVersion, items); + } + + return items; + } + + private static void AddTypeTreeItems(SortedDictionary items, List types) + { + foreach (var type in types) + { + if (type.m_Type == null) { - var items = new SortedDictionary(); - foreach (var type in assetsFile.m_Types.Where(x => x.m_Type != null)) - { - var key = type.classID; - if (type.m_ScriptTypeIndex >= 0) - { - key = -1 - type.m_ScriptTypeIndex; - } - items[key] = new TypeTreeItem(key, type.m_Type); - } - typeMap.Add(assetsFile.unityVersion, items); + continue; } + + var key = GetTypeTreeItemKey(type); + items[key] = new TypeTreeItem(key, type.m_Type); } + } - return typeMap; + private static int GetTypeTreeItemKey(SerializedType type) + { + return type.m_ScriptTypeIndex >= 0 + ? -1 - type.m_ScriptTypeIndex + : type.classID; } #region Export assets From be17e3a93f2d7d522f869399f7dcb3e15b9dfd2b Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 15:19:06 +0800 Subject: [PATCH 34/56] Split Studio partial files by responsibility --- AnimeStudio.GUI/Studio.AssetData.cs | 457 ++++++++ AnimeStudio.GUI/Studio.ClassStructure.cs | 58 + AnimeStudio.GUI/Studio.Export.cs | 424 ++++++++ AnimeStudio.GUI/Studio.Extract.cs | 312 ++++++ AnimeStudio.GUI/Studio.Utility.cs | 53 + AnimeStudio.GUI/Studio.cs | 1272 +--------------------- 6 files changed, 1306 insertions(+), 1270 deletions(-) create mode 100644 AnimeStudio.GUI/Studio.AssetData.cs create mode 100644 AnimeStudio.GUI/Studio.ClassStructure.cs create mode 100644 AnimeStudio.GUI/Studio.Export.cs create mode 100644 AnimeStudio.GUI/Studio.Extract.cs create mode 100644 AnimeStudio.GUI/Studio.Utility.cs diff --git a/AnimeStudio.GUI/Studio.AssetData.cs b/AnimeStudio.GUI/Studio.AssetData.cs new file mode 100644 index 00000000..f65a8567 --- /dev/null +++ b/AnimeStudio.GUI/Studio.AssetData.cs @@ -0,0 +1,457 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Windows.Forms; + +namespace AnimeStudio.GUI +{ + internal static partial class Studio + { + private sealed class AssetDataBuildContext + { + public int ObjectCount { get; } + public int GameObjectCount { get; } + public Dictionary ObjectAssetItems { get; } + public List<(PPtr PPtr, string Name)> MiHoYoBinDataNames { get; } = new List<(PPtr, string)>(); + public List<(PPtr PPtr, string Container)> Containers { get; } = new List<(PPtr, string)>(); + public HashSet FastAssetFilterKeys { get; } + private readonly Dictionary canExportCache = new Dictionary(); + public string AssetBundleName { get; set; } = ""; + public string ProductName { get; set; } + + public AssetDataBuildContext() + { + foreach (var assetsFile in assetsManager.assetsFileList) + { + ObjectCount += assetsFile.Objects.Count; + foreach (var asset in assetsFile.Objects) + { + if (asset is GameObject) + { + GameObjectCount++; + } + } + } + + ObjectAssetItems = new Dictionary(ObjectCount); + FastAssetFilterKeys = assetsManager.FilterData.Items + .Select(x => new AssetFilterKey(x.Name, x.PathID, x.Type)) + .ToHashSet(); + } + + public bool CanExport(ClassIDType type) + { + if (!canExportCache.TryGetValue(type, out var canExport)) + { + canExport = type.CanExport(); + canExportCache.Add(type, canExport); + } + + return canExport; + } + } + + private readonly struct AssetFilterKey : IEquatable + { + private readonly string name; + private readonly long pathID; + private readonly ClassIDType type; + + public AssetFilterKey(string name, long pathID, ClassIDType type) + { + this.name = name; + this.pathID = pathID; + this.type = type; + } + + public bool Equals(AssetFilterKey other) + { + return type == other.type + && pathID == other.pathID + && string.Equals(name, other.name, StringComparison.OrdinalIgnoreCase); + } + + public override bool Equals(object obj) + { + return obj is AssetFilterKey other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(StringComparer.OrdinalIgnoreCase.GetHashCode(name ?? string.Empty), pathID, type); + } + } + + public static (string, List) BuildAssetData() + { + StatusStripUpdate("Building asset list..."); + + var context = new AssetDataBuildContext(); + Progress.Reset(); + Logger.Info($"Loading {context.ObjectCount} objects from {assetsManager.assetsFileList.Count} files."); + + if (!BuildExportableAssetItems(context) || !ApplyAssetPostProcessing(context)) + { + return (string.Empty, Array.Empty().ToList()); + } + + visibleAssets = exportableAssets; + + StatusStripUpdate("Building tree structure..."); + var treeNodeCollection = BuildSceneTree(context); + context.ObjectAssetItems.Clear(); + + if (treeNodeCollection == null) + { + return (string.Empty, Array.Empty().ToList()); + } + + return (context.ProductName, treeNodeCollection); + } + + private static List BuildSceneTree(AssetDataBuildContext context) + { + var treeNodeCollection = new List(); + var treeNodeDictionary = new Dictionary(context.GameObjectCount); + var hasFilterData = context.FastAssetFilterKeys.Count > 0; + int j = 0; + Progress.Reset(); + var files = assetsManager.assetsFileList.GroupBy(x => x.originalPath ?? string.Empty).OrderBy(x => x.Key).ToList(); + foreach (var fileGroup in files) + { + var file = fileGroup.Key; + var fileNode = !string.IsNullOrEmpty(file) ? new TreeNode(Path.GetFileName(file)) : null; //RootNode + + foreach (var assetsFile in fileGroup) + { + var assetsFileNode = new TreeNode(assetsFile.fileName); + + foreach (var obj in assetsFile.Objects) + { + if (assetsManager.tokenSource.IsCancellationRequested) + { + Logger.Info("Building tree structure been cancelled !!"); + return null; + } + + if (obj is not GameObject) + { + if (hasFilterData && IsMissingFromFilter(obj.Name, obj.m_PathID, obj.type, context.FastAssetFilterKeys)) + { + continue; + } + + continue; + } + + var m_GameObject = (GameObject)obj; + var currentNode = GetOrCreateGameObjectNode(m_GameObject, treeNodeDictionary); + AssignAssetTreeNodes(m_GameObject, currentNode, context); + var parentNode = GetParentTreeNode(m_GameObject, assetsFileNode, treeNodeDictionary); + parentNode.Nodes.Add(currentNode); + } + + // TODO: need to do proper cleaning of list to only include filtered assets when required + + if (assetsFileNode.Nodes.Count > 0) + { + if (fileNode == null) + { + treeNodeCollection.Add(assetsFileNode); + } + else + { + fileNode.Nodes.Add(assetsFileNode); + } + } + } + + if (fileNode?.Nodes.Count > 0) + { + treeNodeCollection.Add(fileNode); + } + + Progress.Report(++j, files.Count); + } + treeNodeDictionary.Clear(); + + return treeNodeCollection; + } + + private static GameObjectTreeNode GetOrCreateGameObjectNode(GameObject gameObject, Dictionary treeNodeDictionary) + { + if (!treeNodeDictionary.TryGetValue(gameObject, out var currentNode)) + { + currentNode = new GameObjectTreeNode(gameObject); + treeNodeDictionary.Add(gameObject, currentNode); + } + + return currentNode; + } + + private static void AssignAssetTreeNodes(GameObject gameObject, GameObjectTreeNode currentNode, AssetDataBuildContext context) + { + foreach (var pptr in gameObject.m_Components) + { + if (!pptr.TryGet(out var m_Component)) + { + continue; + } + + AssignTreeNode(m_Component, currentNode, context); + AssignMeshTreeNode(m_Component, currentNode, context); + } + } + + private static void AssignTreeNode(Object asset, GameObjectTreeNode currentNode, AssetDataBuildContext context) + { + if (context.ObjectAssetItems.TryGetValue(asset, out var assetItem)) + { + assetItem.TreeNode = currentNode; + } + } + + private static void AssignMeshTreeNode(Object component, GameObjectTreeNode currentNode, AssetDataBuildContext context) + { + if (component is MeshFilter m_MeshFilter && m_MeshFilter.m_Mesh.TryGet(out var meshFromFilter)) + { + AssignTreeNode(meshFromFilter, currentNode, context); + } + else if (component is SkinnedMeshRenderer m_SkinnedMeshRenderer && m_SkinnedMeshRenderer.m_Mesh.TryGet(out var meshFromRenderer)) + { + AssignTreeNode(meshFromRenderer, currentNode, context); + } + } + + private static TreeNode GetParentTreeNode(GameObject gameObject, TreeNode assetsFileNode, Dictionary treeNodeDictionary) + { + if (gameObject.m_Transform != null) + { + if (gameObject.m_Transform.m_Father.TryGet(out var m_Father)) + { + if (m_Father.m_GameObject.TryGet(out var parentGameObject)) + { + return GetOrCreateGameObjectNode(parentGameObject, treeNodeDictionary); + } + } + } + + return assetsFileNode; + } + + private static bool BuildExportableAssetItems(AssetDataBuildContext context) + { + int i = 0; + var displayAll = Properties.Settings.Default.displayAll; + var hasFilterData = context.FastAssetFilterKeys.Count > 0; + if (displayAll && exportableAssets.Capacity < context.ObjectCount) + { + exportableAssets.Capacity = context.ObjectCount; + } + + foreach (var assetsFile in assetsManager.assetsFileList) + { + foreach (var asset in assetsFile.Objects) + { + if (assetsManager.tokenSource.IsCancellationRequested) + { + Logger.Info("Building asset list has been cancelled !!"); + return false; + } + + if (hasFilterData && asset is not AssetBundle && asset is not ResourceManager && IsMissingFromFilter(asset.Name, asset.m_PathID, asset.type, context.FastAssetFilterKeys)) + { + continue; + } + + AddAssetItem(asset, context, displayAll, i); + Progress.Report(++i, context.ObjectCount); + } + } + + return true; + } + + private static void AddAssetItem(Object asset, AssetDataBuildContext context, bool displayAll, int index) + { + var assetItem = new AssetItem(asset); + + context.ObjectAssetItems.Add(asset, assetItem); + assetItem.UniqueID = "#" + index; + var exportable = ConfigureAssetItemAndReturnExportable(asset, assetItem, context); + + if (assetItem.Text == "") + { + assetItem.Text = assetItem.TypeString + assetItem.UniqueID; + } + if (displayAll || exportable) + { + exportableAssets.Add(assetItem); + } + } + + private static bool ApplyAssetPostProcessing(AssetDataBuildContext context) + { + foreach ((var pptr, var name) in context.MiHoYoBinDataNames) + { + if (assetsManager.tokenSource.IsCancellationRequested) + { + Logger.Info("Processing asset names has been cancelled !!"); + return false; + } + if (pptr.TryGet(out var obj)) + { + var assetItem = context.ObjectAssetItems[obj]; + if (int.TryParse(name, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hash)) + { + assetItem.Text = name; + assetItem.Container = Properties.Settings.Default.useBundleContainerName ? context.AssetBundleName : hash.ToString(); + } + else assetItem.Text = $"BinFile #{assetItem.m_PathID}"; + } + } + if (!SkipContainer) + { + foreach ((var pptr, var container) in context.Containers) + { + if (assetsManager.tokenSource.IsCancellationRequested) + { + Logger.Info("Processing containers been cancelled !!"); + return false; + } + if (pptr.TryGet(out var obj)) + { + if (context.ObjectAssetItems.TryGetValue(obj, out var assetItem)) + { + assetItem.Container = container; + } + } + } + context.Containers.Clear(); + if (Game.Type.IsGISubGroup() || Game.Type.IsZZZ()) + { + UpdateContainers(); + } + } + return true; + } + + private static bool ConfigureAssetItemAndReturnExportable(Object asset, AssetItem assetItem, AssetDataBuildContext context) + { + switch (asset) + { + case Texture2D m_Texture2D: + if (!string.IsNullOrEmpty(m_Texture2D.m_StreamData?.path)) + assetItem.FullSize = asset.byteSize + m_Texture2D.m_StreamData.size; + return context.CanExport(ClassIDType.Texture2D); + case AudioClip m_AudioClip: + if (!string.IsNullOrEmpty(m_AudioClip.m_Source)) + assetItem.FullSize = asset.byteSize + m_AudioClip.m_Size; + return context.CanExport(ClassIDType.AudioClip); + case VideoClip m_VideoClip: + if (!string.IsNullOrEmpty(m_VideoClip.m_OriginalPath)) + assetItem.FullSize = asset.byteSize + m_VideoClip.m_ExternalResources.m_Size; + return context.CanExport(ClassIDType.VideoClip); + case PlayerSettings m_PlayerSettings: + context.ProductName = m_PlayerSettings.productName; + return context.CanExport(ClassIDType.PlayerSettings); + case AssetBundle m_AssetBundle: + context.AssetBundleName = m_AssetBundle.Name; + AddAssetBundleContainers(m_AssetBundle, context); + return context.CanExport(ClassIDType.AssetBundle); + case IndexObject m_IndexObject: + foreach(var index in m_IndexObject.AssetMap) + { + context.MiHoYoBinDataNames.Add((index.Value.Object, index.Key)); + } + + return context.CanExport(ClassIDType.IndexObject); + case ResourceManager m_ResourceManager: + foreach (var m_Container in m_ResourceManager.m_Container) + { + context.Containers.Add((m_Container.Value, m_Container.Key)); + } + + return context.CanExport(ClassIDType.ResourceManager); + case Mesh _: + return context.CanExport(ClassIDType.Mesh); + case TextAsset _: + return context.CanExport(ClassIDType.TextAsset); + case AnimationClip _: + return context.CanExport(ClassIDType.AnimationClip); + case Font _: + return context.CanExport(ClassIDType.Font); + case MovieTexture _: + return context.CanExport(ClassIDType.MovieTexture); + case Sprite _: + return context.CanExport(ClassIDType.Sprite); + case Material _: + return context.CanExport(ClassIDType.Material); + case MiHoYoBinData _: + return context.CanExport(ClassIDType.MiHoYoBinData); + case NapAssetBundleIndexAsset _: + return context.CanExport(ClassIDType.NapAssetBundleIndexAsset); + case Shader _: + return context.CanExport(ClassIDType.Shader); + case Animator _: + return context.CanExport(ClassIDType.Animator); + case MonoBehaviour _: + return context.CanExport(ClassIDType.MonoBehaviour); + default: + return false; + } + } + + private static void AddAssetBundleContainers(AssetBundle assetBundle, AssetDataBuildContext context) + { + if (SkipContainer) + { + return; + } + + foreach (var m_Container in assetBundle.m_Container) + { + var preloadIndex = m_Container.Value.preloadIndex; + var preloadSize = m_Container.Value.preloadSize; + var preloadEnd = preloadIndex + preloadSize; + + switch (preloadIndex) + { + case int n when n < 0: + Logger.Warning($"preloadIndex {preloadIndex} is out of preloadTable range"); + break; + default: + for (int k = preloadIndex; k < preloadEnd; k++) + { + try + { + context.Containers.Add((assetBundle.m_PreloadTable[k], m_Container.Key)); + } catch + { + Logger.Info($"Failed to add container {m_Container.Key}"); + } + } + break; + } + } + } + + private static bool IsMissingFromFilter(string name, long pathID, ClassIDType type, HashSet fastAssetFilterKeys) + { + if (fastAssetFilterKeys.Count == 0) + { + return false; + } + if (fastAssetFilterKeys.Contains(new AssetFilterKey(name, pathID, type))) + { + return false; + } + + Logger.Verbose($"Skipped {(name.Length > 0 ? name : "an asset")} because filter data was set and it was missing from it"); + return true; + } + } +} + diff --git a/AnimeStudio.GUI/Studio.ClassStructure.cs b/AnimeStudio.GUI/Studio.ClassStructure.cs new file mode 100644 index 00000000..ce3144e6 --- /dev/null +++ b/AnimeStudio.GUI/Studio.ClassStructure.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; + +namespace AnimeStudio.GUI +{ + internal static partial class Studio + { + public static Dictionary> BuildClassStructure() + { + var typeMap = new Dictionary>(); + foreach (var assetsFile in assetsManager.assetsFileList) + { + if (assetsManager.tokenSource.IsCancellationRequested) + { + Logger.Info("Processing class structure been cancelled !!"); + return new Dictionary>(); + } + + var items = GetOrCreateTypeTreeItems(typeMap, assetsFile.unityVersion); + AddTypeTreeItems(items, assetsFile.m_Types); + } + + return typeMap; + } + + private static SortedDictionary GetOrCreateTypeTreeItems(Dictionary> typeMap, string unityVersion) + { + if (!typeMap.TryGetValue(unityVersion, out var items)) + { + items = new SortedDictionary(); + typeMap.Add(unityVersion, items); + } + + return items; + } + + private static void AddTypeTreeItems(SortedDictionary items, List types) + { + foreach (var type in types) + { + if (type.m_Type == null) + { + continue; + } + + var key = GetTypeTreeItemKey(type); + items[key] = new TypeTreeItem(key, type.m_Type); + } + } + + private static int GetTypeTreeItemKey(SerializedType type) + { + return type.m_ScriptTypeIndex >= 0 + ? -1 - type.m_ScriptTypeIndex + : type.classID; + } + } +} + diff --git a/AnimeStudio.GUI/Studio.Export.cs b/AnimeStudio.GUI/Studio.Export.cs new file mode 100644 index 00000000..0b0a41d5 --- /dev/null +++ b/AnimeStudio.GUI/Studio.Export.cs @@ -0,0 +1,424 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.Xml; +using static AnimeStudio.GUI.Exporter; + +namespace AnimeStudio.GUI +{ + internal static partial class Studio + { + public static Task ExportAssets(string savePath, List toExportAssets, ExportType exportType, bool openAfterExport) + { + return Task.Run(() => + { + Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US"); + + int toExportCount = toExportAssets.Count; + int exportedCount = 0; + int i = 0; + Progress.Reset(); + var assetGroupOption = (AssetGroupOption)Properties.Settings.Default.assetGroupOption; + foreach (var asset in toExportAssets) + { + var exportPath = GetAssetExportPath(savePath, asset, assetGroupOption); + StatusStripUpdate($"[{exportedCount}/{toExportCount}] Exporting {asset.TypeString}: {asset.Text}"); + + if (TryExportAsset(asset, exportPath, exportType)) + { + exportedCount++; + } + + Progress.Report(++i, toExportCount); + } + + StatusStripUpdate(GetExportAssetsStatus(toExportCount, exportedCount)); + + if (openAfterExport && exportedCount > 0) + { + OpenFolderInExplorer(savePath); + } + }); + } + + private static string GetAssetExportPath(string savePath, AssetItem asset, AssetGroupOption assetGroupOption) + { + string exportPath; + switch (assetGroupOption) + { + case AssetGroupOption.ByType: + exportPath = Path.Combine(savePath, asset.TypeString); + break; + case AssetGroupOption.ByContainer: + exportPath = GetContainerExportPath(savePath, asset); + break; + case AssetGroupOption.BySource: + exportPath = GetSourceExportPath(savePath, asset); + break; + default: + exportPath = savePath; + break; + } + + return exportPath + Path.DirectorySeparatorChar; + } + + private static string GetContainerExportPath(string savePath, AssetItem asset) + { + if (string.IsNullOrEmpty(asset.Container)) + { + return savePath; + } + + return Path.HasExtension(asset.Container) + ? Path.Combine(savePath, Path.GetDirectoryName(asset.Container)) + : Path.Combine(savePath, asset.Container); + } + + private static string GetSourceExportPath(string savePath, AssetItem asset) + { + if (string.IsNullOrEmpty(asset.SourceFile.originalPath)) + { + return Path.Combine(savePath, asset.SourceFile.fileName + "_export"); + } + + return Path.Combine(savePath, Path.GetFileName(asset.SourceFile.originalPath) + "_export", asset.SourceFile.fileName); + } + + private static bool TryExportAsset(AssetItem asset, string exportPath, ExportType exportType) + { + try + { + switch (exportType) + { + case ExportType.Raw: + return ExportRawFile(asset, exportPath); + case ExportType.Dump: + return ExportDumpFile(asset, exportPath); + case ExportType.Convert: + return ExportConvertFile(asset, exportPath); + case ExportType.JSON: + return ExportJSONFile(asset, exportPath); + default: + return false; + } + } + catch (Exception ex) + { + Logger.Error($"Export {asset.Type}:{asset.Text} error\r\n{ex.Message}\r\n{ex.StackTrace}"); + return false; + } + } + + private static string GetExportAssetsStatus(int toExportCount, int exportedCount) + { + var statusText = exportedCount == 0 ? "Nothing exported." : $"Finished exporting {exportedCount} assets."; + + if (toExportCount > exportedCount) + { + statusText += $" {toExportCount - exportedCount} assets skipped (not extractable or files already exist)"; + } + + return statusText; + } + + public static Task ExportAssetsList(string savePath, List toExportAssets, ExportListType exportListType) + { + return Task.Run(() => + { + Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US"); + + Progress.Reset(); + + switch (exportListType) + { + case ExportListType.XML: + var filename = Path.Combine(savePath, "assets.xml"); + var settings = new XmlWriterSettings() { Indent = true }; + using (XmlWriter writer = XmlWriter.Create(filename, settings)) + { + writer.WriteStartDocument(); + writer.WriteStartElement("Assets"); + writer.WriteAttributeString("filename", filename); + writer.WriteAttributeString("createdAt", DateTime.UtcNow.ToString("s")); + foreach (var asset in toExportAssets) + { + writer.WriteStartElement("Asset"); + writer.WriteElementString("Name", asset.Name); + writer.WriteElementString("Container", asset.Container); + writer.WriteStartElement("Type"); + writer.WriteAttributeString("id", ((int)asset.Type).ToString()); + writer.WriteValue(asset.TypeString); + writer.WriteEndElement(); + writer.WriteElementString("PathID", asset.m_PathID.ToString()); + writer.WriteElementString("Source", asset.SourceFile.fullName); + writer.WriteElementString("Size", asset.FullSize.ToString()); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + break; + } + + var statusText = $"Finished exporting asset list with {toExportAssets.Count()} items."; + + StatusStripUpdate(statusText); + + if (Properties.Settings.Default.openAfterExport && toExportAssets.Count() > 0) + { + OpenFolderInExplorer(savePath); + } + }); + } + + public static Task ExportSplitObjects(string savePath, TreeNodeCollection nodes) + { + return Task.Run(() => + { + var exportNodes = GetSplitExportNodes(nodes).ToList(); + var count = exportNodes.Sum(x => x.Nodes.Count); + int k = 0; + Progress.Reset(); + + foreach (var node in exportNodes) + { + foreach (GameObjectTreeNode childNode in node.Nodes) + { + ExportSplitObject(savePath, node, childNode); + Progress.Report(++k, count); + } + } + + if (Properties.Settings.Default.openAfterExport) + { + OpenFolderInExplorer(savePath); + } + StatusStripUpdate("Finished"); + }); + } + + private static IEnumerable GetSplitExportNodes(TreeNodeCollection nodes) + { + foreach (TreeNode node in nodes) + { + if (node.Nodes.Count == 0) + { + yield return node; + continue; + } + + foreach (TreeNode subNode in node.Nodes) + { + yield return subNode; + } + } + } + + private static void ExportSplitObject(string savePath, TreeNode parentNode, GameObjectTreeNode node) + { + var gameObjects = new List(); + CollectNode(node, gameObjects); + if (gameObjects.All(x => x.m_SkinnedMeshRenderer == null && x.m_MeshFilter == null)) + { + return; + } + + var filename = GetSplitObjectFileName(parentNode, node); + var targetPath = GetUniqueSplitObjectTargetPath(savePath, filename); + Directory.CreateDirectory(targetPath); + + StatusStripUpdate($"Exporting {filename}.fbx"); + try + { + ExportGameObject(node.gameObject, targetPath); + } + catch (Exception ex) + { + Logger.Error($"Export GameObject:{node.Text} error\r\n{ex.Message}\r\n{ex.StackTrace}"); + } + + StatusStripUpdate($"Finished exporting {filename}.fbx"); + } + + private static string GetSplitObjectFileName(TreeNode parentNode, GameObjectTreeNode node) + { + var filename = FixFileName(node.Text); + if (parentNode.Parent != null) + { + filename = Path.Combine(FixFileName(parentNode.Parent.Text), filename); + } + + return filename; + } + + private static string GetUniqueSplitObjectTargetPath(string savePath, string filename) + { + var targetPath = $"{savePath}{filename}{Path.DirectorySeparatorChar}"; + for (int i = 1; Directory.Exists(targetPath); i++) + { + targetPath = $"{savePath}{filename} ({i}){Path.DirectorySeparatorChar}"; + } + + return targetPath; + } + + private static void CollectNode(GameObjectTreeNode node, List gameObjects) + { + gameObjects.Add(node.gameObject); + foreach (GameObjectTreeNode i in node.Nodes) + { + CollectNode(i, gameObjects); + } + } + + public static Task ExportAnimatorWithAnimationClip(AssetItem animator, List animationList, string exportPath) + { + return Task.Run(() => + { + Progress.Reset(); + StatusStripUpdate($"Exporting {animator.Text}"); + try + { + ExportAnimator(animator, exportPath, animationList); + if (Properties.Settings.Default.openAfterExport) + { + OpenFolderInExplorer(exportPath); + } + Progress.Report(1, 1); + StatusStripUpdate($"Finished exporting {animator.Text}"); + } + catch (Exception ex) + { + Logger.Error($"Export Animator:{animator.Text} error\r\n{ex.Message}\r\n{ex.StackTrace}"); + StatusStripUpdate("Error in export"); + } + }); + } + + public static Task ExportObjectsWithAnimationClip(string exportPath, TreeNodeCollection nodes, List animationList = null) + { + return Task.Run(() => + { + var gameObjects = new List(); + GetSelectedParentNode(nodes, gameObjects); + if (gameObjects.Count > 0) + { + var count = gameObjects.Count; + int i = 0; + Progress.Reset(); + foreach (var gameObject in gameObjects) + { + StatusStripUpdate($"Exporting {gameObject.m_Name}"); + try + { + var subExportPath = Path.Combine(exportPath, gameObject.m_Name) + Path.DirectorySeparatorChar; + ExportGameObject(gameObject, subExportPath, animationList); + StatusStripUpdate($"Finished exporting {gameObject.m_Name}"); + } + catch (Exception ex) + { + Logger.Error($"Export GameObject:{gameObject.m_Name} error\r\n{ex.Message}\r\n{ex.StackTrace}"); + StatusStripUpdate("Error in export"); + } + + Progress.Report(++i, count); + } + if (Properties.Settings.Default.openAfterExport) + { + OpenFolderInExplorer(exportPath); + } + } + else + { + StatusStripUpdate("No Object selected for export."); + } + }); + } + + public static Task ExportObjectsMergeWithAnimationClip(string exportPath, List gameObjects, List animationList = null) + { + return Task.Run(() => + { + var name = Path.GetFileName(exportPath); + Progress.Reset(); + StatusStripUpdate($"Exporting {name}"); + try + { + ExportGameObjectMerge(gameObjects, exportPath, animationList); + Progress.Report(1, 1); + StatusStripUpdate($"Finished exporting {name}"); + } + catch (Exception ex) + { + Logger.Error($"Export Model:{name} error\r\n{ex.Message}\r\n{ex.StackTrace}"); + StatusStripUpdate("Error in export"); + } + if (Properties.Settings.Default.openAfterExport) + { + OpenFolderInExplorer(Path.GetDirectoryName(exportPath)); + } + }); + } + + public static Task ExportNodesWithAnimationClip(string exportPath, List nodes, List animationList = null) + { + return Task.Run(() => + { + int i = 0; + Progress.Reset(); + foreach (var node in nodes) + { + var name = node.Text; + StatusStripUpdate($"Exporting {name}"); + var gameObjects = new List(); + GetSelectedParentNode(node.Nodes, gameObjects); + if (gameObjects.Count > 0) + { + var subExportPath = exportPath + Path.Combine(node.Text, FixFileName(node.Text) + ".fbx"); + try + { + ExportGameObjectMerge(gameObjects, subExportPath, animationList); + Progress.Report(++i, nodes.Count); + StatusStripUpdate($"Finished exporting {name}"); + } + catch (Exception ex) + { + Logger.Error($"Export Model:{name} error\r\n{ex.Message}\r\n{ex.StackTrace}"); + StatusStripUpdate("Error in export"); + } + } + else + { + StatusStripUpdate("Empty node selected for export."); + } + } + if (Properties.Settings.Default.openAfterExport) + { + OpenFolderInExplorer(exportPath); + } + }); + } + + public static void GetSelectedParentNode(TreeNodeCollection nodes, List gameObjects) + { + foreach (TreeNode i in nodes) + { + if (i is GameObjectTreeNode gameObjectTreeNode && i.Checked) + { + gameObjects.Add(gameObjectTreeNode.gameObject); + } + else + { + GetSelectedParentNode(i.Nodes, gameObjects); + } + } + } + } +} + diff --git a/AnimeStudio.GUI/Studio.Extract.cs b/AnimeStudio.GUI/Studio.Extract.cs new file mode 100644 index 00000000..bdbeabd4 --- /dev/null +++ b/AnimeStudio.GUI/Studio.Extract.cs @@ -0,0 +1,312 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace AnimeStudio.GUI +{ + internal static partial class Studio + { + public static int ExtractFolder(string path, string savePath) + { + int extractedCount = 0; + Progress.Reset(); + var files = Directory.GetFiles(path, "*.*", SearchOption.AllDirectories); + for (int i = 0; i < files.Length; i++) + { + var file = files[i]; + var fileOriPath = Path.GetDirectoryName(file); + var fileSavePath = fileOriPath.Replace(path, savePath); + extractedCount += ExtractFile(file, fileSavePath); + Progress.Report(i + 1, files.Length); + } + return extractedCount; + } + + public static int ExtractFile(string[] fileNames, string savePath) + { + int extractedCount = 0; + Progress.Reset(); + for (var i = 0; i < fileNames.Length; i++) + { + var fileName = fileNames[i]; + extractedCount += ExtractFile(fileName, savePath); + Progress.Report(i + 1, fileNames.Length); + } + return extractedCount; + } + + public static int ExtractFile(string fileName, string savePath) + { + int extractedCount = 0; + var reader = new FileReader(fileName); + reader = reader.PreProcessing(Game); + if (reader.FileType == FileType.BundleFile) + extractedCount += ExtractBundleFile(reader, savePath); + else if (reader.FileType == FileType.WebFile) + extractedCount += ExtractWebDataFile(reader, savePath); + else if (reader.FileType == FileType.BlkFile) + extractedCount += ExtractBlkFile(reader, savePath); + else if (reader.FileType == FileType.BlockFile) + extractedCount += ExtractBlockFile(reader, savePath); + else if (reader.FileType == FileType.Blb3File) + extractedCount += ExtractBlb3File(reader, savePath); + else if (reader.FileType == FileType.VFSFile) + extractedCount += ExtractVFSFile(reader, savePath); + else + reader.Dispose(); + return extractedCount; + } + private static int ExtractBlb3File(FileReader reader, string savePath) + { + StatusStripUpdate($"Decompressing {reader.FileName} ..."); + try + { + var bundleFile = new Blb3File(reader, reader.FullPath); + reader.Dispose(); + if (bundleFile.fileList != null && bundleFile.fileList.Count > 0) + { + var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); + return ExtractStreamFile(extractPath, bundleFile.fileList); + } + } + catch (InvalidCastException) + { + Logger.Error($"Game type mismatch, Expected {nameof(Mr0k)} but got {Game.Name} ({Game.GetType().Name}) !!"); + } + return 0; + } + private static int ExtractBundleFile(FileReader reader, string savePath) + { + StatusStripUpdate($"Decompressing {reader.FileName} ..."); + try + { + var bundleFile = new BundleFile(reader, Game); + reader.Dispose(); + if (bundleFile.fileList != null && bundleFile.fileList.Count > 0) + { + var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); + return ExtractStreamFile(extractPath, bundleFile.fileList); + } + } + catch (InvalidCastException) + { + Logger.Error($"Game type mismatch, Expected {nameof(Mr0k)} but got {Game.Name} ({Game.GetType().Name}) !!"); + } + return 0; + } + + private static int ExtractWebDataFile(FileReader reader, string savePath) + { + StatusStripUpdate($"Decompressing {reader.FileName} ..."); + var webFile = new WebFile(reader); + reader.Dispose(); + if (webFile.fileList != null && webFile.fileList.Count > 0) + { + var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); + return ExtractStreamFile(extractPath, webFile.fileList); + } + return 0; + } + + private static int ExtractBlkFile(FileReader reader, string savePath) + { + int total = 0; + StatusStripUpdate($"Decompressing {reader.FileName} ..."); + try + { + using var stream = BlkUtils.Decrypt(reader, (Blk)Game); + do + { + stream.Offset = stream.AbsolutePosition; + var dummyPath = Path.Combine(reader.FullPath, stream.AbsolutePosition.ToString("X8")); + var subReader = new FileReader(dummyPath, stream, true); + var subSavePath = Path.Combine(savePath, reader.FileName + "_unpacked"); + switch (subReader.FileType) + { + case FileType.BundleFile: + total += ExtractBundleFile(subReader, subSavePath); + break; + case FileType.MhyFile: + total += ExtractMhyFile(subReader, subSavePath); + break; + } + } while (stream.Remaining > 0); + } + catch (InvalidCastException) + { + Logger.Error($"Game type mismatch, Expected {nameof(Blk)} but got {Game.Name} ({Game.GetType().Name}) !!"); + } + return total; + } + + private static int ExtractBlockFile(FileReader reader, string savePath) + { + int total = 0; + StatusStripUpdate($"Decompressing {reader.FileName} ..."); + using var stream = new OffsetStream(reader.BaseStream, 0); + do + { + stream.Offset = stream.AbsolutePosition; + var subSavePath = Path.Combine(savePath, reader.FileName + "_unpacked"); + var dummyPath = Path.Combine(reader.FullPath, stream.AbsolutePosition.ToString("X8")); + var subReader = new FileReader(dummyPath, stream, true); + if (subReader.FileType == FileType.Blb3File) + total += ExtractBlb3File(subReader, subSavePath); + else if (subReader.FileType == FileType.VFSFile) + total += ExtractVFSFile(subReader, subSavePath); + else + total += ExtractBundleFile(subReader, subSavePath); + } while (stream.Remaining > 0); + return total; + } + + private static int ExtractVFSFile(FileReader reader, string savePath) + { + StatusStripUpdate($"Decompressing {reader.FileName} ..."); + try + { + var vfsFile = new VFSFile(reader, reader.FullPath, Studio.Game.Type); + reader.Dispose(); + if (vfsFile.fileList != null && vfsFile.fileList.Count > 0) + { + var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); + return ExtractStreamFile(extractPath, vfsFile.fileList); + } + } + catch (Exception e) + { + Logger.Error($"Error while reading VFS file {reader.FullPath}", e); + } + return 0; + } + + private static int ExtractMhyFile(FileReader reader, string savePath) + { + StatusStripUpdate($"Decompressing {reader.FileName} ..."); + try + { + var mhy0File = new MhyFile(reader, (Mhy)Game); + reader.Dispose(); + if (mhy0File.fileList != null && mhy0File.fileList.Count > 0) + { + var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); + return ExtractStreamFile(extractPath, mhy0File.fileList); + } + } + catch (InvalidCastException) + { + Logger.Error($"Game type mismatch, Expected {nameof(Mhy)} but got {Game.Name} ({Game.GetType().Name}) !!"); + } + return 0; + } + + private static int ExtractStreamFile(string extractPath, List fileList) + { + int extractedCount = 0; + if (fileList == null || fileList.Count == 0) + { + return 0; + } + foreach (var file in fileList) + { + var filePath = Path.Combine(extractPath, file.path); + var fileDirectory = Path.GetDirectoryName(filePath); + if (!Directory.Exists(fileDirectory)) + { + Directory.CreateDirectory(fileDirectory); + } + if (!File.Exists(filePath)) + { + if (file.stream == null) + { + continue; + } + using (var fileStream = File.Create(filePath)) + { + file.stream.CopyTo(fileStream); + } + extractedCount += 1; + } + file.stream.Dispose(); + } + return extractedCount; + } + + public static void UpdateContainers() + { + if (exportableAssets.Count == 0) + { + return; + } + + Logger.Info("Updating Containers..."); + var isZZZ = Game.Type.IsZZZ(); + var sourceFileIds = new Dictionary(); + + foreach (var asset in exportableAssets) + { + if (isZZZ && TryUpdateZZZContainer(asset)) + { + continue; + } + + TryUpdateIndexedContainer(asset, sourceFileIds); + } + + Logger.Info("Updated !!"); + } + + private static bool TryUpdateZZZContainer(AssetItem asset) + { + if (!ulong.TryParse(asset.Container, out var hash) || !Paths.TryGetValue(hash, out var z3Path)) + { + return false; + } + + asset.Container = z3Path; + return true; + } + + private static void TryUpdateIndexedContainer(AssetItem asset, Dictionary sourceFileIds) + { + if (!int.TryParse(asset.Container, out var value)) + { + return; + } + + var id = GetSourceFileId(asset.SourceFile.originalPath, sourceFileIds); + if (!id.HasValue) + { + return; + } + + var last = unchecked((uint)value); + var path = ResourceIndex.GetContainer(id.Value, last); + if (string.IsNullOrEmpty(path)) + { + return; + } + + asset.Container = path; + if (asset.Type == ClassIDType.MiHoYoBinData) + { + asset.Text = Path.GetFileNameWithoutExtension(path); + } + } + + private static uint? GetSourceFileId(string originalPath, Dictionary sourceFileIds) + { + originalPath ??= string.Empty; + if (sourceFileIds.TryGetValue(originalPath, out var id)) + { + return id; + } + + var name = Path.GetFileNameWithoutExtension(originalPath); + id = uint.TryParse(name, out var parsedId) ? parsedId : null; + sourceFileIds.Add(originalPath, id); + return id; + } + } +} + diff --git a/AnimeStudio.GUI/Studio.Utility.cs b/AnimeStudio.GUI/Studio.Utility.cs new file mode 100644 index 00000000..8223835f --- /dev/null +++ b/AnimeStudio.GUI/Studio.Utility.cs @@ -0,0 +1,53 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using System.Diagnostics; +using System.Windows.Forms; + +namespace AnimeStudio.GUI +{ + internal static partial class Studio + { + public static TypeTree MonoBehaviourToTypeTree(MonoBehaviour m_MonoBehaviour) + { + if (!assemblyLoader.Loaded) + { + var openFolderDialog = new OpenFolderDialog(); + openFolderDialog.Title = "Select Assembly Folder"; + if (openFolderDialog.ShowDialog() == DialogResult.OK) + { + assemblyLoader.Load(openFolderDialog.Folder); + } + else + { + assemblyLoader.Loaded = true; + } + } + return m_MonoBehaviour.ConvertToTypeTree(assemblyLoader); + } + + public static string DumpAsset(Object obj) + { + var str = obj.Dump(); + if (str == null && obj is MonoBehaviour m_MonoBehaviour) + { + var type = MonoBehaviourToTypeTree(m_MonoBehaviour); + str = m_MonoBehaviour.Dump(type); + } + if (string.IsNullOrEmpty(str)) + { + var settings = new JsonSerializerSettings(); + settings.Converters.Add(new StringEnumConverter()); + str = JsonConvert.SerializeObject(obj, Newtonsoft.Json.Formatting.Indented, settings); + } + return str; + } + + public static void OpenFolderInExplorer(string path) + { + var info = new ProcessStartInfo(path); + info.UseShellExecute = true; + Process.Start(info); + } + } +} + diff --git a/AnimeStudio.GUI/Studio.cs b/AnimeStudio.GUI/Studio.cs index 1bc81363..771a8689 100644 --- a/AnimeStudio.GUI/Studio.cs +++ b/AnimeStudio.GUI/Studio.cs @@ -1,17 +1,5 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Forms; -using System.Xml; -using static AnimeStudio.GUI.Exporter; -using static AnimeStudio.AssetsManager; namespace AnimeStudio.GUI { @@ -29,7 +17,7 @@ internal enum ExportFilter Filtered } - internal static class Studio + internal static partial class Studio { public static Game Game; public static bool SkipContainer = false; @@ -39,1262 +27,6 @@ internal static class Studio public static List visibleAssets = new List(); internal static Action StatusStripUpdate = x => { }; public static Dictionary Paths { get; set; } = new Dictionary(); - - public static int ExtractFolder(string path, string savePath) - { - int extractedCount = 0; - Progress.Reset(); - var files = Directory.GetFiles(path, "*.*", SearchOption.AllDirectories); - for (int i = 0; i < files.Length; i++) - { - var file = files[i]; - var fileOriPath = Path.GetDirectoryName(file); - var fileSavePath = fileOriPath.Replace(path, savePath); - extractedCount += ExtractFile(file, fileSavePath); - Progress.Report(i + 1, files.Length); - } - return extractedCount; - } - - public static int ExtractFile(string[] fileNames, string savePath) - { - int extractedCount = 0; - Progress.Reset(); - for (var i = 0; i < fileNames.Length; i++) - { - var fileName = fileNames[i]; - extractedCount += ExtractFile(fileName, savePath); - Progress.Report(i + 1, fileNames.Length); - } - return extractedCount; - } - - public static int ExtractFile(string fileName, string savePath) - { - int extractedCount = 0; - var reader = new FileReader(fileName); - reader = reader.PreProcessing(Game); - if (reader.FileType == FileType.BundleFile) - extractedCount += ExtractBundleFile(reader, savePath); - else if (reader.FileType == FileType.WebFile) - extractedCount += ExtractWebDataFile(reader, savePath); - else if (reader.FileType == FileType.BlkFile) - extractedCount += ExtractBlkFile(reader, savePath); - else if (reader.FileType == FileType.BlockFile) - extractedCount += ExtractBlockFile(reader, savePath); - else if (reader.FileType == FileType.Blb3File) - extractedCount += ExtractBlb3File(reader, savePath); - else if (reader.FileType == FileType.VFSFile) - extractedCount += ExtractVFSFile(reader, savePath); - else - reader.Dispose(); - return extractedCount; - } - private static int ExtractBlb3File(FileReader reader, string savePath) - { - StatusStripUpdate($"Decompressing {reader.FileName} ..."); - try - { - var bundleFile = new Blb3File(reader, reader.FullPath); - reader.Dispose(); - if (bundleFile.fileList != null && bundleFile.fileList.Count > 0) - { - var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); - return ExtractStreamFile(extractPath, bundleFile.fileList); - } - } - catch (InvalidCastException) - { - Logger.Error($"Game type mismatch, Expected {nameof(Mr0k)} but got {Game.Name} ({Game.GetType().Name}) !!"); - } - return 0; - } - private static int ExtractBundleFile(FileReader reader, string savePath) - { - StatusStripUpdate($"Decompressing {reader.FileName} ..."); - try - { - var bundleFile = new BundleFile(reader, Game); - reader.Dispose(); - if (bundleFile.fileList != null && bundleFile.fileList.Count > 0) - { - var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); - return ExtractStreamFile(extractPath, bundleFile.fileList); - } - } - catch (InvalidCastException) - { - Logger.Error($"Game type mismatch, Expected {nameof(Mr0k)} but got {Game.Name} ({Game.GetType().Name}) !!"); - } - return 0; - } - - private static int ExtractWebDataFile(FileReader reader, string savePath) - { - StatusStripUpdate($"Decompressing {reader.FileName} ..."); - var webFile = new WebFile(reader); - reader.Dispose(); - if (webFile.fileList != null && webFile.fileList.Count > 0) - { - var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); - return ExtractStreamFile(extractPath, webFile.fileList); - } - return 0; - } - - private static int ExtractBlkFile(FileReader reader, string savePath) - { - int total = 0; - StatusStripUpdate($"Decompressing {reader.FileName} ..."); - try - { - using var stream = BlkUtils.Decrypt(reader, (Blk)Game); - do - { - stream.Offset = stream.AbsolutePosition; - var dummyPath = Path.Combine(reader.FullPath, stream.AbsolutePosition.ToString("X8")); - var subReader = new FileReader(dummyPath, stream, true); - var subSavePath = Path.Combine(savePath, reader.FileName + "_unpacked"); - switch (subReader.FileType) - { - case FileType.BundleFile: - total += ExtractBundleFile(subReader, subSavePath); - break; - case FileType.MhyFile: - total += ExtractMhyFile(subReader, subSavePath); - break; - } - } while (stream.Remaining > 0); - } - catch (InvalidCastException) - { - Logger.Error($"Game type mismatch, Expected {nameof(Blk)} but got {Game.Name} ({Game.GetType().Name}) !!"); - } - return total; - } - - private static int ExtractBlockFile(FileReader reader, string savePath) - { - int total = 0; - StatusStripUpdate($"Decompressing {reader.FileName} ..."); - using var stream = new OffsetStream(reader.BaseStream, 0); - do - { - stream.Offset = stream.AbsolutePosition; - var subSavePath = Path.Combine(savePath, reader.FileName + "_unpacked"); - var dummyPath = Path.Combine(reader.FullPath, stream.AbsolutePosition.ToString("X8")); - var subReader = new FileReader(dummyPath, stream, true); - if (subReader.FileType == FileType.Blb3File) - total += ExtractBlb3File(subReader, subSavePath); - else if (subReader.FileType == FileType.VFSFile) - total += ExtractVFSFile(subReader, subSavePath); - else - total += ExtractBundleFile(subReader, subSavePath); - } while (stream.Remaining > 0); - return total; - } - - private static int ExtractVFSFile(FileReader reader, string savePath) - { - StatusStripUpdate($"Decompressing {reader.FileName} ..."); - try - { - var vfsFile = new VFSFile(reader, reader.FullPath, Studio.Game.Type); - reader.Dispose(); - if (vfsFile.fileList != null && vfsFile.fileList.Count > 0) - { - var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); - return ExtractStreamFile(extractPath, vfsFile.fileList); - } - } - catch (Exception e) - { - Logger.Error($"Error while reading VFS file {reader.FullPath}", e); - } - return 0; - } - - private static int ExtractMhyFile(FileReader reader, string savePath) - { - StatusStripUpdate($"Decompressing {reader.FileName} ..."); - try - { - var mhy0File = new MhyFile(reader, (Mhy)Game); - reader.Dispose(); - if (mhy0File.fileList != null && mhy0File.fileList.Count > 0) - { - var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); - return ExtractStreamFile(extractPath, mhy0File.fileList); - } - } - catch (InvalidCastException) - { - Logger.Error($"Game type mismatch, Expected {nameof(Mhy)} but got {Game.Name} ({Game.GetType().Name}) !!"); - } - return 0; - } - - private static int ExtractStreamFile(string extractPath, List fileList) - { - int extractedCount = 0; - if (fileList == null || fileList.Count == 0) - { - return 0; - } - foreach (var file in fileList) - { - var filePath = Path.Combine(extractPath, file.path); - var fileDirectory = Path.GetDirectoryName(filePath); - if (!Directory.Exists(fileDirectory)) - { - Directory.CreateDirectory(fileDirectory); - } - if (!File.Exists(filePath)) - { - if (file.stream == null) - { - continue; - } - using (var fileStream = File.Create(filePath)) - { - file.stream.CopyTo(fileStream); - } - extractedCount += 1; - } - file.stream.Dispose(); - } - return extractedCount; - } - - public static void UpdateContainers() - { - if (exportableAssets.Count == 0) - { - return; - } - - Logger.Info("Updating Containers..."); - var isZZZ = Game.Type.IsZZZ(); - var sourceFileIds = new Dictionary(); - - foreach (var asset in exportableAssets) - { - if (isZZZ && TryUpdateZZZContainer(asset)) - { - continue; - } - - TryUpdateIndexedContainer(asset, sourceFileIds); - } - - Logger.Info("Updated !!"); - } - - private static bool TryUpdateZZZContainer(AssetItem asset) - { - if (!ulong.TryParse(asset.Container, out var hash) || !Paths.TryGetValue(hash, out var z3Path)) - { - return false; - } - - asset.Container = z3Path; - return true; - } - - private static void TryUpdateIndexedContainer(AssetItem asset, Dictionary sourceFileIds) - { - if (!int.TryParse(asset.Container, out var value)) - { - return; - } - - var id = GetSourceFileId(asset.SourceFile.originalPath, sourceFileIds); - if (!id.HasValue) - { - return; - } - - var last = unchecked((uint)value); - var path = ResourceIndex.GetContainer(id.Value, last); - if (string.IsNullOrEmpty(path)) - { - return; - } - - asset.Container = path; - if (asset.Type == ClassIDType.MiHoYoBinData) - { - asset.Text = Path.GetFileNameWithoutExtension(path); - } - } - - private static uint? GetSourceFileId(string originalPath, Dictionary sourceFileIds) - { - originalPath ??= string.Empty; - if (sourceFileIds.TryGetValue(originalPath, out var id)) - { - return id; - } - - var name = Path.GetFileNameWithoutExtension(originalPath); - id = uint.TryParse(name, out var parsedId) ? parsedId : null; - sourceFileIds.Add(originalPath, id); - return id; - } - - #region Build asset data - - private sealed class AssetDataBuildContext - { - public int ObjectCount { get; } - public int GameObjectCount { get; } - public Dictionary ObjectAssetItems { get; } - public List<(PPtr PPtr, string Name)> MiHoYoBinDataNames { get; } = new List<(PPtr, string)>(); - public List<(PPtr PPtr, string Container)> Containers { get; } = new List<(PPtr, string)>(); - public HashSet FastAssetFilterKeys { get; } - private readonly Dictionary canExportCache = new Dictionary(); - public string AssetBundleName { get; set; } = ""; - public string ProductName { get; set; } - - public AssetDataBuildContext() - { - foreach (var assetsFile in assetsManager.assetsFileList) - { - ObjectCount += assetsFile.Objects.Count; - foreach (var asset in assetsFile.Objects) - { - if (asset is GameObject) - { - GameObjectCount++; - } - } - } - - ObjectAssetItems = new Dictionary(ObjectCount); - FastAssetFilterKeys = assetsManager.FilterData.Items - .Select(x => new AssetFilterKey(x.Name, x.PathID, x.Type)) - .ToHashSet(); - } - - public bool CanExport(ClassIDType type) - { - if (!canExportCache.TryGetValue(type, out var canExport)) - { - canExport = type.CanExport(); - canExportCache.Add(type, canExport); - } - - return canExport; - } - } - - private readonly struct AssetFilterKey : IEquatable - { - private readonly string name; - private readonly long pathID; - private readonly ClassIDType type; - - public AssetFilterKey(string name, long pathID, ClassIDType type) - { - this.name = name; - this.pathID = pathID; - this.type = type; - } - - public bool Equals(AssetFilterKey other) - { - return type == other.type - && pathID == other.pathID - && string.Equals(name, other.name, StringComparison.OrdinalIgnoreCase); - } - - public override bool Equals(object obj) - { - return obj is AssetFilterKey other && Equals(other); - } - - public override int GetHashCode() - { - return HashCode.Combine(StringComparer.OrdinalIgnoreCase.GetHashCode(name ?? string.Empty), pathID, type); - } - } - - public static (string, List) BuildAssetData() - { - StatusStripUpdate("Building asset list..."); - - var context = new AssetDataBuildContext(); - Progress.Reset(); - Logger.Info($"Loading {context.ObjectCount} objects from {assetsManager.assetsFileList.Count} files."); - - if (!BuildExportableAssetItems(context) || !ApplyAssetPostProcessing(context)) - { - return (string.Empty, Array.Empty().ToList()); - } - - visibleAssets = exportableAssets; - - StatusStripUpdate("Building tree structure..."); - var treeNodeCollection = BuildSceneTree(context); - context.ObjectAssetItems.Clear(); - - if (treeNodeCollection == null) - { - return (string.Empty, Array.Empty().ToList()); - } - - return (context.ProductName, treeNodeCollection); - } - - private static List BuildSceneTree(AssetDataBuildContext context) - { - var treeNodeCollection = new List(); - var treeNodeDictionary = new Dictionary(context.GameObjectCount); - var hasFilterData = context.FastAssetFilterKeys.Count > 0; - int j = 0; - Progress.Reset(); - var files = assetsManager.assetsFileList.GroupBy(x => x.originalPath ?? string.Empty).OrderBy(x => x.Key).ToList(); - foreach (var fileGroup in files) - { - var file = fileGroup.Key; - var fileNode = !string.IsNullOrEmpty(file) ? new TreeNode(Path.GetFileName(file)) : null; //RootNode - - foreach (var assetsFile in fileGroup) - { - var assetsFileNode = new TreeNode(assetsFile.fileName); - - foreach (var obj in assetsFile.Objects) - { - if (assetsManager.tokenSource.IsCancellationRequested) - { - Logger.Info("Building tree structure been cancelled !!"); - return null; - } - - if (obj is not GameObject) - { - if (hasFilterData && IsMissingFromFilter(obj.Name, obj.m_PathID, obj.type, context.FastAssetFilterKeys)) - { - continue; - } - - continue; - } - - var m_GameObject = (GameObject)obj; - var currentNode = GetOrCreateGameObjectNode(m_GameObject, treeNodeDictionary); - AssignAssetTreeNodes(m_GameObject, currentNode, context); - var parentNode = GetParentTreeNode(m_GameObject, assetsFileNode, treeNodeDictionary); - parentNode.Nodes.Add(currentNode); - } - - // TODO: need to do proper cleaning of list to only include filtered assets when required - - if (assetsFileNode.Nodes.Count > 0) - { - if (fileNode == null) - { - treeNodeCollection.Add(assetsFileNode); - } - else - { - fileNode.Nodes.Add(assetsFileNode); - } - } - } - - if (fileNode?.Nodes.Count > 0) - { - treeNodeCollection.Add(fileNode); - } - - Progress.Report(++j, files.Count); - } - treeNodeDictionary.Clear(); - - return treeNodeCollection; - } - - private static GameObjectTreeNode GetOrCreateGameObjectNode(GameObject gameObject, Dictionary treeNodeDictionary) - { - if (!treeNodeDictionary.TryGetValue(gameObject, out var currentNode)) - { - currentNode = new GameObjectTreeNode(gameObject); - treeNodeDictionary.Add(gameObject, currentNode); - } - - return currentNode; - } - - private static void AssignAssetTreeNodes(GameObject gameObject, GameObjectTreeNode currentNode, AssetDataBuildContext context) - { - foreach (var pptr in gameObject.m_Components) - { - if (!pptr.TryGet(out var m_Component)) - { - continue; - } - - AssignTreeNode(m_Component, currentNode, context); - AssignMeshTreeNode(m_Component, currentNode, context); - } - } - - private static void AssignTreeNode(Object asset, GameObjectTreeNode currentNode, AssetDataBuildContext context) - { - if (context.ObjectAssetItems.TryGetValue(asset, out var assetItem)) - { - assetItem.TreeNode = currentNode; - } - } - - private static void AssignMeshTreeNode(Object component, GameObjectTreeNode currentNode, AssetDataBuildContext context) - { - if (component is MeshFilter m_MeshFilter && m_MeshFilter.m_Mesh.TryGet(out var meshFromFilter)) - { - AssignTreeNode(meshFromFilter, currentNode, context); - } - else if (component is SkinnedMeshRenderer m_SkinnedMeshRenderer && m_SkinnedMeshRenderer.m_Mesh.TryGet(out var meshFromRenderer)) - { - AssignTreeNode(meshFromRenderer, currentNode, context); - } - } - - private static TreeNode GetParentTreeNode(GameObject gameObject, TreeNode assetsFileNode, Dictionary treeNodeDictionary) - { - if (gameObject.m_Transform != null) - { - if (gameObject.m_Transform.m_Father.TryGet(out var m_Father)) - { - if (m_Father.m_GameObject.TryGet(out var parentGameObject)) - { - return GetOrCreateGameObjectNode(parentGameObject, treeNodeDictionary); - } - } - } - - return assetsFileNode; - } - - private static bool BuildExportableAssetItems(AssetDataBuildContext context) - { - int i = 0; - var displayAll = Properties.Settings.Default.displayAll; - var hasFilterData = context.FastAssetFilterKeys.Count > 0; - if (displayAll && exportableAssets.Capacity < context.ObjectCount) - { - exportableAssets.Capacity = context.ObjectCount; - } - - foreach (var assetsFile in assetsManager.assetsFileList) - { - foreach (var asset in assetsFile.Objects) - { - if (assetsManager.tokenSource.IsCancellationRequested) - { - Logger.Info("Building asset list has been cancelled !!"); - return false; - } - - if (hasFilterData && asset is not AssetBundle && asset is not ResourceManager && IsMissingFromFilter(asset.Name, asset.m_PathID, asset.type, context.FastAssetFilterKeys)) - { - continue; - } - - AddAssetItem(asset, context, displayAll, i); - Progress.Report(++i, context.ObjectCount); - } - } - - return true; - } - - private static void AddAssetItem(Object asset, AssetDataBuildContext context, bool displayAll, int index) - { - var assetItem = new AssetItem(asset); - - context.ObjectAssetItems.Add(asset, assetItem); - assetItem.UniqueID = "#" + index; - var exportable = ConfigureAssetItemAndReturnExportable(asset, assetItem, context); - - if (assetItem.Text == "") - { - assetItem.Text = assetItem.TypeString + assetItem.UniqueID; - } - if (displayAll || exportable) - { - exportableAssets.Add(assetItem); - } - } - - private static bool ApplyAssetPostProcessing(AssetDataBuildContext context) - { - foreach ((var pptr, var name) in context.MiHoYoBinDataNames) - { - if (assetsManager.tokenSource.IsCancellationRequested) - { - Logger.Info("Processing asset names has been cancelled !!"); - return false; - } - if (pptr.TryGet(out var obj)) - { - var assetItem = context.ObjectAssetItems[obj]; - if (int.TryParse(name, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hash)) - { - assetItem.Text = name; - assetItem.Container = Properties.Settings.Default.useBundleContainerName ? context.AssetBundleName : hash.ToString(); - } - else assetItem.Text = $"BinFile #{assetItem.m_PathID}"; - } - } - if (!SkipContainer) - { - foreach ((var pptr, var container) in context.Containers) - { - if (assetsManager.tokenSource.IsCancellationRequested) - { - Logger.Info("Processing containers been cancelled !!"); - return false; - } - if (pptr.TryGet(out var obj)) - { - if (context.ObjectAssetItems.TryGetValue(obj, out var assetItem)) - { - assetItem.Container = container; - } - } - } - context.Containers.Clear(); - if (Game.Type.IsGISubGroup() || Game.Type.IsZZZ()) - { - UpdateContainers(); - } - } - return true; - } - - private static bool ConfigureAssetItemAndReturnExportable(Object asset, AssetItem assetItem, AssetDataBuildContext context) - { - switch (asset) - { - case Texture2D m_Texture2D: - if (!string.IsNullOrEmpty(m_Texture2D.m_StreamData?.path)) - assetItem.FullSize = asset.byteSize + m_Texture2D.m_StreamData.size; - return context.CanExport(ClassIDType.Texture2D); - case AudioClip m_AudioClip: - if (!string.IsNullOrEmpty(m_AudioClip.m_Source)) - assetItem.FullSize = asset.byteSize + m_AudioClip.m_Size; - return context.CanExport(ClassIDType.AudioClip); - case VideoClip m_VideoClip: - if (!string.IsNullOrEmpty(m_VideoClip.m_OriginalPath)) - assetItem.FullSize = asset.byteSize + m_VideoClip.m_ExternalResources.m_Size; - return context.CanExport(ClassIDType.VideoClip); - case PlayerSettings m_PlayerSettings: - context.ProductName = m_PlayerSettings.productName; - return context.CanExport(ClassIDType.PlayerSettings); - case AssetBundle m_AssetBundle: - context.AssetBundleName = m_AssetBundle.Name; - AddAssetBundleContainers(m_AssetBundle, context); - return context.CanExport(ClassIDType.AssetBundle); - case IndexObject m_IndexObject: - foreach(var index in m_IndexObject.AssetMap) - { - context.MiHoYoBinDataNames.Add((index.Value.Object, index.Key)); - } - - return context.CanExport(ClassIDType.IndexObject); - case ResourceManager m_ResourceManager: - foreach (var m_Container in m_ResourceManager.m_Container) - { - context.Containers.Add((m_Container.Value, m_Container.Key)); - } - - return context.CanExport(ClassIDType.ResourceManager); - case Mesh _: - return context.CanExport(ClassIDType.Mesh); - case TextAsset _: - return context.CanExport(ClassIDType.TextAsset); - case AnimationClip _: - return context.CanExport(ClassIDType.AnimationClip); - case Font _: - return context.CanExport(ClassIDType.Font); - case MovieTexture _: - return context.CanExport(ClassIDType.MovieTexture); - case Sprite _: - return context.CanExport(ClassIDType.Sprite); - case Material _: - return context.CanExport(ClassIDType.Material); - case MiHoYoBinData _: - return context.CanExport(ClassIDType.MiHoYoBinData); - case NapAssetBundleIndexAsset _: - return context.CanExport(ClassIDType.NapAssetBundleIndexAsset); - case Shader _: - return context.CanExport(ClassIDType.Shader); - case Animator _: - return context.CanExport(ClassIDType.Animator); - case MonoBehaviour _: - return context.CanExport(ClassIDType.MonoBehaviour); - default: - return false; - } - } - - private static void AddAssetBundleContainers(AssetBundle assetBundle, AssetDataBuildContext context) - { - if (SkipContainer) - { - return; - } - - foreach (var m_Container in assetBundle.m_Container) - { - var preloadIndex = m_Container.Value.preloadIndex; - var preloadSize = m_Container.Value.preloadSize; - var preloadEnd = preloadIndex + preloadSize; - - switch (preloadIndex) - { - case int n when n < 0: - Logger.Warning($"preloadIndex {preloadIndex} is out of preloadTable range"); - break; - default: - for (int k = preloadIndex; k < preloadEnd; k++) - { - try - { - context.Containers.Add((assetBundle.m_PreloadTable[k], m_Container.Key)); - } catch - { - Logger.Info($"Failed to add container {m_Container.Key}"); - } - } - break; - } - } - } - - private static bool IsMissingFromFilter(string name, long pathID, ClassIDType type, HashSet fastAssetFilterKeys) - { - if (fastAssetFilterKeys.Count == 0) - { - return false; - } - if (fastAssetFilterKeys.Contains(new AssetFilterKey(name, pathID, type))) - { - return false; - } - - Logger.Verbose($"Skipped {(name.Length > 0 ? name : "an asset")} because filter data was set and it was missing from it"); - return true; - } - - #endregion - - public static Dictionary> BuildClassStructure() - { - var typeMap = new Dictionary>(); - foreach (var assetsFile in assetsManager.assetsFileList) - { - if (assetsManager.tokenSource.IsCancellationRequested) - { - Logger.Info("Processing class structure been cancelled !!"); - return new Dictionary>(); - } - - var items = GetOrCreateTypeTreeItems(typeMap, assetsFile.unityVersion); - AddTypeTreeItems(items, assetsFile.m_Types); - } - - return typeMap; - } - - private static SortedDictionary GetOrCreateTypeTreeItems(Dictionary> typeMap, string unityVersion) - { - if (!typeMap.TryGetValue(unityVersion, out var items)) - { - items = new SortedDictionary(); - typeMap.Add(unityVersion, items); - } - - return items; - } - - private static void AddTypeTreeItems(SortedDictionary items, List types) - { - foreach (var type in types) - { - if (type.m_Type == null) - { - continue; - } - - var key = GetTypeTreeItemKey(type); - items[key] = new TypeTreeItem(key, type.m_Type); - } - } - - private static int GetTypeTreeItemKey(SerializedType type) - { - return type.m_ScriptTypeIndex >= 0 - ? -1 - type.m_ScriptTypeIndex - : type.classID; - } - - #region Export assets - - public static Task ExportAssets(string savePath, List toExportAssets, ExportType exportType, bool openAfterExport) - { - return Task.Run(() => - { - Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US"); - - int toExportCount = toExportAssets.Count; - int exportedCount = 0; - int i = 0; - Progress.Reset(); - var assetGroupOption = (AssetGroupOption)Properties.Settings.Default.assetGroupOption; - foreach (var asset in toExportAssets) - { - var exportPath = GetAssetExportPath(savePath, asset, assetGroupOption); - StatusStripUpdate($"[{exportedCount}/{toExportCount}] Exporting {asset.TypeString}: {asset.Text}"); - - if (TryExportAsset(asset, exportPath, exportType)) - { - exportedCount++; - } - - Progress.Report(++i, toExportCount); - } - - StatusStripUpdate(GetExportAssetsStatus(toExportCount, exportedCount)); - - if (openAfterExport && exportedCount > 0) - { - OpenFolderInExplorer(savePath); - } - }); - } - - private static string GetAssetExportPath(string savePath, AssetItem asset, AssetGroupOption assetGroupOption) - { - string exportPath; - switch (assetGroupOption) - { - case AssetGroupOption.ByType: - exportPath = Path.Combine(savePath, asset.TypeString); - break; - case AssetGroupOption.ByContainer: - exportPath = GetContainerExportPath(savePath, asset); - break; - case AssetGroupOption.BySource: - exportPath = GetSourceExportPath(savePath, asset); - break; - default: - exportPath = savePath; - break; - } - - return exportPath + Path.DirectorySeparatorChar; - } - - private static string GetContainerExportPath(string savePath, AssetItem asset) - { - if (string.IsNullOrEmpty(asset.Container)) - { - return savePath; - } - - return Path.HasExtension(asset.Container) - ? Path.Combine(savePath, Path.GetDirectoryName(asset.Container)) - : Path.Combine(savePath, asset.Container); - } - - private static string GetSourceExportPath(string savePath, AssetItem asset) - { - if (string.IsNullOrEmpty(asset.SourceFile.originalPath)) - { - return Path.Combine(savePath, asset.SourceFile.fileName + "_export"); - } - - return Path.Combine(savePath, Path.GetFileName(asset.SourceFile.originalPath) + "_export", asset.SourceFile.fileName); - } - - private static bool TryExportAsset(AssetItem asset, string exportPath, ExportType exportType) - { - try - { - switch (exportType) - { - case ExportType.Raw: - return ExportRawFile(asset, exportPath); - case ExportType.Dump: - return ExportDumpFile(asset, exportPath); - case ExportType.Convert: - return ExportConvertFile(asset, exportPath); - case ExportType.JSON: - return ExportJSONFile(asset, exportPath); - default: - return false; - } - } - catch (Exception ex) - { - Logger.Error($"Export {asset.Type}:{asset.Text} error\r\n{ex.Message}\r\n{ex.StackTrace}"); - return false; - } - } - - private static string GetExportAssetsStatus(int toExportCount, int exportedCount) - { - var statusText = exportedCount == 0 ? "Nothing exported." : $"Finished exporting {exportedCount} assets."; - - if (toExportCount > exportedCount) - { - statusText += $" {toExportCount - exportedCount} assets skipped (not extractable or files already exist)"; - } - - return statusText; - } - - #endregion - - public static Task ExportAssetsList(string savePath, List toExportAssets, ExportListType exportListType) - { - return Task.Run(() => - { - Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US"); - - Progress.Reset(); - - switch (exportListType) - { - case ExportListType.XML: - var filename = Path.Combine(savePath, "assets.xml"); - var settings = new XmlWriterSettings() { Indent = true }; - using (XmlWriter writer = XmlWriter.Create(filename, settings)) - { - writer.WriteStartDocument(); - writer.WriteStartElement("Assets"); - writer.WriteAttributeString("filename", filename); - writer.WriteAttributeString("createdAt", DateTime.UtcNow.ToString("s")); - foreach (var asset in toExportAssets) - { - writer.WriteStartElement("Asset"); - writer.WriteElementString("Name", asset.Name); - writer.WriteElementString("Container", asset.Container); - writer.WriteStartElement("Type"); - writer.WriteAttributeString("id", ((int)asset.Type).ToString()); - writer.WriteValue(asset.TypeString); - writer.WriteEndElement(); - writer.WriteElementString("PathID", asset.m_PathID.ToString()); - writer.WriteElementString("Source", asset.SourceFile.fullName); - writer.WriteElementString("Size", asset.FullSize.ToString()); - writer.WriteEndElement(); - } - writer.WriteEndElement(); - writer.WriteEndDocument(); - } - break; - } - - var statusText = $"Finished exporting asset list with {toExportAssets.Count()} items."; - - StatusStripUpdate(statusText); - - if (Properties.Settings.Default.openAfterExport && toExportAssets.Count() > 0) - { - OpenFolderInExplorer(savePath); - } - }); - } - - #region Export split objects - - public static Task ExportSplitObjects(string savePath, TreeNodeCollection nodes) - { - return Task.Run(() => - { - var exportNodes = GetSplitExportNodes(nodes).ToList(); - var count = exportNodes.Sum(x => x.Nodes.Count); - int k = 0; - Progress.Reset(); - - foreach (var node in exportNodes) - { - foreach (GameObjectTreeNode childNode in node.Nodes) - { - ExportSplitObject(savePath, node, childNode); - Progress.Report(++k, count); - } - } - - if (Properties.Settings.Default.openAfterExport) - { - OpenFolderInExplorer(savePath); - } - StatusStripUpdate("Finished"); - }); - } - - private static IEnumerable GetSplitExportNodes(TreeNodeCollection nodes) - { - foreach (TreeNode node in nodes) - { - if (node.Nodes.Count == 0) - { - yield return node; - continue; - } - - foreach (TreeNode subNode in node.Nodes) - { - yield return subNode; - } - } - } - - private static void ExportSplitObject(string savePath, TreeNode parentNode, GameObjectTreeNode node) - { - var gameObjects = new List(); - CollectNode(node, gameObjects); - if (gameObjects.All(x => x.m_SkinnedMeshRenderer == null && x.m_MeshFilter == null)) - { - return; - } - - var filename = GetSplitObjectFileName(parentNode, node); - var targetPath = GetUniqueSplitObjectTargetPath(savePath, filename); - Directory.CreateDirectory(targetPath); - - StatusStripUpdate($"Exporting {filename}.fbx"); - try - { - ExportGameObject(node.gameObject, targetPath); - } - catch (Exception ex) - { - Logger.Error($"Export GameObject:{node.Text} error\r\n{ex.Message}\r\n{ex.StackTrace}"); - } - - StatusStripUpdate($"Finished exporting {filename}.fbx"); - } - - private static string GetSplitObjectFileName(TreeNode parentNode, GameObjectTreeNode node) - { - var filename = FixFileName(node.Text); - if (parentNode.Parent != null) - { - filename = Path.Combine(FixFileName(parentNode.Parent.Text), filename); - } - - return filename; - } - - private static string GetUniqueSplitObjectTargetPath(string savePath, string filename) - { - var targetPath = $"{savePath}{filename}{Path.DirectorySeparatorChar}"; - for (int i = 1; Directory.Exists(targetPath); i++) - { - targetPath = $"{savePath}{filename} ({i}){Path.DirectorySeparatorChar}"; - } - - return targetPath; - } - - private static void CollectNode(GameObjectTreeNode node, List gameObjects) - { - gameObjects.Add(node.gameObject); - foreach (GameObjectTreeNode i in node.Nodes) - { - CollectNode(i, gameObjects); - } - } - - #endregion - - public static Task ExportAnimatorWithAnimationClip(AssetItem animator, List animationList, string exportPath) - { - return Task.Run(() => - { - Progress.Reset(); - StatusStripUpdate($"Exporting {animator.Text}"); - try - { - ExportAnimator(animator, exportPath, animationList); - if (Properties.Settings.Default.openAfterExport) - { - OpenFolderInExplorer(exportPath); - } - Progress.Report(1, 1); - StatusStripUpdate($"Finished exporting {animator.Text}"); - } - catch (Exception ex) - { - Logger.Error($"Export Animator:{animator.Text} error\r\n{ex.Message}\r\n{ex.StackTrace}"); - StatusStripUpdate("Error in export"); - } - }); - } - - public static Task ExportObjectsWithAnimationClip(string exportPath, TreeNodeCollection nodes, List animationList = null) - { - return Task.Run(() => - { - var gameObjects = new List(); - GetSelectedParentNode(nodes, gameObjects); - if (gameObjects.Count > 0) - { - var count = gameObjects.Count; - int i = 0; - Progress.Reset(); - foreach (var gameObject in gameObjects) - { - StatusStripUpdate($"Exporting {gameObject.m_Name}"); - try - { - var subExportPath = Path.Combine(exportPath, gameObject.m_Name) + Path.DirectorySeparatorChar; - ExportGameObject(gameObject, subExportPath, animationList); - StatusStripUpdate($"Finished exporting {gameObject.m_Name}"); - } - catch (Exception ex) - { - Logger.Error($"Export GameObject:{gameObject.m_Name} error\r\n{ex.Message}\r\n{ex.StackTrace}"); - StatusStripUpdate("Error in export"); - } - - Progress.Report(++i, count); - } - if (Properties.Settings.Default.openAfterExport) - { - OpenFolderInExplorer(exportPath); - } - } - else - { - StatusStripUpdate("No Object selected for export."); - } - }); - } - - public static Task ExportObjectsMergeWithAnimationClip(string exportPath, List gameObjects, List animationList = null) - { - return Task.Run(() => - { - var name = Path.GetFileName(exportPath); - Progress.Reset(); - StatusStripUpdate($"Exporting {name}"); - try - { - ExportGameObjectMerge(gameObjects, exportPath, animationList); - Progress.Report(1, 1); - StatusStripUpdate($"Finished exporting {name}"); - } - catch (Exception ex) - { - Logger.Error($"Export Model:{name} error\r\n{ex.Message}\r\n{ex.StackTrace}"); - StatusStripUpdate("Error in export"); - } - if (Properties.Settings.Default.openAfterExport) - { - OpenFolderInExplorer(Path.GetDirectoryName(exportPath)); - } - }); - } - - public static Task ExportNodesWithAnimationClip(string exportPath, List nodes, List animationList = null) - { - return Task.Run(() => - { - int i = 0; - Progress.Reset(); - foreach (var node in nodes) - { - var name = node.Text; - StatusStripUpdate($"Exporting {name}"); - var gameObjects = new List(); - GetSelectedParentNode(node.Nodes, gameObjects); - if (gameObjects.Count > 0) - { - var subExportPath = exportPath + Path.Combine(node.Text, FixFileName(node.Text) + ".fbx"); - try - { - ExportGameObjectMerge(gameObjects, subExportPath, animationList); - Progress.Report(++i, nodes.Count); - StatusStripUpdate($"Finished exporting {name}"); - } - catch (Exception ex) - { - Logger.Error($"Export Model:{name} error\r\n{ex.Message}\r\n{ex.StackTrace}"); - StatusStripUpdate("Error in export"); - } - } - else - { - StatusStripUpdate("Empty node selected for export."); - } - } - if (Properties.Settings.Default.openAfterExport) - { - OpenFolderInExplorer(exportPath); - } - }); - } - - public static void GetSelectedParentNode(TreeNodeCollection nodes, List gameObjects) - { - foreach (TreeNode i in nodes) - { - if (i is GameObjectTreeNode gameObjectTreeNode && i.Checked) - { - gameObjects.Add(gameObjectTreeNode.gameObject); - } - else - { - GetSelectedParentNode(i.Nodes, gameObjects); - } - } - } - - public static TypeTree MonoBehaviourToTypeTree(MonoBehaviour m_MonoBehaviour) - { - if (!assemblyLoader.Loaded) - { - var openFolderDialog = new OpenFolderDialog(); - openFolderDialog.Title = "Select Assembly Folder"; - if (openFolderDialog.ShowDialog() == DialogResult.OK) - { - assemblyLoader.Load(openFolderDialog.Folder); - } - else - { - assemblyLoader.Loaded = true; - } - } - return m_MonoBehaviour.ConvertToTypeTree(assemblyLoader); - } - - public static string DumpAsset(Object obj) - { - var str = obj.Dump(); - if (str == null && obj is MonoBehaviour m_MonoBehaviour) - { - var type = MonoBehaviourToTypeTree(m_MonoBehaviour); - str = m_MonoBehaviour.Dump(type); - } - if (string.IsNullOrEmpty(str)) - { - var settings = new JsonSerializerSettings(); - settings.Converters.Add(new StringEnumConverter()); - str = JsonConvert.SerializeObject(obj, Newtonsoft.Json.Formatting.Indented, settings); - } - return str; - } - - public static void OpenFolderInExplorer(string path) - { - var info = new ProcessStartInfo(path); - info.UseShellExecute = true; - Process.Start(info); - } } } + From 3bd296a8c64eedcc96d4838224cd50e310e5ba04 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 15:23:38 +0800 Subject: [PATCH 35/56] Refactor archive extraction helpers --- AnimeStudio.GUI/Studio.Extract.cs | 153 ++++++++++++++++-------------- 1 file changed, 80 insertions(+), 73 deletions(-) diff --git a/AnimeStudio.GUI/Studio.Extract.cs b/AnimeStudio.GUI/Studio.Extract.cs index bdbeabd4..33e002e6 100644 --- a/AnimeStudio.GUI/Studio.Extract.cs +++ b/AnimeStudio.GUI/Studio.Extract.cs @@ -37,75 +37,57 @@ public static int ExtractFile(string[] fileNames, string savePath) public static int ExtractFile(string fileName, string savePath) { - int extractedCount = 0; var reader = new FileReader(fileName); reader = reader.PreProcessing(Game); - if (reader.FileType == FileType.BundleFile) - extractedCount += ExtractBundleFile(reader, savePath); - else if (reader.FileType == FileType.WebFile) - extractedCount += ExtractWebDataFile(reader, savePath); - else if (reader.FileType == FileType.BlkFile) - extractedCount += ExtractBlkFile(reader, savePath); - else if (reader.FileType == FileType.BlockFile) - extractedCount += ExtractBlockFile(reader, savePath); - else if (reader.FileType == FileType.Blb3File) - extractedCount += ExtractBlb3File(reader, savePath); - else if (reader.FileType == FileType.VFSFile) - extractedCount += ExtractVFSFile(reader, savePath); - else - reader.Dispose(); - return extractedCount; + switch (reader.FileType) + { + case FileType.BundleFile: + return ExtractBundleFile(reader, savePath); + case FileType.WebFile: + return ExtractWebDataFile(reader, savePath); + case FileType.BlkFile: + return ExtractBlkFile(reader, savePath); + case FileType.BlockFile: + return ExtractBlockFile(reader, savePath); + case FileType.Blb3File: + return ExtractBlb3File(reader, savePath); + case FileType.VFSFile: + return ExtractVFSFile(reader, savePath); + default: + reader.Dispose(); + return 0; + } } + private static int ExtractBlb3File(FileReader reader, string savePath) { - StatusStripUpdate($"Decompressing {reader.FileName} ..."); try { - var bundleFile = new Blb3File(reader, reader.FullPath); - reader.Dispose(); - if (bundleFile.fileList != null && bundleFile.fileList.Count > 0) - { - var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); - return ExtractStreamFile(extractPath, bundleFile.fileList); - } + return ExtractFileList(reader, savePath, currentReader => new Blb3File(currentReader, currentReader.FullPath).fileList); } catch (InvalidCastException) { - Logger.Error($"Game type mismatch, Expected {nameof(Mr0k)} but got {Game.Name} ({Game.GetType().Name}) !!"); + LogGameTypeMismatch(nameof(Mr0k)); } return 0; } + private static int ExtractBundleFile(FileReader reader, string savePath) { - StatusStripUpdate($"Decompressing {reader.FileName} ..."); try { - var bundleFile = new BundleFile(reader, Game); - reader.Dispose(); - if (bundleFile.fileList != null && bundleFile.fileList.Count > 0) - { - var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); - return ExtractStreamFile(extractPath, bundleFile.fileList); - } + return ExtractFileList(reader, savePath, currentReader => new BundleFile(currentReader, Game).fileList); } catch (InvalidCastException) { - Logger.Error($"Game type mismatch, Expected {nameof(Mr0k)} but got {Game.Name} ({Game.GetType().Name}) !!"); + LogGameTypeMismatch(nameof(Mr0k)); } return 0; } private static int ExtractWebDataFile(FileReader reader, string savePath) { - StatusStripUpdate($"Decompressing {reader.FileName} ..."); - var webFile = new WebFile(reader); - reader.Dispose(); - if (webFile.fileList != null && webFile.fileList.Count > 0) - { - var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); - return ExtractStreamFile(extractPath, webFile.fileList); - } - return 0; + return ExtractFileList(reader, savePath, currentReader => new WebFile(currentReader).fileList); } private static int ExtractBlkFile(FileReader reader, string savePath) @@ -118,9 +100,8 @@ private static int ExtractBlkFile(FileReader reader, string savePath) do { stream.Offset = stream.AbsolutePosition; - var dummyPath = Path.Combine(reader.FullPath, stream.AbsolutePosition.ToString("X8")); - var subReader = new FileReader(dummyPath, stream, true); - var subSavePath = Path.Combine(savePath, reader.FileName + "_unpacked"); + var subReader = CreateSubReader(reader.FullPath, stream); + var subSavePath = GetUnpackedPath(savePath, reader.FileName); switch (subReader.FileType) { case FileType.BundleFile: @@ -134,7 +115,7 @@ private static int ExtractBlkFile(FileReader reader, string savePath) } catch (InvalidCastException) { - Logger.Error($"Game type mismatch, Expected {nameof(Blk)} but got {Game.Name} ({Game.GetType().Name}) !!"); + LogGameTypeMismatch(nameof(Blk)); } return total; } @@ -147,31 +128,18 @@ private static int ExtractBlockFile(FileReader reader, string savePath) do { stream.Offset = stream.AbsolutePosition; - var subSavePath = Path.Combine(savePath, reader.FileName + "_unpacked"); - var dummyPath = Path.Combine(reader.FullPath, stream.AbsolutePosition.ToString("X8")); - var subReader = new FileReader(dummyPath, stream, true); - if (subReader.FileType == FileType.Blb3File) - total += ExtractBlb3File(subReader, subSavePath); - else if (subReader.FileType == FileType.VFSFile) - total += ExtractVFSFile(subReader, subSavePath); - else - total += ExtractBundleFile(subReader, subSavePath); + var subSavePath = GetUnpackedPath(savePath, reader.FileName); + var subReader = CreateSubReader(reader.FullPath, stream); + total += ExtractBlockSubFile(subReader, subSavePath); } while (stream.Remaining > 0); return total; } private static int ExtractVFSFile(FileReader reader, string savePath) { - StatusStripUpdate($"Decompressing {reader.FileName} ..."); try { - var vfsFile = new VFSFile(reader, reader.FullPath, Studio.Game.Type); - reader.Dispose(); - if (vfsFile.fileList != null && vfsFile.fileList.Count > 0) - { - var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); - return ExtractStreamFile(extractPath, vfsFile.fileList); - } + return ExtractFileList(reader, savePath, currentReader => new VFSFile(currentReader, currentReader.FullPath, Game.Type).fileList); } catch (Exception e) { @@ -182,24 +150,63 @@ private static int ExtractVFSFile(FileReader reader, string savePath) private static int ExtractMhyFile(FileReader reader, string savePath) { - StatusStripUpdate($"Decompressing {reader.FileName} ..."); try { - var mhy0File = new MhyFile(reader, (Mhy)Game); - reader.Dispose(); - if (mhy0File.fileList != null && mhy0File.fileList.Count > 0) - { - var extractPath = Path.Combine(savePath, reader.FileName + "_unpacked"); - return ExtractStreamFile(extractPath, mhy0File.fileList); - } + return ExtractFileList(reader, savePath, currentReader => new MhyFile(currentReader, (Mhy)Game).fileList); } catch (InvalidCastException) { - Logger.Error($"Game type mismatch, Expected {nameof(Mhy)} but got {Game.Name} ({Game.GetType().Name}) !!"); + LogGameTypeMismatch(nameof(Mhy)); } return 0; } + private static int ExtractBlockSubFile(FileReader subReader, string subSavePath) + { + switch (subReader.FileType) + { + case FileType.Blb3File: + return ExtractBlb3File(subReader, subSavePath); + case FileType.VFSFile: + return ExtractVFSFile(subReader, subSavePath); + default: + return ExtractBundleFile(subReader, subSavePath); + } + } + + private static int ExtractFileList(FileReader reader, string savePath, Func> readFileList) + { + StatusStripUpdate($"Decompressing {reader.FileName} ..."); + var extractPath = GetUnpackedPath(savePath, reader.FileName); + List fileList; + try + { + fileList = readFileList(reader); + } + finally + { + reader.Dispose(); + } + + return ExtractStreamFile(extractPath, fileList); + } + + private static string GetUnpackedPath(string savePath, string fileName) + { + return Path.Combine(savePath, fileName + "_unpacked"); + } + + private static FileReader CreateSubReader(string parentPath, OffsetStream stream) + { + var dummyPath = Path.Combine(parentPath, stream.AbsolutePosition.ToString("X8")); + return new FileReader(dummyPath, stream, true); + } + + private static void LogGameTypeMismatch(string expectedGameType) + { + Logger.Error($"Game type mismatch, Expected {expectedGameType} but got {Game.Name} ({Game.GetType().Name}) !!"); + } + private static int ExtractStreamFile(string extractPath, List fileList) { int extractedCount = 0; From 3bc70b100e7eac2448d30b6f0f3e27b169f1e87a Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 15:30:32 +0800 Subject: [PATCH 36/56] updata build chain --- .../AnimeStudio.FBXNative.vcxproj | 8 ++++---- AnimeStudio.Libraries/AnimeStudio.Ooz.dll | Bin 202240 -> 203264 bytes AnimeStudio.Oodle/AnimeStudio.Ooz.vcxproj | 8 ++++---- .../AnimeStudio.Ooz.vcxproj.filters | 5 +++++ 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/AnimeStudio.FBXNative/AnimeStudio.FBXNative.vcxproj b/AnimeStudio.FBXNative/AnimeStudio.FBXNative.vcxproj index 4a72a819..cb2b9142 100644 --- a/AnimeStudio.FBXNative/AnimeStudio.FBXNative.vcxproj +++ b/AnimeStudio.FBXNative/AnimeStudio.FBXNative.vcxproj @@ -30,26 +30,26 @@ DynamicLibrary true - v143 + v145 Unicode DynamicLibrary false - v143 + v145 true Unicode DynamicLibrary true - v143 + v145 Unicode DynamicLibrary false - v143 + v145 true Unicode diff --git a/AnimeStudio.Libraries/AnimeStudio.Ooz.dll b/AnimeStudio.Libraries/AnimeStudio.Ooz.dll index 639587ee89a43c8e0449cfc79954cdb188a73433..c1b44cc292ef80b9e741dafb242b4c0858be1f10 100644 GIT binary patch delta 103065 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 delta 102020 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 Application true - v143 + v145 Unicode DynamicLibrary true - v143 + v145 Unicode Application false - v143 + v145 true Unicode DynamicLibrary false - v143 + v145 true Unicode diff --git a/AnimeStudio.Oodle/AnimeStudio.Ooz.vcxproj.filters b/AnimeStudio.Oodle/AnimeStudio.Ooz.vcxproj.filters index 2ec1f91c..51e179ef 100644 --- a/AnimeStudio.Oodle/AnimeStudio.Ooz.vcxproj.filters +++ b/AnimeStudio.Oodle/AnimeStudio.Ooz.vcxproj.filters @@ -96,4 +96,9 @@ Source Files + + + Resource Files + + \ No newline at end of file From 810e211b200fb197737a7912d3b71244ccd6bae2 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 15:28:41 +0800 Subject: [PATCH 37/56] Clean up small Studio allocations --- AnimeStudio.GUI/Studio.AssetData.cs | 4 +-- AnimeStudio.GUI/Studio.Export.cs | 55 +++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/AnimeStudio.GUI/Studio.AssetData.cs b/AnimeStudio.GUI/Studio.AssetData.cs index f65a8567..c21f26f9 100644 --- a/AnimeStudio.GUI/Studio.AssetData.cs +++ b/AnimeStudio.GUI/Studio.AssetData.cs @@ -94,7 +94,7 @@ public static (string, List) BuildAssetData() if (!BuildExportableAssetItems(context) || !ApplyAssetPostProcessing(context)) { - return (string.Empty, Array.Empty().ToList()); + return (string.Empty, new List()); } visibleAssets = exportableAssets; @@ -105,7 +105,7 @@ public static (string, List) BuildAssetData() if (treeNodeCollection == null) { - return (string.Empty, Array.Empty().ToList()); + return (string.Empty, new List()); } return (context.ProductName, treeNodeCollection); diff --git a/AnimeStudio.GUI/Studio.Export.cs b/AnimeStudio.GUI/Studio.Export.cs index 0b0a41d5..05044d22 100644 --- a/AnimeStudio.GUI/Studio.Export.cs +++ b/AnimeStudio.GUI/Studio.Export.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Globalization; using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; @@ -65,7 +64,7 @@ private static string GetAssetExportPath(string savePath, AssetItem asset, Asset break; } - return exportPath + Path.DirectorySeparatorChar; + return EnsureTrailingDirectorySeparator(exportPath); } private static string GetContainerExportPath(string savePath, AssetItem asset) @@ -166,11 +165,11 @@ public static Task ExportAssetsList(string savePath, List toExportAss break; } - var statusText = $"Finished exporting asset list with {toExportAssets.Count()} items."; + var statusText = $"Finished exporting asset list with {toExportAssets.Count} items."; StatusStripUpdate(statusText); - if (Properties.Settings.Default.openAfterExport && toExportAssets.Count() > 0) + if (Properties.Settings.Default.openAfterExport && toExportAssets.Count > 0) { OpenFolderInExplorer(savePath); } @@ -181,8 +180,8 @@ public static Task ExportSplitObjects(string savePath, TreeNodeCollection nodes) { return Task.Run(() => { - var exportNodes = GetSplitExportNodes(nodes).ToList(); - var count = exportNodes.Sum(x => x.Nodes.Count); + var exportNodes = GetSplitExportNodes(nodes); + var count = CountSplitExportObjects(exportNodes); int k = 0; Progress.Reset(); @@ -203,28 +202,31 @@ public static Task ExportSplitObjects(string savePath, TreeNodeCollection nodes) }); } - private static IEnumerable GetSplitExportNodes(TreeNodeCollection nodes) + private static List GetSplitExportNodes(TreeNodeCollection nodes) { + var exportNodes = new List(); foreach (TreeNode node in nodes) { if (node.Nodes.Count == 0) { - yield return node; + exportNodes.Add(node); continue; } foreach (TreeNode subNode in node.Nodes) { - yield return subNode; + exportNodes.Add(subNode); } } + + return exportNodes; } private static void ExportSplitObject(string savePath, TreeNode parentNode, GameObjectTreeNode node) { var gameObjects = new List(); CollectNode(node, gameObjects); - if (gameObjects.All(x => x.m_SkinnedMeshRenderer == null && x.m_MeshFilter == null)) + if (!HasAnyModel(gameObjects)) { return; } @@ -317,7 +319,7 @@ public static Task ExportObjectsWithAnimationClip(string exportPath, TreeNodeCol StatusStripUpdate($"Exporting {gameObject.m_Name}"); try { - var subExportPath = Path.Combine(exportPath, gameObject.m_Name) + Path.DirectorySeparatorChar; + var subExportPath = EnsureTrailingDirectorySeparator(Path.Combine(exportPath, gameObject.m_Name)); ExportGameObject(gameObject, subExportPath, animationList); StatusStripUpdate($"Finished exporting {gameObject.m_Name}"); } @@ -419,6 +421,37 @@ public static void GetSelectedParentNode(TreeNodeCollection nodes, List exportNodes) + { + var count = 0; + foreach (var node in exportNodes) + { + count += node.Nodes.Count; + } + + return count; + } + + private static bool HasAnyModel(List gameObjects) + { + foreach (var gameObject in gameObjects) + { + if (gameObject.m_SkinnedMeshRenderer != null || gameObject.m_MeshFilter != null) + { + return true; + } + } + + return false; + } + + private static string EnsureTrailingDirectorySeparator(string path) + { + return path.Length > 0 && path[path.Length - 1] == Path.DirectorySeparatorChar + ? path + : path + Path.DirectorySeparatorChar; + } } } From 885e5952cb07066bb36ba9faa32cc502e96dc0a1 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 15:46:18 +0800 Subject: [PATCH 38/56] add Microsoft.Data.Sqlite --- AnimeStudio/AnimeStudio.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/AnimeStudio/AnimeStudio.csproj b/AnimeStudio/AnimeStudio.csproj index caabf245..e063c666 100644 --- a/AnimeStudio/AnimeStudio.csproj +++ b/AnimeStudio/AnimeStudio.csproj @@ -13,6 +13,7 @@ + From ed4628e553fb63eb59635d1ea85fbfe58b29ec72 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 15:54:54 +0800 Subject: [PATCH 39/56] Group asset map helpers --- AnimeStudio/AssetsHelper.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AnimeStudio/AssetsHelper.cs b/AnimeStudio/AssetsHelper.cs index 28b9c643..4162963a 100644 --- a/AnimeStudio/AssetsHelper.cs +++ b/AnimeStudio/AssetsHelper.cs @@ -315,6 +315,8 @@ private static void ParseCABMap(BinaryReader reader) } } + #region AssetMap + public static async Task BuildAssetMap(string[] files, string mapName, Game game, string savePath, ExportListType exportListType, ClassIDType[] typeFilters = null, Regex[] nameFilters = null, Regex[] containerFilters = null) { Logger.Info("Building AssetMap..."); @@ -777,6 +779,8 @@ public static async Task BuildBoth(string[] files, string mapName, string baseFo await ExportAssetsMap(assets, game, mapName, savePath, exportListType); } + #endregion + #region Nested type: Entry public record Entry From 417e1b22bbe071c83f9f31f25935e3ca91e48b0e Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 15:56:39 +0800 Subject: [PATCH 40/56] Extract asset map filters --- AnimeStudio/AssetsHelper.cs | 130 +++++++++++++++++++++++++++--------- 1 file changed, 97 insertions(+), 33 deletions(-) diff --git a/AnimeStudio/AssetsHelper.cs b/AnimeStudio/AssetsHelper.cs index 4162963a..58066720 100644 --- a/AnimeStudio/AssetsHelper.cs +++ b/AnimeStudio/AssetsHelper.cs @@ -325,9 +325,10 @@ public static async Task BuildAssetMap(string[] files, string mapName, Game game Progress.Reset(); assetsManager.Game = game; var assets = new List(); + var filters = new AssetMapFilters(typeFilters, nameFilters, containerFilters); foreach (var file in LoadFiles(files)) { - BuildAssetMap(file, assets, typeFilters, nameFilters, containerFilters); + BuildAssetMap(file, assets, filters); } UpdateContainers(assets, game); @@ -341,7 +342,7 @@ public static async Task BuildAssetMap(string[] files, string mapName, Game game } - private static void BuildAssetMap(string file, List assets, ClassIDType[] typeFilters = null, Regex[] nameFilters = null, Regex[] containerFilters = null) + private static void BuildAssetMap(string file, List assets, AssetMapFilters filters) { var matches = new List(); var containers = new List<(PPtr, string)>(); @@ -516,13 +517,7 @@ private static void BuildAssetMap(string file, List assets, ClassIDT } } - assets.AddRange(matches.Where(x => - { - var isMatchRegex = nameFilters.IsNullOrEmpty() || nameFilters.Any(y => y.IsMatch(x.Name)); - var isFilteredType = typeFilters.IsNullOrEmpty() || typeFilters.Contains(x.Type); - var isContainerMatch = containerFilters.IsNullOrEmpty() || containerFilters.Any(y => y.IsMatch(x.Container)); - return isMatchRegex && isFilteredType && isContainerMatch; - })); + assets.AddRange(matches.Where(filters.Matches)); } public static string[] ParseAssetMap @@ -533,6 +528,7 @@ public static string[] ParseAssetMap Regex[] containerFilter) { var matches = new HashSet(); + var filters = new AssetMapFilters(typeFilter, nameFilter, containerFilter); switch (mapType) { @@ -542,10 +538,7 @@ public static string[] ParseAssetMap var assetMap = MessagePackSerializer.Deserialize(stream, MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray)); foreach(var entry in assetMap.AssetEntries) { - var isNameMatch = nameFilter.Length == 0 || nameFilter.Any(x => x.IsMatch(entry.Name)); - var isContainerMatch = containerFilter.Length == 0 || containerFilter.Any(x => x.IsMatch(entry.Container)); - var isTypeMatch = typeFilter.Length == 0 || typeFilter.Any(x => x == entry.Type); - if (isNameMatch && isContainerMatch && isTypeMatch) + if (filters.Matches(entry)) { matches.Add(entry.Source); } @@ -564,25 +557,19 @@ public static string[] ParseAssetMap reader.ReadToFollowing("Name"); var name = reader.ReadInnerXml(); - var isNameMatch = nameFilter.Length == 0 || nameFilter.Any(x => x.IsMatch(name)); - reader.ReadToFollowing("Container"); var container = reader.ReadInnerXml(); - var isContainerMatch = containerFilter.Length == 0 || containerFilter.Any(x => x.IsMatch(container)); - reader.ReadToFollowing("Type"); var type = reader.ReadInnerXml(); - var isTypeMatch = typeFilter.Length == 0 || typeFilter.Any(x => x.ToString().Equals(type, StringComparison.OrdinalIgnoreCase)); - reader.ReadToFollowing("PathID"); - var pathID = reader.ReadInnerXml(); + reader.ReadInnerXml(); reader.ReadToFollowing("Source"); var source = reader.ReadInnerXml(); - if (isNameMatch && isContainerMatch && isTypeMatch) + if (filters.Matches(name, container, type)) { matches.Add(source); } @@ -604,10 +591,7 @@ public static string[] ParseAssetMap var entries = serializer.Deserialize>(reader); foreach (var entry in entries) { - var isNameMatch = nameFilter.Length == 0 || nameFilter.Any(x => x.IsMatch(entry.Name)); - var isContainerMatch = containerFilter.Length == 0 || containerFilter.Any(x => x.IsMatch(entry.Container)); - var isTypeMatch = typeFilter.Length == 0 || typeFilter.Any(x => x == entry.Type); - if (isNameMatch && isContainerMatch && isTypeMatch) + if (filters.Matches(entry)) { matches.Add(entry.Source); } @@ -625,13 +609,7 @@ public static string[] ParseAssetMap { if(entry == null) continue; - bool isNameMatch = nameFilter.Length == 0 || nameFilter.Any - (x => x.IsMatch(entry.Name ?? string.Empty)); - bool isContainerMatch = containerFilter.Length == 0 || containerFilter.Any - (x => x.IsMatch(entry.Container ?? string.Empty)); - bool isTypeMatch = typeFilter.Length == 0 || typeFilter.Any(x => x == entry.Type); - - if(isNameMatch && isContainerMatch && isTypeMatch) + if(filters.Matches(entry.Name ?? string.Empty, entry.Container ?? string.Empty, entry.Type)) matches.Add(entry.Source ?? string.Empty); } @@ -766,10 +744,11 @@ public static async Task BuildBoth(string[] files, string mapName, string baseFo BaseFolder = baseFolder; assetsManager.Game = game; var assets = new List(); + var filters = new AssetMapFilters(typeFilters, nameFilters, containerFilters); foreach(var file in LoadFiles(files)) { BuildCABMap(file, ref collision); - BuildAssetMap(file, assets, typeFilters, nameFilters, containerFilters); + BuildAssetMap(file, assets, filters); } UpdateContainers(assets, game); @@ -779,6 +758,91 @@ public static async Task BuildBoth(string[] files, string mapName, string baseFo await ExportAssetsMap(assets, game, mapName, savePath, exportListType); } + private sealed class AssetMapFilters + { + private readonly ClassIDType[] _typeFilters; + private readonly Regex[] _nameFilters; + private readonly Regex[] _containerFilters; + + public AssetMapFilters(ClassIDType[] typeFilters, Regex[] nameFilters, Regex[] containerFilters) + { + _typeFilters = typeFilters ?? Array.Empty(); + _nameFilters = nameFilters ?? Array.Empty(); + _containerFilters = containerFilters ?? Array.Empty(); + } + + public bool Matches(AssetEntry entry) + { + return Matches(entry.Name, entry.Container, entry.Type); + } + + public bool Matches(string name, string container, ClassIDType type) + { + return MatchesName(name) && MatchesContainer(container) && MatchesType(type); + } + + public bool Matches(string name, string container, string type) + { + return MatchesName(name) && MatchesContainer(container) && MatchesType(type); + } + + private bool MatchesName(string name) + { + if (_nameFilters.Length == 0) + return true; + + foreach (var filter in _nameFilters) + { + if (filter.IsMatch(name)) + return true; + } + + return false; + } + + private bool MatchesContainer(string container) + { + if (_containerFilters.Length == 0) + return true; + + foreach (var filter in _containerFilters) + { + if (filter.IsMatch(container)) + return true; + } + + return false; + } + + private bool MatchesType(ClassIDType type) + { + if (_typeFilters.Length == 0) + return true; + + foreach (var filter in _typeFilters) + { + if (filter == type) + return true; + } + + return false; + } + + private bool MatchesType(string type) + { + if (_typeFilters.Length == 0) + return true; + + foreach (var filter in _typeFilters) + { + if (filter.ToString().Equals(type, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + } + #endregion #region Nested type: Entry From 18ba3907924cf63f002144817ffb7249c2824949 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 15:59:29 +0800 Subject: [PATCH 41/56] Split asset map collection flow --- AnimeStudio/AssetsHelper.cs | 309 ++++++++++++++++++++---------------- 1 file changed, 170 insertions(+), 139 deletions(-) diff --git a/AnimeStudio/AssetsHelper.cs b/AnimeStudio/AssetsHelper.cs index 58066720..f82f1cf7 100644 --- a/AnimeStudio/AssetsHelper.cs +++ b/AnimeStudio/AssetsHelper.cs @@ -328,7 +328,7 @@ public static async Task BuildAssetMap(string[] files, string mapName, Game game var filters = new AssetMapFilters(typeFilters, nameFilters, containerFilters); foreach (var file in LoadFiles(files)) { - BuildAssetMap(file, assets, filters); + CollectAssetMapEntries(file, assets, filters); } UpdateContainers(assets, game); @@ -342,13 +342,9 @@ public static async Task BuildAssetMap(string[] files, string mapName, Game game } - private static void BuildAssetMap(string file, List assets, AssetMapFilters filters) + private static void CollectAssetMapEntries(string file, List assets, AssetMapFilters filters) { - var matches = new List(); - var containers = new List<(PPtr, string)>(); - var mihoyoBinDataNames = new List<(PPtr, string)>(); - var objectAssetItemDic = new Dictionary(); - var animators = new List<(PPtr, AssetEntry)>(); + var context = new AssetMapBuildContext(file); foreach (var assetsFile in assetsManager.assetsFileList) { foreach (var objInfo in assetsFile.m_Objects) @@ -359,148 +355,166 @@ private static void BuildAssetMap(string file, List assets, AssetMap return; } var objectReader = new ObjectReader(assetsFile.reader, assetsFile, objInfo, assetsManager.Game); - var obj = new Object(objectReader); - var asset = new AssetEntry() - { - Source = file, - PathID = objectReader.m_PathID, - Type = objectReader.type, - Container = "", - Hash = obj.GetHash(), - Offset = assetsFile.offset - }; - - var exportable = false; - try - { - switch (objectReader.type) + ProcessAssetMapObject(assetsFile, objectReader, context); + } + } + + ApplyAnimatorNames(context); + ApplyMiHoYoBinDataNames(context); + ApplyAssetBundleContainers(context); + + assets.AddRange(context.Matches.Where(filters.Matches)); + } + + private static void ProcessAssetMapObject(SerializedFile assetsFile, ObjectReader objectReader, AssetMapBuildContext context) + { + var obj = new Object(objectReader); + var asset = new AssetEntry() + { + Source = context.File, + PathID = objectReader.m_PathID, + Type = objectReader.type, + Container = "", + Hash = obj.GetHash(), + Offset = assetsFile.offset + }; + + var exportable = false; + try + { + switch (objectReader.type) + { + case ClassIDType.AssetBundle when ClassIDType.AssetBundle.CanParse(): + var assetBundle = new AssetBundle(objectReader); + foreach (var m_Container in assetBundle.m_Container) { - case ClassIDType.AssetBundle when ClassIDType.AssetBundle.CanParse(): - var assetBundle = new AssetBundle(objectReader); - foreach (var m_Container in assetBundle.m_Container) - { - var preloadIndex = m_Container.Value.preloadIndex; - var preloadSize = m_Container.Value.preloadSize; - var preloadEnd = preloadIndex + preloadSize; - - string container = m_Container.Key; - var pathTest = Paths; - if(ulong.TryParse(container, out var hash) && Paths.TryGetValue(hash, out var path)) - { - container = path; - } - for (int k = preloadIndex; k < preloadEnd; k++) - { - containers.Add((assetBundle.m_PreloadTable[k], container)); - } - } + var preloadIndex = m_Container.Value.preloadIndex; + var preloadSize = m_Container.Value.preloadSize; + var preloadEnd = preloadIndex + preloadSize; - obj = null; - asset.Name = assetBundle.m_Name; - exportable = ClassIDType.AssetBundle.CanExport(); - break; - case ClassIDType.GameObject when ClassIDType.GameObject.CanParse(): - var gameObject = new GameObject(objectReader); - obj = gameObject; - asset.Name = gameObject.m_Name; - exportable = ClassIDType.GameObject.CanExport(); - break; - case ClassIDType.Shader when ClassIDType.Shader.CanParse(): - asset.Name = objectReader.ReadAlignedString(); - if (string.IsNullOrEmpty(asset.Name)) - { - var m_parsedForm = new SerializedShader(objectReader); - asset.Name = m_parsedForm.m_Name; - } + string container = m_Container.Key; + if(ulong.TryParse(container, out var hash) && Paths.TryGetValue(hash, out var path)) + { + container = path; + } + for (int k = preloadIndex; k < preloadEnd; k++) + { + context.Containers.Add((assetBundle.m_PreloadTable[k], container)); + } + } - exportable = ClassIDType.Shader.CanExport(); - break; - case ClassIDType.Animator when ClassIDType.Animator.CanParse(): - var component = new PPtr(objectReader); - animators.Add((component, asset)); - asset.Name = objectReader.type.ToString(); - exportable = ClassIDType.Animator.CanExport(); - break; - case ClassIDType.MiHoYoBinData when ClassIDType.MiHoYoBinData.CanParse(): - var MiHoYoBinData = new MiHoYoBinData(objectReader); - obj = MiHoYoBinData; - asset.Name = objectReader.type.ToString(); - exportable = ClassIDType.MiHoYoBinData.CanExport(); - break; - case ClassIDType.NapAssetBundleIndexAsset when ClassIDType.NapAssetBundleIndexAsset.CanParse(): - var NapAssetBundleIndexAsset = new NapAssetBundleIndexAsset(objectReader); - obj = NapAssetBundleIndexAsset; - asset.Name = obj.Name; - exportable = ClassIDType.NapAssetBundleIndexAsset.CanExport(); - break; - case ClassIDType.IndexObject when ClassIDType.IndexObject.CanParse(): - var indexObject = new IndexObject(objectReader); - obj = null; - foreach (var index in indexObject.AssetMap) - { - mihoyoBinDataNames.Add((index.Value.Object, index.Key)); - } - asset.Name = "IndexObject"; - exportable = ClassIDType.IndexObject.CanExport(); - break; - case ClassIDType.Font when ClassIDType.Font.CanExport(): - case ClassIDType.Material when ClassIDType.Material.CanExport(): - case ClassIDType.Texture when ClassIDType.Texture.CanExport(): - case ClassIDType.Mesh when ClassIDType.Mesh.CanExport(): - case ClassIDType.Sprite when ClassIDType.Sprite.CanExport(): - case ClassIDType.TextAsset when ClassIDType.TextAsset.CanExport(): - case ClassIDType.Texture2D when ClassIDType.Texture2D.CanExport(): - case ClassIDType.VideoClip when ClassIDType.VideoClip.CanExport(): - case ClassIDType.AudioClip when ClassIDType.AudioClip.CanExport(): - case ClassIDType.AnimationClip when ClassIDType.AnimationClip.CanExport(): - asset.Name = objectReader.ReadAlignedString(); - exportable = true; - break; - case ClassIDType.MonoBehaviour when ClassIDType.MonoBehaviour.CanParse(): - var monoBehaviour = new MonoBehaviour(objectReader); - asset.Name = String.IsNullOrWhiteSpace(monoBehaviour.Name) ? objectReader.type.ToString() : monoBehaviour.Name; - exportable = true; - break; - default: - asset.Name = objectReader.type.ToString(); - exportable = !Minimal; - break; + obj = null; + asset.Name = assetBundle.m_Name; + exportable = ClassIDType.AssetBundle.CanExport(); + break; + case ClassIDType.GameObject when ClassIDType.GameObject.CanParse(): + var gameObject = new GameObject(objectReader); + obj = gameObject; + asset.Name = gameObject.m_Name; + exportable = ClassIDType.GameObject.CanExport(); + break; + case ClassIDType.Shader when ClassIDType.Shader.CanParse(): + asset.Name = objectReader.ReadAlignedString(); + if (string.IsNullOrEmpty(asset.Name)) + { + var m_parsedForm = new SerializedShader(objectReader); + asset.Name = m_parsedForm.m_Name; } - } - catch (Exception e) - { - var sb = new StringBuilder(); - sb.AppendLine("Unable to load object") - .AppendLine($"Assets {assetsFile.fileName}") - .AppendLine($"Path {assetsFile.originalPath}") - .AppendLine($"Type {objectReader.type}") - .AppendLine($"PathID {objectReader.m_PathID}") - .Append(e); - Logger.Error(sb.ToString()); - } - if (obj != null) - { - objectAssetItemDic.Add(obj, asset); - assetsFile.AddObject(obj); - } - if (exportable) - { - matches.Add(asset); - } + + exportable = ClassIDType.Shader.CanExport(); + break; + case ClassIDType.Animator when ClassIDType.Animator.CanParse(): + var component = new PPtr(objectReader); + context.Animators.Add((component, asset)); + asset.Name = objectReader.type.ToString(); + exportable = ClassIDType.Animator.CanExport(); + break; + case ClassIDType.MiHoYoBinData when ClassIDType.MiHoYoBinData.CanParse(): + var MiHoYoBinData = new MiHoYoBinData(objectReader); + obj = MiHoYoBinData; + asset.Name = objectReader.type.ToString(); + exportable = ClassIDType.MiHoYoBinData.CanExport(); + break; + case ClassIDType.NapAssetBundleIndexAsset when ClassIDType.NapAssetBundleIndexAsset.CanParse(): + var NapAssetBundleIndexAsset = new NapAssetBundleIndexAsset(objectReader); + obj = NapAssetBundleIndexAsset; + asset.Name = obj.Name; + exportable = ClassIDType.NapAssetBundleIndexAsset.CanExport(); + break; + case ClassIDType.IndexObject when ClassIDType.IndexObject.CanParse(): + var indexObject = new IndexObject(objectReader); + obj = null; + foreach (var index in indexObject.AssetMap) + { + context.MiHoYoBinDataNames.Add((index.Value.Object, index.Key)); + } + asset.Name = "IndexObject"; + exportable = ClassIDType.IndexObject.CanExport(); + break; + case ClassIDType.Font when ClassIDType.Font.CanExport(): + case ClassIDType.Material when ClassIDType.Material.CanExport(): + case ClassIDType.Texture when ClassIDType.Texture.CanExport(): + case ClassIDType.Mesh when ClassIDType.Mesh.CanExport(): + case ClassIDType.Sprite when ClassIDType.Sprite.CanExport(): + case ClassIDType.TextAsset when ClassIDType.TextAsset.CanExport(): + case ClassIDType.Texture2D when ClassIDType.Texture2D.CanExport(): + case ClassIDType.VideoClip when ClassIDType.VideoClip.CanExport(): + case ClassIDType.AudioClip when ClassIDType.AudioClip.CanExport(): + case ClassIDType.AnimationClip when ClassIDType.AnimationClip.CanExport(): + asset.Name = objectReader.ReadAlignedString(); + exportable = true; + break; + case ClassIDType.MonoBehaviour when ClassIDType.MonoBehaviour.CanParse(): + var monoBehaviour = new MonoBehaviour(objectReader); + asset.Name = String.IsNullOrWhiteSpace(monoBehaviour.Name) ? objectReader.type.ToString() : monoBehaviour.Name; + exportable = true; + break; + default: + asset.Name = objectReader.type.ToString(); + exportable = !Minimal; + break; } } - foreach ((var pptr, var asset) in animators) + catch (Exception e) + { + var sb = new StringBuilder(); + sb.AppendLine("Unable to load object") + .AppendLine($"Assets {assetsFile.fileName}") + .AppendLine($"Path {assetsFile.originalPath}") + .AppendLine($"Type {objectReader.type}") + .AppendLine($"PathID {objectReader.m_PathID}") + .Append(e); + Logger.Error(sb.ToString()); + } + if (obj != null) + { + context.ObjectAssetEntries.Add(obj, asset); + assetsFile.AddObject(obj); + } + if (exportable) + { + context.Matches.Add(asset); + } + } + + private static void ApplyAnimatorNames(AssetMapBuildContext context) + { + foreach ((var pptr, var asset) in context.Animators) { if (pptr.TryGet(out var gameObject)) { asset.Name = gameObject.m_Name; } } - foreach ((var pptr, var name) in mihoyoBinDataNames) + } + + private static void ApplyMiHoYoBinDataNames(AssetMapBuildContext context) + { + foreach ((var pptr, var name) in context.MiHoYoBinDataNames) { if (pptr.TryGet(out var miHoYoBinData)) { - var asset = objectAssetItemDic[miHoYoBinData]; + var asset = context.ObjectAssetEntries[miHoYoBinData]; if (int.TryParse(name, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hash)) { asset.Name = name; @@ -509,15 +523,17 @@ private static void BuildAssetMap(string file, List assets, AssetMap else asset.Name = $"BinFile #{asset.PathID}"; } } - foreach ((var pptr, var container) in containers) + } + + private static void ApplyAssetBundleContainers(AssetMapBuildContext context) + { + foreach ((var pptr, var container) in context.Containers) { if (pptr.TryGet(out var obj)) { - objectAssetItemDic[obj].Container = container; + context.ObjectAssetEntries[obj].Container = container; } } - - assets.AddRange(matches.Where(filters.Matches)); } public static string[] ParseAssetMap @@ -748,7 +764,7 @@ public static async Task BuildBoth(string[] files, string mapName, string baseFo foreach(var file in LoadFiles(files)) { BuildCABMap(file, ref collision); - BuildAssetMap(file, assets, filters); + CollectAssetMapEntries(file, assets, filters); } UpdateContainers(assets, game); @@ -843,6 +859,21 @@ private bool MatchesType(string type) } } + private sealed class AssetMapBuildContext + { + public AssetMapBuildContext(string file) + { + File = file; + } + + public string File { get; } + public List Matches { get; } = new(); + public List<(PPtr, string)> Containers { get; } = new(); + public List<(PPtr, string)> MiHoYoBinDataNames { get; } = new(); + public Dictionary ObjectAssetEntries { get; } = new(); + public List<(PPtr, AssetEntry)> Animators { get; } = new(); + } + #endregion #region Nested type: Entry From 2869e464eecd9ad5c8dd29225bdc5c7ec309663d Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 16:00:22 +0800 Subject: [PATCH 42/56] Split asset map parsers --- AnimeStudio/AssetsHelper.cs | 148 +++++++++++++++++++----------------- 1 file changed, 78 insertions(+), 70 deletions(-) diff --git a/AnimeStudio/AssetsHelper.cs b/AnimeStudio/AssetsHelper.cs index f82f1cf7..f23212fa 100644 --- a/AnimeStudio/AssetsHelper.cs +++ b/AnimeStudio/AssetsHelper.cs @@ -549,91 +549,99 @@ public static string[] ParseAssetMap switch (mapType) { case ExportListType.MessagePack: - { - using var stream = File.OpenRead(mapName); - var assetMap = MessagePackSerializer.Deserialize(stream, MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray)); - foreach(var entry in assetMap.AssetEntries) - { - if (filters.Matches(entry)) - { - matches.Add(entry.Source); - } - } - } - + ParseMessagePackAssetMap(mapName, filters, matches); break; case ExportListType.XML: - { - using var stream = File.OpenRead(mapName); - using var reader = XmlReader.Create(stream); - reader.ReadToFollowing("Assets"); - reader.ReadToFollowing("Asset"); - do - { - reader.ReadToFollowing("Name"); - var name = reader.ReadInnerXml(); + ParseXmlAssetMap(mapName, filters, matches); + break; + case ExportListType.JSON: + ParseJsonAssetMap(mapName, filters, matches); + break; + case ExportListType.MemoryPack: + ParseMemoryPackAssetMap(mapName, filters, matches); + break; + } + + return matches.ToArray(); + } - reader.ReadToFollowing("Container"); - var container = reader.ReadInnerXml(); + private static void ParseMessagePackAssetMap(string mapName, AssetMapFilters filters, HashSet matches) + { + using var stream = File.OpenRead(mapName); + var assetMap = MessagePackSerializer.Deserialize(stream, MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray)); + foreach(var entry in assetMap.AssetEntries) + { + if (filters.Matches(entry)) + { + matches.Add(entry.Source); + } + } + } - reader.ReadToFollowing("Type"); - var type = reader.ReadInnerXml(); + private static void ParseXmlAssetMap(string mapName, AssetMapFilters filters, HashSet matches) + { + using var stream = File.OpenRead(mapName); + using var reader = XmlReader.Create(stream); + reader.ReadToFollowing("Assets"); + reader.ReadToFollowing("Asset"); + do + { + reader.ReadToFollowing("Name"); + var name = reader.ReadInnerXml(); - reader.ReadToFollowing("PathID"); - reader.ReadInnerXml(); + reader.ReadToFollowing("Container"); + var container = reader.ReadInnerXml(); - reader.ReadToFollowing("Source"); - var source = reader.ReadInnerXml(); + reader.ReadToFollowing("Type"); + var type = reader.ReadInnerXml(); - if (filters.Matches(name, container, type)) - { - matches.Add(source); - } + reader.ReadToFollowing("PathID"); + reader.ReadInnerXml(); - reader.ReadEndElement(); - } while (reader.ReadToNextSibling("Asset")); - } + reader.ReadToFollowing("Source"); + var source = reader.ReadInnerXml(); - break; - case ExportListType.JSON: - { - using var stream = File.OpenRead(mapName); - using var file = new StreamReader(stream); - using var reader = new JsonTextReader(file); + if (filters.Matches(name, container, type)) + { + matches.Add(source); + } - var serializer = new JsonSerializer { Formatting = Formatting.Indented }; - serializer.Converters.Add(new StringEnumConverter()); + reader.ReadEndElement(); + } while (reader.ReadToNextSibling("Asset")); + } - var entries = serializer.Deserialize>(reader); - foreach (var entry in entries) - { - if (filters.Matches(entry)) - { - matches.Add(entry.Source); - } - } - } - - break; - case ExportListType.MemoryPack: - { - using FileStream stream = File.OpenRead(mapName); - AssetMap assetMap = MemoryPackStreamingSerializer.DeserializeAsync - (stream).FirstAsync().GetAwaiter().GetResult(); + private static void ParseJsonAssetMap(string mapName, AssetMapFilters filters, HashSet matches) + { + using var stream = File.OpenRead(mapName); + using var file = new StreamReader(stream); + using var reader = new JsonTextReader(file); - foreach (AssetEntry entry in assetMap.AssetEntries) - { - if(entry == null) continue; + var serializer = new JsonSerializer { Formatting = Formatting.Indented }; + serializer.Converters.Add(new StringEnumConverter()); - if(filters.Matches(entry.Name ?? string.Empty, entry.Container ?? string.Empty, entry.Type)) - matches.Add(entry.Source ?? string.Empty); - } - + var entries = serializer.Deserialize>(reader); + foreach (var entry in entries) + { + if (filters.Matches(entry)) + { + matches.Add(entry.Source); } - break; } - - return matches.ToArray(); + } + + private static void ParseMemoryPackAssetMap(string mapName, AssetMapFilters filters, HashSet matches) + { + using FileStream stream = File.OpenRead(mapName); + AssetMap assetMap = MemoryPackStreamingSerializer.DeserializeAsync + (stream).FirstAsync().GetAwaiter().GetResult(); + + foreach (AssetEntry entry in assetMap.AssetEntries) + { + if(entry == null) continue; + + if(filters.Matches(entry.Name ?? string.Empty, entry.Container ?? string.Empty, entry.Type)) + matches.Add(entry.Source ?? string.Empty); + } } private static void UpdateContainers(List assets, Game game) From 01f538ef1e7284b3ace89d91a22aadb6ed1bc055 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 16:02:00 +0800 Subject: [PATCH 43/56] Split asset map exporters and containers --- AnimeStudio/AssetsHelper.cs | 161 ++++++++++++++++++++---------------- 1 file changed, 90 insertions(+), 71 deletions(-) diff --git a/AnimeStudio/AssetsHelper.cs b/AnimeStudio/AssetsHelper.cs index f23212fa..f6d25706 100644 --- a/AnimeStudio/AssetsHelper.cs +++ b/AnimeStudio/AssetsHelper.cs @@ -646,31 +646,37 @@ private static void ParseMemoryPackAssetMap(string mapName, AssetMapFilters filt private static void UpdateContainers(List assets, Game game) { - if (game.Type.IsGISubGroup() && assets.Count > 0) + if (!game.Type.IsGISubGroup() || assets.Count == 0) + return; + + Logger.Info("Updating Containers..."); + foreach (var asset in assets) { - Logger.Info("Updating Containers..."); - foreach (var asset in assets) + if (TryResolveUpdatedContainer(asset, out var path)) { - if (int.TryParse(asset.Container, out var value)) + asset.Container = path; + if (asset.Type == ClassIDType.MiHoYoBinData) { - var last = unchecked((uint)value); - var name = Path.GetFileNameWithoutExtension(asset.Source); - if (uint.TryParse(name, out var id)) - { - var path = ResourceIndex.GetContainer(id, last); - if (!string.IsNullOrEmpty(path)) - { - asset.Container = path; - if (asset.Type == ClassIDType.MiHoYoBinData) - { - asset.Name = Path.GetFileNameWithoutExtension(path); - } - } - } + asset.Name = Path.GetFileNameWithoutExtension(path); } } - Logger.Info("Updated !!"); } + Logger.Info("Updated !!"); + } + + private static bool TryResolveUpdatedContainer(AssetEntry asset, out string path) + { + path = null; + if (!int.TryParse(asset.Container, out var value)) + return false; + + var last = unchecked((uint)value); + var name = Path.GetFileNameWithoutExtension(asset.Source); + if (!uint.TryParse(name, out var id)) + return false; + + path = ResourceIndex.GetContainer(id, last); + return !string.IsNullOrEmpty(path); } private static Task ExportAssetsMap(List toExportAssets, Game game, string name, string savePath, ExportListType exportListType) @@ -682,7 +688,6 @@ private static Task ExportAssetsMap(List toExportAssets, Game game, Progress.Reset(); - string filename = string.Empty; if (exportListType.Equals(ExportListType.None)) { Logger.Info($"No export list type has been selected, skipping..."); @@ -691,67 +696,20 @@ private static Task ExportAssetsMap(List toExportAssets, Game game, { if (exportListType.HasFlag(ExportListType.XML)) { - filename = Path.Combine(savePath, $"{name}.xml"); - var xmlSettings = new XmlWriterSettings() { Indent = true }; - using XmlWriter writer = XmlWriter.Create(filename, xmlSettings); - writer.WriteStartDocument(); - writer.WriteStartElement("Assets"); - writer.WriteAttributeString("filename", filename); - writer.WriteAttributeString("createdAt", DateTime.UtcNow.ToString("s")); - foreach (var asset in toExportAssets) - { - writer.WriteStartElement("Asset"); - writer.WriteElementString("Name", asset.Name); - writer.WriteElementString("Container", asset.Container); - writer.WriteStartElement("Type"); - writer.WriteAttributeString("id", ((int)asset.Type).ToString()); - writer.WriteValue(asset.Type.ToString()); - writer.WriteEndElement(); - writer.WriteElementString("PathID", asset.PathID.ToString()); - writer.WriteElementString("Source", asset.Source); - writer.WriteEndElement(); - } - writer.WriteEndElement(); - writer.WriteEndDocument(); + WriteXmlAssetMap(toExportAssets, name, savePath); } if (exportListType.HasFlag(ExportListType.JSON)) { - filename = Path.Combine(savePath, $"{name}.json"); - using StreamWriter file = File.CreateText(filename); - var serializer = new JsonSerializer { Formatting = Formatting.Indented }; - serializer.Converters.Add(new StringEnumConverter()); - serializer.Serialize(file, new - { - GameType = game.Type, - AssetEntries = toExportAssets - }); + WriteJsonAssetMap(toExportAssets, game, name, savePath); } if (exportListType.HasFlag(ExportListType.MessagePack)) { - filename = Path.Combine(savePath, $"{name}.map"); - using var file = File.Create(filename); - var assetMap = new AssetMap - { - GameType = game.Type, - AssetEntries = toExportAssets - }; - MessagePackSerializer.Serialize(file, assetMap, MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray)); + WriteMessagePackAssetMap(toExportAssets, game, name, savePath); } if(exportListType.HasFlag(ExportListType.MemoryPack)) { - filename = Path.Combine(savePath, $"{name}.memory"); - var assetMap = new AssetMap - { - GameType = game.Type, - AssetEntries = toExportAssets - }; - - var assetMaps = new List(); - assetMaps.Add(assetMap); - - byte[] data = MemoryPackSerializer.Serialize(assetMaps); - File.WriteAllBytes(filename, data); + WriteMemoryPackAssetMap(toExportAssets, game, name, savePath); } Logger.Info($"Finished buidling AssetMap with {toExportAssets.Count} assets."); @@ -759,6 +717,67 @@ private static Task ExportAssetsMap(List toExportAssets, Game game, }); } + private static void WriteXmlAssetMap(List toExportAssets, string name, string savePath) + { + var filename = Path.Combine(savePath, $"{name}.xml"); + var xmlSettings = new XmlWriterSettings() { Indent = true }; + using XmlWriter writer = XmlWriter.Create(filename, xmlSettings); + writer.WriteStartDocument(); + writer.WriteStartElement("Assets"); + writer.WriteAttributeString("filename", filename); + writer.WriteAttributeString("createdAt", DateTime.UtcNow.ToString("s")); + foreach (var asset in toExportAssets) + { + writer.WriteStartElement("Asset"); + writer.WriteElementString("Name", asset.Name); + writer.WriteElementString("Container", asset.Container); + writer.WriteStartElement("Type"); + writer.WriteAttributeString("id", ((int)asset.Type).ToString()); + writer.WriteValue(asset.Type.ToString()); + writer.WriteEndElement(); + writer.WriteElementString("PathID", asset.PathID.ToString()); + writer.WriteElementString("Source", asset.Source); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + + private static void WriteJsonAssetMap(List toExportAssets, Game game, string name, string savePath) + { + var filename = Path.Combine(savePath, $"{name}.json"); + using StreamWriter file = File.CreateText(filename); + var serializer = new JsonSerializer { Formatting = Formatting.Indented }; + serializer.Converters.Add(new StringEnumConverter()); + serializer.Serialize(file, CreateAssetMap(game, toExportAssets)); + } + + private static void WriteMessagePackAssetMap(List toExportAssets, Game game, string name, string savePath) + { + var filename = Path.Combine(savePath, $"{name}.map"); + using var file = File.Create(filename); + MessagePackSerializer.Serialize(file, CreateAssetMap(game, toExportAssets), MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray)); + } + + private static void WriteMemoryPackAssetMap(List toExportAssets, Game game, string name, string savePath) + { + var filename = Path.Combine(savePath, $"{name}.memory"); + var assetMaps = new List(); + assetMaps.Add(CreateAssetMap(game, toExportAssets)); + + byte[] data = MemoryPackSerializer.Serialize(assetMaps); + File.WriteAllBytes(filename, data); + } + + private static AssetMap CreateAssetMap(Game game, List assetEntries) + { + return new AssetMap + { + GameType = game.Type, + AssetEntries = assetEntries + }; + } + public static async Task BuildBoth(string[] files, string mapName, string baseFolder, Game game, string savePath, ExportListType exportListType, ClassIDType[] typeFilters = null, Regex[] nameFilters = null, Regex[] containerFilters = null) { Logger.Info($"Building Both..."); From c6fc1ae30402dde26286673340bf7e91023a88a6 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 18:15:39 +0800 Subject: [PATCH 44/56] test --- AnimeStudio.GUI/AssetBrowser.cs | 11 ++- AnimeStudio.GUI/MainForm.cs | 2 +- AnimeStudio/AssetMapSqlite.cs | 138 ++++++++++++++++++++++++++++++++ AnimeStudio/AssetsHelper.cs | 65 ++++++++++----- AnimeStudio/ExportTypeList.cs | 3 +- AnimeStudio/ResourceMap.cs | 6 +- 6 files changed, 199 insertions(+), 26 deletions(-) create mode 100644 AnimeStudio/AssetMapSqlite.cs diff --git a/AnimeStudio.GUI/AssetBrowser.cs b/AnimeStudio.GUI/AssetBrowser.cs index 5efa5cbc..25db9226 100644 --- a/AnimeStudio.GUI/AssetBrowser.cs +++ b/AnimeStudio.GUI/AssetBrowser.cs @@ -45,8 +45,9 @@ private async void loadAssetMap_Click(object sender, EventArgs e) var openFileDialog = new OpenFileDialog { - Multiselect = false, Filter = "MessagePack AssetMap File|*" + - ".map|JSON AssetMap File|*.json|MemoryPack AssetMap File|*.memory" + Multiselect = false, + Filter + = "MessagePack AssetMap File|*.map|JSON AssetMap File|*.json|MemoryPack AssetMap File|*.memory|SQLite AssetMap File|*.sqlite" }; if (openFileDialog.ShowDialog(this) == DialogResult.OK) { @@ -586,7 +587,11 @@ private async void loadMapTwoBtn_Click(object sender, EventArgs e) { loadMapTwoBtn.Enabled = false; - var openFileDialog = new OpenFileDialog() { Multiselect = false, Filter = "MessagePack AssetMap File|*.map|JSON AssetMap File|*.json" }; + var openFileDialog = new OpenFileDialog + { + Multiselect = false, + Filter = "MessagePack AssetMap File|*.map|JSON AssetMap File|*.json|SQLite AssetMap File|*.sqlite" + }; if (openFileDialog.ShowDialog(this) == DialogResult.OK) { try diff --git a/AnimeStudio.GUI/MainForm.cs b/AnimeStudio.GUI/MainForm.cs index 65053584..10b1677c 100644 --- a/AnimeStudio.GUI/MainForm.cs +++ b/AnimeStudio.GUI/MainForm.cs @@ -2647,7 +2647,7 @@ private async void buildAssetMapToolStripMenuItem_Click(object sender, EventArgs var saveFileDialog = new SaveFileDialog() { - Filter = "Map file (*.map)|*.map|MemoryPack AssetMap File|*.memory", + Filter = "Map file (*.map)|*.map|MemoryPack AssetMap File|*.memory|SQLite AssetMap File|*.sqlite", DefaultExt = "map", Title = "Select Output File (format will auto adjust according to what you selected)", InitialDirectory = saveDirectory, diff --git a/AnimeStudio/AssetMapSqlite.cs b/AnimeStudio/AssetMapSqlite.cs new file mode 100644 index 00000000..3bca4f80 --- /dev/null +++ b/AnimeStudio/AssetMapSqlite.cs @@ -0,0 +1,138 @@ +using System.Collections.Generic; +using Microsoft.Data.Sqlite; + +namespace AnimeStudio; + +public static class AssetMapSqlite +{ + public static void Save(string path, AssetMap assetMap) + { + using var connection = new SqliteConnection($"Data Source={path}"); + connection.Open(); + + using SqliteTransaction transaction = connection.BeginTransaction(); + + Execute + (connection, + """ + + DROP TABLE IF EXISTS metadata; + DROP TABLE IF EXISTS asset_entries; + + CREATE TABLE metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE asset_entries ( + id INTEGER PRIMARY KEY, + name TEXT, + container TEXT, + source TEXT, + path_id INTEGER NOT NULL, + type INTEGER NOT NULL, + hash TEXT, + offset INTEGER NOT NULL + ); + """); + InsertMetadata(connection, "schema_version", "1"); + InsertMetadata(connection, "game_type", ((int) assetMap.GameType).ToString()); + + + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = + """ + INSERT INTO asset_entries + + (id, name, container, source, path_id, type, hash, offset) + VALUES + ($id, $name, $container, $source, $path_id, $type, $hash, $offset) + """; + + SqliteParameter id = command.Parameters.Add("$id", SqliteType.Integer); + SqliteParameter name = command.Parameters.Add("$name", SqliteType.Text); + SqliteParameter container = command.Parameters.Add("$container", SqliteType.Text); + SqliteParameter source = command.Parameters.Add("$source", SqliteType.Text); + SqliteParameter pathId = command.Parameters.Add("$path_id", SqliteType.Integer); + SqliteParameter type = command.Parameters.Add("$type", SqliteType.Integer); + SqliteParameter hash = command.Parameters.Add("$hash", SqliteType.Text); + SqliteParameter offset = command.Parameters.Add("$offset", SqliteType.Integer); + + for(var i = 0; i < assetMap.AssetEntries.Count; i++) + { + AssetEntry entry = assetMap.AssetEntries[i]; + id.Value = i; + name.Value = entry.Name ?? string.Empty; + container.Value = entry.Container ?? string.Empty; + source.Value = entry.Source ?? string.Empty; + pathId.Value = entry.PathID; + type.Value = (int) entry.Type; + hash.Value = entry.Hash ?? string.Empty; + offset.Value = entry.Offset; + command.ExecuteNonQuery(); + } + + transaction.Commit(); + } + + public static AssetMap Load(string path) + { + using var connection = new SqliteConnection($"Data Source={path};Mode=ReadOnly"); + connection.Open(); + + var gameType = (GameType) int.Parse(GetMetadata(connection, "game_type")); + var entries = new List(); + + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = """ + SELECT name, container, source, path_id, type, hash, offset + FROM asset_entries + ORDER BY id + """; + + using SqliteDataReader reader = command.ExecuteReader(); + while (reader.Read()) + entries.Add + (new AssetEntry + { + Name = reader.GetString(0), + Container = reader.GetString(1), + Source = reader.GetString(2), + PathID = reader.GetInt64(3), + Type = (ClassIDType) reader.GetInt32(4), + Hash = reader.GetString(5), + Offset = reader.GetInt64(6) + }); + + return new AssetMap + { + GameType = gameType, + AssetEntries = entries + }; + } + + + private static void Execute(SqliteConnection connection, string sql) + { + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + private static void InsertMetadata(SqliteConnection connection, string key, string value) + { + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "INSERT INTO metadata (key, value) VALUES ($key, $value)"; + command.Parameters.AddWithValue("$key", key); + command.Parameters.AddWithValue("$value", value); + command.ExecuteNonQuery(); + } + + private static string GetMetadata(SqliteConnection connection, string key) + { + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "SELECT value FROM metadata WHERE key = $key"; + command.Parameters.AddWithValue("$key", key); + return (string) command.ExecuteScalar(); + } +} \ No newline at end of file diff --git a/AnimeStudio/AssetsHelper.cs b/AnimeStudio/AssetsHelper.cs index f6d25706..e77327bf 100644 --- a/AnimeStudio/AssetsHelper.cs +++ b/AnimeStudio/AssetsHelper.cs @@ -315,6 +315,17 @@ private static void ParseCABMap(BinaryReader reader) } } + #region Nested type: Entry + + public record Entry + { + public string Path { get; set; } + public long Offset { get; set; } + public List Dependencies { get; set; } + } + + #endregion + #region AssetMap public static async Task BuildAssetMap(string[] files, string mapName, Game game, string savePath, ExportListType exportListType, ClassIDType[] typeFilters = null, Regex[] nameFilters = null, Regex[] containerFilters = null) @@ -560,6 +571,9 @@ public static string[] ParseAssetMap case ExportListType.MemoryPack: ParseMemoryPackAssetMap(mapName, filters, matches); break; + case ExportListType.SQLite: + ParseSqliteAssetMap(mapName, filters, matches); + break; } return matches.ToArray(); @@ -644,6 +658,14 @@ private static void ParseMemoryPackAssetMap(string mapName, AssetMapFilters filt } } + private static void ParseSqliteAssetMap(string mapName, AssetMapFilters filters, HashSet matches) + { + AssetMap assetMap = AssetMapSqlite.Load(mapName); + foreach (AssetEntry entry in assetMap.AssetEntries) + if(filters.Matches(entry)) + matches.Add(entry.Source); + } + private static void UpdateContainers(List assets, Game game) { if (!game.Type.IsGISubGroup() || assets.Count == 0) @@ -706,12 +728,19 @@ private static Task ExportAssetsMap(List toExportAssets, Game game, { WriteMessagePackAssetMap(toExportAssets, game, name, savePath); } - if(exportListType.HasFlag(ExportListType.MemoryPack)) { WriteMemoryPackAssetMap(toExportAssets, game, name, savePath); } + if(exportListType.HasFlag + (ExportListType.SQLite)) + WriteSqliteAssetMap + (toExportAssets, + game, + name, + savePath); + Logger.Info($"Finished buidling AssetMap with {toExportAssets.Count} assets."); } }); @@ -769,6 +798,13 @@ private static void WriteMemoryPackAssetMap(List toExportAssets, Gam File.WriteAllBytes(filename, data); } + private static void WriteSqliteAssetMap + (List toExportAssets, Game game, string name, string savePath) + { + string filename = Path.Combine(savePath, $"{name}.sqlite"); + AssetMapSqlite.Save(filename, CreateAssetMap(game, toExportAssets)); + } + private static AssetMap CreateAssetMap(Game game, List assetEntries) { return new AssetMap @@ -803,9 +839,9 @@ public static async Task BuildBoth(string[] files, string mapName, string baseFo private sealed class AssetMapFilters { + private readonly Regex[] _containerFilters; + private readonly Regex[] _nameFilters; private readonly ClassIDType[] _typeFilters; - private readonly Regex[] _nameFilters; - private readonly Regex[] _containerFilters; public AssetMapFilters(ClassIDType[] typeFilters, Regex[] nameFilters, Regex[] containerFilters) { @@ -893,23 +929,12 @@ public AssetMapBuildContext(string file) File = file; } - public string File { get; } - public List Matches { get; } = new(); - public List<(PPtr, string)> Containers { get; } = new(); - public List<(PPtr, string)> MiHoYoBinDataNames { get; } = new(); - public Dictionary ObjectAssetEntries { get; } = new(); - public List<(PPtr, AssetEntry)> Animators { get; } = new(); - } - - #endregion - - #region Nested type: Entry - - public record Entry - { - public string Path { get; set; } - public long Offset { get; set; } - public List Dependencies { get; set; } + public string File { get; } + public List Matches { get; } = new List(); + public List<(PPtr, string)> Containers { get; } = new List<(PPtr, string)>(); + public List<(PPtr, string)> MiHoYoBinDataNames { get; } = new List<(PPtr, string)>(); + public Dictionary ObjectAssetEntries { get; } = new Dictionary(); + public List<(PPtr, AssetEntry)> Animators { get; } = new List<(PPtr, AssetEntry)>(); } #endregion diff --git a/AnimeStudio/ExportTypeList.cs b/AnimeStudio/ExportTypeList.cs index 71adfddf..16e23081 100644 --- a/AnimeStudio/ExportTypeList.cs +++ b/AnimeStudio/ExportTypeList.cs @@ -9,6 +9,7 @@ public enum ExportListType MessagePack, XML, JSON = 4, - MemoryPack = 8 + MemoryPack = 8, + SQLite = 16 } } diff --git a/AnimeStudio/ResourceMap.cs b/AnimeStudio/ResourceMap.cs index 298f52b0..4659fd5a 100644 --- a/AnimeStudio/ResourceMap.cs +++ b/AnimeStudio/ResourceMap.cs @@ -49,7 +49,6 @@ public static int FromFile(string path) (MessagePackCompression.Lz4BlockArray)); break; } - case ".json": { // Deserialize json @@ -74,6 +73,11 @@ public static int FromFile(string path) newMap = assetMap; break; } + case ".sqlite": + { + newMap = AssetMapSqlite.Load(path); + break; + } } if (newMap == null) From 244836b125851e08e51c38e2cc2bc93b39e68eb4 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 18:20:44 +0800 Subject: [PATCH 45/56] GetRawData --- AnimeStudio/Classes/Object.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AnimeStudio/Classes/Object.cs b/AnimeStudio/Classes/Object.cs index 16731f5c..b50bbfbb 100644 --- a/AnimeStudio/Classes/Object.cs +++ b/AnimeStudio/Classes/Object.cs @@ -109,8 +109,8 @@ public byte[] GetRawData() { Logger.Verbose($"Dumping raw bytes of the object with {m_PathID} in file {assetsFile.fileName}..."); long pos = reader.Position; - reader.Position = 0; - byte[] data = reader.ReadBytes((int) reader.Remaining); + reader.Reset(); + byte[] data = reader.ReadBytes((int) byteSize); reader.Position = pos; return data; } From b0c35b1373b576d1c14a00f0c0cfa492e6d48b8d Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 18:21:23 +0800 Subject: [PATCH 46/56] GetRawData --- AnimeStudio/Classes/Object.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AnimeStudio/Classes/Object.cs b/AnimeStudio/Classes/Object.cs index 16731f5c..b50bbfbb 100644 --- a/AnimeStudio/Classes/Object.cs +++ b/AnimeStudio/Classes/Object.cs @@ -109,8 +109,8 @@ public byte[] GetRawData() { Logger.Verbose($"Dumping raw bytes of the object with {m_PathID} in file {assetsFile.fileName}..."); long pos = reader.Position; - reader.Position = 0; - byte[] data = reader.ReadBytes((int) reader.Remaining); + reader.Reset(); + byte[] data = reader.ReadBytes((int) byteSize); reader.Position = pos; return data; } From e4f304b259df1a5d1b8b4898d367f29f80fe8816 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 18:27:56 +0800 Subject: [PATCH 47/56] Avoid pre-opening SQLite asset maps --- AnimeStudio/ResourceMap.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/AnimeStudio/ResourceMap.cs b/AnimeStudio/ResourceMap.cs index 4659fd5a..4cf91c82 100644 --- a/AnimeStudio/ResourceMap.cs +++ b/AnimeStudio/ResourceMap.cs @@ -34,15 +34,15 @@ public static int FromFile(string path) Logger.Info(string.Format("Parsing....")); try { - string extension = Path.GetExtension(path).ToLower(); - using FileStream stream = File.OpenRead(path); - AssetMap newMap = null; + string extension = Path.GetExtension(path).ToLower(); + AssetMap newMap = null; switch(extension) { case ".map": { // Deserialize map + using FileStream stream = File.OpenRead(path); newMap = MessagePackSerializer.Deserialize (stream, MessagePackSerializerOptions.Standard.WithCompression @@ -52,6 +52,7 @@ public static int FromFile(string path) case ".json": { // Deserialize json + using FileStream stream = File.OpenRead(path); using var reader = new StreamReader(stream); string jsonContent = reader.ReadToEnd(); AssetMap parsed = JsonConvert.DeserializeObject(jsonContent); From 5061f8081f2dd4dd055e2b7b4a7aed93185b2865 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 19:04:38 +0800 Subject: [PATCH 48/56] Optimize binary reader byte reads --- AnimeStudio/EndianBinaryReader.cs | 35 +++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/AnimeStudio/EndianBinaryReader.cs b/AnimeStudio/EndianBinaryReader.cs index dd6246ed..323e87ed 100644 --- a/AnimeStudio/EndianBinaryReader.cs +++ b/AnimeStudio/EndianBinaryReader.cs @@ -111,28 +111,41 @@ public override double ReadDouble() } public override byte[] ReadBytes(int count) { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + if (count == 0) { return Array.Empty(); } - var buffer = ArrayPool.Shared.Rent(0x1000); - List result = new List(count); - do + var result = new byte[count]; + var offset = 0; + while (offset < count) { - var readNum = Math.Min(count, buffer.Length); - int n = Read(buffer, 0, readNum); - if (n == 0) + var read = Read(result, offset, count - offset); + if (read == 0) { break; } - result.AddRange(buffer[..n]); - count -= n; - } while (count > 0); + offset += read; + } + + if (offset == count) + { + return result; + } + + if (offset == 0) + { + return Array.Empty(); + } - ArrayPool.Shared.Return(buffer); - return result.ToArray(); + Array.Resize(ref result, offset); + return result; } public void AlignStream() From 2634eb5e32c3067fbe03e414dcfccd0fa659d327 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 19:04:49 +0800 Subject: [PATCH 49/56] Pool stream copy buffers --- AnimeStudio/Extensions/StreamExtensions.cs | 24 ++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/AnimeStudio/Extensions/StreamExtensions.cs b/AnimeStudio/Extensions/StreamExtensions.cs index 68eb0d05..0cac4dd9 100644 --- a/AnimeStudio/Extensions/StreamExtensions.cs +++ b/AnimeStudio/Extensions/StreamExtensions.cs @@ -1,4 +1,5 @@ -using System.IO; +using System.Buffers; +using System.IO; namespace AnimeStudio { @@ -8,17 +9,24 @@ public static class StreamExtensions public static void CopyTo(this Stream source, Stream destination, long size) { - var buffer = new byte[BufferSize]; - for (var left = size; left > 0; left -= BufferSize) + var buffer = ArrayPool.Shared.Rent(BufferSize); + try { - int toRead = BufferSize < left ? BufferSize : (int)left; - int read = source.Read(buffer, 0, toRead); - destination.Write(buffer, 0, read); - if (read != toRead) + for (var left = size; left > 0; left -= BufferSize) { - return; + int toRead = BufferSize < left ? BufferSize : (int)left; + int read = source.Read(buffer, 0, toRead); + destination.Write(buffer, 0, read); + if (read != toRead) + { + return; + } } } + finally + { + ArrayPool.Shared.Return(buffer); + } } public static void AlignStream(this Stream stream) From 06ec28a120daadc955a10b587ebe3c419d974a69 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 20:02:26 +0800 Subject: [PATCH 50/56] Dispose duplicate resource readers --- AnimeStudio/AssetsManager.cs | 19 +++++++++++++++---- AnimeStudio/ResourceReader.cs | 8 ++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/AnimeStudio/AssetsManager.cs b/AnimeStudio/AssetsManager.cs index 0b3b087a..9873d82e 100644 --- a/AnimeStudio/AssetsManager.cs +++ b/AnimeStudio/AssetsManager.cs @@ -62,6 +62,17 @@ public bool Equals(AssetFilterDataItem? d1, AssetFilterDataItem? d2) public Dictionary> OffsetData = new(); + internal bool CacheResourceReader(string name, BinaryReader reader) + { + if (resourceFileReaders.TryAdd(name, reader)) + { + return true; + } + + reader.Dispose(); + return false; + } + public void LoadFiles(params string[] files) { if (Silent) @@ -292,7 +303,7 @@ private void LoadAssetsFromMemory(FileReader reader, string originalPath, string catch (Exception e) { Logger.Error($"Error while reading assets file {reader.FullPath} from {Path.GetFileName(originalPath)}", e); - resourceFileReaders.TryAdd(reader.FileName, reader); + CacheResourceReader(reader.FileName, reader); } } else @@ -322,7 +333,7 @@ private void LoadWebFile(FileReader reader) break; case FileType.ResourceFile: Logger.Verbose("Caching resource stream"); - resourceFileReaders.TryAdd(file.fileName, subReader); //TODO + CacheResourceReader(file.fileName, subReader); //TODO break; } } @@ -415,7 +426,7 @@ private void LoadZipFile(FileReader reader) { entryReader.Position = 0; Logger.Verbose("Caching resource file"); - resourceFileReaders.TryAdd(entry.Name, entryReader); + CacheResourceReader(entry.Name, entryReader); } } catch (Exception e) @@ -538,7 +549,7 @@ private void LoadGameBlockFile(FileReader reader, string originalPath = null, lo else { Logger.Verbose("Caching resource stream"); - resourceFileReaders.TryAdd(innerFile.fileName, cabReader); //TODO + CacheResourceReader(innerFile.fileName, cabReader); //TODO } } } diff --git a/AnimeStudio/ResourceReader.cs b/AnimeStudio/ResourceReader.cs index 14774559..86d7568e 100644 --- a/AnimeStudio/ResourceReader.cs +++ b/AnimeStudio/ResourceReader.cs @@ -52,8 +52,12 @@ private BinaryReader GetReader() if (File.Exists(resourceFilePath)) { needSearch = false; - reader = new BinaryReader(File.OpenRead(resourceFilePath)); - assetsFile.assetsManager.resourceFileReaders.TryAdd(resourceFileName, reader); + var newReader = new BinaryReader(File.OpenRead(resourceFilePath)); + if (!assetsFile.assetsManager.CacheResourceReader(resourceFileName, newReader)) + { + assetsFile.assetsManager.resourceFileReaders.TryGetValue(resourceFileName, out newReader); + } + reader = newReader; return reader; } throw new FileNotFoundException($"Can't find the resource file {resourceFileName}"); From 697bfab291fe87f041909c6e6eb97c7317dddec1 Mon Sep 17 00:00:00 2001 From: bbdd Date: Mon, 29 Jun 2026 20:12:37 +0800 Subject: [PATCH 51/56] Prevent overlapping asset browser loads --- AnimeStudio.GUI/AssetBrowser.cs | 62 +++++++++++++++++++-------------- AnimeStudio.GUI/MainForm.cs | 44 +++++++++++++++-------- 2 files changed, 65 insertions(+), 41 deletions(-) diff --git a/AnimeStudio.GUI/AssetBrowser.cs b/AnimeStudio.GUI/AssetBrowser.cs index 25db9226..ff33c1fc 100644 --- a/AnimeStudio.GUI/AssetBrowser.cs +++ b/AnimeStudio.GUI/AssetBrowser.cs @@ -186,36 +186,44 @@ private void clear_Click(object sender, EventArgs e) Logger.Info($"Cleared !!"); } - private void loadSelected_Click(object sender, EventArgs e) + private async void loadSelected_Click(object sender, EventArgs e) { - var files = assetDataGridView.SelectedRows.Cast() - .Select(x => _assetEntries[x.Index]) - .Where(entry => entry != null) - .Select(entry => new AssetFilterDataItem - { - Source = entry.Source, - Type = entry.Type, - Name = entry.Name, - PathID = entry.PathID, - Offset = entry.Offset - }) - .ToList(); - - var filePaths = files.Select(x => x.Source).ToHashSet(); - - var missingFiles = filePaths.Where(x => !File.Exists(x)); - foreach (var file in missingFiles) - { - Logger.Warning($"Unable to find file {file}, skipping..."); - filePaths.Remove(file); - files.RemoveAll(x => x.Source == file); + loadSelected.Enabled = false; + try + { + var files = assetDataGridView.SelectedRows.Cast() + .Select(x => _assetEntries[x.Index]) + .Where(entry => entry != null) + .Select(entry => new AssetFilterDataItem + { + Source = entry.Source, + Type = entry.Type, + Name = entry.Name, + PathID = entry.PathID, + Offset = entry.Offset + }) + .ToList(); + + var filePaths = files.Select(x => x.Source).ToHashSet(); + + var missingFiles = filePaths.Where(x => !File.Exists(x)); + foreach (var file in missingFiles) + { + Logger.Warning($"Unable to find file {file}, skipping..."); + filePaths.Remove(file); + files.RemoveAll(x => x.Source == file); + } + if (filePaths.Count != 0 && !filePaths.Any(string.IsNullOrEmpty)) + { + Logger.Info("Loading..."); + bringMainToFront(); + _parent.Invoke(() => _parent.updateGame(ResourceMap.GetGameType())); + await _parent.LoadPaths(files, filePaths.ToArray()); + } } - if (filePaths.Count != 0 && !filePaths.Any(string.IsNullOrEmpty)) + finally { - Logger.Info("Loading..."); - bringMainToFront(); - _parent.Invoke(() => _parent.updateGame(ResourceMap.GetGameType())); - _parent.Invoke(() => _parent.LoadPaths(files, filePaths.ToArray())); + updateButtons(); } } diff --git a/AnimeStudio.GUI/MainForm.cs b/AnimeStudio.GUI/MainForm.cs index 10b1677c..a6f0c792 100644 --- a/AnimeStudio.GUI/MainForm.cs +++ b/AnimeStudio.GUI/MainForm.cs @@ -287,7 +287,7 @@ private async void MainForm_DragDrop(object sender, DragEventArgs e) var paths = (string[])e.Data.GetData(DataFormats.FileDrop); if (paths.Length > 0) { - LoadPaths(null, paths); + await LoadPaths(null, paths); } } @@ -338,27 +338,43 @@ private bool SizeWarning(long totalSize) return true; } - public async void LoadPaths(List filterData, params string[] paths) + private bool isLoadingPaths; + + public async Task LoadPaths(List filterData, params string[] paths) { + if (isLoadingPaths) + { + Logger.Warning("A load operation is already running, skipping this request..."); + return; + } + long totalSize = GetTotalSize(paths); if (!SizeWarning(totalSize)) return; - ResetForm(); - assetsManager.SpecifyUnityVersion = specifyUnityVersion.Text; - assetsManager.Game = Studio.Game; - if (filterData != null) - { - assetsManager.FilterData = new AssetFilterData { Items = filterData }; - } - if (paths.Length == 1 && Directory.Exists(paths[0])) + isLoadingPaths = true; + try { - await Task.Run(() => assetsManager.LoadFolder(paths[0])); + ResetForm(); + assetsManager.SpecifyUnityVersion = specifyUnityVersion.Text; + assetsManager.Game = Studio.Game; + if (filterData != null) + { + assetsManager.FilterData = new AssetFilterData { Items = filterData }; + } + if (paths.Length == 1 && Directory.Exists(paths[0])) + { + await Task.Run(() => assetsManager.LoadFolder(paths[0])); + } + else + { + await Task.Run(() => assetsManager.LoadFiles(paths)); + } + BuildAssetStructures(); } - else + finally { - await Task.Run(() => assetsManager.LoadFiles(paths)); + isLoadingPaths = false; } - BuildAssetStructures(); } private async void loadFile_Click(object sender, EventArgs e) From 37931372fc86a44fe32a97a2c052faa838246e27 Mon Sep 17 00:00:00 2001 From: bbdd <179790579+bbdd2729@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:04:42 +0800 Subject: [PATCH 52/56] fix AssetsHelper.cs fix EndianBinaryReader.cs --- AnimeStudio/AssetsHelper.cs | 2 ++ AnimeStudio/EndianBinaryReader.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/AnimeStudio/AssetsHelper.cs b/AnimeStudio/AssetsHelper.cs index 2a37d525..d9f486cd 100644 --- a/AnimeStudio/AssetsHelper.cs +++ b/AnimeStudio/AssetsHelper.cs @@ -1,5 +1,7 @@ using System; using System.Buffers; +using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text; diff --git a/AnimeStudio/EndianBinaryReader.cs b/AnimeStudio/EndianBinaryReader.cs index c7e5a309..64c9c9f5 100644 --- a/AnimeStudio/EndianBinaryReader.cs +++ b/AnimeStudio/EndianBinaryReader.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Buffers.Binary; using System.Collections.Generic; using System.IO; @@ -108,6 +109,7 @@ public override double ReadDouble() } return base.ReadDouble(); } + public override byte[] ReadBytes(int count) { if (count == 0) From a23c6959120a283ee167ad84a846fc1ffc45c516 Mon Sep 17 00:00:00 2001 From: bbdd <179790579+bbdd2729@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:57:43 +0800 Subject: [PATCH 53/56] fix: align AssetMap serialization formats Add the AnimeStudio.Test xUnit project and register it in the solution. Cover MessagePack and MemoryPack round trips, file loading, filtering, cache behavior, streaming MessagePack compatibility, and canonical file extensions. Centralize AssetMap extension and save-filter mapping, make MemoryPack builds use the complete writer path, and keep null-valued map fields safe during filtering. Expose the existing AssetMap writer through an internal test seam without changing its public API. --- AnimeStudio.Test/AnimeStudio.Test.csproj | 26 +++ AnimeStudio.Test/AssemblyInfo.cs | 3 + AnimeStudio.Test/AssetMapFileFormatTests.cs | 29 +++ AnimeStudio.Test/AssetMapFileWorkflowTests.cs | 92 +++++++++ .../AssetMapSerializationTests.cs | 89 +++++++++ AnimeStudio.Test/AssetMapTestData.cs | 47 +++++ AnimeStudio.Test/ResourceMapTests.cs | 180 ++++++++++++++++++ AnimeStudio.slnx | 1 + AnimeStudio/AssetMapFileFormat.cs | 59 ++++++ AnimeStudio/AssetsHelper.cs | 33 ++-- AnimeStudio/Properties/AssemblyInfo.cs | 3 + 11 files changed, 548 insertions(+), 14 deletions(-) create mode 100644 AnimeStudio.Test/AnimeStudio.Test.csproj create mode 100644 AnimeStudio.Test/AssemblyInfo.cs create mode 100644 AnimeStudio.Test/AssetMapFileFormatTests.cs create mode 100644 AnimeStudio.Test/AssetMapFileWorkflowTests.cs create mode 100644 AnimeStudio.Test/AssetMapSerializationTests.cs create mode 100644 AnimeStudio.Test/AssetMapTestData.cs create mode 100644 AnimeStudio.Test/ResourceMapTests.cs create mode 100644 AnimeStudio/AssetMapFileFormat.cs create mode 100644 AnimeStudio/Properties/AssemblyInfo.cs diff --git a/AnimeStudio.Test/AnimeStudio.Test.csproj b/AnimeStudio.Test/AnimeStudio.Test.csproj new file mode 100644 index 00000000..f1de12fd --- /dev/null +++ b/AnimeStudio.Test/AnimeStudio.Test.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + x64 + + + + + + + + + + + + + + + + + + diff --git a/AnimeStudio.Test/AssemblyInfo.cs b/AnimeStudio.Test/AssemblyInfo.cs new file mode 100644 index 00000000..21712008 --- /dev/null +++ b/AnimeStudio.Test/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/AnimeStudio.Test/AssetMapFileFormatTests.cs b/AnimeStudio.Test/AssetMapFileFormatTests.cs new file mode 100644 index 00000000..26722d22 --- /dev/null +++ b/AnimeStudio.Test/AssetMapFileFormatTests.cs @@ -0,0 +1,29 @@ +namespace AnimeStudio.Test; + +public class AssetMapFileFormatTests +{ + [Theory] + [InlineData(ExportListType.MessagePack, ".map")] + [InlineData(ExportListType.MemoryPack, ".memory")] + [InlineData(ExportListType.XML, ".xml")] + [InlineData(ExportListType.JSON, ".json")] + public void EachAssetMapFormatHasItsCanonicalExtension(ExportListType format, string expectedExtension) + { + Assert.Equal(expectedExtension, AssetMapFileFormat.GetExtension(format)); + } + + [Fact] + public void SaveFilterContainsOnlySelectedFormats() + { + var filter = AssetMapFileFormat.BuildSaveFilter(ExportListType.MemoryPack); + + Assert.Equal("MemoryPack AssetMap (*.memory)|*.memory", filter); + } + + [Fact] + public void DefaultExtensionUsesMessagePackWhenItIsSelected() + { + Assert.Equal(".map", AssetMapFileFormat.GetDefaultExtension(ExportListType.MessagePack)); + Assert.Equal(".memory", AssetMapFileFormat.GetDefaultExtension(ExportListType.MemoryPack)); + } +} diff --git a/AnimeStudio.Test/AssetMapFileWorkflowTests.cs b/AnimeStudio.Test/AssetMapFileWorkflowTests.cs new file mode 100644 index 00000000..64bad072 --- /dev/null +++ b/AnimeStudio.Test/AssetMapFileWorkflowTests.cs @@ -0,0 +1,92 @@ +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using MemoryPack; +using MessagePack; + +namespace AnimeStudio.Test; + +public class AssetMapFileWorkflowTests +{ + private static readonly MessagePackSerializerOptions MessagePackOptions = + MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray); + + [Fact] + public async Task ExportAssetMapWritesMessagePackAndMemoryPackFiles() + { + var map = AssetMapTestData.Create(); + var directory = Path.Combine(Path.GetTempPath(), $"animestudio-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + + try + { + await AssetsHelper.ExportAssetsMap( + map.AssetEntries, + new Game(GameType.GI, "test"), + "assets", + directory, + ExportListType.MessagePack | ExportListType.MemoryPack); + + Assert.True(File.Exists(Path.Combine(directory, "assets.map"))); + Assert.True(File.Exists(Path.Combine(directory, "assets.memory"))); + + Assert.Equal(1, ResourceMap.FromFile(Path.Combine(directory, "assets.map"))); + Assert.Equal(GameType.GI, ResourceMap.GetGameType()); + Assert.Equal(1, ResourceMap.FromFile(Path.Combine(directory, "assets.memory"))); + Assert.Equal(GameType.GI, ResourceMap.GetGameType()); + } + finally + { + ResourceMap.Clear(); + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void ParseAssetMapFiltersMessagePackEntries() + { + var map = AssetMapTestData.Create(); + var path = Path.Combine(Path.GetTempPath(), $"animestudio-test-{Guid.NewGuid():N}.map"); + File.WriteAllBytes(path, MessagePackSerializer.Serialize(map, MessagePackOptions)); + + try + { + var sources = AssetsHelper.ParseAssetMap( + path, + ExportListType.MessagePack, + [ClassIDType.Texture2D], + [new Regex("hero")], + Array.Empty()); + + Assert.Equal(["E:/Game/shared.bundle"], sources); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ParseAssetMapReadsMemoryPackEntriesAndFiltersByContainer() + { + var map = AssetMapTestData.Create(); + var path = Path.Combine(Path.GetTempPath(), $"animestudio-test-{Guid.NewGuid():N}.memory"); + File.WriteAllBytes(path, MemoryPackSerializer.Serialize(new[] { map }.ToList())); + + try + { + var sources = AssetsHelper.ParseAssetMap( + path, + ExportListType.MemoryPack, + Array.Empty(), + Array.Empty(), + [new Regex("shared\\.bundle")]); + + Assert.Equal(["E:/Game/shared.bundle"], sources.Distinct()); + } + finally + { + File.Delete(path); + } + } +} diff --git a/AnimeStudio.Test/AssetMapSerializationTests.cs b/AnimeStudio.Test/AssetMapSerializationTests.cs new file mode 100644 index 00000000..1a3eeff8 --- /dev/null +++ b/AnimeStudio.Test/AssetMapSerializationTests.cs @@ -0,0 +1,89 @@ +using System.Linq; +using MemoryPack; +using MessagePack; + +namespace AnimeStudio.Test; + +public class AssetMapSerializationTests +{ + private static readonly MessagePackSerializerOptions MessagePackOptions = + MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray); + + [Fact] + public void MessagePackRoundTripPreservesAssetMapFields() + { + var expected = AssetMapTestData.Create(); + + var bytes = MessagePackSerializer.Serialize(expected, MessagePackOptions); + var actual = MessagePackSerializer.Deserialize(bytes, MessagePackOptions); + + AssetMapAssertions.Equal(expected, actual); + } + + [Fact] + public void MemoryPackRoundTripPreservesListEnvelopeAndAssetMapFields() + { + var expected = AssetMapTestData.Create(); + + var bytes = MemoryPackSerializer.Serialize(new[] { expected }.ToList()); + var actual = MemoryPackSerializer.Deserialize>(bytes)! + .Single(); + + AssetMapAssertions.Equal(expected, actual); + } + + [Fact] + public void StringCacheClearRemovesInternedValues() + { + StringCache.Clear(); + var map = AssetMapTestData.Create(); + + _ = map.AssetEntries[0].Container; + _ = map.AssetEntries[1].Container; + Assert.True(StringCache.Count > 0); + + StringCache.Clear(); + + Assert.Equal(0, StringCache.Count); + } + + [Fact] + public void StringCacheReusesRepeatedStringsAndIgnoresNull() + { + StringCache.Clear(); + var first = new string("shared.bundle".ToCharArray()); + var second = new string("shared.bundle".ToCharArray()); + + var cachedFirst = StringCache.Get(first); + var cachedSecond = StringCache.Get(second); + + Assert.Same(cachedFirst, cachedSecond); + var count = StringCache.Count; + Assert.Null(StringCache.Get(null)); + Assert.Equal(count, StringCache.Count); + } +} + +internal static class AssetMapAssertions +{ + public static void Equal(AssetMap expected, AssetMap actual) + { + Assert.NotNull(actual); + Assert.Equal(expected.GameType, actual.GameType); + Assert.NotNull(actual.AssetEntries); + Assert.Equal(expected.AssetEntries.Count, actual.AssetEntries.Count); + + for (var i = 0; i < expected.AssetEntries.Count; i++) + { + var left = expected.AssetEntries[i]; + var right = actual.AssetEntries[i]; + Assert.Equal(left.Name, right.Name); + Assert.Equal(left.Container, right.Container); + Assert.Equal(left.Source, right.Source); + Assert.Equal(left.PathID, right.PathID); + Assert.Equal(left.Type, right.Type); + Assert.Equal(left.Hash, right.Hash); + Assert.Equal(left.Offset, right.Offset); + } + } +} diff --git a/AnimeStudio.Test/AssetMapTestData.cs b/AnimeStudio.Test/AssetMapTestData.cs new file mode 100644 index 00000000..6b3e1741 --- /dev/null +++ b/AnimeStudio.Test/AssetMapTestData.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; + +namespace AnimeStudio.Test; + +internal static class AssetMapTestData +{ + public static AssetMap Create() + { + return new AssetMap + { + GameType = GameType.GI, + AssetEntries = new List + { + new() + { + Name = "hero", + Container = "shared.bundle", + Source = "E:/Game/shared.bundle", + PathID = 9_007_199_254_740_991, + Type = ClassIDType.Texture2D, + Hash = "hash-hero", + Offset = -1, + }, + new() + { + Name = string.Empty, + Container = "shared.bundle", + Source = "E:/Game/shared.bundle", + PathID = -42, + Type = ClassIDType.AudioClip, + Hash = string.Empty, + Offset = 1234, + }, + new() + { + Name = null, + Container = null, + Source = null, + PathID = 0, + Type = ClassIDType.MonoBehaviour, + Hash = null, + Offset = -1, + }, + }, + }; + } +} diff --git a/AnimeStudio.Test/ResourceMapTests.cs b/AnimeStudio.Test/ResourceMapTests.cs new file mode 100644 index 00000000..5cb0bdad --- /dev/null +++ b/AnimeStudio.Test/ResourceMapTests.cs @@ -0,0 +1,180 @@ +using System.IO; +using System.Buffers; +using System.Text; +using MemoryPack; +using MessagePack; +using Newtonsoft.Json; + +namespace AnimeStudio.Test; + +public class ResourceMapTests +{ + private static readonly MessagePackSerializerOptions MessagePackOptions = + MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray); + + [Fact] + public void FromFileLoadsMessagePackMap() + { + var map = AssetMapTestData.Create(); + var path = WriteTempFile(".map", MessagePackSerializer.Serialize(map, MessagePackOptions)); + + try + { + Assert.Equal(1, ResourceMap.FromFile(path)); + AssetMapAssertions.Equal(map, new AssetMap + { + GameType = ResourceMap.GetGameType(), + AssetEntries = ResourceMap.GetEntries(), + }); + } + finally + { + ResourceMap.Clear(); + File.Delete(path); + } + } + + [Fact] + public void FromFileLoadsUncompressedStreamingMessagePackMap() + { + var map = AssetMapTestData.Create(); + var path = Path.Combine(Path.GetTempPath(), $"animestudio-test-{Guid.NewGuid():N}.map"); + + try + { + using (var stream = File.Create(path)) + { + var header = new ArrayBufferWriter(); + var writer = new MessagePackWriter(header); + writer.WriteArrayHeader(2); + writer.Write((int)map.GameType); + writer.WriteArrayHeader(map.AssetEntries.Count); + writer.Flush(); + stream.Write(header.WrittenSpan); + + foreach (var entry in map.AssetEntries) + { + MessagePackSerializer.Serialize(stream, entry, MessagePackSerializerOptions.Standard); + } + } + + Assert.Equal(1, ResourceMap.FromFile(path)); + Assert.Equal(map.GameType, ResourceMap.GetGameType()); + Assert.Equal(map.AssetEntries.Count, ResourceMap.GetEntries().Count); + } + finally + { + ResourceMap.Clear(); + File.Delete(path); + } + } + + [Fact] + public void FromFileLoadsMemoryPackMap() + { + var map = AssetMapTestData.Create(); + var secondMap = AssetMapTestData.Create(); + secondMap.GameType = GameType.Normal; + var path = WriteTempFile(".memory", MemoryPackSerializer.Serialize(new[] { map, secondMap }.ToList())); + + try + { + Assert.Equal(1, ResourceMap.FromFile(path)); + Assert.Equal(GameType.GI, ResourceMap.GetGameType()); + Assert.Equal(map.AssetEntries.Count, ResourceMap.GetEntries().Count); + Assert.Equal(map.AssetEntries[0].Source, ResourceMap.GetEntries()[0].Source); + } + finally + { + ResourceMap.Clear(); + File.Delete(path); + } + } + + [Fact] + public void FromFileLoadsJsonMap() + { + var map = AssetMapTestData.Create(); + var path = WriteTempFile(".json", Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(map))); + + try + { + Assert.Equal(1, ResourceMap.FromFile(path)); + Assert.Equal(map.GameType, ResourceMap.GetGameType()); + Assert.Equal(map.AssetEntries.Count, ResourceMap.GetEntries().Count); + } + finally + { + ResourceMap.Clear(); + File.Delete(path); + } + } + + [Fact] + public void FromFileRejectsUnknownExtensionAndCorruptData() + { + var unknownPath = WriteTempFile(".unknown", [1, 2, 3]); + var corruptPath = WriteTempFile(".memory", [1, 2, 3]); + + try + { + ResourceMap.Clear(); + Assert.Equal(-1, ResourceMap.FromFile(unknownPath)); + Assert.Equal(-1, ResourceMap.FromFile(corruptPath)); + Assert.Empty(ResourceMap.GetEntries()); + } + finally + { + ResourceMap.Clear(); + File.Delete(unknownPath); + File.Delete(corruptPath); + } + } + + [Fact] + public void EmptyMemoryPackListIsRejected() + { + var path = WriteTempFile(".memory", MemoryPackSerializer.Serialize(new System.Collections.Generic.List())); + + try + { + ResourceMap.Clear(); + Assert.Equal(-1, ResourceMap.FromFile(path)); + Assert.Empty(ResourceMap.GetEntries()); + } + finally + { + ResourceMap.Clear(); + File.Delete(path); + } + } + + [Fact] + public void CorruptFileDoesNotReplacePreviouslyLoadedMap() + { + var map = AssetMapTestData.Create(); + var validPath = WriteTempFile(".map", MessagePackSerializer.Serialize(map, MessagePackOptions)); + var corruptPath = WriteTempFile(".map", [1, 2, 3]); + + try + { + Assert.Equal(1, ResourceMap.FromFile(validPath)); + Assert.Equal(-1, ResourceMap.FromFile(corruptPath)); + Assert.Equal(map.GameType, ResourceMap.GetGameType()); + Assert.Equal(map.AssetEntries.Count, ResourceMap.GetEntries().Count); + } + finally + { + ResourceMap.Clear(); + File.Delete(validPath); + File.Delete(corruptPath); + } + } + + private static string WriteTempFile(string extension, byte[] data) + { + var path = Path.Combine(Path.GetTempPath(), $"animestudio-test-{Guid.NewGuid():N}{extension}"); + File.WriteAllBytes(path, data); + return path; + } +} diff --git a/AnimeStudio.slnx b/AnimeStudio.slnx index 8f315f2f..8ea0e7ae 100644 --- a/AnimeStudio.slnx +++ b/AnimeStudio.slnx @@ -19,6 +19,7 @@ + diff --git a/AnimeStudio/AssetMapFileFormat.cs b/AnimeStudio/AssetMapFileFormat.cs new file mode 100644 index 00000000..a482a70e --- /dev/null +++ b/AnimeStudio/AssetMapFileFormat.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AnimeStudio +{ + public static class AssetMapFileFormat + { + private static readonly ExportListType[] SupportedFormats = + { + ExportListType.MessagePack, + ExportListType.MemoryPack, + ExportListType.XML, + ExportListType.JSON, + }; + + public static string GetExtension(ExportListType format) => format switch + { + ExportListType.MessagePack => ".map", + ExportListType.MemoryPack => ".memory", + ExportListType.XML => ".xml", + ExportListType.JSON => ".json", + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "A single AssetMap format is required."), + }; + + public static string GetDisplayName(ExportListType format) => format switch + { + ExportListType.MessagePack => "MessagePack AssetMap", + ExportListType.MemoryPack => "MemoryPack AssetMap", + ExportListType.XML => "XML AssetMap", + ExportListType.JSON => "JSON AssetMap", + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "A single AssetMap format is required."), + }; + + public static string BuildSaveFilter(ExportListType selectedFormats) + { + var formats = SupportedFormats + .Where(format => selectedFormats.HasFlag(format)) + .ToArray(); + + if (formats.Length == 0) + { + formats = SupportedFormats; + } + + return string.Join("|", formats.Select(format => + { + var extension = GetExtension(format); + return $"{GetDisplayName(format)} (*{extension})|*{extension}"; + })); + } + + public static string GetDefaultExtension(ExportListType selectedFormats) + { + var format = SupportedFormats.FirstOrDefault(format => selectedFormats.HasFlag(format)); + return GetExtension(format == ExportListType.None ? ExportListType.MessagePack : format); + } + } +} diff --git a/AnimeStudio/AssetsHelper.cs b/AnimeStudio/AssetsHelper.cs index d9f486cd..642daecc 100644 --- a/AnimeStudio/AssetsHelper.cs +++ b/AnimeStudio/AssetsHelper.cs @@ -378,8 +378,11 @@ public static async Task BuildAssetMap(string[] files, string mapName, Game game assetsManager.Game = game; // Genshin needs a full in-memory list so containers can be rewritten after the scan. - // Everyone else (HSR/ZZZ/…) streams entries out so peak RAM stays flat. - if (game.Type.IsGISubGroup() || exportListType.HasFlag(ExportListType.JSON)) + // JSON and MemoryPack use the shared writer below so their complete object envelope + // is emitted with the matching file format and extension. + if (game.Type.IsGISubGroup() + || exportListType.HasFlag(ExportListType.JSON) + || exportListType.HasFlag(ExportListType.MemoryPack)) { var assets = new List(); ForEachLoadedBundle(files, file => BuildAssetMap(file, assets, typeFilters, nameFilters, containerFilters)); @@ -421,7 +424,7 @@ private static void BuildAssetMapStreaming(string[] files, string mapName, Game { tempEntriesPath = Path.Combine(Path.GetTempPath(), $"animestudio-map-{Guid.NewGuid():N}.tmp"); tempEntries = new FileStream(tempEntriesPath, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 64, FileOptions.SequentialScan); - mapPath = Path.Combine(savePath, $"{mapName}.map"); + mapPath = Path.Combine(savePath, $"{mapName}{AssetMapFileFormat.GetExtension(ExportListType.MessagePack)}"); } if (exportListType.HasFlag(ExportListType.XML)) { @@ -729,8 +732,8 @@ public static string[] ParseAssetMap var assetMap = MessagePackSerializer.Deserialize(stream, MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray)); foreach(var entry in assetMap.AssetEntries) { - var isNameMatch = nameFilter.Length == 0 || nameFilter.Any(x => x.IsMatch(entry.Name)); - var isContainerMatch = containerFilter.Length == 0 || containerFilter.Any(x => x.IsMatch(entry.Container)); + var isNameMatch = nameFilter.Length == 0 || nameFilter.Any(x => x.IsMatch(entry.Name ?? string.Empty)); + var isContainerMatch = containerFilter.Length == 0 || containerFilter.Any(x => x.IsMatch(entry.Container ?? string.Empty)); var isTypeMatch = typeFilter.Length == 0 || typeFilter.Any(x => x == entry.Type); if (isNameMatch && isContainerMatch && isTypeMatch) { @@ -791,8 +794,8 @@ public static string[] ParseAssetMap var entries = serializer.Deserialize>(reader); foreach (var entry in entries) { - var isNameMatch = nameFilter.Length == 0 || nameFilter.Any(x => x.IsMatch(entry.Name)); - var isContainerMatch = containerFilter.Length == 0 || containerFilter.Any(x => x.IsMatch(entry.Container)); + var isNameMatch = nameFilter.Length == 0 || nameFilter.Any(x => x.IsMatch(entry.Name ?? string.Empty)); + var isContainerMatch = containerFilter.Length == 0 || containerFilter.Any(x => x.IsMatch(entry.Container ?? string.Empty)); var isTypeMatch = typeFilter.Length == 0 || typeFilter.Any(x => x == entry.Type); if (isNameMatch && isContainerMatch && isTypeMatch) { @@ -858,7 +861,7 @@ private static void UpdateContainers(List assets, Game game) } } - private static Task ExportAssetsMap(List toExportAssets, Game game, string name, string savePath, ExportListType exportListType) + internal static Task ExportAssetsMap(List toExportAssets, Game game, string name, string savePath, ExportListType exportListType) { return Task.Run (async () => @@ -876,7 +879,7 @@ private static Task ExportAssetsMap(List toExportAssets, Game game, { if (exportListType.HasFlag(ExportListType.XML)) { - filename = Path.Combine(savePath, $"{name}.xml"); + filename = Path.Combine(savePath, $"{name}{AssetMapFileFormat.GetExtension(ExportListType.XML)}"); var xmlSettings = new XmlWriterSettings() { Indent = true }; using XmlWriter writer = XmlWriter.Create(filename, xmlSettings); writer.WriteStartDocument(); @@ -901,7 +904,7 @@ private static Task ExportAssetsMap(List toExportAssets, Game game, } if (exportListType.HasFlag(ExportListType.JSON)) { - filename = Path.Combine(savePath, $"{name}.json"); + filename = Path.Combine(savePath, $"{name}{AssetMapFileFormat.GetExtension(ExportListType.JSON)}"); using StreamWriter file = File.CreateText(filename); var serializer = new JsonSerializer { Formatting = Formatting.Indented }; serializer.Converters.Add(new StringEnumConverter()); @@ -913,7 +916,7 @@ private static Task ExportAssetsMap(List toExportAssets, Game game, } if (exportListType.HasFlag(ExportListType.MessagePack)) { - filename = Path.Combine(savePath, $"{name}.map"); + filename = Path.Combine(savePath, $"{name}{AssetMapFileFormat.GetExtension(ExportListType.MessagePack)}"); using var file = File.Create(filename); var assetMap = new AssetMap { @@ -925,7 +928,7 @@ private static Task ExportAssetsMap(List toExportAssets, Game game, if(exportListType.HasFlag(ExportListType.MemoryPack)) { - filename = Path.Combine(savePath, $"{name}.memory"); + filename = Path.Combine(savePath, $"{name}{AssetMapFileFormat.GetExtension(ExportListType.MemoryPack)}"); var assetMap = new AssetMap { GameType = game.Type, @@ -955,7 +958,9 @@ public static async Task BuildBoth(string[] files, string mapName, string baseFo BaseFolder = baseFolder; assetsManager.Game = game; - if (game.Type.IsGISubGroup() || exportListType.HasFlag(ExportListType.JSON)) + if (game.Type.IsGISubGroup() + || exportListType.HasFlag(ExportListType.JSON) + || exportListType.HasFlag(ExportListType.MemoryPack)) { var assets = new List(); ForEachLoadedBundle(files, file => @@ -989,7 +994,7 @@ await Task.Run(() => { tempEntriesPath = Path.Combine(Path.GetTempPath(), $"animestudio-map-{Guid.NewGuid():N}.tmp"); tempEntries = new FileStream(tempEntriesPath, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 64, FileOptions.SequentialScan); - mapPath = Path.Combine(savePath, $"{mapName}.map"); + mapPath = Path.Combine(savePath, $"{mapName}{AssetMapFileFormat.GetExtension(ExportListType.MessagePack)}"); } if (exportListType.HasFlag(ExportListType.XML)) { diff --git a/AnimeStudio/Properties/AssemblyInfo.cs b/AnimeStudio/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..46c9a9e5 --- /dev/null +++ b/AnimeStudio/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AnimeStudio.Test")] From a3ef3d2e165035a33aeb92d834be16b83bb07380 Mon Sep 17 00:00:00 2001 From: bbdd <179790579+bbdd2729@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:57:55 +0800 Subject: [PATCH 54/56] fix(gui): keep AssetMap save filters format-aware Build the SaveFileDialog filter and default extension from the currently selected AssetMap output types. Show only .map for MessagePack and .memory for MemoryPack, while preserving multiple selected formats as separate outputs. Allow the Asset Browser comparison-map picker to open MemoryPack files. --- AnimeStudio.GUI/AssetBrowser.cs | 6 +++++- AnimeStudio.GUI/MainForm.cs | 12 ++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/AnimeStudio.GUI/AssetBrowser.cs b/AnimeStudio.GUI/AssetBrowser.cs index 5efa5cbc..d8476124 100644 --- a/AnimeStudio.GUI/AssetBrowser.cs +++ b/AnimeStudio.GUI/AssetBrowser.cs @@ -586,7 +586,11 @@ private async void loadMapTwoBtn_Click(object sender, EventArgs e) { loadMapTwoBtn.Enabled = false; - var openFileDialog = new OpenFileDialog() { Multiselect = false, Filter = "MessagePack AssetMap File|*.map|JSON AssetMap File|*.json" }; + var openFileDialog = new OpenFileDialog + { + Multiselect = false, + Filter = "MessagePack AssetMap File|*.map|JSON AssetMap File|*.json|MemoryPack AssetMap File|*.memory", + }; if (openFileDialog.ShowDialog(this) == DialogResult.OK) { try diff --git a/AnimeStudio.GUI/MainForm.cs b/AnimeStudio.GUI/MainForm.cs index 2d2dba62..bf799335 100644 --- a/AnimeStudio.GUI/MainForm.cs +++ b/AnimeStudio.GUI/MainForm.cs @@ -2652,11 +2652,17 @@ private async void buildAssetMapToolStripMenuItem_Click(object sender, EventArgs var name = "assets_map"; var saveDirectory = saveDirectoryBackup; + var exportListType = (ExportListType)assetMapTypeMenuItem.DropDownItems + .Cast() + .Select(x => x.Checked ? (int)x.Tag : 0) + .Sum(); + var defaultExtension = AssetMapFileFormat.GetDefaultExtension(exportListType); var saveFileDialog = new SaveFileDialog() { - Filter = "Map file (*.map)|*.map|MemoryPack AssetMap File|*.memory", - DefaultExt = "map", + Filter = AssetMapFileFormat.BuildSaveFilter(exportListType), + DefaultExt = defaultExtension.TrimStart('.'), + AddExtension = true, Title = "Select Output File (format will auto adjust according to what you selected)", InitialDirectory = saveDirectory, }; @@ -2666,8 +2672,6 @@ private async void buildAssetMapToolStripMenuItem_Click(object sender, EventArgs saveDirectory = Path.GetDirectoryName(saveFileDialog.FileName); var input = Path.GetFileNameWithoutExtension(saveFileDialog.FileName); - var exportListType = (ExportListType)assetMapTypeMenuItem.DropDownItems.Cast().Select(x => x.Checked ? (int)x.Tag : 0).Sum(); - if (!string.IsNullOrEmpty(input)) { if (input.IndexOfAny(Path.GetInvalidFileNameChars()) != -1) From aede6df7048fe6699eb00c4895aab44a43006879 Mon Sep 17 00:00:00 2001 From: bbdd <179790579+bbdd2729@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:55:36 +0800 Subject: [PATCH 55/56] fix(resource): restore external reader caching --- AnimeStudio/ResourceReader.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AnimeStudio/ResourceReader.cs b/AnimeStudio/ResourceReader.cs index 86d7568e..29410f41 100644 --- a/AnimeStudio/ResourceReader.cs +++ b/AnimeStudio/ResourceReader.cs @@ -53,8 +53,9 @@ private BinaryReader GetReader() { needSearch = false; var newReader = new BinaryReader(File.OpenRead(resourceFilePath)); - if (!assetsFile.assetsManager.CacheResourceReader(resourceFileName, newReader)) + if (!assetsFile.assetsManager.resourceFileReaders.TryAdd(resourceFileName, newReader)) { + newReader.Dispose(); assetsFile.assetsManager.resourceFileReaders.TryGetValue(resourceFileName, out newReader); } reader = newReader; From fc73689cf10fea128576c348e0bb04ca2f1c5a6f Mon Sep 17 00:00:00 2001 From: bbdd <179790579+bbdd2729@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:06:30 +0800 Subject: [PATCH 56/56] fix(sqlite): complete AssetMap workflows --- AnimeStudio.GUI/AssetBrowser.cs | 4 ++-- AnimeStudio/AssetMapFileFormat.cs | 3 +++ AnimeStudio/AssetsHelper.cs | 32 +++++++++++++++++++++++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/AnimeStudio.GUI/AssetBrowser.cs b/AnimeStudio.GUI/AssetBrowser.cs index d8476124..22bf424f 100644 --- a/AnimeStudio.GUI/AssetBrowser.cs +++ b/AnimeStudio.GUI/AssetBrowser.cs @@ -46,7 +46,7 @@ private async void loadAssetMap_Click(object sender, EventArgs e) var openFileDialog = new OpenFileDialog { Multiselect = false, Filter = "MessagePack AssetMap File|*" + - ".map|JSON AssetMap File|*.json|MemoryPack AssetMap File|*.memory" + ".map|JSON AssetMap File|*.json|MemoryPack AssetMap File|*.memory|SQLite AssetMap File|*.sqlite" }; if (openFileDialog.ShowDialog(this) == DialogResult.OK) { @@ -589,7 +589,7 @@ private async void loadMapTwoBtn_Click(object sender, EventArgs e) var openFileDialog = new OpenFileDialog { Multiselect = false, - Filter = "MessagePack AssetMap File|*.map|JSON AssetMap File|*.json|MemoryPack AssetMap File|*.memory", + Filter = "MessagePack AssetMap File|*.map|JSON AssetMap File|*.json|MemoryPack AssetMap File|*.memory|SQLite AssetMap File|*.sqlite", }; if (openFileDialog.ShowDialog(this) == DialogResult.OK) { diff --git a/AnimeStudio/AssetMapFileFormat.cs b/AnimeStudio/AssetMapFileFormat.cs index a482a70e..19c26e8d 100644 --- a/AnimeStudio/AssetMapFileFormat.cs +++ b/AnimeStudio/AssetMapFileFormat.cs @@ -12,6 +12,7 @@ public static class AssetMapFileFormat ExportListType.MemoryPack, ExportListType.XML, ExportListType.JSON, + ExportListType.SQLite, }; public static string GetExtension(ExportListType format) => format switch @@ -20,6 +21,7 @@ public static class AssetMapFileFormat ExportListType.MemoryPack => ".memory", ExportListType.XML => ".xml", ExportListType.JSON => ".json", + ExportListType.SQLite => ".sqlite", _ => throw new ArgumentOutOfRangeException(nameof(format), format, "A single AssetMap format is required."), }; @@ -29,6 +31,7 @@ public static class AssetMapFileFormat ExportListType.MemoryPack => "MemoryPack AssetMap", ExportListType.XML => "XML AssetMap", ExportListType.JSON => "JSON AssetMap", + ExportListType.SQLite => "SQLite AssetMap", _ => throw new ArgumentOutOfRangeException(nameof(format), format, "A single AssetMap format is required."), }; diff --git a/AnimeStudio/AssetsHelper.cs b/AnimeStudio/AssetsHelper.cs index 642daecc..35a686a6 100644 --- a/AnimeStudio/AssetsHelper.cs +++ b/AnimeStudio/AssetsHelper.cs @@ -382,7 +382,8 @@ public static async Task BuildAssetMap(string[] files, string mapName, Game game // is emitted with the matching file format and extension. if (game.Type.IsGISubGroup() || exportListType.HasFlag(ExportListType.JSON) - || exportListType.HasFlag(ExportListType.MemoryPack)) + || exportListType.HasFlag(ExportListType.MemoryPack) + || exportListType.HasFlag(ExportListType.SQLite)) { var assets = new List(); ForEachLoadedBundle(files, file => BuildAssetMap(file, assets, typeFilters, nameFilters, containerFilters)); @@ -827,6 +828,22 @@ public static string[] ParseAssetMap } break; + case ExportListType.SQLite: + { + var assetMap = AssetMapSqlite.Load(mapName); + foreach (var entry in assetMap.AssetEntries) + { + var isNameMatch = nameFilter.Length == 0 || nameFilter.Any(x => x.IsMatch(entry.Name ?? string.Empty)); + var isContainerMatch = containerFilter.Length == 0 || containerFilter.Any(x => x.IsMatch(entry.Container ?? string.Empty)); + var isTypeMatch = typeFilter.Length == 0 || typeFilter.Any(x => x == entry.Type); + if (isNameMatch && isContainerMatch && isTypeMatch) + { + matches.Add(entry.Source ?? string.Empty); + } + } + + break; + } } return matches.ToArray(); @@ -942,6 +959,16 @@ internal static Task ExportAssetsMap(List toExportAssets, Game game, File.WriteAllBytes(filename, data); } + if (exportListType.HasFlag(ExportListType.SQLite)) + { + filename = Path.Combine(savePath, $"{name}{AssetMapFileFormat.GetExtension(ExportListType.SQLite)}"); + AssetMapSqlite.Save(filename, new AssetMap + { + GameType = game.Type, + AssetEntries = toExportAssets + }); + } + Logger.Info($"Finished building AssetMap with {toExportAssets.Count} assets."); } }); @@ -960,7 +987,8 @@ public static async Task BuildBoth(string[] files, string mapName, string baseFo if (game.Type.IsGISubGroup() || exportListType.HasFlag(ExportListType.JSON) - || exportListType.HasFlag(ExportListType.MemoryPack)) + || exportListType.HasFlag(ExportListType.MemoryPack) + || exportListType.HasFlag(ExportListType.SQLite)) { var assets = new List(); ForEachLoadedBundle(files, file =>