chore: initial commit

main
Anna Clemens 1 year ago
commit 4e04e31323
Signed by: ascclemens
GPG Key ID: 0B391D8F06FCD9E0

365
.gitignore vendored

@ -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

@ -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

@ -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();
}
}
}

@ -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<SavedPlate> Plates { get; init; } = new();
public bool ShowEditorMenu = true;
public bool ShowExamineMenu = true;
}
[Serializable]
internal class SavedPlate {
public string Name { get; set; }
public Dictionary<PlateSlot, SavedGlamourItem> 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,
};
}
}
}

@ -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<SetGlamourPlateSlotDelegate>(this.Plugin.SigScanner.ScanText(Signatures.SetGlamourPlateSlot));
this._modifyGlamourPlateSlot = Marshal.GetDelegateForFunctionPointer<ModifyGlamourPlateSlotDelegate>(this.Plugin.SigScanner.ScanText(Signatures.ModifyGlamourPlateSlot));
this._isInArmoire = Marshal.GetDelegateForFunctionPointer<IsInArmoireDelegate>(this.Plugin.SigScanner.ScanText(Signatures.IsInArmoire));
this._armoirePtr = this.Plugin.SigScanner.GetStaticAddressFromSig(Signatures.ArmoirePointer);
this._tryOn = Marshal.GetDelegateForFunctionPointer<TryOnDelegate>(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<GlamourItem> DresserContents {
get {
var list = new List<GlamourItem>();
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<PlateSlot, SavedGlamourItem>? 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<PlateSlot, SavedGlamourItem>();
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<Cabinet>()!.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<Cabinet>()!.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<StainTransient>()!.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;
}
}
}

@ -0,0 +1,63 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0-windows</TargetFramework>
<Version>1.3.0</Version>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<IgnoredLints>
D0008
</IgnoredLints>
</PropertyGroup>
<PropertyGroup>
<Dalamud>$(AppData)\XIVLauncher\addon\Hooks\dev</Dalamud>
</PropertyGroup>
<PropertyGroup Condition="'$(IsCI)' == 'true'">
<Dalamud>$(HOME)/dalamud</Dalamud>
</PropertyGroup>
<ItemGroup>
<Reference Include="Dalamud">
<HintPath>$(Dalamud)\Dalamud.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="FFXIVClientStructs">
<HintPath>$(Dalamud)\FFXIVClientStructs.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGui.NET">
<HintPath>$(Dalamud)\ImGui.NET.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGuiScene">
<HintPath>$(Dalamud)\ImGuiScene.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina">
<HintPath>$(Dalamud)\Lumina.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina.Excel">
<HintPath>$(Dalamud)\Lumina.Excel.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(Dalamud)\Newtonsoft.Json.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="DalamudLinter" Version="1.0.3"/>
<PackageReference Include="DalamudPackager" Version="2.1.4"/>
</ItemGroup>
<ItemGroup>
<Content Include="..\icon.png" Link="images/icon.png" CopyToOutputDirectory="PreserveNewest" Visible="false"/>
</ItemGroup>
</Project>

@ -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

@ -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);
}
}
}

@ -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<ushort, TextureWrap> 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);
}
}
}

@ -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();
}
}
}

@ -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();
}
}
}

@ -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);
}
}
}

@ -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<Item> Items { get; }
private List<Item> 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<Item>()!
.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<SavedPlate>(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<Stain>()!) {
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];