From 164f304cf6b94010e2045956b26366ddff9869d9 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Fri, 30 Jul 2021 17:23:15 +0200 Subject: [PATCH] Initial Commit --- .editorconfig | 102 ++++++ .gitignore | 3 + Glamourer.GameData/ActorEquipExtensions.cs | 168 +++++++++ Glamourer.GameData/ActorEquipMask.cs | 23 ++ .../Customization/CharaMakeParams.cs | 124 +++++++ Glamourer.GameData/Customization/CmpFile.cs | 23 ++ .../Customization/Customization.cs | 32 ++ .../Customization/CustomizationId.cs | 70 ++++ .../Customization/CustomizationManager.cs | 35 ++ .../Customization/CustomizationOptions.cs | 264 ++++++++++++++ .../Customization/CustomizationSet.cs | 140 ++++++++ .../Customization/ICustomizationManager.cs | 16 + .../Glamourer - Backup.GameData.csproj | 61 ++++ Glamourer.GameData/Glamourer.GameData.csproj | 61 ++++ Glamourer.GameData/Item.cs | 45 +++ Glamourer.GameData/Main.cs | 73 ++++ Glamourer.GameData/Stain.cs | 41 +++ Glamourer.GameData/Util/IconStorage.cs | 56 +++ Glamourer.json | 11 + Glamourer.sln | 57 +++ Glamourer.sln.DotSettings.user | 4 + Glamourer.zip | Bin 0 -> 30455 bytes Glamourer/CmpFile.cs | 23 ++ Glamourer/Glamourer.csproj | 98 ++++++ Glamourer/Glamourer.json | 11 + Glamourer/Gui/ComboWithFilter.cs | 178 ++++++++++ Glamourer/Gui/ImGuiRaii.cs | 154 +++++++++ Glamourer/Gui/Interface.cs | 326 ++++++++++++++++++ Glamourer/Main.cs | 170 +++++++++ Glamourer/Managers/CommandManager.cs | 73 ++++ Glamourer/SeFunctions/BaseUiObject.cs | 11 + Glamourer/SeFunctions/GetUiModule.cs | 14 + Glamourer/SeFunctions/ProcessChatBox.cs | 14 + Glamourer/SeFunctions/SeAddressBase.cs | 20 ++ Glamourer/SeFunctions/SeFunctionBase.cs | 75 ++++ LICENSE | 201 +++++++++++ README.md | 0 repo.json | 19 + 38 files changed, 2796 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 Glamourer.GameData/ActorEquipExtensions.cs create mode 100644 Glamourer.GameData/ActorEquipMask.cs create mode 100644 Glamourer.GameData/Customization/CharaMakeParams.cs create mode 100644 Glamourer.GameData/Customization/CmpFile.cs create mode 100644 Glamourer.GameData/Customization/Customization.cs create mode 100644 Glamourer.GameData/Customization/CustomizationId.cs create mode 100644 Glamourer.GameData/Customization/CustomizationManager.cs create mode 100644 Glamourer.GameData/Customization/CustomizationOptions.cs create mode 100644 Glamourer.GameData/Customization/CustomizationSet.cs create mode 100644 Glamourer.GameData/Customization/ICustomizationManager.cs create mode 100644 Glamourer.GameData/Glamourer - Backup.GameData.csproj create mode 100644 Glamourer.GameData/Glamourer.GameData.csproj create mode 100644 Glamourer.GameData/Item.cs create mode 100644 Glamourer.GameData/Main.cs create mode 100644 Glamourer.GameData/Stain.cs create mode 100644 Glamourer.GameData/Util/IconStorage.cs create mode 100644 Glamourer.json create mode 100644 Glamourer.sln create mode 100644 Glamourer.sln.DotSettings.user create mode 100644 Glamourer.zip create mode 100644 Glamourer/CmpFile.cs create mode 100644 Glamourer/Glamourer.csproj create mode 100644 Glamourer/Glamourer.json create mode 100644 Glamourer/Gui/ComboWithFilter.cs create mode 100644 Glamourer/Gui/ImGuiRaii.cs create mode 100644 Glamourer/Gui/Interface.cs create mode 100644 Glamourer/Main.cs create mode 100644 Glamourer/Managers/CommandManager.cs create mode 100644 Glamourer/SeFunctions/BaseUiObject.cs create mode 100644 Glamourer/SeFunctions/GetUiModule.cs create mode 100644 Glamourer/SeFunctions/ProcessChatBox.cs create mode 100644 Glamourer/SeFunctions/SeAddressBase.cs create mode 100644 Glamourer/SeFunctions/SeFunctionBase.cs create mode 100644 LICENSE create mode 100644 README.md create mode 100644 repo.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5fa14fd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,102 @@ + +[*.proto] +indent_style=tab +indent_size=tab +tab_width=4 + +[*.{asax,ascx,aspx,axaml,cs,cshtml,css,htm,html,js,jsx,master,paml,razor,skin,ts,tsx,vb,xaml,xamlx,xoml}] +indent_style=space +indent_size=4 +tab_width=4 + +[*.{appxmanifest,axml,build,config,csproj,dbml,discomap,dtd,json,jsproj,lsproj,njsproj,nuspec,proj,props,resjson,resw,resx,StyleCop,targets,tasks,vbproj,xml,xsd}] +indent_style=space +indent_size=2 +tab_width=2 + +[*] + +# Microsoft .NET properties +csharp_preferred_modifier_order=public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion +csharp_style_var_elsewhere=true:suggestion +csharp_style_var_for_built_in_types=true:suggestion +csharp_style_var_when_type_is_apparent=true:suggestion +dotnet_diagnostic.cs8632.severity=none +dotnet_diagnostic.cs8669.severity=none +dotnet_style_parentheses_in_arithmetic_binary_operators=never_if_unnecessary:suggestion +dotnet_style_parentheses_in_other_binary_operators=never_if_unnecessary:suggestion +dotnet_style_parentheses_in_relational_binary_operators=never_if_unnecessary:suggestion +dotnet_style_predefined_type_for_locals_parameters_members=true:suggestion +dotnet_style_predefined_type_for_member_access=true:suggestion +dotnet_style_qualification_for_event=false:suggestion +dotnet_style_qualification_for_field=false:suggestion +dotnet_style_qualification_for_method=false:suggestion +dotnet_style_qualification_for_property=false:suggestion +dotnet_style_require_accessibility_modifiers=for_non_interface_members:suggestion + +# ReSharper properties +resharper_align_first_arg_by_paren=false +resharper_align_multiline_argument=true +resharper_align_multiline_binary_expressions_chain=false +resharper_align_multline_type_parameter_constrains=true +resharper_blank_lines_after_control_transfer_statements=1 +resharper_blank_lines_around_single_line_type=0 +resharper_braces_for_for=required_for_multiline +resharper_braces_for_foreach=required_for_multiline +resharper_braces_for_while=required_for_multiline +resharper_constructor_or_destructor_body=expression_body +resharper_csharp_align_multiline_argument=false +resharper_csharp_align_multiple_declaration=true +resharper_csharp_empty_block_style=together +resharper_csharp_insert_final_newline=true +resharper_csharp_max_line_length=144 +resharper_csharp_wrap_before_binary_opsign=true +resharper_csharp_wrap_for_stmt_header_style=wrap_if_long +resharper_csharp_wrap_parameters_style=wrap_if_long +resharper_indent_nested_foreach_stmt=true +resharper_indent_nested_for_stmt=true +resharper_indent_nested_while_stmt=true +resharper_int_align=true +resharper_int_align_binary_expressions=false +resharper_int_align_parameters=false +resharper_keep_existing_embedded_arrangement=false +resharper_keep_existing_expr_member_arrangement=false +resharper_keep_existing_initializer_arrangement=false +resharper_keep_existing_switch_expression_arrangement=false +resharper_local_function_body=expression_body +resharper_max_enum_members_on_line=1 +resharper_max_initializer_elements_on_line=1 +resharper_method_or_operator_body=expression_body +resharper_outdent_binary_ops=true +resharper_outdent_dots=false +resharper_place_attribute_on_same_line=false +resharper_place_constructor_initializer_on_same_line=false +resharper_place_expr_accessor_on_single_line=true +resharper_place_expr_method_on_single_line=false +resharper_place_expr_property_on_single_line=false +resharper_place_simple_case_statement_on_same_line=if_owner_is_single_line +resharper_place_simple_embedded_statement_on_same_line=false +resharper_place_simple_enum_on_single_line=true +resharper_place_simple_switch_expression_on_single_line=true +resharper_space_within_single_line_array_initializer_braces=true +resharper_trailing_comma_in_multiline_lists=true +resharper_wrap_array_initializer_style=chop_always +resharper_wrap_before_arrow_with_expressions=true +resharper_wrap_chained_binary_expressions=chop_if_long + +# ReSharper inspection severities +resharper_arrange_constructor_or_destructor_body_highlighting=hint +resharper_arrange_local_function_body_highlighting=hint +resharper_arrange_method_or_operator_body_highlighting=hint +resharper_arrange_missing_parentheses_highlighting=hint +resharper_arrange_redundant_parentheses_highlighting=hint +resharper_arrange_this_qualifier_highlighting=hint +resharper_arrange_type_member_modifiers_highlighting=hint +resharper_arrange_type_modifiers_highlighting=hint +resharper_built_in_type_reference_style_for_member_access_highlighting=hint +resharper_built_in_type_reference_style_highlighting=hint +resharper_compare_of_floats_by_equality_operator_highlighting=none +resharper_redundant_base_qualifier_highlighting=warning +resharper_suggest_var_or_type_built_in_types_highlighting=hint +resharper_suggest_var_or_type_elsewhere_highlighting=hint +resharper_suggest_var_or_type_simple_types_highlighting=hint diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e16852 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +.vs/ \ No newline at end of file diff --git a/Glamourer.GameData/ActorEquipExtensions.cs b/Glamourer.GameData/ActorEquipExtensions.cs new file mode 100644 index 0000000..acec01f --- /dev/null +++ b/Glamourer.GameData/ActorEquipExtensions.cs @@ -0,0 +1,168 @@ +using System; +using System.ComponentModel; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; + +namespace Glamourer +{ + public static class WriteExtensions + { + private static unsafe void Write(IntPtr actorPtr, EquipSlot slot, SetId? id, WeaponType? type, ushort? variant, StainId? stain) + { + void WriteWeapon(int offset) + { + var address = (byte*) actorPtr + offset; + if (id.HasValue) + *(ushort*) address = (ushort) id.Value; + + if (type.HasValue) + *(ushort*) (address + 2) = (ushort) type.Value; + + if (variant.HasValue) + *(ushort*) (address + 4) = variant.Value; + + if (stain.HasValue) + *(address + 6) = (byte) stain.Value; + } + + void WriteEquip(int offset) + { + var address = (byte*) actorPtr + offset; + if (id.HasValue) + *(ushort*) address = (ushort) id.Value; + + if (variant < byte.MaxValue) + *(address + 2) = (byte) variant.Value; + + if (stain.HasValue) + *(address + 3) = (byte) stain.Value; + } + + switch (slot) + { + case EquipSlot.MainHand: + WriteWeapon(ActorEquipment.MainWeaponOffset); + break; + case EquipSlot.OffHand: + WriteWeapon(ActorEquipment.OffWeaponOffset); + break; + case EquipSlot.Head: + WriteEquip(ActorEquipment.EquipmentOffset); + break; + case EquipSlot.Body: + WriteEquip(ActorEquipment.EquipmentOffset + 4); + break; + case EquipSlot.Hands: + WriteEquip(ActorEquipment.EquipmentOffset + 8); + break; + case EquipSlot.Legs: + WriteEquip(ActorEquipment.EquipmentOffset + 12); + break; + case EquipSlot.Feet: + WriteEquip(ActorEquipment.EquipmentOffset + 16); + break; + case EquipSlot.Ears: + WriteEquip(ActorEquipment.EquipmentOffset + 20); + break; + case EquipSlot.Neck: + WriteEquip(ActorEquipment.EquipmentOffset + 24); + break; + case EquipSlot.Wrists: + WriteEquip(ActorEquipment.EquipmentOffset + 28); + break; + case EquipSlot.RFinger: + WriteEquip(ActorEquipment.EquipmentOffset + 32); + break; + case EquipSlot.LFinger: + WriteEquip(ActorEquipment.EquipmentOffset + 36); + break; + default: throw new InvalidEnumArgumentException(); + } + } + + public static void Write(this Stain stain, IntPtr actorPtr, EquipSlot slot) + => Write(actorPtr, slot, null, null, null, stain.RowIndex); + + public static void Write(this Item item, IntPtr actorAddress) + { + var (id, type, variant) = item.MainModel; + Write(actorAddress, item.EquippableTo, id, type, variant, null); + if (item.EquippableTo == EquipSlot.MainHand && item.HasSubModel) + { + var (subId, subType, subVariant) = item.SubModel; + Write(actorAddress, EquipSlot.OffHand, subId, subType, subVariant, null); + } + } + + public static void Write(this ActorArmor armor, IntPtr actorAddress, EquipSlot slot) + => Write(actorAddress, slot, armor.Set, null, armor.Variant, armor.Stain); + + public static void Write(this ActorWeapon weapon, IntPtr actorAddress, EquipSlot slot) + => Write(actorAddress, slot, weapon.Set, weapon.Type, weapon.Variant, weapon.Stain); + + public static unsafe void Write(this ActorEquipment equip, IntPtr actorAddress) + { + if (equip.IsSet == 0) + return; + + Write(actorAddress, EquipSlot.MainHand, equip.MainHand.Set, equip.MainHand.Type, equip.MainHand.Variant, equip.MainHand.Stain); + Write(actorAddress, EquipSlot.OffHand, equip.OffHand.Set, equip.OffHand.Type, equip.OffHand.Variant, equip.OffHand.Stain); + + fixed (ActorArmor* equipment = &equip.Head) + { + Buffer.MemoryCopy(equipment, (byte*) actorAddress + ActorEquipment.EquipmentOffset, + ActorEquipment.EquipmentSlots * sizeof(ActorArmor), ActorEquipment.EquipmentSlots * sizeof(ActorArmor)); + } + } + + public static void Write(this ActorEquipment equip, IntPtr actorAddress, ActorEquipMask models, ActorEquipMask stains) + { + if (models == ActorEquipMask.All && stains == ActorEquipMask.All) + { + equip.Write(actorAddress); + return; + } + + if (models.HasFlag(ActorEquipMask.MainHand)) + Write(actorAddress, EquipSlot.MainHand, equip.MainHand.Set, equip.MainHand.Type, equip.MainHand.Variant, null); + if (stains.HasFlag(ActorEquipMask.MainHand)) + Write(actorAddress, EquipSlot.MainHand, null, null, null, equip.MainHand.Stain); + if (models.HasFlag(ActorEquipMask.OffHand)) + Write(actorAddress, EquipSlot.OffHand, equip.OffHand.Set, equip.OffHand.Type, equip.OffHand.Variant, null); + if (stains.HasFlag(ActorEquipMask.OffHand)) + Write(actorAddress, EquipSlot.OffHand, null, null, null, equip.OffHand.Stain); + + if (models.HasFlag(ActorEquipMask.Head)) + Write(actorAddress, EquipSlot.Head, equip.Head.Set, null, equip.Head.Variant, null); + if (stains.HasFlag(ActorEquipMask.Head)) + Write(actorAddress, EquipSlot.Head, null, null, null, equip.Head.Stain); + if (models.HasFlag(ActorEquipMask.Body)) + Write(actorAddress, EquipSlot.Body, equip.Body.Set, null, equip.Body.Variant, null); + if (stains.HasFlag(ActorEquipMask.Body)) + Write(actorAddress, EquipSlot.Body, null, null, null, equip.Body.Stain); + if (models.HasFlag(ActorEquipMask.Hands)) + Write(actorAddress, EquipSlot.Hands, equip.Hands.Set, null, equip.Hands.Variant, null); + if (stains.HasFlag(ActorEquipMask.Hands)) + Write(actorAddress, EquipSlot.Hands, null, null, null, equip.Hands.Stain); + if (models.HasFlag(ActorEquipMask.Legs)) + Write(actorAddress, EquipSlot.Legs, equip.Legs.Set, null, equip.Legs.Variant, null); + if (stains.HasFlag(ActorEquipMask.Legs)) + Write(actorAddress, EquipSlot.Legs, null, null, null, equip.Legs.Stain); + if (models.HasFlag(ActorEquipMask.Feet)) + Write(actorAddress, EquipSlot.Feet, equip.Feet.Set, null, equip.Feet.Variant, null); + if (stains.HasFlag(ActorEquipMask.Feet)) + Write(actorAddress, EquipSlot.Feet, null, null, null, equip.Feet.Stain); + + if (models.HasFlag(ActorEquipMask.Ears)) + Write(actorAddress, EquipSlot.Ears, equip.Ears.Set, null, equip.Ears.Variant, null); + if (models.HasFlag(ActorEquipMask.Neck)) + Write(actorAddress, EquipSlot.Neck, equip.Neck.Set, null, equip.Neck.Variant, null); + if (models.HasFlag(ActorEquipMask.Wrists)) + Write(actorAddress, EquipSlot.Wrists, equip.Wrists.Set, null, equip.Wrists.Variant, null); + if (models.HasFlag(ActorEquipMask.LFinger)) + Write(actorAddress, EquipSlot.LFinger, equip.LFinger.Set, null, equip.LFinger.Variant, null); + if (models.HasFlag(ActorEquipMask.RFinger)) + Write(actorAddress, EquipSlot.RFinger, equip.RFinger.Set, null, equip.RFinger.Variant, null); + } + } +} diff --git a/Glamourer.GameData/ActorEquipMask.cs b/Glamourer.GameData/ActorEquipMask.cs new file mode 100644 index 0000000..24ad254 --- /dev/null +++ b/Glamourer.GameData/ActorEquipMask.cs @@ -0,0 +1,23 @@ +using System; + +namespace Glamourer +{ + [Flags] + public enum ActorEquipMask : ushort + { + None = 0, + MainHand = 0b000000000001, + OffHand = 0b000000000010, + Head = 0b000000000100, + Body = 0b000000001000, + Hands = 0b000000010000, + Legs = 0b000000100000, + Feet = 0b000001000000, + Ears = 0b000010000000, + Neck = 0b000100000000, + Wrists = 0b001000000000, + RFinger = 0b010000000000, + LFinger = 0b100000000000, + All = 0b111111111111, + } +} \ No newline at end of file diff --git a/Glamourer.GameData/Customization/CharaMakeParams.cs b/Glamourer.GameData/Customization/CharaMakeParams.cs new file mode 100644 index 0000000..25f28a3 --- /dev/null +++ b/Glamourer.GameData/Customization/CharaMakeParams.cs @@ -0,0 +1,124 @@ +using Lumina.Data; +using Lumina.Excel; +using Lumina.Excel.GeneratedSheets; + +namespace Glamourer.Customization +{ + [Sheet("CharaMakeParams")] + public class CharaMakeParams : ExcelRow + { + public const int NumMenus = 28; + public const int NumVoices = 12; + public const int NumGraphics = 10; + public const int MaxNumValues = 100; + public const int NumFaces = 8; + public const int NumFeatures = 7; + public const int NumEquip = 3; + + public enum MenuType + { + ListSelector = 0, + IconSelector = 1, + ColorPicker = 2, + DoubleColorPicker = 3, + MultiIconSelector = 4, + Percentage = 5, + } + + public struct Menu + { + public uint Id; + public byte InitVal; + public MenuType Type; + public byte Size; + public byte LookAt; + public uint Mask; + public CustomizationId Customization; + public uint[] Values; + public byte[] Graphic; + } + + public struct FacialFeatures + { + public uint[] Icons; + } + + public LazyRow Race { get; set; } = null!; + public LazyRow Tribe { get; set; } = null!; + + public sbyte Gender { get; set; } + + public Menu[] Menus { get; set; } = new Menu[NumMenus]; + public byte[] Voices { get; set; } = new byte[NumVoices]; + public FacialFeatures[] FacialFeatureByFace { get; set; } = new FacialFeatures[NumFaces]; + public CharaMakeType.UnkStruct3347Struct[] Equip { get; set; } = new CharaMakeType.UnkStruct3347Struct[NumEquip]; + + public override void PopulateData(RowParser parser, Lumina.GameData gameData, Language language) + { + RowId = parser.Row; + SubRowId = parser.SubRow; + Race = new LazyRow(gameData, parser.ReadColumn(0), language); + Tribe = new LazyRow(gameData, parser.ReadColumn(1), language); + Gender = parser.ReadColumn(2); + for (var i = 0; i < NumMenus; ++i) + { + Menus[i].Id = parser.ReadColumn(3 + 0 * NumMenus + i); + Menus[i].InitVal = parser.ReadColumn(3 + 1 * NumMenus + i); + Menus[i].Type = (MenuType) parser.ReadColumn(3 + 2 * NumMenus + i); + Menus[i].Size = parser.ReadColumn(3 + 3 * NumMenus + i); + Menus[i].LookAt = parser.ReadColumn(3 + 4 * NumMenus + i); + Menus[i].Mask = parser.ReadColumn(3 + 5 * NumMenus + i); + Menus[i].Customization = (CustomizationId) parser.ReadColumn(3 + 6 * NumMenus + i); + Menus[i].Values = new uint[Menus[i].Size]; + + switch (Menus[i].Type) + { + case MenuType.ColorPicker: + case MenuType.DoubleColorPicker: + case MenuType.Percentage: + break; + default: + for (var j = 0; j < Menus[i].Size; ++j) + Menus[i].Values[j] = parser.ReadColumn(3 + (7 + j) * NumMenus + i); + break; + } + + Menus[i].Graphic = new byte[NumGraphics]; + for (var j = 0; j < NumGraphics; ++j) + Menus[i].Graphic[j] = parser.ReadColumn(3 + (MaxNumValues + 7 + j) * NumMenus + i); + } + + for (var i = 0; i < NumVoices; ++i) + Voices[i] = parser.ReadColumn(3 + (MaxNumValues + 7 + NumGraphics) * NumMenus + i); + + for (var i = 0; i < NumFaces; ++i) + { + FacialFeatureByFace[i].Icons = new uint[NumFeatures]; + for (var j = 0; j < NumFeatures; ++j) + FacialFeatureByFace[i].Icons[j] = + (uint) parser.ReadColumn(3 + (MaxNumValues + 7 + NumGraphics) * NumMenus + NumVoices + j * NumFaces + i); + } + + for (var i = 0; i < NumEquip; ++i) + { + Equip[i] = new CharaMakeType.UnkStruct3347Struct + { + Helmet = parser.ReadColumn( + 3 + (MaxNumValues + 7 + NumGraphics) * NumMenus + NumVoices + NumFaces * NumFeatures + i * 7 + 0), + Top = parser.ReadColumn( + 3 + (MaxNumValues + 7 + NumGraphics) * NumMenus + NumVoices + NumFaces * NumFeatures + i * 7 + 1), + Gloves = parser.ReadColumn( + 3 + (MaxNumValues + 7 + NumGraphics) * NumMenus + NumVoices + NumFaces * NumFeatures + i * 7 + 2), + Legs = parser.ReadColumn( + 3 + (MaxNumValues + 7 + NumGraphics) * NumMenus + NumVoices + NumFaces * NumFeatures + i * 7 + 3), + Shoes = parser.ReadColumn( + 3 + (MaxNumValues + 7 + NumGraphics) * NumMenus + NumVoices + NumFaces * NumFeatures + i * 7 + 4), + Weapon = parser.ReadColumn( + 3 + (MaxNumValues + 7 + NumGraphics) * NumMenus + NumVoices + NumFaces * NumFeatures + i * 7 + 5), + SubWeapon = parser.ReadColumn( + 3 + (MaxNumValues + 7 + NumGraphics) * NumMenus + NumVoices + NumFaces * NumFeatures + i * 7 + 6), + }; + } + } + } +} diff --git a/Glamourer.GameData/Customization/CmpFile.cs b/Glamourer.GameData/Customization/CmpFile.cs new file mode 100644 index 0000000..e320ab2 --- /dev/null +++ b/Glamourer.GameData/Customization/CmpFile.cs @@ -0,0 +1,23 @@ +using Dalamud.Plugin; + +namespace Glamourer +{ + public class CmpFile + { + public readonly Lumina.Data.FileResource File; + public readonly uint[] RgbaColors; + + public CmpFile(DalamudPluginInterface pi) + { + File = pi.Data.GetFile("chara/xls/charamake/human.cmp"); + RgbaColors = new uint[File.Data.Length >> 2]; + for (var i = 0; i < File.Data.Length; i += 4) + { + RgbaColors[i >> 2] = File.Data[i] + | (uint) (File.Data[i + 1] << 8) + | (uint) (File.Data[i + 2] << 16) + | (uint) (File.Data[i + 3] << 24); + } + } + } +} diff --git a/Glamourer.GameData/Customization/Customization.cs b/Glamourer.GameData/Customization/Customization.cs new file mode 100644 index 0000000..6572d1a --- /dev/null +++ b/Glamourer.GameData/Customization/Customization.cs @@ -0,0 +1,32 @@ +using System.Runtime.InteropServices; + +namespace Glamourer.Customization +{ + [StructLayout(LayoutKind.Explicit)] + public readonly struct Customization + { + [FieldOffset(0)] + public readonly CustomizationId Id; + + [FieldOffset(1)] + public readonly byte Value; + + [FieldOffset(2)] + public readonly ushort CustomizeId; + + [FieldOffset(4)] + public readonly uint IconId; + + [FieldOffset(4)] + public readonly uint Color; + + public Customization(CustomizationId id, byte value, uint data = 0, ushort customizeId = 0) + { + Id = id; + Value = value; + IconId = data; + Color = data; + CustomizeId = customizeId; + } + } +} diff --git a/Glamourer.GameData/Customization/CustomizationId.cs b/Glamourer.GameData/Customization/CustomizationId.cs new file mode 100644 index 0000000..c38ff8a --- /dev/null +++ b/Glamourer.GameData/Customization/CustomizationId.cs @@ -0,0 +1,70 @@ +using System; + +namespace Glamourer.Customization +{ + public enum CustomizationId : byte + { + Race = 0, + Gender = 1, + BodyType = 2, + Height = 3, + Clan = 4, + Face = 5, + Hairstyle = 6, + HighlightsOnFlag = 7, + SkinColor = 8, + EyeColorR = 9, + HairColor = 10, + HighlightColor = 11, + FacialFeaturesTattoos = 12, // Bitmask, 1-7 per face, 8 is 1.0 tattoo + TattooColor = 13, + Eyebrows = 14, + EyeColorL = 15, + EyeShape = 16, // Flag 128 for Small + Nose = 17, + Jaw = 18, + Mouth = 19, // Flag 128 for Lip Color set + LipColor = 20, // Flag 128 for Light instead of Dark + MuscleToneOrTailEarLength = 21, + TailEarShape = 22, + BustSize = 23, + FacePaint = 24, + FacePaintColor = 25, // Flag 128 for Light instead of Dark. + } + + public static class CustomizationExtensions + { + public static CharaMakeParams.MenuType ToType(this CustomizationId customizationId, bool isHrothgar = false) + => customizationId switch + { + CustomizationId.Race => CharaMakeParams.MenuType.IconSelector, + CustomizationId.Gender => CharaMakeParams.MenuType.IconSelector, + CustomizationId.BodyType => CharaMakeParams.MenuType.IconSelector, + CustomizationId.Height => CharaMakeParams.MenuType.Percentage, + CustomizationId.Clan => CharaMakeParams.MenuType.IconSelector, + CustomizationId.Face => CharaMakeParams.MenuType.IconSelector, + CustomizationId.Hairstyle => CharaMakeParams.MenuType.IconSelector, + CustomizationId.HighlightsOnFlag => CharaMakeParams.MenuType.ListSelector, + CustomizationId.SkinColor => CharaMakeParams.MenuType.ColorPicker, + CustomizationId.EyeColorR => CharaMakeParams.MenuType.ColorPicker, + CustomizationId.HairColor => CharaMakeParams.MenuType.ColorPicker, + CustomizationId.HighlightColor => CharaMakeParams.MenuType.ColorPicker, + CustomizationId.FacialFeaturesTattoos => CharaMakeParams.MenuType.MultiIconSelector, + CustomizationId.TattooColor => CharaMakeParams.MenuType.ColorPicker, + CustomizationId.Eyebrows => CharaMakeParams.MenuType.ListSelector, + CustomizationId.EyeColorL => CharaMakeParams.MenuType.ColorPicker, + CustomizationId.EyeShape => CharaMakeParams.MenuType.ListSelector, + CustomizationId.Nose => CharaMakeParams.MenuType.ListSelector, + CustomizationId.Jaw => CharaMakeParams.MenuType.ListSelector, + CustomizationId.Mouth => CharaMakeParams.MenuType.ListSelector, + CustomizationId.MuscleToneOrTailEarLength => CharaMakeParams.MenuType.Percentage, + CustomizationId.TailEarShape => CharaMakeParams.MenuType.IconSelector, + CustomizationId.BustSize => CharaMakeParams.MenuType.Percentage, + CustomizationId.FacePaint => CharaMakeParams.MenuType.IconSelector, + CustomizationId.FacePaintColor => CharaMakeParams.MenuType.ColorPicker, + + CustomizationId.LipColor => isHrothgar ? CharaMakeParams.MenuType.IconSelector : CharaMakeParams.MenuType.ColorPicker, + _ => throw new ArgumentOutOfRangeException(nameof(customizationId), customizationId, null), + }; + } +} diff --git a/Glamourer.GameData/Customization/CustomizationManager.cs b/Glamourer.GameData/Customization/CustomizationManager.cs new file mode 100644 index 0000000..9212d17 --- /dev/null +++ b/Glamourer.GameData/Customization/CustomizationManager.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Dalamud.Plugin; +using Penumbra.GameData.Enums; + +namespace Glamourer.Customization +{ + public class CustomizationManager : ICustomizationManager + { + private static CustomizationOptions? _options; + + private CustomizationManager() + { } + + public static ICustomizationManager Create(DalamudPluginInterface pi) + { + _options ??= new CustomizationOptions(pi); + return new CustomizationManager(); + } + + public IReadOnlyList Races + => CustomizationOptions.Races; + + public IReadOnlyList Clans + => CustomizationOptions.Clans; + + public IReadOnlyList Genders + => CustomizationOptions.Genders; + + public CustomizationSet GetList(SubRace clan, Gender gender) + => _options!.GetList(clan, gender); + + public ImGuiScene.TextureWrap GetIcon(uint iconId) + => _options!.GetIcon(iconId); + } +} diff --git a/Glamourer.GameData/Customization/CustomizationOptions.cs b/Glamourer.GameData/Customization/CustomizationOptions.cs new file mode 100644 index 0000000..d04926a --- /dev/null +++ b/Glamourer.GameData/Customization/CustomizationOptions.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Dalamud; +using Dalamud.Plugin; +using Glamourer.Util; +using Lumina.Data; +using Lumina.Excel; +using Lumina.Excel.GeneratedSheets; +using Penumbra.GameData.Enums; +using Race = Penumbra.GameData.Enums.Race; + +namespace Glamourer.Customization +{ + public class CustomizationOptions + { + internal static readonly Race[] Races = ((Race[]) Enum.GetValues(typeof(Race))).Skip(1).ToArray(); + internal static readonly SubRace[] Clans = ((SubRace[]) Enum.GetValues(typeof(SubRace))).Skip(1).ToArray(); + + internal static readonly Gender[] Genders = + { + Gender.Male, + Gender.Female, + }; + + internal CustomizationSet GetList(SubRace race, Gender gender) + => _list[ToIndex(race, gender)]; + + internal ImGuiScene.TextureWrap GetIcon(uint id) + => _icons.LoadIcon(id); + + private static readonly int ListSize = Clans.Length * Genders.Length; + + private readonly CustomizationSet[] _list = new CustomizationSet[ListSize]; + private readonly IconStorage _icons; + + private static void ThrowException(SubRace race, Gender gender) + => throw new Exception($"Invalid customization requested for {race} {gender}."); + + private static int ToIndex(SubRace race, Gender gender) + { + if (race == SubRace.Unknown || gender != Gender.Female && gender != Gender.Male) + ThrowException(race, gender); + + var ret = (int) race - 1; + ret = ret * Genders.Length + (gender == Gender.Female ? 1 : 0); + return ret; + } + + private Customization[] GetHairStyles(SubRace race, Gender gender) + { + var row = _hairSheet.GetRow(((uint) race - 1) * 2 - 1 + (uint) gender); + var hairList = new List(row.Unknown30); + for (var i = 0; i < row.Unknown30; ++i) + { + var name = $"Unknown{66 + i * 9}"; + var customizeIdx = + (uint?) row.GetType().GetProperty(name, BindingFlags.Public | BindingFlags.Instance)?.GetValue(row) + ?? uint.MaxValue; + if (customizeIdx == uint.MaxValue) + continue; + + var hairRow = _customizeSheet.GetRow(customizeIdx); + hairList.Add(hairRow != null + ? new Customization(CustomizationId.Hairstyle, hairRow.FeatureID, hairRow.Icon, (ushort) hairRow.RowId) + : new Customization(CustomizationId.Hairstyle, (byte) i, customizeIdx, 0)); + } + + return hairList.ToArray(); + } + + private Customization[] CreateColorPicker(CustomizationId id, int offset, int num, bool light = false) + => _cmpFile.RgbaColors.Skip(offset).Take(num) + .Select((c, i) => new Customization(id, (byte) (light ? 128 + i : 0 + i), c, (ushort) (offset + i))) + .ToArray(); + + private (Customization[], Customization[]) GetColors(SubRace race, Gender gender) + { + var (skinOffset, hairOffset) = race switch + { + SubRace.Midlander => gender == Gender.Male ? (0x1200, 0x1300) : (0x0D00, 0x0E00), + SubRace.Highlander => gender == Gender.Male ? (0x1C00, 0x1D00) : (0x1700, 0x1800), + SubRace.Wildwood => gender == Gender.Male ? (0x2600, 0x2700) : (0x2100, 0x2200), + SubRace.Duskwright => gender == Gender.Male ? (0x3000, 0x3100) : (0x2B00, 0x2C00), + SubRace.Plainsfolk => gender == Gender.Male ? (0x3A00, 0x3B00) : (0x3500, 0x3600), + SubRace.Dunesfolk => gender == Gender.Male ? (0x4400, 0x4500) : (0x3F00, 0x4000), + SubRace.SeekerOfTheSun => gender == Gender.Male ? (0x4E00, 0x4F00) : (0x4900, 0x4A00), + SubRace.KeeperOfTheMoon => gender == Gender.Male ? (0x5800, 0x5900) : (0x5300, 0x5400), + SubRace.Seawolf => gender == Gender.Male ? (0x6200, 0x6300) : (0x5D00, 0x5E00), + SubRace.Hellsguard => gender == Gender.Male ? (0x6C00, 0x6D00) : (0x6700, 0x6800), + SubRace.Raen => gender == Gender.Male ? (0x7100, 0x7700) : (0x7600, 0x7200), + SubRace.Xaela => gender == Gender.Male ? (0x7B00, 0x8100) : (0x8000, 0x7C00), + SubRace.Hellion => gender == Gender.Male ? (0x8500, 0x8600) : (0x0000, 0x0000), + SubRace.Lost => gender == Gender.Male ? (0x8C00, 0x8F00) : (0x0000, 0x0000), + SubRace.Rava => gender == Gender.Male ? (0x0000, 0x0000) : (0x9E00, 0x9F00), + SubRace.Veena => gender == Gender.Male ? (0x0000, 0x0000) : (0xA800, 0xA900), + _ => throw new ArgumentOutOfRangeException(nameof(race), race, null), + }; + + return (CreateColorPicker(CustomizationId.SkinColor, skinOffset, 192), + CreateColorPicker(CustomizationId.HairColor, hairOffset, 192)); + } + + private Customization FromValueAndIndex(CustomizationId id, uint value, int index) + { + var row = _customizeSheet.GetRow(value); + return row == null + ? new Customization(id, (byte) index, value, 0) + : new Customization(id, row.FeatureID, row.Icon, (ushort) row.RowId); + } + + private int GetListSize(CharaMakeParams row, CustomizationId id) + { + var menu = row.Menus.Cast().FirstOrDefault(m => m!.Value.Customization == id); + return menu?.Size ?? 0; + } + + private Customization[] GetFacePaints(CharaMakeParams row) + => row.Menus.Cast().FirstOrDefault(m => m!.Value.Customization == CustomizationId.FacePaint)?.Values + .Select((v, i) => FromValueAndIndex(CustomizationId.FacePaint, v, i)).ToArray() + ?? Array.Empty(); + + private Customization[] GetTailEarShapes(CharaMakeParams row) + => row.Menus.Cast().FirstOrDefault(m => m!.Value.Customization == CustomizationId.TailEarShape)?.Values + .Select((v, i) => FromValueAndIndex(CustomizationId.TailEarShape, v, i)).ToArray() + ?? Array.Empty(); + + private Customization[] GetFaces(CharaMakeParams row) + => row.Menus.Cast().FirstOrDefault(m => m!.Value.Customization == CustomizationId.Face)?.Values + .Select((v, i) => FromValueAndIndex(CustomizationId.Face, v, i)).ToArray() + ?? Array.Empty(); + + private Customization[] HrothgarFurPattern(CharaMakeParams row) + => row.Menus.Cast().FirstOrDefault(m => m!.Value.Customization == CustomizationId.LipColor)?.Values + .Select((v, i) => FromValueAndIndex(CustomizationId.LipColor, v, i)).ToArray() + ?? Array.Empty(); + + private Customization[] HrothgarFaces(CharaMakeParams row) + => row.Menus.Cast().FirstOrDefault(m => m!.Value.Customization == CustomizationId.Hairstyle)?.Values + .Select((v, i) => FromValueAndIndex(CustomizationId.Hairstyle, v, i)).ToArray() + ?? Array.Empty(); + + private CustomizationSet GetSet(SubRace race, Gender gender) + { + var (skin, hair) = GetColors(race, gender); + var row = _listSheet.GetRow(((uint) race - 1) * 2 - 1 + (uint) gender); + var set = new CustomizationSet(race, gender) + { + HairStyles = race.ToRace() == Race.Hrothgar ? HrothgarFaces(row) : GetHairStyles(race, gender), + HairColors = hair, + SkinColors = skin, + EyeColors = _eyeColorPicker, + HighlightColors = _highlightPicker, + TattooColors = _tattooColorPicker, + LipColorsDark = race.ToRace() == Race.Hrothgar ? HrothgarFurPattern(row) : _lipColorPickerDark, + LipColorsLight = race.ToRace() == Race.Hrothgar ? Array.Empty() : _lipColorPickerLight, + FacePaintColorsDark = _facePaintColorPickerDark, + FacePaintColorsLight = _facePaintColorPickerLight, + Faces = GetFaces(row), + NumEyebrows = GetListSize(row, CustomizationId.Eyebrows), + NumEyeShapes = GetListSize(row, CustomizationId.EyeShape), + NumNoseShapes = GetListSize(row, CustomizationId.Nose), + NumJawShapes = GetListSize(row, CustomizationId.Jaw), + NumMouthShapes = GetListSize(row, CustomizationId.Mouth), + FacePaints = GetFacePaints(row), + TailEarShapes = GetTailEarShapes(row), + }; + + if (GetListSize(row, CustomizationId.BustSize) > 0) + set.SetAvailable(CustomizationId.BustSize); + if (GetListSize(row, CustomizationId.MuscleToneOrTailEarLength) > 0) + set.SetAvailable(CustomizationId.MuscleToneOrTailEarLength); + + if (set.NumEyebrows > 0) + set.SetAvailable(CustomizationId.Eyebrows); + if (set.NumEyeShapes > 0) + set.SetAvailable(CustomizationId.EyeShape); + if (set.NumNoseShapes > 0) + set.SetAvailable(CustomizationId.Nose); + if (set.NumJawShapes > 0) + set.SetAvailable(CustomizationId.Jaw); + if (set.NumMouthShapes > 0) + set.SetAvailable(CustomizationId.Mouth); + if (set.FacePaints.Count > 0) + { + set.SetAvailable(CustomizationId.FacePaint); + set.SetAvailable(CustomizationId.FacePaintColor); + } + if (set.TailEarShapes.Count > 0) + set.SetAvailable(CustomizationId.TailEarShape); + if (set.Faces.Count > 0) + set.SetAvailable(CustomizationId.Face); + + + + var count = race.ToRace() == Race.Hrothgar ? set.HairStyles.Count : set.Faces.Count; + var featureDict = new List>(count); + for (var i = 0; i < count; ++i) + { + featureDict.Add(row.FacialFeatureByFace[i].Icons.Select((val, idx) + => new Customization(CustomizationId.FacialFeaturesTattoos, (byte) (1 << idx), val, (ushort) (i * 8 + idx))) + .Append(new Customization(CustomizationId.FacialFeaturesTattoos, 1 << 7, 137905, (ushort) ((i + 1) * 8))) + .ToArray()); + } + + set.FeaturesTattoos = featureDict; + + return set; + } + + private readonly ExcelSheet _customizeSheet; + private readonly ExcelSheet _listSheet; + private readonly ExcelSheet _hairSheet; + private readonly CmpFile _cmpFile; + private readonly Customization[] _highlightPicker; + private readonly Customization[] _eyeColorPicker; + private readonly Customization[] _facePaintColorPickerDark; + private readonly Customization[] _facePaintColorPickerLight; + private readonly Customization[] _lipColorPickerDark; + private readonly Customization[] _lipColorPickerLight; + private readonly Customization[] _tattooColorPicker; + + private static Language FromClientLanguage(ClientLanguage language) + => language switch + { + ClientLanguage.English => Language.English, + ClientLanguage.French => Language.French, + ClientLanguage.German => Language.German, + ClientLanguage.Japanese => Language.Japanese, + _ => Language.English, + }; + + internal CustomizationOptions(DalamudPluginInterface pi) + { + _cmpFile = new CmpFile(pi); + _customizeSheet = pi.Data.GetExcelSheet(); + var tmp = pi.Data.Excel.GetType()!.GetMethod("GetSheet", BindingFlags.Instance | BindingFlags.NonPublic)! + .MakeGenericMethod(typeof(CharaMakeParams))!.Invoke(pi.Data.Excel, new object?[] + { + "charamaketype", + FromClientLanguage(pi.ClientState.ClientLanguage), + null, + }) as ExcelSheet; + _listSheet = tmp!; + _hairSheet = pi.Data.GetExcelSheet(); + + _highlightPicker = CreateColorPicker(CustomizationId.HighlightColor, 256, 192); + _lipColorPickerDark = CreateColorPicker(CustomizationId.LipColor, 512, 96); + _lipColorPickerLight = CreateColorPicker(CustomizationId.LipColor, 1024, 96, true); + _eyeColorPicker = CreateColorPicker(CustomizationId.EyeColorL, 0, 192); + _facePaintColorPickerDark = CreateColorPicker(CustomizationId.FacePaintColor, 640, 96); + _facePaintColorPickerLight = CreateColorPicker(CustomizationId.FacePaintColor, 1152, 96, true); + _tattooColorPicker = CreateColorPicker(CustomizationId.TattooColor, 0, 192); + + _icons = new IconStorage(pi, _list.Length * 50); + foreach (var race in Clans) + { + foreach (var gender in Genders) + _list[ToIndex(race, gender)] = GetSet(race, gender); + } + } + } +} diff --git a/Glamourer.GameData/Customization/CustomizationSet.cs b/Glamourer.GameData/Customization/CustomizationSet.cs new file mode 100644 index 0000000..9741441 --- /dev/null +++ b/Glamourer.GameData/Customization/CustomizationSet.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using Penumbra.GameData.Enums; + +namespace Glamourer.Customization +{ + public class CustomizationSet + { + public const int DefaultAvailable = + (1 << (int) CustomizationId.Height) + | (1 << (int) CustomizationId.Hairstyle) + | (1 << (int) CustomizationId.HighlightsOnFlag) + | (1 << (int) CustomizationId.SkinColor) + | (1 << (int) CustomizationId.EyeColorR) + | (1 << (int) CustomizationId.EyeColorL) + | (1 << (int) CustomizationId.HairColor) + | (1 << (int) CustomizationId.HighlightColor) + | (1 << (int) CustomizationId.FacialFeaturesTattoos) + | (1 << (int) CustomizationId.TattooColor) + | (1 << (int) CustomizationId.LipColor) + | (1 << (int) CustomizationId.Height); + + internal CustomizationSet(SubRace clan, Gender gender) + { + Gender = gender; + Clan = clan; + _settingAvailable = + clan.ToRace() == Race.Viera && gender == Gender.Male + || clan.ToRace() == Race.Hrothgar && gender == Gender.Female + ? 0 + : DefaultAvailable; + } + + public Gender Gender { get; } + public SubRace Clan { get; } + + public Race Race + => Clan.ToRace(); + + private int _settingAvailable = DefaultAvailable; + + internal void SetAvailable(CustomizationId id) + => _settingAvailable |= 1 << (int) id; + + public bool IsAvailable(CustomizationId id) + => (_settingAvailable & (1 << (int) id)) != 0; + + public int NumEyebrows { get; internal set; } + public int NumEyeShapes { get; internal set; } + public int NumNoseShapes { get; internal set; } + public int NumJawShapes { get; internal set; } + public int NumMouthShapes { get; internal set; } + + + public IReadOnlyList Faces { get; internal set; } = null!; + public IReadOnlyList HairStyles { get; internal set; } = null!; + public IReadOnlyList TailEarShapes { get; internal set; } = null!; + public IReadOnlyList> FeaturesTattoos { get; internal set; } = null!; + public IReadOnlyList FacePaints { get; internal set; } = null!; + + public IReadOnlyList SkinColors { get; internal set; } = null!; + public IReadOnlyList HairColors { get; internal set; } = null!; + public IReadOnlyList HighlightColors { get; internal set; } = null!; + public IReadOnlyList EyeColors { get; internal set; } = null!; + public IReadOnlyList TattooColors { get; internal set; } = null!; + public IReadOnlyList FacePaintColorsLight { get; internal set; } = null!; + public IReadOnlyList FacePaintColorsDark { get; internal set; } = null!; + public IReadOnlyList LipColorsLight { get; internal set; } = null!; + public IReadOnlyList LipColorsDark { get; internal set; } = null!; + + public IReadOnlyDictionary OptionName { get; internal set; } = null!; + + public Customization FacialFeature(int faceIdx, int idx) + => FeaturesTattoos[faceIdx][idx]; + + public Customization Data(CustomizationId id, int idx) + { + if (idx > Count(id)) + throw new IndexOutOfRangeException(); + + switch (id.ToType()) + { + case CharaMakeParams.MenuType.Percentage: return new Customization(id, (byte) idx, 0, (ushort) idx); + case CharaMakeParams.MenuType.ListSelector: return new Customization(id, (byte) idx, 0, (ushort) idx); + } + + return id switch + { + CustomizationId.Face => Faces[idx], + CustomizationId.Hairstyle => HairStyles[idx], + CustomizationId.TailEarShape => TailEarShapes[idx], + CustomizationId.FacePaint => FacePaints[idx], + CustomizationId.FacialFeaturesTattoos => FeaturesTattoos[0][idx], + + CustomizationId.SkinColor => SkinColors[idx], + CustomizationId.EyeColorL => EyeColors[idx], + CustomizationId.EyeColorR => EyeColors[idx], + CustomizationId.HairColor => HairColors[idx], + CustomizationId.HighlightColor => HighlightColors[idx], + CustomizationId.TattooColor => TattooColors[idx], + CustomizationId.LipColor => idx < 96 ? LipColorsDark[idx] : LipColorsLight[idx - 96], + CustomizationId.FacePaintColor => idx < 96 ? FacePaintColorsDark[idx] : FacePaintColorsLight[idx - 96], + _ => new Customization(0, 0), + }; + } + + public int Count(CustomizationId id) + { + if (!IsAvailable(id)) + return 0; + + if (id.ToType() == CharaMakeParams.MenuType.Percentage) + return 101; + + return id switch + { + CustomizationId.Face => Faces.Count, + CustomizationId.Hairstyle => HairStyles.Count, + CustomizationId.HighlightsOnFlag => 2, + CustomizationId.SkinColor => SkinColors.Count, + CustomizationId.EyeColorR => EyeColors.Count, + CustomizationId.HairColor => HairColors.Count, + CustomizationId.HighlightColor => HighlightColors.Count, + CustomizationId.FacialFeaturesTattoos => 8, + CustomizationId.TattooColor => TattooColors.Count, + CustomizationId.Eyebrows => NumEyebrows, + CustomizationId.EyeColorL => EyeColors.Count, + CustomizationId.EyeShape => NumEyeShapes, + CustomizationId.Nose => NumNoseShapes, + CustomizationId.Jaw => NumJawShapes, + CustomizationId.Mouth => NumMouthShapes, + CustomizationId.LipColor => LipColorsLight.Count + LipColorsDark.Count, + CustomizationId.TailEarShape => TailEarShapes.Count, + CustomizationId.FacePaint => FacePaints.Count, + CustomizationId.FacePaintColor => FacePaintColorsLight.Count + FacePaintColorsDark.Count, + _ => throw new ArgumentOutOfRangeException(nameof(id), id, null), + }; + } + } +} diff --git a/Glamourer.GameData/Customization/ICustomizationManager.cs b/Glamourer.GameData/Customization/ICustomizationManager.cs new file mode 100644 index 0000000..8e03285 --- /dev/null +++ b/Glamourer.GameData/Customization/ICustomizationManager.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using Penumbra.GameData.Enums; + +namespace Glamourer.Customization +{ + public interface ICustomizationManager + { + public IReadOnlyList Races { get; } + public IReadOnlyList Clans { get; } + public IReadOnlyList Genders { get; } + + public CustomizationSet GetList(SubRace race, Gender gender); + + public ImGuiScene.TextureWrap GetIcon(uint iconId); + } +} diff --git a/Glamourer.GameData/Glamourer - Backup.GameData.csproj b/Glamourer.GameData/Glamourer - Backup.GameData.csproj new file mode 100644 index 0000000..74d1588 --- /dev/null +++ b/Glamourer.GameData/Glamourer - Backup.GameData.csproj @@ -0,0 +1,61 @@ + + + net472 + preview + Glamourer + Glamourer.GameData + 1.0.0.0 + 1.0.0.0 + SoftOtter + Glamourer + Copyright © 2020 + true + Library + 4 + true + enable + bin\$(Configuration)\ + $(MSBuildWarningsAsMessages);MSB3277 + + + + full + DEBUG;TRACE + + + + pdbonly + + + + $(MSBuildWarningsAsMessages);MSB3277 + + + + + $(DALAMUD_ROOT)\Dalamud.dll + ..\libs\Dalamud.dll + $(AppData)\XIVLauncher\addon\Hooks\dev\Dalamud.dll + False + + + $(DALAMUD_ROOT)\Lumina.dll + ..\libs\Lumina.dll + $(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.dll + False + + + $(DALAMUD_ROOT)\Lumina.Excel.dll + ..\libs\Lumina.Excel.dll + $(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.Excel.dll + False + + + ..\..\Penumbra\Penumbra\bin\$(Configuration)\net472\Penumbra.GameData.dll + + + + + + + diff --git a/Glamourer.GameData/Glamourer.GameData.csproj b/Glamourer.GameData/Glamourer.GameData.csproj new file mode 100644 index 0000000..7c1be7c --- /dev/null +++ b/Glamourer.GameData/Glamourer.GameData.csproj @@ -0,0 +1,61 @@ + + + net472 + preview + Glamourer + Glamourer.GameData + 1.0.0.0 + 1.0.0.0 + SoftOtter + Glamourer + Copyright © 2020 + true + Library + 4 + true + enable + bin\$(Configuration)\ + $(MSBuildWarningsAsMessages);MSB3277 + + + + full + DEBUG;TRACE + + + + pdbonly + + + + $(MSBuildWarningsAsMessages);MSB3277 + + + + + $(DALAMUD_ROOT)\Dalamud.dll + ..\libs\Dalamud.dll + $(AppData)\XIVLauncher\addon\Hooks\dev\Dalamud.dll + False + + + $(AppData)\XIVLauncher\addon\Hooks\dev\ImGuiScene.dll + False + + + $(DALAMUD_ROOT)\Lumina.dll + ..\libs\Lumina.dll + $(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.dll + False + + + $(DALAMUD_ROOT)\Lumina.Excel.dll + ..\libs\Lumina.Excel.dll + $(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.Excel.dll + False + + + ..\..\Penumbra\Penumbra\bin\$(Configuration)\net472\Penumbra.GameData.dll + + + diff --git a/Glamourer.GameData/Item.cs b/Glamourer.GameData/Item.cs new file mode 100644 index 0000000..f755d49 --- /dev/null +++ b/Glamourer.GameData/Item.cs @@ -0,0 +1,45 @@ +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; + +namespace Glamourer +{ + public readonly struct Item + { + private static (SetId id, WeaponType type, ushort variant) ParseModel(EquipSlot slot, ulong data) + { + if (slot == EquipSlot.MainHand || slot == EquipSlot.OffHand) + { + var id = (SetId) (data & 0xFFFF); + var type = (WeaponType) ((data >> 16) & 0xFFFF); + var variant = (ushort) ((data >> 32) & 0xFFFF); + return (id, type, variant); + } + else + { + var id = (SetId) (data & 0xFFFF); + var variant = (byte) ((data >> 16) & 0xFF); + return (id, new WeaponType(), variant); + } + } + + public readonly Lumina.Excel.GeneratedSheets.Item Base; + public readonly string Name; + public readonly EquipSlot EquippableTo; + + public (SetId id, WeaponType type, ushort variant) MainModel + => ParseModel(EquippableTo, Base.ModelMain); + + public bool HasSubModel + => Base.ModelSub != 0; + + public (SetId id, WeaponType type, ushort variant) SubModel + => ParseModel(EquippableTo, Base.ModelSub); + + public Item(Lumina.Excel.GeneratedSheets.Item item, string name, EquipSlot slot = EquipSlot.Unknown) + { + Base = item; + Name = name; + EquippableTo = slot == EquipSlot.Unknown ? ((EquipSlot) item.EquipSlotCategory.Row).ToSlot() : slot; + } + } +} diff --git a/Glamourer.GameData/Main.cs b/Glamourer.GameData/Main.cs new file mode 100644 index 0000000..40c64ae --- /dev/null +++ b/Glamourer.GameData/Main.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dalamud.Plugin; +using Penumbra.GameData.Enums; + +namespace Glamourer +{ + public static class GameData + { + private static Dictionary? _stains; + private static Dictionary>? _itemsBySlot; + + public static IReadOnlyDictionary Stains(DalamudPluginInterface pi) + { + if (_stains != null) + return _stains; + + var sheet = pi.Data.GetExcelSheet(); + _stains = sheet.Where(s => s.Color != 0).ToDictionary(s => (byte) s.RowId, s => new Stain((byte) s.RowId, s)); + return _stains; + } + + public static IReadOnlyDictionary> ItemsBySlot(DalamudPluginInterface pi) + { + if (_itemsBySlot != null) + return _itemsBySlot; + + var sheet = pi.Data.GetExcelSheet(); + + Item EmptySlot(EquipSlot slot) + => new(sheet.First(), "Nothing", slot); + + _itemsBySlot = new Dictionary>() + { + [EquipSlot.Head] = new(200) { EmptySlot(EquipSlot.Head) }, + [EquipSlot.Body] = new(200) { EmptySlot(EquipSlot.Body) }, + [EquipSlot.Hands] = new(200) { EmptySlot(EquipSlot.Hands) }, + [EquipSlot.Legs] = new(200) { EmptySlot(EquipSlot.Legs) }, + [EquipSlot.Feet] = new(200) { EmptySlot(EquipSlot.Feet) }, + [EquipSlot.RFinger] = new(200) { EmptySlot(EquipSlot.RFinger) }, + [EquipSlot.Neck] = new(200) { EmptySlot(EquipSlot.Neck) }, + [EquipSlot.MainHand] = new(200) { EmptySlot(EquipSlot.MainHand) }, + [EquipSlot.OffHand] = new(200) { EmptySlot(EquipSlot.OffHand) }, + [EquipSlot.Wrists] = new(200) { EmptySlot(EquipSlot.Wrists) }, + [EquipSlot.Ears] = new(200) { EmptySlot(EquipSlot.Ears) }, + }; + + foreach (var item in sheet) + { + var name = item.Name.ToString(); + if (!name.Any()) + continue; + + var slot = (EquipSlot) item.EquipSlotCategory.Row; + if (slot == EquipSlot.Unknown) + continue; + + slot = slot.ToSlot(); + if (!_itemsBySlot.TryGetValue(slot, out var list)) + continue; + + list.Add(new Item(item, name, slot)); + } + + foreach (var list in _itemsBySlot.Values) + list.Sort((i1, i2) => string.Compare(i1.Name, i2.Name, StringComparison.InvariantCulture)); + + _itemsBySlot[EquipSlot.LFinger] = _itemsBySlot[EquipSlot.RFinger]; + return _itemsBySlot; + } + } +} diff --git a/Glamourer.GameData/Stain.cs b/Glamourer.GameData/Stain.cs new file mode 100644 index 0000000..9ff0b32 --- /dev/null +++ b/Glamourer.GameData/Stain.cs @@ -0,0 +1,41 @@ +using Penumbra.GameData.Structs; + +namespace Glamourer +{ + public readonly struct Stain + { + public readonly string Name; + public readonly uint RgbaColor; + + private readonly uint _seColorId; + + public byte R + => (byte) (RgbaColor & 0xFF); + + public byte G + => (byte) ((RgbaColor >> 8) & 0xFF); + + public byte B + => (byte) ((RgbaColor >> 16) & 0xFF); + + public byte Intensity + => (byte) ((1 + R + G + B) / 3); + + public uint SeColor + => _seColorId & 0x00FFFFFF; + + public StainId RowIndex + => (StainId) (_seColorId >> 24); + + + public static uint SeColorToRgba(uint color) + => ((color & 0xFF) << 16) | ((color >> 16) & 0xFF) | (color & 0xFF00) | 0xFF000000; + + public Stain(byte index, Lumina.Excel.GeneratedSheets.Stain stain) + { + Name = stain.Name.ToString(); + _seColorId = stain.Color | ((uint) index << 24); + RgbaColor = SeColorToRgba(stain.Color); + } + } +} diff --git a/Glamourer.GameData/Util/IconStorage.cs b/Glamourer.GameData/Util/IconStorage.cs new file mode 100644 index 0000000..24aa63a --- /dev/null +++ b/Glamourer.GameData/Util/IconStorage.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using Dalamud.Data.LuminaExtensions; +using Dalamud.Plugin; +using ImGuiScene; +using Lumina.Data.Files; + +namespace Glamourer.Util +{ + public class IconStorage : IDisposable + { + private readonly DalamudPluginInterface _pi; + private readonly Dictionary _icons; + + public IconStorage(DalamudPluginInterface pi, int size = 0) + { + _pi = pi; + _icons = new Dictionary(size); + } + + public TextureWrap this[int id] + => LoadIcon(id); + + private TexFile? LoadIconHq(int id) + { + var path = $"ui/icon/{id / 1000 * 1000:000000}/{id:000000}_hr1.tex"; + return _pi.Data.GetFile(path); + } + + public TextureWrap LoadIcon(uint id) + => LoadIcon((int) id); + + public TextureWrap LoadIcon(int id) + { + if (_icons.TryGetValue(id, out var ret)) + return ret; + + var icon = LoadIconHq(id) ?? _pi.Data.GetIcon(id); + var iconData = icon.GetRgbaImageData(); + + ret = _pi.UiBuilder.LoadImageRaw(iconData, icon.Header.Width, icon.Header.Height, 4); + _icons[id] = ret; + return ret; + } + + public void Dispose() + { + foreach (var icon in _icons.Values) + icon.Dispose(); + _icons.Clear(); + } + + ~IconStorage() + => Dispose(); + } +} diff --git a/Glamourer.json b/Glamourer.json new file mode 100644 index 0000000..85a0e2d --- /dev/null +++ b/Glamourer.json @@ -0,0 +1,11 @@ +{ + "Author": "Ottermandias", + "Name": "Glamourer", + "Description": "Adds functionality to change appearance of actors. Requires Penumbra to be installed and activated to work.", + "InternalName": "Glamourer", + "AssemblyVersion": "1.0.0.0", + "RepoUrl": "https://github.com/Ottermandias/Glamourer", + "ApplicableVersion": "any", + "DalamudApiLevel": 3, + "LoadPriority": -100 +} \ No newline at end of file diff --git a/Glamourer.sln b/Glamourer.sln new file mode 100644 index 0000000..8b1bc26 --- /dev/null +++ b/Glamourer.sln @@ -0,0 +1,57 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29613.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Glamourer", "Glamourer\Glamourer.csproj", "{A5439F6B-83C1-4078-9371-354A147FF554}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{383AEE76-D423-431C-893A-7AB3DEA13630}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + repo.json = repo.json + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Glamourer.GameData", "Glamourer.GameData\Glamourer.GameData.csproj", "{51F4DDB0-1FA0-4629-9CFE-C55B6062907B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A5439F6B-83C1-4078-9371-354A147FF554}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5439F6B-83C1-4078-9371-354A147FF554}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5439F6B-83C1-4078-9371-354A147FF554}.Debug|x64.ActiveCfg = Debug|Any CPU + {A5439F6B-83C1-4078-9371-354A147FF554}.Debug|x64.Build.0 = Debug|Any CPU + {A5439F6B-83C1-4078-9371-354A147FF554}.Debug|x86.ActiveCfg = Debug|Any CPU + {A5439F6B-83C1-4078-9371-354A147FF554}.Debug|x86.Build.0 = Debug|Any CPU + {A5439F6B-83C1-4078-9371-354A147FF554}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5439F6B-83C1-4078-9371-354A147FF554}.Release|Any CPU.Build.0 = Release|Any CPU + {A5439F6B-83C1-4078-9371-354A147FF554}.Release|x64.ActiveCfg = Release|Any CPU + {A5439F6B-83C1-4078-9371-354A147FF554}.Release|x64.Build.0 = Release|Any CPU + {A5439F6B-83C1-4078-9371-354A147FF554}.Release|x86.ActiveCfg = Release|Any CPU + {A5439F6B-83C1-4078-9371-354A147FF554}.Release|x86.Build.0 = Release|Any CPU + {51F4DDB0-1FA0-4629-9CFE-C55B6062907B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51F4DDB0-1FA0-4629-9CFE-C55B6062907B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51F4DDB0-1FA0-4629-9CFE-C55B6062907B}.Debug|x64.ActiveCfg = Debug|Any CPU + {51F4DDB0-1FA0-4629-9CFE-C55B6062907B}.Debug|x64.Build.0 = Debug|Any CPU + {51F4DDB0-1FA0-4629-9CFE-C55B6062907B}.Debug|x86.ActiveCfg = Debug|Any CPU + {51F4DDB0-1FA0-4629-9CFE-C55B6062907B}.Debug|x86.Build.0 = Debug|Any CPU + {51F4DDB0-1FA0-4629-9CFE-C55B6062907B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51F4DDB0-1FA0-4629-9CFE-C55B6062907B}.Release|Any CPU.Build.0 = Release|Any CPU + {51F4DDB0-1FA0-4629-9CFE-C55B6062907B}.Release|x64.ActiveCfg = Release|Any CPU + {51F4DDB0-1FA0-4629-9CFE-C55B6062907B}.Release|x64.Build.0 = Release|Any CPU + {51F4DDB0-1FA0-4629-9CFE-C55B6062907B}.Release|x86.ActiveCfg = Release|Any CPU + {51F4DDB0-1FA0-4629-9CFE-C55B6062907B}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {37674B88-2038-4C63-A979-84404391773A} + EndGlobalSection +EndGlobal diff --git a/Glamourer.sln.DotSettings.user b/Glamourer.sln.DotSettings.user new file mode 100644 index 0000000..e414fe4 --- /dev/null +++ b/Glamourer.sln.DotSettings.user @@ -0,0 +1,4 @@ + + <AssemblyExplorer> + <Assembly Path="H:\Projects\FFPlugins\Penumbra\Penumbra\bin\Debug\net472\Penumbra.GameData.dll" /> +</AssemblyExplorer> \ No newline at end of file diff --git a/Glamourer.zip b/Glamourer.zip new file mode 100644 index 0000000000000000000000000000000000000000..4c4ae57c4e63e0ae03f134a76c504b088253bd18 GIT binary patch literal 30455 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 literal 0 HcmV?d00001 diff --git a/Glamourer/CmpFile.cs b/Glamourer/CmpFile.cs new file mode 100644 index 0000000..e320ab2 --- /dev/null +++ b/Glamourer/CmpFile.cs @@ -0,0 +1,23 @@ +using Dalamud.Plugin; + +namespace Glamourer +{ + public class CmpFile + { + public readonly Lumina.Data.FileResource File; + public readonly uint[] RgbaColors; + + public CmpFile(DalamudPluginInterface pi) + { + File = pi.Data.GetFile("chara/xls/charamake/human.cmp"); + RgbaColors = new uint[File.Data.Length >> 2]; + for (var i = 0; i < File.Data.Length; i += 4) + { + RgbaColors[i >> 2] = File.Data[i] + | (uint) (File.Data[i + 1] << 8) + | (uint) (File.Data[i + 2] << 16) + | (uint) (File.Data[i + 3] << 24); + } + } + } +} diff --git a/Glamourer/Glamourer.csproj b/Glamourer/Glamourer.csproj new file mode 100644 index 0000000..5670e92 --- /dev/null +++ b/Glamourer/Glamourer.csproj @@ -0,0 +1,98 @@ + + + net472 + preview + Glamourer + Glamourer + 1.0.0.0 + 1.0.0.0 + SoftOtter + Glamourer + Copyright © 2020 + true + Library + 4 + true + enable + bin\$(Configuration)\ + $(MSBuildWarningsAsMessages);MSB3277 + + + + true + full + false + DEBUG;TRACE + + + + pdbonly + true + TRACE + + + + OnOutputUpdated + + + + + $(appdata)\XIVLauncher\addon\Hooks\dev\Dalamud.dll + + + $(appdata)\XIVLauncher\addon\Hooks\dev\ImGui.NET.dll + + + $(appdata)\XIVLauncher\addon\Hooks\dev\ImGuiScene.dll + + + $(appdata)\XIVLauncher\addon\Hooks\dev\SDL2-CS.dll + + + $(appdata)\XIVLauncher\addon\Hooks\dev\Lumina.dll + + + $(appdata)\XIVLauncher\addon\Hooks\dev\Lumina.Excel.dll + + + ..\..\Penumbra\Penumbra\bin\$(Configuration)\net472\Penumbra.GameData.dll + + + ..\..\Penumbra\Penumbra\bin\$(Configuration)\net472\Penumbra.Api.dll + + + ..\..\Penumbra\Penumbra\bin\$(Configuration)\net472\Penumbra.PlayerWatch.dll + + + + + + + + + + + + + + + + 12.0.3 + + + + + + + PreserveNewest + + + + + + + + + + + \ No newline at end of file diff --git a/Glamourer/Glamourer.json b/Glamourer/Glamourer.json new file mode 100644 index 0000000..85a0e2d --- /dev/null +++ b/Glamourer/Glamourer.json @@ -0,0 +1,11 @@ +{ + "Author": "Ottermandias", + "Name": "Glamourer", + "Description": "Adds functionality to change appearance of actors. Requires Penumbra to be installed and activated to work.", + "InternalName": "Glamourer", + "AssemblyVersion": "1.0.0.0", + "RepoUrl": "https://github.com/Ottermandias/Glamourer", + "ApplicableVersion": "any", + "DalamudApiLevel": 3, + "LoadPriority": -100 +} \ No newline at end of file diff --git a/Glamourer/Gui/ComboWithFilter.cs b/Glamourer/Gui/ComboWithFilter.cs new file mode 100644 index 0000000..2729ff7 --- /dev/null +++ b/Glamourer/Gui/ComboWithFilter.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using ImGuiNET; + +namespace Glamourer.Gui +{ + public class ComboWithFilter + { + private readonly string _label; + private readonly string _filterLabel; + private readonly string _listLabel; + private string _currentFilter = string.Empty; + private string _currentFilterLower = string.Empty; + private bool _focus; + private readonly float _size; + private readonly IReadOnlyList _items; + private readonly IReadOnlyList _itemNamesLower; + private readonly Func _itemToName; + + public Action? PrePreview = null; + public Action? PostPreview = null; + public Func? CreateSelectable = null; + public Action? PreList = null; + public Action? PostList = null; + public float? HeightPerItem = null; + + private float _heightPerItem; + + public ImGuiComboFlags Flags { get; set; } = ImGuiComboFlags.None; + public int ItemsAtOnce { get; set; } = 12; + + public ComboWithFilter(string label, float size, IReadOnlyList items, Func itemToName) + { + _label = label; + _filterLabel = $"##_{label}_filter"; + _listLabel = $"##_{label}_list"; + _itemToName = itemToName; + _items = items; + _size = size; + + _itemNamesLower = _items.Select(i => _itemToName(i).ToLowerInvariant()).ToList(); + } + + public ComboWithFilter(string label, ComboWithFilter other) + { + _label = label; + _filterLabel = $"##_{label}_filter"; + _listLabel = $"##_{label}_list"; + _itemToName = other._itemToName; + _items = other._items; + _itemNamesLower = other._itemNamesLower; + _size = other._size; + PrePreview = other.PrePreview; + PostPreview = other.PostPreview; + CreateSelectable = other.CreateSelectable; + PreList = other.PreList; + PostList = other.PostList; + HeightPerItem = other.HeightPerItem; + Flags = other.Flags; + } + + private bool DrawList(string currentName, out int numItems, out int nodeIdx, ref T? value) + { + numItems = ItemsAtOnce; + nodeIdx = -1; + if (!ImGui.BeginChild(_listLabel, new Vector2(_size, ItemsAtOnce * _heightPerItem))) + return false; + + var ret = false; + try + { + if (!_focus) + { + ImGui.SetScrollY(0); + _focus = true; + } + + + var scrollY = Math.Max((int) (ImGui.GetScrollY() / _heightPerItem) - 1, 0); + var restHeight = scrollY * _heightPerItem; + numItems = 0; + nodeIdx = 0; + + if (restHeight > 0) + ImGui.Dummy(Vector2.UnitY * restHeight); + + for (var i = scrollY; i < _items.Count; ++i) + { + if (!_itemNamesLower[i].Contains(_currentFilterLower)) + continue; + + ++numItems; + if (numItems <= ItemsAtOnce + 2) + { + nodeIdx = i; + var item = _items[i]!; + var success = false; + if (CreateSelectable != null) + { + success = CreateSelectable(item); + } + else + { + var name = _itemToName(item); + success = ImGui.Selectable(name, name == currentName); + } + + if (success) + { + value = item; + ImGui.CloseCurrentPopup(); + ret = true; + } + } + } + + if (numItems > ItemsAtOnce + 2) + ImGui.Dummy(Vector2.UnitY * (numItems - ItemsAtOnce - 2) * _heightPerItem); + } + finally + { + ImGui.EndChild(); + } + + return ret; + } + + public bool Draw(string currentName, out T? value) + { + value = default; + ImGui.SetNextItemWidth(_size); + PrePreview?.Invoke(); + if (!ImGui.BeginCombo(_label, currentName, Flags)) + { + _focus = false; + _currentFilter = string.Empty; + _currentFilterLower = string.Empty; + PostPreview?.Invoke(); + return false; + } + + PostPreview?.Invoke(); + + _heightPerItem = HeightPerItem ?? ImGui.GetTextLineHeightWithSpacing(); + + var ret = false; + try + { + ImGui.SetNextItemWidth(-1); + if (ImGui.InputTextWithHint(_filterLabel, "Filter...", ref _currentFilter, 255)) + _currentFilterLower = _currentFilter.ToLowerInvariant(); + + var isFocused = ImGui.IsItemActive(); + if (!_focus) + ImGui.SetKeyboardFocusHere(); + + PreList?.Invoke(); + ret = DrawList(currentName, out var numItems, out var nodeIdx, ref value); + PostList?.Invoke(); + + if (!isFocused && numItems <= 1 && nodeIdx >= 0) + { + value = _items[nodeIdx]; + ret = true; + ImGui.CloseCurrentPopup(); + } + } + finally + { + ImGui.EndCombo(); + } + + return ret; + } + } +} diff --git a/Glamourer/Gui/ImGuiRaii.cs b/Glamourer/Gui/ImGuiRaii.cs new file mode 100644 index 0000000..8aba17b --- /dev/null +++ b/Glamourer/Gui/ImGuiRaii.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using ImGuiNET; + +namespace Glamourer.Gui +{ + public sealed class ImGuiRaii : IDisposable + { + private int _colorStack = 0; + private int _fontStack = 0; + private int _styleStack = 0; + private float _indentation = 0f; + + private Stack? _onDispose = null; + + public ImGuiRaii() + { } + + public static ImGuiRaii NewGroup() + => new ImGuiRaii().Group(); + + public ImGuiRaii Group() + => Begin(ImGui.BeginGroup, ImGui.EndGroup); + + public static ImGuiRaii NewTooltip() + => new ImGuiRaii().Tooltip(); + + public ImGuiRaii Tooltip() + => Begin(ImGui.BeginTooltip, ImGui.EndTooltip); + + public ImGuiRaii PushColor(ImGuiCol which, uint color) + { + ImGui.PushStyleColor(which, color); + ++_colorStack; + return this; + } + + public ImGuiRaii PushColor(ImGuiCol which, Vector4 color) + { + ImGui.PushStyleColor(which, color); + ++_colorStack; + return this; + } + + public ImGuiRaii PopColors(int n = 1) + { + var actualN = Math.Min(n, _colorStack); + if (actualN > 0) + { + ImGui.PopStyleColor(actualN); + _colorStack -= actualN; + } + return this; + } + + public ImGuiRaii PushStyle(ImGuiStyleVar style, Vector2 value) + { + ImGui.PushStyleVar(style, value); + ++_styleStack; + return this; + } + + public ImGuiRaii PushStyle(ImGuiStyleVar style, float value) + { + ImGui.PushStyleVar(style, value); + ++_styleStack; + return this; + } + + public ImGuiRaii PopStyles(int n = 1) + { + var actualN = Math.Min(n, _styleStack); + if (actualN > 0) + { + ImGui.PopStyleVar(actualN); + _styleStack -= actualN; + } + return this; + } + + public ImGuiRaii PushFont(ImFontPtr font) + { + ImGui.PushFont(font); + ++_fontStack; + return this; + } + + public ImGuiRaii PopFonts(int n = 1) + { + var actualN = Math.Min(n, _fontStack); + + while (actualN-- > 0) + { + ImGui.PopFont(); + --_fontStack; + } + return this; + } + + public ImGuiRaii Indent(float width) + { + if (width != 0) + { + ImGui.Indent(width); + _indentation += width; + } + return this; + } + + public ImGuiRaii Unindent(float width) + => Indent(-width); + + public bool Begin(Func begin, Action end) + { + if (begin()) + { + _onDispose ??= new Stack(); + _onDispose.Push(end); + return true; + } + + return false; + } + + public ImGuiRaii Begin(Action begin, Action end) + { + begin(); + _onDispose ??= new Stack(); + _onDispose.Push(end); + return this; + } + + public void End(int n = 1) + { + var actualN = Math.Min(n, _onDispose?.Count ?? 0); + while(actualN-- > 0) + _onDispose!.Pop()(); + } + + public void Dispose() + { + Unindent(_indentation); + PopColors(_colorStack); + PopStyles(_styleStack); + PopFonts(_fontStack); + if (_onDispose != null) + { + End(_onDispose.Count); + _onDispose = null; + } + } + } +} diff --git a/Glamourer/Gui/Interface.cs b/Glamourer/Gui/Interface.cs new file mode 100644 index 0000000..95b9e7d --- /dev/null +++ b/Glamourer/Gui/Interface.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Dalamud.Data.LuminaExtensions; +using Dalamud.Game.ClientState.Actors; +using Dalamud.Game.ClientState.Actors.Types; +using Glamourer.Customization; +using ImGuiNET; +using Penumbra.Api; +using Penumbra.GameData; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.PlayerWatch; +using SDL2; + +namespace Glamourer.Gui +{ + internal class Interface : IDisposable + { + public const int GPoseActorId = 201; + private const string PluginName = "Glamourer"; + private readonly string _glamourerHeader; + + private const int ColorButtonWidth = 140; + + private readonly IReadOnlyDictionary _stains; + private readonly IReadOnlyDictionary> _equip; + private readonly ActorTable _actors; + private readonly IObjectIdentifier _identifier; + private readonly Dictionary, ComboWithFilter)> _combos; + private readonly IPlayerWatcher _playerWatcher; + + private bool _visible = false; + + private Actor? _player; + + private static readonly Vector2 FeatureIconSize = new(80, 80); + + + public Interface() + { + _glamourerHeader = GlamourerPlugin.Version.Length > 0 + ? $"{PluginName} v{GlamourerPlugin.Version}###{PluginName}Main" + : $"{PluginName}###{PluginName}Main"; + GlamourerPlugin.PluginInterface.UiBuilder.OnBuildUi += Draw; + GlamourerPlugin.PluginInterface.UiBuilder.OnOpenConfigUi += ToggleVisibility; + + _stains = GameData.Stains(GlamourerPlugin.PluginInterface); + _equip = GameData.ItemsBySlot(GlamourerPlugin.PluginInterface); + _identifier = Penumbra.GameData.GameData.GetIdentifier(GlamourerPlugin.PluginInterface); + _actors = GlamourerPlugin.PluginInterface.ClientState.Actors; + _playerWatcher = PlayerWatchFactory.Create(GlamourerPlugin.PluginInterface); + + var stainCombo = new ComboWithFilter("##StainCombo", ColorButtonWidth, _stains.Values.ToArray(), + s => s.Name.ToString()) + { + Flags = ImGuiComboFlags.NoArrowButton | ImGuiComboFlags.HeightLarge, + PreList = () => + { + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 0); + }, + PostList = () => { ImGui.PopStyleVar(3); }, + CreateSelectable = s => + { + var push = PushColor(s); + var ret = ImGui.Button($"{s.Name}##Stain{(byte) s.RowIndex}", + Vector2.UnitX * (ColorButtonWidth - ImGui.GetStyle().ScrollbarSize)); + ImGui.PopStyleColor(push); + return ret; + }, + ItemsAtOnce = 12, + }; + + _combos = _equip.ToDictionary(kvp => kvp.Key, + kvp => (new ComboWithFilter($"{kvp.Key}##Equip", 300, kvp.Value, i => i.Name) { Flags = ImGuiComboFlags.HeightLarge } + , new ComboWithFilter($"##{kvp.Key}Stain", stainCombo)) + ); + } + + public void ToggleVisibility(object _, object _2) + => _visible = !_visible; + + public void Dispose() + { + _playerWatcher?.Dispose(); + GlamourerPlugin.PluginInterface.UiBuilder.OnBuildUi -= Draw; + GlamourerPlugin.PluginInterface.UiBuilder.OnOpenConfigUi -= ToggleVisibility; + } + + private string _currentActorName = ""; + + private static int PushColor(Stain stain, ImGuiCol type = ImGuiCol.Button) + { + ImGui.PushStyleColor(type, stain.RgbaColor); + if (stain.Intensity > 127) + { + ImGui.PushStyleColor(ImGuiCol.Text, 0xFF101010); + return 2; + } + + return 1; + } + + private bool DrawColorSelector(ComboWithFilter stainCombo, EquipSlot slot, StainId stainIdx) + { + var name = string.Empty; + stainCombo.PostPreview = null; + if (_stains.TryGetValue((byte) stainIdx, out var stain)) + { + name = stain.Name; + var previewPush = PushColor(stain, ImGuiCol.FrameBg); + stainCombo.PostPreview = () => ImGui.PopStyleColor(previewPush); + } + + if (stainCombo.Draw(name, out var newStain) && _player != null) + { + newStain.Write(_player.Address, slot); + return true; + } + + return false; + } + + private bool DrawItemSelector(ComboWithFilter equipCombo, Lumina.Excel.GeneratedSheets.Item? item) + { + var currentName = item?.Name.ToString() ?? "Nothing"; + if (equipCombo.Draw(currentName, out var newItem) && _player != null) + { + newItem.Write(_player.Address); + return true; + } + + return false; + } + + private bool DrawEquip(EquipSlot slot, ActorArmor equip) + { + var (equipCombo, stainCombo) = _combos[slot]; + + var ret = false; + ret = DrawColorSelector(stainCombo, slot, equip.Stain); + ImGui.SameLine(); + var item = _identifier.Identify(equip.Set, new WeaponType(), equip.Variant, slot); + ret |= DrawItemSelector(equipCombo, item); + + return ret; + } + + private bool DrawWeapon(EquipSlot slot, ActorWeapon weapon) + { + var (equipCombo, stainCombo) = _combos[slot]; + + var ret = DrawColorSelector(stainCombo, slot, weapon.Stain); + ImGui.SameLine(); + + + var item = _identifier.Identify(weapon.Set, weapon.Type, weapon.Variant, slot); + ret |= DrawItemSelector(equipCombo, item); + + return ret; + } + + public void UpdateActors(Actor actor) + { + var newEquip = _playerWatcher.UpdateActorWithoutEvent(actor); + GlamourerPlugin.Penumbra?.RedrawActor(actor, RedrawType.WithSettings); + + var gPose = _actors[GPoseActorId]; + var player = _actors[0]; + if (gPose != null && actor.Address == gPose.Address && player != null) + newEquip.Write(player.Address); + } + + private SubRace _currentSubRace = SubRace.Midlander; + private Gender _currentGender = Gender.Male; + private CustomizationId _currentCustomization = CustomizationId.Hairstyle; + + private static readonly string[] + SubRaceNames = ((SubRace[]) Enum.GetValues(typeof(SubRace))).Skip(1).Select(s => s.ToName()).ToArray(); + + + private void DrawStuff() + { + if (ImGui.BeginCombo("SubRace", _currentSubRace.ToString())) + { + for (var i = 0; i < SubRaceNames.Length; ++i) + { + if (ImGui.Selectable(SubRaceNames[i], (int) _currentSubRace == i + 1)) + _currentSubRace = (SubRace) (i + 1); + } + + ImGui.EndCombo(); + } + + if (ImGui.BeginCombo("Gender", _currentGender.ToName())) + { + if (ImGui.Selectable(Gender.Male.ToName(), _currentGender == Gender.Male)) + _currentGender = Gender.Male; + if (ImGui.Selectable(Gender.Female.ToName(), _currentGender == Gender.Female)) + _currentGender = Gender.Female; + ImGui.EndCombo(); + } + + + var set = GlamourerPlugin.Customization.GetList(_currentSubRace, _currentGender); + if (ImGui.BeginCombo("Customization", _currentCustomization.ToString())) + { + foreach (CustomizationId customizationId in Enum.GetValues(typeof(CustomizationId))) + { + if (!set.IsAvailable(customizationId)) + continue; + + if (ImGui.Selectable(customizationId.ToString(), customizationId == _currentCustomization)) + _currentCustomization = customizationId; + } + + ImGui.EndCombo(); + } + + var count = set.Count(_currentCustomization); + var tmp = 0; + switch (_currentCustomization.ToType(_currentSubRace.ToRace() == Race.Hrothgar)) + { + case CharaMakeParams.MenuType.ColorPicker: + { + using var raii = new ImGuiRaii().PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .PushStyle(ImGuiStyleVar.FrameRounding, 0f); + for (var i = 0; i < count; ++i) + { + var data = set.Data(_currentCustomization, i); + ImGui.ColorButton($"{data.Value}", ImGui.ColorConvertU32ToFloat4(data.Color)); + if (i % 8 != 7) + ImGui.SameLine(); + } + } + break; + case CharaMakeParams.MenuType.Percentage: + ImGui.SliderInt("Percentage", ref tmp, 0, 100); + break; + case CharaMakeParams.MenuType.ListSelector: + ImGui.Combo("List", ref tmp, Enumerable.Range(0, count).Select(i => $"{_currentCustomization} #{i}").ToArray(), count); + break; + case CharaMakeParams.MenuType.IconSelector: + case CharaMakeParams.MenuType.MultiIconSelector: + { + using var raii = new ImGuiRaii().PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .PushStyle(ImGuiStyleVar.FrameRounding, 0f); + for (var i = 0; i < count; ++i) + { + var data = set.Data(_currentCustomization, i); + var texture = GlamourerPlugin.Customization.GetIcon(data.IconId); + ImGui.ImageButton(texture.ImGuiHandle, FeatureIconSize * ImGui.GetIO().FontGlobalScale); + if (ImGui.IsItemHovered()) + { + using var tooltip = ImGuiRaii.NewTooltip(); + ImGui.Image(texture.ImGuiHandle, new Vector2(texture.Width, texture.Height)); + } + + if (i % 4 != 3) + ImGui.SameLine(); + } + } + break; + } + } + + private void Draw() + { + ImGui.SetNextWindowSizeConstraints(Vector2.One * 600, Vector2.One * 5000); + if (!_visible || !ImGui.Begin(_glamourerHeader)) + return; + + try + { + if (ImGui.BeginCombo("Actor", _currentActorName)) + { + var idx = 0; + foreach (var actor in GlamourerPlugin.PluginInterface.ClientState.Actors.Where(a => a.ObjectKind == ObjectKind.Player)) + { + if (ImGui.Selectable($"{actor.Name}##{idx++}")) + _currentActorName = actor.Name; + } + + ImGui.EndCombo(); + } + + _player = _actors[GPoseActorId] ?? _actors[0]; + if (_player == null || !GlamourerPlugin.PluginInterface.ClientState.Condition.Any()) + { + ImGui.TextColored(new Vector4(0.4f, 0.1f, 0.1f, 1f), + "No player character available."); + } + else + { + var equip = new ActorEquipment(_player); + + var changes = false; + changes |= DrawWeapon(EquipSlot.MainHand, equip.MainHand); + changes |= DrawWeapon(EquipSlot.OffHand, equip.OffHand); + changes |= DrawEquip(EquipSlot.Head, equip.Head); + changes |= DrawEquip(EquipSlot.Body, equip.Body); + changes |= DrawEquip(EquipSlot.Hands, equip.Hands); + changes |= DrawEquip(EquipSlot.Legs, equip.Legs); + changes |= DrawEquip(EquipSlot.Feet, equip.Feet); + changes |= DrawEquip(EquipSlot.Ears, equip.Ears); + changes |= DrawEquip(EquipSlot.Neck, equip.Neck); + changes |= DrawEquip(EquipSlot.Wrists, equip.Wrists); + changes |= DrawEquip(EquipSlot.RFinger, equip.RFinger); + changes |= DrawEquip(EquipSlot.LFinger, equip.LFinger); + + if (changes) + UpdateActors(_player); + } + + DrawStuff(); + } + finally + { + ImGui.End(); + } + } + } +} diff --git a/Glamourer/Main.cs b/Glamourer/Main.cs new file mode 100644 index 0000000..d7e9fdb --- /dev/null +++ b/Glamourer/Main.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Dalamud.Game.Command; +using Dalamud.Plugin; +using Glamourer.Customization; +using Glamourer.Gui; +using ImGuiNET; +using Penumbra.Api; +using CommandManager = Glamourer.Managers.CommandManager; + +namespace Glamourer +{ + internal class Glamourer + { + private readonly DalamudPluginInterface _pluginInterface; + private readonly CommandManager _commands; + + public Glamourer(DalamudPluginInterface pi) + { + _pluginInterface = pi; + _commands = new CommandManager(_pluginInterface); + } + } + + public class GlamourerPlugin : IDalamudPlugin + { + public const int RequiredPenumbraShareVersion = 1; + + public string Name + => "Glamourer"; + + public static DalamudPluginInterface PluginInterface = null!; + private Glamourer _glamourer = null!; + private Interface _interface = null!; + public static ICustomizationManager Customization = null!; + + public static string Version = string.Empty; + + public static IPenumbraApi? Penumbra; + + private Dalamud.Dalamud _dalamud = null!; + private List<(IDalamudPlugin Plugin, PluginDefinition Definition, DalamudPluginInterface PluginInterface, bool IsRaw)> _plugins = null!; + + private void SetDalamud(DalamudPluginInterface pi) + { + var dalamud = (Dalamud.Dalamud?) pi.GetType() + ?.GetField("dalamud", BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(pi); + + _dalamud = dalamud ?? throw new Exception("Could not obtain Dalamud."); + } + + private void PenumbraTooltip(object? it) + { + if (it is Lumina.Excel.GeneratedSheets.Item) + ImGui.Text("Right click to apply to current Glamourer Set. [Glamourer]"); + } + + private void PenumbraRightClick(MouseButton button, object? it) + { + if (button == MouseButton.Right && it is Lumina.Excel.GeneratedSheets.Item item) + { + var actors = PluginInterface.ClientState.Actors; + var player = actors[Interface.GPoseActorId] ?? actors[0]; + if (player != null) + { + var writeItem = new Item(item, string.Empty); + writeItem.Write(player.Address); + _interface.UpdateActors(player); + } + } + } + + private void RegisterFunctions() + { + if (Penumbra == null || !Penumbra.Valid) + return; + + Penumbra!.ChangedItemTooltip += PenumbraTooltip; + Penumbra!.ChangedItemClicked += PenumbraRightClick; + } + + private void UnregisterFunctions() + { + if (Penumbra == null || !Penumbra.Valid) + return; + + Penumbra!.ChangedItemTooltip -= PenumbraTooltip; + Penumbra!.ChangedItemClicked -= PenumbraRightClick; + } + + private void SetPlugins(DalamudPluginInterface pi) + { + var pluginManager = _dalamud?.GetType() + ?.GetProperty("PluginManager", BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetValue(_dalamud); + + if (pluginManager == null) + throw new Exception("Could not obtain plugin manager."); + + var pluginsList = + (List<(IDalamudPlugin Plugin, PluginDefinition Definition, DalamudPluginInterface PluginInterface, bool IsRaw)>?) pluginManager + ?.GetType() + ?.GetProperty("Plugins", BindingFlags.Instance | BindingFlags.Public) + ?.GetValue(pluginManager); + + _plugins = pluginsList ?? throw new Exception("Could not obtain Dalamud."); + } + + private bool GetPenumbra() + { + if (Penumbra?.Valid ?? false) + return true; + + var plugin = _plugins.Find(p + => p.Definition.InternalName == "Penumbra" + && string.Compare(p.Definition.AssemblyVersion, "0.4.0.3", StringComparison.Ordinal) >= 0).Plugin; + + var penumbra = (IPenumbraApiBase?) plugin?.GetType().GetProperty("Api", BindingFlags.Instance | BindingFlags.Public) + ?.GetValue(plugin); + if (penumbra != null && penumbra.Valid && penumbra.ApiVersion >= RequiredPenumbraShareVersion) + { + Penumbra = (IPenumbraApi) penumbra!; + RegisterFunctions(); + } + else + { + Penumbra = null; + } + + return Penumbra != null; + } + + public void Initialize(DalamudPluginInterface pluginInterface) + { + Version = Assembly.GetExecutingAssembly()?.GetName().Version.ToString() ?? ""; + PluginInterface = pluginInterface; + Customization = CustomizationManager.Create(PluginInterface); + SetDalamud(PluginInterface); + SetPlugins(PluginInterface); + GetPenumbra(); + + PluginInterface.CommandManager.AddHandler("/glamour", new CommandInfo(OnCommand) + { + HelpMessage = "/penumbra - toggle ui\n/penumbra reload - reload mod file lists & discover any new mods", + }); + + _glamourer = new Glamourer(PluginInterface); + _interface = new Interface(); + } + + public void OnCommand(string command, string arguments) + { + if (GetPenumbra()) + Penumbra!.RedrawAll(RedrawType.WithSettings); + else + PluginLog.Information("Could not get Penumbra."); + } + + + public void Dispose() + { + UnregisterFunctions(); + _interface?.Dispose(); + PluginInterface.CommandManager.RemoveHandler("/glamour"); + PluginInterface.Dispose(); + } + } +} diff --git a/Glamourer/Managers/CommandManager.cs b/Glamourer/Managers/CommandManager.cs new file mode 100644 index 0000000..68f1ab1 --- /dev/null +++ b/Glamourer/Managers/CommandManager.cs @@ -0,0 +1,73 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; +using Dalamud.Plugin; +using Glamourer.SeFunctions; + +namespace Glamourer.Managers +{ + public class CommandManager + { + private readonly ProcessChatBox _processChatBox; + private readonly Dalamud.Game.Command.CommandManager _dalamudCommands; + + private readonly IntPtr _uiModulePtr; + + public CommandManager(DalamudPluginInterface pi, BaseUiObject baseUiObject, GetUiModule getUiModule, ProcessChatBox processChatBox) + { + _dalamudCommands = pi.CommandManager; + _processChatBox = processChatBox; + _uiModulePtr = getUiModule.Invoke(Marshal.ReadIntPtr(baseUiObject.Address)); + } + + public CommandManager(DalamudPluginInterface pi) + : this(pi, new BaseUiObject(pi.TargetModuleScanner), new GetUiModule(pi.TargetModuleScanner), + new ProcessChatBox(pi.TargetModuleScanner)) + { } + + public bool Execute(string message) + { + // First try to process the command through Dalamud. + if (_dalamudCommands.ProcessCommand(message)) + { + PluginLog.Verbose("Executed Dalamud command \"{Message:l}\".", message); + return true; + } + + if (_uiModulePtr == IntPtr.Zero) + { + PluginLog.Error("Can not execute \"{Message:l}\" because no uiModulePtr is available.", message); + return false; + } + + // Then prepare a string to send to the game itself. + var (text, length) = PrepareString(message); + var payload = PrepareContainer(text, length); + + _processChatBox.Invoke(_uiModulePtr, payload, IntPtr.Zero, (byte) 0); + + Marshal.FreeHGlobal(payload); + Marshal.FreeHGlobal(text); + return false; + } + + private static (IntPtr, long) PrepareString(string message) + { + var bytes = Encoding.UTF8.GetBytes(message); + var mem = Marshal.AllocHGlobal(bytes.Length + 30); + Marshal.Copy(bytes, 0, mem, bytes.Length); + Marshal.WriteByte(mem + bytes.Length, 0); + return (mem, bytes.Length + 1); + } + + private static IntPtr PrepareContainer(IntPtr message, long length) + { + var mem = Marshal.AllocHGlobal(400); + Marshal.WriteInt64(mem, message.ToInt64()); + Marshal.WriteInt64(mem + 0x8, 64); + Marshal.WriteInt64(mem + 0x10, length); + Marshal.WriteInt64(mem + 0x18, 0); + return mem; + } + } +} diff --git a/Glamourer/SeFunctions/BaseUiObject.cs b/Glamourer/SeFunctions/BaseUiObject.cs new file mode 100644 index 0000000..9ad54f6 --- /dev/null +++ b/Glamourer/SeFunctions/BaseUiObject.cs @@ -0,0 +1,11 @@ +using Dalamud.Game; + +namespace Glamourer.SeFunctions +{ + public sealed class BaseUiObject : SeAddressBase + { + public BaseUiObject(SigScanner sigScanner) + : base(sigScanner, "48 8B 0D ?? ?? ?? ?? 48 8D 54 24 ?? 48 83 C1 10 E8") + { } + } +} diff --git a/Glamourer/SeFunctions/GetUiModule.cs b/Glamourer/SeFunctions/GetUiModule.cs new file mode 100644 index 0000000..8404e50 --- /dev/null +++ b/Glamourer/SeFunctions/GetUiModule.cs @@ -0,0 +1,14 @@ +using System; +using Dalamud.Game; + +namespace Glamourer.SeFunctions +{ + public delegate IntPtr GetUiModuleDelegate(IntPtr baseUiObj); + + public sealed class GetUiModule : SeFunctionBase + { + public GetUiModule(SigScanner sigScanner) + : base(sigScanner, "E8 ?? ?? ?? ?? 48 83 7F ?? 00 48 8B F0") + { } + } +} diff --git a/Glamourer/SeFunctions/ProcessChatBox.cs b/Glamourer/SeFunctions/ProcessChatBox.cs new file mode 100644 index 0000000..74e2872 --- /dev/null +++ b/Glamourer/SeFunctions/ProcessChatBox.cs @@ -0,0 +1,14 @@ +using System; +using Dalamud.Game; + +namespace Glamourer.SeFunctions +{ + public delegate IntPtr ProcessChatBoxDelegate(IntPtr uiModule, IntPtr message, IntPtr unk1, byte unk2); + + public sealed class ProcessChatBox : SeFunctionBase + { + public ProcessChatBox(SigScanner sigScanner) + : base(sigScanner, "48 89 5C 24 ?? 57 48 83 EC 20 48 8B FA 48 8B D9 45 84 C9") + { } + } +} diff --git a/Glamourer/SeFunctions/SeAddressBase.cs b/Glamourer/SeFunctions/SeAddressBase.cs new file mode 100644 index 0000000..fb4120f --- /dev/null +++ b/Glamourer/SeFunctions/SeAddressBase.cs @@ -0,0 +1,20 @@ +using System; +using Dalamud.Game; +using Dalamud.Plugin; + +namespace Glamourer.SeFunctions +{ + public class SeAddressBase + { + public readonly IntPtr Address; + + public SeAddressBase(SigScanner sigScanner, string signature, int offset = 0) + { + Address = sigScanner.GetStaticAddressFromSig(signature); + if (Address != IntPtr.Zero) + Address += offset; + var baseOffset = (ulong) Address.ToInt64() - (ulong) sigScanner.Module.BaseAddress.ToInt64(); + PluginLog.Debug($"{GetType().Name} address 0x{Address.ToInt64():X16}, baseOffset 0x{baseOffset:X16}."); + } + } +} diff --git a/Glamourer/SeFunctions/SeFunctionBase.cs b/Glamourer/SeFunctions/SeFunctionBase.cs new file mode 100644 index 0000000..38addd9 --- /dev/null +++ b/Glamourer/SeFunctions/SeFunctionBase.cs @@ -0,0 +1,75 @@ +using System; +using System.Runtime.InteropServices; +using Dalamud.Game; +using Dalamud.Hooking; +using Dalamud.Plugin; + +namespace Glamourer.SeFunctions +{ + public class SeFunctionBase where T : Delegate + { + public IntPtr Address; + protected T? FuncDelegate; + + public SeFunctionBase(SigScanner sigScanner, int offset) + { + Address = sigScanner.Module.BaseAddress + offset; + PluginLog.Debug($"{GetType().Name} address 0x{Address.ToInt64():X16}, baseOffset 0x{offset:X16}."); + } + + public SeFunctionBase(SigScanner sigScanner, string signature, int offset = 0) + { + Address = sigScanner.ScanText(signature); + if (Address != IntPtr.Zero) + Address += offset; + var baseOffset = (ulong) Address.ToInt64() - (ulong) sigScanner.Module.BaseAddress.ToInt64(); + PluginLog.Debug($"{GetType().Name} address 0x{Address.ToInt64():X16}, baseOffset 0x{baseOffset:X16}."); + } + + public T? Delegate() + { + if (FuncDelegate != null) + return FuncDelegate; + + if (Address != IntPtr.Zero) + { + FuncDelegate = Marshal.GetDelegateForFunctionPointer(Address); + return FuncDelegate; + } + + PluginLog.Error($"Trying to generate delegate for {GetType().Name}, but no pointer available."); + return null; + } + + public dynamic? Invoke(params dynamic[] parameters) + { + if (FuncDelegate != null) + return FuncDelegate.DynamicInvoke(parameters); + + if (Address != IntPtr.Zero) + { + FuncDelegate = Marshal.GetDelegateForFunctionPointer(Address); + return FuncDelegate!.DynamicInvoke(parameters); + } + else + { + PluginLog.Error($"Trying to call {GetType().Name}, but no pointer available."); + return null; + } + } + + public Hook? CreateHook(T detour) + { + if (Address != IntPtr.Zero) + { + var hook = new Hook(Address, detour); + hook.Enable(); + PluginLog.Debug($"Hooked onto {GetType().Name} at address 0x{Address.ToInt64():X16}."); + return hook; + } + + PluginLog.Error($"Trying to create Hook for {GetType().Name}, but no pointer available."); + return null; + } + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product 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 NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of 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 reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/repo.json b/repo.json new file mode 100644 index 0000000..3ac3b0d --- /dev/null +++ b/repo.json @@ -0,0 +1,19 @@ +[ + { + "Author": "Ottermandias", + "Name": "Glamourer", + "Description": "Adds functionality to change appearance of actors. Requires Penumbra to be installed and activated to work.", + "InternalName": "Glamourer", + "AssemblyVersion": "0.0.1.0", + "TestingAssemblyVersion": "0.0.1.0", + "RepoUrl": "https://github.com/Ottermandias/Glamourer", + "ApplicableVersion": "any", + "DalamudApiLevel": 3, + "IsHide": "False", + "IsTestingExclusive": "false", + "DownloadCount": 1, + "LastUpdate": 1618608322, + "DownloadLinkTesting": "https://github.com/Ottermandias/Glamourer/raw/main/GatherBuddy.zip", + "DownloadLinkUpdate": "https://github.com/Ottermandias/Glamourer/raw/main/GatherBuddy.zip", + } +]