diff --git a/OtterGui b/OtterGui index c2738e1d..89b3b951 160000 --- a/OtterGui +++ b/OtterGui @@ -1 +1 @@ -Subproject commit c2738e1d42974cddbe5a31238c6ed236a831d17d +Subproject commit 89b3b9513f9b4989045517a452ef971e24377203 diff --git a/Penumbra.GameData b/Penumbra.GameData index 62f6acfb..b5eb074d 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit 62f6acfb0f9e31b2907c02a270d723bfff18b390 +Subproject commit b5eb074d80a4aaa2703a3124d2973a7fa08046ad diff --git a/Penumbra/Configuration.cs b/Penumbra/Configuration.cs index 49aecfdc..8d0f7fd8 100644 --- a/Penumbra/Configuration.cs +++ b/Penumbra/Configuration.cs @@ -99,6 +99,8 @@ public class Configuration : IPluginConfiguration, ISavable, IService public bool UseFileSystemCompression { get; set; } = true; public bool EnableHttpApi { get; set; } = true; + public bool MigrateImportedModelsToV6 { get; set; } = false; + public string DefaultModImportPath { get; set; } = string.Empty; public bool AlwaysOpenDefaultImport { get; set; } = false; public bool KeepDefaultMetaChanges { get; set; } = false; diff --git a/Penumbra/Import/TexToolsImport.cs b/Penumbra/Import/TexToolsImport.cs index bb006d8d..ba089662 100644 --- a/Penumbra/Import/TexToolsImport.cs +++ b/Penumbra/Import/TexToolsImport.cs @@ -3,6 +3,7 @@ using OtterGui.Compression; using Penumbra.Import.Structs; using Penumbra.Mods.Editor; using Penumbra.Mods.Manager; +using Penumbra.Services; using FileMode = System.IO.FileMode; using ZipArchive = SharpCompress.Archives.Zip.ZipArchive; using ZipArchiveEntry = SharpCompress.Archives.Zip.ZipArchiveEntry; @@ -27,24 +28,26 @@ public partial class TexToolsImporter : IDisposable public ImporterState State { get; private set; } public readonly List<(FileInfo File, DirectoryInfo? Mod, Exception? Error)> ExtractedMods; - private readonly Configuration _config; - private readonly ModEditor _editor; - private readonly ModManager _modManager; - private readonly FileCompactor _compactor; + private readonly Configuration _config; + private readonly ModEditor _editor; + private readonly ModManager _modManager; + private readonly FileCompactor _compactor; + private readonly MigrationManager _migrationManager; public TexToolsImporter(int count, IEnumerable modPackFiles, Action handler, - Configuration config, ModEditor editor, ModManager modManager, FileCompactor compactor) + Configuration config, ModEditor editor, ModManager modManager, FileCompactor compactor, MigrationManager migrationManager) { - _baseDirectory = modManager.BasePath; - _tmpFile = Path.Combine(_baseDirectory.FullName, TempFileName); - _modPackFiles = modPackFiles; - _config = config; - _editor = editor; - _modManager = modManager; - _compactor = compactor; - _modPackCount = count; - ExtractedMods = new List<(FileInfo, DirectoryInfo?, Exception?)>(count); - _token = _cancellation.Token; + _baseDirectory = modManager.BasePath; + _tmpFile = Path.Combine(_baseDirectory.FullName, TempFileName); + _modPackFiles = modPackFiles; + _config = config; + _editor = editor; + _modManager = modManager; + _compactor = compactor; + _migrationManager = migrationManager; + _modPackCount = count; + ExtractedMods = new List<(FileInfo, DirectoryInfo?, Exception?)>(count); + _token = _cancellation.Token; Task.Run(ImportFiles, _token) .ContinueWith(_ => CloseStreams(), TaskScheduler.Default) .ContinueWith(_ => diff --git a/Penumbra/Import/TexToolsImporter.Archives.cs b/Penumbra/Import/TexToolsImporter.Archives.cs index 57313ab1..a51dbc61 100644 --- a/Penumbra/Import/TexToolsImporter.Archives.cs +++ b/Penumbra/Import/TexToolsImporter.Archives.cs @@ -2,6 +2,7 @@ using Dalamud.Utility; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using OtterGui.Filesystem; +using Penumbra.GameData.Files; using Penumbra.Import.Structs; using Penumbra.Mods; using SharpCompress.Archives; @@ -9,12 +10,19 @@ using SharpCompress.Archives.Rar; using SharpCompress.Archives.SevenZip; using SharpCompress.Common; using SharpCompress.Readers; +using static FFXIVClientStructs.FFXIV.Client.UI.Misc.ConfigModule; using ZipArchive = SharpCompress.Archives.Zip.ZipArchive; namespace Penumbra.Import; public partial class TexToolsImporter { + private static readonly ExtractionOptions _extractionOptions = new() + { + ExtractFullPath = true, + Overwrite = true, + }; + /// /// Extract regular compressed archives that are folders containing penumbra-formatted mods. /// The mod has to either contain a meta.json at top level, or one folder deep. @@ -45,11 +53,7 @@ public partial class TexToolsImporter Penumbra.Log.Information($" -> Importing {archive.Type} Archive."); _currentModDirectory = ModCreator.CreateModFolder(_baseDirectory, Path.GetRandomFileName(), _config.ReplaceNonAsciiOnImport, true); - var options = new ExtractionOptions() - { - ExtractFullPath = true, - Overwrite = true, - }; + State = ImporterState.ExtractingModFiles; _currentFileIdx = 0; @@ -86,7 +90,7 @@ public partial class TexToolsImporter } else { - reader.WriteEntryToDirectory(_currentModDirectory.FullName, options); + HandleFileMigrations(reader); } ++_currentFileIdx; @@ -114,6 +118,16 @@ public partial class TexToolsImporter } + private void HandleFileMigrations(IReader reader) + { + switch (Path.GetExtension(reader.Entry.Key)) + { + case ".mdl": + _migrationManager.MigrateMdlDuringExtraction(reader, _currentModDirectory!.FullName, _extractionOptions); + break; + } + } + // Search the archive for the meta.json file which needs to exist. private static string FindArchiveModMeta(IArchive archive, out bool leadDir) { diff --git a/Penumbra/Import/TexToolsImporter.ModPack.cs b/Penumbra/Import/TexToolsImporter.ModPack.cs index 2b45ecbe..ba294353 100644 --- a/Penumbra/Import/TexToolsImporter.ModPack.cs +++ b/Penumbra/Import/TexToolsImporter.ModPack.cs @@ -253,25 +253,12 @@ public partial class TexToolsImporter extractedFile.Directory?.Create(); - if (extractedFile.FullName.EndsWith(".mdl")) - ProcessMdl(data.Data); + data.Data = Path.GetExtension(extractedFile.FullName) switch + { + ".mdl" => _migrationManager.MigrateTtmpModel(extractedFile.FullName, data.Data), + _ => data.Data, + }; _compactor.WriteAllBytesAsync(extractedFile.FullName, data.Data, _token).Wait(_token); } - - private static void ProcessMdl(byte[] mdl) - { - const int modelHeaderLodOffset = 22; - - // Model file header LOD num - mdl[64] = 1; - - // Model header LOD num - var stackSize = BitConverter.ToUInt32(mdl, 4); - var runtimeBegin = stackSize + 0x44; - var stringsLengthOffset = runtimeBegin + 4; - var stringsLength = BitConverter.ToUInt32(mdl, (int)stringsLengthOffset); - var modelHeaderStart = stringsLengthOffset + stringsLength + 4; - mdl[modelHeaderStart + modelHeaderLodOffset] = 1; - } } diff --git a/Penumbra/Interop/Hooks/Animation/LoadAreaVfx.cs b/Penumbra/Interop/Hooks/Animation/LoadAreaVfx.cs index cfab29d3..48dc0078 100644 --- a/Penumbra/Interop/Hooks/Animation/LoadAreaVfx.cs +++ b/Penumbra/Interop/Hooks/Animation/LoadAreaVfx.cs @@ -35,7 +35,7 @@ public sealed unsafe class LoadAreaVfx : FastHook var last = _state.SetAnimationData(newData); _crashHandler.LogAnimation(newData.AssociatedGameObject, newData.ModCollection, AnimationInvocationType.LoadAreaVfx); var ret = Task.Result.Original(vfxId, pos, caster, unk1, unk2, unk3); - Penumbra.Log.Information( + Penumbra.Log.Excessive( $"[Load Area VFX] Invoked with {vfxId}, [{pos[0]} {pos[1]} {pos[2]}], 0x{(nint)caster:X}, {unk1}, {unk2}, {unk3} -> 0x{ret:X}."); _state.RestoreAnimationData(last); return ret; diff --git a/Penumbra/Interop/Hooks/ResourceLoading/ResourceLoader.cs b/Penumbra/Interop/Hooks/ResourceLoading/ResourceLoader.cs index af637768..195a8b9e 100644 --- a/Penumbra/Interop/Hooks/ResourceLoading/ResourceLoader.cs +++ b/Penumbra/Interop/Hooks/ResourceLoading/ResourceLoader.cs @@ -94,9 +94,6 @@ public unsafe class ResourceLoader : IDisposable, IService CompareHash(ComputeHash(path.Path, parameters), hash, path); - if (path.ToString() == "vfx/common/eff/abi_cnj022g.avfx") - ; - // If no replacements are being made, we still want to be able to trigger the event. var (resolvedPath, data) = _incMode.Value ? (null, ResolveData.Invalid) diff --git a/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs b/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs index 4dfefd96..72cb1681 100644 --- a/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs +++ b/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs @@ -81,11 +81,12 @@ internal partial record ResolveContext // Resolving a material path through the game's code can dereference null pointers for materials that involve IMC metadata. return ModelType switch { - ModelType.Human when SlotIndex < 10 && mtrlFileName[8] != (byte)'b' => ResolveEquipmentMaterialPath(modelPath, imc, mtrlFileName), - ModelType.DemiHuman => ResolveEquipmentMaterialPath(modelPath, imc, mtrlFileName), - ModelType.Weapon => ResolveWeaponMaterialPath(modelPath, imc, mtrlFileName), - ModelType.Monster => ResolveMonsterMaterialPath(modelPath, imc, mtrlFileName), - _ => ResolveMaterialPathNative(mtrlFileName), + ModelType.Human when SlotIndex is < 10 or 16 && mtrlFileName[8] != (byte)'b' + => ResolveEquipmentMaterialPath(modelPath, imc, mtrlFileName), + ModelType.DemiHuman => ResolveEquipmentMaterialPath(modelPath, imc, mtrlFileName), + ModelType.Weapon => ResolveWeaponMaterialPath(modelPath, imc, mtrlFileName), + ModelType.Monster => ResolveMonsterMaterialPath(modelPath, imc, mtrlFileName), + _ => ResolveMaterialPathNative(mtrlFileName), }; } @@ -96,7 +97,7 @@ internal partial record ResolveContext var fileName = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(mtrlFileName); Span pathBuffer = stackalloc byte[260]; - pathBuffer = AssembleMaterialPath(pathBuffer, modelPath.Path.Span, variant, fileName); + pathBuffer = AssembleMaterialPath(pathBuffer, modelPath.Path.Span, variant, fileName); return Utf8GamePath.FromSpan(pathBuffer, out var path) ? path.Clone() : Utf8GamePath.Empty; } @@ -126,7 +127,7 @@ internal partial record ResolveContext WriteZeroPaddedNumber(mirroredFileName[4..8], mirroredSetId); Span pathBuffer = stackalloc byte[260]; - pathBuffer = AssembleMaterialPath(pathBuffer, modelPath.Path.Span, variant, mirroredFileName); + pathBuffer = AssembleMaterialPath(pathBuffer, modelPath.Path.Span, variant, mirroredFileName); var weaponPosition = pathBuffer.IndexOf("/weapon/w"u8); if (weaponPosition >= 0) @@ -145,7 +146,7 @@ internal partial record ResolveContext var fileName = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(mtrlFileName); Span pathBuffer = stackalloc byte[260]; - pathBuffer = AssembleMaterialPath(pathBuffer, modelPath.Path.Span, variant, fileName); + pathBuffer = AssembleMaterialPath(pathBuffer, modelPath.Path.Span, variant, fileName); return Utf8GamePath.FromSpan(pathBuffer, out var path) ? path.Clone() : Utf8GamePath.Empty; } @@ -166,7 +167,8 @@ internal partial record ResolveContext return entry.MaterialId; } - private static Span AssembleMaterialPath(Span materialPathBuffer, ReadOnlySpan modelPath, byte variant, ReadOnlySpan mtrlFileName) + private static Span AssembleMaterialPath(Span materialPathBuffer, ReadOnlySpan modelPath, byte variant, + ReadOnlySpan mtrlFileName) { var modelPosition = modelPath.IndexOf("/model/"u8); if (modelPosition < 0) @@ -187,8 +189,8 @@ internal partial record ResolveContext { for (var i = destination.Length; i-- > 0;) { - destination[i] = (byte)('0' + number % 10); - number /= 10; + destination[i] = (byte)('0' + number % 10); + number /= 10; } } @@ -197,13 +199,17 @@ internal partial record ResolveContext ByteString? path; try { + Penumbra.Log.Information($"{(nint)CharacterBase:X} {ModelType} {SlotIndex} 0x{(ulong)mtrlFileName:X}"); + Penumbra.Log.Information($"{new ByteString(mtrlFileName)}"); path = CharacterBase->ResolveMtrlPathAsByteString(SlotIndex, mtrlFileName); } catch (AccessViolationException) { - Penumbra.Log.Error($"Access violation during attempt to resolve material path\nDraw object: {(nint)CharacterBase:X} (of type {ModelType})\nSlot index: {SlotIndex}\nMaterial file name: {(nint)mtrlFileName:X} ({new string((sbyte*)mtrlFileName)})"); + Penumbra.Log.Error( + $"Access violation during attempt to resolve material path\nDraw object: {(nint)CharacterBase:X} (of type {ModelType})\nSlot index: {SlotIndex}\nMaterial file name: {(nint)mtrlFileName:X} ({new string((sbyte*)mtrlFileName)})"); return Utf8GamePath.Empty; } + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; } @@ -235,30 +241,23 @@ internal partial record ResolveContext var characterRaceCode = (GenderRace)human->RaceSexId; switch (partialSkeletonIndex) { - case 0: - return (characterRaceCode, "base", 1); + case 0: return (characterRaceCode, "base", 1); case 1: var faceId = human->FaceId; var tribe = human->Customize[(int)Dalamud.Game.ClientState.Objects.Enums.CustomizeIndex.Tribe]; var modelType = human->Customize[(int)Dalamud.Game.ClientState.Objects.Enums.CustomizeIndex.ModelType]; if (faceId < 201) - { faceId -= tribe switch { 0xB when modelType == 4 => 100, 0xE | 0xF => 100, _ => 0, }; - } return ResolveHumanExtraSkeletonData(characterRaceCode, EstType.Face, faceId); - case 2: - return ResolveHumanExtraSkeletonData(characterRaceCode, EstType.Hair, human->HairId); - case 3: - return ResolveHumanEquipmentSkeletonData(EquipSlot.Head, EstType.Head); - case 4: - return ResolveHumanEquipmentSkeletonData(EquipSlot.Body, EstType.Body); - default: - return (0, string.Empty, 0); + case 2: return ResolveHumanExtraSkeletonData(characterRaceCode, EstType.Hair, human->HairId); + case 3: return ResolveHumanEquipmentSkeletonData(EquipSlot.Head, EstType.Head); + case 4: return ResolveHumanEquipmentSkeletonData(EquipSlot.Body, EstType.Body); + default: return (0, string.Empty, 0); } } @@ -269,7 +268,8 @@ internal partial record ResolveContext return ResolveHumanExtraSkeletonData(ResolveEqdpRaceCode(slot, equipment.Set), type, equipment.Set); } - private (GenderRace RaceCode, string Slot, PrimaryId Set) ResolveHumanExtraSkeletonData(GenderRace raceCode, EstType type, PrimaryId primary) + private (GenderRace RaceCode, string Slot, PrimaryId Set) ResolveHumanExtraSkeletonData(GenderRace raceCode, EstType type, + PrimaryId primary) { var metaCache = Global.Collection.MetaCache; var skeletonSet = metaCache?.GetEstEntry(type, raceCode, primary) ?? default; diff --git a/Penumbra/Interop/ResourceTree/ResourceTree.cs b/Penumbra/Interop/ResourceTree/ResourceTree.cs index 96125df2..6663fb40 100644 --- a/Penumbra/Interop/ResourceTree/ResourceTree.cs +++ b/Penumbra/Interop/ResourceTree/ResourceTree.cs @@ -73,11 +73,11 @@ public class ResourceTree var genericContext = globalContext.CreateContext(model); - for (var i = 0; i < model->SlotCount; ++i) + for (var i = 0u; i < model->SlotCount; ++i) { var slotContext = i < equipment.Length - ? globalContext.CreateContext(model, (uint)i, ((uint)i).ToEquipSlot(), equipment[i]) - : globalContext.CreateContext(model, (uint)i); + ? globalContext.CreateContext(model, i, i.ToEquipSlot(), equipment[(int)i]) + : globalContext.CreateContext(model, i); var imc = (ResourceHandle*)model->IMCArray[i]; var imcNode = slotContext.CreateNodeFromImc(imc); diff --git a/Penumbra/Mods/Manager/ModImportManager.cs b/Penumbra/Mods/Manager/ModImportManager.cs index 39a53bb9..d984d374 100644 --- a/Penumbra/Mods/Manager/ModImportManager.cs +++ b/Penumbra/Mods/Manager/ModImportManager.cs @@ -3,10 +3,11 @@ using OtterGui.Classes; using OtterGui.Services; using Penumbra.Import; using Penumbra.Mods.Editor; +using Penumbra.Services; namespace Penumbra.Mods.Manager; -public class ModImportManager(ModManager modManager, Configuration config, ModEditor modEditor) : IDisposable, IService +public class ModImportManager(ModManager modManager, Configuration config, ModEditor modEditor, MigrationManager migrationManager) : IDisposable, IService { private readonly ConcurrentQueue _modsToUnpack = new(); @@ -42,7 +43,7 @@ public class ModImportManager(ModManager modManager, Configuration config, ModEd if (files.Length == 0) return; - _import = new TexToolsImporter(files.Length, files, AddNewMod, config, modEditor, modManager, modEditor.Compactor); + _import = new TexToolsImporter(files.Length, files, AddNewMod, config, modEditor, modManager, modEditor.Compactor, migrationManager); } public bool Importing diff --git a/Penumbra/Services/MigrationManager.cs b/Penumbra/Services/MigrationManager.cs new file mode 100644 index 00000000..a4b80656 --- /dev/null +++ b/Penumbra/Services/MigrationManager.cs @@ -0,0 +1,287 @@ +using Dalamud.Interface.ImGuiNotification; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.GameData.Files; +using SharpCompress.Common; +using SharpCompress.Readers; + +namespace Penumbra.Services; + +public class MigrationManager(Configuration config) : IService +{ + private Task? _currentTask; + private CancellationTokenSource? _source; + + public bool HasCleanUpTask { get; private set; } + public bool HasMigrationTask { get; private set; } + public bool HasRestoreTask { get; private set; } + + public bool IsMigrationTask { get; private set; } + public bool IsRestorationTask { get; private set; } + public bool IsCleanupTask { get; private set; } + + + public int Restored { get; private set; } + public int RestoreFails { get; private set; } + + public int CleanedUp { get; private set; } + + public int CleanupFails { get; private set; } + + public int Migrated { get; private set; } + + public int Unchanged { get; private set; } + + public int Failed { get; private set; } + + public bool IsRunning + => _currentTask is { IsCompleted: false }; + + /// Writes or migrates a .mdl file during extraction from a regular archive. + public void MigrateMdlDuringExtraction(IReader reader, string directory, ExtractionOptions options) + { + if (!config.MigrateImportedModelsToV6) + { + reader.WriteEntryToDirectory(directory, options); + return; + } + + var path = Path.Combine(directory, reader.Entry.Key); + using var s = new MemoryStream(); + using var e = reader.OpenEntryStream(); + e.CopyTo(s); + using var b = new BinaryReader(s); + var version = b.ReadUInt32(); + if (version == MdlFile.V5) + { + var data = s.ToArray(); + var mdl = new MdlFile(data); + MigrateModel(path, mdl, false); + Penumbra.Log.Debug($"Migrated model {reader.Entry.Key} from V5 to V6 during import."); + } + else + { + using var f = File.Open(path, FileMode.Create, FileAccess.Write); + s.Seek(0, SeekOrigin.Begin); + s.WriteTo(f); + } + } + + public void CleanBackups(string path) + { + if (IsRunning) + return; + + _source = new CancellationTokenSource(); + var token = _source.Token; + _currentTask = Task.Run(() => + { + HasCleanUpTask = true; + IsCleanupTask = true; + IsMigrationTask = false; + IsRestorationTask = false; + CleanedUp = 0; + CleanupFails = 0; + foreach (var file in Directory.EnumerateFiles(path, "*.mdl.bak", SearchOption.AllDirectories)) + { + if (token.IsCancellationRequested) + return; + + try + { + File.Delete(file); + ++CleanedUp; + Penumbra.Log.Debug($"Deleted model backup file {file}."); + } + catch (Exception ex) + { + Penumbra.Messager.NotificationMessage(ex, $"Failed to delete model backup file {file}", NotificationType.Warning); + ++CleanupFails; + } + } + }, token); + } + + public void RestoreBackups(string path) + { + if (IsRunning) + return; + + _source = new CancellationTokenSource(); + var token = _source.Token; + _currentTask = Task.Run(() => + { + HasRestoreTask = true; + IsCleanupTask = false; + IsMigrationTask = false; + IsRestorationTask = true; + CleanedUp = 0; + CleanupFails = 0; + foreach (var file in Directory.EnumerateFiles(path, "*.mdl.bak", SearchOption.AllDirectories)) + { + if (token.IsCancellationRequested) + return; + + var target = file[..^4]; + try + { + File.Copy(file, target, true); + ++Restored; + Penumbra.Log.Debug($"Restored model backup file {file} to {target}."); + } + catch (Exception ex) + { + Penumbra.Messager.NotificationMessage(ex, $"Failed to restore model backup file {file} to {target}", + NotificationType.Warning); + ++RestoreFails; + } + } + }, token); + } + + /// Update the data of a .mdl file during TTMP extraction. Returns either the existing array or a new one. + public byte[] MigrateTtmpModel(string path, byte[] data) + { + FixLodNum(data); + if (!config.MigrateImportedModelsToV6) + return data; + + var version = BitConverter.ToUInt32(data); + if (version != 5) + return data; + + var mdl = new MdlFile(data); + if (!mdl.ConvertV5ToV6()) + return data; + + data = mdl.Write(); + Penumbra.Log.Debug($"Migrated model {path} from V5 to V6 during import."); + return data; + } + + public void MigrateDirectory(string path, bool createBackups) + { + if (IsRunning) + return; + + _source = new CancellationTokenSource(); + var token = _source.Token; + _currentTask = Task.Run(() => + { + HasMigrationTask = true; + IsCleanupTask = false; + IsMigrationTask = true; + IsRestorationTask = false; + Unchanged = 0; + Migrated = 0; + Failed = 0; + foreach (var file in Directory.EnumerateFiles(path, "*.mdl", SearchOption.AllDirectories)) + { + if (token.IsCancellationRequested) + return; + + var timer = Stopwatch.StartNew(); + try + { + var data = File.ReadAllBytes(file); + var mdl = new MdlFile(data); + if (MigrateModel(file, mdl, createBackups)) + { + ++Migrated; + Penumbra.Log.Debug($"Migrated model file {file} from V5 to V6 in {timer.ElapsedMilliseconds} ms."); + } + else + { + ++Unchanged; + Penumbra.Log.Verbose($"Verified that model file {file} is already V6 in {timer.ElapsedMilliseconds} ms."); + } + } + catch (Exception ex) + { + Penumbra.Messager.NotificationMessage(ex, $"Failed to migrate model file {file} to V6 in {timer.ElapsedMilliseconds} ms", + NotificationType.Warning); + ++Failed; + } + } + }, token); + } + + public void Cancel() + { + _source?.Cancel(); + _source = null; + _currentTask = null; + } + + private static void FixLodNum(byte[] data) + { + const int modelHeaderLodOffset = 22; + + // Model file header LOD num + data[64] = 1; + + // Model header LOD num + var stackSize = BitConverter.ToUInt32(data, 4); + var runtimeBegin = stackSize + 0x44; + var stringsLengthOffset = runtimeBegin + 4; + var stringsLength = BitConverter.ToUInt32(data, (int)stringsLengthOffset); + var modelHeaderStart = stringsLengthOffset + stringsLength + 4; + data[modelHeaderStart + modelHeaderLodOffset] = 1; + } + + public static bool TryMigrateSingleModel(string path, bool createBackup) + { + try + { + var data = File.ReadAllBytes(path); + var mdl = new MdlFile(data); + return MigrateModel(path, mdl, createBackup); + } + catch (Exception ex) + { + Penumbra.Messager.NotificationMessage(ex, $"Failed to migrate the model {path} to V6", NotificationType.Warning); + return false; + } + } + + public static bool TryMigrateSingleMaterial(string path, bool createBackup) + { + try + { + var data = File.ReadAllBytes(path); + var mtrl = new MtrlFile(data); + return MigrateMaterial(path, mtrl, createBackup); + } + catch (Exception ex) + { + Penumbra.Messager.NotificationMessage(ex, $"Failed to migrate the material {path} to Dawntrail", NotificationType.Warning); + return false; + } + } + + private static bool MigrateModel(string path, MdlFile mdl, bool createBackup) + { + if (!mdl.ConvertV5ToV6()) + return false; + + var data = mdl.Write(); + if (createBackup) + File.Copy(path, Path.ChangeExtension(path, ".mdl.bak")); + File.WriteAllBytes(path, data); + return true; + } + + private static bool MigrateMaterial(string path, MtrlFile mtrl, bool createBackup) + { + if (!mtrl.MigrateToDawntrail()) + return false; + + var data = mtrl.Write(); + + mtrl.Write(); + if (createBackup) + File.Copy(path, Path.ChangeExtension(path, ".mtrl.bak")); + File.WriteAllBytes(path, data); + return true; + } +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Materials.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Materials.cs index 68b3717f..0223ca6b 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Materials.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Materials.cs @@ -3,6 +3,7 @@ using Dalamud.Interface.Utility; using ImGuiNET; using OtterGui; using OtterGui.Raii; +using OtterGui.Text; using OtterGui.Widgets; using Penumbra.GameData.Files; using Penumbra.String.Classes; @@ -16,6 +17,7 @@ public partial class ModEditWindow private bool DrawMaterialPanel(MtrlTab tab, bool disabled) { + DrawVersionUpdate(tab, disabled); DrawMaterialLivePreviewRebind(tab, disabled); ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); @@ -34,6 +36,20 @@ public partial class ModEditWindow return !disabled && ret; } + private void DrawVersionUpdate(MtrlTab tab, bool disabled) + { + if (disabled || tab.Mtrl.IsDawnTrail) + return; + + if (!ImUtf8.ButtonEx("Update MTRL Version to Dawntrail"u8, + "Try using this if the material can not be loaded or should use legacy shaders.\n\nThis is not revertible."u8, + new Vector2(-0.1f, 0), false, 0, Colors.PressEnterWarningBg)) + return; + + tab.Mtrl.MigrateToDawntrail(); + _materialTab.SaveFile(); + } + private static void DrawMaterialLivePreviewRebind(MtrlTab tab, bool disabled) { if (disabled) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs index 90fdc48e..83a8958b 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs @@ -37,6 +37,8 @@ public partial class ModEditWindow : Window, IDisposable, IUiService { private const string WindowBaseLabel = "###SubModEdit"; + public readonly MigrationManager MigrationManager; + private readonly PerformanceTracker _performance; private readonly ModEditor _editor; private readonly Configuration _config; @@ -588,7 +590,7 @@ public partial class ModEditWindow : Window, IDisposable, IUiService StainService stainService, ActiveCollections activeCollections, ModMergeTab modMergeTab, CommunicatorService communicator, TextureManager textures, ModelManager models, IDragDropManager dragDropManager, ResourceTreeViewerFactory resourceTreeViewerFactory, ObjectManager objects, IFramework framework, - CharacterBaseDestructor characterBaseDestructor, MetaDrawers metaDrawers) + CharacterBaseDestructor characterBaseDestructor, MetaDrawers metaDrawers, MigrationManager migrationManager) : base(WindowBaseLabel) { _performance = performance; @@ -608,6 +610,7 @@ public partial class ModEditWindow : Window, IDisposable, IUiService _objects = objects; _framework = framework; _characterBaseDestructor = characterBaseDestructor; + MigrationManager = migrationManager; _metaDrawers = metaDrawers; _materialTab = new FileEditor(this, _communicator, gameData, config, _editor.Compactor, _fileDialog, "Materials", ".mtrl", () => PopulateIsOnPlayer(_editor.Files.Mtrl, ResourceType.Mtrl), DrawMaterialPanel, () => Mod?.ModPath.FullName ?? string.Empty, diff --git a/Penumbra/UI/Classes/CollectionSelectHeader.cs b/Penumbra/UI/Classes/CollectionSelectHeader.cs index 6c8bbf64..0f9b2518 100644 --- a/Penumbra/UI/Classes/CollectionSelectHeader.cs +++ b/Penumbra/UI/Classes/CollectionSelectHeader.cs @@ -37,9 +37,9 @@ public class CollectionSelectHeader : IUiService var buttonSize = new Vector2(comboWidth * 3f / 4f, 0f); using (var _ = ImRaii.Group()) { - DrawCollectionButton(buttonSize, GetDefaultCollectionInfo(), 1); + DrawCollectionButton(buttonSize, GetDefaultCollectionInfo(), 1); DrawCollectionButton(buttonSize, GetInterfaceCollectionInfo(), 2); - DrawCollectionButton(buttonSize, GetPlayerCollectionInfo(), 3); + DrawCollectionButton(buttonSize, GetPlayerCollectionInfo(), 3); DrawCollectionButton(buttonSize, GetInheritedCollectionInfo(), 4); _collectionCombo.Draw("##collectionSelector", comboWidth, ColorId.SelectedCollection.Value()); diff --git a/Penumbra/UI/Classes/MigrationSectionDrawer.cs b/Penumbra/UI/Classes/MigrationSectionDrawer.cs new file mode 100644 index 00000000..75d37368 --- /dev/null +++ b/Penumbra/UI/Classes/MigrationSectionDrawer.cs @@ -0,0 +1,121 @@ +using ImGuiNET; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Services; + +namespace Penumbra.UI.Classes; + +public class MigrationSectionDrawer(MigrationManager migrationManager, Configuration config) : IUiService +{ + private bool _createBackups = true; + private Vector2 _buttonSize; + + public void Draw() + { + using var header = ImUtf8.CollapsingHeaderId("Migration"u8); + if (!header) + return; + + _buttonSize = UiHelpers.InputTextWidth; + DrawSettings(); + ImGui.Separator(); + DrawMigration(); + ImGui.Separator(); + DrawCleanup(); + ImGui.Separator(); + DrawRestore(); + } + + private void DrawSettings() + { + var value = config.MigrateImportedModelsToV6; + if (ImUtf8.Checkbox("Automatically Migrate V5 Models to V6 on Import"u8, ref value)) + { + config.MigrateImportedModelsToV6 = value; + config.Save(); + } + } + + private void DrawMigration() + { + ImUtf8.Checkbox("Create Backups During Manual Migration", ref _createBackups); + if (ImUtf8.ButtonEx("Migrate Model Files From V5 to V6"u8, "\0"u8, _buttonSize, migrationManager.IsRunning)) + migrationManager.MigrateDirectory(config.ModDirectory, _createBackups); + + ImUtf8.SameLineInner(); + DrawCancelButton(0, "Cancel the migration. This does not revert already finished migrations."u8); + DrawSpinner(migrationManager is { IsMigrationTask: true, IsRunning: true }); + + if (!migrationManager.HasMigrationTask) + { + ImUtf8.IconDummy(); + return; + } + + var total = migrationManager.Failed + migrationManager.Migrated + migrationManager.Unchanged; + if (total == 0) + ImUtf8.TextFrameAligned("No model files found."u8); + else + ImUtf8.TextFrameAligned($"{migrationManager.Migrated} files migrated, {migrationManager.Failed} files failed, {total} total files."); + } + + private void DrawCleanup() + { + if (ImUtf8.ButtonEx("Delete Existing Model Backup Files"u8, "\0"u8, _buttonSize, migrationManager.IsRunning)) + migrationManager.CleanBackups(config.ModDirectory); + + ImUtf8.SameLineInner(); + DrawCancelButton(1, "Cancel the cleanup. This is not revertible."u8); + DrawSpinner(migrationManager is { IsCleanupTask: true, IsRunning: true }); + if (!migrationManager.HasCleanUpTask) + { + ImUtf8.IconDummy(); + return; + } + + var total = migrationManager.CleanedUp + migrationManager.CleanupFails; + if (total == 0) + ImUtf8.TextFrameAligned("No model backup files found."u8); + else + ImUtf8.TextFrameAligned( + $"{migrationManager.CleanedUp} backups deleted, {migrationManager.CleanupFails} deletions failed, {total} total backups."); + } + + private void DrawSpinner(bool enabled) + { + if (!enabled) + return; + ImGui.SameLine(); + ImUtf8.Spinner("Spinner"u8, ImGui.GetTextLineHeight() / 2, 2, ImGui.GetColorU32(ImGuiCol.Text)); + } + + private void DrawRestore() + { + if (ImUtf8.ButtonEx("Restore Model Backups"u8, "\0"u8, _buttonSize, migrationManager.IsRunning)) + migrationManager.RestoreBackups(config.ModDirectory); + + ImUtf8.SameLineInner(); + DrawCancelButton(2, "Cancel the restoration. This does not revert already finished restoration."u8); + DrawSpinner(migrationManager is { IsRestorationTask: true, IsRunning: true }); + + if (!migrationManager.HasRestoreTask) + { + ImUtf8.IconDummy(); + return; + } + + var total = migrationManager.Restored + migrationManager.RestoreFails; + if (total == 0) + ImUtf8.TextFrameAligned("No model backup files found."u8); + else + ImUtf8.TextFrameAligned( + $"{migrationManager.Restored} backups restored, {migrationManager.RestoreFails} restorations failed, {total} total backups."); + } + + private void DrawCancelButton(int id, ReadOnlySpan tooltip) + { + using var _ = ImUtf8.PushId(id); + if (ImUtf8.ButtonEx("Cancel"u8, tooltip, disabled: !migrationManager.IsRunning)) + migrationManager.Cancel(); + } +} diff --git a/Penumbra/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs index 49e77a4d..8a4d6874 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -41,10 +41,11 @@ public class SettingsTab : ITab, IUiService private readonly DalamudSubstitutionProvider _dalamudSubstitutionProvider; private readonly FileCompactor _compactor; private readonly DalamudConfigService _dalamudConfig; - private readonly IDalamudPluginInterface _pluginInterface; + private readonly IDalamudPluginInterface _pluginInterface; private readonly IDataManager _gameData; private readonly PredefinedTagManager _predefinedTagManager; private readonly CrashHandlerService _crashService; + private readonly MigrationSectionDrawer _migrationDrawer; private int _minimumX = int.MaxValue; private int _minimumY = int.MaxValue; @@ -55,7 +56,8 @@ public class SettingsTab : ITab, IUiService Penumbra penumbra, FileDialogService fileDialog, ModManager modManager, ModFileSystemSelector selector, CharacterUtility characterUtility, ResidentResourceManager residentResources, ModExportManager modExportManager, HttpApi httpApi, DalamudSubstitutionProvider dalamudSubstitutionProvider, FileCompactor compactor, DalamudConfigService dalamudConfig, - IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService) + IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService, + MigrationSectionDrawer migrationDrawer) { _pluginInterface = pluginInterface; _config = config; @@ -77,6 +79,7 @@ public class SettingsTab : ITab, IUiService _compactor.Enabled = _config.UseFileSystemCompression; _predefinedTagManager = predefinedTagConfig; _crashService = crashService; + _migrationDrawer = migrationDrawer; } public void DrawHeader() @@ -102,6 +105,7 @@ public class SettingsTab : ITab, IUiService ImGui.NewLine(); DrawGeneralSettings(); + _migrationDrawer.Draw(); DrawColorSettings(); DrawPredefinedTagsSection(); DrawAdvancedSettings();