diff --git a/Glamourer.GameData/Customization/CustomizeData.cs b/Glamourer.GameData/Customization/CustomizeData.cs index 6707d95..8e9f914 100644 --- a/Glamourer.GameData/Customization/CustomizeData.cs +++ b/Glamourer.GameData/Customization/CustomizeData.cs @@ -1,11 +1,12 @@ -using System.Runtime.InteropServices; +using System; +using System.Runtime.InteropServices; namespace Glamourer.Customization; // Any customization value can be represented in 8 bytes by its ID, // a byte value, an optional value-id and an optional icon or color. [StructLayout(LayoutKind.Explicit)] -public readonly struct CustomizeData +public readonly struct CustomizeData : IEquatable { [FieldOffset(0)] public readonly CustomizeIndex Index; @@ -24,10 +25,21 @@ public readonly struct CustomizeData public CustomizeData(CustomizeIndex index, CustomizeValue value, uint data = 0, ushort customizeId = 0) { - Index = index; + Index = index; Value = value; IconId = data; Color = data; CustomizeId = customizeId; } + + public bool Equals(CustomizeData other) + => Index == other.Index + && Value.Value == other.Value.Value + && CustomizeId == other.CustomizeId; + + public override bool Equals(object? obj) + => obj is CustomizeData other && Equals(other); + + public override int GetHashCode() + => HashCode.Combine((int)Index, Value.Value, CustomizeId); } diff --git a/Glamourer/Automation/AutoDesignManager.cs b/Glamourer/Automation/AutoDesignManager.cs index 1e86fde..09239e6 100644 --- a/Glamourer/Automation/AutoDesignManager.cs +++ b/Glamourer/Automation/AutoDesignManager.cs @@ -11,6 +11,7 @@ using Glamourer.Events; using Glamourer.Interop; using Glamourer.Services; using Glamourer.Structs; +using Glamourer.Unlocks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using OtterGui.Filesystem; @@ -28,6 +29,7 @@ public class AutoDesignManager : ISavable, IReadOnlyList private readonly DesignManager _designs; private readonly ActorService _actors; private readonly AutomationChanged _event; + private readonly ItemUnlockManager _unlockManager; private readonly List _data = new(); private readonly Dictionary _enabled = new(); @@ -36,13 +38,14 @@ public class AutoDesignManager : ISavable, IReadOnlyList => _enabled; public AutoDesignManager(JobService jobs, ActorService actors, SaveService saveService, DesignManager designs, AutomationChanged @event, - FixedDesignMigrator migrator, DesignFileSystem fileSystem) + FixedDesignMigrator migrator, DesignFileSystem fileSystem, ItemUnlockManager unlockManager) { - _jobs = jobs; - _actors = actors; - _saveService = saveService; - _designs = designs; - _event = @event; + _jobs = jobs; + _actors = actors; + _saveService = saveService; + _designs = designs; + _event = @event; + _unlockManager = unlockManager; Load(); migrator.ConsumeMigratedData(_actors, fileSystem, this); } diff --git a/Glamourer/Gui/Tabs/DebugTab.cs b/Glamourer/Gui/Tabs/DebugTab.cs index ada210e..f6e647d 100644 --- a/Glamourer/Gui/Tabs/DebugTab.cs +++ b/Glamourer/Gui/Tabs/DebugTab.cs @@ -18,6 +18,7 @@ using Glamourer.Interop.Penumbra; using Glamourer.Interop.Structs; using Glamourer.Services; using Glamourer.State; +using Glamourer.Unlocks; using ImGuiNET; using OtterGui; using OtterGui.Raii; @@ -43,10 +44,12 @@ public unsafe class DebugTab : ITab private readonly GlamourerIpc _ipc; private readonly PhrasingService _phrasing; - private readonly ItemManager _items; - private readonly ActorService _actors; - private readonly CustomizationService _customization; - private readonly JobService _jobs; + private readonly ItemManager _items; + private readonly ActorService _actors; + private readonly CustomizationService _customization; + private readonly JobService _jobs; + private readonly CustomizeUnlockManager _customizeUnlocks; + private readonly CustomizeUnlockManager _itemUnlocks; private readonly DesignManager _designManager; private readonly DesignFileSystem _designFileSystem; @@ -66,7 +69,7 @@ public unsafe class DebugTab : ITab ActorService actors, ItemManager items, CustomizationService customization, ObjectManager objectManager, DesignFileSystem designFileSystem, DesignManager designManager, StateManager state, Configuration config, PenumbraChangedItemTooltip penumbraTooltip, MetaService metaService, GlamourerIpc ipc, DalamudPluginInterface pluginInterface, - AutoDesignManager autoDesignManager, JobService jobs, PhrasingService phrasing) + AutoDesignManager autoDesignManager, JobService jobs, PhrasingService phrasing, CustomizeUnlockManager customizeUnlocks) { _changeCustomizeService = changeCustomizeService; _visorService = visorService; @@ -89,6 +92,7 @@ public unsafe class DebugTab : ITab _autoDesignManager = autoDesignManager; _jobs = jobs; _phrasing = phrasing; + _customizeUnlocks = customizeUnlocks; } public ReadOnlySpan Label @@ -106,6 +110,7 @@ public unsafe class DebugTab : ITab DrawDesigns(); DrawState(); DrawAutoDesigns(); + DrawUnlocks(); DrawIpc(); } @@ -1236,6 +1241,61 @@ public unsafe class DebugTab : ITab #endregion + #region Unlocks + + private void DrawUnlocks() + { + if (!ImGui.CollapsingHeader("Unlocks")) + return; + + DrawCustomizationUnlocks(); + DrawItemUnlocks(); + } + + private void DrawCustomizationUnlocks() + { + using var tree = ImRaii.TreeNode("Customization"); + if (!tree) + return; + + + using var table = ImRaii.Table("customizationUnlocks", 6, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY, + new Vector2(ImGui.GetContentRegionAvail().X, 12 * ImGui.GetTextLineHeight())); + if (!table) + return; + + ImGui.TableNextColumn(); + var skips = ImGuiClip.GetNecessarySkips(ImGui.GetTextLineHeightWithSpacing()); + ImGui.TableNextRow(); + var remainder = ImGuiClip.ClippedDraw(_customizeUnlocks.Unlockable, skips, t => + { + ImGuiUtil.DrawTableColumn(t.Key.Index.ToDefaultName()); + ImGuiUtil.DrawTableColumn(t.Key.CustomizeId.ToString()); + ImGuiUtil.DrawTableColumn(t.Key.Value.Value.ToString()); + ImGuiUtil.DrawTableColumn(t.Value.Data.ToString()); + ImGuiUtil.DrawTableColumn(t.Value.Name); + ImGuiUtil.DrawTableColumn(_customizeUnlocks.IsUnlocked(t.Key, out var time) + ? time == DateTimeOffset.MaxValue ? "Always" : time.LocalDateTime.ToShortDateString() + : "Never"); + }, _customizeUnlocks.Unlockable.Count); + ImGuiClip.DrawEndDummy(remainder, ImGui.GetTextLineHeight()); + } + + private void DrawItemUnlocks() + { + using var tree = ImRaii.TreeNode("Item"); + if (!tree) + return; + + + using var table = ImRaii.Table("itemUnlocks", 6, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); + if (!table) + return; + } + + #endregion + #region IPC private string _gameObjectName = string.Empty; diff --git a/Glamourer/Interop/MetaService.cs b/Glamourer/Interop/MetaService.cs index 4528abb..3fa13c9 100644 --- a/Glamourer/Interop/MetaService.cs +++ b/Glamourer/Interop/MetaService.cs @@ -3,7 +3,6 @@ using Dalamud.Hooking; using FFXIVClientStructs.FFXIV.Client.Game.Character; using Glamourer.Events; using Glamourer.Interop.Structs; -using static OtterGui.Raii.ImRaii; namespace Glamourer.Interop; @@ -39,7 +38,7 @@ public unsafe class MetaService : IDisposable if (!actor.IsCharacter) return; - _hideHatGearHook.Original(&actor.AsCharacter->DrawData, 0, (byte) (value ? 1 : 0)); + _hideHatGearHook.Original(&actor.AsCharacter->DrawData, 0, (byte)(value ? 1 : 0)); } public void SetWeaponState(Actor actor, bool value) @@ -53,10 +52,10 @@ public unsafe class MetaService : IDisposable private void HideHatDetour(DrawDataContainer* drawData, uint id, byte value) { Actor actor = drawData->Parent; - var v = value == 0; + var v = value == 0; _headGearEvent.Invoke(actor, ref v); - value = (byte) (v ? 0 : 1); - Glamourer.Log.Information($"[MetaService] Hide Hat triggered with 0x{(nint)drawData:X} {id} {value} for {actor.Utf8Name}."); + value = (byte)(v ? 0 : 1); + Glamourer.Log.Verbose($"[MetaService] Hide Hat triggered with 0x{(nint)drawData:X} {id} {value} for {actor.Utf8Name}."); _hideHatGearHook.Original(drawData, id, value); } @@ -66,7 +65,7 @@ public unsafe class MetaService : IDisposable value = !value; _weaponEvent.Invoke(actor, ref value); value = !value; - Glamourer.Log.Information($"[MetaService] Hide Weapon triggered with 0x{(nint)drawData:X} {value} for {actor.Utf8Name}."); + Glamourer.Log.Verbose($"[MetaService] Hide Weapon triggered with 0x{(nint)drawData:X} {value} for {actor.Utf8Name}."); _hideWeaponsHook.Original(drawData, value); } } diff --git a/Glamourer/Services/BackupService.cs b/Glamourer/Services/BackupService.cs index b98de25..39e9ce2 100644 --- a/Glamourer/Services/BackupService.cs +++ b/Glamourer/Services/BackupService.cs @@ -21,6 +21,9 @@ public class BackupService new(fileNames.ConfigFile), new(fileNames.DesignFileSystem), new(fileNames.MigrationDesignFile), + new(fileNames.AutomationFile), + new(fileNames.UnlockFileCustomize), + new(fileNames.UnlockFileItems), }; list.AddRange(fileNames.Designs()); diff --git a/Glamourer/Services/FilenameService.cs b/Glamourer/Services/FilenameService.cs index e5f21ba..5a5f554 100644 --- a/Glamourer/Services/FilenameService.cs +++ b/Glamourer/Services/FilenameService.cs @@ -13,6 +13,8 @@ public class FilenameService public readonly string MigrationDesignFile; public readonly string DesignDirectory; public readonly string AutomationFile; + public readonly string UnlockFileCustomize; + public readonly string UnlockFileItems; public FilenameService(DalamudPluginInterface pi) { @@ -21,6 +23,8 @@ public class FilenameService AutomationFile = Path.Combine(ConfigDirectory, "automation.json"); DesignFileSystem = Path.Combine(ConfigDirectory, "sort_order.json"); MigrationDesignFile = Path.Combine(ConfigDirectory, "Designs.json"); + UnlockFileCustomize = Path.Combine(ConfigDirectory, "unlocks_customize.json"); + UnlockFileItems = Path.Combine(ConfigDirectory, "unlocks_items.json"); DesignDirectory = Path.Combine(ConfigDirectory, "designs"); } diff --git a/Glamourer/Services/ServiceManager.cs b/Glamourer/Services/ServiceManager.cs index 639ef2d..0860214 100644 --- a/Glamourer/Services/ServiceManager.cs +++ b/Glamourer/Services/ServiceManager.cs @@ -13,6 +13,7 @@ using Glamourer.Gui.Tabs.DesignTab; using Glamourer.Interop; using Glamourer.Interop.Penumbra; using Glamourer.State; +using Glamourer.Unlocks; using Microsoft.Extensions.DependencyInjection; using OtterGui.Classes; using OtterGui.Log; @@ -80,7 +81,9 @@ public static class ServiceManager .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton() + .AddSingleton(); private static IServiceCollection AddDesigns(this IServiceCollection services) => services.AddSingleton() diff --git a/Glamourer/State/StateEditor.cs b/Glamourer/State/StateEditor.cs index 2d79a00..f3fcca1 100644 --- a/Glamourer/State/StateEditor.cs +++ b/Glamourer/State/StateEditor.cs @@ -20,11 +20,12 @@ public class StateEditor private readonly UpdateSlotService _updateSlot; private readonly VisorService _visor; private readonly WeaponService _weapon; + private readonly MetaService _metaService; private readonly ChangeCustomizeService _changeCustomize; private readonly ItemManager _items; public StateEditor(UpdateSlotService updateSlot, VisorService visor, WeaponService weapon, ChangeCustomizeService changeCustomize, - ItemManager items, PenumbraService penumbra) + ItemManager items, PenumbraService penumbra, MetaService metaService) { _updateSlot = updateSlot; _visor = visor; @@ -32,6 +33,7 @@ public class StateEditor _changeCustomize = changeCustomize; _items = items; _penumbra = penumbra; + _metaService = metaService; } /// Changing the model ID simply requires guaranteed redrawing. @@ -152,13 +154,13 @@ public class StateEditor public unsafe void ChangeHatState(ActorData data, bool value) { foreach (var actor in data.Objects.Where(a => a.IsCharacter)) - actor.AsCharacter->DrawData.HideHeadgear(0, !value); + _metaService.SetHatState(actor, value); } /// Change the weapon-visibility state on actors. public unsafe void ChangeWeaponState(ActorData data, bool value) { foreach (var actor in data.Objects.Where(a => a.IsCharacter)) - actor.AsCharacter->DrawData.HideWeapons(!value); + _metaService.SetWeaponState(actor, value); } } diff --git a/Glamourer/Unlocks/CustomizeUnlockManager.cs b/Glamourer/Unlocks/CustomizeUnlockManager.cs new file mode 100644 index 0000000..ddffa8f --- /dev/null +++ b/Glamourer/Unlocks/CustomizeUnlockManager.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Dalamud; +using Dalamud.Data; +using Dalamud.Game.ClientState; +using Dalamud.Hooking; +using Dalamud.Utility; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using Glamourer.Customization; +using Glamourer.Services; +using Lumina.Excel.GeneratedSheets; + +namespace Glamourer.Unlocks; + +public class CustomizeUnlockManager : IDisposable, ISavable +{ + private readonly SaveService _saveService; + private readonly ClientState _clientState; + private readonly Dictionary _unlocked = new(); + + public readonly IReadOnlyDictionary Unlockable; + + public IReadOnlyDictionary Unlocked + => _unlocked; + + public unsafe CustomizeUnlockManager(SaveService saveService, CustomizationService customizations, DataManager gameData, + ClientState clientState) + { + SignatureHelper.Initialise(this); + _saveService = saveService; + _clientState = clientState; + Unlockable = CreateUnlockableCustomizations(customizations, gameData); + Load(); + _setUnlockLinkValueHook.Enable(); + _clientState.Login += OnLogin; + Scan(); + } + + public void Dispose() + { + _setUnlockLinkValueHook.Dispose(); + _clientState.Login -= OnLogin; + } + + /// Check if a customization is unlocked for Glamourer. + public bool IsUnlocked(CustomizeData data, out DateTimeOffset time) + { + if (!Unlockable.TryGetValue(data, out var pair)) + { + time = DateTime.MaxValue; + return true; + } + + if (_unlocked.TryGetValue(pair.Data, out var t)) + { + time = DateTimeOffset.FromUnixTimeMilliseconds(t); + return true; + } + + if (!IsUnlockedGame(pair.Data)) + { + time = DateTimeOffset.MinValue; + return false; + } + + _unlocked.TryAdd(pair.Data, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + Save(); + time = DateTimeOffset.UtcNow; + return true; + } + + /// Check if a customization is currently unlocked for the game state. + public unsafe bool IsUnlockedGame(uint dataId) + { + var instance = UIState.Instance(); + if (instance == null) + return false; + + return UIState.Instance()->IsUnlockLinkUnlocked(dataId); + } + + /// Scan and update all unlockable customizations for their current game state. + public unsafe void Scan() + { + if (_clientState.LocalPlayer == null) + return; + + Glamourer.Log.Debug("[UnlockManager] Scanning for new unlocked customizations."); + var instance = UIState.Instance(); + if (instance == null) + return; + + try + { + var count = 0; + var time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + foreach (var (_, (id, _)) in Unlockable) + { + if (instance->IsUnlockLinkUnlocked(id) && _unlocked.TryAdd(id, time)) + ++count; + } + + if (count <= 0) + return; + + Save(); + Glamourer.Log.Debug($"[UnlockManager] Found {count} new unlocked customizations.."); + } + catch (Exception ex) + { + Glamourer.Log.Error($"[UnlockManager] Error scanning for newly unlocked customizations:\n{ex}"); + } + } + + private delegate void SetUnlockLinkValueDelegate(nint uiState, uint data, byte value); + + [Signature("48 83 EC ?? 8B C2 44 8B D2", DetourName = nameof(SetUnlockLinkValueDetour))] + private readonly Hook _setUnlockLinkValueHook = null!; + + private void SetUnlockLinkValueDetour(nint uiState, uint data, byte value) + { + _setUnlockLinkValueHook.Original(uiState, data, value); + try + { + if (value == 0) + return; + + var time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + foreach (var (_, (id, _)) in Unlockable) + { + if (id != data || !_unlocked.TryAdd(id, time)) + continue; + + Save(); + break; + } + } + catch (Exception ex) + { + Glamourer.Log.Error($"[UnlockManager] Error in SetUnlockLinkValue Hook:\n{ex}"); + } + } + + private void OnLogin(object? _, EventArgs _2) + => Scan(); + + public string ToFilename(FilenameService fileNames) + => fileNames.UnlockFileCustomize; + + public void Save() + => _saveService.QueueSave(this); + + public void Save(StreamWriter writer) + { } + + private void Load() + { + var file = ToFilename(_saveService.FileNames); + if (!File.Exists(file)) + return; + + _unlocked.Clear(); + } + + /// Create a list of all unlockable hairstyles and facepaints. + private static Dictionary CreateUnlockableCustomizations(CustomizationService customizations, + DataManager gameData) + { + var ret = new Dictionary(); + var sheet = gameData.GetExcelSheet(ClientLanguage.English)!; + foreach (var clan in customizations.AwaitedService.Clans) + { + foreach (var gender in customizations.AwaitedService.Genders) + { + var list = customizations.AwaitedService.GetList(clan, gender); + foreach (var hair in list.HairStyles) + { + var x = sheet.FirstOrDefault(f => f.FeatureID == hair.Value.Value); + if (x?.IsPurchasable == true) + { + var name = x.FeatureID == 61 + ? "Eternal Bond" + : x.HintItem.Value?.Name.ToDalamudString().ToString().Replace("Modern Aesthetics - ", string.Empty) + ?? string.Empty; + ret.TryAdd(hair, (x.Data, name)); + } + } + + foreach (var paint in list.FacePaints) + { + var x = sheet.FirstOrDefault(f => f.FeatureID == paint.Value.Value); + if (x?.IsPurchasable == true) + { + var name = x.HintItem.Value?.Name.ToDalamudString().ToString().Replace("Modern Cosmetics - ", string.Empty) + ?? string.Empty; + ret.TryAdd(paint, (x.Data, name)); + } + } + } + } + + return ret; + } +} diff --git a/Glamourer/Unlocks/ItemUnlockManager.cs b/Glamourer/Unlocks/ItemUnlockManager.cs new file mode 100644 index 0000000..8a3cc9d --- /dev/null +++ b/Glamourer/Unlocks/ItemUnlockManager.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.Game.ClientState; +using Dalamud.Hooking; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using Glamourer.Services; +using Lumina.Excel; +using Lumina.Excel.GeneratedSheets; +using Achievement = FFXIVClientStructs.FFXIV.Client.Game.UI.Achievement; +using Cabinet = Lumina.Excel.GeneratedSheets.Cabinet; + +namespace Glamourer.Unlocks; + +public class ItemUnlockManager : ISavable, IDisposable +{ + private readonly SaveService _saveService; + private readonly ItemManager _items; + private readonly ClientState _clientState; + private readonly Framework _framework; + private readonly Dictionary _unlocked = new(); + + + public enum UnlockType : byte + { + Cabinet, + Quest, + Achievement, + } + + public readonly IReadOnlyDictionary _unlockable; + + public IReadOnlyDictionary Unlocked + => _unlocked; + + public ItemUnlockManager(SaveService saveService, ItemManager items, ClientState clientState, DataManager gameData, Framework framework) + { + SignatureHelper.Initialise(this); + _saveService = saveService; + _items = items; + _clientState = clientState; + _framework = framework; + _unlockable = CreateUnlockData(gameData, items); + Load(); + _clientState.Login += OnLogin; + _framework.Update += OnFramework; + Scan(); + } + + //private Achievement.AchievementState _achievementState = Achievement.AchievementState.Invalid; + + private unsafe void OnFramework(Framework _) + { + //var achievement = Achievement.Instance(); + var uiState = UIState.Instance(); + } + + public bool IsUnlocked(uint itemId) + { + // Pseudo items are always unlocked. + if (itemId >= _items.ItemSheet.RowCount) + return true; + + if (_unlocked.ContainsKey(itemId)) + return true; + + // TODO + return false; + } + + public unsafe bool IsGameUnlocked(uint id, UnlockType type) + { + var uiState = UIState.Instance(); + if (uiState == null) + return false; + + return type switch + { + UnlockType.Cabinet => uiState->Cabinet.IsCabinetLoaded() && uiState->Cabinet.IsItemInCabinet((int)id), + UnlockType.Quest => uiState->IsUnlockLinkUnlockedOrQuestCompleted(id), + UnlockType.Achievement => false, + _ => false, + }; + } + + public void Dispose() + { + _clientState.Login -= OnLogin; + _framework.Update -= OnFramework; + } + + public void Scan() + { + // TODO + } + + public string ToFilename(FilenameService fileNames) + => fileNames.UnlockFileItems; + + public void Save() + => _saveService.QueueSave(this); + + public void Save(StreamWriter writer) + { } + + private void Load() + { + var file = ToFilename(_saveService.FileNames); + if (!File.Exists(file)) + return; + + _unlocked.Clear(); + } + + private void OnLogin(object? _, EventArgs _2) + => Scan(); + + private static Dictionary CreateUnlockData(DataManager gameData, ItemManager items) + { + var ret = new Dictionary(); + var cabinet = gameData.GetExcelSheet()!; + foreach (var row in cabinet) + { + if (items.ItemService.AwaitedService.TryGetValue(row.Item.Row, out var item)) + ret.TryAdd(item.Id, (row.RowId, UnlockType.Cabinet)); + } + + var gilShop = gameData.GetExcelSheet()!; + // TODO + + return ret; + } +}