Code commit

This commit is contained in:
RisaDev
2024-01-06 01:21:41 +03:00
parent a7d7297c59
commit a486dd2c96
90 changed files with 11576 additions and 0 deletions

230
.editorconfig Normal file
View File

@@ -0,0 +1,230 @@
root = true
file_header_template = © Customize+.\nLicensed under the MIT license.
dotnet_diagnostic.sa1000.severity = none
dotnet_diagnostic.sa1027.severity = none
dotnet_diagnostic.sa1600.severity = none
dotnet_diagnostic.sa1503.severity = none
dotnet_diagnostic.sa1633.severity = none
dotnet_diagnostic.sa1401.severity = none
dotnet_diagnostic.sa1601.severity = none
dotnet_diagnostic.sa1602.severity = none
dotnet_diagnostic.sa1516.severity = none
dotnet_diagnostic.sa1618.severity = none
dotnet_diagnostic.sa1611.severity = none
dotnet_diagnostic.sa1615.severity = none
dotnet_diagnostic.sa1011.severity = none
dotnet_diagnostic.sa1134.severity = none
dotnet_diagnostic.ide0011.severity = none
dotnet_diagnostic.ide0022.severity = none
dotnet_diagnostic.ide0023.severity = none
dotnet_diagnostic.ide1006.severity = warning
dotnet_diagnostic.ide0044.severity = suggestion
dotnet_diagnostic.ide0058.severity = none
dotnet_diagnostic.ide0073.severity = warning
dotnet_diagnostic.ide0025.severity = none
dotnet_diagnostic.ide0027.severity = none
dotnet_diagnostic.ide0059.severity = none
dotnet_diagnostic.ide0065.severity = none
dotnet_diagnostic.unt0001.severity = warning
dotnet_diagnostic.unt0002.severity = warning
dotnet_diagnostic.unt0003.severity = warning
dotnet_diagnostic.unt0004.severity = warning
dotnet_diagnostic.unt0005.severity = warning
dotnet_diagnostic.unt0006.severity = warning
dotnet_diagnostic.unt0007.severity = warning
dotnet_diagnostic.unt0008.severity = warning
dotnet_diagnostic.unt0009.severity = warning
dotnet_diagnostic.unt0010.severity = warning
dotnet_diagnostic.unt0011.severity = warning
dotnet_diagnostic.unt0012.severity = warning
dotnet_diagnostic.unt0013.severity = none
dotnet_diagnostic.unt0014.severity = warning
dotnet_diagnostic.unt0015.severity = warning
dotnet_diagnostic.unt0016.severity = warning
dotnet_diagnostic.unt0017.severity = warning
dotnet_diagnostic.unt0018.severity = warning
dotnet_diagnostic.unt0019.severity = warning
dotnet_diagnostic.unt0020.severity = warning
dotnet_diagnostic.cs1591.severity = none
dotnet_diagnostic.cs0067.severity = none
dotnet_diagnostic.ca1416.severity = none
dotnet_diagnostic.ca2201.severity = none
# SA1101: Prefix local calls with this
dotnet_diagnostic.sa1101.severity = silent
# CS8602: Dereference of a possibly null reference.
dotnet_diagnostic.cs8602.severity = silent
# CS0168: Variable is declared but never used
dotnet_diagnostic.cs0168.severity = silent
# CS8600: Converting null literal or possible null value to non-nullable type.
dotnet_diagnostic.cs8600.severity = suggestion
[*.{cs,vb}]
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
indent_size = 4
end_of_line = crlf
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:silent
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_prefer_conditional_expression_over_return = true:silent
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
dotnet_style_namespace_match_folder = true:suggestion
[*.{cs,vb}]
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
# Default severity for analyzer diagnostics with category 'StyleCop.CSharp.SpacingRules'
dotnet_analyzer_diagnostic.category-stylecop.csharp.spacingrules.severity = silent
# Default severity for analyzer diagnostics with category 'StyleCop.CSharp.ReadabilityRules'
dotnet_analyzer_diagnostic.category-stylecop.csharp.readabilityrules.severity = silent
# Default severity for analyzer diagnostics with category 'StyleCop.CSharp.LayoutRules'
dotnet_analyzer_diagnostic.category-stylecop.csharp.layoutrules.severity = silent
# Default severity for analyzer diagnostics with category 'StyleCop.CSharp.OrderingRules'
dotnet_analyzer_diagnostic.category-stylecop.csharp.orderingrules.severity = silent
[*.{asax,ascx,aspx,axaml,cs,cshtml,htm,html,master,paml,razor,skin,vb,xaml,xamlx,xoml}]
indent_style = space
indent_size = 4
tab_width = 4
[*.{appxmanifest,axml,build,config,csproj,dbml,discomap,dtd,jsproj,lsproj,njsproj,nuspec,proj,props,resw,resx,StyleCop,targets,tasks,vbproj,xml,xsd}]
indent_style = space
indent_size = 2
tab_width = 2
[*]
# Microsoft .NET properties
csharp_new_line_before_members_in_object_initializers = false
csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion
csharp_style_prefer_utf8_string_literals = true: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_naming_rule.private_constants_rule.import_to_resharper = as_predefined
dotnet_naming_rule.private_constants_rule.resharper_style = Label + AaBb, AaBb
dotnet_naming_rule.private_constants_rule.severity = warning
dotnet_naming_rule.private_constants_rule.style = label_upper_camel_case_style
dotnet_naming_rule.private_constants_rule.symbols = private_constants_symbols
dotnet_naming_style.label_upper_camel_case_style.capitalization = pascal_case
dotnet_naming_style.label_upper_camel_case_style.required_prefix = Label
dotnet_naming_symbols.private_constants_symbols.applicable_accessibilities = private
dotnet_naming_symbols.private_constants_symbols.applicable_kinds = field
dotnet_naming_symbols.private_constants_symbols.required_modifiers = const
dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none
dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none
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 inspection severities
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_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
csharp_indent_labels = one_less_than_current
csharp_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_braces = true:silent
csharp_style_namespace_declarations = block_scoped:suggestion
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_throw_expression = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = true:silent
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
dotnet_style_readonly_field = true:suggestion

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
publish/
\.vs/
packages/
*.user
bin/
obj/
deploy/
Setup/Release/
Setup/Debug/
*.tlog
*.iobj
*.ipdb
*.filters
*.pdb
*.log
*.obj
*.idb
*.recipe
*.ilk
\.vscode/

16
.gitmodules vendored Normal file
View File

@@ -0,0 +1,16 @@
[submodule "submodules/OtterGui"]
path = submodules/OtterGui
url = https://github.com/Ottermandias/OtterGui.git
branch = main
[submodule "submodules/Penumbra.GameData"]
path = submodules/Penumbra.GameData
url = https://github.com/Ottermandias/Penumbra.GameData
branch = main
[submodule "submodules/Penumbra.Api"]
path = submodules/Penumbra.Api
url = https://github.com/Ottermandias/Penumbra.Api.git
branch = main
[submodule "submodules/Penumbra.String"]
path = submodules/Penumbra.String
url = https://github.com/Ottermandias/Penumbra.String.git
branch = main

View File

@@ -0,0 +1,85 @@
[*.{cs,vb}]
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
indent_size = 4
end_of_line = crlf
[*.cs]
csharp_indent_labels = one_less_than_current
csharp_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_braces = true:silent
csharp_style_namespace_declarations = file_scoped:error
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = true:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_throw_expression = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
[*.{cs,vb}]
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:silent
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_prefer_conditional_expression_over_return = true:silent
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
dotnet_style_namespace_match_folder = true:error

View File

@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Configurations>Debug;Release</Configurations>
</PropertyGroup>
<PropertyGroup>
<DalamudLibPath>$(appdata)\XIVLauncher\addon\Hooks\dev\</DalamudLibPath>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\submodules\Penumbra.GameData\Penumbra.GameData.csproj" />
<Reference Include="Newtonsoft.Json">
<HintPath>$(DalamudLibPath)Newtonsoft.Json.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Dalamud">
<HintPath>$(DalamudLibPath)Dalamud.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGui.NET">
<HintPath>$(DalamudLibPath)ImGui.NET.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGuiScene">
<HintPath>$(DalamudLibPath)ImGuiScene.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina">
<HintPath>$(DalamudLibPath)Lumina.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina.Excel">
<HintPath>$(DalamudLibPath)Lumina.Excel.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="FFXIVClientStructs">
<HintPath>$(DalamudLibPath)FFXIVClientStructs.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,116 @@
using Penumbra.GameData.Actors;
using System;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Structs;
using Penumbra.String;
namespace CustomizePlus.GameData.Data;
public readonly unsafe struct Actor : IEquatable<Actor>
{
private Actor(nint address)
=> Address = address;
public static readonly Actor Null = new(nint.Zero);
public readonly nint Address;
public GameObject* AsObject
=> (GameObject*)Address;
public Character* AsCharacter
=> (Character*)Address;
public bool Valid
=> Address != nint.Zero;
public bool IsCharacter
=> Valid && AsObject->IsCharacter();
public static implicit operator Actor(nint? pointer)
=> new(pointer ?? nint.Zero);
public static implicit operator Actor(GameObject* pointer)
=> new((nint)pointer);
public static implicit operator Actor(Character* pointer)
=> new((nint)pointer);
public static implicit operator nint(Actor actor)
=> actor.Address;
public bool IsGPoseOrCutscene
=> Index.Index is >= (int)ScreenActor.CutsceneStart and < (int)ScreenActor.CutsceneEnd;
public ActorIdentifier GetIdentifier(ActorManager actors)
=> actors.FromObject(AsObject, out _, true, true, false);
public ByteString Utf8Name
=> Valid ? new ByteString(AsObject->Name) : ByteString.Empty;
public bool Identifier(ActorManager actors, out ActorIdentifier ident)
{
if (Valid)
{
ident = GetIdentifier(actors);
return ident.IsValid;
}
ident = ActorIdentifier.Invalid;
return false;
}
public ObjectIndex Index
=> Valid ? AsObject->ObjectIndex : ObjectIndex.AnyIndex;
public Model Model
=> Valid ? AsObject->DrawObject : null;
public byte Job
=> IsCharacter ? AsCharacter->CharacterData.ClassJob : (byte)0;
public static implicit operator bool(Actor actor)
=> actor.Address != nint.Zero;
public static bool operator true(Actor actor)
=> actor.Address != nint.Zero;
public static bool operator false(Actor actor)
=> actor.Address == nint.Zero;
public static bool operator !(Actor actor)
=> actor.Address == nint.Zero;
public bool Equals(Actor other)
=> Address == other.Address;
public override bool Equals(object? obj)
=> obj is Actor other && Equals(other);
public override int GetHashCode()
=> Address.GetHashCode();
public static bool operator ==(Actor lhs, Actor rhs)
=> lhs.Address == rhs.Address;
public static bool operator !=(Actor lhs, Actor rhs)
=> lhs.Address != rhs.Address;
/*
/// <summary> Only valid for characters. </summary>
public CharacterArmor GetArmor(EquipSlot slot)
=> ((CharacterArmor*)&AsCharacter->DrawData.Head)[slot.ToIndex()];
public CharacterWeapon GetMainhand()
=> new(AsCharacter->DrawData.Weapon(DrawDataContainer.WeaponSlot.MainHand).ModelId.Value);
public CharacterWeapon GetOffhand()
=> new(AsCharacter->DrawData.Weapon(DrawDataContainer.WeaponSlot.OffHand).ModelId.Value);
public Customize GetCustomize()
=> *(Customize*)&AsCharacter->DrawData.CustomizeData;
*/
public override string ToString()
=> $"0x{Address:X}";
}

View File

@@ -0,0 +1,44 @@
namespace CustomizePlus.GameData.Data;
/// <summary>
/// A single actor with its label and the list of associated game objects.
/// </summary>
public readonly struct ActorData
{
public readonly List<Actor> Objects;
public readonly string Label;
public bool Valid
=> Objects.Count > 0;
public ActorData(Actor actor, string label)
{
Objects = new List<Actor> { actor };
Label = label;
}
public static readonly ActorData Invalid = new(false);
private ActorData(bool _)
{
Objects = new List<Actor>(0);
Label = string.Empty;
}
/*public LazyString ToLazyString(string invalid)
{
var objects = Objects;
return Valid
? new LazyString(() => string.Join(", ", objects.Select(o => o.ToString())))
: new LazyString(() => invalid);
}*/
private ActorData(List<Actor> objects, string label)
{
Objects = objects;
Label = label;
}
public ActorData OnlyGPose()
=> new(Objects.Where(o => o.IsGPoseOrCutscene).ToList(), Label);
}

View File

@@ -0,0 +1,199 @@
using System;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Structs;
using Object = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.Object;
using ObjectType = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.ObjectType;
namespace CustomizePlus.GameData.Data;
public readonly unsafe struct Model : IEquatable<Model>
{
private Model(nint address)
=> Address = address;
public readonly nint Address;
public static readonly Model Null = new(0);
public DrawObject* AsDrawObject
=> (DrawObject*)Address;
public CharacterBase* AsCharacterBase
=> (CharacterBase*)Address;
public Weapon* AsWeapon
=> (Weapon*)Address;
public Human* AsHuman
=> (Human*)Address;
public static implicit operator Model(nint? pointer)
=> new(pointer ?? nint.Zero);
public static implicit operator Model(Object* pointer)
=> new((nint)pointer);
public static implicit operator Model(DrawObject* pointer)
=> new((nint)pointer);
public static implicit operator Model(Human* pointer)
=> new((nint)pointer);
public static implicit operator Model(CharacterBase* pointer)
=> new((nint)pointer);
public static implicit operator nint(Model model)
=> model.Address;
public bool Valid
=> Address != nint.Zero;
public bool IsCharacterBase
=> Valid && AsDrawObject->Object.GetObjectType() == ObjectType.CharacterBase;
public bool IsHuman
=> IsCharacterBase && AsCharacterBase->GetModelType() == CharacterBase.ModelType.Human;
public bool IsWeapon
=> IsCharacterBase && AsCharacterBase->GetModelType() == CharacterBase.ModelType.Weapon;
public static implicit operator bool(Model actor)
=> actor.Address != nint.Zero;
public static bool operator true(Model actor)
=> actor.Address != nint.Zero;
public static bool operator false(Model actor)
=> actor.Address == nint.Zero;
public static bool operator !(Model actor)
=> actor.Address == nint.Zero;
public bool Equals(Model other)
=> Address == other.Address;
public override bool Equals(object? obj)
=> obj is Model other && Equals(other);
public override int GetHashCode()
=> Address.GetHashCode();
public static bool operator ==(Model lhs, Model rhs)
=> lhs.Address == rhs.Address;
public static bool operator !=(Model lhs, Model rhs)
=> lhs.Address != rhs.Address;
/*
/// <summary> Only valid for humans. </summary>
public CharacterArmor GetArmor(EquipSlot slot)
=> ((CharacterArmor*)&AsHuman->Head)[slot.ToIndex()];
public Customize GetCustomize()
=> *(Customize*)&AsHuman->Customize;
public (Model Address, CharacterWeapon Data) GetMainhand()
{
Model weapon = AsDrawObject->Object.ChildObject;
return !weapon.IsWeapon
? (Null, CharacterWeapon.Empty)
: (weapon, new CharacterWeapon(weapon.AsWeapon->ModelSetId, weapon.AsWeapon->SecondaryId, (Variant)weapon.AsWeapon->Variant,
(StainId)weapon.AsWeapon->ModelUnknown));
}
public (Model Address, CharacterWeapon Data) GetOffhand()
{
var mainhand = AsDrawObject->Object.ChildObject;
if (mainhand == null)
return (Null, CharacterWeapon.Empty);
Model offhand = mainhand->NextSiblingObject;
if (offhand == mainhand || !offhand.IsWeapon)
return (Null, CharacterWeapon.Empty);
return (offhand, new CharacterWeapon(offhand.AsWeapon->ModelSetId, offhand.AsWeapon->SecondaryId, (Variant)offhand.AsWeapon->Variant,
(StainId)offhand.AsWeapon->ModelUnknown));
}
/// <summary> Obtain the mainhand and offhand and their data by guesstimating which child object is which. </summary>
public (Model Mainhand, Model Offhand, CharacterWeapon MainData, CharacterWeapon OffData) GetWeapons()
{
var (first, second, count) = GetChildrenWeapons();
switch (count)
{
case 0: return (Null, Null, CharacterWeapon.Empty, CharacterWeapon.Empty);
case 1:
return (first, Null, new CharacterWeapon(first.AsWeapon->ModelSetId, first.AsWeapon->SecondaryId,
(Variant)first.AsWeapon->Variant,
(StainId)first.AsWeapon->ModelUnknown), CharacterWeapon.Empty);
default:
var (main, off) = DetermineMainhand(first, second);
var mainData = new CharacterWeapon(main.AsWeapon->ModelSetId, main.AsWeapon->SecondaryId, (Variant)main.AsWeapon->Variant,
(StainId)main.AsWeapon->ModelUnknown);
var offData = new CharacterWeapon(off.AsWeapon->ModelSetId, off.AsWeapon->SecondaryId, (Variant)off.AsWeapon->Variant,
(StainId)off.AsWeapon->ModelUnknown);
return (main, off, mainData, offData);
}
}
/// <summary> Obtain the mainhand and offhand and their data by using the drawdata container from the corresponding actor. </summary>
public (Model Mainhand, Model Offhand, CharacterWeapon MainData, CharacterWeapon OffData) GetWeapons(Actor actor)
{
if (!Valid || !actor.IsCharacter || actor.Model.Address != Address)
return (Null, Null, CharacterWeapon.Empty, CharacterWeapon.Empty);
Model main = actor.AsCharacter->DrawData.Weapon(DrawDataContainer.WeaponSlot.MainHand).DrawObject;
var mainData = CharacterWeapon.Empty;
if (main.IsWeapon)
mainData = new CharacterWeapon(main.AsWeapon->ModelSetId, main.AsWeapon->SecondaryId, (Variant)main.AsWeapon->Variant,
(StainId)main.AsWeapon->ModelUnknown);
else
main = Null;
Model off = actor.AsCharacter->DrawData.Weapon(DrawDataContainer.WeaponSlot.OffHand).DrawObject;
var offData = CharacterWeapon.Empty;
if (off.IsWeapon)
offData = new CharacterWeapon(off.AsWeapon->ModelSetId, off.AsWeapon->SecondaryId, (Variant)off.AsWeapon->Variant,
(StainId)off.AsWeapon->ModelUnknown);
else
off = Null;
return (main, off, mainData, offData);
}
private (Model, Model, int) GetChildrenWeapons()
{
Span<Model> weapons = stackalloc Model[2];
weapons[0] = Null;
weapons[1] = Null;
var count = 0;
if (!Valid || AsDrawObject->Object.ChildObject == null)
return (weapons[0], weapons[1], count);
Model starter = AsDrawObject->Object.ChildObject;
var iterator = starter;
do
{
if (iterator.IsWeapon)
weapons[count++] = iterator;
if (count == 2)
return (weapons[0], weapons[1], count);
iterator = iterator.AsDrawObject->Object.NextSiblingObject;
} while (iterator.Address != starter.Address);
return (weapons[0], weapons[1], count);
}
/// <summary> I don't know a safe way to do this but in experiments this worked.
/// The first uint at +0x8 was set to non-zero for the mainhand and zero for the offhand. </summary>
private static (Model Mainhand, Model Offhand) DetermineMainhand(Model first, Model second)
{
var discriminator1 = *(ulong*)(first.Address + 0x10);
var discriminator2 = *(ulong*)(second.Address + 0x10);
return discriminator1 == 0 && discriminator2 != 0 ? (second, first) : (first, second);
}
*/
public override string ToString()
=> $"0x{Address:X}";
}

View File

@@ -0,0 +1,125 @@
using Dalamud.Game.ClientState.Objects.Enums;
using Penumbra.GameData.Actors;
namespace CustomizePlus.GameData.Extensions;
public static class ActorIdentifierExtensions
{
/// <summary>
/// Get actor name. Without owner's name if this is owned object.
/// </summary>
/// <param name="identifier"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public static string ToNameWithoutOwnerName(this ActorIdentifier identifier)
{
if (identifier == ActorIdentifier.Invalid)
return "Invalid";
if (!identifier.IsValid || identifier.Type != IdentifierType.Owned)
return identifier.ToName();
if (ActorIdentifier.Manager == null)
throw new Exception("ActorIdentifier.Manager is not initialized");
return ActorIdentifier.Manager.Data.ToName(identifier.Kind, identifier.DataId);
}
/// <summary>
/// Wrapper around Incognito which returns non-incognito name in debug builds
/// </summary>
/// <param name="identifier"></param>
/// <returns></returns>
public static string IncognitoDebug(this ActorIdentifier identifier)
{
if (identifier == ActorIdentifier.Invalid)
return "Invalid";
try
{
#if DEBUG
return identifier.ToString();
#else
return identifier.Incognito(null);
#endif
}
catch (Exception e)
{
#if DEBUG
throw;
#else
return "Unknown";
#endif
}
}
/// <summary>
/// For now used to determine if root scaling should be allowed or not
/// </summary>
/// <param name="identifier"></param>
/// <returns></returns>
public static bool IsAllowedForProfiles(this ActorIdentifier identifier)
{
if (identifier == ActorIdentifier.Invalid)
return false;
switch (identifier.Type)
{
case IdentifierType.Player:
case IdentifierType.Retainer:
case IdentifierType.Npc:
return true;
case IdentifierType.Owned:
return
identifier.Kind == ObjectKind.BattleNpc ||
//identifier.Kind == ObjectKind.MountType ||
identifier.Kind == ObjectKind.Companion;
default:
return false;
}
}
/// <summary>
/// Get "true" actor for special actors. Returns ActorIdentifier.Invalid for non-special actors or if actor cannot be found.
/// </summary>
/// <param name="identifier"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public static ActorIdentifier GetTrueActorForSpecialType(this ActorIdentifier identifier)
{
if (!identifier.IsValid)
return ActorIdentifier.Invalid;
if (identifier.Type != IdentifierType.Special)
return ActorIdentifier.Invalid;
if (ActorIdentifier.Manager == null)
throw new Exception("ActorIdentifier.Manager is not initialized");
switch (identifier.Special)
{
case ScreenActor.GPosePlayer:
case ScreenActor.CharacterScreen:
case ScreenActor.FittingRoom:
case ScreenActor.DyePreview:
case ScreenActor.Portrait:
return ActorIdentifier.Manager.GetCurrentPlayer();
case ScreenActor.ExamineScreen:
var examineIdentifier = ActorIdentifier.Manager.GetInspectPlayer();
if (!examineIdentifier.IsValid)
examineIdentifier = ActorIdentifier.Manager.GetGlamourPlayer(); //returns ActorIdentifier.Invalid if player is invalid
if (!examineIdentifier.IsValid)
return ActorIdentifier.Invalid;
return examineIdentifier;
case ScreenActor.Card6:
case ScreenActor.Card7:
case ScreenActor.Card8:
return ActorIdentifier.Manager.GetCardPlayer();
}
return ActorIdentifier.Invalid;
}
}

View File

@@ -0,0 +1,83 @@
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using Penumbra.GameData.Actors;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CustomizePlus.GameData.Services;
public class CutsceneService : IDisposable
{
public const int CutsceneStartIdx = (int)ScreenActor.CutsceneStart;
public const int CutsceneEndIdx = (int)ScreenActor.CutsceneEnd;
public const int CutsceneSlots = CutsceneEndIdx - CutsceneStartIdx;
private readonly GameEventManager _events;
private readonly IObjectTable _objects;
private readonly short[] _copiedCharacters = Enumerable.Repeat((short)-1, CutsceneSlots).ToArray();
public IEnumerable<KeyValuePair<int, Dalamud.Game.ClientState.Objects.Types.GameObject>> Actors
=> Enumerable.Range(CutsceneStartIdx, CutsceneSlots)
.Where(i => _objects[i] != null)
.Select(i => KeyValuePair.Create(i, this[i] ?? _objects[i]!));
public unsafe CutsceneService(IObjectTable objects, GameEventManager events)
{
_objects = objects;
_events = events;
_events.CopyCharacter += OnCharacterCopy;
_events.CharacterDestructor += OnCharacterDestructor;
}
/// <summary>
/// Get the related actor to a cutscene actor.
/// Does not check for valid input index.
/// Returns null if no connected actor is set or the actor does not exist anymore.
/// </summary>
public Dalamud.Game.ClientState.Objects.Types.GameObject? this[int idx]
{
get
{
Debug.Assert(idx is >= CutsceneStartIdx and < CutsceneEndIdx);
idx = _copiedCharacters[idx - CutsceneStartIdx];
return idx < 0 ? null : _objects[idx];
}
}
/// <summary> Return the currently set index of a parent or -1 if none is set or the index is invalid. </summary>
public int GetParentIndex(int idx)
{
if (idx is >= CutsceneStartIdx and < CutsceneEndIdx)
return _copiedCharacters[idx - CutsceneStartIdx];
return -1;
}
public unsafe void Dispose()
{
_events.CopyCharacter -= OnCharacterCopy;
_events.CharacterDestructor -= OnCharacterDestructor;
}
private unsafe void OnCharacterDestructor(Character* character)
{
if (character->GameObject.ObjectIndex is < CutsceneStartIdx or >= CutsceneEndIdx)
return;
var idx = character->GameObject.ObjectIndex - CutsceneStartIdx;
_copiedCharacters[idx] = -1;
}
private unsafe void OnCharacterCopy(Character* target, Character* source)
{
if (target == null || target->GameObject.ObjectIndex is < CutsceneStartIdx or >= CutsceneEndIdx)
return;
var idx = target->GameObject.ObjectIndex - CutsceneStartIdx;
_copiedCharacters[idx] = (short)(source != null ? source->GameObject.ObjectIndex : -1);
}
}

View File

@@ -0,0 +1,193 @@
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle;
using Penumbra.GameData;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CustomizePlus.GameData.Services;
public unsafe class GameEventManager : IDisposable
{
private const string Prefix = $"[{nameof(GameEventManager)}]";
public event CharacterDestructorEvent? CharacterDestructor;
public event CopyCharacterEvent? CopyCharacter;
public event CreatingCharacterBaseEvent? CreatingCharacterBase;
public event CharacterBaseCreatedEvent? CharacterBaseCreated;
public event CharacterBaseDestructorEvent? CharacterBaseDestructor;
public GameEventManager(IGameInteropProvider interop)
{
interop.InitializeFromAttributes(this);
_copyCharacterHook =
interop.HookFromAddress<CopyCharacterDelegate>((nint)CharacterSetup.MemberFunctionPointers.CopyFromCharacter, CopyCharacterDetour);
_characterBaseCreateHook =
interop.HookFromAddress<CharacterBaseCreateDelegate>((nint)CharacterBase.MemberFunctionPointers.Create, CharacterBaseCreateDetour);
_characterBaseDestructorHook =
interop.HookFromAddress<CharacterBaseDestructorEvent>((nint)CharacterBase.MemberFunctionPointers.Destroy,
CharacterBaseDestructorDetour);
_characterDtorHook.Enable();
_copyCharacterHook.Enable();
_characterBaseCreateHook.Enable();
_characterBaseDestructorHook.Enable();
}
public void Dispose()
{
_characterDtorHook.Dispose();
_copyCharacterHook.Dispose();
_characterBaseCreateHook.Dispose();
_characterBaseDestructorHook.Dispose();
}
#region Character Destructor
private delegate void CharacterDestructorDelegate(Character* character);
[Signature(Sigs.CharacterDestructor, DetourName = nameof(CharacterDestructorDetour))]
private readonly Hook<CharacterDestructorDelegate> _characterDtorHook = null!;
private void CharacterDestructorDetour(Character* character)
{
if (CharacterDestructor != null)
foreach (var subscriber in CharacterDestructor.GetInvocationList())
{
try
{
((CharacterDestructorEvent)subscriber).Invoke(character);
}
catch (Exception ex)
{
//Penumbra.Log.Error($"{Prefix} Error in {nameof(CharacterDestructor)} event when executing {subscriber.Method.Name}:\n{ex}");
//todo: log
}
}
//Penumbra.Log.Verbose($"{Prefix} {nameof(CharacterDestructor)} triggered with 0x{(nint)character:X}.");
//todo: log
_characterDtorHook.Original(character);
}
public delegate void CharacterDestructorEvent(Character* character);
#endregion
#region Copy Character
private delegate ulong CopyCharacterDelegate(CharacterSetup* target, GameObject* source, uint unk);
private readonly Hook<CopyCharacterDelegate> _copyCharacterHook;
private ulong CopyCharacterDetour(CharacterSetup* target, GameObject* source, uint unk)
{
// TODO: update when CS updated.
var character = ((Character**)target)[1];
if (CopyCharacter != null)
foreach (var subscriber in CopyCharacter.GetInvocationList())
{
try
{
((CopyCharacterEvent)subscriber).Invoke(character, (Character*)source);
}
catch (Exception ex)
{
/*Penumbra.Log.Error(
$"{Prefix} Error in {nameof(CopyCharacter)} event when executing {subscriber.Method.Name}:\n{ex}");*/
//todo: log
}
}
/*Penumbra.Log.Verbose(
$"{Prefix} {nameof(CopyCharacter)} triggered with target 0x{(nint)target:X} and source 0x{(nint)source:X}.");*/
//todo: log
return _copyCharacterHook.Original(target, source, unk);
}
public delegate void CopyCharacterEvent(Character* target, Character* source);
#endregion
#region CharacterBaseCreate
private delegate nint CharacterBaseCreateDelegate(uint a, nint b, nint c, byte d);
private readonly Hook<CharacterBaseCreateDelegate> _characterBaseCreateHook;
private nint CharacterBaseCreateDetour(uint a, nint b, nint c, byte d)
{
if (CreatingCharacterBase != null)
foreach (var subscriber in CreatingCharacterBase.GetInvocationList())
{
try
{
((CreatingCharacterBaseEvent)subscriber).Invoke((nint)(&a), b, c);
}
catch (Exception ex)
{
/*Penumbra.Log.Error(
$"{Prefix} Error in {nameof(CharacterBaseCreateDetour)} event when executing {subscriber.Method.Name}:\n{ex}");*/
//todo: log
}
}
var ret = _characterBaseCreateHook.Original(a, b, c, d);
if (CharacterBaseCreated != null)
foreach (var subscriber in CharacterBaseCreated.GetInvocationList())
{
try
{
((CharacterBaseCreatedEvent)subscriber).Invoke(a, b, c, ret);
}
catch (Exception ex)
{
/*Penumbra.Log.Error(
$"{Prefix} Error in {nameof(CharacterBaseCreateDetour)} event when executing {subscriber.Method.Name}:\n{ex}");*/
//todo: log
}
}
return ret;
}
public delegate void CreatingCharacterBaseEvent(nint modelCharaId, nint customize, nint equipment);
public delegate void CharacterBaseCreatedEvent(uint modelCharaId, nint customize, nint equipment, nint drawObject);
#endregion
#region CharacterBase Destructor
public delegate void CharacterBaseDestructorEvent(nint drawBase);
private readonly Hook<CharacterBaseDestructorEvent> _characterBaseDestructorHook;
private void CharacterBaseDestructorDetour(nint drawBase)
{
if (CharacterBaseDestructor != null)
foreach (var subscriber in CharacterBaseDestructor.GetInvocationList())
{
try
{
((CharacterBaseDestructorEvent)subscriber).Invoke(drawBase);
}
catch (Exception ex)
{
/*Penumbra.Log.Error(
$"{Prefix} Error in {nameof(CharacterBaseDestructorDetour)} event when executing {subscriber.Method.Name}:\n{ex}");*/
//todo: log
}
}
_characterBaseDestructorHook.Original.Invoke(drawBase);
}
#endregion
}

View File

@@ -0,0 +1,238 @@
using CustomizePlus.GameData.Data;
using Dalamud.Game.ClientState.Objects;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Control;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using Penumbra.GameData.Actors;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CustomizePlus.GameData.Services;
public class ObjectManager : IReadOnlyDictionary<ActorIdentifier, ActorData>
{
private readonly IFramework _framework;
private readonly IClientState _clientState;
private readonly IObjectTable _objects;
private readonly ActorService _actors;
private readonly ITargetManager _targets;
public IObjectTable Objects
=> _objects;
public ObjectManager(IFramework framework, IClientState clientState, IObjectTable objects, ActorService actors, ITargetManager targets)
{
_framework = framework;
_clientState = clientState;
_objects = objects;
_actors = actors;
_targets = targets;
}
public DateTime LastUpdate { get; private set; }
public bool IsInGPose { get; private set; }
public ushort World { get; private set; }
private readonly Dictionary<ActorIdentifier, ActorData> _identifiers = new(200);
private readonly Dictionary<ActorIdentifier, ActorData> _allWorldIdentifiers = new(200);
private readonly Dictionary<ActorIdentifier, ActorData> _nonOwnedIdentifiers = new(200);
public IReadOnlyDictionary<ActorIdentifier, ActorData> Identifiers
=> _identifiers;
public void Update()
{
var lastUpdate = _framework.LastUpdate;
if (lastUpdate <= LastUpdate)
return;
LastUpdate = lastUpdate;
World = (ushort)(_clientState.LocalPlayer?.CurrentWorld.Id ?? 0u);
_identifiers.Clear();
_allWorldIdentifiers.Clear();
_nonOwnedIdentifiers.Clear();
for (var i = 0; i < (int)ScreenActor.CutsceneStart; ++i)
{
Actor character = _objects.GetObjectAddress(i);
if (character.Identifier(_actors.AwaitedService, out var identifier))
HandleIdentifier(identifier, character);
}
for (var i = (int)ScreenActor.CutsceneStart; i < (int)ScreenActor.CutsceneEnd; ++i)
{
Actor character = _objects.GetObjectAddress(i);
if (!character.Valid)
break;
HandleIdentifier(character.GetIdentifier(_actors.AwaitedService), character);
}
void AddSpecial(ScreenActor idx, string label)
{
Actor actor = _objects.GetObjectAddress((int)idx);
if (actor.Identifier(_actors.AwaitedService, out var ident))
{
var data = new ActorData(actor, label);
_identifiers.Add(ident, data);
}
}
AddSpecial(ScreenActor.CharacterScreen, "Character Screen Actor");
AddSpecial(ScreenActor.ExamineScreen, "Examine Screen Actor");
AddSpecial(ScreenActor.FittingRoom, "Fitting Room Actor");
AddSpecial(ScreenActor.DyePreview, "Dye Preview Actor");
AddSpecial(ScreenActor.Portrait, "Portrait Actor");
AddSpecial(ScreenActor.Card6, "Card Actor 6");
AddSpecial(ScreenActor.Card7, "Card Actor 7");
AddSpecial(ScreenActor.Card8, "Card Actor 8");
for (var i = (int)ScreenActor.ScreenEnd; i < _objects.Length; ++i)
{
Actor character = _objects.GetObjectAddress(i);
if (character.Identifier(_actors.AwaitedService, out var identifier))
HandleIdentifier(identifier, character);
}
var gPose = GPosePlayer;
IsInGPose = gPose.Utf8Name.Length > 0;
}
private void HandleIdentifier(ActorIdentifier identifier, Actor character)
{
if (!character.Model || !identifier.IsValid)
return;
if (!_identifiers.TryGetValue(identifier, out var data))
{
data = new ActorData(character, identifier.ToString());
_identifiers[identifier] = data;
}
else
{
data.Objects.Add(character);
}
if (identifier.Type is IdentifierType.Player or IdentifierType.Owned)
{
var allWorld = _actors.AwaitedService.CreateIndividualUnchecked(identifier.Type, identifier.PlayerName, ushort.MaxValue,
identifier.Kind,
identifier.DataId);
if (!_allWorldIdentifiers.TryGetValue(allWorld, out var allWorldData))
{
allWorldData = new ActorData(character, allWorld.ToString());
_allWorldIdentifiers[allWorld] = allWorldData;
}
else
{
allWorldData.Objects.Add(character);
}
}
if (identifier.Type is IdentifierType.Owned)
{
var nonOwned = _actors.AwaitedService.CreateNpc(identifier.Kind, identifier.DataId);
if (!_nonOwnedIdentifiers.TryGetValue(nonOwned, out var nonOwnedData))
{
nonOwnedData = new ActorData(character, nonOwned.ToString());
_nonOwnedIdentifiers[nonOwned] = nonOwnedData;
}
else
{
nonOwnedData.Objects.Add(character);
}
}
}
public Actor GPosePlayer
=> _objects.GetObjectAddress((int)ScreenActor.GPosePlayer);
public Actor Player
=> _objects.GetObjectAddress(0);
public unsafe Actor Target
=> _clientState.IsGPosing ? TargetSystem.Instance()->GPoseTarget : TargetSystem.Instance()->Target;
public Actor Focus
=> _targets.FocusTarget?.Address ?? nint.Zero;
public Actor MouseOver
=> _targets.MouseOverTarget?.Address ?? nint.Zero;
public (ActorIdentifier Identifier, ActorData Data) PlayerData
{
get
{
Update();
return Player.Identifier(_actors.AwaitedService, out var ident) && _identifiers.TryGetValue(ident, out var data)
? (ident, data)
: (ident, ActorData.Invalid);
}
}
public (ActorIdentifier Identifier, ActorData Data) TargetData
{
get
{
Update();
return Target.Identifier(_actors.AwaitedService, out var ident) && _identifiers.TryGetValue(ident, out var data)
? (ident, data)
: (ident, ActorData.Invalid);
}
}
public IEnumerator<KeyValuePair<ActorIdentifier, ActorData>> GetEnumerator()
=> Identifiers.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator()
=> GetEnumerator();
public int Count
=> Identifiers.Count;
/// <summary> Also handles All Worlds players and non-owned NPCs. </summary>
public bool ContainsKey(ActorIdentifier key)
=> Identifiers.ContainsKey(key) || _allWorldIdentifiers.ContainsKey(key) || _nonOwnedIdentifiers.ContainsKey(key);
public bool TryGetValue(ActorIdentifier key, out ActorData value)
=> Identifiers.TryGetValue(key, out value);
public bool TryGetValueAllWorld(ActorIdentifier key, out ActorData value)
=> _allWorldIdentifiers.TryGetValue(key, out value);
public bool TryGetValueNonOwned(ActorIdentifier key, out ActorData value)
=> _nonOwnedIdentifiers.TryGetValue(key, out value);
public ActorData this[ActorIdentifier key]
=> Identifiers[key];
public IEnumerable<ActorIdentifier> Keys
=> Identifiers.Keys;
public IEnumerable<ActorData> Values
=> Identifiers.Values;
public bool GetName(string lowerName, out Actor actor)
{
(actor, var ret) = lowerName switch
{
"" => (Actor.Null, true),
"<me>" => (Player, true),
"self" => (Player, true),
"<t>" => (Target, true),
"target" => (Target, true),
"<f>" => (Focus, true),
"focus" => (Focus, true),
"<mo>" => (MouseOver, true),
"mouseover" => (MouseOver, true),
_ => (Actor.Null, false),
};
return ret;
}
}

View File

@@ -0,0 +1,78 @@
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using Penumbra.GameData;
using Penumbra.GameData.Actors;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CustomizePlus.GameData.Services;
public abstract class AsyncServiceWrapper<T> : IDisposable
{
public string Name { get; }
public T? Service { get; private set; }
public T AwaitedService
{
get
{
_task?.Wait();
return Service!;
}
}
public bool Valid
=> Service != null && !_isDisposed;
public event Action? FinishedCreation;
private Task? _task;
private bool _isDisposed;
protected AsyncServiceWrapper(string name, Func<T> factory)
{
Name = name;
_task = Task.Run(() =>
{
var service = factory();
if (_isDisposed)
{
if (service is IDisposable d)
d.Dispose();
}
else
{
Service = service;
_task = null;
}
});
_task.ContinueWith((t, x) =>
{
if (!_isDisposed)
FinishedCreation?.Invoke();
}, null);
}
public void Dispose()
{
if (_isDisposed)
return;
_isDisposed = true;
_task = null;
if (Service is IDisposable d)
d.Dispose();
}
}
public sealed class ActorService : AsyncServiceWrapper<ActorManager>
{
public ActorService(DalamudPluginInterface pi, IObjectTable objects, IClientState clientState, IFramework framework, IGameInteropProvider interop, IDataManager gameData,
IGameGui gui, CutsceneService cutsceneService, IPluginLog log)
: base(nameof(ActorService),
() => new ActorManager(pi, objects, clientState, framework, interop, gameData, gui, idx => (short)cutsceneService.GetParentIndex(idx), log))
{ }
}

120
CustomizePlus.sln Normal file
View File

@@ -0,0 +1,120 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.1.31911.260
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4CF55D1D-C5C5-4368-89D6-31D79EF6978C}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CustomizePlus", "CustomizePlus\CustomizePlus.csproj", "{5BA385F5-C17E-4CE4-828A-24F7F19C434B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "submodules", "submodules", "{121C2200-A844-44FD-85C4-22D6C7E35553}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OtterGui", "submodules\OtterGui\OtterGui.csproj", "{0D465539-6133-4088-B4BB-F260FA2A1557}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CustomizePlus.GameData", "CustomizePlus.GameData\CustomizePlus.GameData.csproj", "{CDB26C94-1200-45AA-AF96-D4526DC76AD5}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.GameData", "submodules\Penumbra.GameData\Penumbra.GameData.csproj", "{D79C8833-D241-4867-BF6F-8097E0ED8067}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.Api", "submodules\Penumbra.Api\Penumbra.Api.csproj", "{CC460943-1E07-4FA0-8B8C-67F0EF385290}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.String", "submodules\Penumbra.String\Penumbra.String.csproj", "{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
ReleaseObfuscated|Any CPU = ReleaseObfuscated|Any CPU
ReleaseObfuscated|x64 = ReleaseObfuscated|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{5BA385F5-C17E-4CE4-828A-24F7F19C434B}.Debug|Any CPU.ActiveCfg = Debug|x64
{5BA385F5-C17E-4CE4-828A-24F7F19C434B}.Debug|Any CPU.Build.0 = Debug|x64
{5BA385F5-C17E-4CE4-828A-24F7F19C434B}.Debug|x64.ActiveCfg = Debug|x64
{5BA385F5-C17E-4CE4-828A-24F7F19C434B}.Debug|x64.Build.0 = Debug|x64
{5BA385F5-C17E-4CE4-828A-24F7F19C434B}.Release|Any CPU.ActiveCfg = Release|x64
{5BA385F5-C17E-4CE4-828A-24F7F19C434B}.Release|Any CPU.Build.0 = Release|x64
{5BA385F5-C17E-4CE4-828A-24F7F19C434B}.Release|x64.ActiveCfg = Release|x64
{5BA385F5-C17E-4CE4-828A-24F7F19C434B}.Release|x64.Build.0 = Release|x64
{5BA385F5-C17E-4CE4-828A-24F7F19C434B}.ReleaseObfuscated|Any CPU.ActiveCfg = ReleaseObfuscated|x64
{5BA385F5-C17E-4CE4-828A-24F7F19C434B}.ReleaseObfuscated|Any CPU.Build.0 = ReleaseObfuscated|x64
{5BA385F5-C17E-4CE4-828A-24F7F19C434B}.ReleaseObfuscated|x64.ActiveCfg = ReleaseObfuscated|x64
{5BA385F5-C17E-4CE4-828A-24F7F19C434B}.ReleaseObfuscated|x64.Build.0 = ReleaseObfuscated|x64
{0D465539-6133-4088-B4BB-F260FA2A1557}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0D465539-6133-4088-B4BB-F260FA2A1557}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0D465539-6133-4088-B4BB-F260FA2A1557}.Debug|x64.ActiveCfg = Debug|Any CPU
{0D465539-6133-4088-B4BB-F260FA2A1557}.Debug|x64.Build.0 = Debug|Any CPU
{0D465539-6133-4088-B4BB-F260FA2A1557}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0D465539-6133-4088-B4BB-F260FA2A1557}.Release|Any CPU.Build.0 = Release|Any CPU
{0D465539-6133-4088-B4BB-F260FA2A1557}.Release|x64.ActiveCfg = Release|Any CPU
{0D465539-6133-4088-B4BB-F260FA2A1557}.Release|x64.Build.0 = Release|Any CPU
{0D465539-6133-4088-B4BB-F260FA2A1557}.ReleaseObfuscated|Any CPU.ActiveCfg = Release|Any CPU
{0D465539-6133-4088-B4BB-F260FA2A1557}.ReleaseObfuscated|Any CPU.Build.0 = Release|Any CPU
{0D465539-6133-4088-B4BB-F260FA2A1557}.ReleaseObfuscated|x64.ActiveCfg = ReleaseObfuscated|Any CPU
{0D465539-6133-4088-B4BB-F260FA2A1557}.ReleaseObfuscated|x64.Build.0 = ReleaseObfuscated|Any CPU
{CDB26C94-1200-45AA-AF96-D4526DC76AD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CDB26C94-1200-45AA-AF96-D4526DC76AD5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CDB26C94-1200-45AA-AF96-D4526DC76AD5}.Debug|x64.ActiveCfg = Debug|Any CPU
{CDB26C94-1200-45AA-AF96-D4526DC76AD5}.Debug|x64.Build.0 = Debug|Any CPU
{CDB26C94-1200-45AA-AF96-D4526DC76AD5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CDB26C94-1200-45AA-AF96-D4526DC76AD5}.Release|Any CPU.Build.0 = Release|Any CPU
{CDB26C94-1200-45AA-AF96-D4526DC76AD5}.Release|x64.ActiveCfg = Release|Any CPU
{CDB26C94-1200-45AA-AF96-D4526DC76AD5}.Release|x64.Build.0 = Release|Any CPU
{CDB26C94-1200-45AA-AF96-D4526DC76AD5}.ReleaseObfuscated|Any CPU.ActiveCfg = ReleaseObfuscated|Any CPU
{CDB26C94-1200-45AA-AF96-D4526DC76AD5}.ReleaseObfuscated|Any CPU.Build.0 = ReleaseObfuscated|Any CPU
{CDB26C94-1200-45AA-AF96-D4526DC76AD5}.ReleaseObfuscated|x64.ActiveCfg = ReleaseObfuscated|Any CPU
{CDB26C94-1200-45AA-AF96-D4526DC76AD5}.ReleaseObfuscated|x64.Build.0 = ReleaseObfuscated|Any CPU
{D79C8833-D241-4867-BF6F-8097E0ED8067}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D79C8833-D241-4867-BF6F-8097E0ED8067}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D79C8833-D241-4867-BF6F-8097E0ED8067}.Debug|x64.ActiveCfg = Debug|Any CPU
{D79C8833-D241-4867-BF6F-8097E0ED8067}.Debug|x64.Build.0 = Debug|Any CPU
{D79C8833-D241-4867-BF6F-8097E0ED8067}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D79C8833-D241-4867-BF6F-8097E0ED8067}.Release|Any CPU.Build.0 = Release|Any CPU
{D79C8833-D241-4867-BF6F-8097E0ED8067}.Release|x64.ActiveCfg = Release|Any CPU
{D79C8833-D241-4867-BF6F-8097E0ED8067}.Release|x64.Build.0 = Release|Any CPU
{D79C8833-D241-4867-BF6F-8097E0ED8067}.ReleaseObfuscated|Any CPU.ActiveCfg = Release|Any CPU
{D79C8833-D241-4867-BF6F-8097E0ED8067}.ReleaseObfuscated|Any CPU.Build.0 = Release|Any CPU
{D79C8833-D241-4867-BF6F-8097E0ED8067}.ReleaseObfuscated|x64.ActiveCfg = ReleaseObfuscated|Any CPU
{D79C8833-D241-4867-BF6F-8097E0ED8067}.ReleaseObfuscated|x64.Build.0 = ReleaseObfuscated|Any CPU
{CC460943-1E07-4FA0-8B8C-67F0EF385290}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CC460943-1E07-4FA0-8B8C-67F0EF385290}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CC460943-1E07-4FA0-8B8C-67F0EF385290}.Debug|x64.ActiveCfg = Debug|Any CPU
{CC460943-1E07-4FA0-8B8C-67F0EF385290}.Debug|x64.Build.0 = Debug|Any CPU
{CC460943-1E07-4FA0-8B8C-67F0EF385290}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CC460943-1E07-4FA0-8B8C-67F0EF385290}.Release|Any CPU.Build.0 = Release|Any CPU
{CC460943-1E07-4FA0-8B8C-67F0EF385290}.Release|x64.ActiveCfg = Release|Any CPU
{CC460943-1E07-4FA0-8B8C-67F0EF385290}.Release|x64.Build.0 = Release|Any CPU
{CC460943-1E07-4FA0-8B8C-67F0EF385290}.ReleaseObfuscated|Any CPU.ActiveCfg = Release|Any CPU
{CC460943-1E07-4FA0-8B8C-67F0EF385290}.ReleaseObfuscated|Any CPU.Build.0 = Release|Any CPU
{CC460943-1E07-4FA0-8B8C-67F0EF385290}.ReleaseObfuscated|x64.ActiveCfg = ReleaseObfuscated|Any CPU
{CC460943-1E07-4FA0-8B8C-67F0EF385290}.ReleaseObfuscated|x64.Build.0 = ReleaseObfuscated|Any CPU
{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823}.Debug|x64.ActiveCfg = Debug|Any CPU
{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823}.Debug|x64.Build.0 = Debug|Any CPU
{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823}.Release|Any CPU.Build.0 = Release|Any CPU
{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823}.Release|x64.ActiveCfg = Release|Any CPU
{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823}.Release|x64.Build.0 = Release|Any CPU
{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823}.ReleaseObfuscated|Any CPU.ActiveCfg = Release|Any CPU
{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823}.ReleaseObfuscated|Any CPU.Build.0 = Release|Any CPU
{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823}.ReleaseObfuscated|x64.ActiveCfg = ReleaseObfuscated|Any CPU
{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823}.ReleaseObfuscated|x64.Build.0 = ReleaseObfuscated|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{0D465539-6133-4088-B4BB-F260FA2A1557} = {121C2200-A844-44FD-85C4-22D6C7E35553}
{D79C8833-D241-4867-BF6F-8097E0ED8067} = {121C2200-A844-44FD-85C4-22D6C7E35553}
{CC460943-1E07-4FA0-8B8C-67F0EF385290} = {121C2200-A844-44FD-85C4-22D6C7E35553}
{CB1DFB63-22D9-4E90-A8C1-A4F7CFEF7823} = {121C2200-A844-44FD-85C4-22D6C7E35553}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B17E85B1-5F60-4440-9F9A-3DDE877E8CDF}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,85 @@
[*.{cs,vb}]
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
indent_size = 4
end_of_line = crlf
[*.cs]
csharp_indent_labels = one_less_than_current
csharp_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_braces = true:silent
csharp_style_namespace_declarations = file_scoped:error
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = true:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_throw_expression = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
[*.{cs,vb}]
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:silent
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_prefer_conditional_expression_over_return = true:silent
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
dotnet_style_namespace_match_folder = true:error

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Newtonsoft.Json;
namespace CustomizePlus.Anamnesis.Data;
[Serializable]
public class PoseFile
{
public Vector? Scale { get; set; }
public Dictionary<string, Bone?>? Bones { get; set; }
[Serializable]
public class Bone
{
public Vector? Scale { get; set; }
}
[Serializable]
public class Vector
{
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
public static Vector FromString(string str)
{
var parts = str.Split(new[] { ", " }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3)
{
throw new FormatException();
}
Vector v = new()
{
X = float.Parse(parts[0], CultureInfo.InvariantCulture),
Y = float.Parse(parts[1], CultureInfo.InvariantCulture),
Z = float.Parse(parts[2], CultureInfo.InvariantCulture)
};
return v;
}
public override string ToString()
{
return $"{X}, {Y}, {Z}";
}
}
public class VectorConverter : JsonConverter<Vector>
{
public override Vector? ReadJson(JsonReader reader, Type objectType, Vector? existingValue,
bool hasExistingValue, JsonSerializer serializer)
{
return reader.Value is not string str ? null : Vector.FromString(str);
}
public override void WriteJson(JsonWriter writer, Vector? value, JsonSerializer serializer)
{
throw new NotSupportedException();
}
}
}

View File

@@ -0,0 +1,74 @@
using CustomizePlus.Anamnesis.Data;
using CustomizePlus.Core.Data;
using CustomizePlus.Core.Extensions;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
namespace CustomizePlus.Anamnesis;
public class PoseFileBoneLoader
{
public Dictionary<string, BoneTransform>? LoadBoneTransformsFromFile(string path)
{
if (!File.Exists(path))
return null;
var json = File.ReadAllText(path);
JsonSerializerSettings settings = new()
{
NullValueHandling = NullValueHandling.Ignore,
Converters = new List<JsonConverter> { new PoseFile.VectorConverter() }
};
var pose = JsonConvert.DeserializeObject<PoseFile>(json, settings);
if (pose == null)
{
throw new Exception("Failed to deserialize pose file");
}
if (pose.Bones == null)
{
return null;
}
var retDict = new Dictionary<string, BoneTransform>();
foreach (var kvp in pose.Bones)
{
if (kvp.Key == Constants.RootBoneName || kvp.Value == null || kvp.Value.Scale == null)
continue;
var scale = kvp.Value!.Scale!.GetAsNumericsVector();
if (scale == Vector3.One)
continue;
retDict[kvp.Key] = new BoneTransform
{
Scaling = scale
};
}
//load up root, but check it more rigorously
var validRoot = pose.Bones.TryGetValue(Constants.RootBoneName, out var root)
&& root != null
&& root.Scale != null
&& root.Scale.GetAsNumericsVector() != Vector3.Zero
&& root.Scale.GetAsNumericsVector() != Vector3.One;
if (validRoot)
{
retDict[Constants.RootBoneName] = new BoneTransform
{
Scaling = root!.Scale!.GetAsNumericsVector()
};
}
return retDict;
}
}

View File

@@ -0,0 +1,243 @@
using Dalamud.Plugin.Services;
using Dalamud.Plugin;
using System;
using System.Text;
using OtterGui.Log;
using Newtonsoft.Json;
using System.IO.Compression;
using System.IO;
using Dalamud.Plugin.Ipc;
using Dalamud.Game.ClientState.Objects.Types;
using CustomizePlus.Configuration.Helpers;
using CustomizePlus.Profiles;
using CustomizePlus.Configuration.Data.Version3;
using CustomizePlus.Profiles.Data;
using CustomizePlus.Game.Services;
using CustomizePlus.Templates.Events;
using CustomizePlus.Profiles.Events;
using CustomizePlus.Templates.Data;
using CustomizePlus.GameData.Data;
namespace CustomizePlus.Api.Compatibility;
public class CustomizePlusIpc : IDisposable
{
private readonly IObjectTable _objectTable;
private readonly DalamudPluginInterface _pluginInterface;
private readonly Logger _logger;
private readonly ProfileManager _profileManager;
private readonly GameObjectService _gameObjectService;
private readonly TemplateChanged _templateChangedEvent;
private readonly ProfileChanged _profileChangedEvent;
private const int _configurationVersion = 3;
public const string ProviderApiVersionLabel = $"CustomizePlus.{nameof(GetApiVersion)}";
public const string GetProfileFromCharacterLabel = $"CustomizePlus.{nameof(GetProfileFromCharacter)}";
public const string SetProfileToCharacterLabel = $"CustomizePlus.{nameof(SetProfileToCharacter)}";
public const string RevertCharacterLabel = $"CustomizePlus.{nameof(RevertCharacter)}";
//public const string OnProfileUpdateLabel = $"CustomizePlus.{nameof(OnProfileUpdate)}"; //I'm honestly not sure this is even used by mare
public static readonly (int, int) ApiVersion = (3, 0);
//Sends local player's profile on hooks reload (plugin startup) as well as any updates to their profile.
//If no profile is applied sends null
internal ICallGateProvider<string?, string?, object?>? ProviderOnProfileUpdate;
internal ICallGateProvider<Character?, object>? ProviderRevertCharacter;
internal ICallGateProvider<string, Character?, object>? ProviderSetProfileToCharacter;
internal ICallGateProvider<Character?, string?>? ProviderGetProfileFromCharacter;
internal ICallGateProvider<(int, int)>? ProviderGetApiVersion;
public CustomizePlusIpc(
IObjectTable objectTable,
DalamudPluginInterface pluginInterface,
Logger logger,
ProfileManager profileManager,
GameObjectService gameObjectService//,
/*TemplateChanged templateChangedEvent,
ProfileChanged profileChangedEvent*/)
{
_objectTable = objectTable;
_pluginInterface = pluginInterface;
_logger = logger;
_profileManager = profileManager;
_gameObjectService = gameObjectService;
/* _templateChangedEvent = templateChangedEvent;
_profileChangedEvent = profileChangedEvent;*/
InitializeProviders();
/*_templateChangedEvent.Subscribe(OnTemplateChange, TemplateChanged.Priority.CustomizePlusIpc);
_profileChangedEvent.Subscribe(OnProfileChange, ProfileChanged.Priority.CustomizePlusIpc);*/
}
public void Dispose()
{
DisposeProviders();
}
private void InitializeProviders()
{
_logger.Debug("Initializing legacy Customize+ IPC providers.");
try
{
ProviderGetApiVersion = _pluginInterface.GetIpcProvider<(int, int)>(ProviderApiVersionLabel);
ProviderGetApiVersion.RegisterFunc(GetApiVersion);
}
catch (Exception ex)
{
_logger.Error($"Error registering legacy Customize+ IPC provider for {ProviderApiVersionLabel}: {ex}");
}
try
{
ProviderGetProfileFromCharacter =
_pluginInterface.GetIpcProvider<Character?, string?>(GetProfileFromCharacterLabel);
ProviderGetProfileFromCharacter.RegisterFunc(GetProfileFromCharacter);
}
catch (Exception ex)
{
_logger.Error($"Error registering legacy Customize+ IPC provider for {GetProfileFromCharacterLabel}: {ex}");
}
try
{
ProviderSetProfileToCharacter =
_pluginInterface.GetIpcProvider<string, Character?, object>(SetProfileToCharacterLabel);
ProviderSetProfileToCharacter.RegisterAction(SetProfileToCharacter);
}
catch (Exception ex)
{
_logger.Error($"Error registering legacy Customize+ IPC provider for {SetProfileToCharacterLabel}: {ex}");
}
try
{
ProviderRevertCharacter =
_pluginInterface.GetIpcProvider<Character?, object>(RevertCharacterLabel);
ProviderRevertCharacter.RegisterAction(RevertCharacter);
}
catch (Exception ex)
{
_logger.Error($"Error registering legacy Customize+ IPC provider for {RevertCharacterLabel}: {ex}");
}
/*
try
{
ProviderOnProfileUpdate = _pluginInterface.GetIpcProvider<string?, string?, object?>(OnProfileUpdateLabel);
}
catch (Exception ex)
{
_logger.Error($"Error registering legacy Customize+ IPC provider for {OnProfileUpdateLabel}: {ex}");
}*/
}
private void DisposeProviders()
{
ProviderGetProfileFromCharacter?.UnregisterFunc();
ProviderSetProfileToCharacter?.UnregisterAction();
ProviderRevertCharacter?.UnregisterAction();
ProviderGetApiVersion?.UnregisterFunc();
ProviderOnProfileUpdate?.UnregisterFunc();
}
private void OnProfileUpdate(Profile? profile)
{
//Get player's body profile string and send IPC message
_logger.Debug($"Sending local player update message: {profile?.Name ?? "no profile"} - {profile?.CharacterName ?? "no profile"}");
var convertedProfile = profile != null ? GetVersion3Profile(profile) : null;
ProviderOnProfileUpdate?.SendMessage(convertedProfile?.CharacterName ?? null, convertedProfile == null ? null : JsonConvert.SerializeObject(convertedProfile));
}
private static (int, int) GetApiVersion()
{
return ApiVersion;
}
private string? GetCharacterProfile(string characterName)
{
var profile = _profileManager.GetProfileByCharacterName(characterName, true);
var convertedProfile = profile != null ? GetVersion3Profile(profile) : null;
return convertedProfile != null ? JsonConvert.SerializeObject(convertedProfile) : null;
}
private string? GetProfileFromCharacter(Character? character)
{
return character == null ? null : GetCharacterProfile(character.Name.ToString());
}
private void SetProfileToCharacter(string profileJson, Character? character)
{
if (character == null)
return;
var actor = (Actor)character.Address;
if (!actor.Valid)
return;
/*if (character == _objectTable[0])
{
_logger.Error($"Received request to set profile on local character, this is not allowed");
return;
}*/
try
{
var profile = JsonConvert.DeserializeObject<Version3Profile>(profileJson);
if (profile != null)
{
if (profile.ConfigVersion != _configurationVersion)
throw new Exception("Incompatible version");
_profileManager.AddTemporaryProfile(GetProfileFromVersion3(profile).Item1, actor);
}
}
catch (Exception ex)
{
_logger.Warning($"Unable to set body profile. Character: {character?.Name}, exception: {ex}, debug data: {GetBase64String(profileJson)}");
}
}
private void RevertCharacter(Character? character)
{
if (character == null)
return;
var actor = (Actor)character.Address;
if (!actor.Valid)
return;
/*if (character == _objectTable[0])
{
_logger.Error($"Received request to revert profile on local character, this is not allowed");
return;
}*/
_profileManager.RemoveTemporaryProfile(actor);
}
private string GetBase64String(string data)
{
var json = JsonConvert.SerializeObject(data, Formatting.None);
var bytes = Encoding.UTF8.GetBytes(json);
using var compressedStream = new MemoryStream();
using (var zipStream = new GZipStream(compressedStream, CompressionMode.Compress))
zipStream.Write(bytes, 0, bytes.Length);
return Convert.ToBase64String(compressedStream.ToArray());
}
private Version3Profile GetVersion3Profile(Profile profile)
{
return V4ProfileToV3Converter.Convert(profile);
}
private (Profile, Template) GetProfileFromVersion3(Version3Profile profile)
{
return V3ProfileToV4Converter.Convert(profile);
}
}

View File

@@ -0,0 +1,355 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using Penumbra.GameData.Actors;
using CustomizePlus.Core.Data;
using CustomizePlus.Profiles.Data;
using CustomizePlus.Templates.Data;
using CustomizePlus.GameData.Extensions;
namespace CustomizePlus.Armatures.Data;
/// <summary>
/// Represents a "copy" of the ingame skeleton upon which the linked character profile is meant to operate.
/// Acts as an interface by which the in-game skeleton can be manipulated on a bone-by-bone basis.
/// </summary>
public unsafe class Armature
{
/// <summary>
/// Gets the Customize+ profile for which this mockup applies transformations.
/// </summary>
public Profile Profile { get; set; }
/// <summary>
/// Static identifier of the actor associated with this armature
/// </summary>
public ActorIdentifier ActorIdentifier { get; init; }
/// <summary>
/// Gets or sets a value indicating whether or not this armature has any renderable objects on which it should act.
/// </summary>
public bool IsVisible { get; set; }
/// <summary>
/// Gets a value indicating whether or not this armature has successfully built itself with bone information.
/// </summary>
public bool IsBuilt => _partialSkeletons.Any();
/// <summary>
/// Internal flag telling ArmatureManager that it should attempt to rebind profile to (another) profile whenever possible.
/// </summary>
public bool IsPendingProfileRebind { get; set; }
/// <summary>
/// Represents date and time until which any kind of removal protections will not be applying to this armature.
/// Implemented mostly as a armature cleanup protection hack due to how mare works when downloading files for the first time
/// </summary>
public DateTime ProtectedUntil { get; private set; }
/// <summary>
/// For debugging purposes, each armature is assigned a globally-unique ID number upon creation.
/// </summary>
private static uint _nextGlobalId;
private readonly uint _localId;
/// <summary>
/// Binding telling which bones are bound to each template for this armature. Built from template list in profile.
/// </summary>
public Dictionary<string, Template> BoneTemplateBinding { get; init; }
/// <summary>
/// Each skeleton is made up of several smaller "partial" skeletons.
/// Each partial skeleton has its own list of bones, with a root bone at index zero.
/// The root bone of a partial skeleton may also be a regular bone in a different partial skeleton.
/// </summary>
private ModelBone[][] _partialSkeletons;
#region Bone Accessors -------------------------------------------------------------------------------
/// <summary>
/// Gets the number of partial skeletons contained in this armature.
/// </summary>
public int PartialSkeletonCount => _partialSkeletons.Length;
/// <summary>
/// Get the list of bones belonging to the partial skeleton at the given index.
/// </summary>
public ModelBone[] this[int i]
{
get => _partialSkeletons[i];
}
/// <summary>
/// Returns the number of bones contained within the partial skeleton with the given index.
/// </summary>
public int GetBoneCountOfPartial(int partialIndex) => _partialSkeletons[partialIndex].Length;
/// <summary>
/// Get the bone at index 'j' within the partial skeleton at index 'i'.
/// </summary>
public ModelBone this[int i, int j]
{
get => _partialSkeletons[i][j];
}
/// <summary>
/// Return the bone at the given indices, if it exists
/// </summary>
public ModelBone? GetBoneAt(int partialIndex, int boneIndex)
{
if (_partialSkeletons.Length > partialIndex
&& _partialSkeletons[partialIndex].Length > boneIndex)
{
return this[partialIndex, boneIndex];
}
return null;
}
/// <summary>
/// Returns the root bone of the partial skeleton with the given index.
/// </summary>
public ModelBone GetRootBoneOfPartial(int partialIndex) => this[partialIndex, 0];
public ModelBone MainRootBone => GetRootBoneOfPartial(0);
/// <summary>
/// Get the total number of bones in each partial skeleton combined.
/// </summary>
// In exactly one partial skeleton will the root bone be an independent bone. In all others, it's a reference to a separate, real bone.
// For that reason we must subtract the number of duplicate bones
public int TotalBoneCount => _partialSkeletons.Sum(x => x.Length);
public IEnumerable<ModelBone> GetAllBones()
{
for (var i = 0; i < _partialSkeletons.Length; ++i)
{
for (var j = 0; j < _partialSkeletons[i].Length; ++j)
{
yield return this[i, j];
}
}
}
//----------------------------------------------------------------------------------------------------
#endregion
public Armature(ActorIdentifier actorIdentifier, Profile profile)
{
_localId = _nextGlobalId++;
_partialSkeletons = Array.Empty<ModelBone[]>();
BoneTemplateBinding = new Dictionary<string, Template>();
ActorIdentifier = actorIdentifier;
Profile = profile;
IsVisible = false;
ProtectFromRemoval();
Profile.Armatures.Add(this);
Plugin.Logger.Debug($"Instantiated {this}, attached to {Profile}");
}
/// <inheritdoc/>
public override string ToString()
{
return IsBuilt
? $"Armature (#{_localId}) on {ActorIdentifier.IncognitoDebug()} ({Profile}) with {TotalBoneCount} bone/s"
: $"Armature (#{_localId}) on {ActorIdentifier.IncognitoDebug()} ({Profile}) with no skeleton reference";
}
public bool NewBonesAvailable(CharacterBase* cBase)
{
if (cBase == null)
{
return false;
}
else if (cBase->Skeleton->PartialSkeletonCount > _partialSkeletons.Length)
{
return true;
}
else
{
for (var i = 0; i < cBase->Skeleton->PartialSkeletonCount; ++i)
{
var newPose = cBase->Skeleton->PartialSkeletons[i].GetHavokPose(Constants.TruePoseIndex);
if (newPose != null
&& newPose->Skeleton->Bones.Length > _partialSkeletons[i].Length)
{
return true;
}
}
}
return false;
}
/// <summary>
/// Rebuild the armature using the provided character base as a reference.
/// </summary>
public void RebuildSkeleton(CharacterBase* cBase)
{
if (cBase == null)
return;
var newPartials = ParseBonesFromObject(this, cBase);
_partialSkeletons = newPartials.Select(x => x.ToArray()).ToArray();
RebuildBoneTemplateBinding();
Plugin.Logger.Debug($"Rebuilt {this}");
}
public void AugmentSkeleton(CharacterBase* cBase)
{
if (cBase == null)
return;
var oldPartials = _partialSkeletons.Select(x => x.ToList()).ToList();
var newPartials = ParseBonesFromObject(this, cBase);
//for each of the new partial skeletons discovered...
for (var i = 0; i < newPartials.Count; ++i)
{
//if the old skeleton doesn't contain the new partial at all, add the whole thing
if (i > oldPartials.Count)
{
oldPartials.Add(newPartials[i]);
}
//otherwise, add every model bone the new partial has that the old one doesn't
else
{
//Case: get carbuncle, enable profile for it, turn carbuncle into human via glamourer
if (oldPartials.Count <= i)
oldPartials.Add(new List<ModelBone>());
for (var j = oldPartials[i].Count; j < newPartials[i].Count; ++j)
{
oldPartials[i].Add(newPartials[i][j]);
}
}
}
_partialSkeletons = oldPartials.Select(x => x.ToArray()).ToArray();
RebuildBoneTemplateBinding();
Plugin.Logger.Debug($"Augmented {this} with new bones");
}
public BoneTransform? GetAppliedBoneTransform(string boneName)
{
if (BoneTemplateBinding.TryGetValue(boneName, out var template)
&& template != null)
{
if (template.Bones.TryGetValue(boneName, out var boneTransform))
return boneTransform;
else
Plugin.Logger.Error($"Bone {boneName} is null in template {template.UniqueId}");
}
return null;
}
/// <summary>
/// Apply removal protection for 30 seconds starting from current time. For the most part this is a hack for mare.
/// </summary>
public void ProtectFromRemoval()
{
ProtectedUntil = DateTime.UtcNow.AddSeconds(30);
}
private static unsafe List<List<ModelBone>> ParseBonesFromObject(Armature arm, CharacterBase* cBase)
{
List<List<ModelBone>> newPartials = new();
try
{
//build the skeleton
for (var pSkeleIndex = 0; pSkeleIndex < cBase->Skeleton->PartialSkeletonCount; ++pSkeleIndex)
{
var currentPartial = cBase->Skeleton->PartialSkeletons[pSkeleIndex];
var currentPose = currentPartial.GetHavokPose(Constants.TruePoseIndex);
newPartials.Add(new());
if (currentPose == null)
continue;
for (var boneIndex = 0; boneIndex < currentPose->Skeleton->Bones.Length; ++boneIndex)
{
if (currentPose->Skeleton->Bones[boneIndex].Name.String is string boneName &&
boneName != null)
{
//time to build a new bone
ModelBone newBone = new(arm, boneName, pSkeleIndex, boneIndex);
Plugin.Logger.Debug($"Created new bone: {boneName} on {pSkeleIndex}->{boneIndex} arm: {arm._localId}");
if (currentPose->Skeleton->ParentIndices[boneIndex] is short parentIndex
&& parentIndex >= 0)
{
newBone.AddParent(pSkeleIndex, parentIndex);
newPartials[pSkeleIndex][parentIndex].AddChild(pSkeleIndex, boneIndex);
}
foreach (var mb in newPartials.SelectMany(x => x))
{
if (AreTwinnedNames(boneName, mb.BoneName))
{
newBone.AddTwin(mb.PartialSkeletonIndex, mb.BoneIndex);
mb.AddTwin(pSkeleIndex, boneIndex);
break;
}
}
//linking is performed later
newPartials.Last().Add(newBone);
}
else
{
Plugin.Logger.Error($"Failed to process bone @ <{pSkeleIndex}, {boneIndex}> while parsing bones from {cBase->ToString()}");
}
}
}
BoneData.LogNewBones(newPartials.SelectMany(x => x.Select(y => y.BoneName)).ToArray());
}
catch (Exception ex)
{
Plugin.Logger.Error($"Error parsing armature skeleton from {cBase->ToString()}:\n\t{ex}");
}
return newPartials;
}
public void RebuildBoneTemplateBinding()
{
BoneTemplateBinding.Clear();
foreach (var template in Profile.Templates)
{
foreach (var kvPair in template.Bones)
{
BoneTemplateBinding[kvPair.Key] = template;
}
}
foreach (var bone in GetAllBones())
bone.LinkToTemplate(BoneTemplateBinding.ContainsKey(bone.BoneName) ? BoneTemplateBinding[bone.BoneName] : null);
Plugin.Logger.Debug($"Rebuilt template binding for armature {_localId}");
}
private static bool AreTwinnedNames(string name1, string name2)
{
return name1[^1] == 'r' ^ name2[^1] == 'r'
&& name1[^1] == 'l' ^ name2[^1] == 'l'
&& name1[0..^1] == name2[0..^1];
}
}

View File

@@ -0,0 +1,301 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CustomizePlus.Core.Data;
using CustomizePlus.Templates.Data;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using FFXIVClientStructs.Havok;
namespace CustomizePlus.Armatures.Data;
/// <summary>
/// Represents a single bone of an ingame character's skeleton.
/// </summary>
public unsafe class ModelBone
{
public enum PoseType
{
Local, Model, BindPose, World
}
public readonly Armature MasterArmature;
public readonly int PartialSkeletonIndex;
public readonly int BoneIndex;
/// <summary>
/// Gets the model bone corresponding to this model bone's parent, if it exists.
/// (It should in all cases but the root of the skeleton)
/// </summary>
public ModelBone? ParentBone => _parentPartialIndex >= 0 && _parentBoneIndex >= 0
? MasterArmature[_parentPartialIndex, _parentBoneIndex]
: null;
private int _parentPartialIndex = -1;
private int _parentBoneIndex = -1;
/// <summary>
/// Gets each model bone for which this model bone corresponds to a direct parent thereof.
/// A model bone may have zero children.
/// </summary>
public IEnumerable<ModelBone> ChildBones => _childPartialIndices.Zip(_childBoneIndices, (x, y) => MasterArmature[x, y]);
private List<int> _childPartialIndices = new();
private List<int> _childBoneIndices = new();
/// <summary>
/// Gets the model bone that forms a mirror image of this model bone, if one exists.
/// </summary>
public ModelBone? TwinBone => _twinPartialIndex >= 0 && _twinBoneIndex >= 0
? MasterArmature[_twinPartialIndex, _twinBoneIndex]
: null;
private int _twinPartialIndex = -1;
private int _twinBoneIndex = -1;
/// <summary>
/// The name of the bone within the in-game skeleton. Referred to in some places as its "code name".
/// </summary>
public string BoneName;
/// <summary>
/// The transform that this model bone will impart upon its in-game sibling when the master armature
/// is applied to the in-game skeleton. Reference to transform contained in top most template in profile applied to character.
/// </summary>
public BoneTransform? CustomizedTransform { get; private set; }
/// <summary>
/// True if bone is linked to any template
/// </summary>
public bool IsActive => CustomizedTransform != null;
public ModelBone(Armature arm, string codeName, int partialIdx, int boneIdx)
{
MasterArmature = arm;
PartialSkeletonIndex = partialIdx;
BoneIndex = boneIdx;
BoneName = codeName;
}
/// <summary>
/// Link bone to specific template, unlinks if null is passed
/// </summary>
/// <param name="template"></param>
/// <returns></returns>
public bool LinkToTemplate(Template? template)
{
if (template == null)
{
if (CustomizedTransform == null)
return false;
CustomizedTransform = null;
Plugin.Logger.Information($"Unlinked {BoneName} from all templates");
return true;
}
if (!template.Bones.ContainsKey(BoneName))
return false;
Plugin.Logger.Information($"Linking {BoneName} to {template.Name}");
CustomizedTransform = template.Bones[BoneName];
return true;
}
/// <summary>
/// Indicate a bone to act as this model bone's "parent".
/// </summary>
public void AddParent(int parentPartialIdx, int parentBoneIdx)
{
if (_parentPartialIndex != -1 || _parentBoneIndex != -1)
{
throw new Exception($"Tried to add redundant parent to model bone -- {this}");
}
_parentPartialIndex = parentPartialIdx;
_parentBoneIndex = parentBoneIdx;
}
/// <summary>
/// Indicate that a bone is one of this model bone's "children".
/// </summary>
public void AddChild(int childPartialIdx, int childBoneIdx)
{
_childPartialIndices.Add(childPartialIdx);
_childBoneIndices.Add(childBoneIdx);
}
/// <summary>
/// Indicate a bone that acts as this model bone's mirror image, or "twin".
/// </summary>
public void AddTwin(int twinPartialIdx, int twinBoneIdx)
{
_twinPartialIndex = twinPartialIdx;
_twinBoneIndex = twinBoneIdx;
}
public override string ToString()
{
//string numCopies = _copyIndices.Count > 0 ? $" ({_copyIndices.Count} copies)" : string.Empty;
return $"{BoneName} ({BoneData.GetBoneDisplayName(BoneName)}) @ <{PartialSkeletonIndex}, {BoneIndex}>";
}
/// <summary>
/// Get the lineage of this model bone, going back to the skeleton's root bone.
/// </summary>
public IEnumerable<ModelBone> GetAncestors(bool includeSelf = true) => includeSelf
? GetAncestors(new List<ModelBone>() { this })
: GetAncestors(new List<ModelBone>());
private IEnumerable<ModelBone> GetAncestors(List<ModelBone> tail)
{
tail.Add(this);
if (ParentBone is ModelBone mb && mb != null)
{
return mb.GetAncestors(tail);
}
else
{
return tail;
}
}
/// <summary>
/// Gets all model bones with a lineage that contains this one.
/// </summary>
public IEnumerable<ModelBone> GetDescendants(bool includeSelf = false) => includeSelf
? GetDescendants(this)
: GetDescendants(null);
private IEnumerable<ModelBone> GetDescendants(ModelBone? first)
{
var output = first != null
? new List<ModelBone>() { first }
: new List<ModelBone>();
output.AddRange(ChildBones);
using (var iter = output.GetEnumerator())
{
while (iter.MoveNext())
{
output.AddRange(iter.Current.ChildBones);
yield return iter.Current;
}
}
}
/// <summary>
/// Given a character base to which this model bone's master armature (presumably) applies,
/// return the game's transform value for this model's in-game sibling within the given reference frame.
/// </summary>
public hkQsTransformf GetGameTransform(CharacterBase* cBase, PoseType refFrame)
{
var skelly = cBase->Skeleton;
var pSkelly = skelly->PartialSkeletons[PartialSkeletonIndex];
var targetPose = pSkelly.GetHavokPose(Constants.TruePoseIndex);
//hkaPose* targetPose = cBase->Skeleton->PartialSkeletons[PartialSkeletonIndex].GetHavokPose(Constants.TruePoseIndex);
if (targetPose == null) return Constants.NullTransform;
return refFrame switch
{
PoseType.Local => targetPose->LocalPose[BoneIndex],
PoseType.Model => targetPose->ModelPose[BoneIndex],
_ => Constants.NullTransform
//TODO properly implement the other options
};
}
private void SetGameTransform(CharacterBase* cBase, hkQsTransformf transform, PoseType refFrame)
{
SetGameTransform(cBase, transform, PartialSkeletonIndex, BoneIndex, refFrame);
}
private static void SetGameTransform(CharacterBase* cBase, hkQsTransformf transform, int partialIndex, int boneIndex, PoseType refFrame)
{
var skelly = cBase->Skeleton;
var pSkelly = skelly->PartialSkeletons[partialIndex];
var targetPose = pSkelly.GetHavokPose(Constants.TruePoseIndex);
//hkaPose* targetPose = cBase->Skeleton->PartialSkeletons[PartialSkeletonIndex].GetHavokPose(Constants.TruePoseIndex);
if (targetPose == null || targetPose->ModelInSync == 0) return;
switch (refFrame)
{
case PoseType.Local:
targetPose->LocalPose.Data[boneIndex] = transform;
return;
case PoseType.Model:
targetPose->ModelPose.Data[boneIndex] = transform;
return;
default:
return;
//TODO properly implement the other options
}
}
/// <summary>
/// Apply this model bone's associated transformation to its in-game sibling within
/// the skeleton of the given character base.
/// </summary>
public void ApplyModelTransform(CharacterBase* cBase)
{
if (!IsActive)
return;
if (cBase != null
&& CustomizedTransform.IsEdited()
&& GetGameTransform(cBase, PoseType.Model) is hkQsTransformf gameTransform
&& !gameTransform.Equals(Constants.NullTransform)
&& CustomizedTransform.ModifyExistingTransform(gameTransform) is hkQsTransformf modTransform
&& !modTransform.Equals(Constants.NullTransform))
{
SetGameTransform(cBase, modTransform, PoseType.Model);
}
}
public void ApplyModelScale(CharacterBase* cBase) => ApplyTransFunc(cBase, CustomizedTransform.ModifyExistingScale);
public void ApplyModelRotation(CharacterBase* cBase) => ApplyTransFunc(cBase, CustomizedTransform.ModifyExistingRotation);
public void ApplyModelFullTranslation(CharacterBase* cBase) => ApplyTransFunc(cBase, CustomizedTransform.ModifyExistingTranslationWithRotation);
public void ApplyStraightModelTranslation(CharacterBase* cBase) => ApplyTransFunc(cBase, CustomizedTransform.ModifyExistingTranslation);
private void ApplyTransFunc(CharacterBase* cBase, Func<hkQsTransformf, hkQsTransformf> modTrans)
{
if (!IsActive)
return;
if (cBase != null
&& CustomizedTransform.IsEdited()
&& GetGameTransform(cBase, PoseType.Model) is hkQsTransformf gameTransform
&& !gameTransform.Equals(Constants.NullTransform))
{
var modTransform = modTrans(gameTransform);
if (!modTransform.Equals(gameTransform) && !modTransform.Equals(Constants.NullTransform))
{
SetGameTransform(cBase, modTransform, PoseType.Model);
}
}
}
/// <summary>
/// Checks for a non-zero and non-identity (root) scale.
/// </summary>
/// <param name="mb">The bone to check</param>
/// <returns>If the scale should be applied.</returns>
public bool IsModifiedScale()
{
if (!IsActive)
return false;
return CustomizedTransform.Scaling.X != 0 && CustomizedTransform.Scaling.X != 1 ||
CustomizedTransform.Scaling.Y != 0 && CustomizedTransform.Scaling.Y != 1 ||
CustomizedTransform.Scaling.Z != 0 && CustomizedTransform.Scaling.Z != 1;
}
}

View File

@@ -0,0 +1,30 @@
using CustomizePlus.Armatures.Data;
using OtterGui.Classes;
using System;
namespace CustomizePlus.Armatures.Events;
/// <summary>
/// Triggered when armature is changed
/// </summary>
public sealed class ArmatureChanged() : EventWrapper<ArmatureChanged.Type, Armature?, object?, ArmatureChanged.Priority>(nameof(ArmatureChanged))
{
public enum Type
{
//Created,
Deleted
}
public enum Priority
{
ProfileManager
}
public enum DeletionReason
{
Gone,
NoActiveProfiles,
ProfileManagerEvent,
TemplateEditorEvent
}
}

View File

@@ -0,0 +1,535 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dalamud.Plugin.Services;
using OtterGui.Log;
using OtterGui.Classes;
using Penumbra.GameData.Actors;
using System.Numerics;
using CustomizePlus.Core.Data;
using CustomizePlus.Armatures.Events;
using CustomizePlus.Armatures.Data;
using CustomizePlus.Profiles;
using CustomizePlus.Profiles.Data;
using CustomizePlus.Game.Services;
using CustomizePlus.Templates.Events;
using CustomizePlus.Profiles.Events;
using CustomizePlus.Core.Extensions;
using CustomizePlus.GameData.Data;
using CustomizePlus.GameData.Services;
using CustomizePlus.GameData.Extensions;
namespace CustomizePlus.Armatures.Services;
public unsafe sealed class ArmatureManager : IDisposable
{
private readonly ProfileManager _profileManager;
private readonly IObjectTable _objectTable;
private readonly GameObjectService _gameObjectService;
private readonly TemplateChanged _templateChangedEvent;
private readonly ProfileChanged _profileChangedEvent;
private readonly Logger _logger;
private readonly FrameworkManager _framework;
private readonly ObjectManager _objectManager;
private readonly ActorService _actorService;
private readonly ArmatureChanged _event;
public Dictionary<ActorIdentifier, Armature> Armatures { get; private set; } = new();
public ArmatureManager(
ProfileManager profileManager,
IObjectTable objectTable,
GameObjectService gameObjectService,
TemplateChanged templateChangedEvent,
ProfileChanged profileChangedEvent,
Logger logger,
FrameworkManager framework,
ObjectManager objectManager,
ActorService actorService,
ArmatureChanged @event)
{
_profileManager = profileManager;
_objectTable = objectTable;
_gameObjectService = gameObjectService;
_templateChangedEvent = templateChangedEvent;
_profileChangedEvent = profileChangedEvent;
_logger = logger;
_framework = framework;
_objectManager = objectManager;
_actorService = actorService;
_event = @event;
_templateChangedEvent.Subscribe(OnTemplateChange, TemplateChanged.Priority.ArmatureManager);
_profileChangedEvent.Subscribe(OnProfileChange, ProfileChanged.Priority.ArmatureManager);
}
public void Dispose()
{
_templateChangedEvent.Unsubscribe(OnTemplateChange);
_profileChangedEvent.Unsubscribe(OnProfileChange);
}
/// <summary>
/// Main rendering function, called from rendering hook
/// </summary>
public void OnRender()
{
try
{
RefreshArmatures();
ApplyArmatureTransforms();
}
catch (Exception ex)
{
_logger.Error($"Exception while rendering armatures:\n\t{ex}");
}
}
/// <summary>
/// Function called when game object movement is detected
/// </summary>
public void OnGameObjectMove(Actor actor)
{
if (!actor.Identifier(_actorService.AwaitedService, out var identifier))
return;
if (Armatures.TryGetValue(identifier, out var armature) && armature.IsBuilt && armature.IsVisible)
ApplyRootTranslation(armature, actor);
}
/// <summary>
/// Deletes armatures which no longer have actor associated with them and creates armatures for new actors
/// </summary>
private void RefreshArmatures()
{
_objectManager.Update();
var currentTime = DateTime.UtcNow;
foreach (var kvPair in Armatures.ToList())
{
var armature = kvPair.Value;
if (!_objectManager.ContainsKey(kvPair.Value.ActorIdentifier) &&
currentTime > armature.ProtectedUntil) //Only remove armatures which are no longer protected
{
_logger.Debug($"Removing armature {armature} because {kvPair.Key.IncognitoDebug()} is gone");
RemoveArmature(armature, ArmatureChanged.DeletionReason.Gone);
}
}
Profile? GetProfileForActor(ActorIdentifier identifier)
{
foreach (var profile in _profileManager.GetEnabledProfilesByActor(identifier))
{
if (profile.LimitLookupToOwnedObjects &&
identifier.Type == IdentifierType.Owned &&
identifier.PlayerName != _objectManager.PlayerData.Identifier.PlayerName)
continue;
return profile;
}
return null;
}
foreach (var obj in _objectManager)
{
if (!Armatures.ContainsKey(obj.Key))
{
var activeProfile = GetProfileForActor(obj.Key);
if (activeProfile == null)
continue;
var newArm = new Armature(obj.Key, activeProfile);
TryLinkSkeleton(newArm);
Armatures.Add(obj.Key, newArm);
_logger.Debug($"Added '{newArm}' for {obj.Key.IncognitoDebug()} to cache");
continue;
}
var armature = Armatures[obj.Key];
if (armature.IsPendingProfileRebind)
{
_logger.Debug($"Armature {armature} is pending profile rebind, rebinding...");
armature.IsPendingProfileRebind = false;
var activeProfile = GetProfileForActor(obj.Key);
if (activeProfile == armature.Profile)
continue;
if (activeProfile == null)
{
_logger.Debug($"Removing armature {armature} because it doesn't have any active profiles");
RemoveArmature(armature, ArmatureChanged.DeletionReason.NoActiveProfiles);
continue;
}
armature.Profile.Armatures.Remove(armature);
armature.Profile = activeProfile;
activeProfile.Armatures.Add(armature);
armature.RebuildBoneTemplateBinding();
}
armature.IsVisible = armature.Profile.Enabled && TryLinkSkeleton(armature); //todo: remove armatures which are not visible?
}
}
private unsafe void ApplyArmatureTransforms()
{
foreach (var kvPair in Armatures)
{
var armature = kvPair.Value;
if (armature.IsBuilt && armature.IsVisible && _objectManager.ContainsKey(armature.ActorIdentifier))
{
foreach (var actor in _objectManager[armature.ActorIdentifier].Objects)
ApplyPiecewiseTransformation(armature, actor, armature.ActorIdentifier);
}
}
}
/// <summary>
/// Returns whether or not a link can be established between the armature and an in-game object.
/// If unbuilt, the armature will be rebuilded.
/// </summary>
private bool TryLinkSkeleton(Armature armature, bool forceRebuild = false)
{
_objectManager.Update();
try
{
if (!_objectManager.ContainsKey(armature.ActorIdentifier))
return false;
var actor = _objectManager[armature.ActorIdentifier].Objects[0];
if (!armature.IsBuilt || forceRebuild)
{
armature.RebuildSkeleton(actor.Model.AsCharacterBase);
}
else if (armature.NewBonesAvailable(actor.Model.AsCharacterBase))
{
armature.AugmentSkeleton(actor.Model.AsCharacterBase);
}
return true;
}
catch (Exception ex)
{
// This is on wait until isse #191 on Github responds. Keeping it in code, delete it if I forget and this is longer then a month ago.
// Disabling this if its any Default Profile due to Log spam. A bit crazy but hey, if its for me id Remove Default profiles all together so this is as much as ill do for now! :)
//if(!(Profile.CharacterName.Equals(Constants.DefaultProfileCharacterName) || Profile.CharacterName.Equals("DefaultCutscene"))) {
_logger.Error($"Error occured while attempting to link skeleton: {armature}");
throw;
//}
}
}
/// <summary>
/// Iterate through the skeleton of the given character base, and apply any transformations
/// for which this armature contains corresponding model bones. This method of application
/// is safer but more computationally costly
/// </summary>
private void ApplyPiecewiseTransformation(Armature armature, Actor actor, ActorIdentifier actorIdentifier)
{
var cBase = actor.Model.AsCharacterBase;
var isMount = actorIdentifier.Type == IdentifierType.Owned &&
actorIdentifier.Kind == Dalamud.Game.ClientState.Objects.Enums.ObjectKind.MountType;
Actor? mountOwner = null;
Armature? mountOwnerArmature = null;
if (isMount)
{
(var ident, mountOwner) = _gameObjectService.FindActorsByName(actorIdentifier.PlayerName.ToString()).FirstOrDefault();
Armatures.TryGetValue(ident, out mountOwnerArmature);
}
if (cBase != null)
{
for (var pSkeleIndex = 0; pSkeleIndex < cBase->Skeleton->PartialSkeletonCount; ++pSkeleIndex)
{
var currentPose = cBase->Skeleton->PartialSkeletons[pSkeleIndex].GetHavokPose(Constants.TruePoseIndex);
if (currentPose != null)
{
for (var boneIndex = 0; boneIndex < currentPose->Skeleton->Bones.Length; ++boneIndex)
{
if (armature.GetBoneAt(pSkeleIndex, boneIndex) is ModelBone mb
&& mb != null
&& mb.BoneName == currentPose->Skeleton->Bones[boneIndex].Name.String)
{
if (mb == armature.MainRootBone)
{
if (_gameObjectService.IsActorHasScalableRoot(actor) && mb.IsModifiedScale())
{
cBase->DrawObject.Object.Scale = mb.CustomizedTransform!.Scaling;
//Fix mount owner's scale if needed
//todo: always keep owner's scale proper instead of scaling with mount if no armature found
if (isMount && mountOwner != null && mountOwnerArmature != null)
{
var ownerDrawObject = cBase->DrawObject.Object.ChildObject;
//limit to only modified scales because that is just easier to handle
//because we don't need to hook into dismount code to reset character scale
//todo: hook into dismount
//https://github.com/Cytraen/SeatedSidekickSpectator/blob/main/SetModeHook.cs?
if (cBase->DrawObject.Object.ChildObject == mountOwner.Value.Model &&
mountOwnerArmature.MainRootBone.IsModifiedScale())
{
var baseScale = mountOwnerArmature.MainRootBone.CustomizedTransform!.Scaling;
ownerDrawObject->Scale = new Vector3(Math.Abs(baseScale.X / cBase->DrawObject.Object.Scale.X),
Math.Abs(baseScale.Y / cBase->DrawObject.Object.Scale.Y),
Math.Abs(baseScale.Z / cBase->DrawObject.Object.Scale.Z));
}
}
}
}
else
{
mb.ApplyModelTransform(cBase);
}
}
}
}
}
}
}
private void ApplyRootTranslation(Armature arm, Actor actor)
{
//I'm honestly not sure if we should or even can check if cBase->DrawObject or cBase->DrawObject.Object is a valid object
//So for now let's assume we don't need to check for that
var cBase = actor.Model.AsCharacterBase;
if (cBase != null)
{
var rootBoneTransform = arm.GetAppliedBoneTransform("n_root");
if (rootBoneTransform == null)
return;
if (rootBoneTransform.Translation.X == 0 &&
rootBoneTransform.Translation.Y == 0 &&
rootBoneTransform.Translation.Z == 0)
return;
if (!cBase->DrawObject.IsVisible)
return;
var newPosition = new FFXIVClientStructs.FFXIV.Common.Math.Vector3
{
X = cBase->DrawObject.Object.Position.X + MathF.Max(rootBoneTransform.Translation.X, 0.01f),
Y = cBase->DrawObject.Object.Position.Y + MathF.Max(rootBoneTransform.Translation.Y, 0.01f),
Z = cBase->DrawObject.Object.Position.Z + MathF.Max(rootBoneTransform.Translation.Z, 0.01f)
};
cBase->DrawObject.Object.Position = newPosition;
}
}
private void RemoveArmature(Armature armature, ArmatureChanged.DeletionReason reason)
{
armature.Profile.Armatures.Remove(armature);
Armatures.Remove(armature.ActorIdentifier);
_logger.Debug($"Armature {armature} removed from cache");
_event.Invoke(ArmatureChanged.Type.Deleted, armature, reason);
}
private void OnTemplateChange(TemplateChanged.Type type, Templates.Data.Template? template, object? arg3)
{
if (type is not TemplateChanged.Type.NewBone &&
type is not TemplateChanged.Type.DeletedBone &&
type is not TemplateChanged.Type.EditorCharacterChanged &&
type is not TemplateChanged.Type.EditorEnabled &&
type is not TemplateChanged.Type.EditorDisabled)
return;
if (type == TemplateChanged.Type.NewBone ||
type == TemplateChanged.Type.DeletedBone) //type == TemplateChanged.Type.EditorCharacterChanged?
{
//In case a lot of events are triggered at the same time for the same template this should limit the amount of times bindings are unneccessary rebuilt
_framework.RegisterImportant($"TemplateRebuild @ {template.UniqueId}", () =>
{
foreach (var profile in _profileManager.GetProfilesUsingTemplate(template))
{
_logger.Debug($"ArmatureManager.OnTemplateChange New/Deleted bone or character changed: {type}, template: {template.Name.Text.Incognify()}, profile: {profile.Name.Text.Incognify()}->{profile.Enabled}->{profile.Armatures.Count} armatures");
if (!profile.Enabled || profile.Armatures.Count == 0)
continue;
profile.Armatures.ForEach(x => x.RebuildBoneTemplateBinding());
}
});
return;
}
if (type == TemplateChanged.Type.EditorCharacterChanged)
{
(var characterName, var profile) = ((string, Profile))arg3;
foreach (var armature in GetArmaturesForCharacterName(characterName))
{
armature.IsPendingProfileRebind = true;
_logger.Debug($"ArmatureManager.OnTemplateChange Editor profile character name changed, armature rebind scheduled: {type}, {armature}");
}
if (profile.Armatures.Count == 0)
return;
//Rebuild armatures for previous character
foreach (var armature in profile.Armatures)
armature.IsPendingProfileRebind = true;
_logger.Debug($"ArmatureManager.OnTemplateChange Editor profile character name changed, armature rebind scheduled: {type}, profile: {profile.Name.Text.Incognify()}->{profile.Enabled}, new name: {characterName.Incognify()}");
return;
}
if (type == TemplateChanged.Type.EditorEnabled ||
type == TemplateChanged.Type.EditorDisabled)
{
foreach (var armature in GetArmaturesForCharacterName((string)arg3!))
{
armature.IsPendingProfileRebind = true;
_logger.Debug($"ArmatureManager.OnTemplateChange template editor enabled/disabled: {type}, pending profile set for {armature}");
}
return;
}
}
private void OnProfileChange(ProfileChanged.Type type, Profile? profile, object? arg3)
{
if (type is not ProfileChanged.Type.AddedTemplate &&
type is not ProfileChanged.Type.RemovedTemplate &&
type is not ProfileChanged.Type.MovedTemplate &&
type is not ProfileChanged.Type.ChangedTemplate &&
type is not ProfileChanged.Type.Toggled &&
type is not ProfileChanged.Type.Deleted &&
type is not ProfileChanged.Type.TemporaryProfileAdded &&
type is not ProfileChanged.Type.TemporaryProfileDeleted &&
type is not ProfileChanged.Type.ChangedCharacterName &&
type is not ProfileChanged.Type.ChangedDefaultProfile &&
type is not ProfileChanged.Type.LimitLookupToOwnedChanged)
return;
if (type == ProfileChanged.Type.ChangedDefaultProfile)
{
var oldProfile = (Profile?)arg3;
if (oldProfile == null || oldProfile.Armatures.Count == 0)
return;
foreach (var armature in oldProfile.Armatures)
armature.IsPendingProfileRebind = true;
_logger.Debug($"ArmatureManager.OnProfileChange Profile no longer default, armatures rebind scheduled: {type}, old profile: {oldProfile.Name.Text.Incognify()}->{oldProfile.Enabled}");
return;
}
if (profile == null)
{
_logger.Error($"ArmatureManager.OnProfileChange Invalid input for event: {type}, profile is null.");
return;
}
if (type == ProfileChanged.Type.Toggled)
{
if (!profile.Enabled && profile.Armatures.Count == 0)
return;
if (profile == _profileManager.DefaultProfile)
{
foreach (var kvPair in Armatures)
{
var armature = kvPair.Value;
if (armature.Profile == profile)
armature.IsPendingProfileRebind = true;
_logger.Debug($"ArmatureManager.OnProfileChange default profile toggled, planning rebind for armature {armature}");
}
return;
}
if (string.IsNullOrWhiteSpace(profile.CharacterName))
return;
foreach (var armature in GetArmaturesForCharacterName(profile.CharacterName))
{
armature.IsPendingProfileRebind = true;
_logger.Debug($"ArmatureManager.OnProfileChange profile {profile} toggled, planning rebind for armature {armature}");
}
return;
}
if (type == ProfileChanged.Type.TemporaryProfileAdded)
{
if (!profile.TemporaryActor.IsValid || !Armatures.ContainsKey(profile.TemporaryActor))
return;
var armature = Armatures[profile.TemporaryActor];
if (armature.Profile == profile)
return;
armature.ProtectFromRemoval();
armature.IsPendingProfileRebind = true;
_logger.Debug($"ArmatureManager.OnProfileChange TemporaryProfileAdded, calling rebind for existing armature: {type}, data payload: {arg3?.ToString()}, profile: {profile.Name.Text.Incognify()}->{profile.Enabled}");
return;
}
if (type == ProfileChanged.Type.ChangedCharacterName ||
type == ProfileChanged.Type.Deleted ||
type == ProfileChanged.Type.TemporaryProfileDeleted ||
type == ProfileChanged.Type.LimitLookupToOwnedChanged)
{
if (profile.Armatures.Count == 0)
return;
foreach (var armature in profile.Armatures)
{
if (type == ProfileChanged.Type.TemporaryProfileDeleted)
armature.ProtectFromRemoval(); //just to be safe
armature.IsPendingProfileRebind = true;
}
_logger.Debug($"ArmatureManager.OnProfileChange CCN/DEL/TPD/LLTOC, armature rebind scheduled: {type}, data payload: {arg3?.ToString()?.Incognify()}, profile: {profile.Name.Text.Incognify()}->{profile.Enabled}");
return;
}
//todo: shouldn't happen, but happens sometimes? I think?
if (profile.Armatures.Count == 0)
return;
_logger.Debug($"ArmatureManager.OnProfileChange Added/Deleted/Moved/Changed template: {type}, data payload: {arg3?.ToString()}, profile: {profile.Name}->{profile.Enabled}->{profile.Armatures.Count} armatures");
profile!.Armatures.ForEach(x => x.RebuildBoneTemplateBinding());
}
private IEnumerable<Armature> GetArmaturesForCharacterName(string characterName)
{
var actors = _gameObjectService.FindActorsByName(characterName).ToList();
if (actors.Count == 0)
yield break;
foreach (var actorData in actors)
{
if (!Armatures.TryGetValue(actorData.Item1, out var armature))
continue;
yield return armature;
}
}
}

View File

@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.IO;
using Dalamud.Configuration;
using Dalamud.Interface.Internal.Notifications;
using Newtonsoft.Json;
using OtterGui.Classes;
using OtterGui.Log;
using OtterGui.Widgets;
using ErrorEventArgs = Newtonsoft.Json.Serialization.ErrorEventArgs;
using CustomizePlus.Core.Services;
using CustomizePlus.Core.Data;
using CustomizePlus.Configuration.Services;
using CustomizePlus.Game.Services;
using CustomizePlus.UI.Windows;
namespace CustomizePlus.Configuration.Data;
[Serializable]
public class PluginConfiguration : IPluginConfiguration, ISavable
{
public const int CurrentVersion = Constants.ConfigurationVersion;
public int Version { get; set; } = CurrentVersion;
public bool PluginEnabled { get; set; } = true;
public bool DebuggingModeEnabled { get; set; }
/// <summary>
/// Id of the default profile applied to all characters without any profile. Can be set to Empty to disable this feature.
/// </summary>
public Guid DefaultProfile { get; set; } = Guid.Empty;
[Serializable]
public class ChangelogSettingsEntries
{
public int LastSeenVersion { get; set; } = CPlusChangeLog.LastChangelogVersion;
public ChangeLogDisplayType ChangeLogDisplayType { get; set; } = ChangeLogDisplayType.New;
}
public ChangelogSettingsEntries ChangelogSettings { get; set; } = new();
[Serializable]
public class UISettingsEntries
{
public DoubleModifier DeleteTemplateModifier { get; set; } = new(ModifierHotkey.Control, ModifierHotkey.Shift);
public bool FoldersDefaultOpen { get; set; } = true;
public bool HideWindowInCutscene { get; set; } = true;
public bool IncognitoMode { get; set; } = false;
public List<string> ViewedMessageWindows { get; set; } = new();
}
public UISettingsEntries UISettings { get; set; } = new();
[Serializable]
public class EditorConfigurationEntries
{
/// <summary>
/// Hides root position from the UI. DOES NOT DISABLE LOADING IT FROM THE CONFIG!
/// </summary>
public bool RootPositionEditingEnabled { get; set; } = false;
public bool ShowLiveBones { get; set; } = true;
public bool BoneMirroringEnabled { get; set; } = false;
public bool LimitLookupToOwnedObjects { get; set; } = false;
public string? PreviewCharacterName { get; set; } = null;
public int EditorValuesPrecision { get; set; } = 3;
public BoneAttribute EditorMode { get; set; } = BoneAttribute.Position;
}
public EditorConfigurationEntries EditorConfiguration { get; set; } = new();
[JsonIgnore]
private readonly SaveService _saveService;
[JsonIgnore]
private readonly Logger _logger;
[JsonIgnore]
private readonly ChatService _chatService;
[JsonIgnore]
private readonly MessageService _messageService;
public PluginConfiguration(
SaveService saveService,
Logger logger,
ChatService chatService,
MessageService messageService,
ConfigurationMigrator migrator)
{
_saveService = saveService;
_logger = logger;
_chatService = chatService;
_messageService = messageService;
Load(migrator);
}
public void Load(ConfigurationMigrator migrator)
{
static void HandleDeserializationError(object? sender, ErrorEventArgs errorArgs)
{
Plugin.Logger.Error(
$"Error parsing configuration at {errorArgs.ErrorContext.Path}, using default or migrating:\n{errorArgs.ErrorContext.Error}");
errorArgs.ErrorContext.Handled = true;
}
if (!File.Exists(_saveService.FileNames.ConfigFile))
return;
try
{
var text = File.ReadAllText(_saveService.FileNames.ConfigFile);
JsonConvert.PopulateObject(text, this, new JsonSerializerSettings
{
Error = HandleDeserializationError,
});
}
catch (Exception ex)
{
_messageService.NotificationMessage(ex,
"Error reading configuration, reverting to default.\nYou may be able to restore your configuration using the rolling backups in the XIVLauncher/backups/CustomizePlus directory.",
"Error reading configuration", NotificationType.Error);
}
migrator.Migrate(this);
}
public string ToFilename(FilenameService fileNames)
=> fileNames.ConfigFile;
public void Save(StreamWriter writer)
{
using var jWriter = new JsonTextWriter(writer) { Formatting = Formatting.Indented };
var serializer = new JsonSerializer { Formatting = Formatting.Indented };
serializer.Serialize(jWriter, this);
}
public void Save()
=> _saveService.DelaySave(this);
}

View File

@@ -0,0 +1,60 @@
using System;
using System.Numerics;
namespace CustomizePlus.Configuration.Data.Version3;
public class V3BoneTransform
{
private Vector3 _translation;
public Vector3 Translation
{
get => _translation;
set => _translation = ClampVector(value);
}
private Vector3 _rotation;
public Vector3 Rotation
{
get => _rotation;
set => _rotation = ClampAngles(value);
}
private Vector3 _scaling;
public Vector3 Scaling
{
get => _scaling;
set => _scaling = ClampVector(value);
}
/// <summary>
/// Clamp all vector values to be within allowed limits.
/// </summary>
private Vector3 ClampVector(Vector3 vector)
{
return new Vector3
{
X = Math.Clamp(vector.X, -512, 512),
Y = Math.Clamp(vector.Y, -512, 512),
Z = Math.Clamp(vector.Z, -512, 512)
};
}
private static Vector3 ClampAngles(Vector3 rotVec)
{
static float Clamp(float angle)
{
if (angle > 180)
angle -= 360;
else if (angle < -180)
angle += 360;
return angle;
}
rotVec.X = Clamp(rotVec.X);
rotVec.Y = Clamp(rotVec.Y);
rotVec.Z = Clamp(rotVec.Z);
return rotVec;
}
}

View File

@@ -0,0 +1,24 @@
using CustomizePlus.Core.Data;
using System;
using System.Collections.Generic;
namespace CustomizePlus.Configuration.Data.Version3;
/// <summary>
/// Encapsulates the user-controlled aspects of a character profile, ie all of
/// the information that gets saved to disk by the plugin.
/// </summary>
[Serializable]
public sealed class Version3Profile
{
public string CharacterName { get; set; } = "Default";
public string ProfileName { get; set; } = "Profile";
public nint? Address { get; set; } = null;
public bool OwnedOnly { get; set; } = false;
public int ConfigVersion { get; set; } = Constants.ConfigurationVersion;
public bool Enabled { get; set; }
public DateTime CreationDate { get; set; } = DateTime.Now;
public DateTime ModifiedDate { get; set; } = DateTime.Now;
public Dictionary<string, V3BoneTransform> Bones { get; init; } = new();
}

View File

@@ -0,0 +1,43 @@
using CustomizePlus.Configuration.Data.Version3;
using CustomizePlus.Core.Data;
using CustomizePlus.Profiles.Data;
using CustomizePlus.Templates.Data;
using System;
using System.Collections.Generic;
namespace CustomizePlus.Configuration.Helpers;
internal static class V3ProfileToV4Converter
{
public static (Profile, Template) Convert(Version3Profile v3Profile)
{
var profile = new Profile
{
Name = $"{v3Profile.ProfileName} - {v3Profile.CharacterName}",
CharacterName = v3Profile.CharacterName,
CreationDate = v3Profile.CreationDate,
ModifiedDate = DateTimeOffset.UtcNow,
Enabled = v3Profile.Enabled,
LimitLookupToOwnedObjects = v3Profile.OwnedOnly,
UniqueId = Guid.NewGuid(),
Templates = new List<Template>(1)
};
var template = new Template
{
Name = $"{profile.Name}'s template",
CreationDate = profile.CreationDate,
ModifiedDate = profile.ModifiedDate,
UniqueId = Guid.NewGuid(),
Bones = new Dictionary<string, BoneTransform>(v3Profile.Bones.Count)
};
foreach (var kvPair in v3Profile.Bones)
template.Bones.Add(kvPair.Key,
new BoneTransform { Translation = kvPair.Value.Translation, Rotation = kvPair.Value.Rotation, Scaling = kvPair.Value.Scaling });
profile.Templates.Add(template);
return (profile, template);
}
}

View File

@@ -0,0 +1,39 @@
using CustomizePlus.Configuration.Data.Version3;
using CustomizePlus.Profiles.Data;
using System;
using System.Collections.Generic;
namespace CustomizePlus.Configuration.Helpers;
internal static class V4ProfileToV3Converter
{
public static Version3Profile Convert(Profile v4Profile)
{
var profile = new Version3Profile
{
ProfileName = v4Profile.Name,
CharacterName = v4Profile.CharacterName,
CreationDate = v4Profile.CreationDate.DateTime,
ModifiedDate = DateTime.UtcNow,
Enabled = v4Profile.Enabled,
OwnedOnly = v4Profile.LimitLookupToOwnedObjects,
ConfigVersion = 3,
Bones = new Dictionary<string, V3BoneTransform>()
};
foreach (var template in v4Profile.Templates)
{
foreach (var kvPair in template.Bones) //not super optimal but whatever
{
profile.Bones[kvPair.Key] = new V3BoneTransform
{
Translation = kvPair.Value.Translation,
Rotation = kvPair.Value.Rotation,
Scaling = kvPair.Value.Scaling
};
}
}
return profile;
}
}

View File

@@ -0,0 +1,107 @@
using Dalamud.Interface.Internal.Notifications;
using Newtonsoft.Json;
using OtterGui.Classes;
using OtterGui.Log;
using System;
using System.Collections.Generic;
using System.IO;
using CustomizePlus.Core.Data;
using CustomizePlus.Core.Services;
using CustomizePlus.Configuration.Helpers;
using CustomizePlus.Configuration.Data;
using CustomizePlus.Core.Events;
using CustomizePlus.Configuration.Data.Version3;
namespace CustomizePlus.Configuration.Services;
public class ConfigurationMigrator
{
private readonly SaveService _saveService;
private readonly BackupService _backupService;
private readonly MessageService _messageService;
private readonly Logger _logger;
private readonly ReloadEvent _reloadEvent;
public ConfigurationMigrator(
SaveService saveService,
BackupService backupService,
MessageService messageService,
Logger logger,
ReloadEvent reloadEvent
)
{
_saveService = saveService;
_backupService = backupService;
_messageService = messageService;
_logger = logger;
_reloadEvent = reloadEvent;
}
public void Migrate(PluginConfiguration config)
{
var configVersion = config.Version;
if (configVersion >= Constants.ConfigurationVersion)
return;
//V3 migration code
if (configVersion < 3)
{
_messageService.NotificationMessage($"Unable to migrate your Customize+ configuration because it is too old. Manually install latest version of Customize+ 1.x to migrate your configuration to supported version first.", NotificationType.Error);
return;
}
MigrateV3ToV4();
// /V3 migration code
//I'm sorry, I'm too lazy so v3's enable root position setting is not getting migrated for now
//MigrateV3ToV4(configVersion);
config.Version = Constants.ConfigurationVersion;
_saveService.ImmediateSave(config);
}
private void MigrateV3ToV4()
{
_backupService.CreateV3Backup();
//I'm sorry, I'm too lazy so v3's enable root position setting is not getting migrated
var usedGuids = new HashSet<Guid>();
foreach (var file in Directory.EnumerateFiles(_saveService.FileNames.ConfigDirectory, "*.profile", SearchOption.TopDirectoryOnly))
{
_logger.Debug($"Migrating v3 profile {file}");
var legacyProfile = JsonConvert.DeserializeObject<Version3Profile>(File.ReadAllText(file));
if (legacyProfile == null)
continue;
_logger.Debug($"v3 profile {file} loaded as {legacyProfile.ProfileName}");
(var profile, var template) = V3ProfileToV4Converter.Convert(legacyProfile);
//regenerate guids just to be safe
do
{
profile.UniqueId = Guid.NewGuid();
}
while (profile.UniqueId == Guid.Empty || usedGuids.Contains(profile.UniqueId));
usedGuids.Add(profile.UniqueId);
do
{
template.UniqueId = Guid.NewGuid();
}
while (template.UniqueId == Guid.Empty || usedGuids.Contains(template.UniqueId));
usedGuids.Add(template.UniqueId);
_saveService.ImmediateSave(template);
_saveService.ImmediateSave(profile);
_logger.Debug($"Migrated v3 profile {legacyProfile.ProfileName} to profile {profile.UniqueId} and template {template.UniqueId}");
File.Delete(file);
}
_reloadEvent.Invoke(ReloadEvent.Type.ReloadAll);
}
}

View File

@@ -0,0 +1,86 @@
using CustomizePlus.Core.Services;
using OtterGui.Log;
using System.IO;
namespace CustomizePlus.Configuration.Services.Temporary;
internal class FantasiaPlusConfigMover
{
private readonly Logger _logger;
private readonly FilenameService _filenameService;
private readonly BackupService _backupService;
public FantasiaPlusConfigMover(
BackupService backupService,
Logger logger,
FilenameService filenameService
)
{
_backupService = backupService;
_logger = logger;
_filenameService = filenameService;
}
public void MoveConfigsIfNeeded()
{
string fantasiaPlusConfig = _filenameService.ConfigFile.Replace("CustomizePlus.json", "FantasiaPlus.json");
if (!File.Exists(_filenameService.ConfigFile.Replace("CustomizePlus.json", "FantasiaPlus.json")))
return;
_logger.Information("Found FantasiaPlus configuration, moving it to CustomizePlus folders");
if(File.Exists(_filenameService.ConfigFile) || Directory.Exists(_filenameService.ConfigDirectory))
{
_logger.Debug("Creating a backup of current c+ config");
_backupService.CreateV3Backup("fantasia_plus_migration");
}
_logger.Debug("Removing current c+ data");
File.Delete(_filenameService.ConfigFile);
Directory.Delete(_filenameService.ConfigDirectory, true);
_logger.Debug("Copying fantasia+ data");
File.Copy(fantasiaPlusConfig, _filenameService.ConfigFile);
string fantasiaPlusDirectory = _filenameService.ConfigDirectory.Replace("CustomizePlus", "FantasiaPlus");
CopyDirectory(fantasiaPlusDirectory, _filenameService.ConfigDirectory, true);
_logger.Debug("Deleting fantasia+ data");
File.Delete(fantasiaPlusConfig);
Directory.Delete(fantasiaPlusDirectory, true);
_logger.Information("Done moving fantasia+ configuration");
}
static void CopyDirectory(string sourceDir, string destinationDir, bool recursive)
{
// Get information about the source directory
var dir = new DirectoryInfo(sourceDir);
// Check if the source directory exists
if (!dir.Exists)
throw new DirectoryNotFoundException($"Source directory not found: {dir.FullName}");
// Cache directories before we start copying
DirectoryInfo[] dirs = dir.GetDirectories();
// Create the destination directory
Directory.CreateDirectory(destinationDir);
// Get the files in the source directory and copy to the destination directory
foreach (FileInfo file in dir.GetFiles())
{
string targetFilePath = Path.Combine(destinationDir, file.Name);
file.CopyTo(targetFilePath);
}
// If recursive and copying subdirectories, recursively call this method
if (recursive)
{
foreach (DirectoryInfo subDir in dirs)
{
string newDestinationDir = Path.Combine(destinationDir, subDir.Name);
CopyDirectory(subDir.FullName, newDestinationDir, true);
}
}
}
}

View File

@@ -0,0 +1,570 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Dalamud.Utility;
namespace CustomizePlus.Core.Data;
public static class BoneData //todo: DI, do not show IVCS unless IVCS is installed/user enabled it, do not show weapon bones
{
public enum BoneFamily
{
Root,
Spine,
Hair,
Face,
Ears,
Chest,
Arms,
Hands,
Tail,
Groin,
Legs,
Feet,
Earrings,
Hat,
Cape,
Armor,
Skirt,
Equipment,
Unknown
}
//TODO move the csv data to an external (compressed?) file
private static readonly string[] BoneRawTable =
{
//Codename, Display Name, Bone Family, Parent (if any), Mirrored Bone (if any)
"n_root,Root,Root,TRUE,FALSE,,", "n_hara,Abdomen,Root,TRUE,FALSE,,", "j_kao,Head,Spine,TRUE,FALSE,j_kubi,",
"j_kubi,Neck,Spine,TRUE,FALSE,j_sebo_c,", "j_sebo_c,Spine C,Spine,TRUE,FALSE,j_sebo_b,",
"j_sebo_b,Spine B,Spine,TRUE,FALSE,j_sebo_a,", "j_sebo_a,Spine A,Spine,TRUE,FALSE,j_kosi,",
"j_kosi,Waist,Spine,TRUE,FALSE,,", "j_kami_a,Hair A,Hair,TRUE,FALSE,j_kao,",
"j_kami_b,Hair B,Hair,TRUE,FALSE,j_kami_a,", "j_kami_f_l,Hair Front Left,Hair,TRUE,FALSE,j_kao,j_kami_f_r",
"j_kami_f_r,Hair Front Right,Hair,TRUE,FALSE,j_kao,j_kami_f_l",
"j_f_mayu_l,Brow Outer Left,Face,TRUE,FALSE,j_kao,j_f_mayu_r",
"j_f_mayu_r,Brow Outer Right,Face,TRUE,FALSE,j_kao,j_f_mayu_l",
"j_f_miken_l,Brow Inner Left,Face,TRUE,FALSE,j_kao,j_f_miken_r",
"j_f_miken_r,Brow Inner Right,Face,TRUE,FALSE,j_kao,j_f_miken_l",
"j_f_memoto,Bridge,Face,TRUE,FALSE,j_kao,", "j_f_umab_l,Eyelid Upper Left,Face,TRUE,FALSE,j_kao,j_f_umab_r",
"j_f_umab_r,Eyelid Upper Right,Face,TRUE,FALSE,j_kao,j_f_umab_l",
"j_f_dmab_l,Eyelid Lower Left,Face,TRUE,FALSE,j_kao,j_f_dmab_r",
"j_f_dmab_r,Eyelid Lower Right,Face,TRUE,FALSE,j_kao,j_f_dmab_l",
"j_f_eye_l,Eye Left,Face,TRUE,FALSE,j_kao,j_f_eye_r", "j_f_eye_r,Eye Right,Face,TRUE,FALSE,j_kao,j_f_eye_l",
"j_f_hoho_l,Cheek Left,Face,TRUE,FALSE,j_kao,j_f_hoho_r",
"j_f_hoho_r,Cheek Right,Face,TRUE,FALSE,j_kao,j_f_hoho_l",
"j_f_hige_l,Hrothgar Whiskers Left,Face,FALSE,FALSE,j_kao,j_f_hige_r",
"j_f_hige_r,Hrothgar Whiskers Right,Face,FALSE,FALSE,j_kao,j_f_hige_l",
"j_f_hana,Nose,Face,TRUE,FALSE,j_kao,", "j_f_lip_l,Lips Left,Face,TRUE,FALSE,j_kao,j_f_lip_r",
"j_f_lip_r,Lips Right,Face,TRUE,FALSE,j_kao,j_f_lip_l", "j_f_ulip_a,Lip Upper A,Face,TRUE,FALSE,j_kao,",
"j_f_ulip_b,Lip Upper B,Face,TRUE,FALSE,j_kao,", "j_f_dlip_a,Lip Lower A,Face,TRUE,FALSE,j_kao,",
"j_f_dlip_b,Lip Lower B,Face,TRUE,FALSE,j_kao,",
"n_f_lip_l,Hrothgar Cheek Left,Face,FALSE,FALSE,j_kao,n_f_lip_r",
"n_f_lip_r,Hrothgar Cheek Right,Face,FALSE,FALSE,j_kao,n_f_lip_l",
"n_f_ulip_l,Hrothgar Lip Upper Left,Face,FALSE,FALSE,j_kao,n_f_ulip_r",
"n_f_ulip_r,Hrothgar Lip Upper Right,Face,FALSE,FALSE,j_kao,n_f_ulip_l",
"j_f_dlip,Hrothgar Lip Lower,Face,FALSE,FALSE,j_kao,", "j_ago,Jaw,Face,TRUE,FALSE,j_kao,",
"j_f_uago,Hrothgar Palate Upper,Face,FALSE,FALSE,j_kao,",
"j_f_ulip,Hrothgar Palate Lower,Face,FALSE,FALSE,j_kao,",
"j_mimi_l,Ear Left,Ears,TRUE,FALSE,j_kao,j_mimi_r", "j_mimi_r,Ear Right,Ears,TRUE,FALSE,j_kao,j_mimi_l",
"j_zera_a_l,Viera Ear 01 A Left,Ears,FALSE,FALSE,j_kao,j_zera_a_r",
"j_zera_a_r,Viera Ear 01 A Right,Ears,FALSE,FALSE,j_kao,j_zera_a_l",
"j_zera_b_l,Viera Ear 01 B Left,Ears,FALSE,FALSE,j_kao,j_zera_b_r",
"j_zera_b_r,Viera Ear 01 B Right,Ears,FALSE,FALSE,j_kao,j_zera_b_l",
"j_zerb_a_l,Viera Ear 02 A Left,Ears,FALSE,FALSE,j_kao,j_zerb_a_r",
"j_zerb_a_r,Viera Ear 02 A Right,Ears,FALSE,FALSE,j_kao,j_zerb_a_l",
"j_zerb_b_l,Viera Ear 02 B Left,Ears,FALSE,FALSE,j_kao,j_zerb_b_r",
"j_zerb_b_r,Viera Ear 02 B Right,Ears,FALSE,FALSE,j_kao,j_zerb_b_l",
"j_zerc_a_l,Viera Ear 03 A Left,Ears,FALSE,FALSE,j_kao,j_zerc_a_r",
"j_zerc_a_r,Viera Ear 03 A Right,Ears,FALSE,FALSE,j_kao,j_zerc_a_l",
"j_zerc_b_l,Viera Ear 03 B Left,Ears,FALSE,FALSE,j_kao,j_zerc_b_r",
"j_zerc_b_r,Viera Ear 03 B Right,Ears,FALSE,FALSE,j_kao,j_zerc_b_l",
"j_zerd_a_l,Viera Ear 04 A Left,Ears,FALSE,FALSE,j_kao,j_zerd_a_r",
"j_zerd_a_r,Viera Ear 04 A Right,Ears,FALSE,FALSE,j_kao,j_zerd_a_l",
"j_zerd_b_l,Viera Ear 04 B Left,Ears,FALSE,FALSE,j_kao,j_zerd_b_r",
"j_zerd_b_r,Viera Ear 04 B Right,Ears,FALSE,FALSE,j_kao,j_zerd_b_l",
"j_sako_l,Clavicle Left,Chest,TRUE,FALSE,j_sebo_c,j_sako_r",
"j_sako_r,Clavicle Right,Chest,TRUE,FALSE,j_sebo_c,j_sako_l",
"j_mune_l,Breast Left,Chest,TRUE,FALSE,j_sebo_b,j_mune_r",
"j_mune_r,Breast Right,Chest,TRUE,FALSE,j_sebo_b,j_mune_l",
"iv_c_mune_l,Breast B Left,Chest,FALSE,TRUE,j_mune_l,iv_c_mune_r",
"iv_c_mune_r,Breast B Right,Chest,FALSE,TRUE,j_mune_r,iv_c_mune_l",
"n_hkata_l,Shoulder Left,Arms,TRUE,FALSE,j_ude_a_l,n_hkata_r",
"n_hkata_r,Shoulder Right,Arms,TRUE,FALSE,j_ude_a_r,n_hkata_l",
"j_ude_a_l,Arm Left,Arms,TRUE,FALSE,j_sako_l,j_ude_a_r",
"j_ude_a_r,Arm Right,Arms,TRUE,FALSE,j_sako_r,j_ude_a_l",
"iv_nitoukin_l,Bicep Left,Arms,FALSE,TRUE,j_ude_a_l,iv_nitoukin_r",
"iv_nitoukin_r,Bicep Right,Arms,FALSE,TRUE,j_ude_a_r,iv_nitoukin_l",
"n_hhiji_l,Elbow Left,Arms,TRUE,FALSE,j_ude_b_l,n_hhiji_r",
"n_hhiji_r,Elbow Right,Arms,TRUE,FALSE,j_ude_b_r,n_hhiji_l",
"j_ude_b_l,Forearm Left,Arms,TRUE,FALSE,j_ude_a_l,j_ude_b_r",
"j_ude_b_r,Forearm Right,Arms,TRUE,FALSE,j_ude_a_r,j_ude_b_l",
"n_hte_l,Wrist Left,Arms,TRUE,FALSE,j_ude_b_l,n_hte_r",
"n_hte_r,Wrist Right,Arms,TRUE,FALSE,j_ude_b_r,n_hte_l", "j_te_l,Hand Left,Hands,TRUE,FALSE,n_hte_l,j_te_r",
"j_te_r,Hand Right,Hands,TRUE,FALSE,n_hte_r,j_te_l",
"j_oya_a_l,Thumb A Left,Hands,TRUE,FALSE,j_te_l,j_oya_a_r",
"j_oya_a_r,Thumb A Right,Hands,TRUE,FALSE,j_te_r,j_oya_a_l",
"j_oya_b_l,Thumb B Left,Hands,TRUE,FALSE,j_oya_a_l,j_oya_b_r",
"j_oya_b_r,Thumb B Right,Hands,TRUE,FALSE,j_oya_a_r,j_oya_b_l",
"j_hito_a_l,Index A Left,Hands,TRUE,FALSE,j_te_l,j_hito_a_r",
"j_hito_a_r,Index A Right,Hands,TRUE,FALSE,j_te_r,j_hito_a_l",
"j_hito_b_l,Index B Left,Hands,TRUE,FALSE,j_hito_a_l,j_hito_b_r",
"j_hito_b_r,Index B Right,Hands,TRUE,FALSE,j_hito_a_r,j_hito_b_l",
"j_naka_a_l,Middle A Left,Hands,TRUE,FALSE,j_te_l,j_naka_a_r",
"j_naka_a_r,Middle A Right,Hands,TRUE,FALSE,j_te_r,j_naka_a_l",
"j_naka_b_l,Middle B Left,Hands,TRUE,FALSE,j_naka_a_l,j_naka_b_r",
"j_naka_b_r,Middle B Right,Hands,TRUE,FALSE,j_naka_a_r,j_naka_b_l",
"j_kusu_a_l,Ring A Left,Hands,TRUE,FALSE,j_te_l,j_kusu_a_r",
"j_kusu_a_r,Ring A Right,Hands,TRUE,FALSE,j_te_r,j_kusu_a_l",
"j_kusu_b_l,Ring B Left,Hands,TRUE,FALSE,j_kusu_a_l,j_kusu_b_r",
"j_kusu_b_r,Ring B Right,Hands,TRUE,FALSE,j_kusu_a_r,j_kusu_b_l",
"j_ko_a_l,Pinky A Left,Hands,TRUE,FALSE,j_te_l,j_ko_a_r",
"j_ko_a_r,Pinky A Right,Hands,TRUE,FALSE,j_te_r,j_ko_a_l",
"j_ko_b_l,Pinky B Left,Hands,TRUE,FALSE,j_ko_a_l,j_ko_b_r",
"j_ko_b_r,Pinky B Right,Hands,TRUE,FALSE,j_ko_a_r,j_ko_b_l",
"iv_hito_c_l,Index C Left,Hands,FALSE,TRUE,j_hito_b_l,iv_hito_c_r",
"iv_hito_c_r,Index C Right,Hands,FALSE,TRUE,j_hito_b_r,iv_hito_c_l",
"iv_naka_c_l,Middle C Left,Hands,FALSE,TRUE,j_naka_b_l,iv_naka_c_r",
"iv_naka_c_r,Middle C Right,Hands,FALSE,TRUE,j_naka_b_r,iv_naka_c_l",
"iv_kusu_c_l,Ring C Left,Hands,FALSE,TRUE,j_kusu_b_l,iv_kusu_c_r",
"iv_kusu_c_r,Ring C Right,Hands,FALSE,TRUE,j_kusu_b_r,iv_kusu_c_l",
"iv_ko_c_l,Pinky C Left,Hands,FALSE,TRUE,j_ko_b_l,iv_ko_c_r",
"iv_ko_c_r,Pinky C Right,Hands,FALSE,TRUE,j_ko_b_r,iv_ko_c_l", "n_sippo_a,Tail A,Tail,FALSE,FALSE,j_kosi,",
"n_sippo_b,Tail B,Tail,FALSE,FALSE,n_sippo_a,", "n_sippo_c,Tail C,Tail,FALSE,FALSE,n_sippo_b,",
"n_sippo_d,Tail D,Tail,FALSE,FALSE,n_sippo_c,", "n_sippo_e,Tail E,Tail,FALSE,FALSE,n_sippo_d,",
"iv_shiri_l,Buttock Left,Groin,FALSE,TRUE,j_kosi,iv_shiri_r",
"iv_shiri_r,Buttock Right,Groin,FALSE,TRUE,j_kosi,iv_shiri_l",
"iv_kougan_l,Scrotum Left,Groin,FALSE,TRUE,iv_ochinko_a,iv_kougan_r",
"iv_kougan_r,Scrotum Right,Groin,FALSE,TRUE,iv_ochinko_a,iv_kougan_l",
"iv_ochinko_a,Penis A,Groin,FALSE,TRUE,j_kosi,", "iv_ochinko_b,Penis B,Groin,FALSE,TRUE,iv_ochinko_a,",
"iv_ochinko_c,Penis C,Groin,FALSE,TRUE,iv_ochinko_b,",
"iv_ochinko_d,Penis D,Groin,FALSE,TRUE,iv_ochinko_c,",
"iv_ochinko_e,Penis E,Groin,FALSE,TRUE,iv_ochinko_d,",
"iv_ochinko_f,Penis F,Groin,FALSE,TRUE,iv_ochinko_e,", "iv_omanko,Vagina,Groin,FALSE,TRUE,j_kosi,",
"iv_kuritto,Clitoris,Groin,FALSE,TRUE,iv_omanko,",
"iv_inshin_l,Labia Left,Groin,FALSE,TRUE,iv_omanko,iv_inshin_r",
"iv_inshin_r,Labia Right,Groin,FALSE,TRUE,iv_omanko,iv_inshin_l", "iv_koumon,Anus,Groin,FALSE,TRUE,j_kosi,",
"iv_koumon_l,Anus B Right,Groin,FALSE,TRUE,iv_koumon,iv_koumon_r",
"iv_koumon_r,Anus B Left,Groin,FALSE,TRUE,iv_koumon,iv_koumon_l",
"j_asi_a_l,Leg Left,Legs,TRUE,FALSE,j_kosi,j_asi_a_r",
"j_asi_a_r,Leg Right,Legs,TRUE,FALSE,j_kosi,j_asi_a_l",
"j_asi_b_l,Knee Left,Legs,TRUE,FALSE,j_asi_a_l,j_asi_b_r",
"j_asi_b_r,Knee Right,Legs,TRUE,FALSE,j_asi_a_r,j_asi_b_l",
"j_asi_c_l,Calf Left,Legs,TRUE,FALSE,j_asi_b_l,j_asi_c_r",
"j_asi_c_r,Calf Right,Legs,TRUE,FALSE,j_asi_b_r,j_asi_c_l",
"j_asi_d_l,Foot Left,Feet,TRUE,FALSE,j_asi_c_l,j_asi_d_r",
"j_asi_d_r,Foot Right,Feet,TRUE,FALSE,j_asi_c_r,j_asi_d_l",
"j_asi_e_l,Toes Left,Feet,TRUE,FALSE,j_asi_d_l,j_asi_e_r",
"j_asi_e_r,Toes Right,Feet,TRUE,FALSE,j_asi_d_r,j_asi_e_l",
"iv_asi_oya_a_l,Big Toe A Left,Feet,FALSE,TRUE,j_asi_e_l,iv_asi_oya_a_r",
"iv_asi_oya_a_r,Big Toe A Right,Feet,FALSE,TRUE,j_asi_e_r,iv_asi_oya_a_l",
"iv_asi_oya_b_l,Big Toe B Left,Feet,FALSE,TRUE,j_asi_oya_a_l,iv_asi_oya_b_r",
"iv_asi_oya_b_r,Big Toe B Right,Feet,FALSE,TRUE,j_asi_oya_a_r,iv_asi_oya_b_l",
"iv_asi_hito_a_l,Index Toe A Left,Feet,FALSE,TRUE,j_asi_e_l,iv_asi_hito_a_r",
"iv_asi_hito_a_r,Index Toe A Right,Feet,FALSE,TRUE,j_asi_e_r,iv_asi_hito_a_l",
"iv_asi_hito_b_l,Index Toe B Left,Feet,FALSE,TRUE,j_asi_hito_a_l,iv_asi_hito_b_r",
"iv_asi_hito_b_r,Index Toe B Right,Feet,FALSE,TRUE,j_asi_hito_a_r,iv_asi_hito_b_l",
"iv_asi_naka_a_l,Middle Toe A Left,Feet,FALSE,TRUE,j_asi_e_l,iv_asi_naka_a_r",
"iv_asi_naka_a_r,Middle Toe A Right,Feet,FALSE,TRUE,j_asi_e_r,iv_asi_naka_a_l",
"iv_asi_naka_b_l,Middle Toe B Left,Feet,FALSE,TRUE,j_asi_naka_b_l,iv_asi_naka_b_r",
"iv_asi_naka_b_r,Middle Toe B Right,Feet,FALSE,TRUE,j_asi_naka_b_r,iv_asi_naka_b_l",
"iv_asi_kusu_a_l,Fore Toe A Left,Feet,FALSE,TRUE,j_asi_e_l,iv_asi_kusu_a_r",
"iv_asi_kusu_a_r,Fore Toe A Right,Feet,FALSE,TRUE,j_asi_e_r,iv_asi_kusu_a_l",
"iv_asi_kusu_b_l,Fore Toe B Left,Feet,FALSE,TRUE,j_asi_kusu_a_l,iv_asi_kusu_b_r",
"iv_asi_kusu_b_r,Fore Toe B Right,Feet,FALSE,TRUE,j_asi_kusu_a_r,iv_asi_kusu_b_l",
"iv_asi_ko_a_l,Pinky Toe A Left,Feet,FALSE,TRUE,j_asi_e_l,iv_asi_ko_a_r",
"iv_asi_ko_a_r,Pinky Toe A Right,Feet,FALSE,TRUE,j_asi_e_r,iv_asi_ko_a_l",
"iv_asi_ko_b_l,Pinky Toe B Left,Feet,FALSE,TRUE,j_asi_ko_a_l,iv_asi_ko_b_r",
"iv_asi_ko_b_r,Pinky Toe B Right,Feet,FALSE,TRUE,j_asi_ko_a_r,iv_asi_ko_b_l",
"j_ex_met_va,Visor,Hat,FALSE,FALSE,j_kao,", "j_ex_met_a,Hat Accessory A,Hat,FALSE,FALSE,j_kao,",
"j_ex_met_b,Hat Accessory B,Hat,FALSE,FALSE,j_kao,",
"n_ear_b_l,Earring B Left,Earrings,FALSE,FALSE,n_ear_a_l,n_ear_b_r",
"n_ear_b_r,Earring B Right,Earrings,FALSE,FALSE,n_ear_a_r,n_ear_b_l",
"n_ear_a_l,Earring A Left,Earrings,FALSE,FALSE,j_kao,n_ear_a_r",
"n_ear_a_r,Earring A Right,Earrings,FALSE,FALSE,j_kao,n_ear_a_l",
"j_ex_top_a_l,Cape A Left,Cape,FALSE,FALSE,j_sebo_c,j_ex_top_a_r",
"j_ex_top_a_r,Cape A Right,Cape,FALSE,FALSE,j_sebo_c,j_ex_top_a_l",
"j_ex_top_b_l,Cape B Left,Cape,FALSE,FALSE,j_ex_top_a_l,j_ex_top_b_r",
"j_ex_top_b_r,Cape B Right,Cape,FALSE,FALSE,j_ex_top_a_r,j_ex_top_b_l",
"n_kataarmor_l,Pauldron Left,Armor,FALSE,FALSE,n_hkata_l,n_kataarmor_r",
"n_kataarmor_r,Pauldron Right,Armor,FALSE,FALSE,n_hkata_r,n_kataarmor_l",
"n_hijisoubi_l,Elbow Plate Left,Armor,FALSE,FALSE,n_hhiji_l,n_hijisoubi_r",
"n_hijisoubi_r,Elbow Plate Right,Armor,FALSE,FALSE,n_hhiji_r,n_hijisoubi_l",
"n_hizasoubi_l,Knee Plate Left,Armor,FALSE,FALSE,j_asi_b_l,n_hizasoubi_r",
"n_hizasoubi_r,Knee Plate Right,Armor,FALSE,FALSE,j_asi_b_r,n_hizasoubi_l",
"j_sk_b_a_l,Skirt Back A Left,Skirt,FALSE,FALSE,j_kosi,j_sk_b_a_r",
"j_sk_b_a_r,Skirt Back A Right,Skirt,FALSE,FALSE,j_kosi,j_sk_b_a_l",
"j_sk_b_b_l,Skirt Back B Left,Skirt,FALSE,FALSE,j_sk_b_a_l,j_sk_b_b_r",
"j_sk_b_b_r,Skirt Back B Right,Skirt,FALSE,FALSE,j_sk_b_a_r,j_sk_b_b_l",
"j_sk_b_c_l,Skirt Back C Left,Skirt,FALSE,FALSE,j_sk_b_b_l,j_sk_b_c_r",
"j_sk_b_c_r,Skirt Back C Right,Skirt,FALSE,FALSE,j_sk_b_b_r,j_sk_b_c_l",
"j_sk_f_a_l,Skirt Front A Left,Skirt,FALSE,FALSE,j_kosi,j_sk_f_a_r",
"j_sk_f_a_r,Skirt Front A Right,Skirt,FALSE,FALSE,j_kosi,j_sk_f_a_l",
"j_sk_f_b_l,Skirt Front B Left,Skirt,FALSE,FALSE,j_sk_f_a_l,j_sk_f_b_r",
"j_sk_f_b_r,Skirt Front B Right,Skirt,FALSE,FALSE,j_sk_f_a_r,j_sk_f_b_l",
"j_sk_f_c_l,Skirt Front C Left,Skirt,FALSE,FALSE,j_sk_f_b_l,j_sk_f_c_r",
"j_sk_f_c_r,Skirt Front C Right,Skirt,FALSE,FALSE,j_sk_f_b_r,j_sk_f_c_l",
"j_sk_s_a_l,Skirt Side A Left,Skirt,FALSE,FALSE,j_kosi,j_sk_s_a_r",
"j_sk_s_a_r,Skirt Side A Right,Skirt,FALSE,FALSE,j_kosi,j_sk_s_a_l",
"j_sk_s_b_l,Skirt Side B Left,Skirt,FALSE,FALSE,j_sk_s_a_l,j_sk_s_b_r",
"j_sk_s_b_r,Skirt Side B Right,Skirt,FALSE,FALSE,j_sk_s_a_r,j_sk_s_b_l",
"j_sk_s_c_l,Skirt Side C Left,Skirt,FALSE,FALSE,j_sk_s_b_l,j_sk_s_c_r",
"j_sk_s_c_r,Skirt Side C Right,Skirt,FALSE,FALSE,j_sk_s_b_r,j_sk_s_c_l",
"n_throw,Throw,Root,FALSE,FALSE,j_kosi,",
"j_buki_sebo_l,Scabbard Left,Equipment,FALSE,FALSE,j_kosi,j_buki_sebo_r",
"j_buki_sebo_r,Scabbard Right,Equipment,FALSE,FALSE,j_kosi,j_buki_sebo_l",
"j_buki2_kosi_l,Holster Left,Equipment,FALSE,FALSE,j_kosi,j_buki2_kosi_r",
"j_buki2_kosi_r,Holster Right,Equipment,FALSE,FALSE,j_kosi,j_buki2_kosi_l",
"j_buki_kosi_l,Sheath Left,Equipment,FALSE,FALSE,j_kosi,j_buki_kosi_r",
"j_buki_kosi_r,Sheath Right,Equipment,FALSE,FALSE,j_kosi,j_buki_kosi_l",
"n_buki_tate_l,Shield Left,Equipment,FALSE,FALSE,n_hte_l,n_buki_tate_r",
"n_buki_tate_r,Shield Right,Equipment,FALSE,FALSE,n_hte_r,n_buki_tate_l",
"n_buki_l,Weapon Left,Equipment,FALSE,FALSE,j_te_l,n_buki_r",
"n_buki_r,Weapon Right,Equipment,FALSE,FALSE,j_te_r,n_buki_l"
};
public static readonly Dictionary<BoneFamily, string?> DisplayableFamilies = new()
{
{ BoneFamily.Spine, null },
{ BoneFamily.Hair, null },
{ BoneFamily.Face, null },
{ BoneFamily.Ears, null },
{ BoneFamily.Chest, null },
{ BoneFamily.Arms, null },
{ BoneFamily.Hands, null },
{ BoneFamily.Tail, null },
{ BoneFamily.Groin, "NSFW IVCS Bones" },
{ BoneFamily.Legs, null },
{ BoneFamily.Feet, null },
{ BoneFamily.Earrings, "Some mods utilize these bones for their physics properties" },
{ BoneFamily.Hat, null },
{ BoneFamily.Cape, "Some mods utilize these bones for their physics properties" },
{ BoneFamily.Armor, null },
{ BoneFamily.Skirt, null },
{ BoneFamily.Equipment, "These may behave oddly" },
{
BoneFamily.Unknown,
"These bones weren't immediately identifiable.\nIf you can figure out what they're for, let us know and we'll add them to the table."
}
};
private static readonly Dictionary<string, BoneDatum> BoneTable = new();
private static readonly Dictionary<string, string> BoneLookupByDispName = new();
static BoneData()
{
//apparently static constructors are only guaranteed to START before the class is called
//which can apparently lead to race conditions, as I've found out
//this lock is to make sure the table is fully initialized before anything else can try to look at it
lock (BoneTable)
{
var rowIndex = 0;
foreach (var entry in BoneRawTable)
{
try
{
var cells = entry.Split(',');
var codename = cells[0];
var dispName = cells[1];
BoneTable[codename] = new BoneDatum(rowIndex, cells);
BoneLookupByDispName[dispName] = codename;
if (BoneTable[codename].Family == BoneFamily.Unknown)
{
throw new Exception("what the fuck?");
}
}
catch
{
throw new InvalidCastException($"Couldn't parse raw bone table @ row {rowIndex}");
}
++rowIndex;
}
//iterate through the complete collection and assign children to their parents
foreach (var kvp in BoneTable)
{
var datum = BoneTable[kvp.Key];
datum.Children = BoneTable.Where(x => x.Value.Parent == kvp.Key).Select(x => x.Key).ToArray();
BoneTable[kvp.Key] = datum;
}
}
}
public static void LogNewBones(params string[] boneNames)
{
var probablyHairstyleBones = boneNames.Where(IsProbablyHairstyle).ToArray();
foreach (var hairBone in ParseHairstyle(probablyHairstyleBones))
{
BoneTable[hairBone.Codename] = hairBone;
}
foreach (var boneName in boneNames.Except(BoneTable.Keys))
{
var newBone = new BoneDatum
{
RowIndex = -1,
Codename = boneName,
DisplayName = $"Unknown ({boneName})",
Family = BoneFamily.Unknown,
Parent = "j_kosi",
Children = Array.Empty<string>(),
MirroredCodename = null
};
}
}
public static void UpdateParentage(string parentName, string childName)
{
var child = BoneTable[childName];
var parent = BoneTable[parentName];
child.Parent = parentName;
parent.Children = parent.Children.Append(childName).Distinct().ToArray();
BoneTable[childName] = child;
BoneTable[parentName] = parent;
}
public static string GetBoneDisplayName(string codename)
{
return BoneTable.TryGetValue(codename, out var row) ? row.DisplayName : codename;
}
public static string? GetBoneCodename(string boneDisplayName)
{
return BoneLookupByDispName.TryGetValue(boneDisplayName, out var name) ? name : null;
}
public static List<string> GetBoneCodenames()
{
return BoneTable.Keys.ToList();
}
public static List<string> GetBoneCodenames(Func<BoneDatum, bool> predicate)
{
return BoneTable.Where(x => predicate(x.Value)).Select(x => x.Key).ToList();
}
public static List<string> GetBoneDisplayNames()
{
return BoneLookupByDispName.Keys.ToList();
}
public static BoneFamily GetBoneFamily(string codename)
{
return BoneTable.TryGetValue(codename, out var row) ? row.Family : BoneFamily.Unknown;
}
public static bool IsDefaultBone(string codename)
{
return BoneTable.TryGetValue(codename, out var row) && row.IsDefault;
}
public static int GetBoneRanking(string codename)
{
return BoneTable.TryGetValue(codename, out var row) ? row.RowIndex : 0;
}
public static bool IsIVCSBone(string codename)
{
return BoneTable.TryGetValue(codename, out var row) && row.IsIVCS;
}
public static string? GetBoneMirror(string codename)
{
return BoneTable.TryGetValue(codename, out var row) ? row.MirroredCodename : null;
}
public static string[] GetChildren(string codename)
{
return BoneTable.TryGetValue(codename, out var row) ? row.Children : Array.Empty<string>();
}
public static bool IsProbablyHairstyle(string codename)
{
return Regex.IsMatch(codename, @"j_ex_h\d\d\d\d_ke_[abcdeflrsu](_[abcdeflrsu])?");
}
public static bool IsNewBone(string codename)
{
return !BoneTable.ContainsKey(codename);
}
private static BoneFamily ParseFamilyName(string n)
{
var simplified = n.Split(' ').FirstOrDefault()?.ToLower() ?? string.Empty;
var fam = simplified switch
{
"root" => BoneFamily.Root,
"spine" => BoneFamily.Spine,
"hair" => BoneFamily.Hair,
"face" => BoneFamily.Face,
"ears" => BoneFamily.Ears,
"chest" => BoneFamily.Chest,
"arms" => BoneFamily.Arms,
"hands" => BoneFamily.Hands,
"tail" => BoneFamily.Tail,
"groin" => BoneFamily.Groin,
"legs" => BoneFamily.Legs,
"feet" => BoneFamily.Feet,
"earrings" => BoneFamily.Earrings,
"hat" => BoneFamily.Hat,
"cape" => BoneFamily.Cape,
"armor" => BoneFamily.Armor,
"skirt" => BoneFamily.Skirt,
"equipment" => BoneFamily.Equipment,
_ => BoneFamily.Unknown
};
return fam;
}
public struct BoneDatum : IComparable<BoneDatum>
{
public int RowIndex;
public string Codename;
public string DisplayName;
public BoneFamily Family;
public bool IsDefault;
public bool IsIVCS;
public string? Parent;
public string? MirroredCodename;
public string[] Children;
public BoneDatum(int row, string[] fields)
{
RowIndex = row;
var i = 0;
Codename = fields[i++];
DisplayName = fields[i++];
Family = ParseFamilyName(fields[i++]);
IsDefault = bool.Parse(fields[i++]);
IsIVCS = bool.Parse(fields[i++]);
Parent = fields[i].IsNullOrEmpty() ? null : fields[i];
i++;
MirroredCodename = fields[i].IsNullOrEmpty() ? null : fields[i];
i++;
Children = Array.Empty<string>();
}
public int CompareTo(BoneDatum other)
{
return RowIndex != other.RowIndex
? RowIndex.CompareTo(other.RowIndex)
: string.Compare(DisplayName, other.DisplayName, StringComparison.Ordinal);
}
}
#region hair stuff
private static IEnumerable<BoneDatum> ParseHairstyle(params string[] boneNames)
{
List<BoneDatum> output = new();
var index = 0;
foreach (var style in boneNames.GroupBy(x => Regex.Match(x, @"\d\d\d\d").Value))
{
try
{
var parsedBones = style.Select(ParseHairBone).ToArray();
// if any of the first subs is nonstandard letter, we can presume that any bcd... are part of a rising sequence
var firstAsc =
parsedBones.Any(x => x.sub1 is "a" or "c" or "d" or "e");
//and we can then presume that the second subs are directional
//or vice versa. the naming conventions aren't really consistent about whether the sequence is first or second
foreach (var boneInfo in parsedBones)
{
StringBuilder dispName = new();
dispName.Append($"Hair #{boneInfo.id}");
var sub1 = GetHairBoneSubLabel(boneInfo.sub1, firstAsc);
var sub2 = boneInfo.sub2 == null ? null : GetHairBoneSubLabel(boneInfo.sub2, !firstAsc);
dispName.Append($" {sub1}");
if (sub2 != null)
{
dispName.Append($" {sub2}");
}
var result = new BoneDatum
{
RowIndex = -1,
Codename = boneInfo.name,
DisplayName = dispName.ToString(),
Family = BoneFamily.Hair,
IsDefault = false,
IsIVCS = false,
Parent = "j_kao",
Children = Array.Empty<string>(),
MirroredCodename = null
};
output.Add(result);
}
}
catch (Exception e)
{
Plugin.Logger.Error($"Failed to dynamically parse bones for hairstyle of '{boneNames[index]}'");
}
index++;
}
return output;
}
private static (string name, int id, string sub1, string? sub2) ParseHairBone(string boneName)
{
var groups = Regex.Match(boneName.ToLower(), @"j_ex_h(\d\d\d\d)_ke_([abcdeflrsu])(?:_([abcdeflrsu]))?")
.Groups;
var idNo = int.Parse(groups[1].Value);
var subFirst = groups[2].Value;
var subSecond = groups[3].Value.IsNullOrWhitespace() ? null : groups[3].Value;
return (boneName, idNo, subFirst, subSecond);
}
private static string GetHairBoneSubLabel(string sub, bool ascending)
{
return (sub.ToLower(), ascending) switch
{
("a", _) => "A",
("b", true) => "B",
("b", false) => "Back",
("c", _) => "C",
("d", _) => "D",
("e", _) => "E",
("f", true) => "F",
("f", false) => "Front",
("l", _) => "Left",
("r", _) => "Right",
("u", _) => "Upper",
("s", _) => "Side",
(_, true) => "Next",
(_, false) => "Bone"
};
}
#endregion
}

View File

@@ -0,0 +1,243 @@
using System;
using System.Numerics;
using System.Runtime.Serialization;
using CustomizePlus.Core.Extensions;
using CustomizePlus.Game.Services.GPose.ExternalTools;
using FFXIVClientStructs.Havok;
namespace CustomizePlus.Core.Data;
//not the correct terms but they double as user-visible labels so ¯\_(ツ)_/¯
public enum BoneAttribute
{
//hard-coding the backing values for legacy purposes
Position = 0,
Rotation = 1,
Scale = 2
}
[Serializable]
public class BoneTransform
{
//TODO if if ever becomes a point of concern, I might be able to marginally speed things up
//by natively storing translation and scaling values as their own vector4s
//that way the cost of translating back and forth to vector3s would be frontloaded
//to when the user is updating things instead of during the render loop
public BoneTransform()
{
Translation = Vector3.Zero;
Rotation = Vector3.Zero;
Scaling = Vector3.One;
}
public BoneTransform(BoneTransform original)
{
UpdateToMatch(original);
}
private Vector3 _translation;
public Vector3 Translation
{
get => _translation;
set => _translation = ClampVector(value);
}
private Vector3 _rotation;
public Vector3 Rotation
{
get => _rotation;
set => _rotation = ClampAngles(value);
}
private Vector3 _scaling;
public Vector3 Scaling
{
get => _scaling;
set => _scaling = ClampVector(value);
}
[OnDeserialized]
internal void OnDeserialized(StreamingContext context)
{
//Sanitize all values on deserialization
_translation = ClampToDefaultLimits(_translation);
_rotation = ClampAngles(_rotation);
_scaling = ClampToDefaultLimits(_scaling);
}
public bool IsEdited()
{
return !Translation.IsApproximately(Vector3.Zero, 0.00001f)
|| !Rotation.IsApproximately(Vector3.Zero, 0.1f)
|| !Scaling.IsApproximately(Vector3.One, 0.00001f);
}
public BoneTransform DeepCopy()
{
return new BoneTransform
{
Translation = Translation,
Rotation = Rotation,
Scaling = Scaling
};
}
public void UpdateAttribute(BoneAttribute which, Vector3 newValue)
{
if (which == BoneAttribute.Position)
{
Translation = newValue;
}
else if (which == BoneAttribute.Rotation)
{
Rotation = newValue;
}
else
{
Scaling = newValue;
}
}
public void UpdateToMatch(BoneTransform newValues)
{
Translation = newValues.Translation;
Rotation = newValues.Rotation;
Scaling = newValues.Scaling;
}
/// <summary>
/// Flip a bone's transforms from left to right, so you can use it to update its sibling.
/// IVCS bones need to use the special reflection instead.
/// </summary>
public BoneTransform GetStandardReflection()
{
return new BoneTransform
{
Translation = new Vector3(Translation.X, Translation.Y, -1 * Translation.Z),
Rotation = new Vector3(-1 * Rotation.X, -1 * Rotation.Y, Rotation.Z),
Scaling = Scaling
};
}
/// <summary>
/// Flip a bone's transforms from left to right, so you can use it to update its sibling.
/// IVCS bones are oriented in a system with different symmetries, so they're handled specially.
/// </summary>
public BoneTransform GetSpecialReflection()
{
return new BoneTransform
{
Translation = new Vector3(Translation.X, -1 * Translation.Y, Translation.Z),
Rotation = new Vector3(Rotation.X, -1 * Rotation.Y, -1 * Rotation.Z),
Scaling = Scaling
};
}
/// <summary>
/// Sanitize all vectors inside of this container.
/// </summary>
private void Sanitize()
{
_translation = ClampVector(_translation);
_rotation = ClampAngles(_rotation);
_scaling = ClampVector(_scaling);
}
/// <summary>
/// Clamp all vector values to be within allowed limits.
/// </summary>
private Vector3 ClampVector(Vector3 vector)
{
return new Vector3
{
X = Math.Clamp(vector.X, Constants.MinVectorValueLimit, Constants.MaxVectorValueLimit),
Y = Math.Clamp(vector.Y, Constants.MinVectorValueLimit, Constants.MaxVectorValueLimit),
Z = Math.Clamp(vector.Z, Constants.MinVectorValueLimit, Constants.MaxVectorValueLimit)
};
}
private static Vector3 ClampAngles(Vector3 rotVec)
{
static float Clamp(float angle)
{
if (angle > 180)
angle -= 360;
else if (angle < -180)
angle += 360;
return angle;
}
rotVec.X = Clamp(rotVec.X);
rotVec.Y = Clamp(rotVec.Y);
rotVec.Z = Clamp(rotVec.Z);
return rotVec;
}
public hkQsTransformf ModifyExistingTransform(hkQsTransformf tr)
{
return ModifyExistingTranslationWithRotation(ModifyExistingRotation(ModifyExistingScale(tr)));
}
public hkQsTransformf ModifyExistingScale(hkQsTransformf tr)
{
if (PosingModeDetectService.IsAnamnesisScalingFrozen) return tr;
tr.Scale.X *= Scaling.X;
tr.Scale.Y *= Scaling.Y;
tr.Scale.Z *= Scaling.Z;
return tr;
}
public hkQsTransformf ModifyExistingRotation(hkQsTransformf tr)
{
if (PosingModeDetectService.IsAnamnesisRotationFrozen) return tr;
var newRotation = Quaternion.Multiply(tr.Rotation.ToQuaternion(), Rotation.ToQuaternion());
tr.Rotation.X = newRotation.X;
tr.Rotation.Y = newRotation.Y;
tr.Rotation.Z = newRotation.Z;
tr.Rotation.W = newRotation.W;
return tr;
}
public hkQsTransformf ModifyExistingTranslationWithRotation(hkQsTransformf tr)
{
if (PosingModeDetectService.IsAnamnesisPositionFrozen) return tr;
var adjustedTranslation = Vector4.Transform(Translation, tr.Rotation.ToQuaternion());
tr.Translation.X += adjustedTranslation.X;
tr.Translation.Y += adjustedTranslation.Y;
tr.Translation.Z += adjustedTranslation.Z;
tr.Translation.W += adjustedTranslation.W;
return tr;
}
public hkQsTransformf ModifyExistingTranslation(hkQsTransformf tr)
{
if (PosingModeDetectService.IsAnamnesisPositionFrozen) return tr;
tr.Translation.X += Translation.X;
tr.Translation.Y += Translation.Y;
tr.Translation.Z += Translation.Z;
return tr;
}
/// <summary>
/// Clamp all vector values to be within allowed limits.
/// </summary>
private static Vector3 ClampToDefaultLimits(Vector3 vector)
{
vector.X = Math.Clamp(vector.X, Constants.MinVectorValueLimit, Constants.MaxVectorValueLimit);
vector.Y = Math.Clamp(vector.Y, Constants.MinVectorValueLimit, Constants.MaxVectorValueLimit);
vector.Z = Math.Clamp(vector.Z, Constants.MinVectorValueLimit, Constants.MaxVectorValueLimit);
return vector;
}
}

View File

@@ -0,0 +1,82 @@
using FFXIVClientStructs.Havok;
namespace CustomizePlus.Core.Data;
internal static class Constants
{
/// <summary>
/// Version of the configuration file, when increased a converter should be implemented if necessary.
/// </summary>
public const int ConfigurationVersion = 4;
/// <summary>
/// The name of the root bone.
/// </summary>
public const string RootBoneName = "n_root";
/// <summary>
/// Minimum allowed value for any of the vector values.
/// </summary>
public const int MinVectorValueLimit = -512;
/// <summary>
/// Maximum allowed value for any of the vector values.
/// </summary>
public const int MaxVectorValueLimit = 512;
/// <summary>
/// Predicate function for determining if the given object table index represents an
/// NPC in a busy area (i.e. there are ~245 other objects already).
/// </summary>
public static bool IsInObjectTableBusyNPCRange(int index) => index > 245;
/// <summary>
/// A "null" havok vector. Since the type isn't inherently nullable, and the default value (0, 0, 0, 0)
/// is valid input in a lot of cases, we can use this instead.
/// </summary>
public static readonly hkVector4f NullVector = new()
{
X = float.NaN,
Y = float.NaN,
Z = float.NaN,
W = float.NaN
};
/// <summary>
/// A "null" havok quaternion. Since the type isn't inherently nullable, and the default value (0, 0, 0, 0)
/// is valid input in a lot of cases, we can use this instead.
/// </summary>
public static readonly hkQuaternionf NullQuaternion = new()
{
X = float.NaN,
Y = float.NaN,
Z = float.NaN,
W = float.NaN
};
/// <summary>
/// A "null" havok transform. Since the type isn't inherently nullable, and the default values
/// aren't immediately obviously wrong, we can use this instead.
/// </summary>
public static readonly hkQsTransformf NullTransform = new()
{
Translation = NullVector,
Rotation = NullQuaternion,
Scale = NullVector
};
/// <summary>
/// The pose at index 0 is the only one we apparently need to care about.
/// </summary>
public const int TruePoseIndex = 0;
/// <summary>
/// Main render hook address
/// </summary>
public const string RenderHookAddress = "E8 ?? ?? ?? ?? 48 81 C3 ?? ?? ?? ?? BF ?? ?? ?? ?? 33 ED";
/// <summary>
/// Movement hook address, used for position offset and other changes which cannot be done in main hook
/// </summary>
public const string MovementHookAddress = "E8 ?? ?? ?? ?? EB 29 48 8B 5F 08";
}

View File

@@ -0,0 +1,23 @@
using OtterGui.Classes;
using System;
namespace CustomizePlus.Core.Events;
/// <summary>
/// Triggered when complete plugin reload is requested
/// </summary>
public sealed class ReloadEvent() : EventWrapper<ReloadEvent.Type, ReloadEvent.Priority>(nameof(ReloadEvent))
{
public enum Type
{
ReloadAll,
ReloadProfiles,
ReloadTemplates
}
public enum Priority
{
TemplateManager = -2,
ProfileManager = -1
}
}

View File

@@ -0,0 +1,32 @@
using Dalamud.Utility;
namespace CustomizePlus.Core.Extensions
{
internal static class StringExtensions
{
/// <summary>
/// Incognify string. Usually used for logging character names and stuff. Does nothing in debug build.
/// </summary>
public static string Incognify(this string str)
{
if (str.IsNullOrWhitespace())
return str;
#if DEBUG
return str;
#endif
if (str.Contains(" "))
{
var split = str.Split(' ');
if (split.Length > 2)
return $"{str[..2]}...";
return $"{split[0][0]}.{split[1][0]}";
}
return $"{str[..2]}...";
}
}
}

View File

@@ -0,0 +1,56 @@
using System;
using System.Numerics;
using CustomizePlus.Core.Data;
using FFXIVClientStructs.Havok;
//using FFXIVClientStructs.FFXIV.Client.Graphics;
namespace CustomizePlus.Core.Extensions;
internal static class TransformExtensions
{
public static bool Equals(this hkQsTransformf first, hkQsTransformf second)
{
return first.Translation.Equals(second.Translation)
&& first.Rotation.Equals(second.Rotation)
&& first.Scale.Equals(second.Scale);
}
public static bool IsNull(this hkQsTransformf t)
{
return t.Equals(Constants.NullTransform);
}
public static hkQsTransformf ToHavokTransform(this BoneTransform bt)
{
return new hkQsTransformf
{
Translation = bt.Translation.ToHavokTranslation(),
Rotation = bt.Rotation.ToQuaternion().ToHavokRotation(),
Scale = bt.Scaling.ToHavokScaling()
};
}
public static BoneTransform ToBoneTransform(this hkQsTransformf t)
{
var rotVec = Quaternion.Divide(t.Translation.ToQuaternion(), t.Rotation.ToQuaternion());
return new BoneTransform
{
Translation = new Vector3(rotVec.X / rotVec.W, rotVec.Y / rotVec.W, rotVec.Z / rotVec.W),
Rotation = t.Rotation.ToQuaternion().ToEulerAngles(),
Scaling = new Vector3(t.Scale.X, t.Scale.Y, t.Scale.Z)
};
}
public static hkVector4f GetAttribute(this hkQsTransformf t, BoneAttribute att)
{
return att switch
{
BoneAttribute.Position => t.Translation,
BoneAttribute.Rotation => t.Rotation.ToQuaternion().GetAsNumericsVector().ToHavokVector(),
BoneAttribute.Scale => t.Scale,
_ => throw new NotImplementedException()
};
}
}

View File

@@ -0,0 +1,144 @@
using System;
using System.Numerics;
using CustomizePlus.Anamnesis.Data;
using FFXIVClientStructs.Havok;
namespace CustomizePlus.Core.Extensions;
internal static class VectorExtensions
{
public static bool IsApproximately(this hkVector4f vector, Vector3 other, float errorMargin = 0.001f)
{
return IsApproximately(vector.X, other.X, errorMargin)
&& IsApproximately(vector.Y, other.Y, errorMargin)
&& IsApproximately(vector.Z, other.Z, errorMargin);
}
public static bool IsApproximately(this Vector3 vector, Vector3 other, float errorMargin = 0.001f)
{
return IsApproximately(vector.X, other.X, errorMargin)
&& IsApproximately(vector.Y, other.Y, errorMargin)
&& IsApproximately(vector.Z, other.Z, errorMargin);
}
private static bool IsApproximately(float a, float b, float errorMargin)
{
var d = MathF.Abs(a - b);
return d < errorMargin;
}
public static Quaternion ToQuaternion(this Vector3 rotation)
{
return Quaternion.CreateFromYawPitchRoll(
rotation.X * MathF.PI / 180,
rotation.Y * MathF.PI / 180,
rotation.Z * MathF.PI / 180);
}
public static Vector3 ToEulerAngles(this Quaternion q)
{
var nq = Vector4.Normalize(q.GetAsNumericsVector());
var rollX = MathF.Atan2(
2 * (nq.W * nq.X + nq.Y * nq.Z),
1 - 2 * (nq.X * nq.X + nq.Y * nq.Y));
var pitchY = 2 * MathF.Atan2(
MathF.Sqrt(1 + 2 * (nq.W * nq.Y - nq.X * nq.Z)),
MathF.Sqrt(1 - 2 * (nq.W * nq.Y - nq.X * nq.Z)));
var yawZ = MathF.Atan2(
2 * (nq.W * nq.Z + nq.X * nq.Y),
1 - 2 * (nq.Y * nq.Y + nq.Z * nq.Z));
return new Vector3(rollX, pitchY, yawZ);
}
public static Quaternion ToQuaternion(this Vector4 rotation)
{
return new Quaternion(rotation.X, rotation.Y, rotation.Z, rotation.W);
}
public static Quaternion ToQuaternion(this hkQuaternionf rotation)
{
return new Quaternion(rotation.X, rotation.Y, rotation.Z, rotation.W);
}
public static Quaternion ToQuaternion(this hkVector4f rotation)
{
return new Quaternion(rotation.X, rotation.Y, rotation.Z, rotation.W);
}
public static hkQuaternionf ToHavokRotation(this Quaternion rotation)
{
return new hkQuaternionf
{
X = rotation.X,
Y = rotation.Y,
Z = rotation.Z,
W = rotation.W
};
}
public static hkVector4f ToHavokTranslation(this Vector3 translation)
{
return new hkVector4f
{
X = translation.X,
Y = translation.Y,
Z = translation.Z,
W = 0.0f
};
}
public static hkVector4f ToHavokScaling(this Vector3 scaling)
{
return new hkVector4f
{
X = scaling.X,
Y = scaling.Y,
Z = scaling.Z,
W = 1.0f
};
}
public static hkVector4f ToHavokVector(this Vector4 vec)
{
return new hkVector4f
{
X = vec.X,
Y = vec.Y,
Z = vec.Z,
W = vec.W
};
}
public static Vector3 GetAsNumericsVector(this PoseFile.Vector vec)
{
return new Vector3(vec.X, vec.Y, vec.Z);
}
public static Vector4 GetAsNumericsVector(this hkVector4f vec)
{
return new Vector4(vec.X, vec.Y, vec.Z, vec.W);
}
public static Vector4 GetAsNumericsVector(this Quaternion q)
{
return new Vector4(q.X, q.Y, q.Z, q.W);
}
public static Vector3 RemoveWTerm(this Vector4 vec)
{
return new Vector3(vec.X, vec.Y, vec.Z);
}
public static bool Equals(this hkVector4f first, hkVector4f second)
{
return first.X == second.X
&& first.Y == second.Y
&& first.Z == second.Z
&& first.W == second.W;
}
}

View File

@@ -0,0 +1,60 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using Newtonsoft.Json;
namespace CustomizePlus.Core.Helpers;
public static class Base64Helper
{
// Compress any type to a base64 encoding of its compressed json representation, prepended with a version byte.
// Returns an empty string on failure.
// Original by Ottermandias: OtterGui <3
public static unsafe string ExportToBase64<T>(T obj, byte version)
{
try
{
var json = JsonConvert.SerializeObject(obj, Formatting.None);
var bytes = Encoding.UTF8.GetBytes(json);
using var compressedStream = new MemoryStream();
using (var zipStream = new GZipStream(compressedStream, CompressionMode.Compress))
{
zipStream.Write(new ReadOnlySpan<byte>(&version, 1));
zipStream.Write(bytes, 0, bytes.Length);
}
return Convert.ToBase64String(compressedStream.ToArray());
}
catch
{
return string.Empty;
}
}
// Decompress a base64 encoded string to the given type and a prepended version byte if possible.
// On failure, data will be String error and version will be byte.MaxValue.
// Original by Ottermandias: OtterGui <3
public static byte ImportFromBase64(string base64, out string data)
{
var version = byte.MaxValue;
try
{
var bytes = Convert.FromBase64String(base64);
using var compressedStream = new MemoryStream(bytes);
using var zipStream = new GZipStream(compressedStream, CompressionMode.Decompress);
using var resultStream = new MemoryStream();
zipStream.CopyTo(resultStream);
bytes = resultStream.ToArray();
version = bytes[0];
var json = Encoding.UTF8.GetString(bytes, 1, bytes.Length - 1);
data = json;
}
catch
{
data = "error";
}
return version;
}
}

View File

@@ -0,0 +1,122 @@
using System;
using Dalamud.Interface;
using Dalamud.Utility;
using ImGuiNET;
namespace CustomizePlus.Core.Helpers;
public static class CtrlHelper
{
/// <summary>
/// Gets the width of an icon button, checkbox, etc...
/// </summary>
/// per https://github.com/ocornut/imgui/issues/3714#issuecomment-759319268
public static float IconButtonWidth => ImGui.GetFrameHeight() + 2 * ImGui.GetStyle().ItemInnerSpacing.X;
public static bool TextBox(string label, ref string value)
{
ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X);
return ImGui.InputText(label, ref value, 1024);
}
public static bool TextPropertyBox(string label, Func<string> get, Action<string> set)
{
var temp = get();
var result = TextBox(label, ref temp);
if (result)
{
set(temp);
}
return result;
}
public static bool Checkbox(string label, ref bool value)
{
return ImGui.Checkbox(label, ref value);
}
public static bool CheckboxWithTextAndHelp(string label, string text, string helpText, ref bool value)
{
var checkBoxState = ImGui.Checkbox(label, ref value);
ImGui.SameLine();
ImGui.PushFont(UiBuilder.IconFont);
ImGui.Text(FontAwesomeIcon.InfoCircle.ToIconString());
ImGui.PopFont();
AddHoverText(helpText);
ImGui.SameLine();
ImGui.Text(text);
AddHoverText(helpText);
return checkBoxState;
}
public static bool CheckboxToggle(string label, in bool shown, Action<bool> toggle)
{
var temp = shown;
var toggled = ImGui.Checkbox(label, ref temp);
if (toggled)
{
toggle(temp);
}
return toggled;
}
public static bool ArrowToggle(string label, ref bool value)
{
var toggled = ImGui.ArrowButton(label, value ? ImGuiDir.Down : ImGuiDir.Right);
if (toggled)
{
value = !value;
}
return value;
}
public static void AddHoverText(string text)
{
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip(text);
}
}
public enum TextAlignment { Left, Center, Right };
public static void StaticLabel(string? text, TextAlignment align = TextAlignment.Left, string tooltip = "")
{
if (text != null)
{
if (align == TextAlignment.Center)
{
ImGui.Dummy(new System.Numerics.Vector2((ImGui.GetContentRegionAvail().X - ImGui.CalcTextSize(text).X) / 2, 0));
ImGui.SameLine();
}
else if (align == TextAlignment.Right)
{
ImGui.Dummy(new System.Numerics.Vector2(ImGui.GetContentRegionAvail().X - ImGui.CalcTextSize(text).X, 0));
ImGui.SameLine();
}
ImGui.Text(text);
if (!tooltip.IsNullOrWhitespace())
{
AddHoverText(tooltip);
}
}
}
public static void LabelWithIcon(FontAwesomeIcon icon, string text, bool isSameLine = true)
{
if (isSameLine)
ImGui.SameLine();
ImGui.PushFont(UiBuilder.IconFont);
ImGui.Text(icon.ToIconString());
ImGui.PopFont();
ImGui.SameLine();
ImGui.TextWrapped(text);
}
}

View File

@@ -0,0 +1,22 @@
namespace CustomizePlus.Core.Helpers;
//todo: better name
internal static class NameParsingHelper
{
internal static (string Name, string? Path) ParseName(string name, bool handlePath)
{
var actualName = name;
string? path = null;
if (handlePath)
{
var slashPos = name.LastIndexOf('/');
if (slashPos >= 0)
{
path = name[..slashPos];
actualName = slashPos >= name.Length - 1 ? "<Unnamed>" : name[(slashPos + 1)..];
}
}
return (actualName, path);
}
}

View File

@@ -0,0 +1,194 @@
using Dalamud.Plugin;
using Microsoft.Extensions.DependencyInjection;
using OtterGui.Classes;
using OtterGui.Log;
using CustomizePlus.Profiles;
using CustomizePlus.Core.Services;
using CustomizePlus.UI.Windows.MainWindow.Tabs.Debug;
using CustomizePlus.Game.Services;
using CustomizePlus.Configuration.Services;
using CustomizePlus.Templates;
using CustomizePlus.UI.Windows.MainWindow.Tabs.Templates;
using CustomizePlus.Armatures.Events;
using CustomizePlus.Configuration.Data;
using CustomizePlus.Core.Events;
using CustomizePlus.UI;
using CustomizePlus.UI.Windows.Controls;
using CustomizePlus.Anamnesis;
using CustomizePlus.Armatures.Services;
using CustomizePlus.UI.Windows.MainWindow.Tabs.Profiles;
using CustomizePlus.UI.Windows.MainWindow;
using CustomizePlus.Game.Events;
using CustomizePlus.UI.Windows;
using CustomizePlus.UI.Windows.MainWindow.Tabs;
using CustomizePlus.Templates.Events;
using CustomizePlus.Profiles.Events;
using CustomizePlus.Api.Compatibility;
using CustomizePlus.Game.Services.GPose;
using CustomizePlus.Game.Services.GPose.ExternalTools;
using CustomizePlus.GameData.Services;
using CustomizePlus.Configuration.Services.Temporary;
namespace CustomizePlus.Core;
public static class ServiceManager
{
public static ServiceProvider CreateProvider(DalamudPluginInterface pi, Logger logger)
{
var services = new ServiceCollection()
.AddSingleton(logger)
.AddDalamud(pi)
.AddCore()
.AddEvents()
.AddGPoseServices()
.AddArmatureServices()
.AddUI()
.AddGameDataServices()
.AddTemplateServices()
.AddProfileServices()
.AddGameServices()
.AddConfigServices()
.AddRestOfServices();
return services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true });
}
private static IServiceCollection AddDalamud(this IServiceCollection services, DalamudPluginInterface pluginInterface)
{
new DalamudServices(pluginInterface).AddServices(services);
return services;
}
private static IServiceCollection AddGPoseServices(this IServiceCollection services)
{
services
.AddSingleton<PosingModeDetectService>()
.AddSingleton<GPoseService>()
.AddSingleton<GPoseStateChanged>();
return services;
}
private static IServiceCollection AddArmatureServices(this IServiceCollection services)
{
services
.AddSingleton<ArmatureManager>();
return services;
}
private static IServiceCollection AddUI(this IServiceCollection services)
{
services
.AddSingleton<TemplateCombo>()
.AddSingleton<PluginStateBlock>()
.AddSingleton<SettingsTab>()
// template
.AddSingleton<TemplatesTab>()
.AddSingleton<TemplateFileSystemSelector>()
.AddSingleton<TemplatePanel>()
.AddSingleton<BoneEditorPanel>()
// /template
// profile
.AddSingleton<ProfilesTab>()
.AddSingleton<ProfileFileSystemSelector>()
.AddSingleton<ProfilePanel>()
// /profile
// messages
.AddSingleton<MessageService>()
.AddSingleton<MessagesTab>()
// /messages
//
.AddSingleton<IPCTestTab>()
.AddSingleton<StateMonitoringTab>()
//
.AddSingleton<PopupSystem>()
.AddSingleton<CPlusChangeLog>()
.AddSingleton<CPlusWindowSystem>()
.AddSingleton<MainWindow>();
return services;
}
private static IServiceCollection AddEvents(this IServiceCollection services)
{
services
.AddSingleton<ProfileChanged>()
.AddSingleton<TemplateChanged>()
.AddSingleton<ReloadEvent>()
.AddSingleton<ArmatureChanged>();
return services;
}
private static IServiceCollection AddCore(this IServiceCollection services)
{
services
.AddSingleton<HookingService>()
.AddSingleton<ChatService>()
.AddSingleton<CommandService>()
.AddSingleton<SaveService>()
.AddSingleton<FilenameService>()
.AddSingleton<BackupService>()
.AddSingleton<FantasiaPlusDetectService>()
.AddSingleton<FrameworkManager>();
return services;
}
private static IServiceCollection AddRestOfServices(this IServiceCollection services) //temp
{
services
.AddSingleton<PoseFileBoneLoader>()
.AddSingleton<CustomizePlusIpc>();
return services;
}
private static IServiceCollection AddConfigServices(this IServiceCollection services)
{
services
.AddSingleton<PluginConfiguration>()
.AddSingleton<ConfigurationMigrator>()
.AddSingleton<FantasiaPlusConfigMover>();
return services;
}
private static IServiceCollection AddGameServices(this IServiceCollection services)
{
services
.AddSingleton<GameObjectService>()
.AddSingleton<GameStateService>();
return services;
}
private static IServiceCollection AddProfileServices(this IServiceCollection services)
{
services
.AddSingleton<ProfileManager>()
.AddSingleton<ProfileFileSystem>()
.AddSingleton<TemplateEditorManager>();
return services;
}
private static IServiceCollection AddTemplateServices(this IServiceCollection services)
{
services
.AddSingleton<TemplateManager>()
.AddSingleton<TemplateFileSystem>()
.AddSingleton<TemplateEditorManager>();
return services;
}
private static IServiceCollection AddGameDataServices(this IServiceCollection services)
{
services
.AddSingleton<CutsceneService>()
.AddSingleton<GameEventManager>()
.AddSingleton<ActorService>()
.AddSingleton<ObjectManager>();
return services;
}
}

View File

@@ -0,0 +1,59 @@
using OtterGui.Classes;
using OtterGui.Log;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace CustomizePlus.Core.Services;
public class BackupService
{
private readonly Logger _logger;
private readonly FilenameService _filenameService;
private readonly DirectoryInfo _configDirectory;
private readonly IReadOnlyList<FileInfo> _fileNames;
public BackupService(Logger logger, FilenameService filenameService)
{
_logger = logger;
_filenameService = filenameService;
_fileNames = PluginFiles(_filenameService);
_configDirectory = new DirectoryInfo(_filenameService.ConfigDirectory);
Backup.CreateAutomaticBackup(logger, _configDirectory, _fileNames);
}
/// <summary>
/// Create a permanent backup with a given name for migrations.
/// </summary>
public void CreateMigrationBackup(string name)
=> Backup.CreatePermanentBackup(_logger, _configDirectory, _fileNames, name);
/// <summary>
/// Create backup for all version 3 configuration files
/// </summary>
public void CreateV3Backup(string name = "v3_to_v4_migration")
{
var list = new List<FileInfo>(16) { new(_filenameService.ConfigFile) };
list.AddRange(Directory.EnumerateFiles(_filenameService.ConfigDirectory, "*.profile", SearchOption.TopDirectoryOnly).Select(x => new FileInfo(x)));
Backup.CreatePermanentBackup(_logger, _configDirectory, list, name);
}
/// <summary>
/// Collect all relevant files for plugin configuration.
/// </summary>
private static IReadOnlyList<FileInfo> PluginFiles(FilenameService fileNames)
{
var list = new List<FileInfo>(16)
{
new(fileNames.ConfigFile),
new(fileNames.ProfileFileSystem),
new(fileNames.TemplateFileSystem)
};
list.AddRange(fileNames.Profiles());
list.AddRange(fileNames.Templates());
return list;
}
}

View File

@@ -0,0 +1,182 @@
using Dalamud.Game.Command;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Plugin.Services;
using OtterGui.Classes;
using System;
using System.Linq;
using OtterGui.Log;
using Dalamud.Game.Text.SeStringHandling;
using CustomizePlus.Profiles;
using CustomizePlus.Game.Services;
using CustomizePlus.UI.Windows.MainWindow.Tabs.Templates;
using CustomizePlus.UI.Windows.MainWindow;
namespace CustomizePlus.Core.Services;
public class CommandService : IDisposable
{
private readonly ProfileManager _profileManager;
private readonly GameObjectService _gameObjectService;
private readonly ICommandManager _commandManager;
private readonly Logger _logger;
private readonly ChatService _chatService;
private readonly MainWindow _mainWindow;
private readonly BoneEditorPanel _boneEditorPanel;
private readonly MessageService _messageService;
private static readonly string[] Commands = new[] { "/customize", "/c+" };
public CommandService(
ProfileManager profileManager,
GameObjectService gameObjectService,
ICommandManager commandManager,
MainWindow mainWindow,
ChatService chatService,
BoneEditorPanel boneEditorPanel,
Logger logger,
MessageService messageService)
{
_profileManager = profileManager;
_gameObjectService = gameObjectService;
_commandManager = commandManager;
_logger = logger;
_chatService = chatService;
_mainWindow = mainWindow;
_boneEditorPanel = boneEditorPanel;
_messageService = messageService;
foreach (var command in Commands)
{
_commandManager.AddHandler(command, new CommandInfo(OnMainCommand) { HelpMessage = "Toggles main plugin window if no commands passed. Use \"/customize help\" for list of available commands." });
}
chatService.PrintInChat($"Started!"); //safe to assume at this point we have successfully initialized
}
public void Dispose()
{
foreach (var command in Commands)
{
_commandManager.RemoveHandler(command);
}
}
private void OnMainCommand(string command, string arguments)
{
if (_boneEditorPanel.IsEditorActive)
{
_messageService.NotificationMessage("Customize+ commands cannot be used when editor is active", NotificationType.Error);
return;
}
var argumentList = arguments.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var argument = argumentList.Length == 2 ? argumentList[1] : string.Empty;
if (arguments.Length > 0)
{
switch (argumentList[0].ToLowerInvariant())
{
case "apply":
Apply(argument);
return;
case "toggle":
Apply(argument, true);
return;
default:
case "help":
PrintHelp(argumentList[0]);
return;
}
}
_mainWindow.Toggle();
}
private bool PrintHelp(string argument)
{
if (!string.Equals(argument, "help", StringComparison.OrdinalIgnoreCase) && argument != "?")
_chatService.PrintInChat(new SeStringBuilder().AddText("The given argument ").AddRed(argument, true)
.AddText(" is not valid. Valid arguments are:").BuiltString);
else
_chatService.PrintInChat(new SeStringBuilder().AddText("Valid arguments for /customize are:").BuiltString);
_chatService.PrintInChat(new SeStringBuilder().AddCommand("apply", "Applies a given profile for a given character. Use without arguments for help.")
.BuiltString);
_chatService.PrintInChat(new SeStringBuilder().AddCommand("toggle", "Toggles a given profile for a given character. Use without arguments for help.")
.BuiltString);
return true;
}
private void Apply(string argument, bool toggle = false)
{
var argumentList = argument.Split(',', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (argumentList.Length != 2)
{
_chatService.PrintInChat(new SeStringBuilder().AddText($"Usage: /customize {(toggle ? "toggle" : "apply")} ").AddBlue("Character Name", true)
.AddText(",")
.AddRed("Profile Name", true)
.BuiltString);
_chatService.PrintInChat(new SeStringBuilder().AddText(" 》 ")
.AddBlue("Character Name", true).AddText("can be either full character name or one of the following:").BuiltString);
_chatService.PrintInChat(new SeStringBuilder().AddText(" 》 To apply to yourself: ").AddBlue("<me>").AddText(", ").AddBlue("self").BuiltString);
_chatService.PrintInChat(new SeStringBuilder().AddText(" 》 To apply to target: ").AddBlue("<t>").AddText(", ").AddBlue("target").BuiltString);
return;
}
string charaName = "", profName = "";
try
{
(charaName, profName) = argumentList switch { var a => (a[0].Trim(), a[1].Trim()) };
charaName = charaName switch
{
"<me>" => _gameObjectService.GetCurrentPlayerName() ?? string.Empty,
"self" => _gameObjectService.GetCurrentPlayerName() ?? string.Empty,
"<t>" => _gameObjectService.GetCurrentPlayerTargetName() ?? string.Empty,
"target" => _gameObjectService.GetCurrentPlayerTargetName() ?? string.Empty,
_ => charaName,
};
if (!_profileManager.Profiles.Any())
{
_chatService.PrintInChat(
$"Can't {(toggle ? "toggle" : "apply")} profile \"{profName}\" for character \"{charaName}\" because no profiles exist", ChatService.ChatMessageColor.Error);
return;
}
if (_profileManager.Profiles.Count(x => x.Name == profName && x.CharacterName == charaName) > 1)
{
_logger.Warning(
$"Found more than one profile matching profile \"{profName}\" and character \"{charaName}\". Using first match.");
}
var outProf = _profileManager.Profiles.FirstOrDefault(x => x.Name == profName && x.CharacterName == charaName);
if (outProf == null)
{
_chatService.PrintInChat(
$"Can't {(toggle ? "toggle" : "apply")} profile \"{(string.IsNullOrWhiteSpace(profName) ? "empty (none provided)" : profName)}\" " +
$"for Character \"{(string.IsNullOrWhiteSpace(charaName) ? "empty (none provided)" : charaName)}\"\n" +
"Check if the profile and character names were provided correctly and said profile exists for chosen character", ChatService.ChatMessageColor.Error);
return;
}
if (!toggle)
_profileManager.SetEnabled(outProf, true);
else
_profileManager.SetEnabled(outProf, !outProf.Enabled);
_chatService.PrintInChat(
$"{outProf.Name} was successfully {(toggle ? "toggled" : "applied")} for {outProf.CharacterName}", ChatService.ChatMessageColor.Info);
}
catch (Exception e)
{
_chatService.PrintInChat($"Error while {(toggle ? "toggling" : "applying")} profile, details are available in dalamud log", ChatService.ChatMessageColor.Error);
_logger.Error($"Error {(toggle ? "toggling" : "applying")} profile by command: \n" +
$"Profile name \"{(string.IsNullOrWhiteSpace(profName) ? "empty (none provided)" : profName)}\"\n" +
$"Character name \"{(string.IsNullOrWhiteSpace(charaName) ? "empty (none provided)" : charaName)}\"\n" +
$"Error: {e}");
}
}
}

View File

@@ -0,0 +1,93 @@
using Dalamud.Game;
using Dalamud.Game.ClientState.Objects;
using Dalamud.IoC;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using Microsoft.Extensions.DependencyInjection;
namespace CustomizePlus.Core.Services;
public class DalamudServices
{
[PluginService]
[RequiredVersion("1.0")]
public DalamudPluginInterface PluginInterface { get; private set; } = null!;
[PluginService]
[RequiredVersion("1.0")]
public ISigScanner SigScanner { get; private set; } = null!;
[PluginService]
public IFramework Framework { get; private set; } = null!;
[PluginService]
[RequiredVersion("1.0")]
public IObjectTable ObjectTable { get; private set; } = null!;
[PluginService]
[RequiredVersion("1.0")]
public ICommandManager CommandManager { get; private set; } = null!;
[PluginService]
[RequiredVersion("1.0")]
public IChatGui ChatGui { get; private set; } = null!;
[PluginService]
[RequiredVersion("1.0")]
public IClientState ClientState { get; private set; } = null!;
[PluginService]
[RequiredVersion("1.0")]
public IGameGui GameGui { get; private set; } = null!;
[PluginService]
[RequiredVersion("1.0")]
internal IGameInteropProvider Hooker { get; private set; } = null!;
[PluginService]
[RequiredVersion("1.0")]
public IKeyState KeyState { get; private set; } = null!;
//GameData
[PluginService]
[RequiredVersion("1.0")]
public IDataManager DataManager { get; private set; } = null!;
[PluginService]
[RequiredVersion("1.0")]
public IPluginLog PluginLog { get; private set; } = null!;
/*[PluginService]
[RequiredVersion("1.0")]
public ICondition Condition { get; private set; } = null!;*/
[PluginService]
[RequiredVersion("1.0")]
public ITargetManager TargetManager { get; private set; } = null!;
public DalamudServices(DalamudPluginInterface pluginInterface)
{
pluginInterface.Inject(this);
}
public void AddServices(IServiceCollection services)
{
services
.AddSingleton(PluginInterface)
.AddSingleton(SigScanner)
.AddSingleton(Framework)
.AddSingleton(ObjectTable)
.AddSingleton(CommandManager)
.AddSingleton(ChatGui)
.AddSingleton(ClientState)
.AddSingleton(GameGui)
.AddSingleton(Hooker)
.AddSingleton(KeyState)
.AddSingleton(this)
.AddSingleton(PluginInterface.UiBuilder)
.AddSingleton(DataManager)
.AddSingleton(PluginLog)
//.AddSingleton(Condition)
.AddSingleton(TargetManager);
}
}

View File

@@ -0,0 +1,77 @@
using CustomizePlus.UI.Windows;
using Dalamud.Plugin;
using OtterGui.Log;
using System;
using System.Linq;
using System.Timers;
namespace CustomizePlus.Core.Services;
/// <summary>
/// Detects is Fantasia+ is installed and shows a message if it is. The check is performed every 15 seconds.
/// </summary>
public class FantasiaPlusDetectService : IDisposable
{
private readonly DalamudPluginInterface _pluginInterface;
private readonly PopupSystem _popupSystem;
private readonly Logger _logger;
private Timer? _checkTimer = null;
/// <summary>
/// Note: if this is set to true then this is locked until the plugin or game is restarted
/// </summary>
public bool IsFantasiaPlusInstalled { get; private set; }
public FantasiaPlusDetectService(DalamudPluginInterface pluginInterface, PopupSystem popupSystem, Logger logger)
{
_pluginInterface = pluginInterface;
_popupSystem = popupSystem;
_logger = logger;
_popupSystem.RegisterPopup("fantasia_detected_warn", "Customize+ detected that you have Fantasia+ installed.\nPlease delete or turn it off and restart your game to use Customize+.");
if (CheckFantasiaPlusPresence())
{
_popupSystem.ShowPopup("fantasia_detected_warn");
_logger.Error("Fantasia+ detected during startup, plugin will be locked");
}
else
{
_checkTimer = new Timer(15 * 1000);
_checkTimer.Elapsed += CheckTimerOnElapsed;
_checkTimer.Start();
}
}
/// <summary>
/// Returns true if Fantasia+ is installed and loaded
/// </summary>
/// <returns></returns>
private bool CheckFantasiaPlusPresence()
{
if (IsFantasiaPlusInstalled)
return true;
IsFantasiaPlusInstalled = _pluginInterface.InstalledPlugins.Any(pluginInfo => pluginInfo is { InternalName: "FantasiaPlus", IsLoaded: true });
return IsFantasiaPlusInstalled;
}
private void CheckTimerOnElapsed(object? sender, ElapsedEventArgs e)
{
if (CheckFantasiaPlusPresence())
{
_popupSystem.ShowPopup("fantasia_detected_warn");
_checkTimer!.Stop();
_checkTimer?.Dispose();
_logger.Error("Fantasia+ detected by timer, plugin will be locked");
}
}
public void Dispose()
{
if (_checkTimer != null)
_checkTimer.Dispose();
}
}

View File

@@ -0,0 +1,57 @@
using Dalamud.Plugin;
using System;
using System.Collections.Generic;
using System.IO;
using CustomizePlus.Profiles.Data;
namespace CustomizePlus.Core.Services;
public class FilenameService
{
public readonly string ConfigDirectory;
public readonly string ConfigFile;
public readonly string ProfileDirectory;
public readonly string ProfileFileSystem;
public readonly string TemplateDirectory;
public readonly string TemplateFileSystem;
public FilenameService(DalamudPluginInterface pi)
{
ConfigDirectory = pi.ConfigDirectory.FullName;
ConfigFile = pi.ConfigFile.FullName;
ProfileDirectory = Path.Combine(ConfigDirectory, "profiles");
ProfileFileSystem = Path.Combine(ConfigDirectory, "profile_sort_order.json");
TemplateDirectory = Path.Combine(ConfigDirectory, "templates");
TemplateFileSystem = Path.Combine(ConfigDirectory, "template_sort_order.json");
}
public IEnumerable<FileInfo> Templates()
{
if (!Directory.Exists(TemplateDirectory))
yield break;
foreach (var file in Directory.EnumerateFiles(TemplateDirectory, "*.json", SearchOption.TopDirectoryOnly))
yield return new FileInfo(file);
}
public string TemplateFile(Guid id)
=> Path.Combine(TemplateDirectory, $"{id}.json");
public string TemplateFile(Templates.Data.Template template)
=> TemplateFile(template.UniqueId);
public IEnumerable<FileInfo> Profiles()
{
if (!Directory.Exists(ProfileDirectory))
yield break;
foreach (var file in Directory.EnumerateFiles(ProfileDirectory, "*.json", SearchOption.TopDirectoryOnly))
yield return new FileInfo(file);
}
public string ProfileFile(Guid id)
=> Path.Combine(ProfileDirectory, $"{id}.json");
public string ProfileFile(Profile profile)
=> ProfileFile(profile.UniqueId);
}

View File

@@ -0,0 +1,156 @@
using Dalamud.Game;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using System;
using System.Runtime.InteropServices;
using OtterGui.Log;
using CustomizePlus.Core.Data;
using CustomizePlus.Game.Services;
using CustomizePlus.Configuration.Data;
using CustomizePlus.Profiles;
using CustomizePlus.Armatures.Services;
using CustomizePlus.GameData.Data;
namespace CustomizePlus.Core.Services;
public class HookingService : IDisposable
{
private readonly PluginConfiguration _configuration;
private readonly ISigScanner _sigScanner;
private readonly IGameInteropProvider _hooker;
private readonly ProfileManager _profileManager;
private readonly ArmatureManager _armatureManager;
private readonly GameStateService _gameStateService;
private readonly FantasiaPlusDetectService _fantasiaPlusDetectService;
private readonly Logger _logger;
private Hook<RenderDelegate>? _renderManagerHook;
private Hook<GameObjectMovementDelegate>? _gameObjectMovementHook;
private delegate nint RenderDelegate(nint a1, nint a2, int a3, int a4);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate void GameObjectMovementDelegate(nint gameObject);
public HookingService(
PluginConfiguration configuration,
ISigScanner sigScanner,
IGameInteropProvider hooker,
ProfileManager profileManager,
ArmatureManager armatureManager,
GameStateService gameStateService,
FantasiaPlusDetectService fantasiaPlusDetectService,
Logger logger)
{
_configuration = configuration;
_sigScanner = sigScanner;
_hooker = hooker;
_profileManager = profileManager;
_armatureManager = armatureManager;
_gameStateService = gameStateService;
_fantasiaPlusDetectService = fantasiaPlusDetectService;
_logger = logger;
ReloadHooks();
}
public void ReloadHooks()
{
try
{
if (_configuration.PluginEnabled)
{
if (_renderManagerHook == null)
{
var renderAddress = _sigScanner.ScanText(Constants.RenderHookAddress);
_renderManagerHook = _hooker.HookFromAddress<RenderDelegate>(renderAddress, OnRender);
_logger.Debug("Render hook established");
}
if (_gameObjectMovementHook == null)
{
var movementAddress = _sigScanner.ScanText(Constants.MovementHookAddress);
_gameObjectMovementHook = _hooker.HookFromAddress<GameObjectMovementDelegate>(movementAddress, OnGameObjectMove);
_logger.Debug("Movement hook established");
}
_logger.Debug("Hooking render & movement functions");
_renderManagerHook.Enable();
_gameObjectMovementHook.Enable();
_logger.Debug("Hooking render manager");
_renderManagerHook.Enable();
//Send current player's profile update message to IPC
//IPCManager.OnProfileUpdate(null);
}
else
{
_logger.Debug("Unhooking...");
_renderManagerHook?.Disable();
_gameObjectMovementHook?.Disable();
_renderManagerHook?.Disable();
}
}
catch (Exception e)
{
_logger.Error($"Failed to hook Render::Manager::Render {e}");
throw;
}
}
private nint OnRender(nint a1, nint a2, int a3, int a4)
{
if (_renderManagerHook == null)
{
throw new Exception();
}
if (_fantasiaPlusDetectService.IsFantasiaPlusInstalled)
{
_logger.Error($"Fantasia+ detected, disabling all hooks");
_renderManagerHook?.Disable();
_gameObjectMovementHook.Disable();
return _renderManagerHook.Original(a1, a2, a3, a4);
}
try
{
_armatureManager.OnRender();
_profileManager.OnRender();
}
catch (Exception e)
{
_logger.Error($"Error in Customize+ render hook {e}");
_renderManagerHook?.Disable();
}
return _renderManagerHook.Original(a1, a2, a3, a4);
}
private unsafe void OnGameObjectMove(nint gameObjectPtr)
{
// Call the original function.
_gameObjectMovementHook.Original(gameObjectPtr);
// If GPose and a 3rd-party posing service are active simultneously, abort
if (_gameStateService.GameInPosingMode())
{
return;
}
var actor = (Actor)gameObjectPtr;
if (actor.Valid)
_armatureManager.OnGameObjectMove((Actor)gameObjectPtr);
}
public void Dispose()
{
_gameObjectMovementHook?.Disable();
_gameObjectMovementHook?.Dispose();
_renderManagerHook?.Disable();
_renderManagerHook?.Dispose();
}
}

View File

@@ -0,0 +1,17 @@
using OtterGui.Classes;
using OtterGui.Log;
namespace CustomizePlus.Core.Services;
/// <summary>
/// Any file type that we want to save via SaveService.
/// </summary>
public interface ISavable : ISavable<FilenameService>
{ }
public sealed class SaveService : SaveServiceBase<FilenameService>
{
public SaveService(Logger logger, FrameworkManager framework, FilenameService fileNames)
: base(logger, framework, fileNames)
{ }
}

View File

@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Authors></Authors>
<Company></Company>
<Version>2.0.0.0</Version>
<Description>Customize+</Description>
<Copyright></Copyright>
<PackageProjectUrl>https://github.com/Aether-Tools/CustomizePlus</PackageProjectUrl>
<UseWindowsForms>true</UseWindowsForms>
<Configurations>Debug;Release</Configurations>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net7.0-windows</TargetFramework>
<Platforms>x64</Platforms>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<CopyLocalLockfileAssemblies>true</CopyLocalLockfileAssemblies>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Configuration\Data\Version2\**" />
<Compile Remove="Util\**" />
<EmbeddedResource Remove="Configuration\Data\Version2\**" />
<EmbeddedResource Remove="Util\**" />
<None Remove="Configuration\Data\Version2\**" />
<None Remove="Util\**" />
</ItemGroup>
<ItemGroup>
<Content Include="..\Data\icon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Visible>false</Visible>
</Content>
</ItemGroup>
<PropertyGroup>
<DalamudLibPath>$(appdata)\XIVLauncher\addon\Hooks\dev\</DalamudLibPath>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DalamudPackager" Version="2.1.12" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<ProjectReference Include="..\CustomizePlus.GameData\CustomizePlus.GameData.csproj" />
<ProjectReference Include="..\submodules\OtterGui\OtterGui.csproj" />
<Reference Include="Newtonsoft.Json">
<HintPath>$(DalamudLibPath)Newtonsoft.Json.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Dalamud">
<HintPath>$(DalamudLibPath)Dalamud.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGui.NET">
<HintPath>$(DalamudLibPath)ImGui.NET.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGuiScene">
<HintPath>$(DalamudLibPath)ImGuiScene.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina">
<HintPath>$(DalamudLibPath)Lumina.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina.Excel">
<HintPath>$(DalamudLibPath)Lumina.Excel.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="FFXIVClientStructs">
<HintPath>$(DalamudLibPath)FFXIVClientStructs.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<None Update="CustomizePlus.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
{
"Author": "Risa",
"Name": "Customize+",
"Punchline": "Customize your character beyond FFXIV's limitations.",
"Description": "A plugin that allows you to customize your character beyond FFXIV limitations by directly editing bone parameters.",
"InternalName": "CustomizePlus",
"ApplicableVersion": "any",
"DalamudApiLevel": 9,
"Tags": [
"anamnesis",
"customize",
"Risa",
"Aether Tools"
]
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<Target Name="PackagePlugin" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
<DalamudPackager
ProjectDir="$(ProjectDir)"
OutputPath="$(OutputPath)"
AssemblyName="$(AssemblyName)"
MakeZip="true" />
</Target>
</Project>

View File

@@ -0,0 +1,24 @@
using OtterGui.Classes;
using System;
namespace CustomizePlus.Game.Events;
/// <summary>
/// Triggered when GPose is entered/exited
/// </summary>
public sealed class GPoseStateChanged() : EventWrapper<GPoseStateChanged.Type, GPoseStateChanged.Priority>(nameof(GPoseStateChanged))
{
public enum Type
{
Entered,
AttemptingExit,
Exiting,
Exited
}
public enum Priority
{
TemplateEditorManager = -1,
GPoseAmnesisKtisisWarningService
}
}

View File

@@ -0,0 +1,35 @@
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Plugin.Services;
namespace CustomizePlus.Game.Services;
public class ChatService
{
private readonly IChatGui _chatGui;
public ChatService(IChatGui chatGui)
{
_chatGui = chatGui;
}
public void PrintInChat(string message, ChatMessageColor color = ChatMessageColor.Info)
{
var stringBuilder = new SeStringBuilder();
stringBuilder.AddUiForeground((ushort)color);
stringBuilder.AddText($"[Customize+] {message}");
stringBuilder.AddUiForegroundOff();
_chatGui.Print(stringBuilder.BuiltString);
}
public void PrintInChat(SeString seString)
{
_chatGui.Print(seString);
}
public enum ChatMessageColor : ushort
{
Info = 45,
Warning = 500,
Error = 14
}
}

View File

@@ -0,0 +1,36 @@
using Dalamud.Game;
namespace CustomizePlus.Game.Services.GPose.ExternalTools;
/// <summary>
/// Service which detects if Anamnesis/Ktisis posing mode is enabled.
/// </summary>
public class PosingModeDetectService
{
// Borrowed from Ktisis:
// If this is NOP'd, Anam posing is enabled.
internal static unsafe byte* AnamnesisFreezePosition;
internal static unsafe byte* AnamnesisFreezeRotation;
internal static unsafe byte* AnamnesisFreezeScale;
internal static unsafe bool IsAnamnesisPositionFrozen =>
AnamnesisFreezePosition != null && *AnamnesisFreezePosition == 0x90 || *AnamnesisFreezePosition == 0x00;
internal static unsafe bool IsAnamnesisRotationFrozen =>
AnamnesisFreezeRotation != null && *AnamnesisFreezeRotation == 0x90 || *AnamnesisFreezeRotation == 0x00;
internal static unsafe bool IsAnamnesisScalingFrozen =>
AnamnesisFreezeScale != null && *AnamnesisFreezeScale == 0x90 || *AnamnesisFreezeScale == 0x00;
internal static bool IsAnamnesis =>
IsAnamnesisPositionFrozen || IsAnamnesisRotationFrozen || IsAnamnesisScalingFrozen;
public bool IsInPosingMode => IsAnamnesis; //Can't detect Ktisis for now
public unsafe PosingModeDetectService(ISigScanner sigScanner)
{
AnamnesisFreezePosition = (byte*)sigScanner.ScanText("41 0F 29 24 12");
AnamnesisFreezeRotation = (byte*)sigScanner.ScanText("41 0F 29 5C 12 10");
AnamnesisFreezeScale = (byte*)sigScanner.ScanText("41 0F 29 44 12 20");
}
}

View File

@@ -0,0 +1,138 @@
using System;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.Framework;
using OtterGui.Log;
using CustomizePlus.Game.Events;
namespace CustomizePlus.Game.Services.GPose;
public class GPoseService : IDisposable
{
private readonly ChatService _chatService;
private readonly GPoseStateChanged _event;
private readonly Logger _logger;
private Hook<EnterGPoseDelegate>? _enterGPoseHook;
private Hook<ExitGPoseDelegate>? _exitGPoseHook;
private bool _fakeGPose;
public GPoseState GPoseState { get; private set; }
public bool IsInGPose => GPoseState == GPoseState.Inside;
public bool FakeGPose
{
get => _fakeGPose;
set
{
if (value != _fakeGPose)
{
if (!value)
{
_fakeGPose = false;
HandleGPoseChange(GPoseState.Exiting);
HandleGPoseChange(GPoseState.Outside);
}
else
{
HandleGPoseChange(GPoseState.Inside);
_fakeGPose = true;
}
}
}
}
public unsafe GPoseService(
IClientState clientState,
IGameInteropProvider hooker,
ChatService chatService,
GPoseStateChanged @event,
Logger logger)
{
_chatService = chatService;
_event = @event;
_logger = logger;
GPoseState = clientState.IsGPosing ? GPoseState.Inside : GPoseState.Outside;
var uiModule = Framework.Instance()->GetUiModule();
var enterGPoseAddress = (nint)uiModule->VTable->EnterGPose;
var exitGPoseAddress = (nint)uiModule->VTable->ExitGPose;
_enterGPoseHook = hooker.HookFromAddress<EnterGPoseDelegate>(enterGPoseAddress, EnteringGPoseDetour);
_enterGPoseHook.Enable();
_exitGPoseHook = hooker.HookFromAddress<ExitGPoseDelegate>(exitGPoseAddress, ExitingGPoseDetour);
_exitGPoseHook.Enable();
}
private void ExitingGPoseDetour(nint addr)
{
if (HandleGPoseChange(GPoseState.AttemptExit))
{
HandleGPoseChange(GPoseState.Exiting);
_exitGPoseHook!.Original.Invoke(addr);
HandleGPoseChange(GPoseState.Outside);
}
}
private bool EnteringGPoseDetour(nint addr)
{
var didEnter = _enterGPoseHook!.Original.Invoke(addr);
if (didEnter)
{
_fakeGPose = false;
HandleGPoseChange(GPoseState.Inside);
}
return didEnter;
}
private bool HandleGPoseChange(GPoseState state)
{
if (state == GPoseState || _fakeGPose)
{
return true;
}
GPoseState = state;
switch (state)
{
case GPoseState.Inside:
_event.Invoke(GPoseStateChanged.Type.Entered);
break;
case GPoseState.AttemptExit:
_event.Invoke(GPoseStateChanged.Type.AttemptingExit);
break;
case GPoseState.Exiting:
_event.Invoke(GPoseStateChanged.Type.Exiting);
break;
case GPoseState.Outside:
_event.Invoke(GPoseStateChanged.Type.Exited);
break;
}
return true;
}
public void Dispose()
{
_exitGPoseHook?.Dispose();
_enterGPoseHook?.Dispose();
}
private delegate void ExitGPoseDelegate(nint addr);
private delegate bool EnterGPoseDelegate(nint addr);
}
public enum GPoseState
{
Inside,
AttemptExit,
Exiting,
Outside
}

View File

@@ -0,0 +1,71 @@
using Dalamud.Plugin.Services;
using Penumbra.GameData.Actors;
using System.Collections.Generic;
using CustomizePlus.Core.Data;
using CustomizePlus.GameData.Data;
using CustomizePlus.GameData.Services;
using CustomizePlus.GameData.Extensions;
namespace CustomizePlus.Game.Services;
public class GameObjectService
{
private readonly ActorService _actorService;
private readonly IObjectTable _objectTable;
private readonly ObjectManager _objectManager;
public GameObjectService(ActorService actorService, IObjectTable objectTable, ObjectManager objectManager)
{
_actorService = actorService;
_objectTable = objectTable;
_objectManager = objectManager;
}
public string GetCurrentPlayerName()
{
return _objectManager.PlayerData.Identifier.ToName();
}
public string GetCurrentPlayerTargetName()
{
return _objectManager.TargetData.Identifier.ToNameWithoutOwnerName();
}
public bool IsActorHasScalableRoot(Actor actor)
{
if (!actor.Identifier(_actorService.AwaitedService, out var identifier))
return false;
return !Constants.IsInObjectTableBusyNPCRange(actor.Index.Index)
&& (identifier.IsAllowedForProfiles()
|| actor == _objectTable.GetObjectAddress(0));
}
/// <summary>
/// Case sensitive
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
public IEnumerable<(ActorIdentifier, Actor)> FindActorsByName(string name)
{
foreach (var kvPair in _objectManager)
{
var identifier = kvPair.Key;
if (kvPair.Key.Type == IdentifierType.Special)
identifier = identifier.GetTrueActorForSpecialType();
if (!identifier.IsValid)
continue;
if (identifier.ToNameWithoutOwnerName() == name)
{
if (kvPair.Value.Objects.Count > 1) //in gpose we can have more than a single object for one actor
foreach (var obj in kvPair.Value.Objects)
yield return (kvPair.Key, obj);
else
yield return (kvPair.Key, kvPair.Value.Objects[0]);
}
}
}
}

View File

@@ -0,0 +1,21 @@
using CustomizePlus.Game.Services.GPose;
using CustomizePlus.Game.Services.GPose.ExternalTools;
namespace CustomizePlus.Game.Services;
public class GameStateService
{
private readonly GPoseService _gposeService;
private readonly PosingModeDetectService _posingModeDetectService;
public GameStateService(GPoseService gposeService, PosingModeDetectService posingModeDetectService)
{
_gposeService = gposeService;
_posingModeDetectService = posingModeDetectService;
}
public bool GameInPosingMode()
{
return _gposeService.GPoseState == GPoseState.Inside || _posingModeDetectService.IsInPosingMode;
}
}

View File

@@ -0,0 +1,6 @@
using System.Diagnostics.CodeAnalysis;
[assembly:
SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore",
Justification = "Supressed in favor of CA1500. Use _camelCase for private fields.",
Scope = "namespaceanddescendants", Target = "~N:CustomizePlus")]

55
CustomizePlus/Plugin.cs Normal file
View File

@@ -0,0 +1,55 @@
using System;
using System.Reflection;
using Dalamud.Plugin;
using Microsoft.Extensions.DependencyInjection;
using OtterGui.Log;
using CustomizePlus.Core.Services;
using CustomizePlus.UI;
using CustomizePlus.Core;
using CustomizePlus.Api.Compatibility;
using CustomizePlus.Configuration.Services.Temporary;
namespace CustomizePlus;
public sealed class Plugin : IDalamudPlugin
{
#if DEBUG
public static readonly string Version = $"{Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? string.Empty} [DEBUG]";
#else
public static readonly string Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? string.Empty;
#endif
private readonly ServiceProvider _services;
public static readonly Logger Logger = new(); //for loggin in static classes/methods
public Plugin(DalamudPluginInterface pluginInterface)
{
try
{
_services = ServiceManager.CreateProvider(pluginInterface, Logger);
//temporary
var configMover = _services.GetRequiredService<FantasiaPlusConfigMover>();
configMover.MoveConfigsIfNeeded();
_services.GetRequiredService<CustomizePlusIpc>();
_services.GetRequiredService<CPlusWindowSystem>();
_services.GetRequiredService<CommandService>();
Logger.Information($"Customize+ v{Version} [FantasiaPlus] started");
}
catch (Exception ex)
{
Logger.Error($"Error instantiating plugin: {ex}");
Dispose();
throw;
}
}
public void Dispose()
{
_services?.Dispose();
}
}

View File

@@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using System.IO;
using CustomizePlus.Armatures.Data;
using CustomizePlus.Core.Data;
using CustomizePlus.Core.Extensions;
using CustomizePlus.Core.Services;
using CustomizePlus.Templates;
using CustomizePlus.Templates.Data;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OtterGui.Classes;
using Penumbra.GameData.Actors;
namespace CustomizePlus.Profiles.Data;
/// <summary>
/// Encapsulates the user-controlled aspects of a character profile, ie all of
/// the information that gets saved to disk by the plugin.
/// </summary>
public sealed class Profile : ISavable
{
private static int _nextGlobalId;
private readonly int _localId;
public List<Armature> Armatures = new();
public LowerString CharacterName { get; set; } = LowerString.Empty;
public LowerString Name { get; set; } = LowerString.Empty;
/// <summary>
/// Whether to search only through local player owned characters or all characters when searching for game object by name
/// </summary>
public bool LimitLookupToOwnedObjects { get; set; } = false;
public int Version { get; set; } = Constants.ConfigurationVersion;
public bool Enabled { get; set; }
public DateTimeOffset CreationDate { get; set; } = DateTime.UtcNow;
public DateTimeOffset ModifiedDate { get; set; } = DateTime.UtcNow;
public Guid UniqueId { get; set; } = Guid.NewGuid();
public List<Template> Templates { get; init; } = new();
public bool IsWriteProtected { get; internal set; }
/// <summary>
/// Specifies if this profile is not persistent (ex. was made via IPC calls) and should not be displayed in UI.
/// WARNING, TEMPLATES FOR TEMPORARY PROFILES *ARE NOT* STORED IN TemplateManager
/// </summary>
public bool IsTemporary { get; set; }
/// <summary>
/// Identificator specifying specific actor this profile applies to, only works for temporary profiles
/// </summary>
public ActorIdentifier TemporaryActor { get; set; } = ActorIdentifier.Invalid;
public string Incognito
=> UniqueId.ToString()[..8];
public Profile()
{
_localId = _nextGlobalId++;
}
/// <summary>
/// Creates a new profile based on data from another one
/// </summary>
/// <param name="original"></param>
public Profile(Profile original) : this()
{
CharacterName = original.CharacterName;
LimitLookupToOwnedObjects = original.LimitLookupToOwnedObjects;
foreach (var template in original.Templates)
{
Templates.Add(template);
}
}
public override string ToString()
{
return $"Profile '{Name.Text.Incognify()}' on {CharacterName.Text.Incognify()} [{UniqueId}]";
}
#region Serialization
public new JObject JsonSerialize()
{
var ret = new JObject()
{
["Version"] = Version,
["UniqueId"] = UniqueId,
["CreationDate"] = CreationDate,
["ModifiedDate"] = ModifiedDate,
["CharacterName"] = CharacterName.Text,
["Name"] = Name.Text,
["LimitLookupToOwnedObjects"] = LimitLookupToOwnedObjects,
["Enabled"] = Enabled,
["IsWriteProtected"] = IsWriteProtected,
["Templates"] = SerializeTemplates()
};
return ret;
}
private JArray SerializeTemplates()
{
var ret = new JArray();
foreach (var template in Templates)
{
ret.Add(new JObject()
{
["TemplateId"] = template.UniqueId
});
}
return ret;
}
#endregion
#region Deserialization
public static Profile Load(TemplateManager templateManager, JObject obj)
{
var version = obj["Version"]?.ToObject<int>() ?? 0;
return version switch
{
//Ignore everything below v4 for now
4 => LoadV4(templateManager, obj),
_ => throw new Exception("The design to be loaded has no valid Version."),
};
}
private static Profile LoadV4(TemplateManager templateManager, JObject obj)
{
var creationDate = obj["CreationDate"]?.ToObject<DateTimeOffset>() ?? throw new ArgumentNullException("CreationDate");
var profile = new Profile()
{
CreationDate = creationDate,
UniqueId = obj["UniqueId"]?.ToObject<Guid>() ?? throw new ArgumentNullException("UniqueId"),
Name = new LowerString(obj["Name"]?.ToObject<string>() ?? throw new ArgumentNullException("Name")),
CharacterName = new LowerString(obj["CharacterName"]?.ToObject<string>() ?? throw new ArgumentNullException("CharacterName")),
LimitLookupToOwnedObjects = obj["LimitLookupToOwnedObjects"]?.ToObject<bool>() ?? throw new ArgumentNullException("LimitLookupToOwnedObjects"),
Enabled = obj["Enabled"]?.ToObject<bool>() ?? throw new ArgumentNullException("Enabled"),
ModifiedDate = obj["ModifiedDate"]?.ToObject<DateTimeOffset>() ?? creationDate,
IsWriteProtected = obj["IsWriteProtected"]?.ToObject<bool>() ?? false,
Templates = new List<Template>()
};
if (profile.ModifiedDate < creationDate)
profile.ModifiedDate = creationDate;
if (obj["Templates"] is not JArray templateArray)
return profile;
foreach (var templateObj in templateArray)
{
if (templateObj is not JObject templateObjCast)
{
//todo: warning
continue;
}
var templateId = templateObjCast["TemplateId"]?.ToObject<Guid>();
if (templateId == null)
continue; //todo: error
var template = templateManager.GetTemplate((Guid)templateId);
if (template != null)
profile.Templates.Add(template);
}
return profile;
}
#endregion
#region ISavable
public string ToFilename(FilenameService fileNames)
=> fileNames.ProfileFile(this);
public void Save(StreamWriter writer)
{
//saving of temporary profiles is not allowed
if (IsTemporary)
return;
using var j = new JsonTextWriter(writer)
{
Formatting = Formatting.Indented,
};
var obj = JsonSerialize();
obj.WriteTo(j);
}
public string LogName(string fileName)
=> Path.GetFileNameWithoutExtension(fileName);
#endregion
}

View File

@@ -0,0 +1,46 @@
using CustomizePlus.Profiles.Data;
using OtterGui.Classes;
using System;
namespace CustomizePlus.Profiles.Events;
/// <summary>
/// Triggered when profile is changed
/// </summary>
public sealed class ProfileChanged() : EventWrapper<ProfileChanged.Type, Profile?, object?, ProfileChanged.Priority>(nameof(ProfileChanged))
{
public enum Type
{
Created,
Deleted,
Renamed,
Toggled,
ChangedCharacterName,
AddedTemplate,
RemovedTemplate,
MovedTemplate,
ChangedTemplate,
ReloadedAll,
WriteProtection,
LimitLookupToOwnedChanged,
ChangedDefaultProfile,
TemporaryProfileAdded,
TemporaryProfileDeleted,
/*
ToggledProfile,
AddedTemplate,
RemovedTemplate,
MovedTemplate,
ChangedTemplate*/
}
public enum Priority
{
ProfileFileSystemSelector = -2,
TemplateFileSystemSelector = -1,
ProfileFileSystem,
ArmatureManager,
TemplateManager,
CustomizePlusIpc
}
}

View File

@@ -0,0 +1,149 @@
using OtterGui.Filesystem;
using OtterGui.Log;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text.RegularExpressions;
using System;
using System.Linq;
using OtterGui.Classes;
using Dalamud.Interface.Internal.Notifications;
using CustomizePlus.Core.Services;
using CustomizePlus.Profiles.Data;
using CustomizePlus.Profiles.Events;
namespace CustomizePlus.Profiles;
public class ProfileFileSystem : FileSystem<Profile>, IDisposable, ISavable
{
private readonly ProfileManager _profileManager;
private readonly SaveService _saveService;
private readonly ProfileChanged _profileChanged;
private readonly MessageService _messageService;
private readonly Logger _logger;
public ProfileFileSystem(
ProfileManager profileManager,
SaveService saveService,
ProfileChanged profileChanged,
MessageService messageService,
Logger logger)
{
_profileManager = profileManager;
_saveService = saveService;
_profileChanged = profileChanged;
_messageService = messageService;
_logger = logger;
_profileChanged.Subscribe(OnProfileChange, ProfileChanged.Priority.ProfileFileSystem);
Changed += OnChange;
Reload();
}
public void Dispose()
{
_profileChanged.Unsubscribe(OnProfileChange);
}
// Search the entire filesystem for the leaf corresponding to a profile.
public bool FindLeaf(Profile profile, [NotNullWhen(true)] out Leaf? leaf)
{
leaf = Root.GetAllDescendants(ISortMode<Profile>.Lexicographical)
.OfType<Leaf>()
.FirstOrDefault(l => l.Value == profile);
return leaf != null;
}
private void OnProfileChange(ProfileChanged.Type type, Profile? profile, object? data)
{
switch (type)
{
case ProfileChanged.Type.Created:
var parent = Root;
if (data is string path)
try
{
parent = FindOrCreateAllFolders(path);
}
catch (Exception ex)
{
_messageService.NotificationMessage(ex, $"Could not move profile to {path} because the folder could not be created.", NotificationType.Error);
}
CreateDuplicateLeaf(parent, profile.Name.Text, profile);
return;
case ProfileChanged.Type.Deleted:
if (FindLeaf(profile, out var leaf1))
Delete(leaf1);
return;
case ProfileChanged.Type.ReloadedAll:
Reload();
return;
case ProfileChanged.Type.Renamed when data is string oldName:
if (!FindLeaf(profile, out var leaf2))
return;
var old = oldName.FixName();
if (old == leaf2.Name || leaf2.Name.IsDuplicateName(out var baseName, out _) && baseName == old)
RenameWithDuplicates(leaf2, profile.Name);
return;
}
}
private void Reload()
{
if (Load(new FileInfo(_saveService.FileNames.ProfileFileSystem), _profileManager.Profiles, ProfileToIdentifier, ProfileToName))
{
var shouldReloadAgain = false;
if (!File.Exists(_saveService.FileNames.ProfileFileSystem))
shouldReloadAgain = true;
_saveService.ImmediateSave(this);
//this is a workaround for FileSystem's weird behavior where it doesn't load objects into itself if its file does not exist
if (shouldReloadAgain)
{
_logger.Debug("BUG WORKAROUND: reloading profile filesystem again");
Reload();
return;
}
}
_logger.Debug("Reloaded profile filesystem.");
}
private void OnChange(FileSystemChangeType type, IPath _1, IPath? _2, IPath? _3)
{
if (type != FileSystemChangeType.Reload)
_saveService.QueueSave(this);
}
// Used for saving and loading.
private static string ProfileToIdentifier(Profile profile)
=> profile.UniqueId.ToString();
private static string ProfileToName(Profile profile)
=> profile.Name.Text.FixName();
private static bool ProfileHasDefaultPath(Profile profile, string fullPath)
{
var regex = new Regex($@"^{Regex.Escape(ProfileToName(profile))}( \(\d+\))?$");
return regex.IsMatch(fullPath);
}
private static (string, bool) SaveProfile(Profile profile, string fullPath)
// Only save pairs with non-default paths.
=> ProfileHasDefaultPath(profile, fullPath)
? (string.Empty, false)
: (ProfileToIdentifier(profile), true);
public string ToFilename(FilenameService fileNames) => fileNames.ProfileFileSystem;
public void Save(StreamWriter writer)
{
SaveToFile(writer, SaveProfile, true);
}
}

View File

@@ -0,0 +1,618 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dalamud.Utility;
using Newtonsoft.Json.Linq;
using OtterGui.Log;
using OtterGui.Filesystem;
using Penumbra.GameData.Actors;
using CustomizePlus.Core.Services;
using CustomizePlus.Core.Helpers;
using CustomizePlus.Armatures.Events;
using CustomizePlus.Configuration.Data;
using CustomizePlus.Armatures.Data;
using CustomizePlus.Core.Events;
using CustomizePlus.Templates;
using CustomizePlus.Profiles.Data;
using CustomizePlus.Templates.Events;
using CustomizePlus.Profiles.Events;
using CustomizePlus.Templates.Data;
using CustomizePlus.GameData.Data;
using CustomizePlus.GameData.Services;
using CustomizePlus.GameData.Extensions;
namespace CustomizePlus.Profiles;
/// <summary>
/// Container class for administrating <see cref="Profile" />s during runtime.
/// </summary>
public class ProfileManager : IDisposable
{
private readonly TemplateManager _templateManager;
private readonly TemplateEditorManager _templateEditorManager;
private readonly SaveService _saveService;
private readonly Logger _logger;
private readonly PluginConfiguration _configuration;
private readonly ActorService _actorService;
private readonly ProfileChanged _event;
private readonly TemplateChanged _templateChangedEvent;
private readonly ReloadEvent _reloadEvent;
private readonly ArmatureChanged _armatureChangedEvent;
public readonly List<Profile> Profiles = new();
public Profile? DefaultProfile { get; private set; }
public ProfileManager(
TemplateManager templateManager,
TemplateEditorManager templateEditorManager,
SaveService saveService,
Logger logger,
PluginConfiguration configuration,
ActorService actorService,
ProfileChanged @event,
TemplateChanged templateChangedEvent,
ReloadEvent reloadEvent,
ArmatureChanged armatureChangedEvent)
{
_templateManager = templateManager;
_templateEditorManager = templateEditorManager;
_saveService = saveService;
_logger = logger;
_configuration = configuration;
_actorService = actorService;
_event = @event;
_templateChangedEvent = templateChangedEvent;
_templateChangedEvent.Subscribe(OnTemplateChange, TemplateChanged.Priority.ProfileManager);
_reloadEvent = reloadEvent;
_reloadEvent.Subscribe(OnReload, ReloadEvent.Priority.ProfileManager);
_armatureChangedEvent = armatureChangedEvent;
_armatureChangedEvent.Subscribe(OnArmatureChange, ArmatureChanged.Priority.ProfileManager);
CreateProfileFolder(saveService);
LoadProfiles();
}
public void Dispose()
{
_templateChangedEvent.Unsubscribe(OnTemplateChange);
}
public void LoadProfiles()
{
_logger.Information("Loading profiles from directory...");
//todo: hot reload was not tested
//save temp profiles
var temporaryProfiles = Profiles.Where(x => x.IsTemporary).ToList();
Profiles.Clear();
List<(Profile, string)> invalidNames = new();
foreach (var file in _saveService.FileNames.Profiles())
{
try
{
var text = File.ReadAllText(file.FullName);
var data = JObject.Parse(text);
var profile = Profile.Load(_templateManager, data);
if (profile.UniqueId.ToString() != Path.GetFileNameWithoutExtension(file.Name))
invalidNames.Add((profile, file.FullName));
if (Profiles.Any(f => f.UniqueId == profile.UniqueId))
throw new Exception($"ID {profile.UniqueId} was not unique.");
Profiles.Add(profile);
}
catch (Exception ex)
{
_logger.Error($"Could not load profile, skipped:\n{ex}");
//++skipped;
}
}
foreach (var profile in Profiles)
{
//This will solve any issues if file on disk was manually edited and we have more than a single active profile
if (profile.Enabled)
SetEnabled(profile, true, true);
if (_configuration.DefaultProfile == profile.UniqueId)
DefaultProfile = profile;
}
//insert temp profiles back into profile list
if (temporaryProfiles.Count > 0)
{
Profiles.AddRange(temporaryProfiles);
Profiles.Sort((x, y) => y.IsTemporary.CompareTo(x.IsTemporary));
}
var failed = MoveInvalidNames(invalidNames);
if (invalidNames.Count > 0)
_logger.Information(
$"Moved {invalidNames.Count - failed} profiles to correct names.{(failed > 0 ? $" Failed to move {failed} profiles to correct names." : string.Empty)}");
_logger.Information("Directory load complete");
_event.Invoke(ProfileChanged.Type.ReloadedAll, null, null);
}
/// <summary>
/// Main rendering function, called from rendering hook after calling ArmatureManager.OnRender
/// </summary>
public void OnRender()
{
}
public Profile Create(string name, bool handlePath)
{
var (actualName, path) = NameParsingHelper.ParseName(name, handlePath);
var profile = new Profile
{
CreationDate = DateTimeOffset.UtcNow,
ModifiedDate = DateTimeOffset.UtcNow,
UniqueId = CreateNewGuid(),
Name = actualName
};
Profiles.Add(profile);
_logger.Debug($"Added new profile {profile.UniqueId}.");
_saveService.ImmediateSave(profile);
_event.Invoke(ProfileChanged.Type.Created, profile, path);
return profile;
}
/// <summary>
/// Create a new profile by cloning passed profile
/// </summary>
/// <returns></returns>
public Profile Clone(Profile clone, string name, bool handlePath)
{
var (actualName, path) = NameParsingHelper.ParseName(name, handlePath);
var profile = new Profile(clone)
{
CreationDate = DateTimeOffset.UtcNow,
ModifiedDate = DateTimeOffset.UtcNow,
UniqueId = CreateNewGuid(),
Name = actualName,
Enabled = false
};
Profiles.Add(profile);
_logger.Debug($"Added new profile {profile.UniqueId} by cloning.");
_saveService.ImmediateSave(profile);
_event.Invoke(ProfileChanged.Type.Created, profile, path);
return profile;
}
/// <summary>
/// Rename profile
/// </summary>
public void Rename(Profile profile, string newName)
{
var oldName = profile.Name.Text;
if (oldName == newName)
return;
profile.Name = newName;
SaveProfile(profile);
_logger.Debug($"Renamed profile {profile.UniqueId}.");
_event.Invoke(ProfileChanged.Type.Renamed, profile, oldName);
}
/// <summary>
/// Change character name for profile
/// </summary>
public void ChangeCharacterName(Profile profile, string newName)
{
var oldName = profile.CharacterName.Text;
if (oldName == newName)
return;
profile.CharacterName = newName;
//Called so all other active profiles for new character name get disabled
//saving is performed there
SetEnabled(profile, profile.Enabled, true);
SaveProfile(profile);
_logger.Debug($"Changed character name for profile {profile.UniqueId}.");
_event.Invoke(ProfileChanged.Type.ChangedCharacterName, profile, oldName);
}
/// <summary>
/// Delete profile
/// </summary>
/// <param name="profile"></param>
public void Delete(Profile profile)
{
Profiles.Remove(profile);
_saveService.ImmediateDelete(profile);
_event.Invoke(ProfileChanged.Type.Deleted, profile, null);
}
/// <summary>
/// Set write protection state for profile
/// </summary>
public void SetWriteProtection(Profile profile, bool value)
{
if (profile.IsWriteProtected == value)
return;
profile.IsWriteProtected = value;
SaveProfile(profile);
_logger.Debug($"Set profile {profile.UniqueId} to {(value ? string.Empty : "no longer be ")} write-protected.");
_event.Invoke(ProfileChanged.Type.WriteProtection, profile, value);
}
public void SetEnabled(Profile profile, bool value, bool force = false)
{
if (profile.Enabled == value && !force)
return;
var oldValue = profile.Enabled;
if (value)
{
_logger.Debug($"Setting {profile} as enabled...");
foreach (var otherProfile in Profiles
.Where(x => x.CharacterName == profile.CharacterName && x != profile && x.Enabled && !x.IsTemporary))
{
_logger.Debug($"\t-> {otherProfile} disabled");
SetEnabled(otherProfile, false);
}
}
if (oldValue != value)
{
profile.Enabled = value;
SaveProfile(profile);
_event.Invoke(ProfileChanged.Type.Toggled, profile, value);
}
}
public void SetLimitLookupToOwned(Profile profile, bool value)
{
if (profile.LimitLookupToOwnedObjects != value)
{
profile.LimitLookupToOwnedObjects = value;
SaveProfile(profile);
_event.Invoke(ProfileChanged.Type.LimitLookupToOwnedChanged, profile, value);
}
}
public void DeleteTemplate(Profile profile, int templateIndex)
{
_logger.Debug($"Deleting template #{templateIndex} from {profile}...");
var template = profile.Templates[templateIndex];
profile.Templates.RemoveAt(templateIndex);
SaveProfile(profile);
_event.Invoke(ProfileChanged.Type.RemovedTemplate, profile, template);
}
public void AddTemplate(Profile profile, Template template)
{
if (profile.Templates.Contains(template))
return;
profile.Templates.Add(template);
SaveProfile(profile);
_logger.Debug($"Added template: {template.UniqueId} to {profile.UniqueId}");
_event.Invoke(ProfileChanged.Type.AddedTemplate, profile, template);
}
public void ChangeTemplate(Profile profile, int index, Template newTemplate)
{
if (index >= profile.Templates.Count || index < 0)
return;
if (profile.Templates[index] == newTemplate)
return;
var oldTemplate = profile.Templates[index];
profile.Templates[index] = newTemplate;
SaveProfile(profile);
_logger.Debug($"Changed template on profile {profile.UniqueId} from {oldTemplate.UniqueId} to {newTemplate.UniqueId}");
_event.Invoke(ProfileChanged.Type.ChangedTemplate, profile, (index, oldTemplate, newTemplate));
}
public void MoveTemplate(Profile profile, int fromIndex, int toIndex)
{
if (!profile.Templates.Move(fromIndex, toIndex))
return;
SaveProfile(profile);
_logger.Debug($"Moved template {fromIndex + 1} to position {toIndex + 1}.");
_event.Invoke(ProfileChanged.Type.MovedTemplate, profile, (fromIndex, toIndex));
}
public void SetDefaultProfile(Profile? profile)
{
if (profile == null)
{
if (DefaultProfile == null)
return;
}
else if (!Profiles.Contains(profile))
return;
var previousProfile = DefaultProfile;
DefaultProfile = profile;
_configuration.DefaultProfile = profile?.UniqueId ?? Guid.Empty;
_configuration.Save();
_logger.Debug($"Set profile {profile?.Incognito ?? "no profile"} as default");
_event.Invoke(ProfileChanged.Type.ChangedDefaultProfile, profile, previousProfile);
}
public void AddTemporaryProfile(Profile profile, Actor actor/*, Template template*/)
{
if (!actor.Identifier(_actorService.AwaitedService, out var identifier))
return;
profile.Enabled = true;
profile.IsTemporary = true;
profile.TemporaryActor = identifier;
profile.CharacterName = identifier.ToNameWithoutOwnerName();
profile.LimitLookupToOwnedObjects = false;
var existingProfile = Profiles.FirstOrDefault(x => x.CharacterName.Lower == profile.CharacterName.Lower && x.IsTemporary);
if (existingProfile != null)
{
_logger.Debug($"Temporary profile for {existingProfile.CharacterName} already exists, removing...");
Profiles.Remove(existingProfile);
_event.Invoke(ProfileChanged.Type.TemporaryProfileDeleted, existingProfile, null);
}
Profiles.Add(profile);
//Make sure temporary profiles come first, so they are returned by all other methods first
Profiles.Sort((x, y) => y.IsTemporary.CompareTo(x.IsTemporary));
_logger.Debug($"Added temporary profile for {identifier}");
_event.Invoke(ProfileChanged.Type.TemporaryProfileAdded, profile, null);
}
public void RemoveTemporaryProfile(Profile profile)
{
if (!Profiles.Remove(profile))
return;
_logger.Debug($"Removed temporary profile for {profile.CharacterName}");
_event.Invoke(ProfileChanged.Type.TemporaryProfileDeleted, profile, null);
}
public void RemoveTemporaryProfile(Actor actor)
{
if (!actor.Identifier(_actorService.AwaitedService, out var identifier))
return;
var profile = Profiles.FirstOrDefault(x => x.TemporaryActor == identifier && x.IsTemporary);
if (profile == null)
return;
RemoveTemporaryProfile(profile);
}
/// <summary>
/// Return profile by character name, does not return temporary profiles
/// </summary>
/// <param name="name"></param>
/// <param name="enabledOnly"></param>
/// <returns></returns>
public Profile? GetProfileByCharacterName(string name, bool enabledOnly = false)
{
if (string.IsNullOrWhiteSpace(name))
return null;
var query = Profiles.Where(x => x.CharacterName == name);
if (enabledOnly)
query = query.Where(x => x.Enabled);
return query.FirstOrDefault();
}
//todo: replace with dictionary
/// <summary>
/// Returns all enabled profiles which might apply to the given object, prioritizing temporary profiles and editor profile.
/// </summary>
public IEnumerable<Profile> GetEnabledProfilesByActor(ActorIdentifier actorIdentifier)
{
//performance: using textual override for ProfileAppliesTo here to not call
//GetGameObjectName every time we are trying to check object against profiles
if (actorIdentifier.Type == IdentifierType.Special)
actorIdentifier = actorIdentifier.GetTrueActorForSpecialType();
if (!actorIdentifier.IsValid)
yield break;
var name = actorIdentifier.ToNameWithoutOwnerName();
if (name.IsNullOrWhitespace())
yield break;
if (_templateEditorManager.IsEditorActive && _templateEditorManager.EditorProfile.Enabled)
{
if (ProfileAppliesTo(_templateEditorManager.EditorProfile, name))
{
yield return _templateEditorManager.EditorProfile;
}
}
foreach (var profile in Profiles)
{
if (ProfileAppliesTo(profile, name) && profile.Enabled)
yield return profile;
}
if (DefaultProfile != null &&
DefaultProfile.Enabled &&
(actorIdentifier.Type == IdentifierType.Player || actorIdentifier.Type == IdentifierType.Retainer))
yield return DefaultProfile;
}
public IEnumerable<Profile> GetProfilesUsingTemplate(Template template)
{
if (template == null)
yield break;
foreach (var profile in Profiles)
if (profile.Templates.Contains(template))
yield return profile;
if (_templateEditorManager.EditorProfile.Templates.Contains(template))
yield return _templateEditorManager.EditorProfile;
}
/// <summary>
/// Returns whether or not profile applies to the object with the indicated name.
/// </summary>
public bool ProfileAppliesTo(Profile profile, string objectName) => !string.IsNullOrWhiteSpace(objectName) && objectName == profile.CharacterName.Text;
private void SaveProfile(Profile profile)
{
profile.ModifiedDate = DateTimeOffset.UtcNow;
_saveService.QueueSave(profile);
}
private void OnTemplateChange(TemplateChanged.Type type, Template? template, object? arg3)
{
if (type is not TemplateChanged.Type.Deleted)
return;
foreach (var profile in Profiles)
{
for (var i = 0; i < profile.Templates.Count; ++i)
{
if (profile.Templates[i] != template)
continue;
profile.Templates.RemoveAt(i--);
_event.Invoke(ProfileChanged.Type.RemovedTemplate, profile, template);
SaveProfile(profile);
_logger.Debug($"Removed template {template.UniqueId} from {profile.UniqueId} because template was deleted");
}
}
return;
}
private void OnReload(ReloadEvent.Type type)
{
if (type != ReloadEvent.Type.ReloadProfiles &&
type != ReloadEvent.Type.ReloadAll)
return;
_logger.Debug("Reload event received");
LoadProfiles();
}
private void OnArmatureChange(ArmatureChanged.Type type, Armature? armature, object? arg3)
{
if (type == ArmatureChanged.Type.Deleted)
{
//hack: sending TemporaryProfileDeleted will result in OnArmatureChange being sent
//so we need to make sure that we do not end up with endless loop here
//the whole reason DeletionReason exists is this
if ((ArmatureChanged.DeletionReason)arg3 != ArmatureChanged.DeletionReason.Gone)
return;
var profile = armature!.Profile;
//todo: TemporaryProfileDeleted ends up calling this again, fix this.
//Profiles.Remove check won't allow for infinite loop but this isn't good anyway
if (!profile.IsTemporary || !Profiles.Remove(profile))
return;
_logger.Debug($"ProfileManager.OnArmatureChange: Removed unused temporary profile for {profile.CharacterName}");
_event.Invoke(ProfileChanged.Type.TemporaryProfileDeleted, profile, null);
}
}
private static void CreateProfileFolder(SaveService service)
{
var ret = service.FileNames.ProfileDirectory;
if (Directory.Exists(ret))
return;
try
{
Directory.CreateDirectory(ret);
}
catch (Exception ex)
{
Plugin.Logger.Error($"Could not create profile directory {ret}:\n{ex}");
}
}
/// <summary> Move all files that were discovered to have names not corresponding to their identifier to correct names, if possible. </summary>
/// <returns>The number of files that could not be moved.</returns>
private int MoveInvalidNames(IEnumerable<(Profile, string)> invalidNames)
{
var failed = 0;
foreach (var (profile, name) in invalidNames)
{
try
{
var correctName = _saveService.FileNames.ProfileFile(profile);
File.Move(name, correctName, false);
_logger.Information($"Moved invalid profile file from {Path.GetFileName(name)} to {Path.GetFileName(correctName)}.");
}
catch (Exception ex)
{
++failed;
_logger.Error($"Failed to move invalid profile file from {Path.GetFileName(name)}:\n{ex}");
}
}
return failed;
}
/// <summary>
/// Create new guid until we find one which isn't used by existing profile
/// </summary>
/// <returns></returns>
private Guid CreateNewGuid()
{
while (true)
{
var guid = Guid.NewGuid();
if (Profiles.All(d => d.UniqueId != guid))
return guid;
}
}
}

View File

@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.IO;
using CustomizePlus.Core.Data;
using CustomizePlus.Core.Extensions;
using CustomizePlus.Core.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OtterGui.Classes;
namespace CustomizePlus.Templates.Data;
/// <summary>
/// Encapsulates the user-controlled aspects of a template, ie all of
/// the information that gets saved to disk by the plugin.
/// </summary>
public sealed class Template : ISavable
{
public LowerString Name { get; internal set; } = "Template";
public int Version { get; internal set; } = Constants.ConfigurationVersion;
public DateTimeOffset CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTimeOffset ModifiedDate { get; internal set; } = DateTime.UtcNow;
public Guid UniqueId { get; internal set; } = Guid.NewGuid();
public bool IsWriteProtected { get; internal set; }
public string Incognito
=> UniqueId.ToString()[..8];
public Dictionary<string, BoneTransform> Bones { get; init; } = new();
public Template()
{
}
/// <summary>
/// Creates a new template based on bone data from another one
/// </summary>
public Template(Template original) : this()
{
foreach (var kvp in original.Bones)
{
Bones[kvp.Key] = new BoneTransform();
Bones[kvp.Key].UpdateToMatch(kvp.Value);
}
}
public override string ToString()
{
return $"Template '{Name.Text.Incognify()}' with {Bones.Count} bone edits [{UniqueId}]";
}
#region Serialization
public new JObject JsonSerialize()
{
var ret = new JObject()
{
["Version"] = Version,
["UniqueId"] = UniqueId,
["CreationDate"] = CreationDate,
["ModifiedDate"] = ModifiedDate,
["Name"] = Name.Text,
["Bones"] = JObject.FromObject(Bones),
["IsWriteProtected"] = IsWriteProtected
};
return ret;
}
#endregion
#region Deserialization
public static Template Load(JObject obj)
{
var version = obj["Version"]?.ToObject<int>() ?? 0;
return version switch
{
//Did not exist before v4
4 => LoadV4(obj),
_ => throw new Exception("The design to be loaded has no valid Version."),
};
}
private static Template LoadV4(JObject obj)
{
var creationDate = obj["CreationDate"]?.ToObject<DateTimeOffset>() ?? throw new ArgumentNullException("CreationDate");
var template = new Template()
{
CreationDate = creationDate,
UniqueId = obj["UniqueId"]?.ToObject<Guid>() ?? throw new ArgumentNullException("UniqueId"),
Name = new LowerString(obj["Name"]?.ToObject<string>() ?? throw new ArgumentNullException("Name")),
ModifiedDate = obj["ModifiedDate"]?.ToObject<DateTimeOffset>() ?? creationDate,
Bones = obj["Bones"]?.ToObject<Dictionary<string, BoneTransform>>() ?? throw new ArgumentNullException("Bones"),
IsWriteProtected = obj["IsWriteProtected"]?.ToObject<bool>() ?? false
};
if (template.ModifiedDate < creationDate)
template.ModifiedDate = creationDate;
return template;
}
#endregion
#region ISavable
public string ToFilename(FilenameService fileNames)
=> fileNames.TemplateFile(this);
public void Save(StreamWriter writer)
{
using var j = new JsonTextWriter(writer)
{
Formatting = Formatting.Indented,
};
var obj = JsonSerialize();
obj.WriteTo(j);
}
public string LogName(string fileName)
=> Path.GetFileNameWithoutExtension(fileName);
#endregion
}

View File

@@ -0,0 +1,36 @@
using CustomizePlus.Templates.Data;
using OtterGui.Classes;
using System;
namespace CustomizePlus.Templates.Events;
/// <summary>
/// Triggered when Template is changed
/// </summary>
public class TemplateChanged() : EventWrapper<TemplateChanged.Type, Template?, object?, TemplateChanged.Priority>(nameof(TemplateChanged))
{
public enum Type
{
Created,
Deleted,
Renamed,
NewBone,
UpdatedBone,
DeletedBone,
EditorEnabled,
EditorDisabled,
EditorCharacterChanged,
ReloadedAll,
WriteProtection
}
public enum Priority
{
TemplateCombo = -2,
TemplateFileSystemSelector = -1,
TemplateFileSystem,
ArmatureManager,
ProfileManager,
CustomizePlusIpc
}
}

View File

@@ -0,0 +1,288 @@
using CustomizePlus.Core.Data;
using CustomizePlus.Game.Events;
using CustomizePlus.Game.Services;
using CustomizePlus.Profiles.Data;
using CustomizePlus.Templates.Data;
using CustomizePlus.Templates.Events;
using OtterGui.Log;
using System;
using System.Collections.Generic;
using System.Numerics;
namespace CustomizePlus.Templates;
public class TemplateEditorManager : IDisposable
{
private readonly TemplateChanged _event;
private readonly GPoseStateChanged _gposeStateChanged;
private readonly Logger _logger;
private readonly GameObjectService _gameObjectService;
private readonly TemplateManager _templateManager;
/// <summary>
/// Reference to the original template which is currently being edited, should not be edited!
/// </summary>
private Template _currentlyEditedTemplateOriginal;
/// <summary>
/// Internal profile for the editor
/// </summary>
public Profile EditorProfile { get; private set; }
/// <summary>
/// Original ID of the template which is currently being edited
/// </summary>
public Guid CurrentlyEditedTemplateId { get; private set; }
/// <summary>
/// A copy of currently edited template, all changes must be done on this template
/// </summary>
public Template? CurrentlyEditedTemplate { get; private set; }
public bool IsEditorActive { get; private set; }
/// <summary>
/// Is editor currently paused? Happens automatically when editor is not compatible with the current game state.
/// Keeps editor state frozen and prevents any changes to it, also sets editor profile as disabled.
/// </summary>
public bool IsEditorPaused { get; private set; }
/// <summary>
/// Indicates if there are any changes in current editing session or not
/// </summary>
public bool HasChanges { get; private set; }
public bool IsKeepOnlyEditorProfileActive { get; set; } //todo
public TemplateEditorManager(
TemplateChanged @event,
GPoseStateChanged gposeStateChanged,
Logger logger,
TemplateManager templateManager,
GameObjectService gameObjectService)
{
_event = @event;
_gposeStateChanged = gposeStateChanged;
_logger = logger;
_templateManager = templateManager;
_gameObjectService = gameObjectService;
_gposeStateChanged.Subscribe(OnGPoseStateChanged, GPoseStateChanged.Priority.TemplateEditorManager);
EditorProfile = new Profile() { Templates = new List<Template>(), Enabled = false, Name = "Template editor profile" };
}
public void Dispose()
{
_gposeStateChanged.Unsubscribe(OnGPoseStateChanged);
}
/// <summary>
/// Turn on editing of a specific template. If character name not set will default to local player.
/// </summary>
internal bool EnableEditor(Template template, string? characterName = null) //todo: editor is borked
{
if (IsEditorActive || IsEditorPaused)
return false;
_logger.Debug($"Enabling editor profile for {template.Name} via character {characterName}");
CurrentlyEditedTemplateId = template.UniqueId;
_currentlyEditedTemplateOriginal = template;
CurrentlyEditedTemplate = new Template(template)
{
CreationDate = DateTimeOffset.UtcNow,
ModifiedDate = DateTimeOffset.UtcNow,
UniqueId = Guid.NewGuid(),
Name = "Template editor temporary template"
};
if (characterName != null)
EditorProfile.CharacterName = characterName;
else //safeguard
EditorProfile.CharacterName = _gameObjectService.GetCurrentPlayerName();
EditorProfile.Templates.Clear(); //safeguard
EditorProfile.Templates.Add(CurrentlyEditedTemplate);
EditorProfile.Enabled = true;
HasChanges = false;
IsEditorActive = true;
_event.Invoke(TemplateChanged.Type.EditorEnabled, template, characterName);
return true;
}
/// <summary>
/// Turn off editing of a specific template
/// </summary>
internal bool DisableEditor()
{
if (!IsEditorActive || IsEditorPaused)
return false;
_logger.Debug($"Disabling editor profile");
string characterName = EditorProfile.CharacterName;
CurrentlyEditedTemplateId = Guid.Empty;
CurrentlyEditedTemplate = null;
EditorProfile.Enabled = false;
EditorProfile.CharacterName = "";
EditorProfile.Templates.Clear();
IsEditorActive = false;
HasChanges = false;
_event.Invoke(TemplateChanged.Type.EditorDisabled, null, characterName);
return true;
}
public void SaveChanges(bool asCopy = false)
{
var targetTemplate = _templateManager.GetTemplate(CurrentlyEditedTemplateId);
if (targetTemplate == null)
throw new Exception($"Fatal editor error: Template with ID {CurrentlyEditedTemplateId} not found in template manager");
if (asCopy)
targetTemplate = _templateManager.Clone(targetTemplate, $"{targetTemplate.Name} - Copy {Guid.NewGuid().ToString().Substring(0, 4)}", false);
_templateManager.ApplyBoneChangesAndSave(targetTemplate, CurrentlyEditedTemplate!);
}
public bool ChangeEditorCharacter(string characterName)
{
if (!IsEditorActive || EditorProfile.CharacterName == characterName || IsEditorPaused)
return false;
_logger.Debug($"Changing character name for editor profile from {EditorProfile.CharacterName} to {characterName}");
EditorProfile.CharacterName = characterName;
_event.Invoke(TemplateChanged.Type.EditorCharacterChanged, CurrentlyEditedTemplate, (characterName, EditorProfile));
return true;
}
/// <summary>
/// Resets changes in currently edited template to default values
/// </summary>
public bool ResetBoneAttributeChanges(string boneName, BoneAttribute attribute)
{
if (!IsEditorActive || IsEditorPaused)
return false;
if (!CurrentlyEditedTemplate!.Bones.ContainsKey(boneName))
return false;
var resetValue = GetResetValueForAttribute(attribute);
switch (attribute)
{
case BoneAttribute.Position:
if (resetValue == CurrentlyEditedTemplate!.Bones[boneName].Translation)
return false;
break;
case BoneAttribute.Rotation:
if (resetValue == CurrentlyEditedTemplate!.Bones[boneName].Rotation)
return false;
break;
case BoneAttribute.Scale:
if (resetValue == CurrentlyEditedTemplate!.Bones[boneName].Scaling)
return false;
break;
}
CurrentlyEditedTemplate!.Bones[boneName].UpdateAttribute(attribute, resetValue);
if (!HasChanges)
HasChanges = true;
return true;
}
/// <summary>
/// Reverts changes in currently edited template to values set in saved copy of the template.
/// Resets to default value if saved copy doesn't have that bone edited
/// </summary>
public bool RevertBoneAttributeChanges(string boneName, BoneAttribute attribute)
{
if (!IsEditorActive || IsEditorPaused)
return false;
if (!CurrentlyEditedTemplate!.Bones.ContainsKey(boneName))
return false;
Vector3? originalValue = null!;
if (_currentlyEditedTemplateOriginal.Bones.ContainsKey(boneName))
{
switch (attribute)
{
case BoneAttribute.Position:
originalValue = _currentlyEditedTemplateOriginal.Bones[boneName].Translation;
if (originalValue == CurrentlyEditedTemplate!.Bones[boneName].Translation)
return false;
break;
case BoneAttribute.Rotation:
originalValue = _currentlyEditedTemplateOriginal.Bones[boneName].Rotation;
if (originalValue == CurrentlyEditedTemplate!.Bones[boneName].Rotation)
return false;
break;
case BoneAttribute.Scale:
originalValue = _currentlyEditedTemplateOriginal.Bones[boneName].Scaling;
if (originalValue == CurrentlyEditedTemplate!.Bones[boneName].Scaling)
return false;
break;
}
}
else
originalValue = GetResetValueForAttribute(attribute);
CurrentlyEditedTemplate!.Bones[boneName].UpdateAttribute(attribute, originalValue.Value);
if (!HasChanges)
HasChanges = true;
return true;
}
public bool ModifyBoneTransform(string boneName, BoneTransform transform)
{
if (!IsEditorActive || IsEditorPaused)
return false;
if (!_templateManager.ModifyBoneTransform(CurrentlyEditedTemplate!, boneName, transform))
return false;
if (!HasChanges)
HasChanges = true;
return true;
}
private Vector3 GetResetValueForAttribute(BoneAttribute attribute)
{
switch (attribute)
{
case BoneAttribute.Scale:
return Vector3.One;
default:
return Vector3.Zero;
}
}
private void OnGPoseStateChanged(GPoseStateChanged.Type type)
{
switch (type)
{
case GPoseStateChanged.Type.Entered:
IsEditorPaused = true;
EditorProfile.Enabled = false;
break;
case GPoseStateChanged.Type.Exited:
EditorProfile.Enabled = true;
IsEditorPaused = false;
break;
}
}
}

View File

@@ -0,0 +1,150 @@
using Dalamud.Interface.Internal.Notifications;
using OtterGui.Classes;
using OtterGui.Filesystem;
using OtterGui.Log;
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using CustomizePlus.Core.Services;
using CustomizePlus.Templates.Events;
using CustomizePlus.Templates.Data;
namespace CustomizePlus.Templates;
//Adapted from glamourer source code
public sealed class TemplateFileSystem : FileSystem<Template>, IDisposable, ISavable
{
private readonly TemplateManager _templateManager;
private readonly SaveService _saveService;
private readonly TemplateChanged _templateChanged;
private readonly MessageService _messageService;
private readonly Logger _logger;
public TemplateFileSystem(
TemplateManager templateManager,
SaveService saveService,
TemplateChanged templateChanged,
MessageService messageService,
Logger logger)
{
_templateManager = templateManager;
_saveService = saveService;
_templateChanged = templateChanged;
_messageService = messageService;
_logger = logger;
_templateChanged.Subscribe(OnTemplateChange, TemplateChanged.Priority.TemplateFileSystem);
Changed += OnChange;
Reload();
}
public void Dispose()
{
_templateChanged.Unsubscribe(OnTemplateChange);
}
// Search the entire filesystem for the leaf corresponding to a template.
public bool FindLeaf(Template template, [NotNullWhen(true)] out Leaf? leaf)
{
leaf = Root.GetAllDescendants(ISortMode<Template>.Lexicographical)
.OfType<Leaf>()
.FirstOrDefault(l => l.Value == template);
return leaf != null;
}
private void OnTemplateChange(TemplateChanged.Type type, Template? template, object? data)
{
switch (type)
{
case TemplateChanged.Type.Created:
var parent = Root;
if (data is string path)
try
{
parent = FindOrCreateAllFolders(path);
}
catch (Exception ex)
{
_messageService.NotificationMessage(ex, $"Could not move template to {path} because the folder could not be created.", NotificationType.Error);
}
CreateDuplicateLeaf(parent, template.Name.Text, template);
return;
case TemplateChanged.Type.Deleted:
if (FindLeaf(template, out var leaf1))
Delete(leaf1);
return;
case TemplateChanged.Type.ReloadedAll:
Reload();
return;
case TemplateChanged.Type.Renamed when data is string oldName:
if (!FindLeaf(template, out var leaf2))
return;
var old = oldName.FixName();
if (old == leaf2.Name || leaf2.Name.IsDuplicateName(out var baseName, out _) && baseName == old)
RenameWithDuplicates(leaf2, template.Name);
return;
}
}
private void Reload()
{
if (Load(new FileInfo(_saveService.FileNames.TemplateFileSystem), _templateManager.Templates, TemplateToIdentifier, TemplateToName))
{
var shouldReloadAgain = false;
if (!File.Exists(_saveService.FileNames.TemplateFileSystem))
shouldReloadAgain = true;
_saveService.ImmediateSave(this);
//this is a workaround for FileSystem's weird behavior where it doesn't load objects into itself if its file does not exist
if (shouldReloadAgain)
{
_logger.Debug("BUG WORKAROUND: reloading template filesystem again");
Reload();
return;
}
}
_logger.Debug("Reloaded template filesystem.");
}
private void OnChange(FileSystemChangeType type, IPath _1, IPath? _2, IPath? _3)
{
if (type != FileSystemChangeType.Reload)
_saveService.QueueSave(this);
}
// Used for saving and loading.
private static string TemplateToIdentifier(Template template)
=> template.UniqueId.ToString();
private static string TemplateToName(Template template)
=> template.Name.Text.FixName();
private static bool TemplateHasDefaultPath(Template template, string fullPath)
{
var regex = new Regex($@"^{Regex.Escape(TemplateToName(template))}( \(\d+\))?$");
return regex.IsMatch(fullPath);
}
private static (string, bool) SaveTemplate(Template template, string fullPath)
// Only save pairs with non-default paths.
=> TemplateHasDefaultPath(template, fullPath)
? (string.Empty, false)
: (TemplateToIdentifier(template), true);
public string ToFilename(FilenameService fileNames) => fileNames.TemplateFileSystem;
public void Save(StreamWriter writer)
{
SaveToFile(writer, SaveTemplate, true);
}
}

View File

@@ -0,0 +1,332 @@
using CustomizePlus.Core.Data;
using CustomizePlus.Core.Events;
using CustomizePlus.Core.Helpers;
using CustomizePlus.Core.Services;
using CustomizePlus.Templates.Data;
using CustomizePlus.Templates.Events;
using Newtonsoft.Json.Linq;
using OtterGui.Log;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace CustomizePlus.Templates;
public class TemplateManager
{
private readonly SaveService _saveService;
private readonly Logger _logger;
private readonly TemplateChanged _event;
private readonly ReloadEvent _reloadEvent;
private readonly List<Template> _templates = new();
public IReadOnlyList<Template> Templates
=> _templates;
public TemplateManager(
SaveService saveService,
Logger logger,
TemplateChanged @event,
ReloadEvent reloadEvent)
{
_saveService = saveService;
_logger = logger;
_event = @event;
_reloadEvent = reloadEvent;
_reloadEvent.Subscribe(OnReload, ReloadEvent.Priority.TemplateManager);
CreateTemplateFolder(saveService);
LoadTemplates();
}
public Template? GetTemplate(Guid templateId) => _templates.FirstOrDefault(d => d.UniqueId == templateId);
public void LoadTemplates()
{
_logger.Information("Loading templates from directory...");
_templates.Clear();
List<(Template, string)> invalidNames = new();
foreach (var file in _saveService.FileNames.Templates())
{
try
{
var text = File.ReadAllText(file.FullName);
var data = JObject.Parse(text);
var template = Template.Load(data);
if (template.UniqueId.ToString() != Path.GetFileNameWithoutExtension(file.Name))
invalidNames.Add((template, file.FullName));
if (_templates.Any(f => f.UniqueId == template.UniqueId))
throw new Exception($"ID {template.UniqueId} was not unique.");
PruneIdempotentTransforms(template);
_templates.Add(template);
}
catch (Exception ex)
{
_logger.Error($"Could not load template, skipped:\n{ex}");
//++skipped;
}
}
var failed = MoveInvalidNames(invalidNames);
if (invalidNames.Count > 0)
_logger.Information(
$"Moved {invalidNames.Count - failed} templates to correct names.{(failed > 0 ? $" Failed to move {failed} templates to correct names." : string.Empty)}");
_logger.Information("Directory load complete");
_event.Invoke(TemplateChanged.Type.ReloadedAll, null, null);
}
public Template Create(string name, Dictionary<string, BoneTransform>? bones, bool handlePath)
{
var (actualName, path) = NameParsingHelper.ParseName(name, handlePath);
var template = new Template
{
CreationDate = DateTimeOffset.UtcNow,
ModifiedDate = DateTimeOffset.UtcNow,
UniqueId = CreateNewGuid(),
Name = actualName,
Bones = bones != null && bones.Count > 0 ? new Dictionary<string, BoneTransform>(bones) : new()
};
if (template.Bones.Count > 0)
PruneIdempotentTransforms(template);
_templates.Add(template);
_logger.Debug($"Added new template {template.UniqueId}.");
_saveService.ImmediateSave(template);
_event.Invoke(TemplateChanged.Type.Created, template, path);
return template;
}
public Template Create(string name, bool handlePath)
{
return Create(name, null, handlePath);
}
/// <summary>
/// Create a new template by cloning passed template
/// </summary>
/// <returns></returns>
public Template Clone(Template clone, string name, bool handlePath)
{
var (actualName, path) = NameParsingHelper.ParseName(name, handlePath);
var template = new Template(clone)
{
CreationDate = DateTimeOffset.UtcNow,
ModifiedDate = DateTimeOffset.UtcNow,
UniqueId = CreateNewGuid(),
Name = actualName
};
_templates.Add(template);
_logger.Debug($"Added new template {template.UniqueId} by cloning.");
_saveService.ImmediateSave(template);
_event.Invoke(TemplateChanged.Type.Created, template, path);
return template;
}
/// <summary>
/// Rename template
/// </summary>
public void Rename(Template template, string newName)
{
var oldName = template.Name.Text;
if (oldName == newName)
return;
template.Name = newName;
SaveTemplate(template);
_logger.Debug($"Renamed template {template.UniqueId}.");
_event.Invoke(TemplateChanged.Type.Renamed, template, oldName);
}
/// <summary>
/// Delete template
/// </summary>
/// <param name="template"></param>
public void Delete(Template template)
{
_templates.Remove(template);
_saveService.ImmediateDelete(template);
_event.Invoke(TemplateChanged.Type.Deleted, template, null);
}
/// <summary>
/// Set write protection state for template
/// </summary>
public void SetWriteProtection(Template template, bool value)
{
if (template.IsWriteProtected == value)
return;
template.IsWriteProtected = value;
SaveTemplate(template);
_logger.Debug($"Set template {template.UniqueId} to {(value ? string.Empty : "no longer be ")} write-protected.");
_event.Invoke(TemplateChanged.Type.WriteProtection, template, value);
}
/// <summary>
/// Copy bone data from source template to target template and queue a save for target template
/// </summary>
/// <param name="targetTemplate"></param>
/// <param name="sourceTemplate"></param>
public void ApplyBoneChangesAndSave(Template targetTemplate, Template sourceTemplate)
{
_logger.Debug($"Copying bones from {sourceTemplate.Name} to {targetTemplate.Name}");
var deletedBones = targetTemplate.Bones.Keys.Except(sourceTemplate.Bones.Keys).ToList();
foreach (var kvPair in sourceTemplate.Bones)
{
ModifyBoneTransform(targetTemplate, kvPair.Key, kvPair.Value);
}
foreach (var boneName in deletedBones)
{
DeleteBoneTransform(targetTemplate, boneName);
}
SaveTemplate(targetTemplate);
}
//Creates, updates or deletes bone transform
//not to be used on editor-related features by anything but TemplateEditorManager
public bool ModifyBoneTransform(Template template, string boneName, BoneTransform transform)
{
if (template.Bones.TryGetValue(boneName, out var boneTransform)
&& boneTransform != null)
{
if (boneTransform == transform)
return false;
if (transform.IsEdited())
{
template.Bones[boneName].UpdateToMatch(transform);
_logger.Debug($"Updated bone {boneName} on {template.Name}");
_event.Invoke(TemplateChanged.Type.UpdatedBone, template, boneName);
}
else
{
template.Bones.Remove(boneName);
_logger.Debug($"Deleted bone {boneName} on {template.Name}");
_event.Invoke(TemplateChanged.Type.DeletedBone, template, boneName);
}
}
else
{
template.Bones[boneName] = new BoneTransform(transform);
_logger.Debug($"Created bone {boneName} on {template.Name}");
_event.Invoke(TemplateChanged.Type.NewBone, template, boneName);
}
return true;
}
private void DeleteBoneTransform(Template template, string boneName)
{
if (!template.Bones.ContainsKey(boneName))
return;
template.Bones.Remove(boneName);
_logger.Debug($"Deleted bone {boneName} on {template.Name}");
_event.Invoke(TemplateChanged.Type.DeletedBone, template, boneName);
}
private static void PruneIdempotentTransforms(Template template)
{
foreach (var kvp in template.Bones)
{
if (!kvp.Value.IsEdited())
{
template.Bones.Remove(kvp.Key);
}
}
}
private void SaveTemplate(Template template)
{
template.ModifiedDate = DateTimeOffset.UtcNow;
_saveService.QueueSave(template);
}
private void OnReload(ReloadEvent.Type type)
{
if (type != ReloadEvent.Type.ReloadTemplates &&
type != ReloadEvent.Type.ReloadAll)
return;
_logger.Debug("Reload event received");
LoadTemplates();
}
private static void CreateTemplateFolder(SaveService service)
{
var ret = service.FileNames.TemplateDirectory;
if (Directory.Exists(ret))
return;
try
{
Directory.CreateDirectory(ret);
}
catch (Exception ex)
{
Plugin.Logger.Error($"Could not create template directory {ret}:\n{ex}");
}
}
/// <summary> Move all files that were discovered to have names not corresponding to their identifier to correct names, if possible. </summary>
/// <returns>The number of files that could not be moved.</returns>
private int MoveInvalidNames(IEnumerable<(Template, string)> invalidNames)
{
var failed = 0;
foreach (var (template, name) in invalidNames)
{
try
{
var correctName = _saveService.FileNames.TemplateFile(template);
File.Move(name, correctName, false);
_logger.Information($"Moved invalid template file from {Path.GetFileName(name)} to {Path.GetFileName(correctName)}.");
}
catch (Exception ex)
{
++failed;
_logger.Error($"Failed to move invalid template file from {Path.GetFileName(name)}:\n{ex}");
}
}
return failed;
}
/// <summary>
/// Create new guid until we find one which isn't used by existing template
/// </summary>
/// <returns></returns>
private Guid CreateNewGuid()
{
while (true)
{
var guid = Guid.NewGuid();
if (_templates.All(d => d.UniqueId != guid))
return guid;
}
}
}

View File

@@ -0,0 +1,48 @@
using Dalamud.Interface;
using Dalamud.Interface.Windowing;
using System;
using CustomizePlus.Configuration.Data;
using CustomizePlus.UI.Windows.MainWindow;
using CustomizePlus.UI.Windows;
namespace CustomizePlus.UI;
public class CPlusWindowSystem : IDisposable
{
private readonly WindowSystem _windowSystem = new("Customize+");
private readonly UiBuilder _uiBuilder;
private readonly MainWindow _mainWindow;
private readonly PopupSystem _popupSystem;
public CPlusWindowSystem(
UiBuilder uiBuilder,
MainWindow mainWindow,
CPlusChangeLog changelog,
PopupSystem popupSystem,
PluginConfiguration configuration)
{
_uiBuilder = uiBuilder;
_mainWindow = mainWindow;
_popupSystem = popupSystem;
_windowSystem.AddWindow(mainWindow);
_windowSystem.AddWindow(changelog.Changelog);
_uiBuilder.Draw += OnDraw;
_uiBuilder.OpenConfigUi += _mainWindow.Toggle;
_uiBuilder.DisableGposeUiHide = true;
_uiBuilder.DisableCutsceneUiHide = !configuration.UISettings.HideWindowInCutscene;
}
private void OnDraw()
{
_windowSystem.Draw();
_popupSystem.Draw();
}
public void Dispose()
{
_uiBuilder.Draw -= _windowSystem.Draw;
_uiBuilder.OpenConfigUi -= _mainWindow.Toggle;
}
}

View File

@@ -0,0 +1,51 @@
using System.Collections.Generic;
namespace CustomizePlus.UI;
public enum ColorId
{
UsedTemplate,
UnusedTemplate,
EnabledProfile,
DisabledProfile,
LocalCharacterEnabledProfile,
LocalCharacterDisabledProfile,
FolderExpanded,
FolderCollapsed,
FolderLine,
HeaderButtons,
}
public static class Colors
{
public const uint SelectedRed = 0xFF2020D0;
public static (uint DefaultColor, string Name, string Description) Data(this ColorId color)
=> color switch
{
// @formatter:off
ColorId.UsedTemplate => (0xFFFFFFFF, "Used Template", "A template which is being used by at least one profile."),
//ColorId.EnabledTemplate => (0xFFA0F0A0, "Enabled Automation Set", "An automation set that is currently enabled. Only one set can be enabled for each identifier at once."),
ColorId.UnusedTemplate => (0xFF808080, "Unused template", "Template which is currently not being used by any profile."),
ColorId.EnabledProfile => (0xFFFFFFFF, "Enabled profile", "A profile which is currently enabled."),
ColorId.DisabledProfile => (0xFF808080, "Disabled profile", "A profile which is currently disabled"),
ColorId.LocalCharacterEnabledProfile => (0xFF18C018, "Current character profile (enabled)", "A profile which is currently enabled and associated with your character."),
ColorId.LocalCharacterDisabledProfile => (0xFF808080, "Current character profile (disabled)", "A profile which is currently disabled and associated with your character."),
ColorId.FolderExpanded => (0xFFFFF0C0, "Expanded Folder", "A folder that is currently expanded."),
ColorId.FolderCollapsed => (0xFFFFF0C0, "Collapsed Folder", "A folder that is currently collapsed."),
ColorId.FolderLine => (0xFFFFF0C0, "Expanded Folder Line", "The line signifying which descendants belong to an expanded folder."),
ColorId.HeaderButtons => (0xFFFFF0C0, "Header Buttons", "The text and border color of buttons in the header, like the write protection toggle."),
_ => (0x00000000, string.Empty, string.Empty),
// @formatter:on
};
private static IReadOnlyDictionary<ColorId, uint> _colors = new Dictionary<ColorId, uint>();
/// <summary> Obtain the configured value for a color. </summary>
public static uint Value(this ColorId color)
=> _colors.TryGetValue(color, out var value) ? value : color.Data().DefaultColor;
/// <summary> Set the configurable colors dictionary to a value. </summary>
/*public static void SetColors(Configuration config)
=> _colors = config.Colors;*/
}

View File

@@ -0,0 +1,60 @@
using CustomizePlus.Configuration.Data;
using OtterGui.Widgets;
namespace CustomizePlus.UI.Windows;
public class CPlusChangeLog
{
public const int LastChangelogVersion = 0;
private readonly PluginConfiguration _config;
public readonly Changelog Changelog;
public CPlusChangeLog(PluginConfiguration config)
{
_config = config;
Changelog = new Changelog("Customize+ update history", ConfigData, Save);
Add2_0_0_0(Changelog);
}
private (int, ChangeLogDisplayType) ConfigData()
=> (_config.ChangelogSettings.LastSeenVersion, _config.ChangelogSettings.ChangeLogDisplayType);
private void Save(int version, ChangeLogDisplayType type)
{
_config.ChangelogSettings.LastSeenVersion = version;
_config.ChangelogSettings.ChangeLogDisplayType = type;
_config.Save();
}
private static void Add2_0_0_0(Changelog log)
=> log.NextVersion("Version 2.0.0.0")
.RegisterHighlight("Major rework of the entire plugin.")
.RegisterEntry("Migration of your Customize+ settings and profiles should be performed without any issues.", 1)
.RegisterImportant("Old version configuration is backed up in case something goes wrong, please report any issues with configuration migration as soon as possible.", 1)
.RegisterHighlight("Major changes:")
.RegisterEntry("Plugin has been almost completely rewritten from scratch.", 1)
.RegisterImportant("Clipboard copies and profiles from previous versions are not currently supported.", 2)
.RegisterEntry("User interface has been moved to the framework used by Glamourer and Penumbra, so the interface should feel familiar to the users of those plugins.", 1)
.RegisterEntry("User interface issues related to different resolutions and font sizes should *mostly* not occur anymore.", 2)
.RegisterImportant("There are several issues with text not fitting in some places depending on your resolution and font size. This will be fixed later.", 3)
.RegisterEntry("Template system has been added", 1)
.RegisterEntry("All bone edits are now stored in templates which can be used by multiple profiles and single profile can reference unlimited number of templates.", 2)
.RegisterImportant("Chat commands have been changed, refer to \"/customize help\" for information about available commands.", 1)
.RegisterEntry("Added \"toggle\" chat command which can be used to toggle given profile on a given character.", 2)
.RegisterEntry("Profiles can be applied to summons, mounts and pets without any limitations.", 1)
.RegisterImportant("Root scaling of mounts is not available for now.", 2)
.RegisterEntry("Fixed \"Only owned\" not working properly in various cases and renamed it to \"Limit to my creatures\".", 1)
.RegisterEntry("Fixed profiles \"leaking\" to other characters due to the way original Mare Synchronos integration implementation was handled.", 1)
.RegisterEntry("Compatibility with cutscenes is improved, but that was not extensively tested.", 1)
.RegisterEntry("Plugin configuration is now being regularly backed up, the backup is located in %appdata%\\XIVLauncher\\backups\\CustomizePlus folder", 1);
}

View File

@@ -0,0 +1,102 @@
using Dalamud.Interface.Utility;
using Dalamud.Interface;
using ImGuiNET;
using System.Numerics;
using CustomizePlus.Core.Services;
using CustomizePlus.Game.Services;
using CustomizePlus.Configuration.Data;
using CustomizePlus.UI.Windows.MainWindow.Tabs.Templates;
using CustomizePlus.Core.Helpers;
namespace CustomizePlus.UI.Windows.Controls;
public class PluginStateBlock
{
private readonly BoneEditorPanel _boneEditorPanel;
private readonly PluginConfiguration _configuration;
private readonly GameStateService _gameStateService;
private readonly FantasiaPlusDetectService _fantasiaPlusDetectService;
private static Vector4 normalColor = new Vector4(1, 1, 1, 1);
private static Vector4 warnColor = new Vector4(1, 0.5f, 0, 1);
private static Vector4 errorColor = new Vector4(1, 0, 0, 1);
public PluginStateBlock(
BoneEditorPanel boneEditorPanel,
PluginConfiguration configuration,
GameStateService gameStateService,
FantasiaPlusDetectService fantasiaPlusDetectService)
{
_boneEditorPanel = boneEditorPanel;
_configuration = configuration;
_gameStateService = gameStateService;
_fantasiaPlusDetectService = fantasiaPlusDetectService;
}
public void Draw(float yPos)
{
var severity = PluginStateSeverity.Normal;
string? message = null;
if (_fantasiaPlusDetectService.IsFantasiaPlusInstalled)
{
severity = PluginStateSeverity.Error;
message = $"Fantasia+ detected. The plugin is disabled until Fantasia+ is disabled and the game is restarted.";
}
else if (_gameStateService.GameInPosingMode())
{
severity = PluginStateSeverity.Warning;
message = $"GPose active. Most editor features are unavailable while you're in this mode.";
}
else if (!_configuration.PluginEnabled)
{
severity = PluginStateSeverity.Warning;
message = "Plugin is disabled, template bone editing is not available.";
}
else if (_boneEditorPanel.IsEditorActive)
{
if (!_boneEditorPanel.IsCharacterFound)
{
severity = PluginStateSeverity.Error;
message = $"Selected preview character was not found.";
}
else
{
if (_boneEditorPanel.HasChanges)
severity = PluginStateSeverity.Warning;
message = $"Editor is active.{(_boneEditorPanel.HasChanges ? " You have unsaved changes, finish template bone editing to open save/revert dialog." : "")}";
}
}
if (message != null)
{
ImGui.SetCursorPos(new Vector2(ImGui.GetWindowContentRegionMax().X - ImGui.CalcTextSize(message).X - 30, yPos - ImGuiHelpers.GlobalScale));
var icon = FontAwesomeIcon.InfoCircle;
var color = normalColor;
switch (severity)
{
case PluginStateSeverity.Warning:
icon = FontAwesomeIcon.ExclamationTriangle;
color = warnColor;
break;
case PluginStateSeverity.Error:
icon = FontAwesomeIcon.ExclamationTriangle;
color = errorColor;
break;
}
ImGui.PushStyleColor(ImGuiCol.Text, color);
CtrlHelper.LabelWithIcon(icon, message, false);
ImGui.PopStyleColor();
}
}
private enum PluginStateSeverity
{
Normal,
Warning,
Error
}
}

View File

@@ -0,0 +1,151 @@
using Dalamud.Interface.Utility;
using ImGuiNET;
using OtterGui.Classes;
using OtterGui.Log;
using OtterGui.Widgets;
using OtterGui;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using CustomizePlus.Templates;
using CustomizePlus.Configuration.Data;
using CustomizePlus.Profiles;
using CustomizePlus.Profiles.Data;
using CustomizePlus.Templates.Events;
using CustomizePlus.Templates.Data;
namespace CustomizePlus.UI.Windows.Controls;
public abstract class TemplateComboBase : FilterComboCache<Tuple<Template, string>>, IDisposable
{
private readonly PluginConfiguration _configuration;
private readonly TemplateChanged _templateChanged;
// protected readonly TabSelected TabSelected;
protected float InnerWidth;
protected TemplateComboBase(
Func<IReadOnlyList<Tuple<Template, string>>> generator,
Logger logger,
TemplateChanged templateChanged,
//TabSelected tabSelected,
PluginConfiguration configuration)
: base(generator, logger)
{
_templateChanged = templateChanged;
//TabSelected = tabSelected;
_configuration = configuration;
_templateChanged.Subscribe(OnTemplateChange, TemplateChanged.Priority.TemplateCombo);
}
public bool Incognito
=> _configuration.UISettings.IncognitoMode;
void IDisposable.Dispose()
=> _templateChanged.Unsubscribe(OnTemplateChange);
protected override bool DrawSelectable(int globalIdx, bool selected)
{
var ret = base.DrawSelectable(globalIdx, selected);
var (design, path) = Items[globalIdx];
if (path.Length > 0 && design.Name != path)
{
var start = ImGui.GetItemRectMin();
var pos = start.X + ImGui.CalcTextSize(design.Name).X;
var maxSize = ImGui.GetWindowPos().X + ImGui.GetWindowContentRegionMax().X;
var remainingSpace = maxSize - pos;
var requiredSize = ImGui.CalcTextSize(path).X + ImGui.GetStyle().ItemInnerSpacing.X;
var offset = remainingSpace - requiredSize;
if (ImGui.GetScrollMaxY() == 0)
offset -= ImGui.GetStyle().ItemInnerSpacing.X;
if (offset < ImGui.GetStyle().ItemSpacing.X)
ImGuiUtil.HoverTooltip(path);
else
ImGui.GetWindowDrawList().AddText(start with { X = pos + offset },
ImGui.GetColorU32(ImGuiCol.TextDisabled), path);
}
return ret;
}
protected bool Draw(Template? currentTemplate, string? label, float width)
{
InnerWidth = 400 * ImGuiHelpers.GlobalScale;
CurrentSelectionIdx = Math.Max(Items.IndexOf(p => currentTemplate == p.Item1), 0);
CurrentSelection = Items[CurrentSelectionIdx];
var name = label ?? "Select Template Here...";
var ret = Draw("##template", name, string.Empty, width, ImGui.GetTextLineHeightWithSpacing())
&& CurrentSelection != null;
return ret;
}
protected override string ToString(Tuple<Template, string> obj)
=> obj.Item1.Name.Text;
protected override float GetFilterWidth()
=> InnerWidth - 2 * ImGui.GetStyle().FramePadding.X;
protected override bool IsVisible(int globalIndex, LowerString filter)
{
var (design, path) = Items[globalIndex];
return filter.IsContained(path) || design.Name.Lower.Contains(filter.Lower);
}
private void OnTemplateChange(TemplateChanged.Type type, Template template, object? data = null)
{
switch (type)
{
case TemplateChanged.Type.Created:
case TemplateChanged.Type.Renamed:
Cleanup();
break;
case TemplateChanged.Type.Deleted:
Cleanup();
if (CurrentSelection?.Item1 == template)
{
CurrentSelectionIdx = -1;
CurrentSelection = null;
}
break;
}
}
}
public sealed class TemplateCombo : TemplateComboBase
{
private readonly ProfileManager _profileManager;
public TemplateCombo(
TemplateManager templateManager,
ProfileManager profileManager,
TemplateFileSystem fileSystem,
Logger logger,
TemplateChanged templateChanged,
//TabSelected tabSelected,
PluginConfiguration configuration)
: base(
() => templateManager.Templates
.Select(d => new Tuple<Template, string>(d, fileSystem.FindLeaf(d, out var l) ? l.FullName() : string.Empty))
.OrderBy(d => d.Item2)
.ToList(), logger, templateChanged,/* tabSelected, */configuration)
{
_profileManager = profileManager;
}
public Template? Template
=> CurrentSelection?.Item1;
public void Draw(Profile profile, Template? template, int templateIndex)
{
if (!Draw(template, Incognito ? template?.Incognito : template?.Name, ImGui.GetContentRegionAvail().X))
return;
if (templateIndex >= 0)
_profileManager.ChangeTemplate(profile, templateIndex, CurrentSelection!.Item1);
else
_profileManager.AddTemplate(profile, CurrentSelection!.Item1);
}
}

View File

@@ -0,0 +1,145 @@
using Dalamud.Interface.Windowing;
using Dalamud.Plugin;
using ImGuiNET;
using OtterGui.Raii;
using System;
using System.Numerics;
using SettingsTab = CustomizePlus.UI.Windows.MainWindow.Tabs.SettingsTab;
using CustomizePlus.Core.Services;
using CustomizePlus.UI.Windows.MainWindow.Tabs.Debug;
using CustomizePlus.Configuration.Data;
using CustomizePlus.UI.Windows.MainWindow.Tabs.Templates;
using CustomizePlus.UI.Windows.Controls;
using CustomizePlus.UI.Windows.MainWindow.Tabs.Profiles;
using CustomizePlus.UI.Windows.MainWindow.Tabs;
using CustomizePlus.Templates;
namespace CustomizePlus.UI.Windows.MainWindow;
public class MainWindow : Window, IDisposable
{
private readonly SettingsTab _settingsTab;
private readonly TemplatesTab _templatesTab;
private readonly ProfilesTab _profilesTab;
private readonly MessagesTab _messagesTab;
private readonly IPCTestTab _ipcTestTab;
private readonly StateMonitoringTab _stateMonitoringTab;
private readonly PluginStateBlock _pluginStateBlock;
private readonly TemplateEditorManager _templateEditorManager;
private readonly FantasiaPlusDetectService _cPlusDetectService;
private readonly PluginConfiguration _configuration;
public MainWindow(
DalamudPluginInterface pluginInterface,
SettingsTab settingsTab,
TemplatesTab templatesTab,
ProfilesTab profilesTab,
MessagesTab messagesTab,
IPCTestTab ipcTestTab,
StateMonitoringTab stateMonitoringTab,
PluginStateBlock pluginStateBlock,
TemplateEditorManager templateEditorManager,
PluginConfiguration configuration,
FantasiaPlusDetectService cPlusDetectService
) : base($"Customize+ v{Plugin.Version}###CPlusMainWindow")
{
_settingsTab = settingsTab;
_templatesTab = templatesTab;
_profilesTab = profilesTab;
_messagesTab = messagesTab;
_ipcTestTab = ipcTestTab;
_stateMonitoringTab = stateMonitoringTab;
_pluginStateBlock = pluginStateBlock;
_templateEditorManager = templateEditorManager;
_cPlusDetectService = cPlusDetectService;
_configuration = configuration;
pluginInterface.UiBuilder.DisableGposeUiHide = true;
SizeConstraints = new WindowSizeConstraints()
{
MinimumSize = new Vector2(700, 675),
MaximumSize = ImGui.GetIO().DisplaySize,
};
IsOpen = pluginInterface.IsDevMenuOpen && configuration.DebuggingModeEnabled;
}
public void Dispose()
{
//throw new NotImplementedException();
}
public override void Draw()
{
var yPos = ImGui.GetCursorPosY();
using (var disabled = ImRaii.Disabled(_cPlusDetectService.IsFantasiaPlusInstalled))
{
LockWindowClosureIfNeeded();
if (ImGui.BeginTabBar("##tabs", ImGuiTabBarFlags.None)) //todo: remember last selected tab
{
if (ImGui.BeginTabItem("Settings"))
{
_settingsTab.Draw();
ImGui.EndTabItem();
}
if (ImGui.BeginTabItem("Templates"))
{
_templatesTab.Draw();
ImGui.EndTabItem();
}
if (ImGui.BeginTabItem("Profiles"))
{
_profilesTab.Draw();
ImGui.EndTabItem();
}
//if(_messagesTab.IsVisible)
//{
/*if (ImGui.BeginTabItem("Messages"))
{
_messagesTab.Draw();
ImGui.EndTabItem();
}*/
//}
if (_configuration.DebuggingModeEnabled)
{
if (ImGui.BeginTabItem("IPC Test"))
{
_ipcTestTab.Draw();
ImGui.EndTabItem();
}
if (ImGui.BeginTabItem("State monitoring"))
{
_stateMonitoringTab.Draw();
ImGui.EndTabItem();
}
}
}
}
_pluginStateBlock.Draw(yPos);
}
private void LockWindowClosureIfNeeded()
{
if (_templateEditorManager.IsEditorActive)
{
ShowCloseButton = false;
RespectCloseHotkey = false;
}
else
{
ShowCloseButton = true;
RespectCloseHotkey = true;
}
}
}

View File

@@ -0,0 +1,152 @@
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Services;
using ImGuiNET;
using Newtonsoft.Json;
using OtterGui.Raii;
using System.Linq;
using CustomizePlus.Profiles;
using CustomizePlus.Configuration.Helpers;
using CustomizePlus.Game.Services;
using CustomizePlus.GameData.Services;
namespace CustomizePlus.UI.Windows.MainWindow.Tabs.Debug;
public class IPCTestTab //: IDisposable
{
private readonly IObjectTable _objectTable;
private readonly ProfileManager _profileManager;
private readonly PopupSystem _popupSystem;
private readonly GameObjectService _gameObjectService;
private readonly ObjectManager _objectManager;
private readonly ActorService _actorService;
private readonly ICallGateSubscriber<(int, int)>? _getApiVersion;
private readonly ICallGateSubscriber<string, Character?, object>? _setCharacterProfile;
private readonly ICallGateSubscriber<Character?, string>? _getProfileFromCharacter;
private readonly ICallGateSubscriber<Character?, object>? _revertCharacter;
//private readonly ICallGateSubscriber<string?, string?, object?>? _onProfileUpdate;
private string? _rememberedProfileJson;
private (int, int) _apiVersion;
private string? _targetCharacterName;
public IPCTestTab(
DalamudPluginInterface pluginInterface,
IObjectTable objectTable,
ProfileManager profileManager,
PopupSystem popupSystem,
ObjectManager objectManager,
GameObjectService gameObjectService,
ActorService actorService)
{
_objectTable = objectTable;
_profileManager = profileManager;
_popupSystem = popupSystem;
_objectManager = objectManager;
_gameObjectService = gameObjectService;
_actorService = actorService;
_popupSystem.RegisterPopup("ipc_v4_profile_remembered", "Current profile has been copied into memory");
_popupSystem.RegisterPopup("ipc_get_profile_from_character_remembered", "GetProfileFromCharacter result has been copied into memory");
_popupSystem.RegisterPopup("ipc_set_profile_to_character_done", "SetProfileToCharacter has been called with data from memory");
_popupSystem.RegisterPopup("ipc_revert_done", "Revert has been called");
_getApiVersion = pluginInterface.GetIpcSubscriber<(int, int)>("CustomizePlus.GetApiVersion");
_apiVersion = _getApiVersion.InvokeFunc();
_setCharacterProfile = pluginInterface.GetIpcSubscriber<string, Character?, object>("CustomizePlus.SetProfileToCharacter");
_getProfileFromCharacter = pluginInterface.GetIpcSubscriber<Character?, string>("CustomizePlus.GetProfileFromCharacter");
_revertCharacter = pluginInterface.GetIpcSubscriber<Character?, object>("CustomizePlus.RevertCharacter");
/*_onProfileUpdate = pluginInterface.GetIpcSubscriber<string?, string?, object?>("CustomizePlus.OnProfileUpdate");
_onProfileUpdate.Subscribe(OnProfileUpdate);*/
}
/* public void Dispose()
{
_onProfileUpdate?.Unsubscribe(OnProfileUpdate);
}
private void OnProfileUpdate(string? characterName, string? profileJson)
{
_lastProfileUpdate = DateTime.Now;
_lastProfileUpdateName = characterName;
}
*/
public unsafe void Draw()
{
_objectManager.Update();
if (_targetCharacterName == null)
_targetCharacterName = _gameObjectService.GetCurrentPlayerName();
ImGui.Text($"Version: {_apiVersion.Item1}.{_apiVersion.Item2}");
//ImGui.Text($"Last profile update: {_lastProfileUpdate}, Character: {_lastProfileUpdateName}");
ImGui.Text($"Memory: {(string.IsNullOrWhiteSpace(_rememberedProfileJson) ? "empty" : "has data")}");
ImGui.Text("Character to operate on:");
ImGui.SameLine();
ImGui.InputText("##operateon", ref _targetCharacterName, 128);
if (ImGui.Button("Copy current profile into memory as V3"))
{
var actors = _gameObjectService.FindActorsByName(_targetCharacterName).ToList();
if (actors.Count == 0)
return;
if (!actors[0].Item2.Identifier(_actorService.AwaitedService, out var identifier))
return;
var profile = _profileManager.GetEnabledProfilesByActor(identifier).FirstOrDefault();
if (profile == null)
return;
_rememberedProfileJson = JsonConvert.SerializeObject(V4ProfileToV3Converter.Convert(profile));
_popupSystem.ShowPopup("ipc_v4_profile_remembered");
}
if (ImGui.Button("GetProfileFromCharacter into memory"))
{
var actors = _gameObjectService.FindActorsByName(_targetCharacterName).ToList();
if (actors.Count == 0)
return;
_rememberedProfileJson = _getProfileFromCharacter!.InvokeFunc(FindCharacterByAddress(actors[0].Item2.Address));
_popupSystem.ShowPopup("ipc_get_profile_from_character_remembered");
}
using (var disabled = ImRaii.Disabled(_rememberedProfileJson == null))
{
if (ImGui.Button("SetProfileToCharacter from memory") && _rememberedProfileJson != null)
{
var actors = _gameObjectService.FindActorsByName(_targetCharacterName).ToList();
if (actors.Count == 0)
return;
_setCharacterProfile!.InvokeAction(_rememberedProfileJson, FindCharacterByAddress(actors[0].Item2.Address));
_popupSystem.ShowPopup("ipc_set_profile_to_character_done");
}
}
if (ImGui.Button("RevertCharacter") && _rememberedProfileJson != null)
{
var actors = _gameObjectService.FindActorsByName(_targetCharacterName).ToList();
if (actors.Count == 0)
return;
_revertCharacter!.InvokeAction(FindCharacterByAddress(actors[0].Item2.Address));
_popupSystem.ShowPopup("ipc_revert_done");
}
}
private Character? FindCharacterByAddress(nint address)
{
foreach (var obj in _objectTable)
if (obj.Address == address)
return (Character)obj;
return null;
}
}

View File

@@ -0,0 +1,195 @@
using ImGuiNET;
using System.Linq;
using System;
using CustomizePlus.Armatures.Data;
using CustomizePlus.Profiles;
using CustomizePlus.Armatures.Services;
using CustomizePlus.Templates;
using CustomizePlus.Profiles.Data;
using CustomizePlus.Templates.Data;
using CustomizePlus.GameData.Extensions;
using CustomizePlus.GameData.Services;
namespace CustomizePlus.UI.Windows.MainWindow.Tabs.Debug;
public class StateMonitoringTab
{
private readonly ProfileManager _profileManager;
private readonly TemplateManager _templateManager;
private readonly ArmatureManager _armatureManager;
private readonly ObjectManager _objectManager;
public StateMonitoringTab(
ProfileManager profileManager,
TemplateManager templateManager,
ArmatureManager armatureManager,
ObjectManager objectManager)
{
_profileManager = profileManager;
_templateManager = templateManager;
_armatureManager = armatureManager;
_objectManager = objectManager;
}
public void Draw()
{
var showProfiles = ImGui.CollapsingHeader($"Profiles ({_profileManager.Profiles.Count})###profiles_header");
if (showProfiles)
DrawProfiles();
var showTemplates = ImGui.CollapsingHeader($"Templates ({_templateManager.Templates.Count})###templates_header");
if (showTemplates)
DrawTemplates();
var showArmatures = ImGui.CollapsingHeader($"Armatures ({_armatureManager.Armatures.Count})###armatures_header");
if (showArmatures)
DrawArmatures();
var showObjectManager = ImGui.CollapsingHeader($"Object manager ({_objectManager.Count})###objectmanager_header");
if (showObjectManager)
DrawObjectManager();
}
private void DrawProfiles()
{
foreach (var profile in _profileManager.Profiles.OrderByDescending(x => x.Enabled))
{
DrawSingleProfile("root", profile);
ImGui.Spacing();
ImGui.Spacing();
}
}
private void DrawTemplates()
{
foreach (var template in _templateManager.Templates)
{
DrawSingleTemplate($"root", template);
ImGui.Spacing();
ImGui.Spacing();
}
}
private void DrawArmatures()
{
foreach (var armature in _armatureManager.Armatures)
{
DrawSingleArmature($"root", armature.Value);
ImGui.Spacing();
ImGui.Spacing();
}
}
private void DrawObjectManager()
{
foreach (var kvPair in _objectManager)
{
var show = ImGui.CollapsingHeader($"{kvPair.Key} ({kvPair.Value.Objects.Count} objects)###object-{kvPair.Key}");
if (!show)
continue;
ImGui.Text($"ActorIdentifier");
ImGui.Text($"PlayerName: {kvPair.Key.PlayerName}");
ImGui.Text($"HomeWorld: {kvPair.Key.HomeWorld}");
ImGui.Text($"Retainer: {kvPair.Key.Retainer}");
ImGui.Text($"Kind: {kvPair.Key.Kind}");
ImGui.Text($"Data id: {kvPair.Key.DataId}");
ImGui.Text($"Index: {kvPair.Key.Index.Index}");
ImGui.Text($"Type: {kvPair.Key.Type}");
ImGui.Text($"Special: {kvPair.Key.Special.ToString()}");
ImGui.Text($"ToName: {kvPair.Key.ToName()}");
ImGui.Text($"ToNameWithoutOwnerName: {kvPair.Key.ToNameWithoutOwnerName()}");
ImGui.Spacing();
ImGui.Spacing();
ImGui.Text($"Objects");
ImGui.Text($"Valid: {kvPair.Value.Valid}");
ImGui.Text($"Label: {kvPair.Value.Label}");
ImGui.Text($"Count: {kvPair.Value.Objects.Count}");
foreach (var item in kvPair.Value.Objects)
{
ImGui.Text($"{item}, valid: {item.Valid}");
}
ImGui.Spacing();
ImGui.Spacing();
}
}
private void DrawSingleProfile(string prefix, Profile profile)
{
var show = ImGui.CollapsingHeader($"[{(profile.Enabled ? "E" : "D")}] {profile.Name} on {profile.CharacterName} [{(profile.IsTemporary ? "Temporary" : "Permanent")}]###{prefix}-profile-{profile.UniqueId}");
if (!show)
return;
ImGui.Text($"ID: {profile.UniqueId}");
ImGui.Text($"Enabled: {(profile.Enabled ? "Enabled" : "Disabled")}");
ImGui.Text($"State : {(profile.IsTemporary ? "Temporary" : "Permanent")}");
ImGui.Text($"Lookup: {(profile.LimitLookupToOwnedObjects ? "Limited lookup" : "Global lookup")}");
var showTemplates = ImGui.CollapsingHeader($"Templates###{prefix}-profile-{profile.UniqueId}-templates");
if (showTemplates)
{
foreach (var template in profile.Templates)
{
DrawSingleTemplate($"profile-{profile.UniqueId}", template);
}
}
if (profile.Armatures.Count > 0)
foreach (var armature in profile.Armatures)
DrawSingleArmature($"profile-{profile.UniqueId}", armature);
else
ImGui.Text("No armatures");
}
private void DrawSingleTemplate(string prefix, Template template)
{
var show = ImGui.CollapsingHeader($"{template.Name}###{prefix}-template-{template.UniqueId}");
if (!show)
return;
ImGui.Text($"ID: {template.UniqueId}");
ImGui.Text($"Bones:");
foreach (var kvPair in template.Bones)
{
ImGui.Text($"{kvPair.Key}: p:{kvPair.Value.Translation} | r: {kvPair.Value.Rotation} | s: {kvPair.Value.Scaling}");
}
}
private void DrawSingleArmature(string prefix, Armature armature)
{
var show = ImGui.CollapsingHeader($"{armature} [{(armature.IsBuilt ? "Built" : "Not built")}, {(armature.IsVisible ? "Visible" : "Not visible")}]###{prefix}-armature-{armature.GetHashCode()}");
if (!show)
return;
if (armature.IsBuilt)
{
ImGui.Text($"Total bones: {armature.TotalBoneCount}");
ImGui.Text($"Partial skeletons: {armature.PartialSkeletonCount}");
ImGui.Text($"Root bone: {armature.MainRootBone}");
}
ImGui.Text($"Profile: {armature.Profile.Name} ({armature.Profile.UniqueId})");
ImGui.Text($"Actor: {armature.ActorIdentifier}");
ImGui.Text($"Protection: {(armature.ProtectedUntil >= DateTime.UtcNow ? "Active" : "NOT active")} [{armature.ProtectedUntil} (UTC)]");
//ImGui.Text("Profile:");
//DrawSingleProfile($"armature-{armature.GetHashCode()}", armature.Profile);
ImGui.Text($"Bone template bindings:");
foreach (var kvPair in armature.BoneTemplateBinding)
{
ImGui.Text($"{kvPair.Key} -> {kvPair.Value.Name} ({kvPair.Value.UniqueId})");
}
}
}

View File

@@ -0,0 +1,101 @@
using Dalamud.Interface.Utility;
using Dalamud.Interface;
using ImGuiNET;
using OtterGui;
using System;
using System.Linq;
using OtterGui.Raii;
using System.Numerics;
namespace CustomizePlus.UI.Windows.MainWindow.Tabs;
public static class HeaderDrawer
{
public struct Button
{
public static readonly Button Invisible = new()
{
Visible = false,
Width = 0,
};
public Action? OnClick;
public string Description = string.Empty;
public float Width;
public uint BorderColor;
public uint TextColor;
public FontAwesomeIcon Icon;
public bool Disabled;
public bool Visible;
public Button()
{
Visible = true;
Width = ImGui.GetFrameHeightWithSpacing();
BorderColor = ColorId.HeaderButtons.Value();
TextColor = ColorId.HeaderButtons.Value();
Disabled = false;
}
public readonly void Draw()
{
if (!Visible)
return;
using var color = ImRaii.PushColor(ImGuiCol.Border, BorderColor)
.Push(ImGuiCol.Text, TextColor, TextColor != 0);
if (ImGuiUtil.DrawDisabledButton(Icon.ToIconString(), new Vector2(Width, ImGui.GetFrameHeight()), string.Empty, Disabled, true))
OnClick?.Invoke();
color.Pop();
ImGuiUtil.HoverTooltip(Description);
}
public static Button IncognitoButton(bool current, Action<bool> setter)
=> current
? new Button
{
Description = "Toggle incognito mode off.",
Icon = FontAwesomeIcon.EyeSlash,
OnClick = () => setter(false),
}
: new Button
{
Description = "Toggle incognito mode on.",
Icon = FontAwesomeIcon.Eye,
OnClick = () => setter(true),
};
}
public static void Draw(string text, uint textColor, uint frameColor, int leftButtons, params Button[] buttons)
{
using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero)
.Push(ImGuiStyleVar.FrameRounding, 0)
.Push(ImGuiStyleVar.FrameBorderSize, ImGuiHelpers.GlobalScale);
var leftButtonSize = 0f;
foreach (var button in buttons.Take(leftButtons).Where(b => b.Visible))
{
button.Draw();
ImGui.SameLine();
leftButtonSize += button.Width;
}
var rightButtonSize = buttons.Length > leftButtons ? buttons.Skip(leftButtons).Where(b => b.Visible).Select(b => b.Width).Sum() : 0f;
var midSize = ImGui.GetContentRegionAvail().X - rightButtonSize - ImGuiHelpers.GlobalScale;
style.Pop();
style.Push(ImGuiStyleVar.ButtonTextAlign, new Vector2(0.5f + (rightButtonSize - leftButtonSize) / midSize, 0.5f));
if (textColor != 0)
ImGuiUtil.DrawTextButton(text, new Vector2(midSize, ImGui.GetFrameHeight()), frameColor, textColor);
else
ImGuiUtil.DrawTextButton(text, new Vector2(midSize, ImGui.GetFrameHeight()), frameColor);
style.Pop();
style.Push(ImGuiStyleVar.FrameBorderSize, ImGuiHelpers.GlobalScale);
foreach (var button in buttons.Skip(leftButtons).Where(b => b.Visible))
{
ImGui.SameLine();
button.Draw();
}
}
}

View File

@@ -0,0 +1,16 @@
using OtterGui.Classes;
namespace CustomizePlus.UI.Windows.MainWindow.Tabs;
public class MessagesTab
{
private readonly MessageService _messages;
public MessagesTab(MessageService messages)
=> _messages = messages;
public bool IsVisible
=> _messages.Count > 0;
public void Draw() => _messages.Draw();
}

View File

@@ -0,0 +1,237 @@
using Dalamud.Interface;
using Dalamud.Plugin.Services;
using ImGuiNET;
using OtterGui.Classes;
using OtterGui.FileSystem.Selector;
using OtterGui.Filesystem;
using OtterGui.Log;
using OtterGui;
using System;
using static CustomizePlus.UI.Windows.MainWindow.Tabs.Profiles.ProfileFileSystemSelector;
using OtterGui.Raii;
using System.Numerics;
using System.Reflection;
using CustomizePlus.Profiles;
using CustomizePlus.Configuration.Data;
using CustomizePlus.Profiles.Data;
using CustomizePlus.Game.Services;
using CustomizePlus.Profiles.Events;
namespace CustomizePlus.UI.Windows.MainWindow.Tabs.Profiles;
public class ProfileFileSystemSelector : FileSystemSelector<Profile, ProfileState>
{
private readonly PluginConfiguration _configuration;
private readonly ProfileManager _profileManager;
private readonly ProfileChanged _event;
private readonly GameObjectService _gameObjectService;
private Profile? _cloneProfile;
private string _newName = string.Empty;
public bool IncognitoMode
{
get => _configuration.UISettings.IncognitoMode;
set
{
_configuration.UISettings.IncognitoMode = value;
_configuration.Save();
}
}
public struct ProfileState
{
public ColorId Color;
}
public ProfileFileSystemSelector(
ProfileFileSystem fileSystem,
IKeyState keyState,
Logger logger,
PluginConfiguration configuration,
ProfileManager profileManager,
ProfileChanged @event,
GameObjectService gameObjectService)
: base(fileSystem, keyState, logger, allowMultipleSelection: true)
{
_configuration = configuration;
_profileManager = profileManager;
_event = @event;
_gameObjectService = gameObjectService;
_event.Subscribe(OnProfileChange, ProfileChanged.Priority.ProfileFileSystemSelector);
AddButton(NewButton, 0);
AddButton(CloneButton, 20);
AddButton(DeleteButton, 1000);
SetFilterTooltip();
}
public void Dispose()
{
base.Dispose();
_event.Unsubscribe(OnProfileChange);
}
protected override uint ExpandedFolderColor
=> ColorId.FolderExpanded.Value();
protected override uint CollapsedFolderColor
=> ColorId.FolderCollapsed.Value();
protected override uint FolderLineColor
=> ColorId.FolderLine.Value();
protected override bool FoldersDefaultOpen
=> _configuration.UISettings.FoldersDefaultOpen;
protected override void DrawLeafName(FileSystem<Profile>.Leaf leaf, in ProfileState state, bool selected)
{
var flag = selected ? ImGuiTreeNodeFlags.Selected | LeafFlags : LeafFlags;
var name = IncognitoMode ? leaf.Value.Incognito : leaf.Value.Name.Text;
using var color = ImRaii.PushColor(ImGuiCol.Text, state.Color.Value());
using var _ = ImRaii.TreeNode(name, flag);
}
protected override void DrawPopups()
{
DrawNewProfilePopup();
}
private void DrawNewProfilePopup()
{
if (!ImGuiUtil.OpenNameField("##NewProfile", ref _newName))
return;
if (_cloneProfile != null)
{
_profileManager.Clone(_cloneProfile, _newName, true);
_cloneProfile = null;
}
else
{
_profileManager.Create(_newName, true);
}
_newName = string.Empty;
}
private void OnProfileChange(ProfileChanged.Type type, Profile? profile, object? arg3 = null)
{
switch (type)
{
case ProfileChanged.Type.Created:
case ProfileChanged.Type.Deleted:
case ProfileChanged.Type.Renamed:
case ProfileChanged.Type.Toggled:
case ProfileChanged.Type.ChangedCharacterName:
case ProfileChanged.Type.ReloadedAll:
SetFilterDirty();
break;
}
}
private void NewButton(Vector2 size)
{
if (!ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Plus.ToIconString(), size, "Create a new profile with default configuration.", false,
true))
return;
ImGui.OpenPopup("##NewProfile");
}
private void CloneButton(Vector2 size)
{
var tt = SelectedLeaf == null
? "No profile selected."
: "Clone the currently selected profile to a duplicate";
if (!ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Clone.ToIconString(), size, tt, SelectedLeaf == null, true))
return;
_cloneProfile = Selected!;
ImGui.OpenPopup("##NewProfile");
}
private void DeleteButton(Vector2 size)
=> DeleteSelectionButton(size, _configuration.UISettings.DeleteTemplateModifier, "profile", "profiles", _profileManager.Delete);
#region Filters
private const StringComparison IgnoreCase = StringComparison.OrdinalIgnoreCase;
private LowerString _filter = LowerString.Empty;
private int _filterType = -1;
private void SetFilterTooltip()
{
FilterTooltip = "Filter profiles for those where their full paths or names contain the given substring.\n"
+ "Enter n:[string] to filter only for profile names and no paths.";
}
/// <summary> Appropriately identify and set the string filter and its type. </summary>
protected override bool ChangeFilter(string filterValue)
{
(_filter, _filterType) = filterValue.Length switch
{
0 => (LowerString.Empty, -1),
> 1 when filterValue[1] == ':' =>
filterValue[0] switch
{
'n' => filterValue.Length == 2 ? (LowerString.Empty, -1) : (new LowerString(filterValue[2..]), 1),
'N' => filterValue.Length == 2 ? (LowerString.Empty, -1) : (new LowerString(filterValue[2..]), 1),
_ => (new LowerString(filterValue), 0),
},
_ => (new LowerString(filterValue), 0),
};
return true;
}
/// <summary>
/// The overwritten filter method also computes the state.
/// Folders have default state and are filtered out on the direct string instead of the other options.
/// If any filter is set, they should be hidden by default unless their children are visible,
/// or they contain the path search string.
/// </summary>
protected override bool ApplyFiltersAndState(FileSystem<Profile>.IPath path, out ProfileState state)
{
if (path is ProfileFileSystem.Folder f)
{
state = default;
return FilterValue.Length > 0 && !f.FullName().Contains(FilterValue, IgnoreCase);
}
return ApplyFiltersAndState((ProfileFileSystem.Leaf)path, out state);
}
/// <summary> Apply the string filters. </summary>
private bool ApplyStringFilters(ProfileFileSystem.Leaf leaf, Profile profile)
{
return _filterType switch
{
-1 => false,
0 => !(_filter.IsContained(leaf.FullName()) || profile.Name.Contains(_filter)),
1 => !profile.Name.Contains(_filter),
_ => false, // Should never happen
};
}
/// <summary> Combined wrapper for handling all filters and setting state. </summary>
private bool ApplyFiltersAndState(ProfileFileSystem.Leaf leaf, out ProfileState state)
{
//Do not display temporary profiles;
if (leaf.Value.IsTemporary)
{
state.Color = ColorId.DisabledProfile;
return false;
}
if (leaf.Value.Enabled)
state.Color = leaf.Value.CharacterName == _gameObjectService.GetCurrentPlayerName() ? ColorId.LocalCharacterEnabledProfile : ColorId.EnabledProfile;
else
state.Color = leaf.Value.CharacterName == _gameObjectService.GetCurrentPlayerName() ? ColorId.LocalCharacterDisabledProfile : ColorId.DisabledProfile;
return ApplyStringFilters(leaf, leaf.Value);
}
#endregion
}

View File

@@ -0,0 +1,307 @@
using Dalamud.Interface;
using Dalamud.Interface.Utility;
using ImGuiNET;
using OtterGui;
using OtterGui.Raii;
using System;
using System.Linq;
using System.Numerics;
using CustomizePlus.Profiles;
using CustomizePlus.Game.Services;
using CustomizePlus.Configuration.Data;
using CustomizePlus.Profiles.Data;
using CustomizePlus.UI.Windows.Controls;
namespace CustomizePlus.UI.Windows.MainWindow.Tabs.Profiles;
public class ProfilePanel
{
private readonly ProfileFileSystemSelector _selector;
private readonly ProfileManager _manager;
private readonly PluginConfiguration _configuration;
private readonly TemplateCombo _templateCombo;
private readonly GameStateService _gameStateService;
private string? _newName;
private string? _newCharacterName;
private Profile? _changedProfile;
private Action? _endAction;
private int _dragIndex = -1;
private string SelectionName
=> _selector.Selected == null ? "No Selection" : _selector.IncognitoMode ? _selector.Selected.Incognito : _selector.Selected.Name.Text;
public ProfilePanel(
ProfileFileSystemSelector selector,
ProfileManager manager,
PluginConfiguration configuration,
TemplateCombo templateCombo,
GameStateService gameStateService)
{
_selector = selector;
_manager = manager;
_configuration = configuration;
_templateCombo = templateCombo;
_gameStateService = gameStateService;
}
public void Draw()
{
using var group = ImRaii.Group();
if (_selector.SelectedPaths.Count > 1)
{
DrawMultiSelection();
}
else
{
DrawHeader();
DrawPanel();
}
}
private HeaderDrawer.Button LockButton()
=> _selector.Selected == null
? HeaderDrawer.Button.Invisible
: _selector.Selected.IsWriteProtected
? new HeaderDrawer.Button
{
Description = "Make this profile editable.",
Icon = FontAwesomeIcon.Lock,
OnClick = () => _manager.SetWriteProtection(_selector.Selected!, false)
}
: new HeaderDrawer.Button
{
Description = "Write-protect this profile.",
Icon = FontAwesomeIcon.LockOpen,
OnClick = () => _manager.SetWriteProtection(_selector.Selected!, true)
};
private void DrawHeader()
=> HeaderDrawer.Draw(SelectionName, 0, ImGui.GetColorU32(ImGuiCol.FrameBg),
0, LockButton(),
HeaderDrawer.Button.IncognitoButton(_selector.IncognitoMode, v => _selector.IncognitoMode = v));
private void DrawMultiSelection()
{
if (_selector.SelectedPaths.Count == 0)
return;
var sizeType = ImGui.GetFrameHeight();
var availableSizePercent = (ImGui.GetContentRegionAvail().X - sizeType - 4 * ImGui.GetStyle().CellPadding.X) / 100;
var sizeMods = availableSizePercent * 35;
var sizeFolders = availableSizePercent * 65;
ImGui.NewLine();
ImGui.TextUnformatted("Currently Selected Profiles");
ImGui.Separator();
using var table = ImRaii.Table("profile", 3, ImGuiTableFlags.RowBg);
ImGui.TableSetupColumn("btn", ImGuiTableColumnFlags.WidthFixed, sizeType);
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthFixed, sizeMods);
ImGui.TableSetupColumn("path", ImGuiTableColumnFlags.WidthFixed, sizeFolders);
var i = 0;
foreach (var (fullName, path) in _selector.SelectedPaths.Select(p => (p.FullName(), p))
.OrderBy(p => p.Item1, StringComparer.OrdinalIgnoreCase))
{
using var id = ImRaii.PushId(i++);
ImGui.TableNextColumn();
var icon = (path is ProfileFileSystem.Leaf ? FontAwesomeIcon.FileCircleMinus : FontAwesomeIcon.FolderMinus).ToIconString();
if (ImGuiUtil.DrawDisabledButton(icon, new Vector2(sizeType), "Remove from selection.", false, true))
_selector.RemovePathFromMultiSelection(path);
ImGui.TableNextColumn();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(path is ProfileFileSystem.Leaf l ? _selector.IncognitoMode ? l.Value.Incognito : l.Value.Name.Text : string.Empty);
ImGui.TableNextColumn();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(_selector.IncognitoMode ? "Incognito is active" : fullName);
}
}
private void DrawPanel()
{
using var child = ImRaii.Child("##Panel", -Vector2.One, true);
if (!child || _selector.Selected == null)
return;
DrawEnabledSetting();
using (var disabled = ImRaii.Disabled(_selector.Selected?.IsWriteProtected ?? true))
{
DrawBasicSettings();
DrawTemplateArea();
}
}
private void DrawEnabledSetting()
{
var spacing = ImGui.GetStyle().ItemInnerSpacing with { X = ImGui.GetStyle().ItemSpacing.X, Y = ImGui.GetStyle().ItemSpacing.Y };
using (var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, spacing))
{
var enabled = _selector.Selected?.Enabled ?? false;
if (ImGui.Checkbox("##Enabled", ref enabled))
_manager.SetEnabled(_selector.Selected!, enabled);
ImGuiUtil.LabeledHelpMarker("Enabled",
"Whether the templates in this profile should be applied at all. Only one profile can be enabled for a character at the same time.");
ImGui.SameLine();
var isDefault = _manager.DefaultProfile == _selector.Selected;
if (ImGui.Checkbox("##DefaultProfile", ref isDefault))
_manager.SetDefaultProfile(isDefault ? _selector.Selected! : null);
ImGuiUtil.LabeledHelpMarker("Default profile (Players and Retainers only)",
"Whether the templates in this profile are applied to all players and retainers without a specific profile. Only one profile can be default at the same time.");
}
}
private void DrawBasicSettings()
{
using (var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)))
{
using (var table = ImRaii.Table("BasicSettings", 2))
{
ImGui.TableSetupColumn("BasicCol1", ImGuiTableColumnFlags.WidthFixed, ImGui.CalcTextSize("lorem ipsum dolor").X);
ImGui.TableSetupColumn("BasicCol2", ImGuiTableColumnFlags.WidthStretch);
ImGuiUtil.DrawFrameColumn("Profile Name");
ImGui.TableNextColumn();
var width = new Vector2(ImGui.GetContentRegionAvail().X, 0);
var name = _newName ?? _selector.Selected!.Name;
ImGui.SetNextItemWidth(width.X);
if (!_selector.IncognitoMode)
{
if (ImGui.InputText("##ProfileName", ref name, 128))
{
_newName = name;
_changedProfile = _selector.Selected;
}
if (ImGui.IsItemDeactivatedAfterEdit() && _changedProfile != null)
{
_manager.Rename(_changedProfile, name);
_newName = null;
_changedProfile = null;
}
}
else
ImGui.TextUnformatted(_selector.Selected!.Incognito);
ImGui.TableNextRow();
ImGuiUtil.DrawFrameColumn("Character Name");
ImGui.TableNextColumn();
width = new Vector2(ImGui.GetContentRegionAvail().X - ImGui.CalcTextSize("Limit to my creatures").X - 68, 0);
name = _newCharacterName ?? _selector.Selected!.CharacterName;
ImGui.SetNextItemWidth(width.X);
using (var disabled = ImRaii.Disabled(_gameStateService.GameInPosingMode()))
{
if (!_selector.IncognitoMode)
{
if (ImGui.InputText("##CharacterName", ref name, 128))
{
_newCharacterName = name;
_changedProfile = _selector.Selected;
}
if (ImGui.IsItemDeactivatedAfterEdit() && _changedProfile != null)
{
_manager.ChangeCharacterName(_changedProfile, name);
_newCharacterName = null;
_changedProfile = null;
}
}
else
ImGui.TextUnformatted("Incognito active");
ImGui.SameLine();
var enabled = _selector.Selected?.LimitLookupToOwnedObjects ?? false;
if (ImGui.Checkbox("##LimitLookupToOwnedObjects", ref enabled))
_manager.SetLimitLookupToOwned(_selector.Selected!, enabled);
ImGuiUtil.LabeledHelpMarker("Limit to my creatures",
"When enabled limits the character search to only your own summons, mounts and minions.\nUseful when there is possibility there will be another character with that name owned by another player.\n* For battle chocobo use \"Chocobo\" as character name.\n** If you are changing root scale for mount and want to keep your scale make sure your own scale is set to anything other than default value.");
}
}
}
}
private void DrawTemplateArea()
{
using var disabled = ImRaii.Disabled(_gameStateService.GameInPosingMode());
using var table = ImRaii.Table("SetTable", 3, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollX | ImGuiTableFlags.ScrollY);
if (!table)
return;
ImGui.TableSetupColumn("##del", ImGuiTableColumnFlags.WidthFixed, ImGui.GetFrameHeight());
ImGui.TableSetupColumn("##Index", ImGuiTableColumnFlags.WidthFixed, 30 * ImGuiHelpers.GlobalScale);
ImGui.TableSetupColumn("Template", ImGuiTableColumnFlags.WidthFixed, 220 * ImGuiHelpers.GlobalScale);
ImGui.TableHeadersRow();
//warn: .ToList() might be performance critical at some point
//the copying via ToList is done because manipulations with .Templates list result in "Collection was modified" exception here
foreach (var (template, idx) in _selector.Selected!.Templates.WithIndex().ToList())
{
using var id = ImRaii.PushId(idx);
ImGui.TableNextColumn();
var keyValid = _configuration.UISettings.DeleteTemplateModifier.IsActive();
var tt = keyValid
? "Remove this template from the profile."
: $"Remove this template from the profile.\nHold {_configuration.UISettings.DeleteTemplateModifier} to remove.";
if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Trash.ToIconString(), new Vector2(ImGui.GetFrameHeight()), tt, !keyValid, true))
_endAction = () => _manager.DeleteTemplate(_selector.Selected!, idx);
ImGui.TableNextColumn();
ImGui.Selectable($"#{idx + 1:D2}");
DrawDragDrop(_selector.Selected!, idx);
ImGui.TableNextColumn();
_templateCombo.Draw(_selector.Selected!, template, idx);
DrawDragDrop(_selector.Selected!, idx);
}
ImGui.TableNextColumn();
ImGui.TableNextColumn();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("New");
ImGui.TableNextColumn();
_templateCombo.Draw(_selector.Selected!, null, -1);
ImGui.TableNextRow();
_endAction?.Invoke();
_endAction = null;
}
private void DrawDragDrop(Profile profile, int index)
{
const string dragDropLabel = "TemplateDragDrop";
using (var target = ImRaii.DragDropTarget())
{
if (target.Success && ImGuiUtil.IsDropping(dragDropLabel))
{
if (_dragIndex >= 0)
{
var idx = _dragIndex;
_endAction = () => _manager.MoveTemplate(profile, idx, index);
}
_dragIndex = -1;
}
}
using (var source = ImRaii.DragDropSource())
{
if (source)
{
ImGui.TextUnformatted($"Moving template #{index + 1:D2}...");
if (ImGui.SetDragDropPayload(dragDropLabel, nint.Zero, 0))
{
_dragIndex = index;
}
}
}
}
}

View File

@@ -0,0 +1,23 @@
using Dalamud.Interface.Utility;
using ImGuiNET;
namespace CustomizePlus.UI.Windows.MainWindow.Tabs.Profiles;
public class ProfilesTab
{
private readonly ProfileFileSystemSelector _selector;
private readonly ProfilePanel _panel;
public ProfilesTab(ProfileFileSystemSelector selector, ProfilePanel panel)
{
_selector = selector;
_panel = panel;
}
public void Draw()
{
_selector.Draw(200f * ImGuiHelpers.GlobalScale);
ImGui.SameLine();
_panel.Draw();
}
}

View File

@@ -0,0 +1,223 @@
//using CustomizePlus.UI.Windows.Debug;
using Dalamud.Interface;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Utility;
using ImGuiNET;
using OtterGui.Classes;
using OtterGui;
using OtterGui.Raii;
using OtterGui.Widgets;
using System.Diagnostics;
using System.Numerics;
using CustomizePlus.Core.Services;
using CustomizePlus.Configuration.Data;
using CustomizePlus.Profiles;
using CustomizePlus.Templates;
using CustomizePlus.Core.Helpers;
namespace CustomizePlus.UI.Windows.MainWindow.Tabs;
public class SettingsTab
{
private const uint DiscordColor = 0xFFDA8972;
private readonly PluginConfiguration _configuration;
private readonly TemplateManager _templateManager;
private readonly ProfileManager _profileManager;
private readonly HookingService _hookingService;
private readonly SaveService _saveService;
private readonly TemplateEditorManager _templateEditorManager;
private readonly CPlusChangeLog _changeLog;
private readonly MessageService _messageService;
public SettingsTab(
PluginConfiguration configuration,
TemplateManager templateManager,
ProfileManager profileManager,
HookingService hookingService,
SaveService saveService,
TemplateEditorManager templateEditorManager,
CPlusChangeLog changeLog,
MessageService messageService)
{
_configuration = configuration;
_templateManager = templateManager;
_profileManager = profileManager;
_hookingService = hookingService;
_saveService = saveService;
_templateEditorManager = templateEditorManager;
_changeLog = changeLog;
_messageService = messageService;
}
public void Draw()
{
using var child = ImRaii.Child("MainWindowChild");
if (!child)
return;
DrawGeneralSettings();
ImGui.NewLine();
ImGui.NewLine();
using (var child2 = ImRaii.Child("SettingsChild"))
{
DrawInterface();
DrawAdvancedSettings();
}
DrawSupportButtons();
}
#region General Settings
// General Settings
private void DrawGeneralSettings()
{
DrawPluginEnabledCheckbox();
}
private void DrawPluginEnabledCheckbox()
{
using (var disabled = ImRaii.Disabled(_templateEditorManager.IsEditorActive))
{
var isChecked = _configuration.PluginEnabled;
//users doesn't really need to know what exactly this checkbox does so we just tell them it toggles all profiles
if (CtrlHelper.CheckboxWithTextAndHelp("##pluginenabled", "Enable Customize+",
"Globally enables or disables all plugin functionality.", ref isChecked))
{
_configuration.PluginEnabled = isChecked;
_configuration.Save();
_hookingService.ReloadHooks();
}
}
}
#endregion
#region Interface Settings
private void DrawInterface()
{
var isShouldDraw = ImGui.CollapsingHeader("Interface");
if (!isShouldDraw)
return;
DrawHideWindowInCutscene();
DrawFoldersDefaultOpen();
if (Widget.DoubleModifierSelector("Template Deletion Modifier",
"A modifier you need to hold while clicking the Delete Template button for it to take effect.", 100 * ImGuiHelpers.GlobalScale,
_configuration.UISettings.DeleteTemplateModifier, v => _configuration.UISettings.DeleteTemplateModifier = v))
_configuration.Save();
}
private void DrawHideWindowInCutscene()
{
var isChecked = _configuration.UISettings.HideWindowInCutscene;
if (CtrlHelper.CheckboxWithTextAndHelp("##hidewindowincutscene", "Hide plugin windows in cutscenes",
"Controls whether any Fantasia+ windows are hidden during cutscenes or not.", ref isChecked))
{
_configuration.UISettings.HideWindowInCutscene = isChecked;
_configuration.Save();
}
}
private void DrawFoldersDefaultOpen()
{
var isChecked = _configuration.UISettings.FoldersDefaultOpen;
if (CtrlHelper.CheckboxWithTextAndHelp("##foldersdefaultopen", "Open all folders by default",
"Controls whether folders in template and profile lists are open by default or not.", ref isChecked))
{
_configuration.UISettings.FoldersDefaultOpen = isChecked;
_configuration.Save();
}
}
#endregion
#region Advanced Settings
// Advanced Settings
private void DrawAdvancedSettings()
{
var isShouldDraw = ImGui.CollapsingHeader("Advanced");
if (!isShouldDraw)
return;
ImGui.NewLine();
CtrlHelper.LabelWithIcon(FontAwesomeIcon.ExclamationTriangle,
"These are advanced settings. Enable them at your own risk.");
ImGui.NewLine();
DrawEnableRootPositionCheckbox();
DrawDebugModeCheckbox();
}
private void DrawEnableRootPositionCheckbox()
{
var isChecked = _configuration.EditorConfiguration.RootPositionEditingEnabled;
if (CtrlHelper.CheckboxWithTextAndHelp("##rootpos", "Root editing",
"Enables ability to edit the root bones.", ref isChecked))
{
_configuration.EditorConfiguration.RootPositionEditingEnabled = isChecked;
_configuration.Save();
}
}
private void DrawDebugModeCheckbox()
{
var isChecked = _configuration.DebuggingModeEnabled;
if (CtrlHelper.CheckboxWithTextAndHelp("##debugmode", "Debug mode",
"Enables debug mode", ref isChecked))
{
_configuration.DebuggingModeEnabled = isChecked;
_configuration.Save();
}
}
#endregion
#region Support Area
private void DrawSupportButtons()
{
var width = ImGui.CalcTextSize("Join Discord for Support").X + ImGui.GetStyle().FramePadding.X * 2;
var xPos = ImGui.GetWindowWidth() - width;
// Respect the scroll bar width.
if (ImGui.GetScrollMaxY() > 0)
xPos -= ImGui.GetStyle().ScrollbarSize + ImGui.GetStyle().FramePadding.X;
ImGui.SetCursorPos(new Vector2(xPos, 0));
DrawDiscordButton(width);
ImGui.SetCursorPos(new Vector2(xPos, 1 * ImGui.GetFrameHeightWithSpacing()));
if (ImGui.Button("Show update history", new Vector2(width, 0)))
_changeLog.Changelog.ForceOpen = true;
}
/// <summary> Draw a button to open the official discord server. </summary>
private void DrawDiscordButton(float width)
{
const string address = @"https://discord.gg/KvGJCCnG8t";
using var color = ImRaii.PushColor(ImGuiCol.Button, DiscordColor);
if (ImGui.Button("Join Discord for Support", new Vector2(width, 0)))
try
{
var process = new ProcessStartInfo(address)
{
UseShellExecute = true,
};
Process.Start(process);
}
catch
{
_messageService.NotificationMessage($"Unable to open Discord at {address}.", NotificationType.Error, false);
}
ImGuiUtil.HoverTooltip($"Open {address}");
}
#endregion
}

View File

@@ -0,0 +1,539 @@
using Dalamud.Interface.Components;
using Dalamud.Interface;
using ImGuiNET;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using Dalamud.Interface.Utility;
using OtterGui;
using OtterGui.Raii;
using CustomizePlus.Core.Data;
using CustomizePlus.Armatures.Data;
using CustomizePlus.Configuration.Data;
using CustomizePlus.Core.Helpers;
using CustomizePlus.Templates;
using CustomizePlus.Game.Services;
using CustomizePlus.Templates.Data;
namespace CustomizePlus.UI.Windows.MainWindow.Tabs.Templates;
public class BoneEditorPanel
{
private readonly TemplateFileSystemSelector _templateFileSystemSelector;
private readonly TemplateEditorManager _editorManager;
private readonly PluginConfiguration _configuration;
private readonly GameObjectService _gameObjectService;
private BoneAttribute _editingAttribute;
private int _precision;
private bool _isShowLiveBones;
private bool _isMirrorModeEnabled;
public bool HasChanges => _editorManager.HasChanges;
public bool IsEditorActive => _editorManager.IsEditorActive;
public bool IsEditorPaused => _editorManager.IsEditorPaused;
/// <summary>
/// Was character with name from CharacterName found in the object table or not
/// </summary>
public bool IsCharacterFound { get; private set; }
public string CharacterName { get; private set; }
private ModelBone? _changedBone;
private string? _changedBoneName;
private BoneTransform? _changedBoneTransform;
private string? _newCharacterName;
private Dictionary<BoneData.BoneFamily, bool> _groupExpandedState = new();
private bool _openSavePopup;
private bool _isUnlocked = false;
public BoneEditorPanel(
TemplateFileSystemSelector templateFileSystemSelector,
TemplateEditorManager editorManager,
PluginConfiguration configuration,
GameObjectService gameObjectService)
{
_templateFileSystemSelector = templateFileSystemSelector;
_editorManager = editorManager;
_configuration = configuration;
_gameObjectService = gameObjectService;
_isShowLiveBones = configuration.EditorConfiguration.ShowLiveBones;
_isMirrorModeEnabled = configuration.EditorConfiguration.BoneMirroringEnabled;
_precision = configuration.EditorConfiguration.EditorValuesPrecision;
_editingAttribute = configuration.EditorConfiguration.EditorMode;
CharacterName = configuration.EditorConfiguration.PreviewCharacterName!;
}
public bool EnableEditor(Template template)
{
if (_editorManager.EnableEditor(template, CharacterName))
{
_editorManager.EditorProfile.LimitLookupToOwnedObjects = _configuration.EditorConfiguration.LimitLookupToOwnedObjects;
return true;
}
return false;
}
public bool DisableEditor()
{
if (!_editorManager.HasChanges)
return _editorManager.DisableEditor();
if (_editorManager.HasChanges && !IsEditorActive)
throw new Exception("Invalid state in BoneEditorPanel: has changes but editor is not active");
_openSavePopup = true;
return false;
}
public void Draw()
{
IsCharacterFound = _gameObjectService.FindActorsByName(CharacterName).Count() > 0;
_isUnlocked = IsCharacterFound && IsEditorActive && !IsEditorPaused;
if (string.IsNullOrWhiteSpace(CharacterName))
{
CharacterName = _gameObjectService.GetCurrentPlayerName();
_editorManager.ChangeEditorCharacter(CharacterName);
_configuration.EditorConfiguration.PreviewCharacterName = CharacterName;
_configuration.Save();
}
DrawEditorConfirmationPopup();
ImGui.Separator();
using (var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)))
{
using (var table = ImRaii.Table("BasicSettings", 2))
{
ImGui.TableSetupColumn("BasicCol1", ImGuiTableColumnFlags.WidthFixed, ImGui.CalcTextSize("Show editor preview on").X);
ImGui.TableSetupColumn("BasicCol2", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableNextRow();
ImGuiUtil.DrawFrameColumn("Show editor preview on");
ImGui.TableNextColumn();
var width = new Vector2(ImGui.GetContentRegionAvail().X - ImGui.CalcTextSize("Limit to my creatures").X - 68, 0);
var name = _newCharacterName ?? CharacterName;
ImGui.SetNextItemWidth(width.X);
using (var disabled = ImRaii.Disabled(!IsEditorActive || IsEditorPaused))
{
if (!_templateFileSystemSelector.IncognitoMode)
{
if (ImGui.InputText("##PreviewCharacterName", ref name, 128))
{
_newCharacterName = name;
}
if (ImGui.IsItemDeactivatedAfterEdit())
{
if (_newCharacterName == "")
_newCharacterName = _gameObjectService.GetCurrentPlayerName();
CharacterName = _newCharacterName!;
_editorManager.ChangeEditorCharacter(CharacterName);
_configuration.EditorConfiguration.PreviewCharacterName = CharacterName;
_configuration.Save();
_newCharacterName = null;
}
}
else
ImGui.TextUnformatted("Incognito active");
ImGui.SameLine();
var enabled = _editorManager.EditorProfile.LimitLookupToOwnedObjects;
if (ImGui.Checkbox("##LimitLookupToOwnedObjects", ref enabled))
{
_editorManager.EditorProfile.LimitLookupToOwnedObjects = enabled;
_configuration.EditorConfiguration.LimitLookupToOwnedObjects = enabled;
_configuration.Save();
}
ImGuiUtil.LabeledHelpMarker("Limit to my creatures",
"When enabled limits the character search to only your own summons, mounts and minions.\nUseful when there is possibility there will be another character with that name owned by another player.\n* For battle chocobo use \"Chocobo\" as character name.\n** If you are changing root scale for mount and want to keep your scale make sure your own scale is set to anything other than default value.");
}
}
using (var table = ImRaii.Table("BoneEditorMenu", 2))
{
ImGui.TableSetupColumn("Attributes", ImGuiTableColumnFlags.WidthFixed);
ImGui.TableSetupColumn("Space", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableNextRow();
ImGui.TableNextColumn();
var modeChanged = false;
if (ImGui.RadioButton("Position", _editingAttribute == BoneAttribute.Position))
{
_editingAttribute = BoneAttribute.Position;
modeChanged = true;
}
CtrlHelper.AddHoverText($"May have unintended effects. Edit at your own risk!");
ImGui.SameLine();
if (ImGui.RadioButton("Rotation", _editingAttribute == BoneAttribute.Rotation))
{
_editingAttribute = BoneAttribute.Rotation;
modeChanged = true;
}
CtrlHelper.AddHoverText($"May have unintended effects. Edit at your own risk!");
ImGui.SameLine();
if (ImGui.RadioButton("Scale", _editingAttribute == BoneAttribute.Scale))
{
_editingAttribute = BoneAttribute.Scale;
modeChanged = true;
}
if (modeChanged)
{
_configuration.EditorConfiguration.EditorMode = _editingAttribute;
_configuration.Save();
}
using (var disabled = ImRaii.Disabled(!_isUnlocked))
{
ImGui.SameLine();
if (CtrlHelper.Checkbox("Show Live Bones", ref _isShowLiveBones))
{
_configuration.EditorConfiguration.ShowLiveBones = _isShowLiveBones;
_configuration.Save();
}
CtrlHelper.AddHoverText($"If selected, present for editing all bones found in the game data,\nelse show only bones for which the profile already contains edits.");
ImGui.SameLine();
ImGui.BeginDisabled(!_isShowLiveBones);
if (CtrlHelper.Checkbox("Mirror Mode", ref _isMirrorModeEnabled))
{
_configuration.EditorConfiguration.BoneMirroringEnabled = _isMirrorModeEnabled;
_configuration.Save();
}
CtrlHelper.AddHoverText($"Bone changes will be reflected from left to right and vice versa");
ImGui.EndDisabled();
}
ImGui.TableNextColumn();
if (ImGui.SliderInt("##Precision", ref _precision, 0, 6, $"{_precision} Place{(_precision == 1 ? "" : "s")}"))
{
_configuration.EditorConfiguration.EditorValuesPrecision = _precision;
_configuration.Save();
}
CtrlHelper.AddHoverText("Level of precision to display while editing values");
}
ImGui.Separator();
using (var table = ImRaii.Table("BoneEditorContents", 6, ImGuiTableFlags.BordersOuterH | ImGuiTableFlags.BordersV | ImGuiTableFlags.ScrollY))
{
if (!table)
return;
var col1Label = _editingAttribute == BoneAttribute.Rotation ? "Roll" : "X";
var col2Label = _editingAttribute == BoneAttribute.Rotation ? "Pitch" : "Y";
var col3Label = _editingAttribute == BoneAttribute.Rotation ? "Yaw" : "Z";
var col4Label = _editingAttribute == BoneAttribute.Scale ? "All" : "N/A";
ImGui.TableSetupColumn("Bones", ImGuiTableColumnFlags.NoReorder | ImGuiTableColumnFlags.WidthFixed, 3 * CtrlHelper.IconButtonWidth);
ImGui.TableSetupColumn($"{col1Label}", ImGuiTableColumnFlags.NoReorder | ImGuiTableColumnFlags.WidthStretch);
ImGui.TableSetupColumn($"{col2Label}", ImGuiTableColumnFlags.NoReorder | ImGuiTableColumnFlags.WidthStretch);
ImGui.TableSetupColumn($"{col3Label}", ImGuiTableColumnFlags.NoReorder | ImGuiTableColumnFlags.WidthStretch);
ImGui.TableSetupColumn($"{col4Label}", ImGuiTableColumnFlags.NoReorder | ImGuiTableColumnFlags.WidthStretch);
ImGui.TableSetColumnEnabled(4, _editingAttribute == BoneAttribute.Scale);
ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.NoReorder | ImGuiTableColumnFlags.WidthStretch);
ImGui.TableHeadersRow();
IEnumerable<EditRowParams> relevantModelBones = null!;
if (_editorManager.IsEditorActive && _editorManager.EditorProfile != null && _editorManager.EditorProfile.Armatures.Count > 0)
relevantModelBones = _isShowLiveBones && _editorManager.EditorProfile.Armatures.Count > 0
? _editorManager.EditorProfile.Armatures[0].GetAllBones().DistinctBy(x => x.BoneName).Select(x => new EditRowParams(x))
: _editorManager.EditorProfile.Armatures[0].BoneTemplateBinding.Where(x => x.Value.Bones.ContainsKey(x.Key))
.Select(x => new EditRowParams(x.Key, x.Value.Bones[x.Key])); //todo: this is awful
else
relevantModelBones = _templateFileSystemSelector.Selected!.Bones.Select(x => new EditRowParams(x.Key, x.Value));
var groupedBones = relevantModelBones.GroupBy(x => BoneData.GetBoneFamily(x.BoneCodeName));
foreach (var boneGroup in groupedBones.OrderBy(x => (int)x.Key))
{
//Hide root bone if it's not enabled in settings or if we are in rotation mode
if (boneGroup.Key == BoneData.BoneFamily.Root &&
(!_configuration.EditorConfiguration.RootPositionEditingEnabled ||
_editingAttribute == BoneAttribute.Rotation))
continue;
//create a dropdown entry for the family if one doesn't already exist
//mind that it'll only be rendered if bones exist to fill it
if (!_groupExpandedState.TryGetValue(boneGroup.Key, out var expanded))
{
_groupExpandedState[boneGroup.Key] = false;
expanded = false;
}
if (expanded)
{
//paint the row in header colors if it's expanded
ImGui.TableNextRow(ImGuiTableRowFlags.Headers);
}
else
{
ImGui.TableNextRow();
}
using var id = ImRaii.PushId(boneGroup.Key.ToString());
ImGui.TableNextColumn();
CtrlHelper.ArrowToggle($"##{boneGroup.Key}", ref expanded);
ImGui.SameLine();
CtrlHelper.StaticLabel(boneGroup.Key.ToString());
if (BoneData.DisplayableFamilies.TryGetValue(boneGroup.Key, out var tip) && tip != null)
CtrlHelper.AddHoverText(tip);
if (expanded)
{
ImGui.TableNextRow();
foreach (var erp in boneGroup.OrderBy(x => BoneData.GetBoneRanking(x.BoneCodeName)))
{
CompleteBoneEditor(erp);
}
}
_groupExpandedState[boneGroup.Key] = expanded;
}
}
}
}
private void DrawEditorConfirmationPopup()
{
if (_openSavePopup)
{
ImGui.OpenPopup("SavePopup");
_openSavePopup = false;
}
var viewportSize = ImGui.GetWindowViewport().Size;
ImGui.SetNextWindowSize(new Vector2(viewportSize.X / 4, viewportSize.Y / 12));
ImGui.SetNextWindowPos(viewportSize / 2, ImGuiCond.Always, new Vector2(0.5f));
using var popup = ImRaii.Popup("SavePopup", ImGuiWindowFlags.Modal);
if (!popup)
return;
ImGui.SetCursorPos(new Vector2(ImGui.GetWindowWidth() / 4 - 40, ImGui.GetWindowHeight() / 4));
ImGuiUtil.TextWrapped("You have unsaved changes in current template, what would you like to do?");
var buttonWidth = new Vector2(150 * ImGuiHelpers.GlobalScale, 0);
var yPos = ImGui.GetWindowHeight() - 2 * ImGui.GetFrameHeight();
var xPos = (ImGui.GetWindowWidth() - ImGui.GetStyle().ItemSpacing.X) / 4 - buttonWidth.X;
ImGui.SetCursorPos(new Vector2(xPos, yPos));
if (ImGui.Button("Save", buttonWidth))
{
_editorManager.SaveChanges();
_editorManager.DisableEditor();
ImGui.CloseCurrentPopup();
}
ImGui.SameLine();
if (ImGui.Button("Save as a copy", buttonWidth))
{
_editorManager.SaveChanges(true);
_editorManager.DisableEditor();
ImGui.CloseCurrentPopup();
}
ImGui.SameLine();
if (ImGui.Button("Do not save", buttonWidth))
{
_editorManager.DisableEditor();
ImGui.CloseCurrentPopup();
}
ImGui.SameLine();
if (ImGui.Button("Keep editing", buttonWidth))
{
ImGui.CloseCurrentPopup();
}
}
#region ImGui helper functions
public bool ResetBoneButton(string codename)
{
var output = ImGuiComponents.IconButton(codename, FontAwesomeIcon.Recycle);
CtrlHelper.AddHoverText(
$"Reset '{BoneData.GetBoneDisplayName(codename)}' to default {_editingAttribute} values");
if (output)
_editorManager.ResetBoneAttributeChanges(codename, _editingAttribute);
return output;
}
private bool RevertBoneButton(string codename)
{
var output = ImGuiComponents.IconButton(codename, FontAwesomeIcon.ArrowCircleLeft);
CtrlHelper.AddHoverText(
$"Revert '{BoneData.GetBoneDisplayName(codename)}' to last saved {_editingAttribute} values");
if (output)
_editorManager.RevertBoneAttributeChanges(codename, _editingAttribute);
return output;
}
private bool FullBoneSlider(string label, ref Vector3 value)
{
var velocity = _editingAttribute == BoneAttribute.Rotation ? 0.1f : 0.001f;
var minValue = _editingAttribute == BoneAttribute.Rotation ? -360.0f : -10.0f;
var maxValue = _editingAttribute == BoneAttribute.Rotation ? 360.0f : 10.0f;
var temp = _editingAttribute switch
{
BoneAttribute.Position => 0.0f,
BoneAttribute.Rotation => 0.0f,
_ => value.X == value.Y && value.Y == value.Z ? value.X : 1.0f
};
ImGui.PushItemWidth(ImGui.GetColumnWidth());
if (ImGui.DragFloat(label, ref temp, velocity, minValue, maxValue, $"%.{_precision}f"))
{
value = new Vector3(temp, temp, temp);
return true;
}
return false;
}
private bool SingleValueSlider(string label, ref float value)
{
var velocity = _editingAttribute == BoneAttribute.Rotation ? 0.1f : 0.001f;
var minValue = _editingAttribute == BoneAttribute.Rotation ? -360.0f : -10.0f;
var maxValue = _editingAttribute == BoneAttribute.Rotation ? 360.0f : 10.0f;
ImGui.PushItemWidth(ImGui.GetColumnWidth());
var temp = value;
if (ImGui.DragFloat(label, ref temp, velocity, minValue, maxValue, $"%.{_precision}f"))
{
value = temp;
return true;
}
return false;
}
private void CompleteBoneEditor(EditRowParams bone)
{
var codename = bone.BoneCodeName;
var displayName = bone.BoneDisplayName;
var transform = new BoneTransform(bone.Transform);
var flagUpdate = false;
var newVector = _editingAttribute switch
{
BoneAttribute.Position => transform.Translation,
BoneAttribute.Rotation => transform.Rotation,
_ => transform.Scaling
};
using var id = ImRaii.PushId(codename);
ImGui.TableNextColumn();
using (var disabled = ImRaii.Disabled(!_isUnlocked))
{
//----------------------------------
ImGui.Dummy(new Vector2(CtrlHelper.IconButtonWidth * 0.75f, 0));
ImGui.SameLine();
ResetBoneButton(codename);
ImGui.SameLine();
RevertBoneButton(codename);
//----------------------------------
ImGui.TableNextColumn();
flagUpdate |= SingleValueSlider($"##{displayName}-X", ref newVector.X);
//----------------------------------
ImGui.TableNextColumn();
flagUpdate |= SingleValueSlider($"##{displayName}-Y", ref newVector.Y);
//-----------------------------------
ImGui.TableNextColumn();
flagUpdate |= SingleValueSlider($"##{displayName}-Z", ref newVector.Z);
//----------------------------------
if (_editingAttribute != BoneAttribute.Scale)
ImGui.BeginDisabled();
ImGui.TableNextColumn();
var tempVec = new Vector3(newVector.X, newVector.Y, newVector.Z);
flagUpdate |= FullBoneSlider($"##{displayName}-All", ref newVector);
if (_editingAttribute != BoneAttribute.Scale)
ImGui.EndDisabled();
}
//----------------------------------
ImGui.TableNextColumn();
CtrlHelper.StaticLabel(displayName, CtrlHelper.TextAlignment.Left, BoneData.IsIVCSBone(codename) ? $"(IVCS) {codename}" : codename);
if (flagUpdate)
{
transform.UpdateAttribute(_editingAttribute, newVector);
_editorManager.ModifyBoneTransform(codename, transform);
if (_isMirrorModeEnabled && bone.Basis?.TwinBone != null) //todo: put it inside manager
_editorManager.ModifyBoneTransform(bone.Basis.TwinBone.BoneName,
BoneData.IsIVCSBone(codename) ? transform.GetSpecialReflection() : transform.GetStandardReflection());
}
ImGui.TableNextRow();
}
#endregion
}
/// <summary>
/// Simple structure for representing arguments to the editor table.
/// Can be constructed with or without access to a live armature.
/// </summary>
internal struct EditRowParams
{
public string BoneCodeName;
public string BoneDisplayName => BoneData.GetBoneDisplayName(BoneCodeName);
public BoneTransform Transform;
public ModelBone? Basis = null;
public EditRowParams(ModelBone mb)
{
BoneCodeName = mb.BoneName;
Transform = mb.CustomizedTransform ?? new BoneTransform();
Basis = mb;
}
public EditRowParams(string codename, BoneTransform tr)
{
BoneCodeName = codename;
Transform = tr;
Basis = null;
}
}

View File

@@ -0,0 +1,408 @@
using Dalamud.Interface;
using Dalamud.Plugin.Services;
using ImGuiNET;
using OtterGui;
using OtterGui.Classes;
using OtterGui.Filesystem;
using OtterGui.FileSystem.Selector;
using OtterGui.Log;
using OtterGui.Raii;
using System;
using System.Numerics;
using static CustomizePlus.UI.Windows.MainWindow.Tabs.Templates.TemplateFileSystemSelector;
using Newtonsoft.Json;
using System.Windows.Forms;
using System.Linq;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.ImGuiFileDialog;
using System.IO;
using System.Reflection;
using CustomizePlus.Templates;
using CustomizePlus.Configuration.Data;
using CustomizePlus.Profiles;
using CustomizePlus.Core.Helpers;
using CustomizePlus.Anamnesis;
using CustomizePlus.Profiles.Data;
using CustomizePlus.Templates.Events;
using CustomizePlus.Profiles.Events;
using CustomizePlus.Templates.Data;
namespace CustomizePlus.UI.Windows.MainWindow.Tabs.Templates;
public class TemplateFileSystemSelector : FileSystemSelector<Template, TemplateState>
{
private readonly PluginConfiguration _configuration;
private readonly TemplateEditorManager _editorManager;
private readonly TemplateManager _templateManager;
private readonly TemplateChanged _templateChangedEvent;
private readonly ProfileChanged _profileChangedEvent;
private readonly ProfileManager _profileManager;
private readonly MessageService _messageService;
private readonly PoseFileBoneLoader _poseFileBoneLoader;
private readonly Logger _logger;
private readonly PopupSystem _popupSystem;
private readonly FileDialogManager _importFilePicker = new();
private string? _clipboardText;
private Template? _cloneTemplate;
private string _newName = string.Empty;
public bool IncognitoMode
{
get => _configuration.UISettings.IncognitoMode;
set
{
_configuration.UISettings.IncognitoMode = value;
_configuration.Save();
}
}
public struct TemplateState
{
public ColorId Color;
}
public TemplateFileSystemSelector(
TemplateFileSystem fileSystem,
IKeyState keyState,
Logger logger,
PluginConfiguration configuration,
TemplateEditorManager editorManager,
TemplateManager templateManager,
TemplateChanged templateChangedEvent,
ProfileChanged profileChangedEvent,
ProfileManager profileManager,
MessageService messageService,
PoseFileBoneLoader poseFileBoneLoader,
PopupSystem popupSystem)
: base(fileSystem, keyState, logger, allowMultipleSelection: true)
{
_configuration = configuration;
_editorManager = editorManager;
_templateManager = templateManager;
_templateChangedEvent = templateChangedEvent;
_profileChangedEvent = profileChangedEvent;
_profileManager = profileManager;
_messageService = messageService;
_poseFileBoneLoader = poseFileBoneLoader;
_logger = logger;
_popupSystem = popupSystem;
_popupSystem.RegisterPopup("template_editor_active_warn", "You need to stop bone editing before doing this action"/*, false, new Vector2(5, 12)*/);
_templateChangedEvent.Subscribe(OnTemplateChange, TemplateChanged.Priority.TemplateFileSystemSelector);
_profileChangedEvent.Subscribe(OnProfileChange, ProfileChanged.Priority.TemplateFileSystemSelector);
AddButton(NewButton, 0);
AddButton(AnamnesisImportButton, 10);
AddButton(ClipboardImportButton, 20);
AddButton(CloneButton, 30);
AddButton(DeleteButton, 1000);
SetFilterTooltip();
}
public void Dispose()
{
base.Dispose();
_templateChangedEvent.Unsubscribe(OnTemplateChange);
_profileChangedEvent.Unsubscribe(OnProfileChange);
}
protected override uint ExpandedFolderColor
=> ColorId.FolderExpanded.Value();
protected override uint CollapsedFolderColor
=> ColorId.FolderCollapsed.Value();
protected override uint FolderLineColor
=> ColorId.FolderLine.Value();
protected override bool FoldersDefaultOpen
=> _configuration.UISettings.FoldersDefaultOpen;
protected override void DrawLeafName(FileSystem<Template>.Leaf leaf, in TemplateState state, bool selected)
{
var flag = selected ? ImGuiTreeNodeFlags.Selected | LeafFlags : LeafFlags;
var name = IncognitoMode ? leaf.Value.Incognito : leaf.Value.Name.Text;
using var color = ImRaii.PushColor(ImGuiCol.Text, state.Color.Value());
using var _ = ImRaii.TreeNode(name, flag);
}
protected override void Select(FileSystem<Template>.Leaf? leaf, bool clear, in TemplateState storage = default)
{
if (_editorManager.IsEditorActive)
{
Plugin.Logger.Debug("Blocked edited item change");
ShowEditorWarningPopup();
return;
}
base.Select(leaf, clear, storage);
}
protected override void DrawPopups()
{
_importFilePicker.Draw();
//DrawEditorWarningPopup();
DrawNewTemplatePopup();
}
private void ShowEditorWarningPopup()
{
_popupSystem.ShowPopup("template_editor_active_warn");
}
private void DrawNewTemplatePopup()
{
if (!ImGuiUtil.OpenNameField("##NewTemplate", ref _newName))
return;
if (_clipboardText != null)
{
var importVer = Base64Helper.ImportFromBase64(Clipboard.GetText(), out var json);
var template = Convert.ToInt32(importVer) switch
{
//0 => ProfileConverter.ConvertFromConfigV0(json),
//2 => ProfileConverter.ConvertFromConfigV2(json),
//3 =>
4 => JsonConvert.DeserializeObject<Template>(json),
_ => null
};
if (template is Template tpl)
_templateManager.Clone(tpl, _newName, true);
else
//Messager.NotificationMessage("Could not create a template, clipboard did not contain valid template data.", NotificationType.Error, false);
throw new Exception("Invalid template"); //todo: temporary
_clipboardText = null;
}
else if (_cloneTemplate != null)
{
_templateManager.Clone(_cloneTemplate, _newName, true);
_cloneTemplate = null;
}
else
{
_templateManager.Create(_newName, true);
}
_newName = string.Empty;
}
private void OnTemplateChange(TemplateChanged.Type type, Template? nullable, object? arg3 = null)
{
switch (type)
{
case TemplateChanged.Type.Created:
case TemplateChanged.Type.Deleted:
case TemplateChanged.Type.Renamed:
case TemplateChanged.Type.ReloadedAll:
SetFilterDirty();
break;
}
}
private void OnProfileChange(ProfileChanged.Type type, Profile? profile, object? arg3 = null)
{
switch (type)
{
case ProfileChanged.Type.Created:
case ProfileChanged.Type.Deleted:
case ProfileChanged.Type.AddedTemplate:
case ProfileChanged.Type.ChangedTemplate:
case ProfileChanged.Type.RemovedTemplate:
case ProfileChanged.Type.ReloadedAll:
SetFilterDirty();
break;
}
}
private void NewButton(Vector2 size)
{
if (!ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Plus.ToIconString(), size, "Create a new template with default configuration.", false,
true))
return;
if (_editorManager.IsEditorActive)
{
ShowEditorWarningPopup();
return;
}
ImGui.OpenPopup("##NewTemplate");
}
private void ClipboardImportButton(Vector2 size)
{
if (!ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Clipboard.ToIconString(), size, "Try to import a template from your clipboard.", false,
true))
return;
if (_editorManager.IsEditorActive)
{
ShowEditorWarningPopup();
return;
}
try
{
_clipboardText = ImGui.GetClipboardText();
ImGui.OpenPopup("##NewTemplate");
}
catch
{
_messageService.NotificationMessage("Could not import data from clipboard.", NotificationType.Error, false);
}
}
private void AnamnesisImportButton(Vector2 size)
{
if (!ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.FileImport.ToIconString(), size, "Import a template from anamnesis pose file (scaling only)", false,
true))
return;
if (_editorManager.IsEditorActive)
{
ShowEditorWarningPopup();
return;
}
_importFilePicker.OpenFileDialog("Import Pose File", ".pose", (isSuccess, path) =>
{
if (isSuccess)
{
var selectedFilePath = path.FirstOrDefault();
//todo: check for selectedFilePath == null?
var bones = _poseFileBoneLoader.LoadBoneTransformsFromFile(selectedFilePath);
if (bones != null)
{
if (bones.Count == 0)
{
_messageService.NotificationMessage("Selected anamnesis pose file doesn't contain any scaled bones", NotificationType.Error);
return;
}
_templateManager.Create(Path.GetFileNameWithoutExtension(selectedFilePath), bones, false);
}
else
{
_messageService.NotificationMessage(
$"Error parsing anamnesis pose file at '{path}'", NotificationType.Error);
}
}
else
{
_logger.Debug(isSuccess + " NO valid file has been selected. " + path);
}
}, 1, null, true);
/*MessageDialog.Show(
"Due to technical limitations, Customize+ is only able to import scale values from *.pose files.\nPosition and rotation information will be ignored.",
new Vector2(570, 100), ImportAction, "ana_import_pos_rot_warning");*/
//todo: message dialog?
}
private void CloneButton(Vector2 size)
{
var tt = SelectedLeaf == null
? "No template selected."
: "Clone the currently selected template to a duplicate";
if (!ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Clone.ToIconString(), size, tt, SelectedLeaf == null, true))
return;
if (_editorManager.IsEditorActive)
{
ShowEditorWarningPopup();
return;
}
_cloneTemplate = Selected!;
ImGui.OpenPopup("##NewTemplate");
}
private void DeleteButton(Vector2 size)
=> DeleteSelectionButton(size, _configuration.UISettings.DeleteTemplateModifier, "template", "templates", (template) =>
{
if (_editorManager.IsEditorActive)
{
ShowEditorWarningPopup();
return;
}
_templateManager.Delete(template);
});
#region Filters
private const StringComparison IgnoreCase = StringComparison.OrdinalIgnoreCase;
private LowerString _filter = LowerString.Empty;
private int _filterType = -1;
private void SetFilterTooltip()
{
FilterTooltip = "Filter templates for those where their full paths or names contain the given substring.\n"
+ "Enter n:[string] to filter only for template names and no paths.";
}
/// <summary> Appropriately identify and set the string filter and its type. </summary>
protected override bool ChangeFilter(string filterValue)
{
(_filter, _filterType) = filterValue.Length switch
{
0 => (LowerString.Empty, -1),
> 1 when filterValue[1] == ':' =>
filterValue[0] switch
{
'n' => filterValue.Length == 2 ? (LowerString.Empty, -1) : (new LowerString(filterValue[2..]), 1),
'N' => filterValue.Length == 2 ? (LowerString.Empty, -1) : (new LowerString(filterValue[2..]), 1),
_ => (new LowerString(filterValue), 0),
},
_ => (new LowerString(filterValue), 0),
};
return true;
}
/// <summary>
/// The overwritten filter method also computes the state.
/// Folders have default state and are filtered out on the direct string instead of the other options.
/// If any filter is set, they should be hidden by default unless their children are visible,
/// or they contain the path search string.
/// </summary>
protected override bool ApplyFiltersAndState(FileSystem<Template>.IPath path, out TemplateState state)
{
if (path is TemplateFileSystem.Folder f)
{
state = default;
return FilterValue.Length > 0 && !f.FullName().Contains(FilterValue, IgnoreCase);
}
return ApplyFiltersAndState((TemplateFileSystem.Leaf)path, out state);
}
/// <summary> Apply the string filters. </summary>
private bool ApplyStringFilters(TemplateFileSystem.Leaf leaf, Template template)
{
return _filterType switch
{
-1 => false,
0 => !(_filter.IsContained(leaf.FullName()) || template.Name.Contains(_filter)),
1 => !template.Name.Contains(_filter),
_ => false, // Should never happen
};
}
/// <summary> Combined wrapper for handling all filters and setting state. </summary>
private bool ApplyFiltersAndState(TemplateFileSystem.Leaf leaf, out TemplateState state)
{
//todo: more efficient to store links to profiles in templates than iterating here
state.Color = _profileManager.GetProfilesUsingTemplate(leaf.Value).Any() ? ColorId.UsedTemplate : ColorId.UnusedTemplate;
return ApplyStringFilters(leaf, leaf.Value);
}
#endregion
}

View File

@@ -0,0 +1,229 @@
using Dalamud.Interface;
using Dalamud.Interface.Internal.Notifications;
using ImGuiNET;
using OtterGui;
using OtterGui.Classes;
using OtterGui.Raii;
using System;
using System.Linq;
using System.Numerics;
using System.Windows.Forms;
using CustomizePlus.Core.Data;
using CustomizePlus.Game.Services;
using CustomizePlus.Templates;
using CustomizePlus.Configuration.Data;
using CustomizePlus.Core.Helpers;
using CustomizePlus.Templates.Data;
namespace CustomizePlus.UI.Windows.MainWindow.Tabs.Templates;
public class TemplatePanel
{
private readonly TemplateFileSystemSelector _selector;
private readonly TemplateManager _manager;
private readonly GameStateService _gameStateService;
private readonly BoneEditorPanel _boneEditor;
private readonly PluginConfiguration _configuration;
private readonly MessageService _messageService;
private string? _newName;
private Template? _changedTemplate;
private string SelectionName
=> _selector.Selected == null ? "No Selection" : _selector.IncognitoMode ? _selector.Selected.Incognito : _selector.Selected.Name.Text;
public TemplatePanel(
TemplateFileSystemSelector selector,
TemplateManager manager,
GameStateService gameStateService,
BoneEditorPanel boneEditor,
PluginConfiguration configuration,
MessageService messageService)
{
_selector = selector;
_manager = manager;
_gameStateService = gameStateService;
_boneEditor = boneEditor;
_configuration = configuration;
_messageService = messageService;
}
public void Draw()
{
using var group = ImRaii.Group();
if (_selector.SelectedPaths.Count > 1)
{
DrawMultiSelection();
}
else
{
DrawHeader();
DrawPanel();
}
}
private HeaderDrawer.Button LockButton()
=> _selector.Selected == null
? HeaderDrawer.Button.Invisible
: _selector.Selected.IsWriteProtected
? new HeaderDrawer.Button
{
Description = "Make this template editable.",
Icon = FontAwesomeIcon.Lock,
OnClick = () => _manager.SetWriteProtection(_selector.Selected!, false),
Disabled = _boneEditor.IsEditorActive
}
: new HeaderDrawer.Button
{
Description = "Write-protect this template.",
Icon = FontAwesomeIcon.LockOpen,
OnClick = () => _manager.SetWriteProtection(_selector.Selected!, true),
Disabled = _boneEditor.IsEditorActive
};
/*private HeaderDrawer.Button SetFromClipboardButton()
=> new()
{
Description =
"Try to apply a template from your clipboard over this template.",
Icon = FontAwesomeIcon.Clipboard,
OnClick = SetFromClipboard,
Visible = _selector.Selected != null,
Disabled = (_selector.Selected?.IsWriteProtected ?? true) || _boneEditor.IsEditorActive,
};*/
private HeaderDrawer.Button ExportToClipboardButton()
=> new()
{
Description = "Copy the current template to your clipboard.",
Icon = FontAwesomeIcon.Copy,
OnClick = ExportToClipboard,
Visible = _selector.Selected != null,
Disabled = _boneEditor.IsEditorActive
};
private void DrawHeader()
=> HeaderDrawer.Draw(SelectionName, 0, ImGui.GetColorU32(ImGuiCol.FrameBg),
1, /*SetFromClipboardButton(),*/ ExportToClipboardButton(), LockButton(),
HeaderDrawer.Button.IncognitoButton(_selector.IncognitoMode, v => _selector.IncognitoMode = v));
private void DrawMultiSelection()
{
if (_selector.SelectedPaths.Count == 0)
return;
var sizeType = ImGui.GetFrameHeight();
var availableSizePercent = (ImGui.GetContentRegionAvail().X - sizeType - 4 * ImGui.GetStyle().CellPadding.X) / 100;
var sizeMods = availableSizePercent * 35;
var sizeFolders = availableSizePercent * 65;
ImGui.NewLine();
ImGui.TextUnformatted("Currently Selected Templates");
ImGui.Separator();
using var table = ImRaii.Table("templates", 3, ImGuiTableFlags.RowBg);
ImGui.TableSetupColumn("btn", ImGuiTableColumnFlags.WidthFixed, sizeType);
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthFixed, sizeMods);
ImGui.TableSetupColumn("path", ImGuiTableColumnFlags.WidthFixed, sizeFolders);
var i = 0;
foreach (var (fullName, path) in _selector.SelectedPaths.Select(p => (p.FullName(), p))
.OrderBy(p => p.Item1, StringComparer.OrdinalIgnoreCase))
{
using var id = ImRaii.PushId(i++);
ImGui.TableNextColumn();
var icon = (path is TemplateFileSystem.Leaf ? FontAwesomeIcon.FileCircleMinus : FontAwesomeIcon.FolderMinus).ToIconString();
if (ImGuiUtil.DrawDisabledButton(icon, new Vector2(sizeType), "Remove from selection.", false, true))
_selector.RemovePathFromMultiSelection(path);
ImGui.TableNextColumn();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(path is TemplateFileSystem.Leaf l ? _selector.IncognitoMode ? l.Value.Incognito : l.Value.Name.Text : string.Empty);
ImGui.TableNextColumn();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(_selector.IncognitoMode ? "Incognito is active" : fullName);
}
}
private void DrawPanel()
{
using var child = ImRaii.Child("##Panel", -Vector2.One, true);
if (!child || _selector.Selected == null)
return;
using (var disabled = ImRaii.Disabled(_selector.Selected?.IsWriteProtected ?? true))
{
DrawBasicSettings();
DrawEditorToggle();
}
_boneEditor.Draw();
}
private void DrawEditorToggle()
{
if (ImGuiUtil.DrawDisabledButton($"{(_boneEditor.IsEditorActive ? "Finish" : "Start")} bone editing", Vector2.Zero,
"Toggle the bone editor for this template",
(_selector.Selected?.IsWriteProtected ?? true) || _gameStateService.GameInPosingMode() || !_configuration.PluginEnabled))
{
if (!_boneEditor.IsEditorActive)
_boneEditor.EnableEditor(_selector.Selected!);
else
_boneEditor.DisableEditor();
}
}
private void DrawBasicSettings()
{
using (var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)))
{
using (var table = ImRaii.Table("BasicSettings", 2))
{
ImGui.TableSetupColumn("BasicCol1", ImGuiTableColumnFlags.WidthFixed, ImGui.CalcTextSize("lorem ipsum dolor").X);
ImGui.TableSetupColumn("BasicCol2", ImGuiTableColumnFlags.WidthStretch);
ImGuiUtil.DrawFrameColumn("Template Name");
ImGui.TableNextColumn();
var width = new Vector2(ImGui.GetContentRegionAvail().X, 0);
var name = _newName ?? _selector.Selected!.Name;
ImGui.SetNextItemWidth(width.X);
if (!_selector.IncognitoMode)
{
if (ImGui.InputText("##Name", ref name, 128))
{
_newName = name;
_changedTemplate = _selector.Selected;
}
if (ImGui.IsItemDeactivatedAfterEdit() && _changedTemplate != null)
{
_manager.Rename(_changedTemplate, name);
_newName = null;
_changedTemplate = null;
}
}
else
ImGui.TextUnformatted(_selector.Selected!.Incognito);
}
}
}
/*private void SetFromClipboard()
{
}*/
private void ExportToClipboard()
{
try
{
Clipboard.SetText(Base64Helper.ExportToBase64(_selector.Selected!, Constants.ConfigurationVersion));
}
catch (Exception ex)
{
_messageService.NotificationMessage(ex, $"Could not copy {_selector.Selected!.Name} data to clipboard.",
$"Could not copy data from template {_selector.Selected!.UniqueId} to clipboard", NotificationType.Error, false);
}
}
}

View File

@@ -0,0 +1,23 @@
using Dalamud.Interface.Utility;
using ImGuiNET;
namespace CustomizePlus.UI.Windows.MainWindow.Tabs.Templates;
public class TemplatesTab
{
private readonly TemplateFileSystemSelector _selector;
private readonly TemplatePanel _panel;
public TemplatesTab(TemplateFileSystemSelector selector, TemplatePanel panel)
{
_selector = selector;
_panel = panel;
}
public void Draw()
{
_selector.Draw(200f * ImGuiHelpers.GlobalScale);
ImGui.SameLine();
_panel.Draw();
}
}

View File

@@ -0,0 +1,127 @@
using Dalamud.Interface.Utility;
using ImGuiNET;
using OtterGui;
using OtterGui.Log;
using OtterGui.Raii;
using System;
using System.Collections.Generic;
using System.Numerics;
using CustomizePlus.Configuration.Data;
namespace CustomizePlus.UI.Windows;
public class PopupSystem
{
private readonly Logger _logger;
private readonly PluginConfiguration _configuration;
private readonly Dictionary<string, PopupData> _popups = new();
private readonly List<PopupData> _displayedPopups = new();
public PopupSystem(Logger logger, PluginConfiguration configuration)
{
_logger = logger;
_configuration = configuration;
}
public void RegisterPopup(string name, string text, bool displayOnce = false, Vector2? sizeDividers = null)
{
name = name.ToLowerInvariant();
if (_popups.ContainsKey(name))
throw new Exception($"Popup \"{name}\" is already registered");
_popups[name] = new PopupData { Name = name, Text = text, DisplayOnce = displayOnce, SizeDividers = sizeDividers };
_logger.Debug($"Popup \"{name}\" registered");
}
public void ShowPopup(string name)
{
name = name.ToLowerInvariant();
if (!_popups.ContainsKey(name))
throw new Exception($"Popup \"{name}\" is not registered");
var popup = _popups[name];
if (popup.DisplayRequested || _configuration.UISettings.ViewedMessageWindows.Contains(name))
return;
popup.DisplayRequested = true;
//_logger.Debug($"Popup \"{name}\" set as requested to be displayed");
}
public void Draw()
{
var viewportSize = ImGui.GetWindowViewport().Size;
foreach (var popup in _popups.Values)
{
if (popup.DisplayRequested)
_displayedPopups.Add(popup);
}
if (_displayedPopups.Count == 0)
return;
for (var i = 0; i < _displayedPopups.Count; ++i)
{
var popup = _displayedPopups[i];
if (popup.DisplayRequested)
{
ImGui.OpenPopup(popup.Name);
popup.DisplayRequested = false;
}
var xDiv = popup.SizeDividers?.X ?? 5;
var yDiv = popup.SizeDividers?.Y ?? 12;
ImGui.SetNextWindowSize(new Vector2(viewportSize.X / xDiv, viewportSize.Y / yDiv));
ImGui.SetNextWindowPos(viewportSize / 2, ImGuiCond.Always, new Vector2(0.5f));
using var popupWindow = ImRaii.Popup(popup.Name, ImGuiWindowFlags.Modal);
if (!popupWindow)
{
//fixes bug with windows being closed after going into gpose
ImGui.OpenPopup(popup.Name);
continue;
}
ImGui.SetCursorPos(new Vector2(10, ImGui.GetWindowHeight() / 4));
ImGuiUtil.TextWrapped(popup.Text);
var buttonWidth = new Vector2(150 * ImGuiHelpers.GlobalScale, 0);
var yPos = ImGui.GetWindowHeight() - 2 * ImGui.GetFrameHeight();
var xPos = (ImGui.GetWindowWidth() - ImGui.GetStyle().ItemSpacing.X - buttonWidth.X) / 2;
ImGui.SetCursorPos(new Vector2(xPos, yPos));
if (ImGui.Button("Ok", buttonWidth))
{
ImGui.CloseCurrentPopup();
_displayedPopups.RemoveAt(i--);
if (popup.DisplayOnce)
{
_configuration.UISettings.ViewedMessageWindows.Add(popup.Name);
_configuration.Save();
}
}
}
}
private class PopupData
{
public string Name { get; set; }
public string Text { get; set; }
public bool DisplayRequested { get; set; }
public bool DisplayOnce { get; set; }
/// <summary>
/// Divider values used to divide viewport size when setting window size
/// </summary>
public Vector2? SizeDividers { get; set; }
}
}

1
submodules/OtterGui Submodule

Submodule submodules/OtterGui added at df754445aa