From 4e04e313230edc17227b3a2657db8cdaa03fe253 Mon Sep 17 00:00:00 2001 From: Anna Clemens Date: Fri, 19 Nov 2021 12:55:07 -0500 Subject: [PATCH] chore: initial commit --- .gitignore | 365 ++++++++++++++ Glamaholic.sln | 16 + Glamaholic/Commands.cs | 24 + Glamaholic/Configuration.cs | 44 ++ Glamaholic/GameFunctions.cs | 379 +++++++++++++++ Glamaholic/Glamaholic.csproj | 63 +++ Glamaholic/Glamaholic.yaml | 8 + Glamaholic/Plugin.cs | 52 ++ Glamaholic/PluginUi.cs | 69 +++ Glamaholic/Ui/Helpers/EditorHelper.cs | 49 ++ Glamaholic/Ui/Helpers/ExamineHelper.cs | 89 ++++ Glamaholic/Ui/Helpers/HelperUtil.cs | 36 ++ Glamaholic/Ui/MainInterface.cs | 640 +++++++++++++++++++++++++ Glamaholic/Util.cs | 85 ++++ LICENCE | 287 +++++++++++ icon.png | Bin 0 -> 9979 bytes 16 files changed, 2206 insertions(+) create mode 100644 .gitignore create mode 100755 Glamaholic.sln create mode 100755 Glamaholic/Commands.cs create mode 100755 Glamaholic/Configuration.cs create mode 100755 Glamaholic/GameFunctions.cs create mode 100755 Glamaholic/Glamaholic.csproj create mode 100755 Glamaholic/Glamaholic.yaml create mode 100755 Glamaholic/Plugin.cs create mode 100755 Glamaholic/PluginUi.cs create mode 100755 Glamaholic/Ui/Helpers/EditorHelper.cs create mode 100755 Glamaholic/Ui/Helpers/ExamineHelper.cs create mode 100755 Glamaholic/Ui/Helpers/HelperUtil.cs create mode 100755 Glamaholic/Ui/MainInterface.cs create mode 100755 Glamaholic/Util.cs create mode 100644 LICENCE create mode 100644 icon.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1db30bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,365 @@ +## 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 + +# Packaging +pack/ + +# 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/ +[Ww][Ii][Nn]32/ +[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/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# 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 + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# 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/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd diff --git a/Glamaholic.sln b/Glamaholic.sln new file mode 100755 index 0000000..9d09b45 --- /dev/null +++ b/Glamaholic.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Glamaholic", "Glamaholic\Glamaholic.csproj", "{83E74102-7DAC-4789-911B-C1FB9BBCC6AC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {83E74102-7DAC-4789-911B-C1FB9BBCC6AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83E74102-7DAC-4789-911B-C1FB9BBCC6AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83E74102-7DAC-4789-911B-C1FB9BBCC6AC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83E74102-7DAC-4789-911B-C1FB9BBCC6AC}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Glamaholic/Commands.cs b/Glamaholic/Commands.cs new file mode 100755 index 0000000..6c45810 --- /dev/null +++ b/Glamaholic/Commands.cs @@ -0,0 +1,24 @@ +using System; +using Dalamud.Game.Command; + +namespace Glamaholic { + internal class Commands : IDisposable { + private Plugin Plugin { get; } + + internal Commands(Plugin plugin) { + this.Plugin = plugin; + + this.Plugin.CommandManager.AddHandler("/glamaholic", new CommandInfo(this.OnCommand) { + HelpMessage = $"Toggle visibility of the {this.Plugin.Name} window", + }); + } + + public void Dispose() { + this.Plugin.CommandManager.RemoveHandler("/glamaholic"); + } + + private void OnCommand(string command, string arguments) { + this.Plugin.Ui.ToggleMainInterface(); + } + } +} diff --git a/Glamaholic/Configuration.cs b/Glamaholic/Configuration.cs new file mode 100755 index 0000000..cc2f1ae --- /dev/null +++ b/Glamaholic/Configuration.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dalamud.Configuration; + +namespace Glamaholic { + [Serializable] + internal class Configuration : IPluginConfiguration { + public int Version { get; set; } = 1; + + public List Plates { get; init; } = new(); + public bool ShowEditorMenu = true; + public bool ShowExamineMenu = true; + } + + [Serializable] + internal class SavedPlate { + public string Name { get; set; } + public Dictionary Items { get; init; } = new(); + + public SavedPlate(string name) { + this.Name = name; + } + + internal SavedPlate Clone() { + return new SavedPlate(this.Name) { + Items = this.Items.ToDictionary(entry => entry.Key, entry => entry.Value.Clone()), + }; + } + } + + [Serializable] + internal class SavedGlamourItem { + public uint ItemId { get; set; } + public byte StainId { get; set; } + + internal SavedGlamourItem Clone() { + return new SavedGlamourItem() { + ItemId = this.ItemId, + StainId = this.StainId, + }; + } + } +} diff --git a/Glamaholic/GameFunctions.cs b/Glamaholic/GameFunctions.cs new file mode 100755 index 0000000..54460ca --- /dev/null +++ b/Glamaholic/GameFunctions.cs @@ -0,0 +1,379 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.InteropServices; +using Dalamud.Memory; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Lumina.Excel.GeneratedSheets; + +namespace Glamaholic { + internal class GameFunctions { + private static class Signatures { + internal const string SetGlamourPlateSlot = "E8 ?? ?? ?? ?? E9 ?? ?? ?? ?? 48 8B 46 10"; + internal const string ModifyGlamourPlateSlot = "48 89 74 24 ?? 57 48 83 EC 20 80 79 30 00"; + internal const string IsInArmoire = "E8 ?? ?? ?? ?? 84 C0 74 16 8B CB"; + internal const string ArmoirePointer = "48 8D 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 84 C0 74 16 8B CB E8"; + internal const string TryOn = "E8 ?? ?? ?? ?? EB 35 BA"; + internal const string ExamineNamePointer = "48 8D 05 ?? ?? ?? ?? 48 89 85 ?? ?? ?? ?? 74 56 49 8B 4F"; + } + + internal delegate void SetGlamourPlateSlotDelegate(IntPtr agent, MirageSource mirageSource, int glamId, uint itemId, byte stainId); + + internal delegate void ModifyGlamourPlateSlotDelegate(IntPtr agent, PlateSlot slot, byte stainId, IntPtr numbers, int stainItemId); + + internal delegate byte IsInArmoireDelegate(IntPtr armoire, int index); + + private delegate byte TryOnDelegate(uint unknownCanEquip, uint itemBaseId, ulong stainColor, uint itemGlamourId, byte unknownByte); + + private Plugin Plugin { get; } + + private readonly SetGlamourPlateSlotDelegate _setGlamourPlateSlot; + private readonly ModifyGlamourPlateSlotDelegate _modifyGlamourPlateSlot; + private readonly IsInArmoireDelegate _isInArmoire; + private readonly IntPtr _armoirePtr; + private readonly TryOnDelegate _tryOn; + private readonly IntPtr _examineNamePtr; + + internal GameFunctions(Plugin plugin) { + this.Plugin = plugin; + + this._setGlamourPlateSlot = Marshal.GetDelegateForFunctionPointer(this.Plugin.SigScanner.ScanText(Signatures.SetGlamourPlateSlot)); + this._modifyGlamourPlateSlot = Marshal.GetDelegateForFunctionPointer(this.Plugin.SigScanner.ScanText(Signatures.ModifyGlamourPlateSlot)); + this._isInArmoire = Marshal.GetDelegateForFunctionPointer(this.Plugin.SigScanner.ScanText(Signatures.IsInArmoire)); + this._armoirePtr = this.Plugin.SigScanner.GetStaticAddressFromSig(Signatures.ArmoirePointer); + this._tryOn = Marshal.GetDelegateForFunctionPointer(this.Plugin.SigScanner.ScanText(Signatures.TryOn)); + this._examineNamePtr = this.Plugin.SigScanner.GetStaticAddressFromSig(Signatures.ExamineNamePointer); + } + + internal unsafe bool ArmoireLoaded => *(byte*) this._armoirePtr > 0; + + internal string? ExamineName => this._examineNamePtr == IntPtr.Zero + ? null + : MemoryHelper.ReadStringNullTerminated(this._examineNamePtr); + + internal static unsafe List DresserContents { + get { + var list = new List(); + + var agents = Framework.Instance()->GetUiModule()->GetAgentModule(); + var dresserAgent = agents->GetAgentByInternalId(AgentId.MiragePrismPrismBox); + + var itemsStart = *(IntPtr*) ((IntPtr) dresserAgent + 0x28); + if (itemsStart == IntPtr.Zero) { + return list; + } + + for (var i = 0; i < 400; i++) { + var glamItem = *(GlamourItem*) (itemsStart + i * 32); + if (glamItem.ItemId == 0) { + continue; + } + + list.Add(glamItem); + } + + return list; + } + } + + internal static unsafe Dictionary? CurrentPlate { + get { + var agent = EditorAgent; + if (agent == null) { + return null; + } + + var editorInfo = *(IntPtr*) ((IntPtr) agent + 0x28); + if (editorInfo == IntPtr.Zero) { + return null; + } + + var plate = new Dictionary(); + foreach (var slot in (PlateSlot[]) Enum.GetValues(typeof(PlateSlot))) { + // from SetGlamourPlateSlot + var itemId = *(uint*) (editorInfo + 44 * (int) slot + 7956); + var stainId = *(byte*) (editorInfo + 44 * (int) slot + 7980); + + if (itemId == 0) { + continue; + } + + plate[slot] = new SavedGlamourItem { + ItemId = itemId, + StainId = stainId, + }; + } + + return plate; + } + } + + private static unsafe AgentInterface* EditorAgent => Framework.Instance()->GetUiModule()->GetAgentModule()->GetAgentByInternalId((AgentId) 293); + + internal unsafe void SetGlamourPlateSlot(MirageSource source, int glamId, uint itemId, byte stainId) { + this._setGlamourPlateSlot((IntPtr) EditorAgent, source, glamId, itemId, stainId); + } + + internal unsafe void ModifyGlamourPlateSlot(PlateSlot slot, byte stainId, IntPtr numbers, int stainItemId) { + this._modifyGlamourPlateSlot((IntPtr) EditorAgent, slot, stainId, numbers, stainItemId); + } + + internal bool IsInArmoire(uint itemId) { + var row = this.Plugin.DataManager.GetExcelSheet()!.FirstOrDefault(row => row.Item.Row == itemId); + if (row == null) { + return false; + } + + return this._isInArmoire(this._armoirePtr, (int) row.RowId) != 0; + } + + internal uint? ArmoireIndexIfPresent(uint itemId) { + var row = this.Plugin.DataManager.GetExcelSheet()!.FirstOrDefault(row => row.Item.Row == itemId); + if (row == null) { + return null; + } + + var isInArmoire = this._isInArmoire(this._armoirePtr, (int) row.RowId) != 0; + return isInArmoire + ? row.RowId + : null; + } + + internal unsafe void LoadPlate(SavedPlate plate) { + var agent = EditorAgent; + if (agent == null) { + return; + } + + var editorInfo = *(IntPtr*) ((IntPtr) agent + 0x28); + if (editorInfo == IntPtr.Zero) { + return; + } + + var dresser = DresserContents; + var current = CurrentPlate; + var usedStains = new Dictionary<(uint, uint), uint>(); + + var slotPtr = (PlateSlot*) (editorInfo + 0x18); + var initialSlot = *slotPtr; + foreach (var (slot, item) in plate.Items) { + if (current != null && current.TryGetValue(slot, out var currentItem)) { + if (currentItem.ItemId == item.ItemId && currentItem.StainId == item.StainId) { + // ignore already-correct items + continue; + } + } + + var source = MirageSource.GlamourDresser; + var info = (0, 0u, (byte) 0); + // find an item in the dresser that matches + var matchingIds = dresser.FindAll(mirage => mirage.ItemId % 1_000_000 == item.ItemId); + if (matchingIds.Count == 0) { + // if not in the glamour dresser, look in the armoire + if (this.ArmoireIndexIfPresent(item.ItemId) is { } armoireIdx) { + source = MirageSource.Armoire; + info = ((int) armoireIdx, item.ItemId, 0); + } + } else { + // try to find an item with a matching stain + var idx = matchingIds.FindIndex(mirage => mirage.StainId == item.StainId); + if (idx == -1) { + idx = 0; + } + + var mirage = matchingIds[idx]; + info = ((int) mirage.Index, mirage.ItemId, mirage.StainId); + } + + if (info.Item1 == 0) { + continue; + } + + *slotPtr = slot; + this._setGlamourPlateSlot( + (IntPtr) agent, + source, + info.Item1, + info.Item2, + info.Item3 + ); + + if (item.StainId != info.Item3) { + // mirage in dresser did not have stain for this item, so apply it + this.ApplyStain(agent, slot, item, usedStains); + } + } + + // restore initial slot, since changing this does not update the ui + *slotPtr = initialSlot; + } + + private static readonly InventoryType[] PlayerInventories = { + InventoryType.Inventory1, + InventoryType.Inventory2, + InventoryType.Inventory3, + InventoryType.Inventory4, + }; + + private unsafe void ApplyStain(AgentInterface* editorAgent, PlateSlot slot, SavedGlamourItem item, Dictionary<(uint, uint), uint> usedStains) { + // find the dye for this stain in the player's inventory + var inventory = InventoryManager.Instance(); + var transient = this.Plugin.DataManager.GetExcelSheet()!.GetRow(item.StainId); + (int itemId, int qty, int inv, int slot) dyeInfo = (0, 0, -1, 0); + var items = new[] { transient?.Item1?.Value, transient?.Item2?.Value }; + foreach (var dyeItem in items) { + if (dyeItem == null || dyeItem.RowId == 0) { + continue; + } + + if (dyeInfo.itemId == 0) { + // use the first one (free one) as placeholder + dyeInfo.itemId = (int) dyeItem.RowId; + } + + foreach (var type in PlayerInventories) { + var inv = inventory->GetInventoryContainer(type); + if (inv == null) { + continue; + } + + for (var i = 0; i < inv->Size; i++) { + var address = ((uint) type, (uint) i); + var invItem = inv->Items[i]; + if (invItem.ItemID != dyeItem.RowId) { + continue; + } + + if (usedStains.TryGetValue(address, out var numUsed) && numUsed >= invItem.Quantity) { + continue; + } + + // first one that we find in the inventory is the one we'll use + dyeInfo = ((int) dyeItem.RowId, (int) inv->Items[i].Quantity, (int) type, i); + if (usedStains.ContainsKey(address)) { + usedStains[address] += 1; + } else { + usedStains[address] = 1; + } + goto NoBreakLabels; + } + } + + NoBreakLabels: + { + } + } + + // do nothing if there is no dye item found + if (dyeInfo.itemId == 0) { + return; + } + + var info = new ColorantInfo((uint) dyeInfo.inv, (ushort) dyeInfo.slot, (uint) dyeInfo.itemId, (uint) dyeInfo.qty); + + // allocate 24 bytes to store dye info if we have the dye + var mem = dyeInfo.inv == -1 + ? IntPtr.Zero + : Marshal.AllocHGlobal(24); + + if (mem != IntPtr.Zero) { + *(ColorantInfo*) mem = info; + } + + this._modifyGlamourPlateSlot( + (IntPtr) editorAgent, + slot, + item.StainId, + mem, + dyeInfo.Item1 + ); + + if (mem != IntPtr.Zero) { + Marshal.FreeHGlobal(mem); + } + } + + internal void TryOn(uint itemId, byte stainId) { + this._tryOn(0xFF, itemId % 1_000_000, stainId, 0, 0); + } + } + + internal enum MirageSource { + GlamourDresser = 1, + Armoire = 2, + } + + internal enum PlateSlot : uint { + MainHand = 0, + OffHand = 1, + Head = 2, + Body = 3, + Hands = 4, + Legs = 5, + Feet = 6, + Ears = 7, + Neck = 8, + Wrists = 9, + RightRing = 10, + LeftRing = 11, + } + + internal static class PlateSlotExt { + internal static string Name(this PlateSlot slot) { + return slot switch { + PlateSlot.MainHand => "Main Hand", + PlateSlot.OffHand => "Off Hand", + PlateSlot.Head => "Head", + PlateSlot.Body => "Body", + PlateSlot.Hands => "Hands", + PlateSlot.Legs => "Legs", + PlateSlot.Feet => "Feet", + PlateSlot.Ears => "Ears", + PlateSlot.Neck => "Neck", + PlateSlot.Wrists => "Wrists", + PlateSlot.RightRing => "Right Ring", + PlateSlot.LeftRing => "Left Ring", + _ => throw new ArgumentOutOfRangeException(nameof(slot), slot, null), + }; + } + } + + [StructLayout(LayoutKind.Explicit, Size = 32)] + internal readonly struct GlamourItem { + [FieldOffset(4)] + internal readonly uint Index; + + [FieldOffset(8)] + internal readonly uint ItemId; + + [FieldOffset(26)] + internal readonly byte StainId; + } + + [StructLayout(LayoutKind.Sequential)] + [SuppressMessage("ReSharper", "PrivateFieldCanBeConvertedToLocalVariable")] + internal readonly struct ColorantInfo { + private readonly uint InventoryId; + private readonly ushort InventorySlot; + private readonly byte Unk3; + private readonly byte Unk4; + private readonly uint StainItemId; + private readonly uint StainItemCount; + private readonly ulong Unk7; + + internal ColorantInfo(uint inventoryId, ushort inventorySlot, uint stainItemId, uint stainItemCount) { + this.InventoryId = inventoryId; + this.InventorySlot = inventorySlot; + this.StainItemId = stainItemId; + this.StainItemCount = stainItemCount; + + this.Unk3 = 0; + this.Unk4 = 0; + this.Unk7 = 0; + } + } +} diff --git a/Glamaholic/Glamaholic.csproj b/Glamaholic/Glamaholic.csproj new file mode 100755 index 0000000..e2396f5 --- /dev/null +++ b/Glamaholic/Glamaholic.csproj @@ -0,0 +1,63 @@ + + + + net5.0-windows + 1.3.0 + true + enable + false + true + + D0008 + + + + + $(AppData)\XIVLauncher\addon\Hooks\dev + + + + $(HOME)/dalamud + + + + + $(Dalamud)\Dalamud.dll + false + + + $(Dalamud)\FFXIVClientStructs.dll + false + + + $(Dalamud)\ImGui.NET.dll + false + + + $(Dalamud)\ImGuiScene.dll + false + + + $(Dalamud)\Lumina.dll + false + + + $(Dalamud)\Lumina.Excel.dll + false + + + $(Dalamud)\Newtonsoft.Json.dll + false + + + + + + + + + + + + + diff --git a/Glamaholic/Glamaholic.yaml b/Glamaholic/Glamaholic.yaml new file mode 100755 index 0000000..f5aad75 --- /dev/null +++ b/Glamaholic/Glamaholic.yaml @@ -0,0 +1,8 @@ +name: Glamaholic +author: ascclemens +description: | + Create and save as many glamour plates as you want. Activate up to 15 of them + at once at the Glamour Dresser. Supports exporting and importing plates for + easy sharing. +punchline: Save and swap your glamour plates. +repo_url: https://git.sr.ht/~jkcclemens/Glamaholic diff --git a/Glamaholic/Plugin.cs b/Glamaholic/Plugin.cs new file mode 100755 index 0000000..902bbf1 --- /dev/null +++ b/Glamaholic/Plugin.cs @@ -0,0 +1,52 @@ +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.Game.Command; +using Dalamud.Game.Gui; +using Dalamud.IoC; +using Dalamud.Plugin; + +namespace Glamaholic { + // ReSharper disable once ClassNeverInstantiated.Global + public class Plugin : IDalamudPlugin { + public string Name => "Glamaholic"; + + [PluginService] + internal DalamudPluginInterface Interface { get; init; } + + [PluginService] + internal CommandManager CommandManager { get; init; } + + [PluginService] + internal DataManager DataManager { get; init; } + + [PluginService] + internal GameGui GameGui { get; init; } + + [PluginService] + internal SigScanner SigScanner { get; init; } + + internal Configuration Config { get; } + internal GameFunctions Functions { get; } + internal PluginUi Ui { get; } + private Commands Commands { get; } + + #pragma warning disable 8618 + public Plugin() { + this.Config = this.Interface!.GetPluginConfig() as Configuration ?? new Configuration(); + + this.Functions = new GameFunctions(this); + this.Ui = new PluginUi(this); + this.Commands = new Commands(this); + } + #pragma warning restore 8618 + + public void Dispose() { + this.Commands.Dispose(); + this.Ui.Dispose(); + } + + internal void SaveConfig() { + this.Interface.SavePluginConfig(this.Config); + } + } +} diff --git a/Glamaholic/PluginUi.cs b/Glamaholic/PluginUi.cs new file mode 100755 index 0000000..53357a5 --- /dev/null +++ b/Glamaholic/PluginUi.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using Glamaholic.Ui; +using Glamaholic.Ui.Helpers; +using ImGuiScene; + +namespace Glamaholic { + internal class PluginUi : IDisposable { + internal Plugin Plugin { get; } + + private Dictionary Icons { get; } = new(); + + private MainInterface MainInterface { get; } + private EditorHelper EditorHelper { get; } + private ExamineHelper ExamineHelper { get; } + + internal PluginUi(Plugin plugin) { + this.Plugin = plugin; + + this.MainInterface = new MainInterface(this); + this.EditorHelper = new EditorHelper(this); + this.ExamineHelper = new ExamineHelper(this); + + this.Plugin.Interface.UiBuilder.Draw += this.Draw; + this.Plugin.Interface.UiBuilder.OpenConfigUi += this.OpenMainInterface; + } + + public void Dispose() { + this.Plugin.Interface.UiBuilder.OpenConfigUi -= this.OpenMainInterface; + this.Plugin.Interface.UiBuilder.Draw -= this.Draw; + + foreach (var icon in this.Icons.Values) { + icon.Dispose(); + } + } + + internal void OpenMainInterface() { + this.MainInterface.Open(); + } + + internal void ToggleMainInterface() { + this.MainInterface.Toggle(); + } + + internal TextureWrap? GetIcon(ushort id) { + if (this.Icons.TryGetValue(id, out var cached)) { + return cached; + } + + var icon = this.Plugin.DataManager.GetImGuiTextureIcon(id); + if (icon == null) { + return null; + } + + this.Icons[id] = icon; + return icon; + } + + private void Draw() { + this.MainInterface.Draw(); + this.EditorHelper.Draw(); + this.ExamineHelper.Draw(); + } + + internal void SwitchPlate(int idx, bool scrollTo = false) { + this.MainInterface.SwitchPlate(idx, scrollTo); + } + } +} diff --git a/Glamaholic/Ui/Helpers/EditorHelper.cs b/Glamaholic/Ui/Helpers/EditorHelper.cs new file mode 100755 index 0000000..510cdb8 --- /dev/null +++ b/Glamaholic/Ui/Helpers/EditorHelper.cs @@ -0,0 +1,49 @@ +using Dalamud.Interface; +using FFXIVClientStructs.FFXIV.Component.GUI; +using ImGuiNET; + +namespace Glamaholic.Ui.Helpers { + internal class EditorHelper { + private PluginUi Ui { get; } + + internal EditorHelper(PluginUi ui) { + this.Ui = ui; + } + + internal unsafe void Draw() { + if (!this.Ui.Plugin.Config.ShowEditorMenu || !Util.IsEditingPlate(this.Ui.Plugin.GameGui)) { + return; + } + + var addon = (AtkUnitBase*) this.Ui.Plugin.GameGui.GetAddonByName(Util.PlateAddon, 1); + // ReSharper disable once ConditionIsAlwaysTrueOrFalse + if (addon != null && addon->IsVisible) { + this.DrawInner(addon); + } + } + + private unsafe void DrawInner(AtkUnitBase* addon) { + var drawPos = HelperUtil.DrawPosForAddon(addon); + if (drawPos == null) { + return; + } + + ImGui.SetNextWindowPos(drawPos.Value); + if (!ImGui.Begin("##glamaholic-helper-open", HelperUtil.HelperWindowFlags)) { + ImGui.End(); + return; + } + + ImGui.SetNextItemWidth(ImGui.CalcTextSize(this.Ui.Plugin.Name).X + ImGui.GetStyle().ItemInnerSpacing.X * 2 + 32 * ImGuiHelpers.GlobalScale); + if (ImGui.BeginCombo("##glamaholic-helper-examine-combo", this.Ui.Plugin.Name)) { + if (ImGui.Selectable($"Open {this.Ui.Plugin.Name}")) { + this.Ui.OpenMainInterface(); + } + + ImGui.EndCombo(); + } + + ImGui.End(); + } + } +} diff --git a/Glamaholic/Ui/Helpers/ExamineHelper.cs b/Glamaholic/Ui/Helpers/ExamineHelper.cs new file mode 100755 index 0000000..8836ca3 --- /dev/null +++ b/Glamaholic/Ui/Helpers/ExamineHelper.cs @@ -0,0 +1,89 @@ +using Dalamud.Interface; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Component.GUI; +using ImGuiNET; + +namespace Glamaholic.Ui.Helpers { + internal class ExamineHelper { + private PluginUi Ui { get; } + + internal ExamineHelper(PluginUi ui) { + this.Ui = ui; + } + + internal unsafe void Draw() { + if (!this.Ui.Plugin.Config.ShowExamineMenu) { + return; + } + + var examineAddon = (AtkUnitBase*) this.Ui.Plugin.GameGui.GetAddonByName("CharacterInspect", 1); + // ReSharper disable once ConditionIsAlwaysTrueOrFalse + if (examineAddon != null && examineAddon->IsVisible) { + this.DrawInner(examineAddon); + } + } + + private unsafe void DrawInner(AtkUnitBase* addon) { + var drawPos = HelperUtil.DrawPosForAddon(addon); + if (drawPos == null) { + return; + } + + ImGui.SetNextWindowPos(drawPos.Value); + if (!ImGui.Begin("##glamaholic-helper-examine", HelperUtil.HelperWindowFlags)) { + ImGui.End(); + return; + } + + ImGui.SetNextItemWidth(ImGui.CalcTextSize(this.Ui.Plugin.Name).X + ImGui.GetStyle().ItemInnerSpacing.X * 2 + 32 * ImGuiHelpers.GlobalScale); + if (ImGui.BeginCombo("##glamaholic-helper-examine-combo", this.Ui.Plugin.Name)) { + if (ImGui.Selectable("Create glamour plate")) { + void DoIt() { + var inventory = InventoryManager.Instance()->GetInventoryContainer(InventoryType.Examine); + if (inventory == null) { + return; + } + + var name = this.Ui.Plugin.Functions.ExamineName; + if (string.IsNullOrEmpty(name)) { + name = "Copied glamour"; + } + + var plate = new SavedPlate(name); + for (var i = 0; i < inventory->Size; i++) { + var item = inventory->Items[i]; + var itemId = item.GlamourID; + if (itemId == 0) { + itemId = item.ItemID; + } + + if (itemId == 0) { + continue; + } + + var stainId = item.Stain; + + // TODO: remove this logic in endwalker + var slot = i > 5 ? i - 1 : i; + plate.Items[(PlateSlot) slot] = new SavedGlamourItem { + ItemId = itemId, + StainId = stainId, + }; + } + + this.Ui.Plugin.Config.Plates.Add(plate); + this.Ui.Plugin.SaveConfig(); + this.Ui.OpenMainInterface(); + this.Ui.SwitchPlate(this.Ui.Plugin.Config.Plates.Count - 1, true); + } + + DoIt(); + } + + ImGui.EndCombo(); + } + + ImGui.End(); + } + } +} diff --git a/Glamaholic/Ui/Helpers/HelperUtil.cs b/Glamaholic/Ui/Helpers/HelperUtil.cs new file mode 100755 index 0000000..5a7a7c8 --- /dev/null +++ b/Glamaholic/Ui/Helpers/HelperUtil.cs @@ -0,0 +1,36 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using ImGuiNET; + +namespace Glamaholic.Ui.Helpers { + internal static class HelperUtil { + internal const ImGuiWindowFlags HelperWindowFlags = ImGuiWindowFlags.NoBackground + | ImGuiWindowFlags.NoDecoration + | ImGuiWindowFlags.NoCollapse + | ImGuiWindowFlags.NoTitleBar + | ImGuiWindowFlags.NoNav + | ImGuiWindowFlags.NoNavFocus + | ImGuiWindowFlags.NoNavInputs + | ImGuiWindowFlags.NoResize + | ImGuiWindowFlags.NoScrollbar + | ImGuiWindowFlags.NoSavedSettings + | ImGuiWindowFlags.NoFocusOnAppearing + | ImGuiWindowFlags.AlwaysAutoResize; + + internal static unsafe Vector2? DrawPosForAddon(AtkUnitBase* addon) { + if (addon == null) { + return null; + } + + var root = addon->RootNode; + if (root == null) { + return null; + } + + return new Vector2(addon->X, addon->Y) + - new Vector2(0, ImGui.CalcTextSize("A").Y) + - new Vector2(0, ImGui.GetStyle().ItemInnerSpacing.Y * 2) + - new Vector2(0, ImGui.GetStyle().CellPadding.Y * 2); + } + } +} diff --git a/Glamaholic/Ui/MainInterface.cs b/Glamaholic/Ui/MainInterface.cs new file mode 100755 index 0000000..f9c36f7 --- /dev/null +++ b/Glamaholic/Ui/MainInterface.cs @@ -0,0 +1,640 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Numerics; +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using ImGuiNET; +using Lumina.Excel.GeneratedSheets; +using Newtonsoft.Json; + +namespace Glamaholic.Ui { + internal class MainInterface { + private static readonly PlateSlot[] LeftSide = { + PlateSlot.MainHand, + PlateSlot.Head, + PlateSlot.Body, + PlateSlot.Hands, + PlateSlot.Legs, + PlateSlot.Feet, + }; + + private static readonly PlateSlot[] RightSide = { + PlateSlot.OffHand, + PlateSlot.Ears, + PlateSlot.Neck, + PlateSlot.Wrists, + PlateSlot.RightRing, + PlateSlot.LeftRing, + }; + + private PluginUi Ui { get; } + private List Items { get; } + private List FilteredItems { get; set; } + + private bool _visible; + private string _plateName = string.Empty; + private int _dragging = -1; + private int _selectedPlate = -1; + private bool _scrollToSelected; + private string _plateFilter = string.Empty; + private bool _showRename; + private string _renameInput = string.Empty; + private string _importInput = string.Empty; + private Exception? _importError; + private bool _deleteConfirm; + private readonly Stopwatch _shareTimer = new(); + private bool _editing; + private SavedPlate? _editingPlate; + private string _itemFilter = string.Empty; + private string _dyeFilter = string.Empty; + + internal MainInterface(PluginUi ui) { + this.Ui = ui; + + // get all equippable items that aren't soul crystals + this.Items = this.Ui.Plugin.DataManager.GetExcelSheet()! + .Where(row => row.EquipSlotCategory.Row is not 0 && row.EquipSlotCategory.Value!.SoulCrystal == 0) + .ToList(); + this.FilteredItems = this.Items; + } + + internal void Open() { + this._visible = true; + } + + internal void Toggle() { + this._visible ^= true; + } + + internal void Draw() { + this.HandleTimers(); + + if (!this._visible) { + return; + } + + ImGui.SetNextWindowSize(new Vector2(415, 650), ImGuiCond.FirstUseEver); + + if (!ImGui.Begin(this.Ui.Plugin.Name, ref this._visible, ImGuiWindowFlags.MenuBar)) { + ImGui.End(); + return; + } + + this.DrawInner(); + + ImGui.End(); + } + + private void DrawMenuBar() { + if (!ImGui.BeginMenuBar()) { + return; + } + + if (ImGui.BeginMenu("Plates")) { + if (ImGui.MenuItem("New")) { + this.Ui.Plugin.Config.Plates.Add(new SavedPlate("Untitled Plate")); + this.Ui.Plugin.SaveConfig(); + this.SwitchPlate(this.Ui.Plugin.Config.Plates.Count - 1, true); + } + + if (ImGui.BeginMenu("Add from current plate")) { + if (Util.DrawTextInput("current-name", ref this._plateName, message: "Input name and press Enter to save.")) { + var current = GameFunctions.CurrentPlate; + if (current != null) { + var plate = new SavedPlate(this._plateName) { + Items = current, + }; + + this.Ui.Plugin.Config.Plates.Add(plate); + + this._plateName = string.Empty; + this.Ui.Plugin.SaveConfig(); + } + } + + if (ImGui.IsWindowAppearing()) { + ImGui.SetKeyboardFocusHere(); + } + + ImGui.EndMenu(); + } + + if (ImGui.BeginMenu("Import")) { + if (Util.DrawTextInput("import-input", ref this._importInput, 2048, "Press Enter to import.")) { + try { + var plate = JsonConvert.DeserializeObject(this._importInput); + this._importError = null; + if (plate != null) { + this.Ui.Plugin.Config.Plates.Add(plate); + this.Ui.Plugin.SaveConfig(); + } + } catch (Exception ex) { + this._importError = ex; + } + } + + if (ImGui.IsWindowAppearing()) { + this._importError = null; + ImGui.SetKeyboardFocusHere(); + } + + if (this._importError != null) { + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudRed); + Util.TextUnformattedWrapped(this._importError.Message); + ImGui.PopStyleColor(); + } + + ImGui.EndMenu(); + } + + ImGui.EndMenu(); + } + + var anyChanged = false; + if (ImGui.BeginMenu("Settings")) { + anyChanged |= ImGui.MenuItem("Show plate editor menu", null, ref this.Ui.Plugin.Config.ShowEditorMenu); + anyChanged |= ImGui.MenuItem("Show examine window menu", null, ref this.Ui.Plugin.Config.ShowExamineMenu); + + ImGui.EndMenu(); + } + + if (anyChanged) { + this.Ui.Plugin.SaveConfig(); + } + + ImGui.EndMenuBar(); + } + + private void DrawPlateList() { + if (!ImGui.BeginChild("plate list", new Vector2(205 * ImGuiHelpers.GlobalScale, 0), true)) { + return; + } + + ImGui.SetNextItemWidth(-1); + ImGui.InputText("##plate-filter", ref this._plateFilter, 512, ImGuiInputTextFlags.AutoSelectAll); + + var filter = this._plateFilter.ToLowerInvariant(); + (int src, int dst)? drag = null; + if (ImGui.BeginChild("plate list actual", Vector2.Zero, false, ImGuiWindowFlags.HorizontalScrollbar)) { + for (var i = 0; i < this.Ui.Plugin.Config.Plates.Count; i++) { + var plate = this.Ui.Plugin.Config.Plates[i]; + + if (filter.Length != 0 && !plate.Name.ToLowerInvariant().Contains(filter)) { + continue; + } + + int? switchTo = null; + if (ImGui.Selectable($"{plate.Name}##{i}", this._selectedPlate == i)) { + switchTo = i; + } + + if (this._scrollToSelected && this._selectedPlate == i) { + this._scrollToSelected = false; + ImGui.SetScrollHereY(1f); + } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) { + switchTo = -1; + } + + if (ImGui.IsItemHovered()) { + ImGui.PushFont(UiBuilder.IconFont); + var deleteWidth = ImGui.CalcTextSize(FontAwesomeIcon.Times.ToIconString()).X; + ImGui.SameLine(ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemInnerSpacing.X * 2 - deleteWidth); + ImGui.TextUnformatted(FontAwesomeIcon.Times.ToIconString()); + ImGui.PopFont(); + + var mouseDown = ImGui.IsMouseDown(ImGuiMouseButton.Left); + var mouseClicked = ImGui.IsMouseReleased(ImGuiMouseButton.Left); + if (ImGui.IsItemHovered() || mouseDown) { + if (mouseClicked) { + switchTo = null; + + if (this._deleteConfirm) { + this._deleteConfirm = false; + if (this._selectedPlate == i) { + switchTo = -1; + } + + this.Ui.Plugin.Config.Plates.RemoveAt(i); + this.Ui.Plugin.SaveConfig(); + } else { + this._deleteConfirm = true; + } + } + } else { + this._deleteConfirm = false; + } + + if (this._deleteConfirm) { + ImGui.BeginTooltip(); + ImGui.TextUnformatted("Click delete again to confirm."); + ImGui.EndTooltip(); + } + } + + if (switchTo != null) { + this.SwitchPlate(switchTo.Value); + } + + // handle dragging + if (this._plateFilter.Length == 0 && ImGui.IsItemActive() || this._dragging == i) { + this._dragging = i; + var step = 0; + if (ImGui.GetIO().MouseDelta.Y < 0 && ImGui.GetMousePos().Y < ImGui.GetItemRectMin().Y) { + step = -1; + } + + if (ImGui.GetIO().MouseDelta.Y > 0 && ImGui.GetMousePos().Y > ImGui.GetItemRectMax().Y) { + step = 1; + } + + if (step != 0) { + drag = (i, i + step); + } + } + } + + if (!ImGui.IsMouseDown(ImGuiMouseButton.Left) && this._dragging != -1) { + this._dragging = -1; + this.Ui.Plugin.SaveConfig(); + } + + if (drag != null && drag.Value.dst < this.Ui.Plugin.Config.Plates.Count && drag.Value.dst >= 0) { + this._dragging = drag.Value.dst; + // ReSharper disable once SwapViaDeconstruction + var temp = this.Ui.Plugin.Config.Plates[drag.Value.src]; + this.Ui.Plugin.Config.Plates[drag.Value.src] = this.Ui.Plugin.Config.Plates[drag.Value.dst]; + this.Ui.Plugin.Config.Plates[drag.Value.dst] = temp; + + // do not SwitchPlate, because this is technically not a switch + if (this._selectedPlate == drag.Value.dst) { + var step = drag.Value.dst - drag.Value.src; + this._selectedPlate = drag.Value.dst - step; + } else if (this._selectedPlate == drag.Value.src) { + this._selectedPlate = drag.Value.dst; + } + } + + ImGui.EndChild(); + } + + ImGui.EndChild(); + } + + private void DrawDyePopup(string dyePopup, SavedGlamourItem mirage) { + if (!ImGui.BeginPopup(dyePopup)) { + return; + } + + ImGui.PushItemWidth(-1); + ImGui.InputText("##dye-filter", ref this._dyeFilter, 512); + + if (ImGui.IsWindowAppearing()) { + ImGui.SetKeyboardFocusHere(); + } + + if (ImGui.BeginChild("dye picker", new Vector2(250, 350), false, ImGuiWindowFlags.HorizontalScrollbar)) { + if (ImGui.Selectable("None", mirage.StainId == 0)) { + mirage.StainId = 0; + ImGui.CloseCurrentPopup(); + } + + var filter = this._dyeFilter.ToLowerInvariant(); + + foreach (var stain in this.Ui.Plugin.DataManager.GetExcelSheet()!) { + if (stain.RowId == 0 || stain.Shade == 0) { + continue; + } + + if (filter.Length > 0 && !stain.Name.RawString.ToLowerInvariant().Contains(filter)) { + continue; + } + + if (ImGui.Selectable($"{stain.Name}##{stain.RowId}", mirage.StainId == stain.RowId)) { + mirage.StainId = (byte) stain.RowId; + ImGui.CloseCurrentPopup(); + } + } + + ImGui.EndChild(); + } + + ImGui.EndPopup(); + } + + private unsafe void DrawItemPopup(string itemPopup, SavedPlate plate, PlateSlot slot) { + if (!ImGui.BeginPopup(itemPopup)) { + return; + } + + ImGui.SetNextItemWidth(-1); + if (ImGui.InputText("##item-filter", ref this._itemFilter, 512, ImGuiInputTextFlags.AutoSelectAll)) { + this.FilterItems(slot); + } + + if (ImGui.IsWindowAppearing()) { + ImGui.SetKeyboardFocusHere(); + } + + if (ImGui.BeginChild("item search", new Vector2(250, 450), false, ImGuiWindowFlags.HorizontalScrollbar)) { + var id = 0u; + if (plate.Items.TryGetValue(slot, out var slotMirage)) { + id = slotMirage.ItemId; + } + + if (ImGui.Selectable("None", id == 0)) { + plate.Items.Remove(slot); + ImGui.CloseCurrentPopup(); + } + + var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper_ImGuiListClipper()); + + clipper.Begin(this.FilteredItems.Count); + while (clipper.Step()) { + for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) { + var item = this.FilteredItems[i]; + + if (ImGui.Selectable($"{item.Name}##{item.RowId}", item.RowId == id)) { + if (!plate.Items.ContainsKey(slot)) { + plate.Items[slot] = new SavedGlamourItem(); + } + + plate.Items[slot].ItemId = item.RowId; + if (!item.IsDyeable) { + plate.Items[slot].StainId = 0; + } + + ImGui.CloseCurrentPopup(); + } + } + } + + ImGui.EndChild(); + } + + ImGui.EndPopup(); + } + + private unsafe void DrawIcon(PlateSlot slot, SavedPlate plate, bool editingPlate, int iconSize, int paddingSize) { + var drawCursor = ImGui.GetCursorScreenPos(); + var tooltip = slot.Name(); + ImGui.BeginGroup(); + + plate.Items.TryGetValue(slot, out var mirage); + + var borderColour = *ImGui.GetStyleColorVec4(ImGuiCol.Border); + + // check for item + if (mirage != null && editingPlate) { + var has = GameFunctions.DresserContents.Any(saved => saved.ItemId % 1_000_000 == mirage.ItemId) || this.Ui.Plugin.Functions.IsInArmoire(mirage.ItemId); + if (!has) { + borderColour = ImGuiColors.DalamudYellow; + } + } + + ImGui.GetWindowDrawList().AddRect(drawCursor, drawCursor + new Vector2(iconSize + paddingSize), ImGui.ColorConvertFloat4ToU32(borderColour)); + + var cursorBefore = ImGui.GetCursorPos(); + ImGui.InvisibleButton($"preview {slot}", new Vector2(iconSize + paddingSize)); + var cursorAfter = ImGui.GetCursorPos(); + + if (mirage != null) { + var item = this.Ui.Plugin.DataManager.GetExcelSheet()!.GetRow(mirage.ItemId); + if (item != null) { + var icon = this.Ui.GetIcon(item.Icon); + if (icon != null) { + ImGui.SetCursorPos(cursorBefore + new Vector2(paddingSize / 2f)); + ImGui.Image(icon.ImGuiHandle, new Vector2(iconSize)); + ImGui.SetCursorPos(cursorAfter); + + var stain = this.Ui.Plugin.DataManager.GetExcelSheet()!.GetRow(mirage.StainId); + var circleCentre = drawCursor + new Vector2(iconSize, 4 + paddingSize / 2f); + if (mirage.StainId != 0 && stain != null) { + var colour = stain.Color; + var abgr = 0xFF000000; + abgr |= (colour & 0xFF) << 16; + abgr |= ((colour >> 8) & 0xFF) << 8; + abgr |= (colour >> 16) & 0xFF; + ImGui.GetWindowDrawList().AddCircleFilled(circleCentre, 4, abgr); + } + + if (item.IsDyeable) { + ImGui.GetWindowDrawList().AddCircle(circleCentre, 5, 0xFF000000); + } + + var stainName = mirage.StainId == 0 || stain == null + ? "" + : $" ({stain.Name})"; + tooltip += $"\n{item.Name}{stainName}"; + } + } + } + + ImGui.EndGroup(); + + // fix spacing + ImGui.SetCursorPos(cursorAfter); + + if (ImGui.IsItemHovered()) { + ImGui.BeginTooltip(); + ImGui.TextUnformatted(tooltip); + ImGui.EndTooltip(); + } + + var itemPopup = $"plate item edit {slot}"; + var dyePopup = $"plate item dye {slot}"; + if (this._editing && ImGui.IsItemClicked(ImGuiMouseButton.Left)) { + ImGui.OpenPopup(itemPopup); + this.FilterItems(slot); + } + + if (this._editing && ImGui.IsItemClicked(ImGuiMouseButton.Right) && mirage != null) { + var dyeable = this.Ui.Plugin.DataManager.GetExcelSheet()!.GetRow(mirage.ItemId)?.IsDyeable ?? false; + if (dyeable) { + ImGui.OpenPopup(dyePopup); + } + } + + this.DrawItemPopup(itemPopup, plate, slot); + + if (mirage != null) { + this.DrawDyePopup(dyePopup, mirage); + } + } + + private void DrawPlatePreview(bool editingPlate, SavedPlate plate) { + const int iconSize = 48; + const int paddingSize = 12; + + if (!ImGui.BeginTable("plate item preview", 2, ImGuiTableFlags.SizingFixedFit)) { + return; + } + + foreach (var (left, right) in LeftSide.Zip(RightSide)) { + ImGui.TableNextColumn(); + this.DrawIcon(left, plate, editingPlate, iconSize, paddingSize); + ImGui.TableNextColumn(); + this.DrawIcon(right, plate, editingPlate, iconSize, paddingSize); + } + + ImGui.EndTable(); + } + + private unsafe void DrawPlateButtons(SavedPlate plate) { + if (this._editing || !ImGui.BeginTable("plate buttons", 5, ImGuiTableFlags.SizingFixedFit)) { + return; + } + + ImGui.TableNextColumn(); + if (Util.IconButton(FontAwesomeIcon.Check, tooltip: "Apply")) { + this.Ui.Plugin.Functions.LoadPlate(plate); + } + + ImGui.TableNextColumn(); + if (Util.IconButton(FontAwesomeIcon.Search, tooltip: "Try on")) { + void SetTryOnSave(bool save) { + var tryOnAgent = (IntPtr) Framework.Instance()->GetUiModule()->GetAgentModule()->GetAgentByInternalId(AgentId.Tryon); + if (tryOnAgent != IntPtr.Zero) { + *(byte*) (tryOnAgent + 0x2E2) = (byte) (save ? 1 : 0); + } + } + + SetTryOnSave(false); + foreach (var mirage in plate.Items.Values) { + this.Ui.Plugin.Functions.TryOn(mirage.ItemId, mirage.StainId); + SetTryOnSave(true); + } + } + + ImGui.TableNextColumn(); + if (Util.IconButton(FontAwesomeIcon.Font, tooltip: "Rename")) { + this._showRename ^= true; + this._renameInput = plate.Name; + } + + ImGui.TableNextColumn(); + if (Util.IconButton(FontAwesomeIcon.PencilAlt, tooltip: "Edit")) { + this._editing = true; + this._editingPlate = plate.Clone(); + } + + ImGui.TableNextColumn(); + if (Util.IconButton(FontAwesomeIcon.ShareAltSquare, tooltip: "Share")) { + ImGui.SetClipboardText(JsonConvert.SerializeObject(plate)); + this._shareTimer.Start(); + } + + ImGui.EndTable(); + } + + private void DrawPlateDetail(bool editingPlate) { + if (!ImGui.BeginChild("plate detail")) { + return; + } + + if (this._selectedPlate > -1 && this._selectedPlate < this.Ui.Plugin.Config.Plates.Count) { + var plate = this._editingPlate ?? this.Ui.Plugin.Config.Plates[this._selectedPlate]; + + this.DrawPlatePreview(editingPlate, plate); + + var renameWasVisible = this._showRename; + + this.DrawPlateButtons(plate); + + if (this._shareTimer.IsRunning) { + Util.TextUnformattedWrapped("Copied to clipboard."); + } + + if (this._showRename && Util.DrawTextInput("plate-rename", ref this._renameInput, flags: ImGuiInputTextFlags.AutoSelectAll)) { + plate.Name = this._renameInput; + this.Ui.Plugin.SaveConfig(); + this._showRename = false; + } + + if (this._showRename && !renameWasVisible) { + ImGui.SetKeyboardFocusHere(); + } + + if (!this.Ui.Plugin.Functions.ArmoireLoaded) { + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudYellow); + Util.TextUnformattedWrapped("The Armoire is not loaded. Open it once to enable glamours from the Armoire."); + ImGui.PopStyleColor(); + } + + if (this._editing) { + Util.TextUnformattedWrapped("Click an item to edit it. Right-click to dye."); + + if (ImGui.Button("Save") && this._editingPlate != null) { + this.Ui.Plugin.Config.Plates[this._selectedPlate] = this._editingPlate; + this.Ui.Plugin.SaveConfig(); + this.ResetEditing(); + } + + ImGui.SameLine(); + + if (ImGui.Button("Cancel")) { + this.ResetEditing(); + } + } + } + + ImGui.EndChild(); + } + + private void DrawInner() { + var editingPlate = Util.IsEditingPlate(this.Ui.Plugin.GameGui); + + this.DrawMenuBar(); + + if (!editingPlate) { + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudYellow); + Util.TextUnformattedWrapped("Glamour Plate editor is not open. Certain functions will not work."); + ImGui.PopStyleColor(); + } + + this.DrawPlateList(); + + ImGui.SameLine(); + + this.DrawPlateDetail(editingPlate); + + ImGui.End(); + } + + private void HandleTimers() { + if (this._shareTimer.Elapsed > TimeSpan.FromSeconds(5)) { + this._shareTimer.Reset(); + } + } + + internal void SwitchPlate(int idx, bool scrollTo = false) { + this._selectedPlate = idx; + this._scrollToSelected = scrollTo; + this._renameInput = string.Empty; + this._showRename = false; + this._deleteConfirm = false; + this._shareTimer.Reset(); + this.ResetEditing(); + } + + private void ResetEditing() { + this._editing = false; + this._editingPlate = null; + this._itemFilter = string.Empty; + this._dyeFilter = string.Empty; + } + + private void FilterItems(PlateSlot slot) { + var filter = this._itemFilter.ToLowerInvariant(); + this.FilteredItems = this.Items + .Where(item => Util.MatchesSlot(item.EquipSlotCategory.Value!, slot)) + .Where(item => this._itemFilter.Length == 0 || item.Name.RawString.ToLowerInvariant().Contains(filter)) + .ToList(); + } + } +} diff --git a/Glamaholic/Util.cs b/Glamaholic/Util.cs new file mode 100755 index 0000000..6729a1d --- /dev/null +++ b/Glamaholic/Util.cs @@ -0,0 +1,85 @@ +using System; +using Dalamud.Game.Gui; +using Dalamud.Interface; +using FFXIVClientStructs.FFXIV.Component.GUI; +using ImGuiNET; +using Lumina.Excel.GeneratedSheets; + +namespace Glamaholic { + internal static class Util { + internal const string PlateAddon = "MiragePrismMiragePlate"; + private const string BoxAddon = "MiragePrismPrismBox"; + private const string ArmoireAddon = "CabinetWithdraw"; + + private static unsafe bool IsOpen(AtkUnitBase* addon) { + return addon != null && addon->IsVisible; + } + + private static unsafe bool IsOpen(GameGui gui, string name) { + var addon = (AtkUnitBase*) gui.GetAddonByName(name, 1); + return IsOpen(addon); + } + + internal static bool IsEditingPlate(GameGui gui) { + var plateOpen = IsOpen(gui, PlateAddon); + var boxOpen = IsOpen(gui, BoxAddon); + var armoireOpen = IsOpen(gui, ArmoireAddon); + + return plateOpen && (boxOpen || armoireOpen); + } + + internal static bool DrawTextInput(string id, ref string input, uint max = 512, string message = "Press Enter to save.", ImGuiInputTextFlags flags = ImGuiInputTextFlags.None) { + ImGui.SetNextItemWidth(-1); + var ret = ImGui.InputText($"##{id}", ref input, max, ImGuiInputTextFlags.EnterReturnsTrue | flags); + + ImGui.TextUnformatted(message); + + return ret && input.Length > 0; + } + + internal static bool IconButton(FontAwesomeIcon icon, string? id = null, string? tooltip = null, bool small = false) { + var label = icon.ToIconString(); + if (id != null) { + label += $"##{id}"; + } + + ImGui.PushFont(UiBuilder.IconFont); + var ret = small + ? ImGui.SmallButton(label) + : ImGui.Button(label); + ImGui.PopFont(); + + if (tooltip != null && ImGui.IsItemHovered()) { + ImGui.BeginTooltip(); + ImGui.TextUnformatted(tooltip); + ImGui.EndTooltip(); + } + + return ret; + } + + internal static void TextUnformattedWrapped(string text) { + ImGui.PushTextWrapPos(); + ImGui.TextUnformatted(text); + ImGui.PopTextWrapPos(); + } + + internal static bool MatchesSlot(EquipSlotCategory category, PlateSlot slot) { + return slot switch { + PlateSlot.MainHand => category.MainHand > 0, + PlateSlot.OffHand => category.OffHand > 0, + PlateSlot.Head => category.Head > 0, + PlateSlot.Body => category.Body > 0, + PlateSlot.Hands => category.Gloves > 0, + PlateSlot.Legs => category.Legs > 0, + PlateSlot.Feet => category.Feet > 0, + PlateSlot.Ears => category.Ears > 0, + PlateSlot.Neck => category.Neck > 0, + PlateSlot.Wrists => category.Wrists > 0, + PlateSlot.RightRing => category.FingerR > 0, + PlateSlot.LeftRing => category.FingerL > 0, + _ => throw new ArgumentOutOfRangeException(nameof(slot), slot, null), + }; + } + } +} diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..4153cd3 --- /dev/null +++ b/LICENCE @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e8c8d901eae7e3e34894a2c0ff8f2f57bf37ff64 GIT binary patch literal 9979 zcmVEX>4Tx04R}tkv&MmKpe$i(@I4v4(%Y~kfAzR5EXHhDi*;)X)CnqU~=gfG-*gu zTpR`0f`cE6RRyeJSX600006VoOIv0RI600RN!9r;`8x010qNS#tmY zE+YT{E+YYWr9XB6000McNlirueSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{03_2%L_t(|+RdDMSX9@&_xnfE*EX>MLBtCJ3UU!Ipixm2 zMHCnqW|$i@!wk%QhKrZfCa0-spPa^ePEr~*nP^KVl3+$624l!ej*T5d>)VMvwv$*B zS`)+)&7oG)Nun*OHl6po_HWOc4T4G9^S;lS=aZE!ENAxjvwmyswPD6DfGeybWU7iT#N}Gll~`gcZ`Y7IV~eQBWwQrg-ZZW{U!PzeQ93K!s#;q z4HmQ2##)nJYcgu7Z7}OOW|M(YZ(^;vK1O}Cod}EMz%u%amWew@pS;d!;#e$o7E8U* z)*0$*iCSZAjY}K)6VdFUQaE-#@aD$Gnvpyw zGfPyEm^>2-D1Vi#!f4Q0@FY&VyU`5`9*+Y!ofe0X-(fWayUoO~n#2Q}MK}Wir(-Ox zTmHzuVjum*afyD{&XSJ8CdF#8SR4&ZPz_`w+5E{3Eq94@B| zGAuG?w%Kge$zdAW?^1Pe{qJl|@$yHTV6|xHCXJzlUlLDv9 zO358IE5jj44i8Q{jiE~dJp%`WKr}T)sna&CFZ6x$KB8>>TH=8wkIPXX>joM{JmW!6 zP6fhVOL%pR6%Z#5zJrIe0TxJv%WWeC1^3?jwfK7gQ-I6E1GFXCJ2ufu?dK< zvFntBmN1v_Fc=IvjSeLsDwvy@0|lr8>;>eCO60fM8z{LL-eR>hSOAuNi(SO-u-Kgp zcFYdyfWvJDd;$Y^lbf^u!EbFYE_x&I6RCm|2cAcGKo*KtVGC2#hGaUf~vSJ%fTVj8!0kp9~bZoZ>iv1}mG| z4HiayLp`cMsZeS(no$Z+1yG=(p8qK!m8iG2LW$k03N;FHMExp%<9C-=1PdbH!IJ$~O04?f#|?4^UC zY~xyBH0zo-J4Bjq!7b3_X!bZ-JWd=tJx)3eC)xv^2eZDnUB@WZWJ z9V=I$jr`Y8PS1;2X-9DD)_{=LJy2XF;o zMz?fBi7kwU1_>86oID~Rhygoay{@{tTBp^Kg6#Q=zLcJsbElv{SyNe7UZT}h>uYPE z%1|d@GMLPa28$8ctVEs7Q3t^umqlz{HjhW7sWFDFa2a6v0mdJ^bvp9VWv;-#XZQZc z|Lxf)cR$y*5B$ebKltdqcfNi9*O%S9tZZYczE0EBVvEr%w2h;|i{b`u#AS8FoYVvV#k z7_3&K#bzLIYv6L2UCw%3kg)dv;w37I)nc?RUmAIT2%LVgQ&e#FG;6tn-MgN7f^nd2 zufP3iQUE@D@14eG>(V7lH*P4^8`Yj>>sZ`lX?C%ViQ9?oJlhvo?e$pxaSk|bfC$Kf zP-=}@u8?ar+VqU{u?pZuRv|-?DDhAz)hcDJPE)H>VY_0~tIcLDu-S~TfEKvy4Fql< zX+?0m8INz;0)DV*OYPEv$ZJR0;)fQz(HFUVE^_`?;FHUj!G+MTUU>2;->x6D@7>q2 zzXQDBKk&-oLw!9*{`1(&=U#ic(QGQnFKDVaZ)tLKaQnc=zopUH#5M&MYDMf8C)?Cn zXpckF85NCT)u7b%P&|U1%shq9e%#UazdvtITy;&|t1@sIP5k zV9*44)ZJLKAonp()5stG5IKJ~a^SJZi5D3^?}>z7iwvBO{NZ<^f?uCLyywXuKi<0k zslCrU{R}w#TnFgwdj7SSx_@_~2RyU;dkgXwtS?!s(#&`lng~BDX5lLC(lZs{I$63T#16J$|_lv3=dME zsFnd$jf{mSv@(NE$*D8o+NohLEEEV{zTT>9u+;vW)2+zOLVl*8y5 zeFFIbeHRM4c0cj7_bKp<@0n*0bafqg7X0r+ov*$0!tVkv5$(Ho%wI5nZSiV#jU1WX z*zHGgTFk)KP>0skpaFID?5ygHYHoo*e%u67C@4UU#}x31P${cq0;-sT%IZqFQbuT1 za$wY|M0BdUS~c?HWq{ResISv~!)D-k3#UgeguuYzJ&_as$lJ#wr;g%a&I;%c+fxCS9=<)Yn-X4E1ImU?*0G^4A&lTAZ*>TTNg%6-t>*&X6m|Q^2aAf+=7Y1O*Be zW0V4{j(Qd4M{)u$&+`}L#PTx*lwSY}$ii3!yINUz0fayHY&*52fGmt$@InDu7^T1- zvtX&$6R1j~*(m;mRj<|S6v(gD$VmZ^SIcliBvFu=gMFe{1wwvEk}A+^6tNO82C?5G7%0jj{LhXOzf$O04~;6JB8fWi}$AMMD6esKy2wZIe*Zy)FR$5HT< zcOnJX&+GxD06;(_P3Px#%4}zbkUB74#nQ=4R*QWaoIS7SQZ8 za$*%>HpXTI^6MEYEdwJeSBhgiK^0VtR-h3SXk+;aJT+cmjDpC`VNpRef8^~RQV>4d z4TR)_<0>EvqyYItUA_=o1;$YT8f}*G6-X>leo_FmI?)0tkbmAP5J~_SLTP?+{Dpes z`SX|Nv*cXC+dZO!i$RP(DPXnmKCT6WKsbmgKzPbe3NZd)i~^n?3||Q4L<_;*pdYKi zHfmkq7I+nqh0muzBT+DV7O?!7c+vd99v{!o6h!lr017C4~#0KS;f~spsL6R z!DG83R|1iDeKCfEoR8l>9=^~EaKOGY($Cs(C^9k(F1>s5)T`~jHn0DgHX?Aa`^?c! zV&v8BpY}t6k8!Patl7n_jeF!5$&1KwJ*wV0;Cb2(eAEpaA(r1wwwQ0;9GJ z3dH;X$)Ny96?|}NiRss_!07kVgJuObEfM5!u7L1ZY7)QbU z+g1U~Z!nIlK(--0mY)=Wa4+L#DDvmitQ8bs{LzAF_)!YF_isn?-Xnf$dH#{teX;x( zeKZS@e~g0Ai9skJ3wEojxzR|4V5g;lD=<%71yTiAA#lOOwn50xHWJuTNG}9fT^_h! z3kr+UdtQC?#*JPvbGUcpqoX5N{H*=!@f$aTAQEA1_$5GJ~NuLloCKuJpL=CU5W z4hrldPNT=ha7HhX;uT%1!cGqXaR>Yv3IOdZZ&g6~!`A{(06x9WS{5Eef*=xw6pY+B z4Ji5bzl6Z$t3jy(Z`1-QAR-t2V*aaLP(b+!p5Ob(4J3zxw(aLiX0?_Um&7ax*9fTs z(So1=w>%RnkjG|$e2fCR5F$S;AU`P>iS$xS;iU?Y|N2O9INZxEP@n;H#dy$+h^h-*BDu4=f{i)US#=61WW$W$s|R z6#yt;$>~M_NEA?b%mO|U*erM>HVbZ5K!t!dCp$f6!8&S1{9Fpyy#%%pQ3?%(!Qqg| zl>n#ronyh_0CDp7L2&AiA+&*a1K`w^QztK;9JqG+%#~AM_-1HuBnbMi2Eg@^zL7`> zZFtxpxzfwQ^*_diAaVI*-{7l3;#~X5!2R9b)r0<`5#U`BWK?#79oA+d@M^C`r_ISo zM+?Lsu2_xwTE%B8h?c-81+o0Yp<};4PWf>_3ebYUZv&(N2T}#Wi$Uav2vUIXBm((K z0l19Z(X!EUh5E2HRXiwK8yV}0D6YTrezk2W6zQbzgY&RQ$|6uo{-*_NBExov?$ZXaFeqGdQt8b^E ztgM9cx1Bl-Sn@Z|dIM)#kAx^ZL`W3`M+T8UFnC%NA%sU8_-*gm^Tz=4qeXZ^s^Hvi z@6jLnPCn;H3n)Bs{Hab-0FFKCJN_L}0m2jh$69@lw?YA0u&zc8xL>=7v)BSiWXnSH$d)q#= z-p5+^e18YPE*tE5Y=^JS3vjS!*G}N9)1%$iXwT2fBT6=usI+pR){R>Q)#b>KDu9Bx zS?SzDV0eTp=s0?e@Ez<%e*ZzgR6#$k{Op<^RWLMo{q3^=`58j~i4-8e|DaC{e~7h5 zpXnfak96Z(WC0*QSFrD?R;~c-d!m&f1pwP5D2R)T`(g^vB0skvReV=jfZ@RL zu~jfMIC7RL2)_{mOvD5VD8Hxx;SUQ64*7eY?AXVV+ z@NsB{6s{=D$;$!Tcei5w z3*piodL!Z(LOsQ+Z^ z&h6~?2pw&pyQ{scyAyPFc7X0KKk}mmU7Z~~cKfsYIKB?A_sJaupAJA)S(-d68RQo% zEG#MnWfi4by@LN{Nv%_VwgLhoNC8k8jbju5C;-S$3c$db0j?l?GYktN9}Zo5Z-Cly z74-J_yL%vjr6mPYx}sm{IM9Z{2jePewi#tbg%qBk@VI5<3beYh3bMHZxw4EVg7lZ@ z^iz1UAWO^d_}3kz0E5phNEMtpb_Nzm#HIHKVIc~$fLfSH0jhw;pH~4mdbo3p3n&0A zKSV?ov_8Cz4SrE!&aC7dNq&?A<>w0MH)|6rxU~wn0Ve&CbXxR;5w{PeWng)FQTiSE!B+-joF4A!8bHf9J{aigN85iPJUlYO z3EvdCbmeMzBn-}+IpzKKcHjP8{)25%d|iip)Jo|*xUaj*2XG)=4qLyA!QW;vt0vtY zmyeZCplp4SQ7g05i9b+Bc#F+wwiwwvE+*I@XG`uB@wc~DiG{>Mec4v7 zfD;PEEFk&7``4*u$pr<27l)+^ynA~cs$-Ra{8fL#%z`8zuH@ced@7K6_fQ21?|?BF&E$inyvq!YmyoeL8w08qd!v|58> zszBsK82L$oI0Zh30xAUFas|M*zm@Wr6y*b+A9I1{|4apAe_oI_H=EvrEW8^}{E-R! zl>`5UI=z9%##R)k%k1%3__IT2X>n10UQT{m3Gn{7D|mLW|Md}0aBsN(c{ax82vgb*p{beRj0Xn*^FH%^ovg3aom15B2yzGU0E-mi!W7LIpsoAZ9^OK#Wm9 zzygK8RRPc6dqfP+^1l+xFI6C0U(E9Zo?k#zuz9nf0Qm(Aq<}0;@Kd9V>`8Y_ntadX z@vFc%RsjkDNG-5p(?0-6fh>7j=kC*@g_l_skcCjk;JFjSfLVBFfPjLo174|uo*u?g zNeTE5czOQxSz>-IdrzoLlD~OWeqIH_IgeijY3Y;Wq6+9!DHBcvb5y~2b3v+rfCBKK zq8$n__yk0d0<-|h&!2s}r|Sh%wW4tO;)QuRixa|~G&dSq_$GUE3wud% z5ptWMxva1Z!sUwUchcXt*R7PhyyU%q_#Z-4vSU;p~o zfq{YVfB*Y)=A{42{ViRcZCE1+;r(Ul(~=U#?VpyGCRdbi-s;+l`~sU>*r*h(S}|__ z)YQ~fEAOpQRwN`R#}rH&y&TeSlSlzTev3svzJjO)z7zwxppu1>L}RZvKnf^7L=63W z0Qnz!=%H{p9En75a__(Y{?)5jas2nc|NYFFGg(<#-}>g}1HQ+$KiG`?*;(1hfBXK` zin6b7wNd^ij|KT}-+yJ{(wKrd8SFMGAt8B;0`@yKW2_L!0==0jY9Xp%(i`TA`%> zx^=}BveMEG#THBbnKN%v|MWR&Hft>@z*Lv^U$tt*>eVZjFE6O8Grswz*njryl=y_H z;@hn<=FMjx*Bws*p9?^$fGmjl8^=}wDIg2m6|I0Qgzg_C1^@c(Zyq{y2xIcIpZ#pJ z%k|uIPj>vc75r%5zc)9zu3Wi-Q{S**!^{~oNx}T=tmmJ9p8CtmN;YkAZo9u3G&S1{ zMjh{8zHu$`gB8n{^5$giqcmy$Ar`dcg(kGp=`*F7RzohGA#_g}m?53k*`NjMO4srJU%BhA!%xYr9tI(8Zc|9|MKNa?%V3( z`GM1J*tzq2)PHvBEL;dIW=&zV|Ki09>l{E`rmoyow40vTzoR1k8AL^ zTiyDOE2cnlfpj{|1QX%0i)^w}HHx($nIKRA=Eu)3UQcmVdbkg8fvM3Qc@x|0`+w@Yoq;hbFo%Wc8M$y9N zmT?p~${iUg8FY$Twf4ofLp(po%gQvHO!VEUiBs5Y+SD*_-dy@hb+sJBEGz!D=&WPboQ@UNEApm2&e_g z$1sGV0&Ic_yG}6bjXh4Ibj##vv=B5$oY<(DRe3oXr%s*1NgO+NEF&!~BQ+%_BRzX= z+LA?i%a$w-1OoULuH@5_rf%Kj1k3Kt=lz#0UxM0pdm3EsdV^85wz!b@PfVObCxAtP z_b*tyP*GiO(AVfR@{013Wy=eA|5z1}0{j8txdpkzf>Z(N6la%N8?#`O?4X6ILCXYJ zz%78Ps#2v=iOV%sW}IbNSy^(@)YOz&EiEmtzyA8QYu9j!*w4(GIh_=!73I?YGMOwp zJ2NjYXH0)=0qLFimF1(JzoMdI?wqu&c^PB+-}&YLB`TPcnTsQ^dTlW&h<=7E`td9c z_SGs9F-8Hw6ihHdoK7i5!qa3~wQ?DjCEBj=2S%p9zn@m@H{N(7D|61~Mhj?mnb)i- z{LKEyDSjTqSX;P!>1X!;@}#dI^}L*gUxEf4O`V=HGj&c`WjVrA8{03}sA3khq6Id! zU?gfmTqZ5C&j?8wqX1YN4C_l)uUWHt*REZ^{N*pPd;7^xeu8Trc0_bZZ*6T|aqpto z$1Uup($eB_`e()ZFD+Qa-y&_&Yl@3jt}ZGX(?2sallPx=*W{Q26ao|gUJF<@=<1+| zZI&3Zx2GG7(jT#kZ*>+ia&FbZKURe)ZK$g$D_^&KMM1%m1uIwGTP5F6Ua{f)`SYKA z^2v%7E6O&mll~Q0Gt}1Li_4c46yz^hzIC|*YW=A)~~}W>J55;Q^NeOQOZ`Y zE?l?>+pElli}O~kE?l#wi1)wyo+&eC&&gd_AS#$SdoET46au$UF4TfT8Cw^GNul7M zQ;=RVX)7H?&lT|HlM(#{opdRcqS4B?Y}xYZr=Pz2?z{8mW!CBxJiqjL%u#A;V*N8S zGpZHUlpkod3J(7~(%O=>n>TN!{fFTSS<`2wCMM4;E?LWK zfo253w+w;^Ip0C?4Yhz476?9T7^WC}67d72vZB0vBX)I|u{(F}T(V?Q?9;#|t@L^H z0##KN8_Qz-7cE|((P*@>hY())MLB&W4f)rVtmFOTrX*tEaVf~c)+i@0f6lyIfIZUe z^m(uV;t~_?EnG(VA%a{;6#$$xP^)n7U8PhjIW?LZjzNlke019CHLF(`42FvrFJhKr z(x%Q%QPosR`_$<*fSyOKQLb1ys((sKimXbe5*~_b1*oc)fihVcaqo&Hx>_CYA0Iy@ zH9a#kdjV5`D?;vq0xSvm1Gg|GX~vW!TqBYI9VASfMkG#~I_e*k{3nOA!mM0 z$@-F>o*r7X*}^_|?%ns?{r|N8jG41Dv+|$-uzLh3VE^8yL;+?5ssP~yrtYto4?m5IKCf?c~H$~G^(lE?(=gp@n0CE@Piwf|EjO;vo z0}4RuoGbtf07U`BXBS=!myfZeele!aoV{S-qLnLG=I7_n&B%;TNJ?TGlh4+fD!g002ovPDHLk FV1jZ&#}@zq literal 0 HcmV?d00001