From 6ade5b21cfad0d951ca92471906a1d21aca23c01 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Wed, 1 Oct 2025 17:06:06 +0200 Subject: [PATCH 01/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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 . /// From 2b2f628096f25b00994f5fc5abec1acc2eb6327e Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 13 Nov 2025 18:43:49 +0100 Subject: [PATCH 19/34] Convert ObjectTable enumerator to struct --- .../Game/ClientState/Objects/ObjectTable.cs | 40 ++----------------- 1 file changed, 3 insertions(+), 37 deletions(-) diff --git a/Dalamud/Game/ClientState/Objects/ObjectTable.cs b/Dalamud/Game/ClientState/Objects/ObjectTable.cs index 84c1b5693..598daf518 100644 --- a/Dalamud/Game/ClientState/Objects/ObjectTable.cs +++ b/Dalamud/Game/ClientState/Objects/ObjectTable.cs @@ -12,8 +12,6 @@ using Dalamud.Utility; using FFXIVClientStructs.Interop; -using Microsoft.Extensions.ObjectPool; - using CSGameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; using CSGameObjectManager = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObjectManager; @@ -34,8 +32,6 @@ internal sealed partial class ObjectTable : IServiceType, IObjectTable private readonly ClientState clientState; private readonly CachedEntry[] cachedObjectTable; - private readonly Enumerator?[] frameworkThreadEnumerators = new Enumerator?[4]; - [ServiceManager.ServiceConstructor] private unsafe ObjectTable(ClientState clientState) { @@ -47,9 +43,6 @@ internal sealed partial class ObjectTable : IServiceType, IObjectTable this.cachedObjectTable = new CachedEntry[objectTableLength]; for (var i = 0; i < this.cachedObjectTable.Length; i++) this.cachedObjectTable[i] = new(nativeObjectTable.GetPointer(i)); - - for (var i = 0; i < this.frameworkThreadEnumerators.Length; i++) - this.frameworkThreadEnumerators[i] = new(this, i); } /// @@ -239,30 +232,14 @@ internal sealed partial class ObjectTable public IEnumerator GetEnumerator() { ThreadSafety.AssertMainThread(); - - // If we're on the framework thread, see if there's an already allocated enumerator available for use. - foreach (ref var x in this.frameworkThreadEnumerators.AsSpan()) - { - if (x is not null) - { - var t = x; - x = null; - t.Reset(); - return t; - } - } - - // No reusable enumerator is available; allocate a new temporary one. - return new Enumerator(this, -1); + return new Enumerator(this); } /// IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); - private sealed class Enumerator(ObjectTable owner, int slotId) : IEnumerator, IResettable + private struct Enumerator(ObjectTable owner) : IEnumerator { - private ObjectTable? owner = owner; - private int index = -1; public IGameObject Current { get; private set; } = null!; @@ -274,7 +251,7 @@ internal sealed partial class ObjectTable if (this.index == objectTableLength) return false; - var cache = this.owner!.cachedObjectTable.AsSpan(); + var cache = owner.cachedObjectTable.AsSpan(); for (this.index++; this.index < objectTableLength; this.index++) { if (cache[this.index].Update() is { } ao) @@ -291,17 +268,6 @@ internal sealed partial class ObjectTable public void Dispose() { - if (this.owner is not { } o) - return; - - if (slotId != -1) - o.frameworkThreadEnumerators[slotId] = this; - } - - public bool TryReset() - { - this.Reset(); - return true; } } } From dd70c5b8eea1627566f0b355fc5ce7807007e803 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 13 Nov 2025 18:36:45 +0100 Subject: [PATCH 20/34] Add struct enumerator to AetheryteList --- .../ClientState/Aetherytes/AetheryteList.cs | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/Dalamud/Game/ClientState/Aetherytes/AetheryteList.cs b/Dalamud/Game/ClientState/Aetherytes/AetheryteList.cs index a3d44d423..f72339ed2 100644 --- a/Dalamud/Game/ClientState/Aetherytes/AetheryteList.cs +++ b/Dalamud/Game/ClientState/Aetherytes/AetheryteList.cs @@ -87,10 +87,7 @@ internal sealed partial class AetheryteList /// public IEnumerator GetEnumerator() { - for (var i = 0; i < this.Length; i++) - { - yield return this[i]; - } + return new Enumerator(this); } /// @@ -98,4 +95,30 @@ internal sealed partial class AetheryteList { return this.GetEnumerator(); } + + private struct Enumerator(AetheryteList aetheryteList) : IEnumerator + { + private int index = 0; + + public IAetheryteEntry Current { get; private set; } + + object IEnumerator.Current => this.Current; + + public bool MoveNext() + { + if (this.index == aetheryteList.Length) return false; + this.Current = aetheryteList[this.index]; + this.index++; + return true; + } + + public void Reset() + { + this.index = 0; + } + + public void Dispose() + { + } + } } From 520e3ea028044395925e8d73d29a1a2fb4f5410f Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 13 Nov 2025 18:37:15 +0100 Subject: [PATCH 21/34] Convert AetheryteEntry to readonly struct --- .../ClientState/Aetherytes/AetheryteEntry.cs | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/Dalamud/Game/ClientState/Aetherytes/AetheryteEntry.cs b/Dalamud/Game/ClientState/Aetherytes/AetheryteEntry.cs index 89dd8b8b1..e0a5df06d 100644 --- a/Dalamud/Game/ClientState/Aetherytes/AetheryteEntry.cs +++ b/Dalamud/Game/ClientState/Aetherytes/AetheryteEntry.cs @@ -63,47 +63,37 @@ public interface IAetheryteEntry } /// -/// Class representing an aetheryte entry available to the game. +/// This struct represents an aetheryte entry available to the game. /// -internal sealed class AetheryteEntry : IAetheryteEntry +/// Data read from the Aetheryte List. +internal readonly struct AetheryteEntry(TeleportInfo data) : IAetheryteEntry { - private readonly TeleportInfo data; - - /// - /// Initializes a new instance of the class. - /// - /// Data read from the Aetheryte List. - internal AetheryteEntry(TeleportInfo data) - { - this.data = data; - } + /// + public uint AetheryteId => data.AetheryteId; /// - public uint AetheryteId => this.data.AetheryteId; + public uint TerritoryId => data.TerritoryId; /// - public uint TerritoryId => this.data.TerritoryId; + public byte SubIndex => data.SubIndex; /// - public byte SubIndex => this.data.SubIndex; + public byte Ward => data.Ward; /// - public byte Ward => this.data.Ward; + public byte Plot => data.Plot; /// - public byte Plot => this.data.Plot; + public uint GilCost => data.GilCost; /// - public uint GilCost => this.data.GilCost; + public bool IsFavourite => data.IsFavourite; /// - public bool IsFavourite => this.data.IsFavourite; + public bool IsSharedHouse => data.IsSharedHouse; /// - public bool IsSharedHouse => this.data.IsSharedHouse; - - /// - public bool IsApartment => this.data.IsApartment; + public bool IsApartment => data.IsApartment; /// public RowRef AetheryteData => LuminaUtils.CreateRef(this.AetheryteId); From 8a9b47c7a472987c33240594fbea645727fb7dc3 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 13 Nov 2025 18:37:35 +0100 Subject: [PATCH 22/34] Add struct enumerator to BuddyList --- Dalamud/Game/ClientState/Buddy/BuddyList.cs | 31 ++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/Dalamud/Game/ClientState/Buddy/BuddyList.cs b/Dalamud/Game/ClientState/Buddy/BuddyList.cs index 84cfd24a3..71121e54e 100644 --- a/Dalamud/Game/ClientState/Buddy/BuddyList.cs +++ b/Dalamud/Game/ClientState/Buddy/BuddyList.cs @@ -130,12 +130,35 @@ internal sealed partial class BuddyList /// public IEnumerator GetEnumerator() { - for (var i = 0; i < this.Length; i++) - { - yield return this[i]; - } + return new Enumerator(this); } /// IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + private struct Enumerator(BuddyList buddyList) : IEnumerator + { + private int index = 0; + + public IBuddyMember Current { get; private set; } + + object IEnumerator.Current => this.Current; + + public bool MoveNext() + { + if (this.index == buddyList.Length) return false; + this.Current = buddyList[this.index]; + this.index++; + return true; + } + + public void Reset() + { + this.index = 0; + } + + public void Dispose() + { + } + } } From 23e7c164d86ca1d59713f7f7f8955f2eed2b29d6 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 13 Nov 2025 18:42:07 +0100 Subject: [PATCH 23/34] Convert BuddyMember to readonly struct --- Dalamud/Game/ClientState/Buddy/BuddyList.cs | 33 +++++----- Dalamud/Game/ClientState/Buddy/BuddyMember.cs | 60 ++++++++++++------- 2 files changed, 56 insertions(+), 37 deletions(-) diff --git a/Dalamud/Game/ClientState/Buddy/BuddyList.cs b/Dalamud/Game/ClientState/Buddy/BuddyList.cs index 71121e54e..78809f8ba 100644 --- a/Dalamud/Game/ClientState/Buddy/BuddyList.cs +++ b/Dalamud/Game/ClientState/Buddy/BuddyList.cs @@ -8,6 +8,9 @@ using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game.UI; +using CSBuddy = FFXIVClientStructs.FFXIV.Client.Game.UI.Buddy; +using CSBuddyMember = FFXIVClientStructs.FFXIV.Client.Game.UI.Buddy.BuddyMember; + namespace Dalamud.Game.ClientState.Buddy; /// @@ -21,7 +24,7 @@ namespace Dalamud.Game.ClientState.Buddy; #pragma warning restore SA1015 internal sealed partial class BuddyList : IServiceType, IBuddyList { - private const uint InvalidObjectID = 0xE0000000; + private const uint InvalidEntityId = 0xE0000000; [ServiceManager.ServiceDependency] private readonly ClientState clientState = Service.Get(); @@ -69,7 +72,7 @@ internal sealed partial class BuddyList : IServiceType, IBuddyList } } - private unsafe FFXIVClientStructs.FFXIV.Client.Game.UI.Buddy* BuddyListStruct => &UIState.Instance()->Buddy; + private unsafe CSBuddy* BuddyListStruct => &UIState.Instance()->Buddy; /// public IBuddyMember? this[int index] @@ -82,37 +85,37 @@ internal sealed partial class BuddyList : IServiceType, IBuddyList } /// - public unsafe IntPtr GetCompanionBuddyMemberAddress() + public unsafe nint GetCompanionBuddyMemberAddress() { - return (IntPtr)this.BuddyListStruct->CompanionInfo.Companion; + return (nint)this.BuddyListStruct->CompanionInfo.Companion; } /// - public unsafe IntPtr GetPetBuddyMemberAddress() + public unsafe nint GetPetBuddyMemberAddress() { - return (IntPtr)this.BuddyListStruct->PetInfo.Pet; + return (nint)this.BuddyListStruct->PetInfo.Pet; } /// - public unsafe IntPtr GetBattleBuddyMemberAddress(int index) + public unsafe nint GetBattleBuddyMemberAddress(int index) { if (index < 0 || index >= 3) - return IntPtr.Zero; + return 0; - return (IntPtr)Unsafe.AsPointer(ref this.BuddyListStruct->BattleBuddies[index]); + return (nint)Unsafe.AsPointer(ref this.BuddyListStruct->BattleBuddies[index]); } /// - public IBuddyMember? CreateBuddyMemberReference(IntPtr address) + public unsafe IBuddyMember? CreateBuddyMemberReference(nint address) { + if (address == 0) + return null; + if (this.clientState.LocalContentId == 0) return null; - if (address == IntPtr.Zero) - return null; - - var buddy = new BuddyMember(address); - if (buddy.ObjectId == InvalidObjectID) + var buddy = new BuddyMember((CSBuddyMember*)address); + if (buddy.EntityId == InvalidEntityId) return null; return buddy; diff --git a/Dalamud/Game/ClientState/Buddy/BuddyMember.cs b/Dalamud/Game/ClientState/Buddy/BuddyMember.cs index 393598d32..8018bafaf 100644 --- a/Dalamud/Game/ClientState/Buddy/BuddyMember.cs +++ b/Dalamud/Game/ClientState/Buddy/BuddyMember.cs @@ -1,20 +1,24 @@ +using System.Diagnostics.CodeAnalysis; + using Dalamud.Data; using Dalamud.Game.ClientState.Objects; using Dalamud.Game.ClientState.Objects.Types; using Lumina.Excel; +using CSBuddyMember = FFXIVClientStructs.FFXIV.Client.Game.UI.Buddy.BuddyMember; + namespace Dalamud.Game.ClientState.Buddy; /// /// Interface representing represents a buddy such as the chocobo companion, summoned pets, squadron groups and trust parties. /// -public interface IBuddyMember +public interface IBuddyMember : IEquatable { /// /// Gets the address of the buddy in memory. /// - IntPtr Address { get; } + nint Address { get; } /// /// Gets the object ID of this buddy. @@ -67,42 +71,34 @@ public interface IBuddyMember } /// -/// This class represents a buddy such as the chocobo companion, summoned pets, squadron groups and trust parties. +/// This struct represents a buddy such as the chocobo companion, summoned pets, squadron groups and trust parties. /// -internal unsafe class BuddyMember : IBuddyMember +/// A pointer to the BuddyMember. +internal readonly unsafe struct BuddyMember(CSBuddyMember* ptr) : IBuddyMember { [ServiceManager.ServiceDependency] private readonly ObjectTable objectTable = Service.Get(); - /// - /// Initializes a new instance of the class. - /// - /// Buddy address. - internal BuddyMember(IntPtr address) - { - this.Address = address; - } + /// + public nint Address => (nint)ptr; /// - public IntPtr Address { get; } + public uint ObjectId => this.EntityId; /// - public uint ObjectId => this.Struct->EntityId; + public uint EntityId => ptr->EntityId; /// - public uint EntityId => this.Struct->EntityId; + public IGameObject? GameObject => this.objectTable.SearchById(this.EntityId); /// - public IGameObject? GameObject => this.objectTable.SearchById(this.ObjectId); + public uint CurrentHP => ptr->CurrentHealth; /// - public uint CurrentHP => this.Struct->CurrentHealth; + public uint MaxHP => ptr->MaxHealth; /// - public uint MaxHP => this.Struct->MaxHealth; - - /// - public uint DataID => this.Struct->DataId; + public uint DataID => ptr->DataId; /// public RowRef MountData => LuminaUtils.CreateRef(this.DataID); @@ -113,5 +109,25 @@ internal unsafe class BuddyMember : IBuddyMember /// public RowRef TrustData => LuminaUtils.CreateRef(this.DataID); - private FFXIVClientStructs.FFXIV.Client.Game.UI.Buddy.BuddyMember* Struct => (FFXIVClientStructs.FFXIV.Client.Game.UI.Buddy.BuddyMember*)this.Address; + public static bool operator ==(BuddyMember x, BuddyMember y) => x.Equals(y); + + public static bool operator !=(BuddyMember x, BuddyMember y) => !(x == y); + + /// + public bool Equals(IBuddyMember? other) + { + return this.EntityId == other.EntityId; + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + { + return obj is BuddyMember fate && this.Equals(fate); + } + + /// + public override int GetHashCode() + { + return this.EntityId.GetHashCode(); + } } From d1bed3ebc5e5f4ec8a7104c2e718babb4b546425 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 13 Nov 2025 18:42:31 +0100 Subject: [PATCH 24/34] Add struct enumerator to FateTable --- Dalamud/Game/ClientState/Fates/FateTable.cs | 31 ++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/Dalamud/Game/ClientState/Fates/FateTable.cs b/Dalamud/Game/ClientState/Fates/FateTable.cs index 1bf557ad5..942d1561f 100644 --- a/Dalamud/Game/ClientState/Fates/FateTable.cs +++ b/Dalamud/Game/ClientState/Fates/FateTable.cs @@ -110,12 +110,35 @@ internal sealed partial class FateTable /// public IEnumerator GetEnumerator() { - for (var i = 0; i < this.Length; i++) - { - yield return this[i]; - } + return new Enumerator(this); } /// IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + private struct Enumerator(FateTable fateTable) : IEnumerator + { + private int index = 0; + + public IFate Current { get; private set; } + + object IEnumerator.Current => this.Current; + + public bool MoveNext() + { + if (this.index == fateTable.Length) return false; + this.Current = fateTable[this.index]; + this.index++; + return true; + } + + public void Reset() + { + this.index = 0; + } + + public void Dispose() + { + } + } } From a48eead85e9f9fb98fcaa841c34752b7dca700e2 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 13 Nov 2025 18:43:06 +0100 Subject: [PATCH 25/34] Convert Fate to readonly struct --- Dalamud/Game/ClientState/Fates/Fate.cs | 130 ++++++++------------ Dalamud/Game/ClientState/Fates/FateTable.cs | 22 ++-- 2 files changed, 59 insertions(+), 93 deletions(-) diff --git a/Dalamud/Game/ClientState/Fates/Fate.cs b/Dalamud/Game/ClientState/Fates/Fate.cs index 504b690c3..c40a8960e 100644 --- a/Dalamud/Game/ClientState/Fates/Fate.cs +++ b/Dalamud/Game/ClientState/Fates/Fate.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Numerics; using Dalamud.Data; @@ -6,10 +7,12 @@ using Dalamud.Memory; using Lumina.Excel; +using CSFateContext = FFXIVClientStructs.FFXIV.Client.Game.Fate.FateContext; + namespace Dalamud.Game.ClientState.Fates; /// -/// Interface representing an fate entry that can be seen in the current area. +/// Interface representing a fate entry that can be seen in the current area. /// public interface IFate : IEquatable { @@ -111,133 +114,96 @@ public interface IFate : IEquatable /// /// Gets the address of this Fate in memory. /// - IntPtr Address { get; } + nint Address { get; } } /// -/// This class represents an FFXIV Fate. +/// This struct represents a Fate. /// -internal unsafe partial class Fate +/// A pointer to the FateContext. +internal readonly unsafe struct Fate(CSFateContext* ptr) : IFate { - /// - /// Initializes a new instance of the class. - /// - /// The address of this fate in memory. - internal Fate(IntPtr address) - { - this.Address = address; - } - /// - public IntPtr Address { get; } - - private FFXIVClientStructs.FFXIV.Client.Game.Fate.FateContext* Struct => (FFXIVClientStructs.FFXIV.Client.Game.Fate.FateContext*)this.Address; - - public static bool operator ==(Fate fate1, Fate fate2) - { - if (fate1 is null || fate2 is null) - return Equals(fate1, fate2); - - return fate1.Equals(fate2); - } - - public static bool operator !=(Fate fate1, Fate fate2) => !(fate1 == fate2); - - /// - /// Gets a value indicating whether this Fate is still valid in memory. - /// - /// The fate to check. - /// True or false. - public static bool IsValid(Fate fate) - { - var clientState = Service.GetNullable(); - - if (fate == null || clientState == null) - return false; - - if (clientState.LocalContentId == 0) - return false; - - return true; - } - - /// - /// Gets a value indicating whether this actor is still valid in memory. - /// - /// True or false. - public bool IsValid() => IsValid(this); + public nint Address => (nint)ptr; /// - bool IEquatable.Equals(IFate other) => this.FateId == other?.FateId; - - /// - public override bool Equals(object obj) => ((IEquatable)this).Equals(obj as IFate); - - /// - public override int GetHashCode() => this.FateId.GetHashCode(); -} - -/// -/// This class represents an FFXIV Fate. -/// -internal unsafe partial class Fate : IFate -{ - /// - public ushort FateId => this.Struct->FateId; + public ushort FateId => ptr->FateId; /// public RowRef GameData => LuminaUtils.CreateRef(this.FateId); /// - public int StartTimeEpoch => this.Struct->StartTimeEpoch; + public int StartTimeEpoch => ptr->StartTimeEpoch; /// - public short Duration => this.Struct->Duration; + public short Duration => ptr->Duration; /// public long TimeRemaining => this.StartTimeEpoch + this.Duration - DateTimeOffset.Now.ToUnixTimeSeconds(); /// - public SeString Name => MemoryHelper.ReadSeString(&this.Struct->Name); + public SeString Name => MemoryHelper.ReadSeString(&ptr->Name); /// - public SeString Description => MemoryHelper.ReadSeString(&this.Struct->Description); + public SeString Description => MemoryHelper.ReadSeString(&ptr->Description); /// - public SeString Objective => MemoryHelper.ReadSeString(&this.Struct->Objective); + public SeString Objective => MemoryHelper.ReadSeString(&ptr->Objective); /// - public FateState State => (FateState)this.Struct->State; + public FateState State => (FateState)ptr->State; /// - public byte HandInCount => this.Struct->HandInCount; + public byte HandInCount => ptr->HandInCount; /// - public byte Progress => this.Struct->Progress; + public byte Progress => ptr->Progress; /// - public bool HasBonus => this.Struct->IsBonus; + public bool HasBonus => ptr->IsBonus; /// - public uint IconId => this.Struct->IconId; + public uint IconId => ptr->IconId; /// - public byte Level => this.Struct->Level; + public byte Level => ptr->Level; /// - public byte MaxLevel => this.Struct->MaxLevel; + public byte MaxLevel => ptr->MaxLevel; /// - public Vector3 Position => this.Struct->Location; + public Vector3 Position => ptr->Location; /// - public float Radius => this.Struct->Radius; + public float Radius => ptr->Radius; /// - public uint MapIconId => this.Struct->MapIconId; + public uint MapIconId => ptr->MapIconId; /// /// Gets the territory this is located in. /// - public RowRef TerritoryType => LuminaUtils.CreateRef(this.Struct->MapMarkers[0].MapMarkerData.TerritoryTypeId); + public RowRef TerritoryType => LuminaUtils.CreateRef(ptr->MapMarkers[0].MapMarkerData.TerritoryTypeId); + + public static bool operator ==(Fate x, Fate y) => x.Equals(y); + + public static bool operator !=(Fate x, Fate y) => !(x == y); + + /// + public bool Equals(IFate? other) + { + return this.FateId == other.FateId; + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + { + return obj is Fate fate && this.Equals(fate); + } + + /// + public override int GetHashCode() + { + return this.FateId.GetHashCode(); + } } diff --git a/Dalamud/Game/ClientState/Fates/FateTable.cs b/Dalamud/Game/ClientState/Fates/FateTable.cs index 942d1561f..a6edf4a18 100644 --- a/Dalamud/Game/ClientState/Fates/FateTable.cs +++ b/Dalamud/Game/ClientState/Fates/FateTable.cs @@ -5,6 +5,7 @@ using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Plugin.Services; +using CSFateContext = FFXIVClientStructs.FFXIV.Client.Game.Fate.FateContext; using CSFateManager = FFXIVClientStructs.FFXIV.Client.Game.Fate.FateManager; namespace Dalamud.Game.ClientState.Fates; @@ -25,7 +26,7 @@ internal sealed partial class FateTable : IServiceType, IFateTable } /// - public unsafe IntPtr Address => (nint)CSFateManager.Instance(); + public unsafe nint Address => (nint)CSFateManager.Instance(); /// public unsafe int Length @@ -72,30 +73,29 @@ internal sealed partial class FateTable : IServiceType, IFateTable } /// - public unsafe IntPtr GetFateAddress(int index) + public unsafe nint GetFateAddress(int index) { if (index >= this.Length) - return IntPtr.Zero; + return 0; var fateManager = CSFateManager.Instance(); if (fateManager == null) - return IntPtr.Zero; + return 0; - return (IntPtr)fateManager->Fates[index].Value; + return (nint)fateManager->Fates[index].Value; } /// - public IFate? CreateFateReference(IntPtr offset) + public unsafe IFate? CreateFateReference(IntPtr address) { - var clientState = Service.Get(); + if (address == 0) + return null; + var clientState = Service.Get(); if (clientState.LocalContentId == 0) return null; - if (offset == IntPtr.Zero) - return null; - - return new Fate(offset); + return new Fate((CSFateContext*)address); } } From d1dc81318a8aa26fbf35da9de96fba3fb369edd5 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 13 Nov 2025 18:47:55 +0100 Subject: [PATCH 26/34] Add struct enumerator to PartyList --- Dalamud/Game/ClientState/Party/PartyList.cs | 46 ++++++++++++++++----- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/Dalamud/Game/ClientState/Party/PartyList.cs b/Dalamud/Game/ClientState/Party/PartyList.cs index a016a8211..bfd423a79 100644 --- a/Dalamud/Game/ClientState/Party/PartyList.cs +++ b/Dalamud/Game/ClientState/Party/PartyList.cs @@ -133,18 +133,44 @@ internal sealed partial class PartyList /// public IEnumerator GetEnumerator() { - // Normally using Length results in a recursion crash, however we know the party size via ptr. - for (var i = 0; i < this.Length; i++) - { - var member = this[i]; - - if (member == null) - break; - - yield return member; - } + return new Enumerator(this); } /// IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + private struct Enumerator(PartyList partyList) : IEnumerator + { + private int index = 0; + + public IPartyMember Current { get; private set; } + + object IEnumerator.Current => this.Current; + + public bool MoveNext() + { + if (this.index == partyList.Length) return false; + + for (; this.index < partyList.Length; this.index++) + { + var partyMember = partyList[this.index]; + if (partyMember != null) + { + this.Current = partyMember; + return true; + } + } + + return false; + } + + public void Reset() + { + this.index = 0; + } + + public void Dispose() + { + } + } } From 53b94caeb7470dc7f2ca639412188f38a7f19343 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 13 Nov 2025 18:53:03 +0100 Subject: [PATCH 27/34] Convert PartyMember to readonly struct --- Dalamud/Game/ClientState/Party/PartyList.cs | 31 ++++---- Dalamud/Game/ClientState/Party/PartyMember.cs | 79 +++++++++++-------- 2 files changed, 62 insertions(+), 48 deletions(-) diff --git a/Dalamud/Game/ClientState/Party/PartyList.cs b/Dalamud/Game/ClientState/Party/PartyList.cs index bfd423a79..ec22932ab 100644 --- a/Dalamud/Game/ClientState/Party/PartyList.cs +++ b/Dalamud/Game/ClientState/Party/PartyList.cs @@ -8,6 +8,7 @@ using Dalamud.IoC.Internal; using Dalamud.Plugin.Services; using CSGroupManager = FFXIVClientStructs.FFXIV.Client.Game.Group.GroupManager; +using CSPartyMember = FFXIVClientStructs.FFXIV.Client.Game.Group.PartyMember; namespace Dalamud.Game.ClientState.Party; @@ -42,20 +43,20 @@ internal sealed unsafe partial class PartyList : IServiceType, IPartyList public bool IsAlliance => this.GroupManagerStruct->MainGroup.AllianceFlags > 0; /// - public unsafe IntPtr GroupManagerAddress => (nint)CSGroupManager.Instance(); + public unsafe nint GroupManagerAddress => (nint)CSGroupManager.Instance(); /// - public IntPtr GroupListAddress => (IntPtr)Unsafe.AsPointer(ref GroupManagerStruct->MainGroup.PartyMembers[0]); + public nint GroupListAddress => (nint)Unsafe.AsPointer(ref GroupManagerStruct->MainGroup.PartyMembers[0]); /// - public IntPtr AllianceListAddress => (IntPtr)Unsafe.AsPointer(ref this.GroupManagerStruct->MainGroup.AllianceMembers[0]); + public nint AllianceListAddress => (nint)Unsafe.AsPointer(ref this.GroupManagerStruct->MainGroup.AllianceMembers[0]); /// public long PartyId => this.GroupManagerStruct->MainGroup.PartyId; - private static int PartyMemberSize { get; } = Marshal.SizeOf(); + private static int PartyMemberSize { get; } = Marshal.SizeOf(); - private FFXIVClientStructs.FFXIV.Client.Game.Group.GroupManager* GroupManagerStruct => (FFXIVClientStructs.FFXIV.Client.Game.Group.GroupManager*)this.GroupManagerAddress; + private CSGroupManager* GroupManagerStruct => (CSGroupManager*)this.GroupManagerAddress; /// public IPartyMember? this[int index] @@ -80,45 +81,45 @@ internal sealed unsafe partial class PartyList : IServiceType, IPartyList } /// - public IntPtr GetPartyMemberAddress(int index) + public nint GetPartyMemberAddress(int index) { if (index < 0 || index >= GroupLength) - return IntPtr.Zero; + return 0; return this.GroupListAddress + (index * PartyMemberSize); } /// - public IPartyMember? CreatePartyMemberReference(IntPtr address) + public IPartyMember? CreatePartyMemberReference(nint address) { if (this.clientState.LocalContentId == 0) return null; - if (address == IntPtr.Zero) + if (address == 0) return null; - return new PartyMember(address); + return new PartyMember((CSPartyMember*)address); } /// - public IntPtr GetAllianceMemberAddress(int index) + public nint GetAllianceMemberAddress(int index) { if (index < 0 || index >= AllianceLength) - return IntPtr.Zero; + return 0; return this.AllianceListAddress + (index * PartyMemberSize); } /// - public IPartyMember? CreateAllianceMemberReference(IntPtr address) + public IPartyMember? CreateAllianceMemberReference(nint address) { if (this.clientState.LocalContentId == 0) return null; - if (address == IntPtr.Zero) + if (address == 0) return null; - return new PartyMember(address); + return new PartyMember((CSPartyMember*)address); } } diff --git a/Dalamud/Game/ClientState/Party/PartyMember.cs b/Dalamud/Game/ClientState/Party/PartyMember.cs index 4c738d866..c9980d9f2 100644 --- a/Dalamud/Game/ClientState/Party/PartyMember.cs +++ b/Dalamud/Game/ClientState/Party/PartyMember.cs @@ -1,26 +1,27 @@ +using System.Diagnostics.CodeAnalysis; using System.Numerics; -using System.Runtime.CompilerServices; using Dalamud.Data; using Dalamud.Game.ClientState.Objects; using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Game.ClientState.Statuses; using Dalamud.Game.Text.SeStringHandling; -using Dalamud.Memory; using Lumina.Excel; +using CSPartyMember = FFXIVClientStructs.FFXIV.Client.Game.Group.PartyMember; + namespace Dalamud.Game.ClientState.Party; /// /// Interface representing a party member. /// -public interface IPartyMember +public interface IPartyMember : IEquatable { /// /// Gets the address of this party member in memory. /// - IntPtr Address { get; } + nint Address { get; } /// /// Gets a list of buffs or debuffs applied to this party member. @@ -108,69 +109,81 @@ public interface IPartyMember } /// -/// This class represents a party member in the group manager. +/// This struct represents a party member in the group manager. /// -internal unsafe class PartyMember : IPartyMember +/// A pointer to the PartyMember. +internal unsafe readonly struct PartyMember(CSPartyMember* ptr) : IPartyMember { - /// - /// Initializes a new instance of the class. - /// - /// Address of the party member. - internal PartyMember(IntPtr address) - { - this.Address = address; - } + /// + public nint Address => (nint)ptr; /// - public IntPtr Address { get; } + public StatusList Statuses => new(&ptr->StatusManager); /// - public StatusList Statuses => new(&this.Struct->StatusManager); + public Vector3 Position => ptr->Position; /// - public Vector3 Position => this.Struct->Position; + public long ContentId => (long)ptr->ContentId; /// - public long ContentId => (long)this.Struct->ContentId; + public uint ObjectId => ptr->EntityId; /// - public uint ObjectId => this.Struct->EntityId; - - /// - public uint EntityId => this.Struct->EntityId; + public uint EntityId => ptr->EntityId; /// public IGameObject? GameObject => Service.Get().SearchById(this.EntityId); /// - public uint CurrentHP => this.Struct->CurrentHP; + public uint CurrentHP => ptr->CurrentHP; /// - public uint MaxHP => this.Struct->MaxHP; + public uint MaxHP => ptr->MaxHP; /// - public ushort CurrentMP => this.Struct->CurrentMP; + public ushort CurrentMP => ptr->CurrentMP; /// - public ushort MaxMP => this.Struct->MaxMP; + public ushort MaxMP => ptr->MaxMP; /// - public RowRef Territory => LuminaUtils.CreateRef(this.Struct->TerritoryType); + public RowRef Territory => LuminaUtils.CreateRef(ptr->TerritoryType); /// - public RowRef World => LuminaUtils.CreateRef(this.Struct->HomeWorld); + public RowRef World => LuminaUtils.CreateRef(ptr->HomeWorld); /// - public SeString Name => SeString.Parse(this.Struct->Name); + public SeString Name => SeString.Parse(ptr->Name); /// - public byte Sex => this.Struct->Sex; + public byte Sex => ptr->Sex; /// - public RowRef ClassJob => LuminaUtils.CreateRef(this.Struct->ClassJob); + public RowRef ClassJob => LuminaUtils.CreateRef(ptr->ClassJob); /// - public byte Level => this.Struct->Level; + public byte Level => ptr->Level; - private FFXIVClientStructs.FFXIV.Client.Game.Group.PartyMember* Struct => (FFXIVClientStructs.FFXIV.Client.Game.Group.PartyMember*)this.Address; + public static bool operator ==(PartyMember x, PartyMember y) => x.Equals(y); + + public static bool operator !=(PartyMember x, PartyMember y) => !(x == y); + + /// + public bool Equals(IPartyMember? other) + { + return this.EntityId == other.EntityId; + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + { + return obj is PartyMember fate && this.Equals(fate); + } + + /// + public override int GetHashCode() + { + return this.EntityId.GetHashCode(); + } } From 7f2ed9adb6534934e6440b288fd6c753bddbe67c Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 13 Nov 2025 19:01:04 +0100 Subject: [PATCH 28/34] Convert Status to readonly struct and add interface --- Dalamud/Game/ClientState/Statuses/Status.cs | 86 +++++++++++++++------ 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/Dalamud/Game/ClientState/Statuses/Status.cs b/Dalamud/Game/ClientState/Statuses/Status.cs index 2775f8f9b..160b15de5 100644 --- a/Dalamud/Game/ClientState/Statuses/Status.cs +++ b/Dalamud/Game/ClientState/Statuses/Status.cs @@ -1,61 +1,49 @@ +using System.Diagnostics.CodeAnalysis; + using Dalamud.Data; using Dalamud.Game.ClientState.Objects; using Dalamud.Game.ClientState.Objects.Types; using Lumina.Excel; +using CSStatus = FFXIVClientStructs.FFXIV.Client.Game.Status; + namespace Dalamud.Game.ClientState.Statuses; /// -/// This class represents a status effect an actor is afflicted by. +/// Interface representing a status. /// -public unsafe class Status +public interface IStatus : IEquatable { - /// - /// Initializes a new instance of the class. - /// - /// Status address. - internal Status(IntPtr address) - { - this.Address = address; - } - /// /// Gets the address of the status in memory. /// - public IntPtr Address { get; } + nint Address { get; } /// /// Gets the status ID of this status. /// - public uint StatusId => this.Struct->StatusId; + uint StatusId { get; } /// /// Gets the GameData associated with this status. /// - public RowRef GameData => LuminaUtils.CreateRef(this.Struct->StatusId); + RowRef GameData { get; } /// /// Gets the parameter value of the status. /// - public ushort Param => this.Struct->Param; - - /// - /// Gets the stack count of this status. - /// Only valid if this is a non-food status. - /// - [Obsolete($"Replaced with {nameof(Param)}", true)] - public byte StackCount => (byte)this.Struct->Param; + ushort Param { get; } /// /// Gets the time remaining of this status. /// - public float RemainingTime => this.Struct->RemainingTime; + float RemainingTime { get; } /// /// Gets the source ID of this status. /// - public uint SourceId => this.Struct->SourceObject.ObjectId; + uint SourceId { get; } /// /// Gets the source actor associated with this status. @@ -63,7 +51,55 @@ public unsafe class Status /// /// This iterates the actor table, it should be used with care. /// + IGameObject? SourceObject { get; } +} + +/// +/// This struct represents a status effect an actor is afflicted by. +/// +/// A pointer to the Status. +internal unsafe readonly struct Status(CSStatus* ptr) : IStatus +{ + /// + public nint Address => (nint)ptr; + + /// + public uint StatusId => ptr->StatusId; + + /// + public RowRef GameData => LuminaUtils.CreateRef(ptr->StatusId); + + /// + public ushort Param => ptr->Param; + + /// + public float RemainingTime => ptr->RemainingTime; + + /// + public uint SourceId => ptr->SourceObject.ObjectId; + + /// public IGameObject? SourceObject => Service.Get().SearchById(this.SourceId); - private FFXIVClientStructs.FFXIV.Client.Game.Status* Struct => (FFXIVClientStructs.FFXIV.Client.Game.Status*)this.Address; + public static bool operator ==(Status x, Status y) => x.Equals(y); + + public static bool operator !=(Status x, Status y) => !(x == y); + + /// + public bool Equals(IStatus? other) + { + return this.StatusId == other.StatusId && this.SourceId == other.SourceId && this.Param == other.Param && this.RemainingTime == other.RemainingTime; + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + { + return obj is Status fate && this.Equals(fate); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(this.StatusId, this.SourceId, this.Param, this.RemainingTime); + } } From 778c82fad2c369e59e442dab8b0a1e3eb7000373 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 13 Nov 2025 19:02:50 +0100 Subject: [PATCH 29/34] Add struct enumerator to StatusList --- .../Game/ClientState/Statuses/StatusList.cs | 77 +++++++++++++------ 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/Dalamud/Game/ClientState/Statuses/StatusList.cs b/Dalamud/Game/ClientState/Statuses/StatusList.cs index a38e45ea3..50d242d33 100644 --- a/Dalamud/Game/ClientState/Statuses/StatusList.cs +++ b/Dalamud/Game/ClientState/Statuses/StatusList.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using CSStatus = FFXIVClientStructs.FFXIV.Client.Game.Status; + namespace Dalamud.Game.ClientState.Statuses; /// @@ -14,7 +16,7 @@ public sealed unsafe partial class StatusList /// Initializes a new instance of the class. /// /// Address of the status list. - internal StatusList(IntPtr address) + internal StatusList(nint address) { this.Address = address; } @@ -24,14 +26,14 @@ public sealed unsafe partial class StatusList /// /// Pointer to the status list. internal unsafe StatusList(void* pointer) - : this((IntPtr)pointer) + : this((nint)pointer) { } /// /// Gets the address of the status list in memory. /// - public IntPtr Address { get; } + public nint Address { get; } /// /// Gets the amount of status effect slots the actor has. @@ -47,7 +49,7 @@ public sealed unsafe partial class StatusList /// /// Status Index. /// The status at the specified index. - public Status? this[int index] + public IStatus? this[int index] { get { @@ -64,7 +66,7 @@ public sealed unsafe partial class StatusList /// /// The address of the status list in memory. /// The status object containing the requested data. - public static StatusList? CreateStatusListReference(IntPtr address) + public static StatusList? CreateStatusListReference(nint address) { // The use case for CreateStatusListReference and CreateStatusReference to be static is so // fake status lists can be generated. Since they aren't exposed as services, it's either @@ -74,7 +76,7 @@ public sealed unsafe partial class StatusList if (clientState.LocalContentId == 0) return null; - if (address == IntPtr.Zero) + if (address == 0) return null; return new StatusList(address); @@ -85,17 +87,17 @@ public sealed unsafe partial class StatusList /// /// The address of the status effect in memory. /// The status object containing the requested data. - public static Status? CreateStatusReference(IntPtr address) + public static IStatus? CreateStatusReference(nint address) { var clientState = Service.Get(); if (clientState.LocalContentId == 0) return null; - if (address == IntPtr.Zero) + if (address == 0) return null; - return new Status(address); + return new Status((CSStatus*)address); } /// @@ -103,22 +105,22 @@ public sealed unsafe partial class StatusList /// /// The index of the status. /// The memory address of the status. - public IntPtr GetStatusAddress(int index) + public nint GetStatusAddress(int index) { if (index < 0 || index >= this.Length) - return IntPtr.Zero; + return 0; - return (IntPtr)Unsafe.AsPointer(ref this.Struct->Status[index]); + return (nint)Unsafe.AsPointer(ref this.Struct->Status[index]); } } /// /// This collection represents the status effects an actor is afflicted by. /// -public sealed partial class StatusList : IReadOnlyCollection, ICollection +public sealed partial class StatusList : IReadOnlyCollection, ICollection { /// - int IReadOnlyCollection.Count => this.Length; + int IReadOnlyCollection.Count => this.Length; /// int ICollection.Count => this.Length; @@ -130,17 +132,9 @@ public sealed partial class StatusList : IReadOnlyCollection, ICollectio object ICollection.SyncRoot => this; /// - public IEnumerator GetEnumerator() + public IEnumerator GetEnumerator() { - for (var i = 0; i < this.Length; i++) - { - var status = this[i]; - - if (status == null || status.StatusId == 0) - continue; - - yield return status; - } + return new Enumerator(this); } /// @@ -155,4 +149,39 @@ public sealed partial class StatusList : IReadOnlyCollection, ICollectio index++; } } + + private struct Enumerator(StatusList statusList) : IEnumerator + { + private int index = 0; + + public IStatus Current { get; private set; } + + object IEnumerator.Current => this.Current; + + public bool MoveNext() + { + if (this.index == statusList.Length) return false; + + for (; this.index < statusList.Length; this.index++) + { + var status = statusList[this.index]; + if (status != null && status.StatusId != 0) + { + this.Current = status; + return true; + } + } + + return false; + } + + public void Reset() + { + this.index = 0; + } + + public void Dispose() + { + } + } } From 9d0879148c740942523da5310c5b8039cb3be707 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Thu, 13 Nov 2025 19:05:25 +0100 Subject: [PATCH 30/34] Remove unused StatusEffect struct --- .../Game/ClientState/Structs/StatusEffect.cs | 35 ------------------- 1 file changed, 35 deletions(-) delete mode 100644 Dalamud/Game/ClientState/Structs/StatusEffect.cs diff --git a/Dalamud/Game/ClientState/Structs/StatusEffect.cs b/Dalamud/Game/ClientState/Structs/StatusEffect.cs deleted file mode 100644 index 2a60a7d3b..000000000 --- a/Dalamud/Game/ClientState/Structs/StatusEffect.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Dalamud.Game.ClientState.Structs; - -/// -/// Native memory representation of a FFXIV status effect. -/// -[StructLayout(LayoutKind.Sequential)] -public struct StatusEffect -{ - /// - /// The effect ID. - /// - public short EffectId; - - /// - /// How many stacks are present. - /// - public byte StackCount; - - /// - /// Additional parameters. - /// - public byte Param; - - /// - /// The duration remaining. - /// - public float Duration; - - /// - /// The ID of the actor that caused this effect. - /// - public int OwnerId; -} From 72dc094b5742748da848caf9a4b1fa05854e79bf Mon Sep 17 00:00:00 2001 From: goaaats Date: Sat, 15 Nov 2025 19:41:46 +0100 Subject: [PATCH 31/34] build: 13.0.0.9 --- Dalamud/Dalamud.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dalamud/Dalamud.csproj b/Dalamud/Dalamud.csproj index 1e5f9f586..e45ba8a1e 100644 --- a/Dalamud/Dalamud.csproj +++ b/Dalamud/Dalamud.csproj @@ -6,7 +6,7 @@ XIV Launcher addon framework - 13.0.0.8 + 13.0.0.9 $(DalamudVersion) $(DalamudVersion) $(DalamudVersion) From 78ed4a2b01f2fb4f4ff86ea5eab2c7eca7e6b015 Mon Sep 17 00:00:00 2001 From: Kaz Wolfe Date: Sun, 16 Nov 2025 15:55:35 -0800 Subject: [PATCH 32/34] feat: Dalamud RPC service A draft for a simple RPC service for Dalamud. Enables use of Dalamud URIs, to be added later. --- Dalamud.Test/Pipes/DalamudUriTests.cs | 107 +++++++++++ Dalamud/Dalamud.csproj | 1 + .../Networking/Pipes/Api/PluginLinkHandler.cs | 53 ++++++ Dalamud/Networking/Pipes/DalamudUri.cs | 102 +++++++++++ .../Pipes/Internal/ClientHelloService.cs | 94 ++++++++++ .../Pipes/Internal/LinkHandlerService.cs | 129 ++++++++++++++ Dalamud/Networking/Pipes/Rpc/PipeRpcHost.cs | 167 ++++++++++++++++++ Dalamud/Networking/Pipes/Rpc/RpcConnection.cs | 92 ++++++++++ .../Networking/Pipes/Rpc/RpcHostService.cs | 49 +++++ .../Pipes/Rpc/RpcServiceRegistry.cs | 85 +++++++++ Dalamud/Plugin/Services/IPluginLinkHandler.cs | 20 +++ Directory.Packages.props | 13 +- 12 files changed, 911 insertions(+), 1 deletion(-) create mode 100644 Dalamud.Test/Pipes/DalamudUriTests.cs create mode 100644 Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs create mode 100644 Dalamud/Networking/Pipes/DalamudUri.cs create mode 100644 Dalamud/Networking/Pipes/Internal/ClientHelloService.cs create mode 100644 Dalamud/Networking/Pipes/Internal/LinkHandlerService.cs create mode 100644 Dalamud/Networking/Pipes/Rpc/PipeRpcHost.cs create mode 100644 Dalamud/Networking/Pipes/Rpc/RpcConnection.cs create mode 100644 Dalamud/Networking/Pipes/Rpc/RpcHostService.cs create mode 100644 Dalamud/Networking/Pipes/Rpc/RpcServiceRegistry.cs create mode 100644 Dalamud/Plugin/Services/IPluginLinkHandler.cs diff --git a/Dalamud.Test/Pipes/DalamudUriTests.cs b/Dalamud.Test/Pipes/DalamudUriTests.cs new file mode 100644 index 000000000..4977f3814 --- /dev/null +++ b/Dalamud.Test/Pipes/DalamudUriTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Linq; + +using Dalamud.Networking.Pipes; +using Xunit; + +namespace Dalamud.Test.Pipes +{ + public class DalamudUriTests + { + [Theory] + [InlineData("https://www.google.com/", false)] + [InlineData("dalamud://PluginInstaller/Dalamud.FindAnything", true)] + public void ValidatesScheme(string uri, bool valid) + { + Action act = () => { _ = DalamudUri.FromUri(uri); }; + + var ex = Record.Exception(act); + if (valid) + { + Assert.Null(ex); + } + else + { + Assert.NotNull(ex); + Assert.IsType(ex); + } + } + + [Theory] + [InlineData("dalamud://PluginInstaller/Dalamud.FindAnything", "plugininstaller")] + [InlineData("dalamud://Plugin/Dalamud.FindAnything/OpenWindow", "plugin")] + [InlineData("dalamud://Test", "test")] + public void ExtractsNamespace(string uri, string expectedNamespace) + { + var dalamudUri = DalamudUri.FromUri(uri); + Assert.Equal(expectedNamespace, dalamudUri.Namespace); + } + + [Theory] + [InlineData("dalamud://foo/bar/baz/qux/?cow=moo", "/bar/baz/qux/")] + [InlineData("dalamud://foo/bar/baz/qux?cow=moo", "/bar/baz/qux")] + [InlineData("dalamud://foo/bar/baz", "/bar/baz")] + [InlineData("dalamud://foo/bar", "/bar")] + [InlineData("dalamud://foo/bar/", "/bar/")] + [InlineData("dalamud://foo/", "/")] + public void ExtractsPath(string uri, string expectedPath) + { + var dalamudUri = DalamudUri.FromUri(uri); + Assert.Equal(expectedPath, dalamudUri.Path); + } + + [Theory] + [InlineData("dalamud://foo/bar/baz/qux/?cow=moo#frag", "/bar/baz/qux/?cow=moo#frag")] + [InlineData("dalamud://foo/bar/baz/qux/?cow=moo", "/bar/baz/qux/?cow=moo")] + [InlineData("dalamud://foo/bar/baz/qux?cow=moo", "/bar/baz/qux?cow=moo")] + [InlineData("dalamud://foo/bar/baz", "/bar/baz")] + [InlineData("dalamud://foo/bar?cow=moo", "/bar?cow=moo")] + [InlineData("dalamud://foo/bar", "/bar")] + [InlineData("dalamud://foo/bar/?cow=moo", "/bar/?cow=moo")] + [InlineData("dalamud://foo/bar/", "/bar/")] + [InlineData("dalamud://foo/?cow=moo#chicken", "/?cow=moo#chicken")] + [InlineData("dalamud://foo/?cow=moo", "/?cow=moo")] + [InlineData("dalamud://foo/", "/")] + public void ExtractsData(string uri, string expectedData) + { + var dalamudUri = DalamudUri.FromUri(uri); + + Assert.Equal(expectedData, dalamudUri.Data); + } + + [Theory] + [InlineData("dalamud://foo/bar", 0)] + [InlineData("dalamud://foo/bar?cow=moo", 1)] + [InlineData("dalamud://foo/bar?cow=moo&wolf=awoo", 2)] + [InlineData("dalamud://foo/bar?cow=moo&wolf=awoo&cat", 3)] + public void ExtractsQueryParams(string uri, int queryCount) + { + var dalamudUri = DalamudUri.FromUri(uri); + Assert.Equal(queryCount, dalamudUri.QueryParams.Count); + } + + [Theory] + [InlineData("dalamud://foo/bar/baz/qux/meh/?foo=bar", 5, true)] + [InlineData("dalamud://foo/bar/baz/qux/meh/", 5, true)] + [InlineData("dalamud://foo/bar/baz/qux/meh", 5)] + [InlineData("dalamud://foo/bar/baz/qux", 4)] + [InlineData("dalamud://foo/bar/baz", 3)] + [InlineData("dalamud://foo/bar/", 2)] + [InlineData("dalamud://foo/bar", 2)] + public void ExtractsSegments(string uri, int segmentCount, bool finalSegmentEndsWithSlash = false) + { + var dalamudUri = DalamudUri.FromUri(uri); + var segments = dalamudUri.Segments; + + // First segment must always be `/` + Assert.Equal("/", segments[0]); + + Assert.Equal(segmentCount, segments.Length); + + if (finalSegmentEndsWithSlash) + { + Assert.EndsWith("/", segments.Last()); + } + } + } +} diff --git a/Dalamud/Dalamud.csproj b/Dalamud/Dalamud.csproj index 1e5f9f586..849a5ce7f 100644 --- a/Dalamud/Dalamud.csproj +++ b/Dalamud/Dalamud.csproj @@ -81,6 +81,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs b/Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs new file mode 100644 index 000000000..2c99901b4 --- /dev/null +++ b/Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs @@ -0,0 +1,53 @@ +using System.Linq; + +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Networking.Pipes.Internal; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Plugin.Services; + +namespace Dalamud.Networking.Pipes.Api; + +/// +[PluginInterface] +[ServiceManager.ScopedService] +[ResolveVia] +public class PluginLinkHandler : IInternalDisposableService, IPluginLinkHandler +{ + private readonly LinkHandlerService linkHandler; + private readonly LocalPlugin localPlugin; + + /// + /// Initializes a new instance of the class. + /// + /// The plugin to bind this service to. + /// The central link handler. + internal PluginLinkHandler(LocalPlugin localPlugin, LinkHandlerService linkHandler) + { + this.linkHandler = linkHandler; + this.localPlugin = localPlugin; + + this.linkHandler.Register("plugin", this.HandleUri); + } + + /// + public event IPluginLinkHandler.PluginUriReceived? OnUriReceived; + + /// + public void DisposeService() + { + this.OnUriReceived = null; + this.linkHandler.Unregister("plugin", this.HandleUri); + } + + private void HandleUri(DalamudUri uri) + { + var target = uri.Path.Split("/").FirstOrDefault(); + if (target == null || !string.Equals(target, this.localPlugin.InternalName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + this.OnUriReceived?.Invoke(uri); + } +} diff --git a/Dalamud/Networking/Pipes/DalamudUri.cs b/Dalamud/Networking/Pipes/DalamudUri.cs new file mode 100644 index 000000000..03ad15af1 --- /dev/null +++ b/Dalamud/Networking/Pipes/DalamudUri.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Web; + +namespace Dalamud.Networking.Pipes; + +/// +/// A Dalamud Uri, in the format: +/// dalamud://{NAMESPACE}/{ARBITRARY} +/// +public record DalamudUri +{ + private readonly Uri rawUri; + + private DalamudUri(Uri uri) + { + if (uri.Scheme != "dalamud") + { + throw new ArgumentOutOfRangeException(nameof(uri), "URI must be of scheme dalamud."); + } + + this.rawUri = uri; + } + + /// + /// Gets the namespace that this URI should be routed to. Generally a high level component like "PluginInstaller". + /// + public string Namespace => this.rawUri.Authority; + + /// + /// Gets the raw (untargeted) path and query params for this URI. + /// + public string Data => + this.rawUri.GetComponents(UriComponents.PathAndQuery | UriComponents.Fragment, UriFormat.UriEscaped); + + /// + /// Gets the raw (untargeted) path for this URI. + /// + public string Path => this.rawUri.AbsolutePath; + + /// + /// Gets a list of segments based on the provided Data element. + /// + public string[] Segments => this.GetDataSegments(); + + /// + /// Gets the raw query parameters for this URI, if any. + /// + public string Query => this.rawUri.Query; + + /// + /// Gets the query params (as a parsed NameValueCollection) in this URI. + /// + public NameValueCollection QueryParams => HttpUtility.ParseQueryString(this.Query); + + /// + /// Gets the fragment (if one is specified) in this URI. + /// + public string Fragment => this.rawUri.Fragment; + + /// + public override string ToString() => this.rawUri.ToString(); + + private string[] GetDataSegments() + { + // reimplementation of the System.URI#Segments, under MIT license. + var path = this.Path; + + var segments = new List(); + var current = 0; + while (current < path.Length) + { + var next = path.IndexOf('/', current); + if (next == -1) + { + next = path.Length - 1; + } + + segments.Add(path.Substring(current, (next - current) + 1)); + current = next + 1; + } + + return segments.ToArray(); + } + + /// + /// Build a DalamudURI from a given URI. + /// + /// The URI to convert to a Dalamud URI. + /// Returns a DalamudUri. + public static DalamudUri FromUri(Uri uri) + { + return new DalamudUri(uri); + } + + /// + /// Build a DalamudURI from a URI in string format. + /// + /// The URI to convert to a Dalamud URI. + /// Returns a DalamudUri. + public static DalamudUri FromUri(string uri) => FromUri(new Uri(uri)); +} diff --git a/Dalamud/Networking/Pipes/Internal/ClientHelloService.cs b/Dalamud/Networking/Pipes/Internal/ClientHelloService.cs new file mode 100644 index 000000000..cc06560bd --- /dev/null +++ b/Dalamud/Networking/Pipes/Internal/ClientHelloService.cs @@ -0,0 +1,94 @@ +using System.Threading.Tasks; + +using Dalamud.Game; +using Dalamud.Game.ClientState; +using Dalamud.Networking.Pipes.Rpc; +using Dalamud.Utility; + +namespace Dalamud.Networking.Pipes.Internal; + +/// +/// A minimal service to respond with information about this client. +/// +[ServiceManager.EarlyLoadedService] +internal sealed class ClientHelloService : IInternalDisposableService +{ + /// + /// Initializes a new instance of the class. + /// + /// Injected host service. + [ServiceManager.ServiceConstructor] + public ClientHelloService(RpcHostService rpcHostService) + { + rpcHostService.AddMethod("hello", this.HandleHello); + } + + /// + /// Handle a hello request. + /// + /// . + /// Respond with information. + public async Task HandleHello(ClientHelloRequest request) + { + var framework = await Service.GetAsync(); + var dalamud = await Service.GetAsync(); + var clientState = await Service.GetAsync(); + + var response = await framework.RunOnFrameworkThread(() => new ClientHelloResponse + { + ApiVersion = "1.0", + DalamudVersion = Util.GetScmVersion(), + GameVersion = dalamud.StartInfo.GameVersion?.ToString() ?? "Unknown", + PlayerName = clientState.IsLoggedIn ? clientState.LocalPlayer?.Name.ToString() ?? "Unknown" : null, + }); + + return response; + } + + /// + public void DisposeService() + { + } +} + +/// +/// A request from a client to say hello. +/// +internal record ClientHelloRequest +{ + /// + /// Gets the API version this client is expecting. + /// + public string ApiVersion { get; init; } = string.Empty; + + /// + /// Gets the user agent of the client. + /// + public string UserAgent { get; init; } = string.Empty; +} + +/// +/// A response from Dalamud to a hello request. +/// +internal record ClientHelloResponse +{ + /// + /// Gets the API version this server has offered. + /// + public string? ApiVersion { get; init; } + + /// + /// Gets the current Dalamud version. + /// + public string? DalamudVersion { get; init; } + + /// + /// Gets the current game version. + /// + public string? GameVersion { get; init; } + + /// + /// Gets or sets the player name, or null if the player isn't logged in. + /// + public string? PlayerName { get; set; } +} diff --git a/Dalamud/Networking/Pipes/Internal/LinkHandlerService.cs b/Dalamud/Networking/Pipes/Internal/LinkHandlerService.cs new file mode 100644 index 000000000..79bb1e017 --- /dev/null +++ b/Dalamud/Networking/Pipes/Internal/LinkHandlerService.cs @@ -0,0 +1,129 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; + +using Dalamud.Logging.Internal; +using Dalamud.Networking.Pipes.Rpc; + +namespace Dalamud.Networking.Pipes.Internal; + +/// +/// A service responsible for handling Dalamud URIs and dispatching them accordingly. +/// +[ServiceManager.EarlyLoadedService] +internal class LinkHandlerService : IInternalDisposableService +{ + private readonly ModuleLog log = new("LinkHandler"); + + // key: namespace (e.g. "plugin" or "PluginInstaller") -> list of handlers + private readonly ConcurrentDictionary>> handlers + = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Initializes a new instance of the class. + /// + /// The injected RPC host service. + [ServiceManager.ServiceConstructor] + public LinkHandlerService(RpcHostService rpcHostService) + { + rpcHostService.AddMethod("handleLink", this.HandleLinkCall); + } + + /// + public void DisposeService() + { + } + + /// + /// Register a handler for a namespace. All URIs with this namespace will be dispatched to the handler. + /// + /// The namespace to use for this subscription. + /// The command handler. + public void Register(string ns, Action handler) + { + if (string.IsNullOrWhiteSpace(ns)) + throw new ArgumentNullException(nameof(ns)); + + var list = this.handlers.GetOrAdd(ns, _ => []); + lock (list) + { + list.Add(handler); + } + + this.log.Verbose("Registered handler for {Namespace}", ns); + } + + /// + /// Unregister a handler. + /// + /// The namespace to use for this subscription. + /// The command handler. + public void Unregister(string ns, Action handler) + { + if (string.IsNullOrWhiteSpace(ns)) + return; + + if (!this.handlers.TryGetValue(ns, out var list)) + return; + + lock (list) + { + list.RemoveAll(x => x == handler); + } + + if (list.Count == 0) + this.handlers.TryRemove(ns, out _); + + this.log.Verbose("Unregistered handler for {Namespace}", ns); + } + + /// + /// Dispatch a URI to matching handlers. + /// + /// The URI to parse and dispatch. + public void Dispatch(DalamudUri uri) + { + this.log.Information("Received URI: {Uri}", uri.ToString()); + + var ns = uri.Namespace; + if (!this.handlers.TryGetValue(ns, out var list)) + return; + + Action[] snapshot; + lock (list) + { + snapshot = list.ToArray(); + } + + foreach (var h in snapshot) + { + try + { + h(uri); + } + catch (Exception e) + { + this.log.Warning(e, "Link handler threw for {UriPath}", uri.Path); + } + } + } + + /// + /// The RPC-invokable link handler. + /// + /// A plain-text URI to parse. + public void HandleLinkCall(string uri) + { + if (string.IsNullOrWhiteSpace(uri)) + return; + + try + { + var du = DalamudUri.FromUri(uri); + this.Dispatch(du); + } + catch (Exception) + { + // swallow parse errors; clients shouldn't crash the host + } + } +} diff --git a/Dalamud/Networking/Pipes/Rpc/PipeRpcHost.cs b/Dalamud/Networking/Pipes/Rpc/PipeRpcHost.cs new file mode 100644 index 000000000..07dc9d96a --- /dev/null +++ b/Dalamud/Networking/Pipes/Rpc/PipeRpcHost.cs @@ -0,0 +1,167 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO.Pipes; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Threading; +using System.Threading.Tasks; + +using Dalamud.Logging.Internal; +using Dalamud.Utility; + +namespace Dalamud.Networking.Pipes.Rpc; + +/// +/// Simple multi-client JSON-RPC named pipe host using StreamJsonRpc. +/// +internal class PipeRpcHost : IDisposable +{ + private readonly ModuleLog log = new("RPC/Host"); + + private readonly RpcServiceRegistry registry = new(); + private readonly CancellationTokenSource cts = new(); + private readonly ConcurrentDictionary sessions = new(); + private Task? acceptLoopTask; + + /// + /// Initializes a new instance of the class. + /// + /// The pipe name to create. + public PipeRpcHost(string? pipeName = null) + { + // Default pipe name based on current process ID for uniqueness per Dalamud instance. + this.PipeName = pipeName ?? $"DalamudRPC.{Environment.ProcessId}"; + } + + /// + /// Gets the name of the named pipe this RPC host is using. + /// + public string PipeName { get; } + + /// Adds a local object exposing RPC methods callable by clients. + /// An arbitrary service object that will be introspected to add to RPC. + public void AddService(object service) => this.registry.AddService(service); + + /// + /// Adds a standalone JSON-RPC method callable by clients. + /// + /// The name to add. + /// The delegate that acts as the handler. + public void AddMethod(string name, Delegate handler) => this.registry.AddMethod(name, handler); + + /// Starts accepting client connections. + public void Start() + { + if (this.acceptLoopTask != null) return; + this.acceptLoopTask = Task.Run(this.AcceptLoopAsync); + } + + /// Invoke an RPC request on a specific client expecting a result. + /// The client ID to invoke. + /// The method to invoke. + /// Any arguments to invoke. + /// An optional return based on the specified RPC. + /// The expected response type. + public Task InvokeClientAsync(Guid clientId, string method, params object[] arguments) + { + if (!this.sessions.TryGetValue(clientId, out var session)) + throw new KeyNotFoundException($"No client {clientId}"); + + return session.Rpc.InvokeAsync(method, arguments); + } + + /// Send a notification to all connected clients (no response expected). + /// The method name to broadcast. + /// The arguments to broadcast. + /// Returns a Task when completed. + public Task BroadcastNotifyAsync(string method, params object[] arguments) + { + var list = this.sessions.Values; + var tasks = new List(list.Count); + foreach (var s in list) + { + tasks.Add(s.Rpc.NotifyAsync(method, arguments)); + } + + return Task.WhenAll(tasks); + } + + /// + /// Gets a list of connected client IDs. + /// + /// Connected client IDs. + public IReadOnlyCollection GetClientIds() => this.sessions.Keys.AsReadOnlyCollection(); + + /// + public void Dispose() + { + this.cts.Cancel(); + this.acceptLoopTask?.Wait(1000); + + foreach (var kv in this.sessions) + { + kv.Value.Dispose(); + } + + this.sessions.Clear(); + this.cts.Dispose(); + this.log.Information("PipeRpcHost disposed ({Pipe})", this.PipeName); + GC.SuppressFinalize(this); + } + + private PipeSecurity BuildPipeSecurity() + { + var ps = new PipeSecurity(); + ps.AddAccessRule(new PipeAccessRule(WindowsIdentity.GetCurrent().User!, PipeAccessRights.FullControl, AccessControlType.Allow)); + + return ps; + } + + private async Task AcceptLoopAsync() + { + this.log.Information("PipeRpcHost starting on pipe {Pipe}", this.PipeName); + var token = this.cts.Token; + var security = this.BuildPipeSecurity(); + + while (!token.IsCancellationRequested) + { + NamedPipeServerStream? server = null; + try + { + server = NamedPipeServerStreamAcl.Create( + this.PipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Message, + PipeOptions.Asynchronous, + 65536, + 65536, + security); + + await server.WaitForConnectionAsync(token).ConfigureAwait(false); + + var session = new RpcConnection(server, this.registry); + this.sessions.TryAdd(session.Id, session); + + this.log.Debug("RPC connection created: {Id}", session.Id); + + _ = session.Completion.ContinueWith(t => + { + this.sessions.TryRemove(session.Id, out _); + this.log.Debug("RPC connection removed: {Id}", session.Id); + }, TaskScheduler.Default); + } + catch (OperationCanceledException) + { + server?.Dispose(); + break; + } + catch (Exception ex) + { + server?.Dispose(); + this.log.Error(ex, "Error in pipe accept loop"); + await Task.Delay(500, token).ConfigureAwait(false); + } + } + } +} diff --git a/Dalamud/Networking/Pipes/Rpc/RpcConnection.cs b/Dalamud/Networking/Pipes/Rpc/RpcConnection.cs new file mode 100644 index 000000000..8e1c3a085 --- /dev/null +++ b/Dalamud/Networking/Pipes/Rpc/RpcConnection.cs @@ -0,0 +1,92 @@ +using System.IO.Pipes; +using System.Threading; +using System.Threading.Tasks; + +using Serilog; +using StreamJsonRpc; + +namespace Dalamud.Networking.Pipes.Rpc; + +/// +/// A single RPC client session connected via named pipe. +/// +internal class RpcConnection : IDisposable +{ + private readonly NamedPipeServerStream pipe; + private readonly RpcServiceRegistry registry; + private readonly CancellationTokenSource cts = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The named pipe that this connection will handle. + /// A registry of RPC services. + public RpcConnection(NamedPipeServerStream pipe, RpcServiceRegistry registry) + { + this.Id = Guid.CreateVersion7(); + this.pipe = pipe; + this.registry = registry; + + var formatter = new JsonMessageFormatter(); + var handler = new HeaderDelimitedMessageHandler(pipe, pipe, formatter); + + this.Rpc = new JsonRpc(handler); + this.Rpc.AllowModificationWhileListening = true; + this.Rpc.Disconnected += this.OnDisconnected; + this.registry.Attach(this.Rpc); + + this.Rpc.StartListening(); + } + + /// + /// Gets the GUID for this connection. + /// + public Guid Id { get; } + + /// + /// Gets the JsonRpc instance for this connection. + /// + public JsonRpc Rpc { get; } + + /// + /// Gets a task that's called on RPC completion. + /// + public Task Completion => this.Rpc.Completion; + + /// + public void Dispose() + { + if (!this.cts.IsCancellationRequested) + { + this.cts.Cancel(); + } + + try + { + this.Rpc.Dispose(); + } + catch (Exception ex) + { + Log.Debug(ex, "Error disposing JsonRpc for client {Id}", this.Id); + } + + try + { + this.pipe.Dispose(); + } + catch (Exception ex) + { + Log.Debug(ex, "Error disposing pipe for client {Id}", this.Id); + } + + this.cts.Dispose(); + GC.SuppressFinalize(this); + } + + private void OnDisconnected(object? sender, JsonRpcDisconnectedEventArgs e) + { + Log.Debug("RPC client {Id} disconnected: {Reason}", this.Id, e.Description); + this.registry.Detach(this.Rpc); + this.Dispose(); + } +} diff --git a/Dalamud/Networking/Pipes/Rpc/RpcHostService.cs b/Dalamud/Networking/Pipes/Rpc/RpcHostService.cs new file mode 100644 index 000000000..78df27323 --- /dev/null +++ b/Dalamud/Networking/Pipes/Rpc/RpcHostService.cs @@ -0,0 +1,49 @@ +using Dalamud.Logging.Internal; + +namespace Dalamud.Networking.Pipes.Rpc; + +/// +/// The Dalamud service repsonsible for hosting the RPC. +/// +[ServiceManager.EarlyLoadedService] +internal class RpcHostService : IServiceType, IInternalDisposableService +{ + private readonly ModuleLog log = new("RPC"); + private readonly PipeRpcHost host; + + /// + /// Initializes a new instance of the class. + /// + [ServiceManager.ServiceConstructor] + public RpcHostService() + { + this.host = new PipeRpcHost(); + this.host.Start(); + + this.log.Information("RpcHostService started on pipe {Pipe}", this.host.PipeName); + } + + /// + /// Gets the RPC host to drill down. + /// + public PipeRpcHost Host => this.host; + + /// + /// Add a new service Object to the RPC host. + /// + /// The object to add. + public void AddService(object service) => this.host.AddService(service); + + /// + /// Add a new standalone method to the RPC host. + /// + /// The method name to add. + /// The handler to add. + public void AddMethod(string name, Delegate handler) => this.host.AddMethod(name, handler); + + /// + public void DisposeService() + { + this.host.Dispose(); + } +} diff --git a/Dalamud/Networking/Pipes/Rpc/RpcServiceRegistry.cs b/Dalamud/Networking/Pipes/Rpc/RpcServiceRegistry.cs new file mode 100644 index 000000000..71037d45e --- /dev/null +++ b/Dalamud/Networking/Pipes/Rpc/RpcServiceRegistry.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Threading; + +using StreamJsonRpc; + +namespace Dalamud.Networking.Pipes.Rpc; + +/// +/// Thread-safe registry of local RPC target objects that are exposed to every connected JsonRpc session. +/// New sessions get all previously registered targets; newly added targets are attached to all active sessions. +/// +internal class RpcServiceRegistry +{ + private readonly Lock sync = new(); + private readonly List targets = []; + private readonly List<(string Name, Delegate Handler)> methods = []; + private readonly List activeRpcs = []; + + /// + /// Registers a new local RPC target object. Its public JSON-RPC methods become callable by clients. + /// Adds to the registry and attaches it to all active RPC sessions. + /// + /// The service instance containing JSON-RPC callable methods to expose. + public void AddService(object service) + { + lock (this.sync) + { + this.targets.Add(service); + foreach (var rpc in this.activeRpcs) + { + rpc.AddLocalRpcTarget(service); + } + } + } + + /// + /// Registers a new standalone JSON-RPC method. + /// + /// The name of the method to add. + /// The handler to add. + public void AddMethod(string name, Delegate handler) + { + lock (this.sync) + { + this.methods.Add((name, handler)); + foreach (var rpc in this.activeRpcs) + { + rpc.AddLocalRpcMethod(name, handler); + } + } + } + + /// + /// Attaches a JsonRpc instance to the registry so it receives all existing service targets. + /// + /// The JsonRpc instance to attach and populate with current targets. + internal void Attach(JsonRpc rpc) + { + lock (this.sync) + { + this.activeRpcs.Add(rpc); + foreach (var t in this.targets) + { + rpc.AddLocalRpcTarget(t); + } + + foreach (var m in this.methods) + { + rpc.AddLocalRpcMethod(m.Name, m.Handler); + } + } + } + + /// + /// Detaches a JsonRpc instance from the registry (e.g. when a client disconnects). + /// + /// The JsonRpc instance being detached. + internal void Detach(JsonRpc rpc) + { + lock (this.sync) + { + this.activeRpcs.Remove(rpc); + } + } +} diff --git a/Dalamud/Plugin/Services/IPluginLinkHandler.cs b/Dalamud/Plugin/Services/IPluginLinkHandler.cs new file mode 100644 index 000000000..57f772768 --- /dev/null +++ b/Dalamud/Plugin/Services/IPluginLinkHandler.cs @@ -0,0 +1,20 @@ +using Dalamud.Networking.Pipes; + +namespace Dalamud.Plugin.Services; + +/// +/// A service to allow plugins to subscribe to dalamud:// URIs targeting them. Plugins will receive any URI sent to the +/// dalamud://plugin/{PLUGIN_INTERNAL_NAME}/... namespace. +/// +public interface IPluginLinkHandler +{ + /// + /// A delegate containing the received URI. + /// + delegate void PluginUriReceived(DalamudUri uri); + + /// + /// The event fired when a URI targeting this plugin is received. + /// + event PluginUriReceived OnUriReceived; +} diff --git a/Directory.Packages.props b/Directory.Packages.props index 91875e63e..903a8ee88 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,11 +3,13 @@ true false + + @@ -22,26 +24,35 @@ + + + + + + + + + @@ -54,4 +65,4 @@ - \ No newline at end of file + From 4937a2f4bd2e551669e7d158b44d0f6e681ffc1d Mon Sep 17 00:00:00 2001 From: Kaz Wolfe Date: Sun, 16 Nov 2025 18:14:02 -0800 Subject: [PATCH 33/34] CR changes --- .../Networking/Pipes/Api/PluginLinkHandler.cs | 4 ++- .../Pipes/Internal/LinkHandlerService.cs | 36 ++++--------------- Dalamud/Networking/Pipes/Rpc/PipeRpcHost.cs | 2 +- Dalamud/Plugin/Services/IPluginLinkHandler.cs | 5 ++- 4 files changed, 15 insertions(+), 32 deletions(-) diff --git a/Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs b/Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs index 2c99901b4..d8f43907c 100644 --- a/Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs +++ b/Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs @@ -1,5 +1,6 @@ using System.Linq; +using Dalamud.Console; using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Networking.Pipes.Internal; @@ -43,7 +44,8 @@ public class PluginLinkHandler : IInternalDisposableService, IPluginLinkHandler private void HandleUri(DalamudUri uri) { var target = uri.Path.Split("/").FirstOrDefault(); - if (target == null || !string.Equals(target, this.localPlugin.InternalName, StringComparison.OrdinalIgnoreCase)) + var thisPlugin = ConsoleManagerPluginUtil.GetSanitizedNamespaceName(this.localPlugin.InternalName); + if (target == null || !string.Equals(target, thisPlugin, StringComparison.OrdinalIgnoreCase)) { return; } diff --git a/Dalamud/Networking/Pipes/Internal/LinkHandlerService.cs b/Dalamud/Networking/Pipes/Internal/LinkHandlerService.cs index 79bb1e017..3cc4af9f4 100644 --- a/Dalamud/Networking/Pipes/Internal/LinkHandlerService.cs +++ b/Dalamud/Networking/Pipes/Internal/LinkHandlerService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Dalamud.Logging.Internal; using Dalamud.Networking.Pipes.Rpc; +using Dalamud.Utility; namespace Dalamud.Networking.Pipes.Internal; @@ -65,10 +66,7 @@ internal class LinkHandlerService : IInternalDisposableService if (!this.handlers.TryGetValue(ns, out var list)) return; - lock (list) - { - list.RemoveAll(x => x == handler); - } + list.RemoveAll(x => x == handler); if (list.Count == 0) this.handlers.TryRemove(ns, out _); @@ -85,25 +83,12 @@ internal class LinkHandlerService : IInternalDisposableService this.log.Information("Received URI: {Uri}", uri.ToString()); var ns = uri.Namespace; - if (!this.handlers.TryGetValue(ns, out var list)) + if (!this.handlers.TryGetValue(ns, out var actions)) return; - Action[] snapshot; - lock (list) + foreach (var h in actions) { - snapshot = list.ToArray(); - } - - foreach (var h in snapshot) - { - try - { - h(uri); - } - catch (Exception e) - { - this.log.Warning(e, "Link handler threw for {UriPath}", uri.Path); - } + h.InvokeSafely(uri); } } @@ -116,14 +101,7 @@ internal class LinkHandlerService : IInternalDisposableService if (string.IsNullOrWhiteSpace(uri)) return; - try - { - var du = DalamudUri.FromUri(uri); - this.Dispatch(du); - } - catch (Exception) - { - // swallow parse errors; clients shouldn't crash the host - } + var du = DalamudUri.FromUri(uri); + this.Dispatch(du); } } diff --git a/Dalamud/Networking/Pipes/Rpc/PipeRpcHost.cs b/Dalamud/Networking/Pipes/Rpc/PipeRpcHost.cs index 07dc9d96a..ad1cc72cd 100644 --- a/Dalamud/Networking/Pipes/Rpc/PipeRpcHost.cs +++ b/Dalamud/Networking/Pipes/Rpc/PipeRpcHost.cs @@ -53,7 +53,7 @@ internal class PipeRpcHost : IDisposable public void Start() { if (this.acceptLoopTask != null) return; - this.acceptLoopTask = Task.Run(this.AcceptLoopAsync); + this.acceptLoopTask = Task.Factory.StartNew(this.AcceptLoopAsync, TaskCreationOptions.LongRunning); } /// Invoke an RPC request on a specific client expecting a result. diff --git a/Dalamud/Plugin/Services/IPluginLinkHandler.cs b/Dalamud/Plugin/Services/IPluginLinkHandler.cs index 57f772768..22139814d 100644 --- a/Dalamud/Plugin/Services/IPluginLinkHandler.cs +++ b/Dalamud/Plugin/Services/IPluginLinkHandler.cs @@ -1,4 +1,6 @@ -using Dalamud.Networking.Pipes; +using System.Diagnostics.CodeAnalysis; + +using Dalamud.Networking.Pipes; namespace Dalamud.Plugin.Services; @@ -6,6 +8,7 @@ namespace Dalamud.Plugin.Services; /// A service to allow plugins to subscribe to dalamud:// URIs targeting them. Plugins will receive any URI sent to the /// dalamud://plugin/{PLUGIN_INTERNAL_NAME}/... namespace. /// +[Experimental("DAL_RPC", Message = "This service will be finalized around 7.41 and may change before then.")] public interface IPluginLinkHandler { /// From 19a3926051ce6aa30ac907a5fb7201536b971452 Mon Sep 17 00:00:00 2001 From: Kaz Wolfe Date: Sun, 16 Nov 2025 21:35:33 -0800 Subject: [PATCH 34/34] Better hello message --- .../Networking/Pipes/Api/PluginLinkHandler.cs | 1 + .../Pipes/Internal/ClientHelloService.cs | 45 +++++++++++++++---- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs b/Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs index d8f43907c..78fbb0d82 100644 --- a/Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs +++ b/Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs @@ -6,6 +6,7 @@ using Dalamud.IoC.Internal; using Dalamud.Networking.Pipes.Internal; using Dalamud.Plugin.Internal.Types; using Dalamud.Plugin.Services; +#pragma warning disable DAL_RPC namespace Dalamud.Networking.Pipes.Api; diff --git a/Dalamud/Networking/Pipes/Internal/ClientHelloService.cs b/Dalamud/Networking/Pipes/Internal/ClientHelloService.cs index cc06560bd..9c182561e 100644 --- a/Dalamud/Networking/Pipes/Internal/ClientHelloService.cs +++ b/Dalamud/Networking/Pipes/Internal/ClientHelloService.cs @@ -1,10 +1,13 @@ using System.Threading.Tasks; +using Dalamud.Data; using Dalamud.Game; using Dalamud.Game.ClientState; using Dalamud.Networking.Pipes.Rpc; using Dalamud.Utility; +using Lumina.Excel.Sheets; + namespace Dalamud.Networking.Pipes.Internal; /// @@ -30,25 +33,49 @@ internal sealed class ClientHelloService : IInternalDisposableService /// Respond with information. public async Task HandleHello(ClientHelloRequest request) { - var framework = await Service.GetAsync(); var dalamud = await Service.GetAsync(); - var clientState = await Service.GetAsync(); - var response = await framework.RunOnFrameworkThread(() => new ClientHelloResponse + return new ClientHelloResponse { ApiVersion = "1.0", DalamudVersion = Util.GetScmVersion(), GameVersion = dalamud.StartInfo.GameVersion?.ToString() ?? "Unknown", - PlayerName = clientState.IsLoggedIn ? clientState.LocalPlayer?.Name.ToString() ?? "Unknown" : null, - }); - - return response; + ClientIdentifier = await this.GetClientIdentifier(), + }; } /// public void DisposeService() { } + + private async Task GetClientIdentifier() + { + var framework = await Service.GetAsync(); + var clientState = await Service.GetAsync(); + var dataManager = await Service.GetAsync(); + + var clientIdentifier = $"FFXIV Process ${Environment.ProcessId}"; + + await framework.RunOnFrameworkThread(() => + { + if (clientState.IsLoggedIn) + { + var player = clientState.LocalPlayer; + if (player != null) + { + var world = dataManager.GetExcelSheet().GetRow(player.HomeWorld.RowId); + clientIdentifier = $"Logged in as {player.Name.TextValue} @ {world.Name.ExtractText()}"; + } + } + else + { + clientIdentifier = "On login screen"; + } + }); + + return clientIdentifier; + } } /// @@ -88,7 +115,7 @@ internal record ClientHelloResponse public string? GameVersion { get; init; } /// - /// Gets or sets the player name, or null if the player isn't logged in. + /// Gets an identifier for this client. /// - public string? PlayerName { get; set; } + public string? ClientIdentifier { get; init; } }