From 6ade5b21cfad0d951ca92471906a1d21aca23c01 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Wed, 1 Oct 2025 17:06:06 +0200 Subject: [PATCH 01/18] Add IUnlockState service --- Dalamud/Game/ItemActionType.cs | 76 +++ Dalamud/Game/UnlockState/UnlockState.cs | 798 ++++++++++++++++++++++++ Dalamud/Plugin/Services/IUnlockState.cs | 297 +++++++++ 3 files changed, 1171 insertions(+) create mode 100644 Dalamud/Game/ItemActionType.cs create mode 100644 Dalamud/Game/UnlockState/UnlockState.cs create mode 100644 Dalamud/Plugin/Services/IUnlockState.cs diff --git a/Dalamud/Game/ItemActionType.cs b/Dalamud/Game/ItemActionType.cs new file mode 100644 index 000000000..3f2ac5f17 --- /dev/null +++ b/Dalamud/Game/ItemActionType.cs @@ -0,0 +1,76 @@ +using Lumina.Excel.Sheets; + +namespace Dalamud.Game; + +/// +/// Enum for . +/// +public enum ItemActionType : ushort +{ + /// + /// Used to unlock a companion (minion). + /// + Companion = 853, + + /// + /// Used to unlock a chocobo companion barding. + /// + BuddyEquip = 1013, + + /// + /// Used to unlock a mount. + /// + Mount = 1322, + + /// + /// Used to unlock recipes from a crafting recipe book. + /// + SecretRecipeBook = 2136, + + /// + /// Used to unlock various types of content (e.g. Riding Maps, Blue Mage Totems, Emotes, Hairstyles). + /// + UnlockLink = 2633, + + /// + /// Used to unlock a Triple Triad Card. + /// + TripleTriadCard = 3357, + + /// + /// Used to unlock gathering nodes of a Folklore Tome. + /// + FolkloreTome = 4107, + + /// + /// Used to unlock an Orchestrion Roll. + /// + OrchestrionRoll = 25183, + + /// + /// Used to unlock portrait designs. + /// + FramersKit = 29459, + + /// + /// Used to unlock Bozjan Field Notes. These are server-side but are cached client-side. + /// + FieldNotes = 19743, + + /// + /// Used to unlock an Ornament (fashion accessory). + /// + Ornament = 20086, + + /// + /// Used to unlock glasses. + /// + Glasses = 37312, + + /// + /// Used for Company Seal Vouchers, which convert the item into Company Seals when used.
+ /// Can be used only if in a Grand Company.
+ /// IsUnlocked always returns false. + ///
+ CompanySealVouchers = 41120, +} diff --git a/Dalamud/Game/UnlockState/UnlockState.cs b/Dalamud/Game/UnlockState/UnlockState.cs new file mode 100644 index 000000000..c84d1e73c --- /dev/null +++ b/Dalamud/Game/UnlockState/UnlockState.cs @@ -0,0 +1,798 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; + +using Dalamud.Data; +using Dalamud.Game.Gui; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; +using Dalamud.Plugin.Services; + +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using FFXIVClientStructs.FFXIV.Component.Exd; + +using Lumina.Excel; +using Lumina.Excel.Sheets; + +using ActionSheet = Lumina.Excel.Sheets.Action; +using InstanceContentSheet = Lumina.Excel.Sheets.InstanceContent; +using PublicContentSheet = Lumina.Excel.Sheets.PublicContent; + +namespace Dalamud.Game.UnlockState; + +/// +/// This class provides unlock state of various content in the game. +/// +[ServiceManager.EarlyLoadedService] +internal unsafe class UnlockState : IInternalDisposableService, IUnlockState +{ + private static readonly ModuleLog Log = new(nameof(UnlockState)); + + private readonly ConcurrentDictionary> cachedUnlockedRowIds = []; + + [ServiceManager.ServiceDependency] + private readonly DataManager dataManager = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly ClientState.ClientState clientState = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly GameGui gameGui = Service.Get(); + + [ServiceManager.ServiceConstructor] + private UnlockState() + { + this.clientState.Login += this.UpdateUnlocks; + this.clientState.Logout += this.OnLogout; + this.gameGui.UnlocksUpdate += this.UpdateUnlocks; + } + + /// + public event IUnlockState.UnlockDelegate Unlock; + + private bool IsLoaded => PlayerState.Instance()->IsLoaded; + + /// + void IInternalDisposableService.DisposeService() + { + this.clientState.Login -= this.UpdateUnlocks; + this.clientState.Logout -= this.OnLogout; + this.gameGui.UnlocksUpdate -= this.UpdateUnlocks; + } + + /// + public bool IsActionUnlocked(ActionSheet row) + { + return this.IsUnlockLinkUnlocked(row.UnlockLink.RowId); + } + + /// + public bool IsAetherCurrentUnlocked(AetherCurrent row) + { + if (!this.IsLoaded) + return false; + + return PlayerState.Instance()->IsAetherCurrentUnlocked(row.RowId); + } + + /// + public bool IsAetherCurrentCompFlgSetUnlocked(AetherCurrentCompFlgSet row) + { + if (!this.IsLoaded) + return false; + + return PlayerState.Instance()->IsAetherCurrentZoneComplete(row.RowId); + } + + /// + public bool IsAozActionUnlocked(AozAction row) + { + if (!this.IsLoaded) + return false; + + if (row.RowId == 0 || !row.Action.IsValid) + return false; + + return UIState.Instance()->IsUnlockLinkUnlockedOrQuestCompleted(row.Action.Value.UnlockLink.RowId); + } + + /// + public bool IsBannerBgUnlocked(BannerBg row) + { + return row.UnlockCondition.IsValid && this.IsBannerConditionUnlocked(row.UnlockCondition.Value); + } + + /// + public bool IsBannerConditionUnlocked(BannerCondition row) + { + if (row.RowId == 0) + return false; + + if (!this.IsLoaded) + return false; + + var rowPtr = ExdModule.GetBannerConditionByIndex(row.RowId); + if (rowPtr == null) + return false; + + return ExdModule.GetBannerConditionUnlockState(rowPtr) == 0; + } + + /// + public bool IsBannerDecorationUnlocked(BannerDecoration row) + { + return row.UnlockCondition.IsValid && this.IsBannerConditionUnlocked(row.UnlockCondition.Value); + } + + /// + public bool IsBannerFacialUnlocked(BannerFacial row) + { + return row.UnlockCondition.IsValid && this.IsBannerConditionUnlocked(row.UnlockCondition.Value); + } + + /// + public bool IsBannerFrameUnlocked(BannerFrame row) + { + return row.UnlockCondition.IsValid && this.IsBannerConditionUnlocked(row.UnlockCondition.Value); + } + + /// + public bool IsBannerTimelineUnlocked(BannerTimeline row) + { + return row.UnlockCondition.IsValid && this.IsBannerConditionUnlocked(row.UnlockCondition.Value); + } + + /// + public bool IsBuddyActionUnlocked(BuddyAction row) + { + return this.IsUnlockLinkUnlocked(row.UnlockLink); + } + + /// + public bool IsBuddyEquipUnlocked(BuddyEquip row) + { + if (!this.IsLoaded) + return false; + + return UIState.Instance()->Buddy.CompanionInfo.IsBuddyEquipUnlocked(row.RowId); + } + + /// + public bool IsCharaMakeCustomizeUnlocked(CharaMakeCustomize row) + { + return row.IsPurchasable && this.IsUnlockLinkUnlocked(row.UnlockLink); + } + + /// + public bool IsChocoboTaxiUnlocked(ChocoboTaxi row) + { + if (!this.IsLoaded) + return false; + + return UIState.Instance()->IsChocoboTaxiStandUnlocked(row.RowId); + } + + /// + public bool IsCompanionUnlocked(Companion row) + { + if (!this.IsLoaded) + return false; + + return UIState.Instance()->IsCompanionUnlocked(row.RowId); + } + + /// + public bool IsCraftActionUnlocked(CraftAction row) + { + return this.IsUnlockLinkUnlocked(row.QuestRequirement.RowId); + } + + /// + public bool IsCSBonusContentTypeUnlocked(CSBonusContentType row) + { + return this.IsUnlockLinkUnlocked(row.UnlockLink); + } + + /// + public bool IsEmoteUnlocked(Emote row) + { + return this.IsUnlockLinkUnlocked(row.UnlockLink); + } + + /// + public bool IsGeneralActionUnlocked(GeneralAction row) + { + return this.IsUnlockLinkUnlocked(row.UnlockLink); + } + + /// + public bool IsGlassesUnlocked(Glasses row) + { + if (!this.IsLoaded) + return false; + + return PlayerState.Instance()->IsGlassesUnlocked((ushort)row.RowId); + } + + /// + public bool IsHowToUnlocked(HowTo row) + { + if (!this.IsLoaded) + return false; + + return UIState.Instance()->IsHowToUnlocked(row.RowId); + } + + /// + public bool IsInstanceContentUnlocked(InstanceContentSheet row) + { + if (!this.IsLoaded) + return false; + + return UIState.IsInstanceContentUnlocked(row.RowId); + } + + /// + public unsafe bool IsItemUnlocked(Item row) + { + if (row.ItemAction.RowId == 0) + return false; + + if (!this.IsLoaded) + return false; + + // To avoid the ExdModule.GetItemRowById call, which can return null if the excel page + // is not loaded, we're going to imitate the IsItemActionUnlocked call first: + switch ((ItemActionType)row.ItemAction.Value.Type) + { + case ItemActionType.Companion: + return UIState.Instance()->IsCompanionUnlocked(row.ItemAction.Value.Data[0]); + + case ItemActionType.BuddyEquip: + return UIState.Instance()->Buddy.CompanionInfo.IsBuddyEquipUnlocked(row.ItemAction.Value.Data[0]); + + case ItemActionType.Mount: + return PlayerState.Instance()->IsMountUnlocked(row.ItemAction.Value.Data[0]); + + case ItemActionType.SecretRecipeBook: + return PlayerState.Instance()->IsSecretRecipeBookUnlocked(row.ItemAction.Value.Data[0]); + + case ItemActionType.UnlockLink: + return UIState.Instance()->IsUnlockLinkUnlocked(row.ItemAction.Value.Data[0]); + + case ItemActionType.TripleTriadCard when row.AdditionalData.Is(): + return UIState.Instance()->IsTripleTriadCardUnlocked((ushort)row.AdditionalData.RowId); + + case ItemActionType.FolkloreTome: + return PlayerState.Instance()->IsFolkloreBookUnlocked(row.ItemAction.Value.Data[0]); + + case ItemActionType.OrchestrionRoll when row.AdditionalData.Is(): + return PlayerState.Instance()->IsOrchestrionRollUnlocked(row.AdditionalData.RowId); + + case ItemActionType.FramersKit: + return PlayerState.Instance()->IsFramersKitUnlocked(row.AdditionalData.RowId); + + case ItemActionType.Ornament: + return PlayerState.Instance()->IsOrnamentUnlocked(row.ItemAction.Value.Data[0]); + + case ItemActionType.Glasses: + return PlayerState.Instance()->IsGlassesUnlocked((ushort)row.AdditionalData.RowId); + + case ItemActionType.CompanySealVouchers: + return false; + } + + var nativeRow = ExdModule.GetItemRowById(row.RowId); + return nativeRow != null && UIState.Instance()->IsItemActionUnlocked(nativeRow) == 1; + } + + /// + public bool IsMcGuffinUnlocked(McGuffin row) + { + return PlayerState.Instance()->IsMcGuffinUnlocked(row.RowId); + } + + /// + public bool IsMJILandmarkUnlocked(MJILandmark row) + { + return this.IsUnlockLinkUnlocked(row.UnlockLink); + } + + /// + public bool IsMountUnlocked(Mount row) + { + if (!this.IsLoaded) + return false; + + return PlayerState.Instance()->IsMountUnlocked(row.RowId); + } + + /// + public bool IsNotebookDivisionUnlocked(NotebookDivision row) + { + return this.IsUnlockLinkUnlocked(row.QuestUnlock.RowId); + } + + /// + public bool IsOrchestrionUnlocked(Orchestrion row) + { + if (!this.IsLoaded) + return false; + + return PlayerState.Instance()->IsOrchestrionRollUnlocked(row.RowId); + } + + /// + public bool IsOrnamentUnlocked(Ornament row) + { + if (!this.IsLoaded) + return false; + + return PlayerState.Instance()->IsOrnamentUnlocked(row.RowId); + } + + /// + public bool IsPerformUnlocked(Perform row) + { + return this.IsUnlockLinkUnlocked((uint)row.UnlockLink); + } + + /// + public bool IsPublicContentUnlocked(PublicContentSheet row) + { + if (!this.IsLoaded) + return false; + + return UIState.IsPublicContentUnlocked(row.RowId); + } + + /// + public bool IsSecretRecipeBookUnlocked(SecretRecipeBook row) + { + if (!this.IsLoaded) + return false; + + return PlayerState.Instance()->IsSecretRecipeBookUnlocked(row.RowId); + } + + /// + public bool IsTraitUnlocked(Trait row) + { + return this.IsUnlockLinkUnlocked(row.Quest.RowId); + } + + /// + public bool IsTripleTriadCardUnlocked(TripleTriadCard row) + { + if (!this.IsLoaded) + return false; + + return UIState.Instance()->IsTripleTriadCardUnlocked((ushort)row.RowId); + } + + /// + public bool IsItemUnlockable(Item row) + { + if (row.ItemAction.RowId == 0) + return false; + + return (ItemActionType)row.ItemAction.Value.Type is + ItemActionType.Companion or + ItemActionType.BuddyEquip or + ItemActionType.Mount or + ItemActionType.SecretRecipeBook or + ItemActionType.UnlockLink or + ItemActionType.TripleTriadCard or + ItemActionType.FolkloreTome or + ItemActionType.OrchestrionRoll or + ItemActionType.FramersKit or + ItemActionType.Ornament or + ItemActionType.Glasses; + } + + /// + public bool IsRowRefUnlocked(RowRef rowRef) where T : struct, IExcelRow + { + return this.IsRowRefUnlocked((RowRef)rowRef); + } + + /// + public bool IsRowRefUnlocked(RowRef rowRef) + { + if (!this.IsLoaded || rowRef.IsUntyped) + return false; + + if (rowRef.TryGetValue(out var actionRow)) + return this.IsActionUnlocked(actionRow); + + if (rowRef.TryGetValue(out var aetherCurrentRow)) + return this.IsAetherCurrentUnlocked(aetherCurrentRow); + + if (rowRef.TryGetValue(out var aetherCurrentCompFlgSetRow)) + return this.IsAetherCurrentCompFlgSetUnlocked(aetherCurrentCompFlgSetRow); + + if (rowRef.TryGetValue(out var aozActionRow)) + return this.IsAozActionUnlocked(aozActionRow); + + if (rowRef.TryGetValue(out var bannerBgRow)) + return this.IsBannerBgUnlocked(bannerBgRow); + + if (rowRef.TryGetValue(out var bannerConditionRow)) + return this.IsBannerConditionUnlocked(bannerConditionRow); + + if (rowRef.TryGetValue(out var bannerDecorationRow)) + return this.IsBannerDecorationUnlocked(bannerDecorationRow); + + if (rowRef.TryGetValue(out var bannerFacialRow)) + return this.IsBannerFacialUnlocked(bannerFacialRow); + + if (rowRef.TryGetValue(out var bannerFrameRow)) + return this.IsBannerFrameUnlocked(bannerFrameRow); + + if (rowRef.TryGetValue(out var bannerTimelineRow)) + return this.IsBannerTimelineUnlocked(bannerTimelineRow); + + if (rowRef.TryGetValue(out var buddyActionRow)) + return this.IsBuddyActionUnlocked(buddyActionRow); + + if (rowRef.TryGetValue(out var buddyEquipRow)) + return this.IsBuddyEquipUnlocked(buddyEquipRow); + + if (rowRef.TryGetValue(out var csBonusContentTypeRow)) + return this.IsCSBonusContentTypeUnlocked(csBonusContentTypeRow); + + if (rowRef.TryGetValue(out var charaMakeCustomizeRow)) + return this.IsCharaMakeCustomizeUnlocked(charaMakeCustomizeRow); + + if (rowRef.TryGetValue(out var chocoboTaxiRow)) + return this.IsChocoboTaxiUnlocked(chocoboTaxiRow); + + if (rowRef.TryGetValue(out var companionRow)) + return this.IsCompanionUnlocked(companionRow); + + if (rowRef.TryGetValue(out var craftActionRow)) + return this.IsCraftActionUnlocked(craftActionRow); + + if (rowRef.TryGetValue(out var emoteRow)) + return this.IsEmoteUnlocked(emoteRow); + + if (rowRef.TryGetValue(out var generalActionRow)) + return this.IsGeneralActionUnlocked(generalActionRow); + + if (rowRef.TryGetValue(out var glassesRow)) + return this.IsGlassesUnlocked(glassesRow); + + if (rowRef.TryGetValue(out var howToRow)) + return this.IsHowToUnlocked(howToRow); + + if (rowRef.TryGetValue(out var instanceContentRow)) + return this.IsInstanceContentUnlocked(instanceContentRow); + + if (rowRef.TryGetValue(out var itemRow)) + return this.IsItemUnlocked(itemRow); + + if (rowRef.TryGetValue(out var mjiLandmarkRow)) + return this.IsMJILandmarkUnlocked(mjiLandmarkRow); + + if (rowRef.TryGetValue(out var mcGuffinRow)) + return this.IsMcGuffinUnlocked(mcGuffinRow); + + if (rowRef.TryGetValue(out var mountRow)) + return this.IsMountUnlocked(mountRow); + + if (rowRef.TryGetValue(out var notebookDivisionRow)) + return this.IsNotebookDivisionUnlocked(notebookDivisionRow); + + if (rowRef.TryGetValue(out var orchestrionRow)) + return this.IsOrchestrionUnlocked(orchestrionRow); + + if (rowRef.TryGetValue(out var ornamentRow)) + return this.IsOrnamentUnlocked(ornamentRow); + + if (rowRef.TryGetValue(out var performRow)) + return this.IsPerformUnlocked(performRow); + + if (rowRef.TryGetValue(out var publicContentRow)) + return this.IsPublicContentUnlocked(publicContentRow); + + if (rowRef.TryGetValue(out var secretRecipeBookRow)) + return this.IsSecretRecipeBookUnlocked(secretRecipeBookRow); + + if (rowRef.TryGetValue(out var traitRow)) + return this.IsTraitUnlocked(traitRow); + + if (rowRef.TryGetValue(out var tripleTriadCardRow)) + return this.IsTripleTriadCardUnlocked(tripleTriadCardRow); + + return false; + } + + /// + public bool IsUnlockLinkUnlocked(ushort unlockLink) + { + if (!this.IsLoaded) + return false; + + if (unlockLink == 0) + return false; + + return UIState.Instance()->IsUnlockLinkUnlocked(unlockLink); + } + + /// + public bool IsUnlockLinkUnlocked(uint unlockLink) + { + if (!this.IsLoaded) + return false; + + if (unlockLink == 0) + return false; + + return UIState.Instance()->IsUnlockLinkUnlockedOrQuestCompleted(unlockLink); + } + + private void UpdateUnlocks() + { + try + { + this.UpdateUnlocks(false); + } + catch (Exception ex) + { + Log.Error(ex, "Error during initial unlock check"); + } + } + + private void OnLogout(int type, int code) + { + this.cachedUnlockedRowIds.Clear(); + } + + private void UpdateUnlocks(bool fireEvent) + { + if (!this.IsLoaded) + return; + + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); + + // Not implemented: + // - DescriptionPage: quite complex + // - QuestAcceptAdditionCondition: ignored + + // For some other day: + // - FishingSpot + // - Spearfishing + // - Adventure (Sightseeing) + // - Recipes + // - MinerFolkloreTome + // - BotanistFolkloreTome + // - FishingFolkloreTome + // - VVD or is that unlocked via quest? + // - VVDNotebookContents? + // - FramersKit (is that just an Item?) + // - ... more? + + // Probably not happening, because it requires fetching data from server: + // - Achievements + // - Titles + // - Bozjan Field Notes + } + + private void UpdateUnlocksForSheet(bool fireEvent = true) where T : struct, IExcelRow + { + var unlockedRowIds = this.cachedUnlockedRowIds.GetOrAdd(typeof(T), _ => []); + + foreach (var row in this.dataManager.GetExcelSheet()) + { + if (unlockedRowIds.Contains(row.RowId)) + continue; + + var rowRef = LuminaUtils.CreateRef(row.RowId); + + if (!this.IsRowRefUnlocked(rowRef)) + continue; + + unlockedRowIds.Add(row.RowId); + + if (fireEvent) + { + Log.Verbose("Unlock detected: {row}", $"{typeof(T).Name}#{row.RowId}"); + + foreach (var action in Delegate.EnumerateInvocationList(this.Unlock)) + { + try + { + action((RowRef)rowRef); + } + catch (Exception ex) + { + Log.Error(ex, "Exception during raise of {handler}", action.Method); + } + } + } + } + } +} + +/// +/// Plugin-scoped version of a service. +/// +[PluginInterface] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class UnlockStatePluginScoped : IInternalDisposableService, IUnlockState +{ + [ServiceManager.ServiceDependency] + private readonly UnlockState unlockStateService = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + internal UnlockStatePluginScoped() + { + this.unlockStateService.Unlock += this.UnlockForward; + } + + /// + public event IUnlockState.UnlockDelegate? Unlock; + + /// + public bool IsActionUnlocked(ActionSheet row) => this.unlockStateService.IsActionUnlocked(row); + + /// + public bool IsAetherCurrentCompFlgSetUnlocked(AetherCurrentCompFlgSet row) => this.unlockStateService.IsAetherCurrentCompFlgSetUnlocked(row); + + /// + public bool IsAetherCurrentUnlocked(AetherCurrent row) => this.unlockStateService.IsAetherCurrentUnlocked(row); + + /// + public bool IsAozActionUnlocked(AozAction row) => this.unlockStateService.IsAozActionUnlocked(row); + + /// + public bool IsBannerBgUnlocked(BannerBg row) => this.unlockStateService.IsBannerBgUnlocked(row); + + /// + public bool IsBannerConditionUnlocked(BannerCondition row) => this.unlockStateService.IsBannerConditionUnlocked(row); + + /// + public bool IsBannerDecorationUnlocked(BannerDecoration row) => this.unlockStateService.IsBannerDecorationUnlocked(row); + + /// + public bool IsBannerFacialUnlocked(BannerFacial row) => this.unlockStateService.IsBannerFacialUnlocked(row); + + /// + public bool IsBannerFrameUnlocked(BannerFrame row) => this.unlockStateService.IsBannerFrameUnlocked(row); + + /// + public bool IsBannerTimelineUnlocked(BannerTimeline row) => this.unlockStateService.IsBannerTimelineUnlocked(row); + + /// + public bool IsBuddyActionUnlocked(BuddyAction row) => this.unlockStateService.IsBuddyActionUnlocked(row); + + /// + public bool IsBuddyEquipUnlocked(BuddyEquip row) => this.unlockStateService.IsBuddyEquipUnlocked(row); + + /// + public bool IsCharaMakeCustomizeUnlocked(CharaMakeCustomize row) => this.unlockStateService.IsCharaMakeCustomizeUnlocked(row); + + /// + public bool IsChocoboTaxiUnlocked(ChocoboTaxi row) => this.unlockStateService.IsChocoboTaxiUnlocked(row); + + /// + public bool IsCompanionUnlocked(Companion row) => this.unlockStateService.IsCompanionUnlocked(row); + + /// + public bool IsCraftActionUnlocked(CraftAction row) => this.unlockStateService.IsCraftActionUnlocked(row); + + /// + public bool IsCSBonusContentTypeUnlocked(CSBonusContentType row) => this.unlockStateService.IsCSBonusContentTypeUnlocked(row); + + /// + public bool IsEmoteUnlocked(Emote row) => this.unlockStateService.IsEmoteUnlocked(row); + + /// + public bool IsGeneralActionUnlocked(GeneralAction row) => this.unlockStateService.IsGeneralActionUnlocked(row); + + /// + public bool IsGlassesUnlocked(Glasses row) => this.unlockStateService.IsGlassesUnlocked(row); + + /// + public bool IsHowToUnlocked(HowTo row) => this.unlockStateService.IsHowToUnlocked(row); + + /// + public bool IsInstanceContentUnlocked(InstanceContentSheet row) => this.unlockStateService.IsInstanceContentUnlocked(row); + + /// + public bool IsItemUnlockable(Item row) => this.unlockStateService.IsItemUnlockable(row); + + /// + public bool IsItemUnlocked(Item row) => this.unlockStateService.IsItemUnlocked(row); + + /// + public bool IsMcGuffinUnlocked(McGuffin row) => this.unlockStateService.IsMcGuffinUnlocked(row); + + /// + public bool IsMJILandmarkUnlocked(MJILandmark row) => this.unlockStateService.IsMJILandmarkUnlocked(row); + + /// + public bool IsMountUnlocked(Mount row) => this.unlockStateService.IsMountUnlocked(row); + + /// + public bool IsNotebookDivisionUnlocked(NotebookDivision row) => this.unlockStateService.IsNotebookDivisionUnlocked(row); + + /// + public bool IsOrchestrionUnlocked(Orchestrion row) => this.unlockStateService.IsOrchestrionUnlocked(row); + + /// + public bool IsOrnamentUnlocked(Ornament row) => this.unlockStateService.IsOrnamentUnlocked(row); + + /// + public bool IsPerformUnlocked(Perform row) => this.unlockStateService.IsPerformUnlocked(row); + + /// + public bool IsPublicContentUnlocked(PublicContentSheet row) => this.unlockStateService.IsPublicContentUnlocked(row); + + /// + public bool IsRowRefUnlocked(RowRef rowRef) => this.unlockStateService.IsRowRefUnlocked(rowRef); + + /// + public bool IsRowRefUnlocked(RowRef rowRef) where T : struct, IExcelRow => this.unlockStateService.IsRowRefUnlocked(rowRef); + + /// + public bool IsSecretRecipeBookUnlocked(SecretRecipeBook row) => this.unlockStateService.IsSecretRecipeBookUnlocked(row); + + /// + public bool IsTraitUnlocked(Trait row) => this.unlockStateService.IsTraitUnlocked(row); + + /// + public bool IsTripleTriadCardUnlocked(TripleTriadCard row) => this.unlockStateService.IsTripleTriadCardUnlocked(row); + + /// + public bool IsUnlockLinkUnlocked(uint unlockLink) => this.unlockStateService.IsUnlockLinkUnlocked(unlockLink); + + /// + public bool IsUnlockLinkUnlocked(ushort unlockLink) => this.unlockStateService.IsUnlockLinkUnlocked(unlockLink); + + /// + void IInternalDisposableService.DisposeService() + { + this.unlockStateService.Unlock -= this.UnlockForward; + } + + private void UnlockForward(RowRef rowRef) => this.Unlock?.Invoke(rowRef); +} diff --git a/Dalamud/Plugin/Services/IUnlockState.cs b/Dalamud/Plugin/Services/IUnlockState.cs new file mode 100644 index 000000000..baee47115 --- /dev/null +++ b/Dalamud/Plugin/Services/IUnlockState.cs @@ -0,0 +1,297 @@ +using Lumina.Excel; +using Lumina.Excel.Sheets; + +namespace Dalamud.Plugin.Services; + +#pragma warning disable SA1400 // Access modifier should be declared: Interface members are public by default + +/// +/// Interface for determining unlock state of various content in the game. +/// +public interface IUnlockState +{ + /// + /// A delegate type used for the event. + /// + /// A RowRef of the unlocked thing. + delegate void UnlockDelegate(RowRef rowRef); + + /// + /// Event triggered when something was unlocked. + /// + event UnlockDelegate? Unlock; + + /// + /// Determines whether the specified Action is unlocked. + /// + /// The Action row to check. + /// if unlocked; otherwise, . + bool IsActionUnlocked(Lumina.Excel.Sheets.Action row); + + /// + /// Determines whether the specified AetherCurrentCompFlgSet is unlocked. + /// + /// The AetherCurrentCompFlgSet row to check. + /// if unlocked; otherwise, . + bool IsAetherCurrentCompFlgSetUnlocked(AetherCurrentCompFlgSet row); + + /// + /// Determines whether the specified AetherCurrent is unlocked. + /// + /// The AetherCurrent row to check. + /// if unlocked; otherwise, . + bool IsAetherCurrentUnlocked(AetherCurrent row); + + /// + /// Determines whether the specified AozAction (Blue Mage Action) is unlocked. + /// + /// The AozAction row to check. + /// if unlocked; otherwise, . + bool IsAozActionUnlocked(AozAction row); + + /// + /// Determines whether the specified BannerBg (Portrait Backgrounds) is unlocked. + /// + /// The BannerBg row to check. + /// if unlocked; otherwise, . + bool IsBannerBgUnlocked(BannerBg row); + + /// + /// Determines whether the specified BannerCondition is unlocked. + /// + /// The BannerCondition row to check. + /// if unlocked; otherwise, . + bool IsBannerConditionUnlocked(BannerCondition row); + + /// + /// Determines whether the specified BannerDecoration (Portrait Accents) is unlocked. + /// + /// The BannerDecoration row to check. + /// if unlocked; otherwise, . + bool IsBannerDecorationUnlocked(BannerDecoration row); + + /// + /// Determines whether the specified BannerFacial (Portrait Expressions) is unlocked. + /// + /// The BannerFacial row to check. + /// if unlocked; otherwise, . + bool IsBannerFacialUnlocked(BannerFacial row); + + /// + /// Determines whether the specified BannerFrame (Portrait Frames) is unlocked. + /// + /// The BannerFrame row to check. + /// if unlocked; otherwise, . + bool IsBannerFrameUnlocked(BannerFrame row); + + /// + /// Determines whether the specified BannerTimeline (Portrait Poses) is unlocked. + /// + /// The BannerTimeline row to check. + /// if unlocked; otherwise, . + bool IsBannerTimelineUnlocked(BannerTimeline row); + + /// + /// Determines whether the specified BuddyAction (Action of the players Chocobo Companion) is unlocked. + /// + /// The BuddyAction row to check. + /// if unlocked; otherwise, . + bool IsBuddyActionUnlocked(BuddyAction row); + + /// + /// Determines whether the specified BuddyEquip (Equipment of the players Chocobo Companion) is unlocked. + /// + /// The BuddyEquip row to check. + /// if unlocked; otherwise, . + bool IsBuddyEquipUnlocked(BuddyEquip row); + + /// + /// Determines whether the specified CharaMakeCustomize (Hairstyles and Face Paint patterns) is unlocked. + /// + /// The CharaMakeCustomize row to check. + /// if unlocked; otherwise, . + bool IsCharaMakeCustomizeUnlocked(CharaMakeCustomize row); + + /// + /// Determines whether the specified ChocoboTaxi (Chocobokeeps of the Chocobo Porter service) is unlocked. + /// + /// The ChocoboTaxi row to check. + /// if unlocked; otherwise, . + bool IsChocoboTaxiUnlocked(ChocoboTaxi row); + + /// + /// Determines whether the specified Companion (Minions) is unlocked. + /// + /// The Companion row to check. + /// if unlocked; otherwise, . + bool IsCompanionUnlocked(Companion row); + + /// + /// Determines whether the specified CraftAction is unlocked. + /// + /// The CraftAction row to check. + /// if unlocked; otherwise, . + bool IsCraftActionUnlocked(CraftAction row); + + /// + /// Determines whether the specified CSBonusContentType is unlocked. + /// + /// The CSBonusContentType row to check. + /// if unlocked; otherwise, . + bool IsCSBonusContentTypeUnlocked(CSBonusContentType row); + + /// + /// Determines whether the specified Emote is unlocked. + /// + /// The Emote row to check. + /// if unlocked; otherwise, . + bool IsEmoteUnlocked(Emote row); + + /// + /// Determines whether the specified GeneralAction is unlocked. + /// + /// The GeneralAction row to check. + /// if unlocked; otherwise, . + bool IsGeneralActionUnlocked(GeneralAction row); + + /// + /// Determines whether the specified Glasses is unlocked. + /// + /// The Glasses row to check. + /// if unlocked; otherwise, . + bool IsGlassesUnlocked(Glasses row); + + /// + /// Determines whether the specified HowTo is unlocked. + /// + /// The HowTo row to check. + /// if unlocked; otherwise, . + bool IsHowToUnlocked(HowTo row); + + /// + /// Determines whether the specified InstanceContent is unlocked. + /// + /// The InstanceContent row to check. + /// if unlocked; otherwise, . + bool IsInstanceContentUnlocked(InstanceContent row); + + /// + /// Determines whether the specified Item is considered unlockable. + /// + /// The Item row to check. + /// if unlockable; otherwise, . + bool IsItemUnlockable(Item row); + + /// + /// Determines whether the specified Item is unlocked. + /// + /// The Item row to check. + /// if unlocked; otherwise, . + bool IsItemUnlocked(Item row); + + /// + /// Determines whether the specified McGuffin is unlocked. + /// + /// The McGuffin row to check. + /// if unlocked; otherwise, . + bool IsMcGuffinUnlocked(McGuffin row); + + /// + /// Determines whether the specified MJILandmark (Island Sanctuary landmark) is unlocked. + /// + /// The MJILandmark row to check. + /// if unlocked; otherwise, . + bool IsMJILandmarkUnlocked(MJILandmark row); + + /// + /// Determines whether the specified Mount is unlocked. + /// + /// The Mount row to check. + /// if unlocked; otherwise, . + bool IsMountUnlocked(Mount row); + + /// + /// Determines whether the specified NotebookDivision (Categories in Crafting/Gathering Log) is unlocked. + /// + /// The NotebookDivision row to check. + /// if unlocked; otherwise, . + bool IsNotebookDivisionUnlocked(NotebookDivision row); + + /// + /// Determines whether the specified Orchestrion roll is unlocked. + /// + /// The Orchestrion row to check. + /// if unlocked; otherwise, . + bool IsOrchestrionUnlocked(Orchestrion row); + + /// + /// Determines whether the specified Ornament (Fashion Accessories) is unlocked. + /// + /// The Ornament row to check. + /// if unlocked; otherwise, . + bool IsOrnamentUnlocked(Ornament row); + + /// + /// Determines whether the specified Perform (Performance Instruments) is unlocked. + /// + /// The Perform row to check. + /// if unlocked; otherwise, . + bool IsPerformUnlocked(Perform row); + + /// + /// Determines whether the specified PublicContent is unlocked. + /// + /// The PublicContent row to check. + /// if unlocked; otherwise, . + bool IsPublicContentUnlocked(PublicContent row); + + /// + /// Determines whether the underlying RowRef type is unlocked. + /// + /// The RowRef to check. + /// if unlocked; otherwise, . + bool IsRowRefUnlocked(RowRef rowRef); + + /// + /// Determines whether the underlying RowRef type is unlocked. + /// + /// The type of the Excel row. + /// The RowRef to check. + /// if unlocked; otherwise, . + bool IsRowRefUnlocked(RowRef rowRef) where T : struct, IExcelRow; + + /// + /// Determines whether the specified SecretRecipeBook (Master Recipe Books) is unlocked. + /// + /// The SecretRecipeBook row to check. + /// if unlocked; otherwise, . + bool IsSecretRecipeBookUnlocked(SecretRecipeBook row); + + /// + /// Determines whether the specified Trait is unlocked. + /// + /// The Trait row to check. + /// if unlocked; otherwise, . + bool IsTraitUnlocked(Trait row); + + /// + /// Determines whether the specified TripleTriadCard is unlocked. + /// + /// The TripleTriadCard row to check. + /// if unlocked; otherwise, . + bool IsTripleTriadCardUnlocked(TripleTriadCard row); + + /// + /// Determines whether the specified unlock link is unlocked or quest is completed. + /// + /// The unlock link id or quest id (quest ids in this case are over 65536). + /// if unlocked; otherwise, . + bool IsUnlockLinkUnlocked(uint unlockLink); + + /// + /// Determines whether the specified unlock link is unlocked. + /// + /// The unlock link id. + /// if unlocked; otherwise, . + bool IsUnlockLinkUnlocked(ushort unlockLink); +} From ba159f8c5fd8e396535d87d361405c7c6d5564af Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Wed, 1 Oct 2025 23:59:01 +0200 Subject: [PATCH 02/18] Add IsRecipeUnlocked --- Dalamud/Game/UnlockState/RecipeData.cs | 257 ++++++++++++++++++++++++ Dalamud/Game/UnlockState/UnlockState.cs | 17 +- Dalamud/Plugin/Services/IUnlockState.cs | 7 + 3 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 Dalamud/Game/UnlockState/RecipeData.cs diff --git a/Dalamud/Game/UnlockState/RecipeData.cs b/Dalamud/Game/UnlockState/RecipeData.cs new file mode 100644 index 000000000..81c0b838b --- /dev/null +++ b/Dalamud/Game/UnlockState/RecipeData.cs @@ -0,0 +1,257 @@ +using System.Linq; + +using CommunityToolkit.HighPerformance; + +using Dalamud.Data; +using Dalamud.Game.Gui; + +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using FFXIVClientStructs.Interop; + +using Lumina.Excel.Sheets; + +namespace Dalamud.Game.UnlockState; + +/// +/// Represents recipe-related data for all crafting classes. +/// +[ServiceManager.EarlyLoadedService] +internal unsafe class RecipeData : IInternalDisposableService +{ + [ServiceManager.ServiceDependency] + private readonly DataManager dataManager = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly ClientState.ClientState clientState = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly GameGui gameGui = Service.Get(); + + private readonly ushort[] craftTypeLevels; + private readonly byte[] unlockedNoteBookDivisionsCount; + private readonly byte[] unlockedSecretNoteBookDivisionsCount; + private readonly ushort[,] noteBookDivisionIds; + private byte[]? cachedUnlockedSecretRecipeBooks; + private byte[]? cachedUnlockLinks; + + /// + /// Initializes a new instance of the class. + /// + [ServiceManager.ServiceConstructor] + public RecipeData() + { + var numCraftTypes = this.dataManager.GetExcelSheet().Count(); + var numSecretNotBookDivisions = this.dataManager.GetExcelSheet().Count(row => row.RowId is >= 1000 and < 2000); + + this.unlockedNoteBookDivisionsCount = new byte[numCraftTypes]; + this.unlockedSecretNoteBookDivisionsCount = new byte[numCraftTypes]; + this.noteBookDivisionIds = new ushort[numCraftTypes, numSecretNotBookDivisions]; + + this.craftTypeLevels = new ushort[numCraftTypes]; + + this.clientState.Login += this.Update; + this.clientState.Logout += this.OnLogout; + this.gameGui.UnlocksUpdate += this.Update; + } + + /// + void IInternalDisposableService.DisposeService() + { + this.clientState.Login -= this.Update; + this.clientState.Logout -= this.OnLogout; + this.gameGui.UnlocksUpdate -= this.Update; + } + + /// + /// Determines whether the specified Recipe is unlocked. + /// + /// The Recipe row to check. + /// if unlocked; otherwise, . + public bool IsRecipeUnlocked(Recipe row) + { + // E8 ?? ?? ?? ?? 48 63 76 (2025.09.04) + var division = row.RecipeNotebookList.RowId != 0 && row.RecipeNotebookList.IsValid + ? (row.RecipeNotebookList.RowId - 1000) / 8 + 1000 + : ((uint)row.RecipeLevelTable.Value.ClassJobLevel - 1) / 5; + + // E8 ?? ?? ?? ?? 33 ED 84 C0 75 (2025.09.04) + foreach (var craftTypeRow in this.dataManager.GetExcelSheet()) + { + var craftType = (byte)craftTypeRow.RowId; + + if (division < this.unlockedNoteBookDivisionsCount[craftType]) + return true; + + if (this.unlockedNoteBookDivisionsCount[craftType] == 0) + continue; + + if (division is 5000 or 5001) + return true; + + if (division < 1000) + continue; + + if (this.unlockedSecretNoteBookDivisionsCount[craftType] == 0) + continue; + + if (this.noteBookDivisionIds.GetRowSpan(craftType).Contains((ushort)division)) + return true; + } + + return false; + } + + private void OnLogout(int type, int code) + { + this.cachedUnlockedSecretRecipeBooks = null; + this.cachedUnlockLinks = null; + } + + private void Update() + { + // Client::Game::UI::RecipeNote.InitializeStructs + + if (!this.NeedsUpdate()) + return; + + Array.Clear(this.unlockedNoteBookDivisionsCount, 0, this.unlockedNoteBookDivisionsCount.Length); + Array.Clear(this.unlockedSecretNoteBookDivisionsCount, 0, this.unlockedSecretNoteBookDivisionsCount.Length); + Array.Clear(this.noteBookDivisionIds, 0, this.noteBookDivisionIds.Length); + + foreach (var craftTypeRow in this.dataManager.GetExcelSheet()) + { + var craftType = (byte)craftTypeRow.RowId; + var craftTypeLevel = RecipeNote.Instance()->GetCraftTypeLevel(craftType); + if (craftTypeLevel == 0) + continue; + + var noteBookDivisionIndex = -1; + + foreach (var noteBookDivisionRow in this.dataManager.GetExcelSheet()) + { + if (noteBookDivisionRow.RowId < 1000) + { + if (craftTypeLevel >= noteBookDivisionRow.CraftOpeningLevel) + this.unlockedNoteBookDivisionsCount[craftType]++; + } + else if (noteBookDivisionRow.RowId < 2000) + { + noteBookDivisionIndex++; + + // For future Lumina.Excel update, replace with: + // if (!notebookDivisionRow.AllowedCraftTypes[craftType]) + // continue; + + switch (craftTypeRow.RowId) + { + case 0 when !noteBookDivisionRow.CRPCraft: continue; + case 1 when !noteBookDivisionRow.BSMCraft: continue; + case 2 when !noteBookDivisionRow.ARMCraft: continue; + case 3 when !noteBookDivisionRow.GSMCraft: continue; + case 4 when !noteBookDivisionRow.LTWCraft: continue; + case 5 when !noteBookDivisionRow.WVRCraft: continue; + case 6 when !noteBookDivisionRow.ALCCraft: continue; + case 7 when !noteBookDivisionRow.CULCraft: continue; + } + + if (noteBookDivisionRow.GatheringOpeningLevel != byte.MaxValue) + continue; + + // For future Lumina.Excel update, replace with: + // if (notebookDivisionRow.RequiresSecretRecipeBookGroupUnlock) + if (noteBookDivisionRow.Unknown1) + { + var secretRecipeBookUnlocked = false; + + // For future Lumina.Excel update, iterate over notebookDivisionRow.SecretRecipeBookGroups + for (var i = 0; i < 2; i++) + { + // For future Lumina.Excel update, replace with: + // if (secretRecipeBookGroup.RowId == 0 || !secretRecipeBookGroup.IsValid) + // continue; + var secretRecipeBookGroupRowId = i switch + { + 0 => noteBookDivisionRow.Unknown2, + 1 => noteBookDivisionRow.Unknown2, + _ => default, + }; + + if (secretRecipeBookGroupRowId == 0) + continue; + + if (!this.dataManager.GetExcelSheet().TryGetRow(secretRecipeBookGroupRowId, out var secretRecipeBookGroupRow)) + continue; + + // For future Lumina.Excel update, replace with: + // var bitIndex = secretRecipeBookGroup.Value.UnlockBitIndex[craftType]; + + var bitIndex = craftType switch + { + 0 => secretRecipeBookGroupRow.Unknown0, + 1 => secretRecipeBookGroupRow.Unknown1, + 2 => secretRecipeBookGroupRow.Unknown2, + 3 => secretRecipeBookGroupRow.Unknown3, + 4 => secretRecipeBookGroupRow.Unknown4, + 5 => secretRecipeBookGroupRow.Unknown5, + 6 => secretRecipeBookGroupRow.Unknown6, + 7 => secretRecipeBookGroupRow.Unknown7, + _ => default, + }; + + if (PlayerState.Instance()->UnlockedSecretRecipeBooksBitmask.TryCheckBitInSpan(bitIndex, out var result) && result) + { + secretRecipeBookUnlocked = true; + break; + } + } + + if (noteBookDivisionRow.CraftOpeningLevel > craftTypeLevel && !secretRecipeBookUnlocked) + continue; + } + else if (craftTypeLevel < noteBookDivisionRow.CraftOpeningLevel) + { + continue; + } + else if (noteBookDivisionRow.QuestUnlock.RowId != 0 && !UIState.Instance()->IsUnlockLinkUnlockedOrQuestCompleted(noteBookDivisionRow.QuestUnlock.RowId)) + { + continue; + } + + this.unlockedSecretNoteBookDivisionsCount[craftType]++; + this.noteBookDivisionIds[craftType, noteBookDivisionIndex] = (ushort)noteBookDivisionRow.RowId; + } + } + } + } + + private bool NeedsUpdate() + { + var changed = false; + + foreach (var craftTypeRow in this.dataManager.GetExcelSheet()) + { + var craftType = (byte)craftTypeRow.RowId; + var craftTypeLevel = RecipeNote.Instance()->GetCraftTypeLevel(craftType); + + if (this.craftTypeLevels[craftType] != craftTypeLevel) + { + this.craftTypeLevels[craftType] = craftTypeLevel; + changed |= true; + } + } + + if (this.cachedUnlockedSecretRecipeBooks == null || !PlayerState.Instance()->UnlockedSecretRecipeBooksBitmask.SequenceEqual(this.cachedUnlockedSecretRecipeBooks)) + { + this.cachedUnlockedSecretRecipeBooks = PlayerState.Instance()->UnlockedSecretRecipeBooksBitmask.ToArray(); + changed |= true; + } + + if (this.cachedUnlockLinks == null || !UIState.Instance()->UnlockLinkBitmask.SequenceEqual(this.cachedUnlockLinks)) + { + this.cachedUnlockLinks = UIState.Instance()->UnlockLinkBitmask.ToArray(); + changed |= true; + } + + return changed; + } +} diff --git a/Dalamud/Game/UnlockState/UnlockState.cs b/Dalamud/Game/UnlockState/UnlockState.cs index c84d1e73c..d41dce2e4 100644 --- a/Dalamud/Game/UnlockState/UnlockState.cs +++ b/Dalamud/Game/UnlockState/UnlockState.cs @@ -39,6 +39,9 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState [ServiceManager.ServiceDependency] private readonly GameGui gameGui = Service.Get(); + [ServiceManager.ServiceDependency] + private readonly RecipeData recipeData = Service.Get(); + [ServiceManager.ServiceConstructor] private UnlockState() { @@ -346,6 +349,12 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState return UIState.IsPublicContentUnlocked(row.RowId); } + /// + public bool IsRecipeUnlocked(Recipe row) + { + return this.recipeData.IsRecipeUnlocked(row); + } + /// public bool IsSecretRecipeBookUnlocked(SecretRecipeBook row) { @@ -495,6 +504,9 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState if (rowRef.TryGetValue(out var publicContentRow)) return this.IsPublicContentUnlocked(publicContentRow); + if (rowRef.TryGetValue(out var recipeRow)) + return this.IsRecipeUnlocked(recipeRow); + if (rowRef.TryGetValue(out var secretRecipeBookRow)) return this.IsSecretRecipeBookUnlocked(secretRecipeBookRow); @@ -584,6 +596,7 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); @@ -596,7 +609,6 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState // - FishingSpot // - Spearfishing // - Adventure (Sightseeing) - // - Recipes // - MinerFolkloreTome // - BotanistFolkloreTome // - FishingFolkloreTome @@ -767,6 +779,9 @@ internal class UnlockStatePluginScoped : IInternalDisposableService, IUnlockStat /// public bool IsPublicContentUnlocked(PublicContentSheet row) => this.unlockStateService.IsPublicContentUnlocked(row); + /// + public bool IsRecipeUnlocked(Recipe row) => this.unlockStateService.IsRecipeUnlocked(row); + /// public bool IsRowRefUnlocked(RowRef rowRef) => this.unlockStateService.IsRowRefUnlocked(rowRef); diff --git a/Dalamud/Plugin/Services/IUnlockState.cs b/Dalamud/Plugin/Services/IUnlockState.cs index baee47115..371af033c 100644 --- a/Dalamud/Plugin/Services/IUnlockState.cs +++ b/Dalamud/Plugin/Services/IUnlockState.cs @@ -245,6 +245,13 @@ public interface IUnlockState /// if unlocked; otherwise, . bool IsPublicContentUnlocked(PublicContent row); + /// + /// Determines whether the specified Recipe is unlocked. + /// + /// The Recipe row to check. + /// if unlocked; otherwise, . + bool IsRecipeUnlocked(Recipe row); + /// /// Determines whether the underlying RowRef type is unlocked. /// From 62fdd2c60d252bb5a0558de7834678869c7546a1 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 2 Oct 2025 02:01:11 +0200 Subject: [PATCH 03/18] Fix IsChocoboTaxiStandUnlocked --- Dalamud/Game/UnlockState/UnlockState.cs | 10 +++++----- Dalamud/Plugin/Services/IUnlockState.cs | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Dalamud/Game/UnlockState/UnlockState.cs b/Dalamud/Game/UnlockState/UnlockState.cs index d41dce2e4..ff1effdd1 100644 --- a/Dalamud/Game/UnlockState/UnlockState.cs +++ b/Dalamud/Game/UnlockState/UnlockState.cs @@ -167,12 +167,12 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState } /// - public bool IsChocoboTaxiUnlocked(ChocoboTaxi row) + public bool IsChocoboTaxiStandUnlocked(ChocoboTaxiStand row) { if (!this.IsLoaded) return false; - return UIState.Instance()->IsChocoboTaxiStandUnlocked(row.RowId); + return UIState.Instance()->IsChocoboTaxiStandUnlocked(row.RowId - 0x120000); } /// @@ -453,8 +453,8 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState if (rowRef.TryGetValue(out var charaMakeCustomizeRow)) return this.IsCharaMakeCustomizeUnlocked(charaMakeCustomizeRow); - if (rowRef.TryGetValue(out var chocoboTaxiRow)) - return this.IsChocoboTaxiUnlocked(chocoboTaxiRow); + if (rowRef.TryGetValue(out var chocoboTaxiStandRow)) + return this.IsChocoboTaxiStandUnlocked(chocoboTaxiStandRow); if (rowRef.TryGetValue(out var companionRow)) return this.IsCompanionUnlocked(companionRow); @@ -723,7 +723,7 @@ internal class UnlockStatePluginScoped : IInternalDisposableService, IUnlockStat public bool IsCharaMakeCustomizeUnlocked(CharaMakeCustomize row) => this.unlockStateService.IsCharaMakeCustomizeUnlocked(row); /// - public bool IsChocoboTaxiUnlocked(ChocoboTaxi row) => this.unlockStateService.IsChocoboTaxiUnlocked(row); + public bool IsChocoboTaxiStandUnlocked(ChocoboTaxiStand row) => this.unlockStateService.IsChocoboTaxiStandUnlocked(row); /// public bool IsCompanionUnlocked(Companion row) => this.unlockStateService.IsCompanionUnlocked(row); diff --git a/Dalamud/Plugin/Services/IUnlockState.cs b/Dalamud/Plugin/Services/IUnlockState.cs index 371af033c..d3620ffe2 100644 --- a/Dalamud/Plugin/Services/IUnlockState.cs +++ b/Dalamud/Plugin/Services/IUnlockState.cs @@ -113,11 +113,11 @@ public interface IUnlockState bool IsCharaMakeCustomizeUnlocked(CharaMakeCustomize row); /// - /// Determines whether the specified ChocoboTaxi (Chocobokeeps of the Chocobo Porter service) is unlocked. + /// Determines whether the specified ChocoboTaxiStand (Chocobokeeps of the Chocobo Porter service) is unlocked. /// - /// The ChocoboTaxi row to check. + /// The ChocoboTaxiStand row to check. /// if unlocked; otherwise, . - bool IsChocoboTaxiUnlocked(ChocoboTaxi row); + bool IsChocoboTaxiStandUnlocked(ChocoboTaxiStand row); /// /// Determines whether the specified Companion (Minions) is unlocked. From 5905afdf103b55fd2ff450579ec340722d562ca4 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 2 Oct 2025 03:23:54 +0200 Subject: [PATCH 04/18] Cache completed Quests in RecipeData too --- Dalamud/Game/UnlockState/RecipeData.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Dalamud/Game/UnlockState/RecipeData.cs b/Dalamud/Game/UnlockState/RecipeData.cs index 81c0b838b..4c89c2f3e 100644 --- a/Dalamud/Game/UnlockState/RecipeData.cs +++ b/Dalamud/Game/UnlockState/RecipeData.cs @@ -5,6 +5,7 @@ using CommunityToolkit.HighPerformance; using Dalamud.Data; using Dalamud.Game.Gui; +using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.Interop; @@ -33,6 +34,7 @@ internal unsafe class RecipeData : IInternalDisposableService private readonly ushort[,] noteBookDivisionIds; private byte[]? cachedUnlockedSecretRecipeBooks; private byte[]? cachedUnlockLinks; + private byte[]? cachedCompletedQuests; /// /// Initializes a new instance of the class. @@ -105,6 +107,7 @@ internal unsafe class RecipeData : IInternalDisposableService { this.cachedUnlockedSecretRecipeBooks = null; this.cachedUnlockLinks = null; + this.cachedCompletedQuests = null; } private void Update() @@ -252,6 +255,12 @@ internal unsafe class RecipeData : IInternalDisposableService changed |= true; } + if (this.cachedCompletedQuests == null || !QuestManager.Instance()->CompletedQuestsBitmask.SequenceEqual(this.cachedCompletedQuests)) + { + this.cachedCompletedQuests = QuestManager.Instance()->CompletedQuestsBitmask.ToArray(); + changed |= true; + } + return changed; } } From c4dd75bdda5f5d0c375801cd54161b96ee9b57c5 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 2 Oct 2025 19:36:52 +0200 Subject: [PATCH 05/18] Update RecipeData when levels changed --- Dalamud/Game/UnlockState/RecipeData.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Dalamud/Game/UnlockState/RecipeData.cs b/Dalamud/Game/UnlockState/RecipeData.cs index 4c89c2f3e..9a2f95b53 100644 --- a/Dalamud/Game/UnlockState/RecipeData.cs +++ b/Dalamud/Game/UnlockState/RecipeData.cs @@ -53,6 +53,7 @@ internal unsafe class RecipeData : IInternalDisposableService this.clientState.Login += this.Update; this.clientState.Logout += this.OnLogout; + this.clientState.LevelChanged += this.OnlevelChanged; this.gameGui.UnlocksUpdate += this.Update; } @@ -61,6 +62,7 @@ internal unsafe class RecipeData : IInternalDisposableService { this.clientState.Login -= this.Update; this.clientState.Logout -= this.OnLogout; + this.clientState.LevelChanged -= this.OnlevelChanged; this.gameGui.UnlocksUpdate -= this.Update; } @@ -110,9 +112,18 @@ internal unsafe class RecipeData : IInternalDisposableService this.cachedCompletedQuests = null; } + private void OnlevelChanged(uint classJobId, uint level) + { + if (this.dataManager.GetExcelSheet().TryGetRow(classJobId, out var classJobRow) && + classJobRow.ClassJobCategory.RowId == 33) // Crafter + { + this.Update(); + } + } + private void Update() { - // Client::Game::UI::RecipeNote.InitializeStructs + // based on Client::Game::UI::RecipeNote.InitializeStructs if (!this.NeedsUpdate()) return; From 3746c47a84331538e5ec3cf9cbb130bf30627987 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 2 Oct 2025 19:39:49 +0200 Subject: [PATCH 06/18] Ignore RecipeData updates when not logged in Just to be safe... --- Dalamud/Game/UnlockState/RecipeData.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dalamud/Game/UnlockState/RecipeData.cs b/Dalamud/Game/UnlockState/RecipeData.cs index 9a2f95b53..48409248a 100644 --- a/Dalamud/Game/UnlockState/RecipeData.cs +++ b/Dalamud/Game/UnlockState/RecipeData.cs @@ -125,7 +125,7 @@ internal unsafe class RecipeData : IInternalDisposableService { // based on Client::Game::UI::RecipeNote.InitializeStructs - if (!this.NeedsUpdate()) + if (!this.clientState.IsLoggedIn || !this.NeedsUpdate()) return; Array.Clear(this.unlockedNoteBookDivisionsCount, 0, this.unlockedNoteBookDivisionsCount.Length); From 986dfa04d0145a5596e8284fce461041c383f723 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Tue, 7 Oct 2025 17:42:16 +0200 Subject: [PATCH 07/18] Mark IUnlockState as experimental --- Dalamud/Game/UnlockState/UnlockState.cs | 2 ++ Dalamud/Plugin/Services/IUnlockState.cs | 3 +++ 2 files changed, 5 insertions(+) diff --git a/Dalamud/Game/UnlockState/UnlockState.cs b/Dalamud/Game/UnlockState/UnlockState.cs index ff1effdd1..b60e9ccdf 100644 --- a/Dalamud/Game/UnlockState/UnlockState.cs +++ b/Dalamud/Game/UnlockState/UnlockState.cs @@ -20,6 +20,8 @@ using PublicContentSheet = Lumina.Excel.Sheets.PublicContent; namespace Dalamud.Game.UnlockState; +#pragma warning disable UnlockState + /// /// This class provides unlock state of various content in the game. /// diff --git a/Dalamud/Plugin/Services/IUnlockState.cs b/Dalamud/Plugin/Services/IUnlockState.cs index d3620ffe2..22ef94eb2 100644 --- a/Dalamud/Plugin/Services/IUnlockState.cs +++ b/Dalamud/Plugin/Services/IUnlockState.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + using Lumina.Excel; using Lumina.Excel.Sheets; @@ -8,6 +10,7 @@ namespace Dalamud.Plugin.Services; /// /// Interface for determining unlock state of various content in the game. /// +[Experimental("UnlockState")] public interface IUnlockState { /// From 878080d66076c3b2b96c86668f8409548a617584 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 16 Oct 2025 00:56:45 +0200 Subject: [PATCH 08/18] Fix IsChocoboTaxiStandUnlocked call --- Dalamud/Game/UnlockState/UnlockState.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dalamud/Game/UnlockState/UnlockState.cs b/Dalamud/Game/UnlockState/UnlockState.cs index b60e9ccdf..ae8fc4f86 100644 --- a/Dalamud/Game/UnlockState/UnlockState.cs +++ b/Dalamud/Game/UnlockState/UnlockState.cs @@ -174,7 +174,7 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState if (!this.IsLoaded) return false; - return UIState.Instance()->IsChocoboTaxiStandUnlocked(row.RowId - 0x120000); + return UIState.Instance()->IsChocoboTaxiStandUnlocked(row.RowId); } /// From 68c02caf37495a14bcd78fea6d58245274746772 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Sat, 18 Oct 2025 01:10:35 +0200 Subject: [PATCH 09/18] Fix obsolete warnings --- Dalamud/Game/UnlockState/RecipeData.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Dalamud/Game/UnlockState/RecipeData.cs b/Dalamud/Game/UnlockState/RecipeData.cs index 48409248a..593b7fd38 100644 --- a/Dalamud/Game/UnlockState/RecipeData.cs +++ b/Dalamud/Game/UnlockState/RecipeData.cs @@ -212,7 +212,7 @@ internal unsafe class RecipeData : IInternalDisposableService _ => default, }; - if (PlayerState.Instance()->UnlockedSecretRecipeBooksBitmask.TryCheckBitInSpan(bitIndex, out var result) && result) + if (PlayerState.Instance()->UnlockedSecretRecipeBooksBitArray.Get(bitIndex)) { secretRecipeBookUnlocked = true; break; @@ -254,15 +254,15 @@ internal unsafe class RecipeData : IInternalDisposableService } } - if (this.cachedUnlockedSecretRecipeBooks == null || !PlayerState.Instance()->UnlockedSecretRecipeBooksBitmask.SequenceEqual(this.cachedUnlockedSecretRecipeBooks)) + if (this.cachedUnlockedSecretRecipeBooks == null || !PlayerState.Instance()->UnlockedSecretRecipeBooks.SequenceEqual(this.cachedUnlockedSecretRecipeBooks)) { - this.cachedUnlockedSecretRecipeBooks = PlayerState.Instance()->UnlockedSecretRecipeBooksBitmask.ToArray(); + this.cachedUnlockedSecretRecipeBooks = PlayerState.Instance()->UnlockedSecretRecipeBooks.ToArray(); changed |= true; } - if (this.cachedUnlockLinks == null || !UIState.Instance()->UnlockLinkBitmask.SequenceEqual(this.cachedUnlockLinks)) + if (this.cachedUnlockLinks == null || !UIState.Instance()->UnlockLinks.SequenceEqual(this.cachedUnlockLinks)) { - this.cachedUnlockLinks = UIState.Instance()->UnlockLinkBitmask.ToArray(); + this.cachedUnlockLinks = UIState.Instance()->UnlockLinks.ToArray(); changed |= true; } From 6e8efabc3b88f396c66e66556244255afa8e5436 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Sat, 18 Oct 2025 02:32:43 +0200 Subject: [PATCH 10/18] Add support for Occult Record items --- Dalamud/Game/ItemActionType.cs | 76 ----------------- Dalamud/Game/UnlockState/ItemActionType.cs | 97 ++++++++++++++++++++++ Dalamud/Game/UnlockState/UnlockState.cs | 26 +++--- 3 files changed, 111 insertions(+), 88 deletions(-) delete mode 100644 Dalamud/Game/ItemActionType.cs create mode 100644 Dalamud/Game/UnlockState/ItemActionType.cs diff --git a/Dalamud/Game/ItemActionType.cs b/Dalamud/Game/ItemActionType.cs deleted file mode 100644 index 3f2ac5f17..000000000 --- a/Dalamud/Game/ItemActionType.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Lumina.Excel.Sheets; - -namespace Dalamud.Game; - -/// -/// Enum for . -/// -public enum ItemActionType : ushort -{ - /// - /// Used to unlock a companion (minion). - /// - Companion = 853, - - /// - /// Used to unlock a chocobo companion barding. - /// - BuddyEquip = 1013, - - /// - /// Used to unlock a mount. - /// - Mount = 1322, - - /// - /// Used to unlock recipes from a crafting recipe book. - /// - SecretRecipeBook = 2136, - - /// - /// Used to unlock various types of content (e.g. Riding Maps, Blue Mage Totems, Emotes, Hairstyles). - /// - UnlockLink = 2633, - - /// - /// Used to unlock a Triple Triad Card. - /// - TripleTriadCard = 3357, - - /// - /// Used to unlock gathering nodes of a Folklore Tome. - /// - FolkloreTome = 4107, - - /// - /// Used to unlock an Orchestrion Roll. - /// - OrchestrionRoll = 25183, - - /// - /// Used to unlock portrait designs. - /// - FramersKit = 29459, - - /// - /// Used to unlock Bozjan Field Notes. These are server-side but are cached client-side. - /// - FieldNotes = 19743, - - /// - /// Used to unlock an Ornament (fashion accessory). - /// - Ornament = 20086, - - /// - /// Used to unlock glasses. - /// - Glasses = 37312, - - /// - /// Used for Company Seal Vouchers, which convert the item into Company Seals when used.
- /// Can be used only if in a Grand Company.
- /// IsUnlocked always returns false. - ///
- CompanySealVouchers = 41120, -} diff --git a/Dalamud/Game/UnlockState/ItemActionType.cs b/Dalamud/Game/UnlockState/ItemActionType.cs new file mode 100644 index 000000000..741dcd31b --- /dev/null +++ b/Dalamud/Game/UnlockState/ItemActionType.cs @@ -0,0 +1,97 @@ +using Lumina.Excel.Sheets; + +namespace Dalamud.Game.UnlockState; + +// TODO: Switch to FFXIVClientStructs.FFXIV.Client.Enums.ItemActionType. + +/// +/// Enum for . +/// +internal enum ItemActionType : ushort +{ + /// + /// No item action. + /// + None = 0, + + /// + /// Unlocks a companion (minion). + /// + Companion = 853, + + /// + /// Unlocks a chocobo companion barding. + /// + BuddyEquip = 1013, + + /// + /// Unlocks a mount. + /// + Mount = 1322, + + /// + /// Unlocks recipes from a crafting recipe book. + /// + SecretRecipeBook = 2136, + + /// + /// Unlocks various types of content (e.g. Riding Maps, Blue Mage Totems, Emotes, Hairstyles). + /// + UnlockLink = 2633, + + /// + /// Unlocks a Triple Triad Card. + /// + TripleTriadCard = 3357, + + /// + /// Unlocks gathering nodes of a Folklore Tome. + /// + FolkloreTome = 4107, + + /// + /// Unlocks an Orchestrion Roll. + /// + OrchestrionRoll = 25183, + + /// + /// Unlocks portrait designs. + /// + FramersKit = 29459, + + /// + /// Unlocks Bozjan Field Notes. + /// + /// These are server-side but are cached client-side. + FieldNotes = 19743, + + /// + /// Unlocks an Ornament (fashion accessory). + /// + Ornament = 20086, + + /// + /// Unlocks Glasses. + /// + Glasses = 37312, + + /// + /// Company Seal Vouchers, which convert the item into Company Seals when used. + /// + CompanySealVouchers = 41120, + + /// + /// Unlocks Occult Records in Occult Crescent. + /// + OccultRecords = 43141, + + /// + /// Unlocks Phantom Jobs in Occult Crescent. + /// + SoulShards = 43142, + + /// + /// Star Contributor Certificate, which grants the Star Contributor status in Cosmic Exploration. + /// + StarContributorCertificate = 45189, +} diff --git a/Dalamud/Game/UnlockState/UnlockState.cs b/Dalamud/Game/UnlockState/UnlockState.cs index ae8fc4f86..e2528c7e7 100644 --- a/Dalamud/Game/UnlockState/UnlockState.cs +++ b/Dalamud/Game/UnlockState/UnlockState.cs @@ -263,6 +263,7 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState return PlayerState.Instance()->IsSecretRecipeBookUnlocked(row.ItemAction.Value.Data[0]); case ItemActionType.UnlockLink: + case ItemActionType.OccultRecords: return UIState.Instance()->IsUnlockLinkUnlocked(row.ItemAction.Value.Data[0]); case ItemActionType.TripleTriadCard when row.AdditionalData.Is(): @@ -387,18 +388,19 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState if (row.ItemAction.RowId == 0) return false; - return (ItemActionType)row.ItemAction.Value.Type is - ItemActionType.Companion or - ItemActionType.BuddyEquip or - ItemActionType.Mount or - ItemActionType.SecretRecipeBook or - ItemActionType.UnlockLink or - ItemActionType.TripleTriadCard or - ItemActionType.FolkloreTome or - ItemActionType.OrchestrionRoll or - ItemActionType.FramersKit or - ItemActionType.Ornament or - ItemActionType.Glasses; + return (ItemActionType)row.ItemAction.Value.Type + is ItemActionType.Companion + or ItemActionType.BuddyEquip + or ItemActionType.Mount + or ItemActionType.SecretRecipeBook + or ItemActionType.UnlockLink + or ItemActionType.TripleTriadCard + or ItemActionType.FolkloreTome + or ItemActionType.OrchestrionRoll + or ItemActionType.FramersKit + or ItemActionType.Ornament + or ItemActionType.Glasses + or ItemActionType.OccultRecords; } /// From 193d321103f78f23e79c6c3ddf5d864830d7fd30 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Sat, 18 Oct 2025 02:33:06 +0200 Subject: [PATCH 11/18] Add support for Soul Shard items --- Dalamud/Game/UnlockState/UnlockState.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Dalamud/Game/UnlockState/UnlockState.cs b/Dalamud/Game/UnlockState/UnlockState.cs index e2528c7e7..14ea9e2c9 100644 --- a/Dalamud/Game/UnlockState/UnlockState.cs +++ b/Dalamud/Game/UnlockState/UnlockState.cs @@ -8,6 +8,7 @@ using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.InstanceContent; using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.FFXIV.Component.Exd; @@ -284,6 +285,10 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState case ItemActionType.Glasses: return PlayerState.Instance()->IsGlassesUnlocked((ushort)row.AdditionalData.RowId); + case ItemActionType.SoulShards when PublicContentOccultCrescent.GetState() is var occultCrescentState && occultCrescentState != null: + var supportJobId = (byte)row.ItemAction.Value.Data[0]; + return supportJobId < occultCrescentState->SupportJobLevels.Length && occultCrescentState->SupportJobLevels[supportJobId] != 0; + case ItemActionType.CompanySealVouchers: return false; } @@ -400,7 +405,8 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState or ItemActionType.FramersKit or ItemActionType.Ornament or ItemActionType.Glasses - or ItemActionType.OccultRecords; + or ItemActionType.OccultRecords + or ItemActionType.SoulShards; } /// @@ -625,6 +631,7 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState // - Achievements // - Titles // - Bozjan Field Notes + // - Support/Phantom Jobs, which require to be in Occult Crescent, because it checks the jobs level for != 0 } private void UpdateUnlocksForSheet(bool fireEvent = true) where T : struct, IExcelRow From 880add5ab374afa7fa7203c9002346fd8265715d Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Sat, 18 Oct 2025 02:50:38 +0200 Subject: [PATCH 12/18] Add support for MKDLore rows --- Dalamud/Game/UnlockState/UnlockState.cs | 13 +++++++++++++ Dalamud/Plugin/Services/IUnlockState.cs | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/Dalamud/Game/UnlockState/UnlockState.cs b/Dalamud/Game/UnlockState/UnlockState.cs index 14ea9e2c9..a016b91cb 100644 --- a/Dalamud/Game/UnlockState/UnlockState.cs +++ b/Dalamud/Game/UnlockState/UnlockState.cs @@ -309,6 +309,12 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState return this.IsUnlockLinkUnlocked(row.UnlockLink); } + /// + public bool IsMKDLoreUnlocked(MKDLore row) + { + return this.IsUnlockLinkUnlocked(row.Unknown2); + } + /// public bool IsMountUnlocked(Mount row) { @@ -493,6 +499,9 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState if (rowRef.TryGetValue(out var mjiLandmarkRow)) return this.IsMJILandmarkUnlocked(mjiLandmarkRow); + if (rowRef.TryGetValue(out var mkdLoreRow)) + return this.IsMKDLoreUnlocked(mkdLoreRow); + if (rowRef.TryGetValue(out var mcGuffinRow)) return this.IsMcGuffinUnlocked(mcGuffinRow); @@ -599,6 +608,7 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); @@ -772,6 +782,9 @@ internal class UnlockStatePluginScoped : IInternalDisposableService, IUnlockStat /// public bool IsMJILandmarkUnlocked(MJILandmark row) => this.unlockStateService.IsMJILandmarkUnlocked(row); + /// + public bool IsMKDLoreUnlocked(MKDLore row) => this.unlockStateService.IsMKDLoreUnlocked(row); + /// public bool IsMountUnlocked(Mount row) => this.unlockStateService.IsMountUnlocked(row); diff --git a/Dalamud/Plugin/Services/IUnlockState.cs b/Dalamud/Plugin/Services/IUnlockState.cs index 22ef94eb2..79f68416e 100644 --- a/Dalamud/Plugin/Services/IUnlockState.cs +++ b/Dalamud/Plugin/Services/IUnlockState.cs @@ -206,6 +206,13 @@ public interface IUnlockState /// if unlocked; otherwise, . bool IsMJILandmarkUnlocked(MJILandmark row); + /// + /// Determines whether the specified MKDLore (Occult Record) is unlocked. + /// + /// The MKDLore row to check. + /// if unlocked; otherwise, . + bool IsMKDLoreUnlocked(MKDLore row); + /// /// Determines whether the specified Mount is unlocked. /// From a06c0e3ed2497823a168c322b82ed5b7e40e2cd2 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Sat, 18 Oct 2025 03:51:38 +0200 Subject: [PATCH 13/18] Add support for EmjVoiceNpc rows --- Dalamud/Game/UnlockState/UnlockState.cs | 10 ++++++++++ Dalamud/Plugin/Services/IUnlockState.cs | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/Dalamud/Game/UnlockState/UnlockState.cs b/Dalamud/Game/UnlockState/UnlockState.cs index a016b91cb..0d4a8859e 100644 --- a/Dalamud/Game/UnlockState/UnlockState.cs +++ b/Dalamud/Game/UnlockState/UnlockState.cs @@ -205,6 +205,12 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState return this.IsUnlockLinkUnlocked(row.UnlockLink); } + /// + public bool IsEmjVoiceNpcUnlocked(EmjVoiceNpc row) + { + return this.IsUnlockLinkUnlocked(row.Unknown26); + } + /// public bool IsGeneralActionUnlocked(GeneralAction row) { @@ -601,6 +607,7 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); this.UpdateUnlocksForSheet(fireEvent); @@ -758,6 +765,9 @@ internal class UnlockStatePluginScoped : IInternalDisposableService, IUnlockStat /// public bool IsEmoteUnlocked(Emote row) => this.unlockStateService.IsEmoteUnlocked(row); + /// + public bool IsEmjVoiceNpcUnlocked(EmjVoiceNpc row) => this.unlockStateService.IsEmjVoiceNpcUnlocked(row); + /// public bool IsGeneralActionUnlocked(GeneralAction row) => this.unlockStateService.IsGeneralActionUnlocked(row); diff --git a/Dalamud/Plugin/Services/IUnlockState.cs b/Dalamud/Plugin/Services/IUnlockState.cs index 79f68416e..50167812d 100644 --- a/Dalamud/Plugin/Services/IUnlockState.cs +++ b/Dalamud/Plugin/Services/IUnlockState.cs @@ -150,6 +150,13 @@ public interface IUnlockState /// if unlocked; otherwise, . bool IsEmoteUnlocked(Emote row); + /// + /// Determines whether the specified EmjVoiceNpc (Doman Mahjong Characters) is unlocked. + /// + /// The EmjVoiceNpc row to check. + /// if unlocked; otherwise, . + bool IsEmjVoiceNpcUnlocked(EmjVoiceNpc row); + /// /// Determines whether the specified GeneralAction is unlocked. /// From 69caffeb97e31d5ec5c58e75ef34ca4de5f733f5 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Sat, 18 Oct 2025 03:52:31 +0200 Subject: [PATCH 14/18] Add support for EmjCostume rows --- Dalamud/Game/UnlockState/UnlockState.cs | 15 +++++++++++++++ Dalamud/Plugin/Services/IUnlockState.cs | 7 +++++++ 2 files changed, 22 insertions(+) diff --git a/Dalamud/Game/UnlockState/UnlockState.cs b/Dalamud/Game/UnlockState/UnlockState.cs index 0d4a8859e..f86b89efb 100644 --- a/Dalamud/Game/UnlockState/UnlockState.cs +++ b/Dalamud/Game/UnlockState/UnlockState.cs @@ -8,6 +8,7 @@ using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.InstanceContent; using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.FFXIV.Component.Exd; @@ -211,6 +212,14 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState return this.IsUnlockLinkUnlocked(row.Unknown26); } + /// + public bool IsEmjCostumeUnlocked(EmjCostume row) + { + return this.dataManager.GetExcelSheet().TryGetRow(row.RowId, out var emjVoiceNpcRow) + && this.IsEmjVoiceNpcUnlocked(emjVoiceNpcRow) + && QuestManager.IsQuestComplete(row.Unknown1); + } + /// public bool IsGeneralActionUnlocked(GeneralAction row) { @@ -644,6 +653,9 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState // - FramersKit (is that just an Item?) // - ... more? + // Subrow sheets, which are incompatible with the current Unlock event, since RowRef doesn't carry the SubrowId: + // - EmjCostume + // Probably not happening, because it requires fetching data from server: // - Achievements // - Titles @@ -768,6 +780,9 @@ internal class UnlockStatePluginScoped : IInternalDisposableService, IUnlockStat /// public bool IsEmjVoiceNpcUnlocked(EmjVoiceNpc row) => this.unlockStateService.IsEmjVoiceNpcUnlocked(row); + /// + public bool IsEmjCostumeUnlocked(EmjCostume row) => this.unlockStateService.IsEmjCostumeUnlocked(row); + /// public bool IsGeneralActionUnlocked(GeneralAction row) => this.unlockStateService.IsGeneralActionUnlocked(row); diff --git a/Dalamud/Plugin/Services/IUnlockState.cs b/Dalamud/Plugin/Services/IUnlockState.cs index 50167812d..00f2df190 100644 --- a/Dalamud/Plugin/Services/IUnlockState.cs +++ b/Dalamud/Plugin/Services/IUnlockState.cs @@ -157,6 +157,13 @@ public interface IUnlockState /// if unlocked; otherwise, . bool IsEmjVoiceNpcUnlocked(EmjVoiceNpc row); + /// + /// Determines whether the specified EmjCostume (Doman Mahjong Character Costume) is unlocked. + /// + /// The EmjCostume row to check. + /// if unlocked; otherwise, . + bool IsEmjCostumeUnlocked(EmjCostume row); + /// /// Determines whether the specified GeneralAction is unlocked. /// From 700aaa4a5d647a74f657088f0d38a85694a3a28f Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Sun, 19 Oct 2025 17:20:30 +0200 Subject: [PATCH 15/18] Fix Unlock event not firing --- Dalamud/Game/UnlockState/UnlockState.cs | 111 ++++++++++-------------- 1 file changed, 48 insertions(+), 63 deletions(-) diff --git a/Dalamud/Game/UnlockState/UnlockState.cs b/Dalamud/Game/UnlockState/UnlockState.cs index f86b89efb..846be8294 100644 --- a/Dalamud/Game/UnlockState/UnlockState.cs +++ b/Dalamud/Game/UnlockState/UnlockState.cs @@ -577,65 +577,53 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState return UIState.Instance()->IsUnlockLinkUnlockedOrQuestCompleted(unlockLink); } - private void UpdateUnlocks() - { - try - { - this.UpdateUnlocks(false); - } - catch (Exception ex) - { - Log.Error(ex, "Error during initial unlock check"); - } - } - private void OnLogout(int type, int code) { this.cachedUnlockedRowIds.Clear(); } - private void UpdateUnlocks(bool fireEvent) + private void UpdateUnlocks() { if (!this.IsLoaded) return; - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); - this.UpdateUnlocksForSheet(fireEvent); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); + this.UpdateUnlocksForSheet(); // Not implemented: // - DescriptionPage: quite complex @@ -663,7 +651,7 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState // - Support/Phantom Jobs, which require to be in Occult Crescent, because it checks the jobs level for != 0 } - private void UpdateUnlocksForSheet(bool fireEvent = true) where T : struct, IExcelRow + private void UpdateUnlocksForSheet() where T : struct, IExcelRow { var unlockedRowIds = this.cachedUnlockedRowIds.GetOrAdd(typeof(T), _ => []); @@ -679,20 +667,17 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState unlockedRowIds.Add(row.RowId); - if (fireEvent) - { - Log.Verbose("Unlock detected: {row}", $"{typeof(T).Name}#{row.RowId}"); + Log.Verbose($"Unlock detected: {typeof(T).Name}#{row.RowId}"); - foreach (var action in Delegate.EnumerateInvocationList(this.Unlock)) + foreach (var action in Delegate.EnumerateInvocationList(this.Unlock)) + { + try { - try - { - action((RowRef)rowRef); - } - catch (Exception ex) - { - Log.Error(ex, "Exception during raise of {handler}", action.Method); - } + action((RowRef)rowRef); + } + catch (Exception ex) + { + Log.Error(ex, "Exception during raise of {handler}", action.Method); } } } From af8b61f08a4759512444f0d11c0e89e2b973c699 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Sat, 8 Nov 2025 11:47:31 +0100 Subject: [PATCH 16/18] Update to use AgentUpdate event --- Dalamud/Game/UnlockState/RecipeData.cs | 10 ++++++++-- Dalamud/Game/UnlockState/UnlockState.cs | 21 ++++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Dalamud/Game/UnlockState/RecipeData.cs b/Dalamud/Game/UnlockState/RecipeData.cs index 593b7fd38..b96cab951 100644 --- a/Dalamud/Game/UnlockState/RecipeData.cs +++ b/Dalamud/Game/UnlockState/RecipeData.cs @@ -54,7 +54,7 @@ internal unsafe class RecipeData : IInternalDisposableService this.clientState.Login += this.Update; this.clientState.Logout += this.OnLogout; this.clientState.LevelChanged += this.OnlevelChanged; - this.gameGui.UnlocksUpdate += this.Update; + this.gameGui.AgentUpdate += this.OnAgentUpdate; } /// @@ -63,7 +63,7 @@ internal unsafe class RecipeData : IInternalDisposableService this.clientState.Login -= this.Update; this.clientState.Logout -= this.OnLogout; this.clientState.LevelChanged -= this.OnlevelChanged; - this.gameGui.UnlocksUpdate -= this.Update; + this.gameGui.AgentUpdate -= this.OnAgentUpdate; } /// @@ -121,6 +121,12 @@ internal unsafe class RecipeData : IInternalDisposableService } } + private void OnAgentUpdate(AgentUpdateFlag agentUpdateFlag) + { + if (agentUpdateFlag.HasFlag(AgentUpdateFlag.UnlocksUpdate)) + this.Update(); + } + private void Update() { // based on Client::Game::UI::RecipeNote.InitializeStructs diff --git a/Dalamud/Game/UnlockState/UnlockState.cs b/Dalamud/Game/UnlockState/UnlockState.cs index 846be8294..a4b9381cc 100644 --- a/Dalamud/Game/UnlockState/UnlockState.cs +++ b/Dalamud/Game/UnlockState/UnlockState.cs @@ -49,9 +49,9 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState [ServiceManager.ServiceConstructor] private UnlockState() { - this.clientState.Login += this.UpdateUnlocks; + this.clientState.Login += this.OnLogin; this.clientState.Logout += this.OnLogout; - this.gameGui.UnlocksUpdate += this.UpdateUnlocks; + this.gameGui.AgentUpdate += this.OnAgentUpdate; } /// @@ -62,9 +62,9 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState /// void IInternalDisposableService.DisposeService() { - this.clientState.Login -= this.UpdateUnlocks; + this.clientState.Login -= this.OnLogin; this.clientState.Logout -= this.OnLogout; - this.gameGui.UnlocksUpdate -= this.UpdateUnlocks; + this.gameGui.AgentUpdate -= this.OnAgentUpdate; } /// @@ -577,12 +577,23 @@ internal unsafe class UnlockState : IInternalDisposableService, IUnlockState return UIState.Instance()->IsUnlockLinkUnlockedOrQuestCompleted(unlockLink); } + private void OnLogin() + { + this.Update(); + } + private void OnLogout(int type, int code) { this.cachedUnlockedRowIds.Clear(); } - private void UpdateUnlocks() + private void OnAgentUpdate(AgentUpdateFlag agentUpdateFlag) + { + if (agentUpdateFlag.HasFlag(AgentUpdateFlag.UnlocksUpdate)) + this.Update(); + } + + private void Update() { if (!this.IsLoaded) return; From 5cc327c5f9abd1439e81ec2eac486a1e6070081b Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Sat, 8 Nov 2025 11:49:23 +0100 Subject: [PATCH 17/18] Fix obsolete --- Dalamud/Game/UnlockState/RecipeData.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dalamud/Game/UnlockState/RecipeData.cs b/Dalamud/Game/UnlockState/RecipeData.cs index b96cab951..c419ba4fd 100644 --- a/Dalamud/Game/UnlockState/RecipeData.cs +++ b/Dalamud/Game/UnlockState/RecipeData.cs @@ -272,9 +272,9 @@ internal unsafe class RecipeData : IInternalDisposableService changed |= true; } - if (this.cachedCompletedQuests == null || !QuestManager.Instance()->CompletedQuestsBitmask.SequenceEqual(this.cachedCompletedQuests)) + if (this.cachedCompletedQuests == null || !QuestManager.Instance()->CompletedQuests.SequenceEqual(this.cachedCompletedQuests)) { - this.cachedCompletedQuests = QuestManager.Instance()->CompletedQuestsBitmask.ToArray(); + this.cachedCompletedQuests = QuestManager.Instance()->CompletedQuests.ToArray(); changed |= true; } From 497e61f699e9ccff29a48a3a37ea0f8f2fc7de00 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Sat, 8 Nov 2025 11:58:54 +0100 Subject: [PATCH 18/18] Remove comment about removed CS enum --- Dalamud/Game/UnlockState/ItemActionType.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Dalamud/Game/UnlockState/ItemActionType.cs b/Dalamud/Game/UnlockState/ItemActionType.cs index 741dcd31b..8e3d79b84 100644 --- a/Dalamud/Game/UnlockState/ItemActionType.cs +++ b/Dalamud/Game/UnlockState/ItemActionType.cs @@ -2,8 +2,6 @@ using Lumina.Excel.Sheets; namespace Dalamud.Game.UnlockState; -// TODO: Switch to FFXIVClientStructs.FFXIV.Client.Enums.ItemActionType. - /// /// Enum for . ///