From e0f96ce3cbb505b90a32a60cc78c1c9bf26784dc Mon Sep 17 00:00:00 2001 From: ilitirit Date: Thu, 23 Mar 2023 20:57:32 +0200 Subject: [PATCH] init --- .gitignore | 350 +++++++++++++++++++++++++++++++++++ CurrentDutyInfo.cs | 14 ++ Expedience.csproj | 69 +++++++ Expedience.json | 11 ++ Expedience.sln | 25 +++ LICENSE | 21 +++ README.md | 3 + assets/icon.png | Bin 0 -> 21267 bytes packages.lock.json | 13 ++ plugin/Configuration.cs | 90 +++++++++ plugin/EmoteCounter.cs | 138 ++++++++++++++ plugin/EmoteReaderHooks.cs | 69 +++++++ plugin/PatCountUI.cs | 40 ++++ plugin/Plugin.cs | 264 ++++++++++++++++++++++++++ plugin/PluginUI.cs | 101 ++++++++++ plugin/PluginWindowConfig.cs | 55 ++++++ plugin/Service.cs | 71 +++++++ plugin/UIReaderBannerMIP.cs | 107 +++++++++++ plugin/UIReaderVoteMvp.cs | 74 ++++++++ utils/GUINodeUtils.cs | 280 ++++++++++++++++++++++++++++ 20 files changed, 1795 insertions(+) create mode 100644 .gitignore create mode 100644 CurrentDutyInfo.cs create mode 100644 Expedience.csproj create mode 100644 Expedience.json create mode 100644 Expedience.sln create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/icon.png create mode 100644 packages.lock.json create mode 100644 plugin/Configuration.cs create mode 100644 plugin/EmoteCounter.cs create mode 100644 plugin/EmoteReaderHooks.cs create mode 100644 plugin/PatCountUI.cs create mode 100644 plugin/Plugin.cs create mode 100644 plugin/PluginUI.cs create mode 100644 plugin/PluginWindowConfig.cs create mode 100644 plugin/Service.cs create mode 100644 plugin/UIReaderBannerMIP.cs create mode 100644 plugin/UIReaderVoteMvp.cs create mode 100644 utils/GUINodeUtils.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfcfd56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,350 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ diff --git a/CurrentDutyInfo.cs b/CurrentDutyInfo.cs new file mode 100644 index 0000000..cd82f70 --- /dev/null +++ b/CurrentDutyInfo.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Lumina.Excel.GeneratedSheets; + +namespace Expedience +{ + public class CurrentDutyInfo + { + public PlayerCharacter Player { get; set; } + public string PlaceName { get; set; } + public List Party { get; set; } + } +} diff --git a/Expedience.csproj b/Expedience.csproj new file mode 100644 index 0000000..31fe6b8 --- /dev/null +++ b/Expedience.csproj @@ -0,0 +1,69 @@ + + + + MgAl2O4 + 1.8.0.0 + Pat counter + (c) 2023 MgAl2O4 + https://github.com/MgAl2O4/ExpedienceDalamud + + + + net7.0-windows + x64 + 9.0 + true + false + false + true + true + $(appdata)\XIVLauncher\addon\Hooks\dev\ + + + + + + + + + + + + + + $(DalamudLibPath)Dalamud.dll + false + + + $(DalamudLibPath)ImGui.NET.dll + false + + + $(DalamudLibPath)ImGuiScene.dll + false + + + $(DalamudLibPath)FFXIVClientStructs.dll + false + + + $(DalamudLibPath)Lumina.dll + false + + + $(DalamudLibPath)Lumina.Excel.dll + false + + + + + + PreserveNewest + + + + + + + + diff --git a/Expedience.json b/Expedience.json new file mode 100644 index 0000000..81cb00a --- /dev/null +++ b/Expedience.json @@ -0,0 +1,11 @@ +{ + "Author": "ilitirit", + "Name": "Expedience", + "Punchline": "Ahahaha worthless buff", + "Description": "Records and uploads Duty Completion times", + "InternalName": "Expedience", + "ApplicableVersion": "any", + "Tags": [ "speed", "duty" ], + "CategoryTags": [ "social" ], + "DalamudApiLevel": 8 +} diff --git a/Expedience.sln b/Expedience.sln new file mode 100644 index 0000000..a7b196a --- /dev/null +++ b/Expedience.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33424.131 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Expedience", "Expedience.csproj", "{8F1BDDC2-28F2-4CE2-8FB0-8FC58B4F6D9F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8F1BDDC2-28F2-4CE2-8FB0-8FC58B4F6D9F}.Debug|x64.ActiveCfg = Debug|x64 + {8F1BDDC2-28F2-4CE2-8FB0-8FC58B4F6D9F}.Debug|x64.Build.0 = Debug|x64 + {8F1BDDC2-28F2-4CE2-8FB0-8FC58B4F6D9F}.Release|x64.ActiveCfg = Release|x64 + {8F1BDDC2-28F2-4CE2-8FB0-8FC58B4F6D9F}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8C1CD108-3B08-43AC-9FD6-A37A6E06CD4B} + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a697d04 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 MgAl2O4 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..191ef2d --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Expedience +Dalamud plugin for recording and uploading duty completion times + diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5c83d8ef8d5046e45d2424be4bf1f5a98e587bca GIT binary patch literal 21267 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 sWI|Nj@ifo@zuOA8%L>0c5J=nN9BlvB0eRZ-1n2 0) && (Counter > 0); } + } + + [Serializable] + public class Configuration : IPluginConfiguration + { + private static int VersionLatest = 1; + + public int Version { get; set; } = VersionLatest; + + public Dictionary mapPats { internal get; set; } = new(); + + public List Emotes { get; set; } = new(); + + public bool showSpecialPats { get; set; } = true; + public bool showFlyText { get; set; } = true; + public bool showCounterUI { get; set; } = false; + public bool canTrackDotes { get; set; } = true; + + [NonSerialized] + private DalamudPluginInterface pluginInterface; + + public void Initialize(DalamudPluginInterface pluginInterface) + { + this.pluginInterface = pluginInterface; + + switch (Version) + { + case 0: + MigrateVersion0(); + break; + + default: break; + } + + Emotes.RemoveAll(x => !x.IsValid()); + } + + public void Save() + { + Emotes.RemoveAll(x => !x.IsValid()); + + pluginInterface.SavePluginConfig(this); + } + + public EmoteDataConfig FindOrAddEmote(string ownerName, int emoteId) + { + if (string.IsNullOrEmpty(ownerName) || emoteId <= 0) + { + return null; + } + + EmoteDataConfig emoteOb = Emotes.Find(x => (x.EmoteId == emoteId && x.OwnerName.Equals(ownerName, StringComparison.OrdinalIgnoreCase))); + if (emoteOb != null) + { + return emoteOb; + } + + emoteOb = new EmoteDataConfig() { OwnerName = ownerName, EmoteId = emoteId }; + Emotes.Add(emoteOb); + return emoteOb; + } + + private void MigrateVersion0() + { + foreach (var kvp in mapPats) + { + var emoteConfig = new EmoteDataConfig() { OwnerName = kvp.Key, EmoteId = EmoteReaderHooks.petEmoteId, Counter = kvp.Value }; + Emotes.Add(emoteConfig); + } + + mapPats.Clear(); + Version = VersionLatest; + } + } +} diff --git a/plugin/EmoteCounter.cs b/plugin/EmoteCounter.cs new file mode 100644 index 0000000..4240e35 --- /dev/null +++ b/plugin/EmoteCounter.cs @@ -0,0 +1,138 @@ +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Logging; +using System; +using System.Collections.Generic; + +namespace Expedience +{ + public class EmoteCounter + { + public Action OnChanged; + public bool isActive = true; + + public int counterEmoteId; + public int[] triggerEmoteIds; + + public string counterDesc; + public string counterDescPlural; + public string uiDesc; + + private Dictionary mapEmotesInZone = new(); + private string currentPlayerName; + + public void OnEmote(GameObject instigator, ushort emoteId) + { + bool canUse = emoteId == counterEmoteId; + if (triggerEmoteIds != null) + { + canUse = Array.FindIndex(triggerEmoteIds, x => x == emoteId) >= 0; + } + + if (canUse && isActive) + { + var canNotify = IncCounter(); + if (canNotify) + { + var playerInstigator = instigator as PlayerCharacter; + uint instigatorWorld = (playerInstigator != null) ? playerInstigator.HomeWorld.Id : 0; + + Service.counterBroadcast.SendMessage(counterDesc, emoteId, instigator.Name.ToString(), instigatorWorld); + } + + var instigatorName = (instigator != null) ? instigator.Name.ToString() : "??"; + if (mapEmotesInZone.TryGetValue(instigatorName, out int counter)) + { + mapEmotesInZone[instigatorName] = counter + 1; + } + else + { + mapEmotesInZone.Add(instigatorName, 1); + } + } + } + + public int GetCounter() + { + if (currentPlayerName == null) + { + currentPlayerName = GetCurrentPlayerName(); + } + + var emoteData = Service.pluginConfig.FindOrAddEmote(currentPlayerName, counterEmoteId); + if (emoteData != null) + { + return emoteData.Counter; + } + + return 0; + } + + public bool IncCounter() + { + if (currentPlayerName == null) + { + currentPlayerName = GetCurrentPlayerName(); + } + + var emoteData = Service.pluginConfig.FindOrAddEmote(currentPlayerName, counterEmoteId); + if (emoteData != null) + { + emoteData.Counter++; + Service.pluginConfig.Save(); + + OnChanged?.Invoke(emoteData.Counter); + return true; + } + + return false; + } + + public void OnLogout() + { + currentPlayerName = null; + } + + public void OnTerritoryChanged() + { + mapEmotesInZone.Clear(); + } + + public (string, int) GetTopEmotesInZone() + { + string maxPatsPlayer = null; + int maxPats = 0; + + foreach (var kvp in mapEmotesInZone) + { + if (kvp.Value > maxPats) + { + maxPats = kvp.Value; + maxPatsPlayer = kvp.Key; + } + } + + return (maxPatsPlayer, maxPats); + } + + public int GetEmotesInCurrentZone(string instigatorName) + { + if (mapEmotesInZone.TryGetValue(instigatorName, out int numPats)) + { + return numPats; + } + + return 0; + } + + private string GetCurrentPlayerName() + { + if (Service.clientState == null || Service.clientState.LocalPlayer == null || Service.clientState.LocalPlayer.Name == null) + { + return null; + } + + return Service.clientState.LocalPlayer.Name.TextValue; + } + } +} diff --git a/plugin/EmoteReaderHooks.cs b/plugin/EmoteReaderHooks.cs new file mode 100644 index 0000000..edb1759 --- /dev/null +++ b/plugin/EmoteReaderHooks.cs @@ -0,0 +1,69 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Hooking; +using Dalamud.Logging; +using System; +using System.Linq; + +namespace Expedience +{ + public class EmoteReaderHooks : IDisposable + { + public static ushort petEmoteId = 105; // TODO: read from lumina? + + public Action OnEmote; + + public delegate void OnEmoteFuncDelegate(ulong unk, ulong instigatorAddr, ushort emoteId, ulong targetId, ulong unk2); + private readonly Hook hookEmote; + + public bool IsValid = false; + + public EmoteReaderHooks() + { + try + { + var emoteFuncPtr = Service.sigScanner.ScanText("48 89 5c 24 08 48 89 6c 24 10 48 89 74 24 18 48 89 7c 24 20 41 56 48 83 ec 30 4c 8b 74 24 60 48 8b d9 48 81 c1 60 2f 00 00"); + hookEmote = Hook.FromAddress(emoteFuncPtr, OnEmoteDetour); + hookEmote.Enable(); + + IsValid = true; + } + catch (Exception ex) + { + PluginLog.Error(ex, "oh noes!"); + } + } + + public void Dispose() + { + hookEmote?.Dispose(); + IsValid = false; + } + + void OnEmoteDetour(ulong unk, ulong instigatorAddr, ushort emoteId, ulong targetId, ulong unk2) + { + // unk - some field of event framework singleton? doesn't matter here anyway + // PluginLog.Log($"Emote >> unk:{unk:X}, instigatorAddr:{instigatorAddr:X}, emoteId:{emoteId}, targetId:{targetId:X}, unk2:{unk2:X}"); + + if (Service.clientState.LocalPlayer != null) + { + if (targetId == Service.clientState.LocalPlayer.ObjectId) + { + var instigatorOb = Service.objectTable.FirstOrDefault(x => (ulong)x.Address == instigatorAddr); + if (instigatorOb != null) + { + bool canCount = (instigatorOb.ObjectId != targetId); +#if DEBUG + canCount = true; +#endif + if (canCount) + { + OnEmote?.Invoke(instigatorOb, emoteId); + } + } + } + } + + hookEmote.Original(unk, instigatorAddr, emoteId, targetId, unk2); + } + } +} diff --git a/plugin/PatCountUI.cs b/plugin/PatCountUI.cs new file mode 100644 index 0000000..3ad5f57 --- /dev/null +++ b/plugin/PatCountUI.cs @@ -0,0 +1,40 @@ +using Dalamud.Interface.Windowing; +using ImGuiNET; +using System; + +namespace Expedience +{ + public class PatCountUI : Window, IDisposable + { + public PatCountUI() : base("Pat Count") + { + IsOpen = false; + + Flags = ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.AlwaysAutoResize; + RespectCloseHotkey = false; + } + + public void Dispose() + { + } + + public override void Draw() + { + int pats = Service.patCounter.GetCounter(); + ImGui.Text($"{Service.patCounter.uiDesc}: {pats}"); + + // add more counters if they want to be there + foreach (var counter in Service.plugin.emoteCounters) + { + if (counter != null && counter != Service.patCounter && !string.IsNullOrEmpty(counter.uiDesc) && counter.isActive) + { + int numEmotes = counter.GetCounter(); + if (numEmotes > 0) + { + ImGui.Text($"{counter.uiDesc}: {numEmotes}"); + } + } + } + } + } +} diff --git a/plugin/Plugin.cs b/plugin/Plugin.cs new file mode 100644 index 0000000..5a44cca --- /dev/null +++ b/plugin/Plugin.cs @@ -0,0 +1,264 @@ +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.Game.ClientState.Objects; +using Dalamud.Game.Gui.FlyText; +using Dalamud.Game.Gui.Toast; +using Dalamud.Interface.Windowing; +using Dalamud.Logging; +using Dalamud.Plugin; +using ImGuiScene; +using Lumina.Excel; +using Lumina.Excel.GeneratedSheets; +using System; +using System.Collections.Generic; + +namespace Expedience +{ + public class Plugin : IDalamudPlugin + { + public string Name => "Expedience"; + + private readonly WindowSystem windowSystem = new("Expedience"); + private PluginUI pluginUI; + private EmoteReaderHooks emoteReader; + private UIReaderVoteMvp uiReaderVoteMvp; + private UIReaderBannerMIP uiReaderBannerMIP; + private PluginWindowConfig windowConfig; + private PatCountUI patCountUI; + public readonly List emoteCounters = new(); + + private ExcelSheet _territories; + private Tuple _currentDuty; + + public Plugin(DalamudPluginInterface pluginInterface) + { + pluginInterface.Create(); + + Service.plugin = this; + + Service.pluginConfig = pluginInterface.GetPluginConfig() as Configuration ?? new Configuration(); + Service.pluginConfig.Initialize(pluginInterface); + + _territories = Service.DataManager.GetExcelSheet(); + + Service.patCounter = new EmoteCounter() + { + counterEmoteId = EmoteReaderHooks.petEmoteId, + counterDesc = "pat", + counterDescPlural = "pats", + uiDesc = "Head pats", + }; + + Service.DutyState.DutyStarted += (sender, args) => + { + var territory = Service.ClientState.TerritoryType; + var player = Service.ClientState.LocalPlayer; + + PlaceName place = _territories.GetRow(territory)?.PlaceName.Value; + foreach (var member in Service.PartyList) + { + + } + }; + + Service.DutyState.DutyCompleted += (sender, args) => + { + var territory = Service.ClientState.TerritoryType; + var place = _territories.GetRow(territory)?.PlaceName.Value; + + }; + + Service.patCounter.OnChanged += (num) => OnEmoteReward(Service.patCounter, num); + emoteCounters.Add(Service.patCounter); + + // two different emote ids? + Service.doteCounter = new EmoteCounter() + { + counterEmoteId = 146, + triggerEmoteIds = new int[] { 146, 147 }, + counterDesc = "dote", + counterDescPlural = "dotes", + uiDesc = "Ranged pats", + }; + Service.doteCounter.OnChanged += (num) => OnEmoteReward(Service.doteCounter, num); + Service.doteCounter.isActive = Service.pluginConfig.canTrackDotes; + emoteCounters.Add(Service.doteCounter); + + pluginUI = new PluginUI(); + pluginUI.overlayImage = LoadEmbeddedImage("fan-kit-lala.png"); + + uiReaderVoteMvp = new UIReaderVoteMvp(); + uiReaderBannerMIP = new UIReaderBannerMIP(); + + windowConfig = new PluginWindowConfig(); + windowSystem.AddWindow(windowConfig); + + patCountUI = new PatCountUI(); + windowSystem.AddWindow(patCountUI); + + Service.commandManager.AddHandler("/Expedience", new(OnCommand) { HelpMessage = "Show pat counter" }); + Service.commandManager.AddHandler("/patcount", new(OnCommand) { HelpMessage = "Show persistent pat counter" }); + pluginInterface.UiBuilder.Draw += OnDraw; + pluginInterface.UiBuilder.OpenConfigUi += OnOpenConfig; + + emoteReader = new EmoteReaderHooks(); + emoteReader.OnEmote += (instigator, emoteId) => emoteCounters.ForEach(x => x.OnEmote(instigator, emoteId)); + + Service.framework.Update += Framework_Update; + Service.ClientState.TerritoryChanged += ClientState_TerritoryChanged; + Service.ClientState.Logout += ClientState_Logout; + + if (Service.pluginConfig.showCounterUI) + { + patCountUI.IsOpen = true; + } + + Service.counterBroadcast = pluginInterface.GetIpcProvider("ExpedienceEmoteCounter"); + } + + private void Framework_Update(Framework framework) + { + float deltaSeconds = (float)framework.UpdateDelta.TotalSeconds; + uiReaderVoteMvp.Tick(deltaSeconds); + uiReaderBannerMIP.Tick(deltaSeconds); + } + + private void ClientState_TerritoryChanged(object sender, ushort e) + { + emoteCounters.ForEach(x => x.OnTerritoryChanged()); + } + + private void ClientState_Logout(object sender, EventArgs e) + { + emoteCounters.ForEach(x => x.OnLogout()); + } + + public void Dispose() + { + pluginUI.Dispose(); + + emoteReader.Dispose(); + windowSystem.RemoveAllWindows(); + + Service.commandManager.RemoveHandler("/Expedience"); + Service.commandManager.RemoveHandler("/patcount"); + Service.framework.Update -= Framework_Update; + Service.ClientState.TerritoryChanged -= ClientState_TerritoryChanged; + Service.ClientState.Logout -= ClientState_Logout; + } + + private void OnCommand(string command, string args) + { + if (command == "/Expedience") + { + DescribeCounter(Service.patCounter, false); + foreach (var counter in emoteCounters) + { + if (counter != Service.patCounter) + { + DescribeCounter(counter); + } + } + } + else if (command == "/patcount") + { + patCountUI.Toggle(); + } + } + + private void DescribeCounter(EmoteCounter counter, bool hideEmpty = true) + { + if (counter == null || string.IsNullOrEmpty(counter.counterDesc) || !counter.isActive) + { + return; + } + + int numEmotes = counter.GetCounter(); + if (numEmotes <= 0 && hideEmpty) + { + return; + } + + var useName = counter.counterDesc[0].ToString().ToUpper() + counter.counterDesc.Substring(1); + Service.chatGui.Print($"{useName} counter: {numEmotes}"); + + var (maxPlayerName, maxCount) = counter.GetTopEmotesInZone(); + if (maxCount > 0) + { + string countDesc = (maxCount == 1) ? counter.counterDesc : counter.counterDescPlural; + Service.chatGui.Print($"♥ {maxPlayerName}: {maxCount} {countDesc}"); + } + } + + private void OnDraw() + { + pluginUI.Draw(); + windowSystem.Draw(); + } + + private void OnOpenConfig() + { + windowConfig.IsOpen = true; + } + + public void OnShowCounterConfigChanged(bool wantsUI) + { + patCountUI.IsOpen = wantsUI; + } + + private void OnEmoteReward(EmoteCounter counter, int numEmotes) + { + // thresholds on: 5, 15, 25, 50, 75, ... + bool isSpecial = (numEmotes < 25) ? (numEmotes == 5 || numEmotes == 15) : ((numEmotes % 25) == 0); + if (isSpecial && Service.pluginConfig.showSpecialPats) + { + // pats get special rewards. + if (counter == Service.patCounter) + { + pluginUI.Show(); + } + + var useDesc = counter.counterDescPlural.ToUpper(); + Service.toastGui?.ShowQuest($"{numEmotes} {useDesc}!", new QuestToastOptions + { + Position = QuestToastPosition.Centre, + DisplayCheckmark = true, + IconId = 0, + PlaySound = true + }); + } + else if (Service.pluginConfig.showFlyText) + { + var useDesc = counter.counterDesc.ToUpper(); + Service.flyTextGui?.AddFlyText(FlyTextKind.NamedCriticalDirectHit, 0, (uint)numEmotes, 0, useDesc, " ", 0xff00ff00, 0, 0); + } + } + + private TextureWrap LoadEmbeddedImage(string name) + { + TextureWrap resultImage = null; + try + { + var myAssembly = GetType().Assembly; + var myAssemblyName = myAssembly.GetName().Name; + var resourceName = $"{myAssemblyName}.assets.{name}"; + + var resStream = myAssembly.GetManifestResourceStream(resourceName); + if (resStream != null && resStream.Length > 0) + { + var contentBytes = new byte[(int)resStream.Length]; + resStream.Read(contentBytes, 0, contentBytes.Length); + + resultImage = Service.pluginInterface.UiBuilder.LoadImage(contentBytes); + resStream.Close(); + } + } + catch (Exception ex) + { + PluginLog.Error(ex, "failed to load overlay image"); + } + + return resultImage; + } + } +} diff --git a/plugin/PluginUI.cs b/plugin/PluginUI.cs new file mode 100644 index 0000000..130f9f0 --- /dev/null +++ b/plugin/PluginUI.cs @@ -0,0 +1,101 @@ +using ImGuiNET; +using ImGuiScene; +using System; +using System.Numerics; + +namespace Expedience +{ + public class PluginUI : IDisposable + { + private enum AnimPhase + { + None, + Appear, + Keep, + Disappear, + } + + public TextureWrap overlayImage; + + private AnimPhase anim = AnimPhase.None; + private static readonly float[] animDuration = new float[] { 0.0f, 1.0f, 1.0f, 1.0f }; + private float animTimeRemaining = 0.0f; + + public void Show() + { + SetAnim(AnimPhase.Appear); + } + + public void Draw() + { + if (anim == AnimPhase.None || overlayImage == null) + { + return; + } + + float animPct = 1.0f - Math.Max(0.0f, animTimeRemaining / animDuration[(int)anim]); + + // draw image + { + var viewport = ImGui.GetMainViewport(); + var viewportCenter = viewport.GetCenter(); + var drawHalfSize = new Vector2(overlayImage.Width * 0.5f, overlayImage.Height * 0.5f); + + if (drawHalfSize.X > viewportCenter.X || drawHalfSize.Y > viewportCenter.Y) + { + drawHalfSize.Y = viewportCenter.Y * 3 / 4; + drawHalfSize.X = overlayImage.Width * drawHalfSize.Y / overlayImage.Height; + } + + if (anim == AnimPhase.Appear) + { + drawHalfSize *= AnimElastic(animPct); + } + + var drawAlpha = + (anim == AnimPhase.Appear) ? 1 - Math.Pow(1 - animPct, 4) : + (anim == AnimPhase.Disappear) ? (1.0f - animPct) : + 1.0f; + + uint drawColor = 0xffffff | (uint)(drawAlpha * 255) << 24; + + var drawList = ImGui.GetForegroundDrawList(); + drawList.AddImage(overlayImage.ImGuiHandle, viewportCenter - drawHalfSize, viewportCenter + drawHalfSize, Vector2.Zero, Vector2.One, drawColor); + } + + // state transitions + animTimeRemaining -= ImGui.GetIO().DeltaTime; + if (animTimeRemaining <= 0.0f) + { + if (anim == AnimPhase.Disappear) + { + SetAnim(AnimPhase.None); + } + else + { + SetAnim(anim + 1); + } + } + } + + private float AnimElastic(float alpha) + { + const float c4 = (float)(2 * Math.PI) / 3.0f; + + return (alpha == 0) ? 0.0f : + (alpha == 1) ? 1.0f : + (float)(Math.Pow(2, -10 * alpha) * Math.Sin((alpha * 10 - 0.75) * c4) + 1); + } + + private void SetAnim(AnimPhase anim) + { + this.anim = anim; + animTimeRemaining = animDuration[(int)anim]; + } + + public void Dispose() + { + overlayImage.Dispose(); + } + } +} diff --git a/plugin/PluginWindowConfig.cs b/plugin/PluginWindowConfig.cs new file mode 100644 index 0000000..4143cb0 --- /dev/null +++ b/plugin/PluginWindowConfig.cs @@ -0,0 +1,55 @@ +using Dalamud.Interface.Windowing; +using ImGuiNET; +using System; +using System.Numerics; + +namespace Expedience +{ + public class PluginWindowConfig : Window, IDisposable + { + public PluginWindowConfig() : base("Pat Config") + { + IsOpen = false; + + SizeConstraints = new WindowSizeConstraints() { MinimumSize = new Vector2(100, 0), MaximumSize = new Vector2(300, 3000) }; + Flags = ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoScrollbar; + } + + public void Dispose() + { + } + + public override void Draw() + { + bool showSpecialPats = Service.pluginConfig.showSpecialPats; + bool showFlyText = Service.pluginConfig.showFlyText; + bool showCounterOnScreen = Service.pluginConfig.showCounterUI; + bool canTrackDotes = Service.pluginConfig.canTrackDotes; + bool hasChanges = false; + + hasChanges = ImGui.Checkbox("Show notify on reaching pat goals", ref showSpecialPats) || hasChanges; + hasChanges = ImGui.Checkbox("Show fly text counter on each emote", ref showFlyText) || hasChanges; + hasChanges = ImGui.Checkbox("Show pat counter on screen", ref showCounterOnScreen) || hasChanges; + + ImGui.Separator(); + hasChanges = ImGui.Checkbox("Track emote: dote", ref canTrackDotes) || hasChanges; + + if (showCounterOnScreen != Service.pluginConfig.showCounterUI) + { + Service.plugin.OnShowCounterConfigChanged(showCounterOnScreen); + } + + if (hasChanges) + { + Service.pluginConfig.showSpecialPats = showSpecialPats; + Service.pluginConfig.showFlyText = showFlyText; + Service.pluginConfig.showCounterUI = showCounterOnScreen; + Service.pluginConfig.canTrackDotes = canTrackDotes; + + Service.pluginConfig.Save(); + + Service.doteCounter.isActive = canTrackDotes; + } + } + } +} diff --git a/plugin/Service.cs b/plugin/Service.cs new file mode 100644 index 0000000..cac31bb --- /dev/null +++ b/plugin/Service.cs @@ -0,0 +1,71 @@ +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.Game.ClientState; +using Dalamud.Game.ClientState.Party; +using Dalamud.Game.ClientState.Objects; +using Dalamud.Game.Command; +using Dalamud.Game.DutyState; +using Dalamud.Game.Gui; +using Dalamud.Game.Gui.FlyText; +using Dalamud.Game.Gui.Toast; +using Dalamud.IoC; +using Dalamud.Plugin; +using Dalamud.Plugin.Ipc; +using Lumina.Excel.GeneratedSheets; + +namespace Expedience +{ + internal class Service + { + public static Plugin plugin; + + public static Configuration pluginConfig; + + public static EmoteCounter patCounter; + public static EmoteCounter doteCounter; + public static ICallGateProvider counterBroadcast; + + [PluginService] + public static DalamudPluginInterface pluginInterface { get; private set; } = null!; + + [PluginService] + public static CommandManager commandManager { get; private set; } = null!; + + [PluginService] + public static FlyTextGui flyTextGui { get; private set; } = null!; + + [PluginService] + public static ToastGui toastGui { get; private set; } = null!; + + [PluginService] + public static ClientState ClientState { get; private set; } = null!; + + [PluginService] + public static ChatGui chatGui { get; private set; } = null!; + + [PluginService] + public static SigScanner sigScanner { get; private set; } = null!; + + [PluginService] + public static ObjectTable objectTable { get; private set; } = null!; + + [PluginService] + public static Framework framework { get; private set; } = null!; + + [PluginService] + public static GameGui gameGui { get; private set; } = null!; + + [PluginService] + public static DataManager DataManager { get; private set; } = null!; + + [PluginService] + public static DutyState DutyState { get; private set; } = null!; + + [PluginService] + public static PartyList PartyList { get; private set; } = null!; + + [PluginService] + public static InstanceContent InstanceContent { get; private set; } = null!; + + } +} diff --git a/plugin/UIReaderBannerMIP.cs b/plugin/UIReaderBannerMIP.cs new file mode 100644 index 0000000..4582b0c --- /dev/null +++ b/plugin/UIReaderBannerMIP.cs @@ -0,0 +1,107 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; +using MgAl2O4.Utils; +using System; +using System.Collections.Generic; + +namespace Expedience +{ + public class UIReaderBannerMIP + { + public const float UpdateInterval = 0.5f; + + private float updateTimeRemaining = 0.0f; + private IntPtr cachedAddonPtr; + + private Dictionary playerNames = new Dictionary(); + + public void Tick(float deltaSeconds) + { + updateTimeRemaining -= deltaSeconds; + if (updateTimeRemaining < 0.0f) + { + updateTimeRemaining = UpdateInterval; + UpdateAddon(); + } + } + + private unsafe void UpdateAddon() + { + var addonPtr = Service.gameGui.GetAddonByName("BannerMIP", 1); + var addonBaseNode = (AtkUnitBase*)addonPtr; + + if (addonBaseNode == null || addonBaseNode->RootNode == null || !addonBaseNode->RootNode->IsVisible) + { + // reset when closed + cachedAddonPtr = IntPtr.Zero; + playerNames.Clear(); + return; + } + + cachedAddonPtr = addonPtr; + + var level0 = GUINodeUtils.GetImmediateChildNodes(addonBaseNode->RootNode); + var listRoot = GUINodeUtils.PickNode(level0, 2, 4); + var listNodes = GUINodeUtils.GetImmediateChildNodes(listRoot); + var collectsPlayerNames = playerNames.Count == 0; + + if (listNodes != null) + { + for (int idx = 0; idx < listNodes.Length; idx++) + { + var innerList = GUINodeUtils.GetChildNode(listNodes[idx]); + if (innerList != null && innerList->IsVisible) + { + var nodeLastName = GUINodeUtils.PickChildNode(innerList, 20, 27); + var nodeFirstName = GUINodeUtils.PickChildNode(innerList, 21, 27); + var nodeCombined = GUINodeUtils.PickChildNode(innerList, 22, 27); + + UpdatePlayerNames(idx, collectsPlayerNames, nodeLastName, nodeFirstName, nodeCombined); + } + } + } + } + + private unsafe void UpdatePlayerNames(int entryIdx, bool collectsPlayerNames, AtkResNode* nodeLastName, AtkResNode* nodeFirstName, AtkResNode* nodeCombined) + { + if (nodeLastName == null || nodeFirstName == null || nodeCombined == null || + nodeLastName->Type != NodeType.Text || nodeFirstName->Type != NodeType.Text || nodeCombined->Type != NodeType.Text) + { + return; + } + + var lastName = GUINodeUtils.GetNodeText(nodeLastName); + if (lastName.Length == 0 || lastName.StartsWith("pats:")) + { + return; + } + + var playerName = ""; + + if (collectsPlayerNames) + { + var firstName = GUINodeUtils.GetNodeText(nodeFirstName); + playerName = $"{firstName} {lastName}"; + playerNames.Add(entryIdx, playerName); + } + else + { + playerNames.TryGetValue(entryIdx, out playerName); + } + + if (playerName.Length <= 1) // include separator + { + return; + } + + int numPats = Service.patCounter.GetEmotesInCurrentZone(playerName); + if (numPats > 0) + { + ((AtkTextNode*)nodeLastName)->SetText($"pats: {numPats}"); + + nodeCombined->Flags &= ~0x10; // hide + nodeLastName->Flags |= 0x10; // show + nodeFirstName->Flags |= 0x10; // show + } + } + } +} diff --git a/plugin/UIReaderVoteMvp.cs b/plugin/UIReaderVoteMvp.cs new file mode 100644 index 0000000..2dfccda --- /dev/null +++ b/plugin/UIReaderVoteMvp.cs @@ -0,0 +1,74 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; +using MgAl2O4.Utils; +using System; + +namespace Expedience +{ + public class UIReaderVoteMvp + { + public const float UpdateInterval = 0.5f; + + private float updateTimeRemaining = 0.0f; + private IntPtr cachedAddonPtr; + + public void Tick(float deltaSeconds) + { + updateTimeRemaining -= deltaSeconds; + if (updateTimeRemaining < 0.0f) + { + updateTimeRemaining = UpdateInterval; + UpdateAddon(); + } + } + + private unsafe void UpdateAddon() + { + var addonPtr = Service.gameGui.GetAddonByName("VoteMvp", 1); + var addonBaseNode = (AtkUnitBase*)addonPtr; + + if (addonBaseNode == null || addonBaseNode->RootNode == null || !addonBaseNode->RootNode->IsVisible) + { + // reset when closed + cachedAddonPtr = IntPtr.Zero; + return; + } + + // update once + if (cachedAddonPtr == addonPtr) + { + return; + } + + cachedAddonPtr = addonPtr; + + var childNodesL0 = GUINodeUtils.GetImmediateChildNodes(addonBaseNode->RootNode); + if (childNodesL0 != null) + { + foreach (var nodeL0 in childNodesL0) + { + var nodeL1 = GUINodeUtils.PickChildNode(nodeL0, 3, 7); + if (nodeL1 != null && nodeL1->Type == NodeType.Text) + { + var textNode = (AtkTextNode*)nodeL1; + var playerName = textNode->NodeText.ToString(); + + if (!playerName.Contains("pats ]") && !playerName.Contains("pat ]")) + { + int numPats = Service.patCounter.GetEmotesInCurrentZone(playerName); + if (numPats == 1) + { + playerName += " [ 1 pat ]"; + textNode->SetText(playerName); + } + else if (numPats > 1) + { + playerName += $" [ {numPats} pats ]"; + textNode->SetText(playerName); + } + } + } + } + } + } + } +} diff --git a/utils/GUINodeUtils.cs b/utils/GUINodeUtils.cs new file mode 100644 index 0000000..35cd0b9 --- /dev/null +++ b/utils/GUINodeUtils.cs @@ -0,0 +1,280 @@ +using Dalamud.Logging; +using FFXIVClientStructs.FFXIV.Component.GUI; +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Runtime.InteropServices; + +namespace MgAl2O4.Utils +{ + // Dalamud.Interface.UIDebug is amazing + + public class GUINodeUtils + { + public static unsafe AtkResNode* PickChildNode(AtkResNode* maybeCompNode, int childIdx, int expectedNumChildren) + { + if (maybeCompNode != null && (int)maybeCompNode->Type >= 1000) + { + var compNode = (AtkComponentNode*)maybeCompNode; + if (compNode->Component->UldManager.NodeListCount == expectedNumChildren && childIdx < expectedNumChildren) + { + return compNode->Component->UldManager.NodeList[childIdx]; + } + } + + return null; + } + + public static unsafe AtkResNode* PickChildNode(AtkComponentBase* compPtr, int childIdx, int expectedNumChildren) + { + if (compPtr != null && compPtr->UldManager.NodeListCount == expectedNumChildren && childIdx < expectedNumChildren) + { + return compPtr->UldManager.NodeList[childIdx]; + } + + return null; + } + + public static unsafe AtkResNode*[] GetImmediateChildNodes(AtkResNode* node) + { + var listAddr = new List(); + if (node != null && node->ChildNode != null) + { + listAddr.Add((ulong)node->ChildNode); + + node = node->ChildNode; + while (node->PrevSiblingNode != null) + { + listAddr.Add((ulong)node->PrevSiblingNode); + node = node->PrevSiblingNode; + } + } + + return ConvertToNodeArr(listAddr); + } + + public static unsafe AtkResNode*[] GetAllChildNodes(AtkResNode* node) + { + if (node != null) + { + var list = new List(); + RecursiveAppendChildNodes(node, list); + + return ConvertToNodeArr(list); + } + + return null; + } + + private static unsafe void RecursiveAppendChildNodes(AtkResNode* node, List listAddr) + { + if (node != null) + { + listAddr.Add((ulong)node); + + // step inside + if (node->ChildNode != null) + { + RecursiveAppendChildNodes(node->ChildNode, listAddr); + + AtkResNode* linkNode = node->ChildNode; + while (linkNode->PrevSiblingNode != null) + { + RecursiveAppendChildNodes(linkNode->PrevSiblingNode, listAddr); + linkNode = linkNode->PrevSiblingNode; + } + + // no need to check next siblings here? + } + } + } + + private static unsafe AtkResNode*[] ConvertToNodeArr(List listAddr) + { + if (listAddr.Count > 0) + { + var typedArr = new AtkResNode*[listAddr.Count]; + for (int idx = 0; idx < listAddr.Count; idx++) + { + typedArr[idx] = (AtkResNode*)listAddr[idx]; + } + + return typedArr; + } + + return null; + } + + public static unsafe AtkResNode* PickNode(AtkResNode*[] nodes, int nodeIdx, int expectedNumNodes) + { + if (nodes != null && nodes.Length == expectedNumNodes && nodeIdx < expectedNumNodes) + { + return nodes[nodeIdx]; + } + + return null; + } + + public static unsafe AtkResNode* GetChildNode(AtkResNode* node) + { + return node != null ? node->ChildNode : null; + } + + public static unsafe string GetNodeTexturePath(AtkResNode* maybeImageNode) + { + if (maybeImageNode != null && maybeImageNode->Type == NodeType.Image) + { + var imageNode = (AtkImageNode*)maybeImageNode; + if (imageNode->PartsList != null && imageNode->PartId <= imageNode->PartsList->PartCount) + { + var textureInfo = imageNode->PartsList->Parts[imageNode->PartId].UldAsset; + var texType = textureInfo->AtkTexture.TextureType; + if (texType == TextureType.Resource) + { + var texFileNameStdString = &textureInfo->AtkTexture.Resource->TexFileResourceHandle->ResourceHandle.FileName; + var texString = texFileNameStdString->Length < 16 + ? Marshal.PtrToStringAnsi((IntPtr)texFileNameStdString->Buffer) + : Marshal.PtrToStringAnsi((IntPtr)texFileNameStdString->BufferPtr); + + return texString; + } + } + } + + return null; + } + + public static unsafe string GetNodeText(AtkResNode* maybeTextNode) + { + if (maybeTextNode != null && maybeTextNode->Type == NodeType.Text) + { + var textNode = (AtkTextNode*)maybeTextNode; + var text = Marshal.PtrToStringUTF8(new IntPtr(textNode->NodeText.StringPtr)); + return text; + } + + return null; + } + + public static unsafe Vector2 GetNodePosition(AtkResNode* node) + { + var pos = new Vector2(node->X, node->Y); + var par = node->ParentNode; + while (par != null) + { + pos *= new Vector2(par->ScaleX, par->ScaleY); + pos += new Vector2(par->X, par->Y); + par = par->ParentNode; + } + + return pos; + } + + public static unsafe Vector2 GetNodeScale(AtkResNode* node) + { + if (node == null) return new Vector2(1, 1); + var scale = new Vector2(node->ScaleX, node->ScaleY); + while (node->ParentNode != null) + { + node = node->ParentNode; + scale *= new Vector2(node->ScaleX, node->ScaleY); + } + + return scale; + } + + public static unsafe (Vector2, Vector2) GetNodePosAndSize(AtkResNode* node) + { + if (node != null) + { + var pos = GetNodePosition(node); + var scale = GetNodeScale(node); + var size = new Vector2(node->Width * scale.X, node->Height * scale.Y); + + return (pos, size); + } + + return (Vector2.Zero, Vector2.Zero); + } + +#if DEBUG + private class ParsableNode + { + public ulong nodeAddr; + public string content; + public int childIdx; + public int numChildren; + public int depth; + public NodeType type; + public string debugPath; + } + + private static unsafe bool RecursiveAppendParsableChildNodes(AtkResNode* node, int depth, int childIdx, List list, string debugPath) + { + bool hasParsableChildNodes = false; + bool hasContent = false; + + if (node != null) + { + // check if this node is interesting for parser (empty string is still interesting) + string content = GetNodeText(node); + content = (content != null) ? content : GetNodeTexturePath(node); + hasContent = (content != null); + + int numChildNodes = 0; + int insertIdx = list.Count; + + if ((int)node->Type < 1000) + { + // step inside + if (node->ChildNode != null) + { + hasParsableChildNodes = RecursiveAppendParsableChildNodes(node->ChildNode, depth + 1, numChildNodes, list, debugPath + "," + numChildNodes); + numChildNodes++; + + AtkResNode* linkNode = node->ChildNode; + + while (linkNode->PrevSiblingNode != null) + { + var hasParsableSibling = RecursiveAppendParsableChildNodes(linkNode->PrevSiblingNode, depth + 1, numChildNodes, list, debugPath + "," + numChildNodes); + hasParsableChildNodes = hasParsableChildNodes || hasParsableSibling; + linkNode = linkNode->PrevSiblingNode; + numChildNodes++; + } + + // no need to check next siblings here? + } + } + else + { + var compNode = (AtkComponentNode*)node; + for (int idx = 0; idx < compNode->Component->UldManager.NodeListCount; idx++) + { + hasParsableChildNodes = RecursiveAppendParsableChildNodes(compNode->Component->UldManager.NodeList[idx], depth + 1, numChildNodes, list, debugPath + "," + numChildNodes); + numChildNodes++; + } + } + + if (hasParsableChildNodes || hasContent) + { + list.Insert(insertIdx, new ParsableNode() { nodeAddr = (ulong)node, content = content, childIdx = childIdx, numChildren = numChildNodes, depth = depth, debugPath = debugPath, type = node->Type }); + } + } + + return hasParsableChildNodes || hasContent; + } + + public static unsafe void LogParsableNodes(AtkResNode* node) + { + var list = new List(); + RecursiveAppendParsableChildNodes(node, 0, 0, list, ""); + + foreach (var entry in list) + { + var prefix = entry.depth > 0 ? new string(' ', entry.depth * 2) : ""; + PluginLog.Log($"{prefix}> '{entry.content}' idx:{entry.childIdx}, children:{entry.numChildren}, type:{entry.type}, addr:{entry.nodeAddr:X}, path:{entry.debugPath}"); + } + } +#endif // DEBUG + } +}