diff --git a/Glamourer.GameData/Customization/CustomizationOptions.cs b/Glamourer.GameData/Customization/CustomizationOptions.cs index 02bd4d7..ade35d7 100644 --- a/Glamourer.GameData/Customization/CustomizationOptions.cs +++ b/Glamourer.GameData/Customization/CustomizationOptions.cs @@ -141,6 +141,7 @@ public partial class CustomizationOptions // Create the initial set with all the easily accessible parameters available for anyone. var set = new CustomizationSet(race, gender) { + Voices = row.Voices, HairStyles = GetHairStyles(race, gender), HairColors = hair, SkinColors = skin, diff --git a/Glamourer.GameData/Customization/DatCharacterFile.cs b/Glamourer.GameData/Customization/DatCharacterFile.cs new file mode 100644 index 0000000..304334a --- /dev/null +++ b/Glamourer.GameData/Customization/DatCharacterFile.cs @@ -0,0 +1,148 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Dalamud.Memory; + +namespace Glamourer.Customization; + +[StructLayout(LayoutKind.Explicit, Size = Size)] +public unsafe struct DatCharacterFile +{ + public const int Size = 4 + 4 + 4 + 4 + Penumbra.GameData.Structs.CustomizeData.Size + 2 + 4 + 41 * 4; // 212 + + [FieldOffset(0)] + private fixed byte _data[Size]; + + [FieldOffset(0)] + public readonly uint Magic = 0x2013FF14; + + [FieldOffset(4)] + public readonly uint Version = 0x05; + + [FieldOffset(8)] + private uint _checksum; + + [FieldOffset(12)] + private readonly uint _padding = 0; + + [FieldOffset(16)] + private Penumbra.GameData.Structs.CustomizeData _customize; + + [FieldOffset(16 + Penumbra.GameData.Structs.CustomizeData.Size)] + private ushort _voice; + + [FieldOffset(16 + Penumbra.GameData.Structs.CustomizeData.Size + 2)] + private uint _timeStamp; + + [FieldOffset(Size - 41 * 4)] + private fixed byte _description[41 * 4]; + + public readonly void Write(Stream stream) + { + for (var i = 0; i < Size; ++i) + stream.WriteByte(_data[i]); + } + + public static bool Read(Stream stream, out DatCharacterFile file) + { + if (stream.Length - stream.Position != Size) + { + file = default; + return false; + } + + file = new DatCharacterFile(stream); + return true; + } + + private DatCharacterFile(Stream stream) + { + for (var i = 0; i < Size; ++i) + _data[i] = (byte)stream.ReadByte(); + } + + public DatCharacterFile(in Customize customize, byte voice, string text) + { + SetCustomize(customize); + SetVoice(voice); + SetTime(DateTimeOffset.UtcNow); + SetDescription(text); + _checksum = CalculateChecksum(); + } + + public readonly uint CalculateChecksum() + { + var ret = 0u; + for (var i = 16; i < Size; i++) + ret ^= (uint)(_data[i] << ((i - 16) % 24)); + return ret; + } + + public readonly uint Checksum + => _checksum; + + public Customize Customize + { + readonly get => new(_customize); + set + { + SetCustomize(value); + _checksum = CalculateChecksum(); + } + } + + public ushort Voice + { + readonly get => _voice; + set + { + SetVoice(value); + _checksum = CalculateChecksum(); + } + } + + public string Description + { + readonly get + { + fixed (byte* ptr = _description) + { + return MemoryHelper.ReadStringNullTerminated((nint)ptr); + } + } + set + { + SetDescription(value); + _checksum = CalculateChecksum(); + } + } + + public DateTimeOffset Time + { + readonly get => DateTimeOffset.FromUnixTimeSeconds(_timeStamp); + set + { + SetTime(value); + _checksum = CalculateChecksum(); + } + } + + private void SetTime(DateTimeOffset time) + => _timeStamp = (uint)time.ToUnixTimeSeconds(); + + private void SetCustomize(in Customize customize) + => _customize = customize.Data.Clone(); + + private void SetVoice(ushort voice) + => _voice = voice; + + private void SetDescription(string text) + { + fixed (byte* ptr = _description) + { + var span = new Span(ptr, 41 * 4); + Encoding.UTF8.GetBytes(text.AsSpan(0, Math.Min(40, text.Length)), span); + } + } +} diff --git a/Glamourer/Gui/Tabs/ActorTab/ActorPanel.cs b/Glamourer/Gui/Tabs/ActorTab/ActorPanel.cs index ae34869..a67afd0 100644 --- a/Glamourer/Gui/Tabs/ActorTab/ActorPanel.cs +++ b/Glamourer/Gui/Tabs/ActorTab/ActorPanel.cs @@ -33,6 +33,7 @@ public class ActorPanel private readonly DesignConverter _converter; private readonly ObjectManager _objects; private readonly DesignManager _designManager; + private readonly DatFileService _datFileService; private ActorIdentifier _identifier; private string _actorName = string.Empty; @@ -42,7 +43,7 @@ public class ActorPanel public ActorPanel(ActorSelector selector, StateManager stateManager, CustomizationDrawer customizationDrawer, EquipmentDrawer equipmentDrawer, IdentifierService identification, AutoDesignApplier autoDesignApplier, - Configuration config, DesignConverter converter, ObjectManager objects, DesignManager designManager) + Configuration config, DesignConverter converter, ObjectManager objects, DesignManager designManager, DatFileService datFileService) { _selector = selector; _stateManager = stateManager; @@ -54,6 +55,7 @@ public class ActorPanel _converter = converter; _objects = objects; _designManager = designManager; + _datFileService = datFileService; } public void Draw() @@ -63,6 +65,13 @@ public class ActorPanel (_actorName, _actor) = GetHeaderName(); DrawHeader(); DrawPanel(); + + if (_state is not { IsLocked: false }) + return; + + if (_datFileService.CreateImGuiTarget(out var dat)) + _stateManager.ChangeCustomize(_state!, dat.Customize, CustomizeFlagExtensions.AllRelevant, StateChanged.Source.Manual); + _datFileService.CreateSource(); } private void DrawHeader() @@ -113,7 +122,7 @@ public class ActorPanel private void DrawCustomizationsHeader() { - if (!ImGui.CollapsingHeader("Customizations")) + if (!ImGui.CollapsingHeader("Customization")) return; if (_customizationDrawer.Draw(_state!.ModelData.Customize, _state.IsLocked, _identifier.Type is IdentifierType.Special)) @@ -372,7 +381,8 @@ public class ActorPanel ? "Apply the current state to your current target." : "The current target can not be manipulated." : "No valid target selected."; - if (!ImGuiUtil.DrawDisabledButton("Apply to Target", Vector2.Zero, tt, !data.Valid || id == _identifier || !_state!.ModelData.IsHuman || _objects.IsInGPose)) + if (!ImGuiUtil.DrawDisabledButton("Apply to Target", Vector2.Zero, tt, + !data.Valid || id == _identifier || !_state!.ModelData.IsHuman || _objects.IsInGPose)) return; if (_stateManager.GetOrCreate(id, data.Objects[0], out var state)) diff --git a/Glamourer/Gui/Tabs/AutomationTab/DesignCombo.cs b/Glamourer/Gui/Tabs/AutomationTab/DesignCombo.cs index 03ffb45..a31601c 100644 --- a/Glamourer/Gui/Tabs/AutomationTab/DesignCombo.cs +++ b/Glamourer/Gui/Tabs/AutomationTab/DesignCombo.cs @@ -27,7 +27,7 @@ public sealed class DesignCombo : FilterComboCache if (_fileSystem.FindLeaf(Items[globalIdx], out var leaf)) { var fullName = leaf.FullName(); - if (fullName != Items[globalIdx].Name) + if (!fullName.StartsWith(Items[globalIdx].Name)) { using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled)); ImGui.SameLine(); diff --git a/Glamourer/Gui/Tabs/DebugTab.cs b/Glamourer/Gui/Tabs/DebugTab.cs index d800828..7924c4c 100644 --- a/Glamourer/Gui/Tabs/DebugTab.cs +++ b/Glamourer/Gui/Tabs/DebugTab.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Numerics; using System.Text; @@ -46,6 +47,7 @@ public unsafe class DebugTab : ITab private readonly ObjectManager _objectManager; private readonly GlamourerIpc _ipc; private readonly CodeService _code; + private readonly DatFileService _datFileService; private readonly ItemManager _items; private readonly ActorService _actors; @@ -74,7 +76,7 @@ public unsafe class DebugTab : ITab DesignFileSystem designFileSystem, DesignManager designManager, StateManager state, Configuration config, PenumbraChangedItemTooltip penumbraTooltip, MetaService metaService, GlamourerIpc ipc, DalamudPluginInterface pluginInterface, AutoDesignManager autoDesignManager, JobService jobs, CodeService code, CustomizeUnlockManager customizeUnlocks, - ItemUnlockManager itemUnlocks, DesignConverter designConverter) + ItemUnlockManager itemUnlocks, DesignConverter designConverter, DatFileService datFileService) { _changeCustomizeService = changeCustomizeService; _visorService = visorService; @@ -100,6 +102,7 @@ public unsafe class DebugTab : ITab _customizeUnlocks = customizeUnlocks; _itemUnlocks = itemUnlocks; _designConverter = designConverter; + _datFileService = datFileService; } public ReadOnlySpan Label @@ -130,6 +133,7 @@ public unsafe class DebugTab : ITab DrawModelEvaluation(); DrawObjectManager(); + DrawDatFiles(); } private void DrawModelEvaluation() @@ -258,6 +262,34 @@ public unsafe class DebugTab : ITab ImGuiClip.DrawEndDummy(remainder, ImGui.GetTextLineHeightWithSpacing()); } + private string _datFilePath = string.Empty; + private DatCharacterFile? _datFile = null; + + private void DrawDatFiles() + { + using var tree = ImRaii.TreeNode("Character Dat File"); + if (!tree) + return; + + ImGui.InputTextWithHint("##datFilePath", "Dat File Path...", ref _datFilePath, 256); + var exists = _datFilePath.Length > 0 && File.Exists(_datFilePath); + if (ImGuiUtil.DrawDisabledButton("Load##Dat", Vector2.Zero, string.Empty, !exists)) + _datFile = _datFileService.LoadDesign(_datFilePath, out var tmp) ? tmp : null; + + if (ImGuiUtil.DrawDisabledButton("Save##Dat", Vector2.Zero, string.Empty, _datFilePath.Length == 0 || _datFile == null)) + _datFileService.SaveDesign(_datFilePath, _datFile!.Value.Customize, _datFile!.Value.Description); + + if (_datFile != null) + { + ImGui.TextUnformatted(_datFile.Value.Magic.ToString()); + ImGui.TextUnformatted(_datFile.Value.Version.ToString()); + ImGui.TextUnformatted(_datFile.Value.Time.LocalDateTime.ToString("g")); + ImGui.TextUnformatted(_datFile.Value.Voice.ToString()); + ImGui.TextUnformatted(_datFile.Value.Customize.Data.ToString()); + ImGui.TextUnformatted(_datFile.Value.Description); + } + } + private void DrawVisor(Actor actor, Model model) { using var id = ImRaii.PushId("Visor"); diff --git a/Glamourer/Gui/Tabs/DesignTab/DesignPanel.cs b/Glamourer/Gui/Tabs/DesignTab/DesignPanel.cs index 648ea43..746ba1d 100644 --- a/Glamourer/Gui/Tabs/DesignTab/DesignPanel.cs +++ b/Glamourer/Gui/Tabs/DesignTab/DesignPanel.cs @@ -3,7 +3,9 @@ using System.Collections.Generic; using System.Linq; using System.Numerics; using Dalamud.Interface; +using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.Internal.Notifications; +using FFXIVClientStructs.FFXIV.Client.System.Framework; using Glamourer.Automation; using Glamourer.Customization; using Glamourer.Designs; @@ -11,7 +13,6 @@ using Glamourer.Events; using Glamourer.Gui.Customization; using Glamourer.Gui.Equipment; using Glamourer.Interop; -using Glamourer.Interop.Penumbra; using Glamourer.Services; using Glamourer.State; using Glamourer.Structs; @@ -34,10 +35,12 @@ public class DesignPanel private readonly ModAssociationsTab _modAssociations; private readonly DesignDetailTab _designDetails; private readonly DesignConverter _converter; + private readonly DatFileService _datFileService; + private readonly FileDialogManager _fileDialog = new(); public DesignPanel(DesignFileSystemSelector selector, CustomizationDrawer customizationDrawer, DesignManager manager, ObjectManager objects, - StateManager state, EquipmentDrawer equipmentDrawer, CustomizationService customizationService, PenumbraService penumbra, - ModAssociationsTab modAssociations, DesignDetailTab designDetails, DesignConverter converter) + StateManager state, EquipmentDrawer equipmentDrawer, CustomizationService customizationService, ModAssociationsTab modAssociations, + DesignDetailTab designDetails, DesignConverter converter, DatFileService datFileService) { _selector = selector; _customizationDrawer = customizationDrawer; @@ -49,6 +52,7 @@ public class DesignPanel _modAssociations = modAssociations; _designDetails = designDetails; _converter = converter; + _datFileService = datFileService; } private HeaderDrawer.Button LockButton() @@ -199,7 +203,7 @@ public class DesignPanel if (ImGui.CheckboxFlags("Apply All Customizations", ref flags, 3)) { var newFlags = flags == 3; - _manager.ChangeApplyCustomize(_selector.Selected!, CustomizeIndex.Clan, newFlags); + _manager.ChangeApplyCustomize(_selector.Selected!, CustomizeIndex.Clan, newFlags); _manager.ChangeApplyCustomize(_selector.Selected!, CustomizeIndex.Gender, newFlags); foreach (var index in CustomizationExtensions.AllBasic.Where(set.IsAvailable)) _manager.ChangeApplyCustomize(_selector.Selected!, index, newFlags); @@ -291,10 +295,26 @@ public class DesignPanel { using var group = ImRaii.Group(); DrawHeader(); + DrawPanel(); - var design = _selector.Selected; - using var child = ImRaii.Child("##Panel", -Vector2.One, true); - if (!child || design == null) + if (_selector.Selected == null || _selector.Selected.WriteProtected()) + return; + + if (_datFileService.CreateImGuiTarget(out var dat)) + { + _manager.ChangeCustomize(_selector.Selected!, CustomizeIndex.Clan, dat.Customize[CustomizeIndex.Clan]); + _manager.ChangeCustomize(_selector.Selected!, CustomizeIndex.Gender, dat.Customize[CustomizeIndex.Gender]); + foreach (var idx in CustomizationExtensions.AllBasic) + _manager.ChangeCustomize(_selector.Selected!, idx, dat.Customize[idx]); + } + + _datFileService.CreateSource(); + } + + private void DrawPanel() + { + using var child = ImRaii.Child("##Panel", -Vector2.One, true); + if (!child || _selector.Selected == null) return; DrawButtonRow(); @@ -310,6 +330,8 @@ public class DesignPanel DrawApplyToSelf(); ImGui.SameLine(); DrawApplyToTarget(); + ImGui.SameLine(); + DrawSaveToDat(); } private void SetFromClipboard() @@ -367,6 +389,25 @@ public class DesignPanel _state.ApplyDesign(_selector.Selected!, state, StateChanged.Source.Manual); } + private void DrawSaveToDat() + { + var verified = _datFileService.Verify(_selector.Selected!.DesignData.Customize, out var voice); + var tt = verified + ? "Export the currently configured customizations of this design to a character creation data file." + : "The current design contains customizations that can not be applied during character creation."; + var startPath = GetUserPath(); + if (startPath.Length == 0) + startPath = null; + if (ImGuiUtil.DrawDisabledButton("Export to Dat", Vector2.Zero, tt, !verified)) + _fileDialog.SaveFileDialog("Save File...", ".dat", "FFXIV_CHARA_01.dat", ".dat", (v, path) => + { + if (v && _selector.Selected != null) + _datFileService.SaveDesign(path, _selector.Selected!.DesignData.Customize, _selector.Selected!.Name); + }, startPath); + + _fileDialog.Draw(); + } + private void ApplyChanges(ActorState.MetaIndex index, DataChange change, bool value, bool apply) { switch (change) @@ -383,4 +424,7 @@ public class DesignPanel break; } } + + private static unsafe string GetUserPath() + => Framework.Instance()->UserPath; } diff --git a/Glamourer/Interop/DatFileService.cs b/Glamourer/Interop/DatFileService.cs new file mode 100644 index 0000000..12cfd11 --- /dev/null +++ b/Glamourer/Interop/DatFileService.cs @@ -0,0 +1,163 @@ +using System; +using System.IO; +using System.Linq; +using Dalamud.Interface.DragDrop; +using Dalamud.Interface.Internal.Notifications; +using Glamourer.Customization; +using Glamourer.Services; +using Glamourer.Unlocks; +using ImGuiNET; + +namespace Glamourer.Interop; + +public class DatFileService +{ + private readonly CustomizationService _customizations; + private readonly CustomizeUnlockManager _unlocks; + private readonly IDragDropManager _dragDropManager; + + public DatFileService(CustomizationService customizations, CustomizeUnlockManager unlocks, IDragDropManager dragDropManager) + { + _customizations = customizations; + _unlocks = unlocks; + _dragDropManager = dragDropManager; + } + + public void CreateSource() + { + _dragDropManager.CreateImGuiSource("DatDragger", m => m.Files.Count == 1 && m.Extensions.Contains(".dat"), m => + { + ImGui.TextUnformatted($"Dragging {Path.GetFileName(m.Files[0])} to import customizations for Glamourer..."); + return true; + }); + } + + public bool CreateImGuiTarget(out DatCharacterFile file) + { + if (!_dragDropManager.CreateImGuiTarget("DatDragger", out var files, out _) || files.Count != 1) + { + file = default; + return false; + } + + return LoadDesign(files[0], out file); + } + + public bool LoadDesign(string path, out DatCharacterFile file) + { + if (!File.Exists(path)) + { + file = default; + return false; + } + + try + { + using var stream = File.OpenRead(path); + if (!DatCharacterFile.Read(stream, out file)) + return false; + + if (!Verify(file)) + return false; + } + catch (Exception ex) + { + Glamourer.Chat.NotificationMessage(ex, $"Could not read character data file {path}.", + $"Could not read character data file {path}", "Failure", NotificationType.Error); + file = default; + } + + return true; + } + + public bool SaveDesign(string path, in Customize input, string description) + { + if (!Verify(input, out var voice)) + return false; + + if (description.Length > 40) + return false; + + if (path.Length == 0) + return false; + + try + { + var file = new DatCharacterFile(input, voice, description); + var directories = Path.GetDirectoryName(path); + if (directories != null) + Directory.CreateDirectory(directories); + using var stream = File.Open(path, File.Exists(path) ? FileMode.Truncate : FileMode.CreateNew); + file.Write(stream); + + return true; + } + catch (Exception ex) + { + Glamourer.Chat.NotificationMessage(ex, $"Could not save character data to file {path}.", + $"Could not save character data to file {path}", "Failure", NotificationType.Error); + return false; + } + } + + public bool Verify(in Customize input, out byte voice, byte? inputVoice = null) + { + voice = 0; + if (_customizations.ValidateClan(input.Clan, input.Race, out _, out _).Length > 0) + return false; + if (!_customizations.IsGenderValid(input.Race, input.Gender)) + return false; + if (input.BodyType.Value != 1) + return false; + + var set = _customizations.AwaitedService.GetList(input.Clan, input.Gender); + voice = set.Voices[0]; + if (inputVoice.HasValue && !set.Voices.Contains(inputVoice.Value)) + return false; + + foreach (var index in Enum.GetValues()) + { + switch (index) + { + case CustomizeIndex.Race: + case CustomizeIndex.BodyType: + case CustomizeIndex.Gender: + case CustomizeIndex.Clan: + continue; + case CustomizeIndex.Hairstyle: + case CustomizeIndex.FacePaint: + if (set.DataByValue(index, input[index], out var data, input.Face) < 0 + || data == null + || _unlocks.Unlockable.ContainsKey(data.Value)) + return false; + + break; + default: + if (!CustomizationService.IsCustomizationValid(set, input.Face, index, input[index])) + return false; + + break; + } + } + + if (input[CustomizeIndex.LegacyTattoo].Value != 0) + return false; + + return true; + } + + public bool Verify(in DatCharacterFile datFile) + { + var customize = datFile.Customize; + if (!Verify(customize, out _, (byte)datFile.Voice)) + return false; + + if (datFile.Time < DateTimeOffset.UnixEpoch || datFile.Time > DateTimeOffset.UtcNow) + return false; + + if (datFile.Checksum != datFile.CalculateChecksum()) + return false; + + return true; + } +} diff --git a/Glamourer/Services/CustomizationService.cs b/Glamourer/Services/CustomizationService.cs index 850e19e..f169628 100644 --- a/Glamourer/Services/CustomizationService.cs +++ b/Glamourer/Services/CustomizationService.cs @@ -277,7 +277,7 @@ public sealed class CustomizationService : AsyncServiceWrapper()) + foreach (var idx in CustomizationExtensions.AllBasic) { if (set.IsAvailable(idx)) { diff --git a/Glamourer/Services/DalamudServices.cs b/Glamourer/Services/DalamudServices.cs index 787ad12..2864f9b 100644 --- a/Glamourer/Services/DalamudServices.cs +++ b/Glamourer/Services/DalamudServices.cs @@ -5,6 +5,7 @@ using Dalamud.Game.ClientState.Keys; using Dalamud.Game.ClientState.Objects; using Dalamud.Game.Command; using Dalamud.Game.Gui; +using Dalamud.Interface.DragDrop; using Dalamud.IoC; using Dalamud.Plugin; using Microsoft.Extensions.DependencyInjection; @@ -32,6 +33,7 @@ public class DalamudServices services.AddSingleton(KeyState); services.AddSingleton(this); services.AddSingleton(PluginInterface.UiBuilder); + services.AddSingleton(DragDropManager); } // @formatter:off @@ -45,5 +47,6 @@ public class DalamudServices [PluginService][RequiredVersion("1.0")] public TargetManager Targets { get; private set; } = null!; [PluginService][RequiredVersion("1.0")] public ObjectTable Objects { get; private set; } = null!; [PluginService][RequiredVersion("1.0")] public KeyState KeyState { get; private set; } = null!; + [PluginService][RequiredVersion("1.0")] public IDragDropManager DragDropManager { get; private set; } = null!; // @formatter:on } diff --git a/Glamourer/Services/ServiceManager.cs b/Glamourer/Services/ServiceManager.cs index f1c9746..015bd1e 100644 --- a/Glamourer/Services/ServiceManager.cs +++ b/Glamourer/Services/ServiceManager.cs @@ -88,7 +88,8 @@ public static class ServiceManager .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); private static IServiceCollection AddDesigns(this IServiceCollection services) => services.AddSingleton()